diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..71ac551 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/kdiff3"] + path = third_party/kdiff3 + url = https://invent.kde.org/sdk/kdiff3.git diff --git a/README.md b/README.md index 3ac3239..7b51cb8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ templates/ ### Инструменты +Исходники внешних приложений находятся в [`third_party`](third_party/README.md): +[`third_party/kdiff3`](third_party/kdiff3) содержит официальный репозиторий KDiff3 +как вложенный Git submodule для развития сравнения файлов и каталогов в SETGUI. + | Инструмент | Что делает | |---|---| | [`tools/firmware-build/fpga`](tools/firmware-build/fpga/README.md) | общий итератор версии и HDL-паспорт для Xilinx ISE, Vivado и Altera/Intel Quartus; контроль `.bit`/`.sof`, manifest, SHA-256 и архив | diff --git a/c/candle/CMakeLists.txt b/c/candle/CMakeLists.txt index 1b11f4a..0332f39 100644 --- a/c/candle/CMakeLists.txt +++ b/c/candle/CMakeLists.txt @@ -9,5 +9,5 @@ if(NOT WIN32) endif() add_library(candle SHARED candle.c candle_ctrl_req.c candle.def) target_compile_definitions(candle PRIVATE UNICODE _UNICODE) -target_link_libraries(candle PRIVATE setupapi winusb ole32 advapi32) +target_link_libraries(candle PRIVATE setupapi winusb ole32 advapi32 cfgmgr32) # Configuration Manager сопоставляет интерфейсы CAN и DFU по родительскому USB-узлу. target_include_directories(candle PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/c/candle/candle.c b/c/candle/candle.c index afba292..40ce0ff 100644 --- a/c/candle/candle.c +++ b/c/candle/candle.c @@ -34,6 +34,86 @@ #include "candle_defs.h" #include "candle_ctrl_req.h" #include "ch_9.h" +#include /* Сопоставляет интерфейсы CAN и DFU по общему родительскому USB-устройству Windows. */ + +static DWORD candle_dfu_parent(const wchar_t *path, DEVINST *parent) /* Возвращает родителя интерфейса, чтобы команда не попала в другой подключённый адаптер. */ +{ /* Начало поиска физического USB-устройства по пути выбранного CAN-интерфейса. */ + HDEVINFO devices = SetupDiCreateDeviceInfoList(NULL, NULL); /* Создаёт временный набор SetupAPI без перечисления посторонних устройств. */ + if (devices == INVALID_HANDLE_VALUE) return GetLastError(); /* Сохраняет причину отказа создания набора Windows. */ + SP_DEVICE_INTERFACE_DATA iface = {0}; /* Подготавливает описание интерфейса для открытия по точному пути. */ + iface.cbSize = sizeof(iface); /* Передаёт SetupAPI размер структуры текущей разрядности процесса. */ + SP_DEVINFO_DATA info = {0}; /* Получает идентификатор узла PnP выбранного интерфейса. */ + info.cbSize = sizeof(info); /* Указывает размер структуры устройства согласно контракту SetupAPI. */ + DWORD size = 0, error = ERROR_SUCCESS; /* Хранит требуемое число байтов и итоговый код Windows. */ + if (!SetupDiOpenDeviceInterfaceW(devices, path, 0, &iface)) error = GetLastError(); /* Открывает только заданный путь, сохраняя ошибку исчезнувшего устройства. */ + if (!error) SetupDiGetDeviceInterfaceDetailW(devices, &iface, NULL, 0, &size, NULL); /* Узнаёт объём буфера переменной длины для пути USB-интерфейса. */ + PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = size ? malloc(size) : NULL; /* Выделяет буфер только после успешного определения его размера. */ + if (!error && !detail) error = size ? ERROR_NOT_ENOUGH_MEMORY : GetLastError(); /* Отличает нехватку памяти от ошибки поиска описания. */ + if (detail) { /* Обрабатывает описание только при наличии выделенного буфера. */ + detail->cbSize = sizeof(*detail); /* Согласует размер заголовка с архитектурой Win32 или Win64. */ + if (!SetupDiGetDeviceInterfaceDetailW(devices, &iface, detail, size, NULL, &info)) error = GetLastError(); /* Получает узел интерфейса из SetupAPI. */ + else if (CM_Get_Parent(parent, info.DevInst, 0) != CR_SUCCESS) error = ERROR_NOT_FOUND; /* Получает общий родительский узел составного USB-адаптера. */ + free(detail); /* Освобождает описание после получения идентификатора родителя. */ + } /* Завершает работу с описанием интерфейса. */ + SetupDiDestroyDeviceInfoList(devices); /* Освобождает набор SetupAPI при любом результате поиска. */ + return error; /* Возвращает ноль при успехе либо причину отказа Windows. */ +} /* Завершает сопоставление интерфейса с физическим USB-устройством. */ + +uint32_t __stdcall candle_dev_enter_dfu(const wchar_t *can_path, uint8_t status[6]) /* Возвращает код Windows и шестибайтовый DFU_GETSTATUS согласно DFU 1.1. */ +{ /* Начало отдельного сеанса управляющего интерфейса обновления. */ + if (!can_path || !status) return ERROR_INVALID_PARAMETER; /* Запрещает отсутствие точного пути адаптера или выходного буфера статуса. */ + memset(status, 0, 6); /* Исключает использование неинициализированных полей ответа при ошибке USB. */ + DEVINST wanted_parent = 0; /* Запоминает физический адаптер, выбранный пользователем до остановки CAN. */ + DWORD error = candle_dfu_parent(can_path, &wanted_parent); /* Ищет родителя по сохранённому пути CAN, не заменяя его первым найденным устройством. */ + if (error) return error; /* Прерывает переход, если выбранный адаптер уже исчез из PnP. */ + GUID guid = {0xc25b4308, 0x04d3, 0x11e6, {0xb3, 0xea, 0x60, 0x57, 0x18, 0x9e, 0x64, 0x43}}; /* GUID интерфейса обновления из дескриптора CANable 2.5. */ + HDEVINFO devices = SetupDiGetClassDevsW(&guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); /* Перечисляет только подключённые интерфейсы обновления CANable 2.5. */ + if (devices == INVALID_HANDLE_VALUE) return GetLastError(); /* Возвращает отказ SetupAPI без попытки послать команду. */ + error = ERROR_NOT_FOUND; /* Отсутствие интерфейса обновления не считается успешным входом в DFU. */ + for (DWORD index = 0; ; ++index) { /* Проверяет интерфейсы до точного совпадения физического устройства. */ + SP_DEVICE_INTERFACE_DATA iface = {0}; /* Создаёт независимое описание очередного DFU-интерфейса. */ + iface.cbSize = sizeof(iface); /* Задаёт обязательный размер структуры SetupAPI. */ + if (!SetupDiEnumDeviceInterfaces(devices, NULL, &guid, index, &iface)) { /* Завершает обход либо сообщает ошибку перечисления. */ + if (GetLastError() != ERROR_NO_MORE_ITEMS) error = GetLastError(); /* Отличает штатный конец списка от отказа Windows. */ + break; /* Выходит из обхода после исчерпания интерфейсов или ошибки. */ + } /* Завершает проверку результата перечисления. */ + DWORD size = 0; /* Хранит размер описания очередного интерфейса в байтах. */ + SetupDiGetDeviceInterfaceDetailW(devices, &iface, NULL, 0, &size, NULL); /* Определяет буфер для полного пути интерфейса обновления. */ + if (!size) { error = GetLastError(); break; } /* Не продолжает работу с неполным результатом SetupAPI. */ + PSP_DEVICE_INTERFACE_DETAIL_DATA_W detail = malloc(size); /* Выделяет описание с достаточным местом для USB-пути. */ + if (!detail) { error = ERROR_NOT_ENOUGH_MEMORY; break; } /* Не допускает обращения к отсутствующему буферу. */ + detail->cbSize = sizeof(*detail); /* Сохраняет ABI описания интерфейса Windows. */ + if (!SetupDiGetDeviceInterfaceDetailW(devices, &iface, detail, size, NULL, NULL)) { /* Получает путь, который можно передать CreateFileW. */ + error = GetLastError(); free(detail); break; /* Сохраняет ошибку и освобождает буфер перед выходом. */ + } /* Завершает чтение пути интерфейса. */ + DEVINST parent = 0; /* Хранит родителя текущего кандидата на обновление. */ + if (candle_dfu_parent(detail->DevicePath, &parent) || parent != wanted_parent) { /* Исключает каждый интерфейс другого физического адаптера. */ + free(detail); continue; /* Освобождает описание неподходящего кандидата и продолжает поиск. */ + } /* Завершает проверку принадлежности выбранному USB-адаптеру. */ + HANDLE file = CreateFileW(detail->DevicePath, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, NULL); /* Эксклюзивно открывает интерфейс обновления выбранного устройства для WinUSB. */ + free(detail); /* Путь больше не нужен после открытия системного дескриптора. */ + if (file == INVALID_HANDLE_VALUE) { error = GetLastError(); break; } /* Не скрывает отсутствие доступа или занятость интерфейса. */ + WINUSB_INTERFACE_HANDLE usb = NULL; /* Хранит отдельный WinUSB-сеанс интерфейса обновления без CAN bulk endpoint. */ + error = ERROR_SUCCESS; /* Сбрасывает исходное отсутствие интерфейса после точного совпадения устройства. */ + if (!WinUsb_Initialize(file, &usb)) error = GetLastError(); /* Проверяет наличие рабочего WinUSB-драйвера DFU runtime. */ + USB_INTERFACE_DESCRIPTOR descriptor = {0}; /* Получает реальный номер и класс открытого интерфейса. */ + if (!error && !WinUsb_QueryInterfaceSettings(usb, 0, &descriptor)) error = GetLastError(); /* Не предполагает, что произвольный WinUSB-интерфейс поддерживает DFU. */ + if (!error && (descriptor.bInterfaceClass != 0xFE || descriptor.bInterfaceSubClass != 1 || descriptor.bInterfaceProtocol != 1)) error = ERROR_NOT_SUPPORTED; /* Проверяет класс приложения, подкласс DFU и runtime-протокол по DFU 1.1. */ + ULONG timeout = 500, transferred = 0; /* Ограничивает управляющий USB-запрос 500 миллисекундами и учитывает длину ответа. */ + if (!error && !WinUsb_SetPipePolicy(usb, 0, PIPE_TRANSFER_TIMEOUT, sizeof(timeout), &timeout)) error = GetLastError(); /* Устанавливает тайм-аут endpoint 0, чтобы отсутствующий ответ не блокировал программу. */ + WINUSB_SETUP_PACKET packet = {0}; /* Обнуляет поля USB SETUP перед запросом перехода в загрузчик. */ + packet.RequestType = 0x21; packet.Request = 0; packet.Value = 1000; packet.Index = descriptor.bInterfaceNumber; /* DFU_DETACH: class/interface OUT, запрос 0, ожидаемое время отключения 1000 мс. */ + if (!error && !WinUsb_ControlTransfer(usb, packet, NULL, 0, &transferred, NULL)) error = GetLastError(); /* Отправляет DETACH и требует подтверждённой USB-транзакции. */ + packet.RequestType = 0xA1; packet.Request = 3; packet.Value = 0; packet.Length = 6; /* DFU_GETSTATUS: class/interface IN, запрос 3, ответ ровно 6 байт по DFU 1.1. */ + if (!error && !WinUsb_ControlTransfer(usb, packet, status, 6, &transferred, NULL)) error = GetLastError(); /* Проверяет результат до отложенного перехода прошивки через 300 мс. */ + if (!error && transferred != 6) error = ERROR_INVALID_DATA; /* Не трактует укороченный ответ как подтверждение входа в загрузчик. */ + if (usb) WinUsb_Free(usb); /* Освобождает WinUSB до появления загрузчика с другим VID/PID. */ + CloseHandle(file); /* Освобождает файловый дескриптор после успеха и при любой ошибке USB. */ + break; /* Не отправляет DETACH повторно и не переключается на другой адаптер при отказе. */ + } /* Завершает поиск и обработку единственного выбранного адаптера. */ + SetupDiDestroyDeviceInfoList(devices); /* Освобождает перечень интерфейсов при любом результате. */ + return error; /* Статус прошивки возвращается отдельно от системной ошибки USB. */ +} /* Завершает управление DFU runtime без записи flash или изменения образа. */ static bool candle_dev_interal_open(candle_handle hdev); @@ -656,6 +736,30 @@ bool __stdcall DLL candle_dev_open(candle_handle hdev) } +bool __stdcall DLL candle_dev_get_product(candle_handle hdev, wchar_t *product, uint32_t capacity) /* Читает USB Product String открытого адаптера в буфер UTF-16; capacity измеряется в wchar_t, включая завершающий ноль. */ +{ /* Начало чтения отображаемого имени без изменения режима CAN. */ + if (!product || !capacity) return false; /* Не пишет в отсутствующий либо пустой выходной буфер. */ + product[0] = 0; /* Сохраняет пустую строку при любом отказе чтения дескриптора. */ + candle_device_t *dev = (candle_device_t*)hdev; /* Получает уже открытый WinUSB-сеанс выбранного физического адаптера. */ + if (!dev || !dev->winUSBHandle) return false; /* Не открывает устройство заново и не обращается к закрытому интерфейсу. */ + USB_DEVICE_DESCRIPTOR descriptor = {0}; /* Получает индекс строки продукта из стандартного дескриптора устройства. */ + ULONG received = 0; /* Хранит фактически полученное количество байтов управляющего ответа. */ + if (!WinUsb_GetDescriptor(dev->winUSBHandle, 1, 0, 0, (PUCHAR)&descriptor, sizeof(descriptor), &received) || received != sizeof(descriptor)) return false; /* Тип 1 обозначает Device Descriptor по USB 2.0; неполный ответ не используется. */ + UCHAR string_index = descriptor.iProduct; /* Для обычных адаптеров использует объявленный устройством индекс USB Product String. */ + if (descriptor.idVendor == 0x1D50 && descriptor.idProduct == 0x606F) string_index = 2; /* В CANable Candlelight имя продукта имеет индекс 2; WinUSB может подменять iProduct индексом имени интерфейса, как описано в штатном примере проекта. */ + if (!string_index) return false; /* Нулевой iProduct означает, что устройство не предоставляет имя продукта. */ + UCHAR raw[256] = {0}; /* Буфер вмещает строковый дескриптор USB, чей однобайтовый bLength ограничен 255 байтами. */ + USHORT language = 0x0409; /* Использует английский идентификатор USB как резервный вариант для устройств без списка языков. */ + if (WinUsb_GetDescriptor(dev->winUSBHandle, 3, 0, 0, raw, sizeof(raw), &received) && received >= 4 && raw[0] >= 4 && raw[1] == 3) language = (USHORT)(raw[2] | ((USHORT)raw[3] << 8)); /* Тип 3 и индекс 0 возвращают список LANGID; выбирает первый язык, предоставленный адаптером. */ + if (!WinUsb_GetDescriptor(dev->winUSBHandle, 3, string_index, language, raw, sizeof(raw), &received)) return false; /* Читает именно строку продукта на поддерживаемом устройством языке. */ + if (received < 2 || raw[1] != 3 || raw[0] < 2 || raw[0] > received || (raw[0] & 1)) return false; /* Проверяет тип, размер и чётное число байтов UTF-16 перед копированием в выходной буфер. */ + uint32_t count = (raw[0] - 2) / 2; /* Исключает двухбайтовый заголовок и переводит длину UTF-16LE из байтов в число кодовых единиц. */ + if (count >= capacity) return false; /* Не обрезает индивидуальный номер в конце имени и оставляет место для завершающего нуля. */ + for (uint32_t index = 0; index < count; ++index) product[index] = (wchar_t)(raw[2 + index * 2] | ((USHORT)raw[3 + index * 2] << 8)); /* Явно собирает кодовые единицы UTF-16LE без предположений о выравнивании массива байтов. */ + product[count] = 0; /* Завершает строку для безопасного чтения через ctypes и отображения в Qt. */ + return count > 0; /* Пустое имя оставляет интерфейсу стандартную резервную подпись адаптера. */ +} /* Завершает получение настоящего имени из прошивки USB-адаптера. */ + bool __stdcall DLL candle_dev_get_timestamp_us(candle_handle hdev, uint32_t *timestamp_us) { return candle_ctrl_get_timestamp(hdev, timestamp_us); @@ -823,6 +927,38 @@ bool __stdcall DLL candle_channel_stop(candle_handle hdev, uint8_t ch) return candle_ctrl_set_device_mode(dev, ch, CANDLE_DEVMODE_RESET, 0); } +bool __stdcall DLL candle_channel_start_configured(candle_handle hdev, uint8_t ch, uint32_t flags) /* Новый вход сохраняет прежний ABI start и позволяет отключать HW_TIMESTAMP. */ +{ /* Явно выбранные Python-флаги должны попасть в прошивку без автоматического дополнения. */ + return candle_ctrl_set_device_mode((candle_device_t*)hdev, ch, CANDLE_DEVMODE_START, flags); /* Передаём точную маску после проверки возможностей на уровне транспорта. */ +} /* Завершение запуска с явно заданными параметрами. */ + +bool __stdcall DLL candle_channel_get_data_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap) /* Экспортируем чтение FD-пределов для расчёта тайминга на любой частоте CAN. */ +{ /* Общая форма capability позволяет повторно использовать валидатор номинального тайминга. */ + return candle_ctrl_data_capability((candle_device_t*)hdev, ch, cap); /* USB-запрос возвращает реальные пределы выбранного канала. */ +} /* Завершение получения возможностей быстрой фазы. */ + +bool __stdcall DLL candle_channel_set_termination(candle_handle hdev, uint8_t ch, uint32_t state) /* Значения 0 и 1 соответствуют OFF и ON стандарта gs_usb. */ +{ /* Проверка исключает отправку неопределённого состояния в прошивку. */ + return state <= 1 && candle_ctrl_termination((candle_device_t*)hdev, ch, &state, true); /* Выполняем запись только для допустимого состояния. */ +} /* Завершение переключения терминатора. */ + +bool __stdcall DLL candle_channel_get_termination(candle_handle hdev, uint8_t ch, uint32_t *state) /* Чтение состояния не меняет топологию CAN. */ +{ /* Используем тот же формат 32-битного значения, что и при записи. */ + return candle_ctrl_termination((candle_device_t*)hdev, ch, state, false); /* false выбирает USB_DIR_IN и GET_TERMINATION. */ +} /* Завершение чтения терминатора. */ + +bool __stdcall DLL candle_channel_get_diagnostics(candle_handle hdev, uint8_t ch, uint32_t *state, uint32_t *rxerr, uint32_t *txerr) /* Сохраняем счётчики, которые старый get_state отбрасывал. */ +{ /* Диагностика выполняется только для уже открытого устройства. */ + candle_device_state_t data = {0}; /* Полная структура протокола содержит три слова: состояние, RX и TX. */ + if (!candle_ctrl_get_state((candle_device_t*)hdev, ch, &data)) { /* Не возвращаем нулевые счётчики как будто запрос был успешным. */ + return false; /* Причина USB-отказа уже записана управляющим запросом. */ + } /* Дальнейшее копирование относится только к подтверждённому ответу. */ + *state = data.state; /* Сохраняем состояние Error-active, Warning, Passive, Bus-off, Stopped или Sleeping. */ + *rxerr = data.rxerr; /* Счётчик ошибок приёма измеряется в единицах CAN-контроллера. */ + *txerr = data.txerr; /* Счётчик ошибок передачи помогает отличать отсутствие ACK от ошибок приёма. */ + return true; /* Все три выходных значения относятся к одному USB-ответу. */ +} /* Завершение расширенной диагностики канала. */ + /* Write len bytes from buf to the OUT pipe, aborting after 300 ms. * Writes are serialised by writeMutex in CandleApiInterface so dev->txEvent * is never accessed by two threads simultaneously. */ @@ -1017,14 +1153,13 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f } dev->rxurbs[urb_num].pending = false; - /* Minimum: classic CAN header (12 bytes) + at least 8 data bytes = 20 bytes */ - static const DWORD classic_min = sizeof(candle_frame_t) - 4; - if (bytes_transfered < classic_min) { + const DWORD header_size = 12; /* Заголовок gs_usb занимает 12 байт в Classic и FD; короткий FD может не содержать восьми байтов данных. */ + if (bytes_transfered < header_size) { /* До чтения flags и DLC проверяем наличие полного заголовка. */ candle_prepare_read(dev, urb_num); candle_logf(L"fd read too small urb=%u bytes=%lu min=%lu", urb_num, bytes_transfered, - classic_min); + header_size); /* Минимальная длина здесь относится к заголовку, а payload проверяется после определения формата. */ dev->last_error = CANDLE_ERR_READ_SIZE; return false; } @@ -1047,6 +1182,11 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f if (is_fd_frame) { /* can_dlc is at byte offset 8 in both classic and FD wire frames. */ const uint8_t raw_dlc = dev->rxurbs[urb_num].buf[8]; + if (raw_dlc > 15) { /* FD DLC является четырёхбитным кодом и не может содержать значения выше 15. */ + candle_prepare_read(dev, urb_num); /* Повреждённый USB-пакет не должен останавливать последующий приём. */ + dev->last_error = CANDLE_ERR_READ_SIZE; /* Неверный DLC считается ошибкой формата входного кадра. */ + return false; /* Не превращаем повреждённый DLC в пустой успешный FD-кадр. */ + } /* Далее длина безопасно вычисляется по таблице CAN FD. */ const DWORD data_len = candle_dlc_to_len(raw_dlc); const DWORD min_size = 12 + data_len; /* header + data, without timestamp */ @@ -1077,6 +1217,11 @@ bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *f } /* else: timestamp stays zero from memset above */ } else { + if (bytes_transfered < sizeof(candle_frame_t) - sizeof(uint32_t)) { /* Classic-пакет обязан содержать весь восьмибайтовый буфер данных, даже при меньшем DLC. */ + candle_prepare_read(dev, urb_num); /* Возобновляем соответствующий URB после отклонения неполного пакета. */ + dev->last_error = CANDLE_ERR_READ_SIZE; /* Не допускаем чтение неполученных данных из локальной структуры. */ + return false; /* Повреждённый Classic-пакет не попадает в Python через FD API. */ + } /* Минимальный размер Classic теперь проверен отдельно от коротких FD. */ /* Classic CAN frame — copy into FD struct, fixing the timestamp position */ candle_frame_t classic; DWORD copy_len = (bytes_transfered < sizeof(classic)) ? bytes_transfered : sizeof(classic); diff --git a/c/candle/candle.def b/c/candle/candle.def index e4321b6..19434bf 100644 --- a/c/candle/candle.def +++ b/c/candle/candle.def @@ -3,6 +3,8 @@ ; прикладные регистры SETProtocol. EXPORTS + candle_dev_get_product ; Имя продукта из USB-дескриптора прошивки для списка адаптеров SETGUI. + candle_dev_enter_dfu ; Переход через отдельный DFU runtime-интерфейс того же физического USB-адаптера. candle_list_scan candle_list_free candle_list_length @@ -31,3 +33,13 @@ EXPORTS candle_frame_data candle_frame_timestamp_us candle_dev_last_error + candle_channel_start_configured ; Точная маска запуска с управляемыми аппаратными метками. + candle_channel_get_data_capabilities ; Пределы тайминга быстрой фазы CAN FD. + candle_channel_set_termination ; Переключение встроенного терминатора. + candle_channel_get_termination ; Чтение состояния встроенного терминатора. + candle_channel_get_diagnostics ; Состояние и счётчики ошибок CAN. + candle_channel_set_data_timing ; Тайминг фазы данных CAN FD. + candle_fd_frame_send ; Отправка полного кадра CAN FD до 64 байт. + candle_fd_frame_read ; Приём FD и Classic без обрезания данных. + candle_fd_frame_type ; Различение RX, TX echo и ошибок FD. + candle_fd_frame_id ; Получение CAN ID из структуры FD. diff --git a/c/candle/candle.h b/c/candle/candle.h index 9f063d4..3c96e9e 100644 --- a/c/candle/candle.h +++ b/c/candle/candle.h @@ -246,7 +246,9 @@ bool __stdcall DLL candle_list_length(candle_list_handle list, uint8_t *len); bool __stdcall DLL candle_dev_get(candle_list_handle list, uint8_t dev_num, candle_handle *hdev); bool __stdcall DLL candle_dev_get_state(candle_handle hdev, candle_devstate_t *state); wchar_t * __stdcall DLL candle_dev_get_path(candle_handle hdev); +bool __stdcall DLL candle_dev_get_product(candle_handle hdev, wchar_t *product, uint32_t capacity); /* Возвращает USB Product String в UTF-16; capacity включает завершающий ноль и измеряется в wchar_t. */ bool __stdcall DLL candle_dev_open(candle_handle hdev); +uint32_t __stdcall DLL candle_dev_enter_dfu(const wchar_t *can_path, uint8_t status[6]); /* Переводит выбранный физический адаптер в DFU и возвращает Win32-код плюс ответ DFU_GETSTATUS. */ bool __stdcall DLL candle_dev_get_timestamp_us(candle_handle hdev, uint32_t *timestamp_us); bool __stdcall DLL candle_dev_close(candle_handle hdev); bool __stdcall DLL candle_dev_free(candle_handle hdev); @@ -259,6 +261,11 @@ bool __stdcall DLL candle_channel_set_timing(candle_handle hdev, uint8_t ch, can bool __stdcall DLL candle_channel_set_bitrate(candle_handle hdev, uint8_t ch, uint32_t bitrate); bool __stdcall DLL candle_channel_start(candle_handle hdev, uint8_t ch, uint32_t flags); bool __stdcall DLL candle_channel_stop(candle_handle hdev, uint8_t ch); +bool __stdcall DLL candle_channel_start_configured(candle_handle hdev, uint8_t ch, uint32_t flags); /* Запускаем с точной маской без принудительного включения аппаратных меток. */ +bool __stdcall DLL candle_channel_get_data_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap); /* Возвращаем пределы тайминга быстрой фазы FD. */ +bool __stdcall DLL candle_channel_set_termination(candle_handle hdev, uint8_t ch, uint32_t state); /* Состояние терминатора: 0 — выключен, 1 — включён. */ +bool __stdcall DLL candle_channel_get_termination(candle_handle hdev, uint8_t ch, uint32_t *state); /* Чтение не меняет подключение встроенного резистора. */ +bool __stdcall DLL candle_channel_get_diagnostics(candle_handle hdev, uint8_t ch, uint32_t *state, uint32_t *rxerr, uint32_t *txerr); /* Передаём состояние CAN и оба счётчика ошибок без потери данных ответа. */ bool __stdcall DLL candle_frame_send(candle_handle hdev, uint8_t ch, candle_frame_t *frame); bool __stdcall DLL candle_frame_read(candle_handle hdev, candle_frame_t *frame, uint32_t timeout_ms); diff --git a/c/candle/candle_ctrl_req.c b/c/candle/candle_ctrl_req.c index 1bbfe41..47484e4 100644 --- a/c/candle/candle_ctrl_req.c +++ b/c/candle/candle_ctrl_req.c @@ -39,8 +39,10 @@ enum { CANDLE_TIMESTAMP_GET = 6, /* 7: IDENTIFY, 8: GET_USER_ID, 9: SET_USER_ID (not used here) */ CANDLE_BREQ_DATA_BITTIMING = 10, - /* 11: SET_TERMINATION, not used here */ - CANDLE_BREQ_GET_STATE = 12, + CANDLE_BREQ_BT_CONST_EXT = 11, /* Расширенные пределы номинальной и быстрой фазы согласно gs_usb. */ + CANDLE_BREQ_SET_TERMINATION = 12, /* Запрос gs_usb 12 переключает встроенный резистор окончания шины. */ + CANDLE_BREQ_GET_TERMINATION = 13, /* Запрос gs_usb 13 читает текущее состояние терминатора. */ + CANDLE_BREQ_GET_STATE = 14, /* Стандартный GET_STATE имеет номер 14; номер 12 принадлежит терминатору. */ CANDLE_BREQ_BUS_OFF_RECOVERY = 32, }; @@ -237,3 +239,22 @@ bool candle_ctrl_bus_off_recover(candle_device_t *dev, uint8_t channel) dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE; return rc; } + +bool candle_ctrl_data_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *cap) /* Структура gs_usb BT_CONST_EXT содержит десять общих и восемь быстрых полей uint32. */ +{ /* Используем массив, поскольку протокол содержит только однородные 32-битные значения без отступов. */ + uint32_t fields[18] = {0}; /* 18 полей по 4 байта образуют стандартный ответ размером 72 байта. */ + bool rc = usb_control_msg(dev->winUSBHandle, CANDLE_BREQ_BT_CONST_EXT, USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE, channel, 0, fields, sizeof(fields)); /* Запрашиваем пределы именно выбранного CAN-канала. */ + if (rc) { /* Выходная структура должна меняться только после успешного USB-ответа. */ + memcpy(cap, fields, 2 * sizeof(uint32_t)); /* Маска функций и частота CAN общие для обеих фаз. */ + memcpy(&cap->tseg1_min, &fields[10], 8 * sizeof(uint32_t)); /* Поля DTSEG1…DBRP_INC заменяют номинальные пределы структуры capability. */ + } /* При отказе вызывающий код получит false и не использует нулевые пределы. */ + dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_BITTIMING_CONST; /* Сохраняем диагностический код чтения аппаратных ограничений. */ + return rc; /* Успех означает наличие полной структуры пределов быстрой фазы. */ +} /* Завершение запроса расширенных возможностей FD. */ + +bool candle_ctrl_termination(candle_device_t *dev, uint8_t channel, uint32_t *state, bool write) /* Переключение терминатора использует стандартные SET=12 и GET=13. */ +{ /* Состояние передаётся одним 32-битным словом согласно протоколу gs_usb. */ + bool rc = usb_control_msg(dev->winUSBHandle, write ? CANDLE_BREQ_SET_TERMINATION : CANDLE_BREQ_GET_TERMINATION, (write ? USB_DIR_OUT : USB_DIR_IN)|USB_TYPE_VENDOR|USB_RECIP_INTERFACE, channel, 0, state, sizeof(*state)); /* Номер CAN-канала передаётся в wValue, а wIndex равен нулю по спецификации. */ + dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE; /* Ошибка настройки доступна Python через candle_dev_last_error. */ + return rc; /* Не выдаём успешное переключение при отказе USB-контроллера. */ +} /* Завершение управления встроенным окончанием шины. */ diff --git a/c/candle/candle_ctrl_req.h b/c/candle/candle_ctrl_req.h index 2ba23d6..97761b6 100644 --- a/c/candle/candle_ctrl_req.h +++ b/c/candle/candle_ctrl_req.h @@ -51,4 +51,6 @@ bool candle_ctrl_set_data_bittiming(candle_device_t *dev, uint8_t channel, candl bool candle_ctrl_get_timestamp(candle_device_t *dev, uint32_t *current_timestamp); bool candle_ctrl_get_state(candle_device_t *dev, uint8_t channel, candle_device_state_t *data); bool candle_ctrl_bus_off_recover(candle_device_t *dev, uint8_t channel); +bool candle_ctrl_data_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *cap); /* Читаем пределы быстрой фазы CAN FD отдельным запросом расширенной структуры. */ +bool candle_ctrl_termination(candle_device_t *dev, uint8_t channel, uint32_t *state, bool write); /* Передаём состояние терминатора: 0 — выключен, 1 — включён. */ diff --git a/c/firmware-info/tools/identity.py b/c/firmware-info/tools/identity.py index a338bca..5556178 100644 --- a/c/firmware-info/tools/identity.py +++ b/c/firmware-info/tools/identity.py @@ -16,6 +16,7 @@ import shutil import tempfile from pathlib import Path import subprocess +import tempfile # Резервирует номер сборки через временный файл на том же диске, как общий итератор ветки 166. def git_state(repository: Path) -> dict: diff --git a/c/protocan-boot/include/pcan_boot.h b/c/protocan-boot/include/pcan_boot.h index 534d42c..8fc830e 100644 --- a/c/protocan-boot/include/pcan_boot.h +++ b/c/protocan-boot/include/pcan_boot.h @@ -52,7 +52,8 @@ typedef enum { PCAN_BOOT_CMD_PING = 0x0CU, PCAN_BOOT_CMD_CAPABILITIES = 0x0DU, PCAN_BOOT_CMD_DEVICE_NAME = 0x0EU, - PCAN_BOOT_CMD_MEMORY_INFO = 0x0FU + PCAN_BOOT_CMD_MEMORY_INFO = 0x0FU, /* Сохраняет существующий номер постраничного паспорта памяти. */ + PCAN_BOOT_CMD_SELECT_MEMORY = 0x10U /* Выбирает 0=штатное хранилище, 1=внутренняя Flash до передачи метаданных. */ } pcan_boot_command_t; typedef enum { @@ -127,6 +128,7 @@ typedef struct { /** Optional precise result, replacing legacy boolean verify_image. */ pcan_boot_status_t (*verify_status)(void *user, pcan_boot_byte_t slot, const pcan_boot_manifest_t *manifest); + pcan_boot_status_t (*select_memory)(void *user, pcan_boot_byte_t memory, pcan_boot_config_t *config); /* Необязательный порт явно переключает назначение образа до ERASE; старые порты возвращают INVALID_COMMAND. */ } pcan_boot_port_t; typedef struct { diff --git a/c/protocan-boot/ports/tms320f28335/README.md b/c/protocan-boot/ports/tms320f28335/README.md index 337d2ec..952f98c 100644 --- a/c/protocan-boot/ports/tms320f28335/README.md +++ b/c/protocan-boot/ports/tms320f28335/README.md @@ -50,6 +50,7 @@ RS Flash должен стирать только B–H, публиковать Старая самостоятельная Flash-сборка с codestart `0x33FFF6` несовместима с этим размещением и не должна передаваться как обновление приложения. + Образ — SCI8 BIN (`AA 08`), чётная длина от 24 до **130560 байт** включительно. Числа в протоколе little endian. Адреса внутри SCI8: сначала старшее 16-битное слово, затем младшее; каждое слово little endian. В C28x `char` 16-битный, @@ -154,7 +155,31 @@ SPI-драйвер загрузчика имеет ограничение ожи Его RAM — 0xE000..0xFFFF; стек — 0x400..0x7FF. Приложение M95M02 использует 0x8000..0xD7FF для кода, 0xD800..0xDFFF для констант/инициализации, 0xE000..0xFFFF для .ebss после перехода в C runtime. Linker резервирует -NOINIT handoff в обоих проектах. CAN не содержит операций записи внутренней Flash. +NOINIT handoff в обоих проектах. TI Flash API временно использует RAM +`0x8000..0x97FF` до запуска приложения. В обновлённом постоянном загрузчике +CAN также записывает внутреннюю Flash приложения B–H; сектор A защищён. + +## Внутренняя Flash по CAN + +После ENTER_BOOT клиент явно отправляет SELECT_MEMORY (`0x10`, DLC=1, +DATA[0]=1) до BEGIN_IMAGE/BEGIN_COMPAT. Новая сессия по умолчанию использует +EEPROM A/B, поэтому старые клиенты сохраняют прежнее поведение. Выбор памяти +после метаданных запрещён; старый boot отвечает INVALID_COMMAND до ERASE. +CAPABILITIES bit 12 обозначает поддержку записи внутренней Flash. + +Flash SCI8 с FAB1 передаётся в неактивный EEPROM-слот как staging. VERIFY +проверяет CRC32 EEPROM, таблицу, непересекающиеся адреса только B–H и FAB1/CRC +будущей Flash. COMMIT повторяет проверку staging, вызывает официальный TI API +со стиранием `0x00FE`, пишет и проверяет данные порциями до 128 слов, публикует +FAB1 последним и проверяет CRC физической Flash. ACK COMMIT приходит после +записи; клиент даёт до 180 секунд и затем отправляет отдельный REBOOT. +EEPROM active и параметры сохраняются; прежний неактивный образ заменяется staging. + +В SETGUI: «Прошивка → CAN / ProtoCAN Boot → ProtoCAN: память прошивки → +Внутренняя Flash F28335», файл `*_flash_rs.bin` существующего выпуска. +Сначала требуется обновить постоянный `F28335_CAN_AB.out` через JTAG с маской +только сектора A. В Debug/Release подключён установленный TI Flash API v2.10; +его код и таблицы копируются в RAM перед занятостью общего банка Flash A–H. ## Первичная установка diff --git a/c/protocan-boot/ports/tms320f28335/f28335_can_boot.c b/c/protocan-boot/ports/tms320f28335/f28335_can_boot.c index f02354b..4f1b80f 100644 --- a/c/protocan-boot/ports/tms320f28335/f28335_can_boot.c +++ b/c/protocan-boot/ports/tms320f28335/f28335_can_boot.c @@ -8,6 +8,21 @@ * Сброс сессии не удаляет active/pending из EEPROM. */ static pcan_boot_t boot; static f28335_boot_store_t store; +static unsigned int healthy; /* Результат открытия EEPROM-журнала известен также функции выбора назначения Flash. */ +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Flash API доступен только постоянному загрузчику с явной поддержкой обновления B–H. */ +#include "f28335_flash_update.h" /* Подключает проверку Flash SCI8 и аппаратный writer с сохранением сектора A. */ +#include "f28335_flash_update.c" /* Общий алгоритм компилируется с тем же pcan_boot и разметкой, что этот порт. */ +static f28335_flash_update_t flash_update; /* Индекс и кеш занимают ограниченную внутреннюю RAM без загрузки приложения поверх boot. */ +static unsigned int flash_selected, flash_staging; /* Явная цель Flash использует только неактивный EEPROM-слот как временное хранилище. */ +static pcan_boot_status_t select_memory(void *u, pcan_boot_byte_t memory, pcan_boot_config_t *cfg) /* Выбирает назначение до BEGIN_IMAGE, сохраняя прежний EEPROM-путь по умолчанию. */ +{ /* Изменения конфигурации разрешены только при исправном EEPROM-журнале. */ + (void)u; if (!healthy) return PCAN_BOOT_STATUS_FLASH_ERROR; /* Ошибка чтения журнала запрещает выбор staging поверх неизвестного active. */ + if (memory > 1U) return PCAN_BOOT_STATUS_INVALID_COMMAND; /* Единственные определённые цели: 0=EEPROM A/B и 1=внутренняя Flash. */ + flash_selected = memory; flash_update.valid = false; /* Новая цель отменяет прежнее разрешение программирования. */ + cfg->slot_count = memory ? 1U : 2U; cfg->active_slot = memory ? 0U : store.active; /* CAN Flash использует один логический слот A, EEPROM сохраняет выбор неактивного A/B. */ + return PCAN_BOOT_STATUS_OK; /* Физический staging будет инвалидирован лишь при последующей команде ERASE. */ +} /* Никакие данные Flash или EEPROM при выборе цели не изменяются. */ +#endif /* Приложение и старые host-модели не подключают Flash API. */ /* Кольцевая очередь: ISR производит кадры, main потребляет. Из 32 элементов * используется 31, чтобы head==tail однозначно означало пустую очередь. * Долгие операции SPI запрещены внутри обработчика CAN. */ @@ -16,7 +31,7 @@ static struct { uint32_t id; unsigned int dlc; pcan_boot_byte_t data[8]; } queue /* reset_requested только планирует reset; Service ждёт завершения TX. * ready сообщает об окончании старта приложения, confirmed — о сохранённом * подтверждении слота. healthy отражает результат открытия журнала при Init. */ -static unsigned int reset_requested,reset_session,ready,healthy,confirmed; +static unsigned int reset_requested,reset_session,ready,confirmed; /* Состояние перехода и подтверждения приложения отделено от доступности EEPROM. */ static uint32_t sent_at,ready_at; static unsigned int main_passes; static unsigned int addressed; /* Разрешает обслуживание только после проверки номера PERIPH и установки согласованных фильтров CAN. */ @@ -49,9 +64,19 @@ static bool transmit(void *u, uint32_t id, const pcan_boot_byte_t *d, pcan_boot_ * эти callbacks возвращают отказ, даже если им удастся передать команду записи. * ERASE для EEPROM означает инвалидирование manifest выбранного слота. */ static bool erase(void *u,pcan_boot_byte_t slot) -{ (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_prepare(&store,slot); } +{ /* ERASE подготавливает staging; внутренняя Flash остаётся неизменной до VERIFY и COMMIT. */ +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Новая цель не меняет прежний формат EEPROM A/B. */ + if (flash_selected) { flash_update.valid = false; flash_staging = store.active == 0U ? 1U : 0U; return healthy && f28335_boot_prepare(&store, flash_staging); } /* Инвалидирует только неактивный слот, сохраняя подтверждённый резерв и параметры. */ +#endif /* В остальных сборках остаётся штатная подготовка EEPROM-слота. */ + (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_prepare(&store,slot); /* Обычный CAN-клиент сохраняет прежний путь обновления RAM-приложения. */ +} /* Подготовка не вызывает TI Flash_Erase. */ static bool write_block(void *u,pcan_boot_byte_t slot,uint32_t offset,const pcan_boot_byte_t *d,pcan_boot_byte_t n) -{ (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_write(&store,slot,offset,d,n); } +{ /* CAN ISR только ставит данные в очередь; запись staging выполняет main. */ +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Запись Flash-файла использует тот же проверенный EEPROM writer. */ + if (flash_selected) return healthy && f28335_boot_write(&store, flash_staging, offset, d, n); /* Логический DATA_A направляется в выбранный неактивный физический слот. */ +#endif /* Обычные A/B-данные сохраняют исходную адресацию. */ + (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_write(&store,slot,offset,d,n); /* Не допускает записи из прикладной сборки. */ +} /* Внутренняя Flash ещё не затронута. */ /* Проверка включает CRC EEPROM и структуру SCI8. Сейчас отказ этого слоя * кодируется CRC_ERROR как общий отказ проверки образа; причины внутри * парсера SCI8 отдельными статусами протокола не различаются. */ @@ -59,10 +84,21 @@ static pcan_boot_status_t verify(void *u,pcan_boot_byte_t slot,const pcan_boot_m { (void)u; if(!F28335_BOOT_LOADER || !healthy) return PCAN_BOOT_STATUS_INVALID_STATE; +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Для внутренней Flash нужен собственный допустимый диапазон SCI8 и обязательный FAB1. */ + if (flash_selected) return f28335_flash_update_validate(&flash_update, &store.io, flash_staging ? F28335_AB_SLOT_B : F28335_AB_SLOT_A, m) ? PCAN_BOOT_STATUS_OK : PCAN_BOOT_STATUS_CRC_ERROR; /* Повторно читает EEPROM и проверяет транспортную CRC, таблицу и CRC будущей Flash до erase. */ +#endif /* RAM SCI8 проходит прежнюю проверку приложения EEPROM. */ return f28335_boot_verify(&store,slot,m,0)?PCAN_BOOT_STATUS_OK:PCAN_BOOT_STATUS_CRC_ERROR; } static bool commit(void *u,pcan_boot_byte_t slot,const pcan_boot_manifest_t *m) -{ (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_commit(&store,slot,m); } +{ /* COMMIT выполняет фактическую запись только после подтверждённой проверки образа. */ +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Постоянный boot остаётся вне обновляемых секторов B–H. */ + if (flash_selected) { /* Синхронный TI API может работать дольше обычного интервала CAN, не создавая pending EEPROM. */ + bool result = healthy && F28335Boot_FlashProgram(&flash_update); /* Итог зависит от erase, каждой порции readback и полной CRC физической Flash. */ + boot.now_ms = F28335Boot_Now(); boot.last_activity_ms = boot.now_ms; return result; /* Обновляет срок сессии после аппаратной операции, чтобы штатный REBOOT не был отклонён из-за времени записи. */ + } /* Ошибка записи оставляет постоянный boot доступным для нового сеанса. */ +#endif /* Обычное обновление EEPROM сохраняет trial/CONFIRM. */ + (void)u; return F28335_BOOT_LOADER && healthy && f28335_boot_commit(&store,slot,m); /* Публикует только RAM-образ в исходном журнале A/B. */ +} /* Итоговый статус и отдельный REBOOT отправляет существующее ядро ProtoCAN. */ /* Подтверждение принадлежит приложению после READY. Загрузчик не может * подтвердить кандидата сам: иначе исчезнет смысл проверки пробного запуска. * Разрешение кормления watchdog из ISR открываем только после записи журнала. */ @@ -83,7 +119,12 @@ static unsigned int enter(void *u,pcan_boot_byte_t session) { (void)u; if(!healthy) return 2; - if(F28335_BOOT_LOADER) return 0; + if(F28335_BOOT_LOADER) { /* Постоянный загрузчик открывает сессию без дополнительного reset. */ +#if F28335_CAN_FLASH_UPDATE /* Каждая новая сессия должна начинаться со штатного EEPROM-назначения. */ + if (boot.state == PCAN_BOOT_STATE_IDLE) (void)select_memory(0, 0U, &boot.config); /* Повторный ENTER текущей сессии не меняет уже выбранную Flash. */ +#endif /* Старые сборки не используют переключение назначения. */ + return 0; /* Ядро отправит адресный ACK готовности. */ + } /* Прикладной handoff проверяется ниже. */ /* No destructive fallback on boards still starting through the SPI ROM. */ if((F28335Boot_RunningSlot()>1 && F28335Boot_RunningSlot()!=F28335_BOOT_FLASH_SLOT) || !confirmed) return 2; /* Только handoff общего загрузчика разрешает переход из EEPROM либо защищённой внутренней Flash; старый SPI-ROM старт остаётся запрещён. */ reset_session=session; reset_requested=1; return 1; @@ -102,7 +143,10 @@ static bool info(void *u,pcan_boot_byte_t command,pcan_boot_byte_t page,pcan_boo /* bits: ping, discovery, identity, name, memory, A/B, confirm, rollback, * separate loader, session timeout, readback, SCI8 validation. */ info32(out,(F28335_BOOT_LOADER || F28335Boot_RunningSlot()<2 || F28335Boot_RunningSlot()==F28335_BOOT_FLASH_SLOT)?0xFFFUL:0x1FUL); /* Flash-приложение с достоверным handoff поддерживает вход в постоянный CAN Boot так же, как подтверждённый EEPROM-образ. */ - info32(out+4,F28335_AB_SLOT_BYTES); return true; +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* Дополнительный бит сообщает клиенту поддержку явной внутренней Flash. */ + out[1] |= 0x10U; /* Бит 12 общего little-endian capability word означает SELECT_MEMORY и запись B–H. */ +#endif /* Старые приложения продолжают сообщать прежние возможности. */ + info32(out+4,F28335_AB_SLOT_BYTES); return true; /* Максимальная длина одинакова для staging Flash и EEPROM A/B. */ } if(command==PCAN_BOOT_CMD_DEVICE_NAME) { if((unsigned int)page*8U>=name_size) return false; /* Страница после конца выбранного имени не является допустимой частью паспорта. */ @@ -146,6 +190,10 @@ void F28335CanBoot_Init(unsigned int periph_id) /* Один номер плат cfg.active_slot=store.active; cfg.slot_count=2; cfg.max_image_size=F28335_AB_SLOT_BYTES; cfg.ack_window=16; cfg.session_timeout_ms=30000UL; +#if F28335_BOOT_LOADER && F28335_CAN_FLASH_UPDATE /* После reset никакое случайное содержимое RAM не выбирает внутреннюю Flash. */ + flash_selected = 0U; flash_staging = F28335_AB_NONE; flash_update.valid = false; /* Возвращает исходную цель и отменяет разрешение записи предыдущей сессии. */ + port.select_memory = select_memory; /* Новая команда доступна только сборке с реально подключённым TI API. */ +#endif /* Приложение отвечает только через постоянный загрузчик после handoff. */ port.send=transmit; port.erase_slot=erase; port.write_slot=write_block; port.verify_status=verify; port.set_pending_slot=commit; port.reboot=reboot; port.confirm_running_slot=confirm; port.enter_boot=enter; diff --git a/c/protocan-boot/ports/tms320f28335/f28335_flash_update.c b/c/protocan-boot/ports/tms320f28335/f28335_flash_update.c new file mode 100644 index 0000000..ed4182b --- /dev/null +++ b/c/protocan-boot/ports/tms320f28335/f28335_flash_update.c @@ -0,0 +1,86 @@ +#include "f28335_flash_update.h" /* Разделяет проверяемый алгоритм и аппаратные вызовы TI Flash API. */ +#include /* Явно очищает состояние источника перед разбором нового SCI8. */ +static unsigned int octet(f28335_flash_update_t *s, uint32_t offset) /* Читает октет через страничный кеш, исключая SPI-транзакцию на каждый байт CRC. */ +{ /* Все смещения ограничены фактической длиной принятого файла. */ + if (offset >= s->length) { s->fault = true; return 0U; } /* Обрезанное поле не превращается в данные за концом staging. */ + if (!s->cache_size || offset < s->cache_at || offset - s->cache_at >= s->cache_size) { /* Загружает новую страницу только при выходе за текущую. */ + s->cache_at = offset & ~255UL; /* Выравнивает смещение на страницу из 256 физических октетов. */ + s->cache_size = s->length - s->cache_at > 256UL ? 256U : (unsigned int)(s->length - s->cache_at); /* Последнее чтение не включает паддинг CAN за концом образа. */ + if (!s->io.read(s->io.user, s->base + s->cache_at, s->cache, s->cache_size)) { s->fault = true; s->cache_size = 0U; return 0U; } /* Любой неполный аппаратный обмен запрещает erase. */ + } /* После заполнения кеша извлечение октета не обращается к SPI. */ + return s->cache[offset - s->cache_at] & 255U; /* Маска сохраняет восьмибитный wire-формат на C28x. */ +} /* Завершает ограниченное чтение октета. */ +static uint16_t word(f28335_flash_update_t *s, uint32_t offset) /* SCI8 передаёт младший октет каждого 16-битного слова первым. */ +{ /* Объединяет два октета только через проверяемый источник. */ + unsigned int low = octet(s, offset); /* Явный порядок вызовов не зависит от порядка вычисления операндов компилятором. */ + return (uint16_t)(low | (octet(s, offset + 1UL) << 8)); /* Восстанавливает одно слово CPU из двух октетов SCI8. */ +} /* Завершает декодирование слова. */ +static uint32_t address_at(f28335_flash_update_t *s, uint32_t offset) /* Адрес таблицы TI содержит старшее слово перед младшим. */ +{ /* Порядок слов адреса отличается от порядка октетов внутри слова. */ + uint32_t high = word(s, offset); /* Читает старшие 16 бит адреса раньше младших. */ + return (high << 16) | word(s, offset + 2UL); /* Получает CPU word-address без перевода в байты. */ +} /* Завершает восстановление адреса SCI8. */ +static uint16_t image_word(void *user, uint32_t address) /* Предоставляет общему валидатору будущий вид Flash, включая стёртые промежутки. */ +{ /* Отсортированный индекс позволяет проверять весь CRC-диапазон последовательно. */ + f28335_flash_update_t *s = user; f28335_flash_section_t *p; /* Контекст содержит только предварительно проверенные секции. */ + if (s->cursor >= s->count || address < s->section[s->cursor].address) s->cursor = 0U; /* Возврат от заголовка к началу диапазона корректно перематывает индекс. */ + while (s->cursor < s->count) { /* Пропускает только секции, целиком лежащие перед запрошенным словом. */ + p = &s->section[s->cursor]; /* Обращается к существующему элементу проверенного индекса. */ + if (address < p->address) return 0xFFFFU; /* Flash_Erase оставляет незагружаемый промежуток заполненным единицами. */ + if (address - p->address < p->words) return word(s, p->offset + 2UL * (address - p->address)); /* Переводит смещение слов секции в октеты SCI8. */ + ++s->cursor; /* Следующий запрос не должен повторно сканировать уже пройденную секцию. */ + } /* После последней секции остаётся только стёртая Flash. */ + return 0xFFFFU; /* CRC учитывает также завершающий промежуток диапазона FAB1. */ +} /* Сам этот callback никогда не читает аппаратную Flash. */ +bool f28335_flash_update_validate(f28335_flash_update_t *s, const f28335_boot_io_t *io, uint32_t base, const pcan_boot_manifest_t *m) /* Проверяет транспорт, таблицу, допустимые адреса и FAB1 перед erase. */ +{ /* Любая ошибка оставляет объект непригодным для программирования. */ + uint32_t offset = 22UL, crc = 0UL, entry, checked_entry, bytes; unsigned int i, j, headers = 0U, have_entry = 0U; /* Заголовок SCI8 занимает 11 слов, то есть 22 октета. */ + f28335_flash_section_t p; bool ended = false; /* Временная секция нужна для вставки в отсортированный индекс. */ + if (!s || !io || !io->read || !m) return false; /* Не разыменовывает отсутствующие callbacks и manifest. */ + memset(s, 0, sizeof(*s)); s->io = *io; s->base = base; s->length = m->image_size; s->crc = m->image_crc32; /* Отбрасывает кеш и разрешение записи предыдущего образа. */ + if (s->length < 24UL || (s->length & 1UL) || s->length > F28335_AB_SLOT_BYTES || word(s, 0UL) != 0x08AAU) return false; /* Принимает только полный чётный SCI8 в пределах staging-слота. */ + for (offset = 0UL; offset < s->length; offset += bytes) { /* Независимо проверяет фактически записанную EEPROM, а не только принятые CAN-кадры. */ + bytes = s->length - offset > 256UL ? 256UL : s->length - offset; /* Делит чтение на ограниченные страницы без выхода за файл. */ + if (!s->io.read(s->io.user, base + offset, s->cache, (unsigned int)bytes)) return false; /* Аппаратная ошибка запрещает последующий erase. */ + crc = pcan_boot_crc32_update(crc, s->cache, (size_t)bytes); /* Использует общий CRC32 IEEE, совпадающий с клиентом SETGUI. */ + } /* Все октеты staging включены в транспортную CRC32. */ + s->cache_size = 0U; if (crc != s->crc) return false; /* Сбрасывает кеш после прямого чтения и отклоняет повреждённую EEPROM. */ + entry = address_at(s, 18UL); offset = 22UL; /* Точка входа следует после ключа и восьми резервных слов SCI8. */ + if (entry < F28335_FLASH_APP_DATA || entry >= F28335_FLASH_APP_STOP) return false; /* Запуск разрешён только из приложения ниже постоянного сектора A. */ + while (offset + 2UL <= s->length) { /* Для каждой секции сначала проверяет наличие поля длины. */ + p.words = word(s, offset); offset += 2UL; /* Длина секции выражена адресуемыми словами C28x. */ + if (!p.words) { ended = offset == s->length; break; } /* Нулевой терминатор обязан завершать файл без лишнего хвоста. */ + if (s->count == F28335_AB_MAX_SECTIONS || offset + 4UL > s->length) return false; /* Не допускает переполнение индекса либо обрезанный адрес секции. */ + p.address = address_at(s, offset); offset += 4UL; p.offset = offset; bytes = 2UL * p.words; /* Хранит CPU-адрес и байтовое смещение полезных данных отдельно. */ + if (bytes > s->length - offset || p.address < F28335_FLASH_APP_START || p.address >= F28335_FLASH_APP_STOP || p.words > F28335_FLASH_APP_STOP - p.address) return false; /* Исключает RAM, сектор A, CSM, OTP и выход за конец staging. */ + if (p.address == F28335_FLASH_APP_START) { if (p.words < F28335_FLASH_APP_HEADER_WORDS) return false; ++headers; } /* Полные 16 слов FAB1 должны публиковаться одной заключительной операцией. */ + if (entry >= p.address && entry - p.address < p.words) have_entry = 1U; /* Код entry обязан присутствовать именно в принятой таблице. */ + for (i = 0U; i < s->count; ++i) if (p.address < s->section[i].address + s->section[i].words && s->section[i].address < p.address + p.words) return false; /* Повторное программирование пересекающихся диапазонов запрещено. */ + for (j = s->count; j && s->section[j - 1U].address > p.address; --j) s->section[j] = s->section[j - 1U]; /* Сортирует индекс по адресу независимо от порядка секций в SCI8. */ + s->section[j] = p; ++s->count; offset += bytes; /* Запоминает непересекающуюся секцию и переходит к следующему заголовку. */ + } /* Структура файла полностью разобрана без записи Flash. */ + if (!ended || headers != 1U || !have_entry || s->fault) return false; /* Требует терминатор, единственный FAB1 и фактический исполняемый entry. */ + s->cursor = 0U; if (!f28335_flash_app_validate(image_word, s, &checked_entry) || s->fault || checked_entry != entry) return false; /* Общий валидатор проверяет magic, границы, инверсию entry и CRC будущей Flash. */ + s->entry = entry; s->valid = true; return true; /* Разрешает программирование только после всех независимых проверок. */ +} /* Ошибка проверки не меняет текущую Flash и журнал активной EEPROM. */ +bool f28335_flash_update_program(f28335_flash_update_t *s, const f28335_flash_ops_t *ops) /* Выполняет запись B–H только из полностью проверенного staging. */ +{ /* Заголовок FAB1 фиксируется после успешной проверки каждой порции данных. */ + uint16_t buffer[128]; unsigned int i, j, n; uint32_t at, left, entry; pcan_boot_manifest_t m; f28335_boot_io_t io; /* Одна порция содержит не более 128 слов, то есть 256 физических октетов. */ + if (!s || !s->valid || !ops || !ops->erase || !ops->program || !ops->read) return false; /* Неполный аппаратный контракт не должен начать стирание. */ + memset(&m, 0, sizeof(m)); m.image_size = s->length; m.image_crc32 = s->crc; io = s->io; at = s->base; /* Сохраняет исходные параметры перед полной повторной проверкой EEPROM. */ + if (!f28335_flash_update_validate(s, &io, at, &m)) return false; /* Изменение staging между VERIFY и COMMIT обнаруживается до первого erase. */ + s->valid = false; if (!ops->erase(ops->user)) return false; /* Одно разрешение записи расходуется до изменения Flash; повтор после отказа требует нового VERIFY. */ + for (i = 0U; i < s->count; ++i) { /* Программирует обычные данные каждой непересекающейся секции. */ + at = s->section[i].address; left = s->section[i].words; /* CPU-курсор и остаток выражены словами. */ + if (at == F28335_FLASH_APP_START) { at += F28335_FLASH_APP_HEADER_WORDS; left -= F28335_FLASH_APP_HEADER_WORDS; } /* Пропускает FAB1 до подтверждения всех остальных данных. */ + while (left) { /* Большие секции разбивает на порции рабочего буфера. */ + n = left > 128UL ? 128U : (unsigned int)left; /* Последняя порция может быть короче 128 слов. */ + for (j = 0U; j < n; ++j) buffer[j] = word(s, s->section[i].offset + 2UL * (at + j - s->section[i].address)); /* Распаковывает слова непосредственно из staging EEPROM. */ + if (s->fault || !ops->program(ops->user, at, buffer, n)) return false; /* Ошибка чтения, записи или readback оставляет FAB1 неопубликованным. */ + at += n; left -= n; /* Продвигает курсоры только после подтверждённой порции. */ + } /* Завершает запись данных секции. */ + } /* Все обычные слова уже записаны и проверены аппаратным портом. */ + for (i = 0U; i < F28335_FLASH_APP_HEADER_WORDS; ++i) buffer[i] = image_word(s, F28335_FLASH_APP_START + i); /* Собирает единственное проверенное описание FAB1 для заключительной записи. */ + if (s->fault || !ops->program(ops->user, F28335_FLASH_APP_START, buffer, F28335_FLASH_APP_HEADER_WORDS)) return false; /* Публикует описание только после успешной записи остальных секций. */ + return f28335_flash_app_validate(ops->read, ops->user, &entry) && entry == s->entry; /* Итоговый успех требует общей CRC уже физической Flash, а не только поблочного сравнения. */ +} /* Решение о CAN-подтверждении и перезапуске остаётся существующему протоколу. */ diff --git a/c/protocan-boot/ports/tms320f28335/f28335_flash_update.h b/c/protocan-boot/ports/tms320f28335/f28335_flash_update.h new file mode 100644 index 0000000..3fa5c66 --- /dev/null +++ b/c/protocan-boot/ports/tms320f28335/f28335_flash_update.h @@ -0,0 +1,20 @@ +#ifndef F28335_FLASH_UPDATE_H /* Один контракт объединяет проверку CAN-образа и аппаратную запись внутренней Flash. */ +#define F28335_FLASH_UPDATE_H /* Исключает повторное объявление структур при подключении аппаратного порта. */ +#include "f28335_boot_store.h" /* Использует существующий побайтовый доступ к staging-слоту EEPROM и manifest ProtoCAN. */ +#include "f28335_flash_app.h" /* Получает границы приложения B–H и проверку завершающего описания FAB1. */ +typedef struct { uint32_t address, offset; uint16_t words; } f28335_flash_section_t; /* Адрес CPU считается словами, смещение SCI8 — октетами, длина секции — словами. */ +typedef struct { /* Хранит проверенный индекс SCI8, не размещая целый образ во внутренней RAM загрузчика. */ + f28335_boot_io_t io; uint32_t base, length, crc, entry; /* Источник EEPROM, начало staging, точная длина, транспортная CRC32 и подтверждённый entry. */ + f28335_flash_section_t section[F28335_AB_MAX_SECTIONS]; /* Лимит 64 совпадает с ограничением выпуска SCI8 и ограничивает расход RAM. */ + unsigned int count, cursor, cache_size; bool fault, valid; /* Курсор ускоряет последовательную CRC; fault запрещает принятие неполного чтения EEPROM. */ + uint32_t cache_at; pcan_boot_byte_t cache[256]; /* Одна страница M95M02 содержит 256 физических октетов, даже при 16-битном char C28x. */ +} f28335_flash_update_t; /* Объект принадлежит только main постоянного загрузчика, а не CAN ISR. */ +typedef struct { /* Аппаратные операции отделены от формата SCI8 для испытаний обрыва и ошибок TI API. */ + bool (*erase)(void *user); /* Стирает только секторы B–H, сохраняя сектор A с постоянным загрузчиком. */ + bool (*program)(void *user, uint32_t address, uint16_t *data, unsigned int words); /* Записывает и проверяет одну порцию слов по допустимому CPU-адресу. */ + f28335_flash_read_word_t read; void *user; /* Чтение физической Flash подтверждает общий FAB1/CRC после последней записи. */ +} f28335_flash_ops_t; /* Никаких неявных операций reset или изменения EEPROM-журнала здесь нет. */ +bool f28335_flash_update_validate(f28335_flash_update_t *s, const f28335_boot_io_t *io, uint32_t base, const pcan_boot_manifest_t *m); /* Полностью проверяет staging до первого стирания Flash. */ +bool f28335_flash_update_program(f28335_flash_update_t *s, const f28335_flash_ops_t *ops); /* Повторяет проверку, пишет данные и публикует FAB1 последним. */ +bool F28335Boot_FlashProgram(f28335_flash_update_t *s); /* Аппаратная реализация подключает официальный TI Flash API только в постоянном загрузчике. */ +#endif /* Завершает защиту интерфейса CAN-обновления внутренней Flash. */ diff --git a/c/protocan-boot/projects/ccs/f28335/.cproject b/c/protocan-boot/projects/ccs/f28335/.cproject index 5c10a85..ea1e54c 100644 --- a/c/protocan-boot/projects/ccs/f28335/.cproject +++ b/c/protocan-boot/projects/ccs/f28335/.cproject @@ -80,6 +80,7 @@ + @@ -119,11 +121,13 @@ @@ -234,6 +238,7 @@ + @@ -271,11 +277,13 @@ diff --git a/c/protocan-boot/projects/ccs/f28335/F28335_CAN_AB.cmd b/c/protocan-boot/projects/ccs/f28335/F28335_CAN_AB.cmd index 161ce61..b6ba0a7 100644 --- a/c/protocan-boot/projects/ccs/f28335/F28335_CAN_AB.cmd +++ b/c/protocan-boot/projects/ccs/f28335/F28335_CAN_AB.cmd @@ -4,6 +4,7 @@ PAGE 0: /* Исполняемые секции и их Flash LOAD-адреса. FLASHA : origin = 0x338000, length = 0x007F80 /* Сектор A до зарезервированной CSM-области содержит постоянный загрузчик. */ BEGIN : origin = 0x33FFF6, length = 0x000002 /* Два слова аппаратного Flash boot перехода; пароли CSM не выделяются. */ RAMFUNCS : origin = 0x00F800, length = 0x000800 /* Верхние 2048 слов RAM загрузчика нужны InitFlash и не входят в SCI8 payload приложения. */ + RAMAPI : origin = 0x008000, length = 0x001800 /* 6144 слова временно принадлежат TI Flash API; приложение использует эту RAM только после выхода загрузчика. */ ADC_CAL : origin = 0x380080, length = 0x000009 /* Заводская калибровка TI используется без записи/стирания. */ RESET : origin = 0x3FFFC0, length = 0x000002 /* Аппаратный reset находится в ROM и не программируется. */ PAGE 1: /* Рабочие данные не пересекают код RAM-приложения ниже E000. */ @@ -15,6 +16,7 @@ PAGE 1: /* Рабочие данные не пересекают код RAM-пр SECTIONS /* Не выделяет ни одного загружаемого слова в RAM приложения. */ { /* Инициализированные Flash-секции остаются в секторе A. */ codestart : > BEGIN, PAGE = 0 /* Аппаратный переход передаёт управление watchdog-safe C startup. */ + flash_api : { -lFlash28335_API_V210.lib(.text) -lFlash28335_API_V210.lib(.econst) } LOAD = FLASHA, RUN = RAMAPI, LOAD_START(_FlashApiLoadStart), LOAD_END(_FlashApiLoadEnd), RUN_START(_FlashApiRunStart), PAGE = 0 /* Код и таблицы TI API выполняются из RAM, пока общий банк Flash занят изменением B–H. */ .text : > FLASHA, PAGE = 0 /* Весь постоянный алгоритм и библиотеки исполняются из Flash A. */ .cinit : > FLASHA, PAGE = 0 /* Таблицы ROM-модели подготавливают данные загрузчика до main. */ .pinit : > FLASHA, PAGE = 0 /* Сохраняет стандартный порядок C runtime, если компилятор создаст эту секцию. */ diff --git a/c/protocan-boot/projects/ccs/f28335/flash_update.c b/c/protocan-boot/projects/ccs/f28335/flash_update.c new file mode 100644 index 0000000..aae9388 --- /dev/null +++ b/c/protocan-boot/projects/ccs/f28335/flash_update.c @@ -0,0 +1,33 @@ +#include "DSP2833x_Device.h" /* Предоставляет реальные типы C28x, частоту SYSCLKOUT, CSM и маскирование IRQ. */ +#include "../../../ports/tms320f28335/f28335_flash_update.h" /* Подключает проверяемый алгоритм staging SCI8 и аппаратный контракт записи. */ +#include "Flash2833x_API_Library.h" /* Использует официальный TI Flash API v2.10 для F28335, а не запись Flash обычным указателем. */ +#include /* Копирует код и таблицы TI API из постоянного сектора A во внутреннюю RAM до операции Flash. */ +extern Uint16 FlashApiLoadStart, FlashApiLoadEnd, FlashApiRunStart; /* Символы LOAD/RUN задаёт linker в адресуемых словах CPU. */ +static FLASH_ST flash_status; /* Диагностика TI хранится во внутренней RAM во время стирания и программирования. */ +static bool erase_flash(void *user) /* Стирает только область приложения, сохраняя постоянный загрузчик. */ +{ /* Даже IRQ из сектора A запрещены, поскольку F28335 имеет общий банк Flash для A–H. */ + Uint16 saved, status; (void)user; /* Сохраняет прежнее состояние IRQ и точный результат TI API. */ + saved = __disable_interrupts(); status = Flash_Erase(0x00FEU, &flash_status); __restore_interrupts(saved); /* Бит 0 — сектор A; маска FE выбирает только B–H согласно TI API v2.10. */ + return status == STATUS_SUCCESS; /* Ошибка erase запрещает любую последующую публикацию FAB1. */ +} /* После возврата TI API банк Flash снова доступен коду постоянного загрузчика. */ +static bool program_flash(void *user, uint32_t address, uint16_t *data, unsigned int words) /* Записывает и проверяет одну ограниченную порцию Flash. */ +{ /* Аппаратный слой повторно защищает сектор A независимо от валидатора SCI8. */ + Uint16 saved, status; (void)user; /* Результат API и маска IRQ не размещаются в обновляемой Flash. */ + if (!words || address < F28335_FLASH_APP_START || address >= F28335_FLASH_APP_STOP || words > F28335_FLASH_APP_STOP - address) return false; /* Не допускает даже частичного выхода за B–H, включая CSM и аппаратный boot. */ + saved = __disable_interrupts(); status = Flash_Program((Uint16 *)address, data, words, &flash_status); /* Во время программирования процессор выполняет только скопированный в RAM TI API. */ + if (!status) status = Flash_Verify((Uint16 *)address, data, words, &flash_status); /* Проверяет фактические слова до подтверждения этой порции. */ + __restore_interrupts(saved); return status == STATUS_SUCCESS; /* Возвращает исходное состояние IRQ даже при отказе записи или readback. */ +} /* Общий writer опубликует FAB1 лишь после успешной записи остальных порций. */ +static uint16_t read_flash(void *user, uint32_t address) /* Читает физическую Flash для итоговой проверки FAB1/CRC. */ +{ /* Этот callback вызывается только после окончания аппаратных операций TI API. */ + (void)user; return *(volatile Uint16 *)address; /* Адрес уже ограничен общим валидатором диапазоном приложения B–H. */ +} /* Программирование не подменяется чтением staging EEPROM. */ +bool F28335Boot_FlashProgram(f28335_flash_update_t *s) /* Единственная точка фактической записи внутренней Flash из CAN COMMIT. */ +{ /* Постоянный загрузчик может вернуться к CAN recovery при любой ошибке. */ + f28335_flash_ops_t ops; /* Аппаратные операции передаются проверяемому транспортно-независимому writer. */ + if (CsmRegs.CSMSCR.bit.SECURE) return false; /* Защищённый CSM запрещает стирание до любого вызова TI API. */ + memcpy(&FlashApiRunStart, &FlashApiLoadStart, &FlashApiLoadEnd - &FlashApiLoadStart); /* API и его константы должны быть в RAM даже при сохранении сектора A. */ + Flash_CPUScaleFactor = (Uint32)(1048576.0L * (200.0L * (long double)SYSCLKOUT / 1000000000.0L)); /* Формула TI IQ20 использует реальную частоту SYSCLKOUT в герцах. */ + Flash_CallbackPtr = 0; ops.erase = erase_flash; ops.program = program_flash; ops.read = read_flash; ops.user = 0; /* Исключает callback во Flash во время занятости общего банка A–H. */ + return f28335_flash_update_program(s, &ops); /* Итоговый ACK COMMIT появляется только после CRC физической Flash; REBOOT остаётся отдельной командой клиента. */ +} /* Постоянный boot, handoff и EEPROM-параметры не программируются. */ diff --git a/c/protocan-boot/src/pcan_boot.c b/c/protocan-boot/src/pcan_boot.c index e1d3945..607fd63 100644 --- a/c/protocan-boot/src/pcan_boot.c +++ b/c/protocan-boot/src/pcan_boot.c @@ -216,7 +216,7 @@ static bool process_control(pcan_boot_t *boot, uint16_t body, pcan_boot_byte_t command = (pcan_boot_byte_t)(body & 0xFFU); if (dlc != ((command == PCAN_BOOT_CMD_BEGIN_IMAGE || command == PCAN_BOOT_CMD_BEGIN_COMPAT) ? 8U : - (command == PCAN_BOOT_CMD_DEVICE_NAME || command == PCAN_BOOT_CMD_MEMORY_INFO) ? 1U : 0U)) { + (command == PCAN_BOOT_CMD_DEVICE_NAME || command == PCAN_BOOT_CMD_MEMORY_INFO || command == PCAN_BOOT_CMD_SELECT_MEMORY) ? 1U : 0U)) { /* Выбор памяти имеет ровно один октет и не может читать отсутствующий payload. */ return send_status(boot, command, PCAN_BOOT_STATUS_INVALID_SIZE); } if (command == PCAN_BOOT_CMD_PING) { @@ -269,6 +269,13 @@ static bool process_control(pcan_boot_t *boot, uint16_t body, boot->last_activity_ms=boot->now_ms; switch (command) { + case PCAN_BOOT_CMD_SELECT_MEMORY: { /* Явное назначение Flash не меняет поведение прежних клиентов без этой команды. */ + pcan_boot_status_t status; /* Точный отказ порта передаётся клиенту без подмены успешным ACK. */ + if (boot->state != PCAN_BOOT_STATE_METADATA || boot->have_image || boot->have_compat) return send_status(boot, command, PCAN_BOOT_STATUS_INVALID_STATE); /* После начала метаданных, приёма либо проверки запрещает менять цель записи. */ + if (!boot->port.select_memory) return send_status(boot, command, PCAN_BOOT_STATUS_INVALID_COMMAND); /* Старый загрузчик отклоняет новую цель до ERASE и передачи образа. */ + status = boot->port.select_memory(boot->port_user, data[0], &boot->config); /* Порт проверяет поддержку выбранной физической памяти и задаёт её режим. */ + return send_status(boot, command, status); /* Ответ защищён исходной адресацией и текущей ненулевой сессией. */ + } /* Остальные команды используют выбранную конфигурацию слотов. */ case PCAN_BOOT_CMD_BEGIN_IMAGE: if(boot->state==PCAN_BOOT_STATE_READY_TO_ERASE && boot->manifest.image_size==get_u32(data) && boot->manifest.image_crc32==get_u32(data+4)) return send_status(boot,command,PCAN_BOOT_STATUS_OK); diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 39a83b5..e645047 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -48,6 +48,7 @@ set(SETPROTOCOL_LEGACY_SOURCES set(SETPROTOCOL_SOURCES src/deif_xdi.c # Общее C99-ядро DEIF для всех платформ. src/set_invertor_gas.c # Подключает две проекции одной карты STM Invertor: GAS 0x3 и масочный GAS SLOT 0x4. + src/set_deif_gauge.c src/set_regmap.c src/set_emu_ump.c src/set_emu_tms.c diff --git a/c/set-protocol/README.md b/c/set-protocol/README.md index 0320214..5f4a3da 100644 --- a/c/set-protocol/README.md +++ b/c/set-protocol/README.md @@ -11,6 +11,16 @@ `include/balsam167_eeprom_layout.h`; PEEK, CRC и порядок байтов реализованы в `src/periph28335.c`. Проверка полного диапазона — `test_periph28335_eeprom`. +Декодер `balsam_can` знает служебные слова heartbeat периферии Бальзам-167: +`0x0100` — счётчик секунд по модулю 65536, `0x0101` — аппаратный Mode, +`0x0102` — код типа платы Desk. Назначения соответствуют `CAN_HEARTBEAT_ADDR` +и ветке `CAN_send` в `Balsam_167_periph/Source/Internal/ecan.c`. +Python-адаптер `set_devices.balsam_can` выделяет ответ с адресом `0x0100` +и маской `111` как `BALZAM Heartbeat`, сохраняя исходные регистры и маршрут. +Пример: `0x00BA0017`, `00 08 00 04 E1 00 00 5A` — heartbeat задатчика, +90 секунд, Mode=8, Desk=4. Счётчик отображается без знака, в том числе +при значениях `0x8000–0xFFFF`; обычные технологические int16 сохраняют знак. + Универсальные сервисы эмуляторов: [адресное пространство, API и порт новой платы](docs/EMULATOR_SERVICES.md). `set_regmap` объединяет УМП, 2812, IGBT и DAC за общим API для RTU, CAN и других оболочек. diff --git a/c/set-protocol/docs/EMULATOR_SERVICES.md b/c/set-protocol/docs/EMULATOR_SERVICES.md index cbdec5d..cf934d8 100644 --- a/c/set-protocol/docs/EMULATOR_SERVICES.md +++ b/c/set-protocol/docs/EMULATOR_SERVICES.md @@ -1,5 +1,31 @@ # Универсальное адресное пространство эмуляторов +## Подключение панелей к конкретному MCU + +Каталог `python/set_devices/mcu_profiles.py` связывает платы с общими сервисами, +моделью `DEVICE_INFO`, настройками подключения и аппаратным таймером. +`python/set_devices/qt_ports/emulator_workspace.py` принимает готовые панели +потребителя по ключам `settings`, `ump`, `tms2812`, `igbt`, `wavegen` и выбирает +их состав методом `select_mcu`. Общие алгоритмы УМП, 2812, IGBT и ЦАП остаются +в описанном ниже C-ядре; новая панель не создаёт вторую модель или соединение. + +SETGUI подключает эти инструменты из страниц «Терминал → STM32 → MCU». +Поддержаны эмулятор STM32F407VET6 с TIM6 72 МГц и порт STM32G474RB с TIM6 +160 МГц. Параметры COM хранятся отдельно; расчёт частот ЦАП, подписи Vin +и проверяемая модель SET v2 соответствуют выбранной плате. Профиль MCU +нельзя переключать во время обмена. Сам выбор профиля не отправляет команды. + +Общий автомат обнаружения находится в `python/set_devices/emulator_protocol.py`, +COM-адаптер и клиент IGBT — в `python/set_devices/qt_ports/emulator_serial.py` +и `igbt_client.py`. Они не импортируют SETGUI. Старые пути SETGUI только +реэкспортируют эти реализации для совместимости. + +STM32G474VET6 с прошивкой CANdle имеет отдельный профиль BOOT: приложение +`0x08008000`, размер до 480 КиБ, CAN 500 кбит/с, Type 7 / Device 15 / +Product 0, либо USART1 PA9/PA10 на 512000 бод, 8N1. Это другая прошивка, +чем эмулятор STM32G474RB. Профиль BOOT готовит форму обновления и проверяет +векторы приложения до начала CAN-передачи. + Общий код находится в `templates/c/set-protocol`. Прошивка платы задаёт память, GPIO и аппаратные callbacks; протокол и модели не включают STM32/HAL/USB/ОС, не выделяют heap и не читают системное время. Все вызовы одного экземпляра diff --git a/c/set-protocol/include/set_deif_gauge.h b/c/set-protocol/include/set_deif_gauge.h new file mode 100644 index 0000000..d6d9a4e --- /dev/null +++ b/c/set-protocol/include/set_deif_gauge.h @@ -0,0 +1,25 @@ +#ifndef SET_DEIF_GAUGE_H /* Переносимая геометрия прибора подключается один раз. */ +#define SET_DEIF_GAUGE_H /* Макет повторяет прибор SETGUI в логических пикселях 600×510. */ +#include /* Цвет передаётся как RGB888 независимо от framebuffer и Qt. */ +#include "pcan_abi.h" /* Общий макрос экспорта делает графику доступной также из shared-библиотеки. */ +#ifdef __cplusplus /* Qt/C++ использует те же немодифицированные имена функций C. */ +extern "C" { /* Открывает переносимый ABI без привязки к конкретному GUI. */ +#endif /* Начало совместимости с C++. */ +typedef struct { /* Снимок содержит физические значения, а не wire-байты CAN. */ + double value[6], angle, command[3]; /* Порядок value совпадает с deif_xdi.h; command: POWER%, RPM%, градусы. */ + unsigned valid, angle_kind, commands, role, node, source; /* Маска свежести, тип угла, черновики и подписи роли/узлов; role=2 обозначает MASTER, остальные роли показывают принятые данные. */ +} setp_deif_gauge_values; /* Выбор свежести и фактического угла выполняет общее протокольное ядро. */ +typedef struct { /* Потребитель преобразует логические пиксели в свой холст. */ + void *user; /* Непрозрачный контекст рисующего порта. */ + void (*line)(void *,double,double,double,double,uint32_t,double); /* Отрезок x1/y1/x2/y2, RGB888 и толщина в пикселях эталона; порт сохраняет сглаживание при масштабировании. */ + void (*rect)(void *,double,double,double,double,uint32_t); /* Прямоугольник x/y/w/h; ненулевой старший байт задаёт alpha, RGB888 без старшего байта означает непрозрачный цвет. */ + void (*text)(void *,double,double,double,double,const char *,uint32_t,double,unsigned); /* UTF-8, RGB888, размер Qt в пунктах; style: 1 bold, 2 stretch80%, 4 left, 8 right. */ + void (*triangle)(void *,const double *,uint32_t); /* Три пары логических координат задают заполненные указатели движителя без копирования геометрии в порт. */ + void (*azimuth)(void *,unsigned); /* Значение 1 включает одинаковый масштаб X/Y вокруг центра компаса; 0 возвращает растяжение остальных элементов макета. */ +} setp_deif_gauge_painter; /* Ни Qt, ни Linux, ни выделение памяти не входят в контракт. */ +PCAN_ABI_API void setp_deif_gauge_draw(const setp_deif_gauge_values *v,const setp_deif_gauge_painter *p); /* Рисует шкалы и движитель; ноль азимута направлен вверх. */ +PCAN_ABI_API int setp_deif_gauge_hit(double x,double y,double *value); /* Возвращает 1=POWER%, 2=RPM%, 3=угол либо 0 вне активных зон. */ +#ifdef __cplusplus /* Закрывает единый ABI при включении заголовка из Qt/C++. */ +} /* Конец extern C. */ +#endif /* Чистая C99-сборка не получает лишних конструкций C++. */ +#endif /* Конец общего интерфейса графики DEIF. */ diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index ed91f87..ded4ca3 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -31,7 +31,8 @@ enum set_plot_operation { PCAN_ABI_API uint32_t set_plot_abi_version(void); /** Evaluates one operation. Returns output count, or 0 for invalid arguments. * TRANSFORM rejects malformed viewports; invalid gesture values return the - * unchanged viewport. Zoom is restricted to 1..128, with focus anchoring. + * unchanged viewport. Zoom is restricted to 1..10000000 on X and 1..128 on Y, + * with focus anchoring. * Screen fractions increase downwards; set inverted=1 for the Y value axis. */ PCAN_ABI_API size_t set_plot_eval(uint32_t operation, const double *input, diff --git a/c/set-protocol/include/set_signal.h b/c/set-protocol/include/set_signal.h index 5626c97..e302b52 100644 --- a/c/set-protocol/include/set_signal.h +++ b/c/set-protocol/include/set_signal.h @@ -13,6 +13,7 @@ extern "C" { #endif enum { SET_SIGNAL_POLYNOMIAL, SET_SIGNAL_LINEAR, SET_SIGNAL_PCHIP, SET_SIGNAL_SPLINE }; /* Caller-owned workspace: at least 14*count+128 doubles. No heap or globals. + * Bounds: 2..100000 input points, 2..1000000 output points. * Inputs may be unsorted; duplicate times are averaged. No extrapolation. * meta[0..2] = original count, unique count, RMSE at original measurements. * endpoint=1 includes the last time; 0 samples a cyclic period [first,last). @@ -26,6 +27,15 @@ enum { SET_SIGNAL_POLYNOMIAL, SET_SIGNAL_LINEAR, SET_SIGNAL_PCHIP, SET_SIGNAL_SP PCAN_ABI_API int set_signal_reconstruct(const double *x, const double *y, size_t count, int method, unsigned degree, size_t output_count, int endpoint, double *out_x, double *out_y, double *meta, double *work, size_t work_count); +/* Дополнительный решатель получает уникальные нормированные X/Y и пишет degree+1 + * коэффициентов по возрастанию степени для аргумента 2*x-1. Код 0 означает успех, + * 3 — недостаточный ранг, 5 — ошибку памяти/решателя. NULL сохраняет прежний МНК. + * C99-подготовка, интерполяция, RMSE и размещение work одинаковы для всех решателей. */ +typedef int (*set_signal_fit_fn)(const double *, const double *, size_t, unsigned, double *); /* Обратный вызов отделяет необязательный C++-порт от переносимого ядра. */ +PCAN_ABI_API int set_signal_reconstruct_with_fit( /* Расширение ABI не меняет сигнатуру существующей функции для прошивок и GUI. */ + const double *x, const double *y, size_t count, int method, unsigned degree, /* Входные точки и степень имеют те же ограничения, что у исходного API. */ + size_t output_count, int endpoint, double *out_x, double *out_y, /* Результат остаётся в памяти вызывающего и сохраняет выбор конечной точки. */ + double *meta, double *work, size_t work_count, set_signal_fit_fn fit); /* Решатель используется только для полинома; рабочая память сохраняет прежний формат. */ /* Reject out-of-range voltages, never silently clip. vref must be positive. * All input is validated before any output is written. */ PCAN_ABI_API int set_signal_dac12(const double *volts, size_t count, double vref, diff --git a/c/set-protocol/include/set_wavegen.h b/c/set-protocol/include/set_wavegen.h index 7ca876c..da94891 100644 --- a/c/set-protocol/include/set_wavegen.h +++ b/c/set-protocol/include/set_wavegen.h @@ -30,8 +30,12 @@ typedef struct { uint16_t count, received, ready, running, rate_high; uint32_t rate; set_wave_port port; + uint32_t *xy_samples; /* Упакованные пары DHR12RD: X в битах 0…11, Y в 16…27; память предоставляет плата. */ + int (*start_xy)(void *, const uint32_t *, size_t, uint32_t); /* Запускает обе координаты общим таймером и одним DMA без сдвига между каналами. */ + uint16_t channels, selected, received_other; /* Режим 1/2, выбранная координата 0/1 и число отсчётов второй координаты для общего commit. */ } set_wave_state; void set_wave_init(set_wave_state *, uint16_t *, size_t, const set_wave_port *); +void set_wave_enable_xy(set_wave_state *, uint32_t *, int (*)(void *, const uint32_t *, size_t, uint32_t)); /* Подключает необязательный аппаратный порт XY после обычной инициализации. */ /* Atomic sample-block transfer. values=NULL builds a read; otherwise a write. * At most 120 words: request <=249 bytes, response <=245 bytes. */ PCAN_ABI_API size_t set_wave_block_request(unsigned device,unsigned index, diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk index c221a7a..dc60592 100644 --- a/c/set-protocol/ports/android/Android.mk +++ b/c/set-protocol/ports/android/Android.mk @@ -33,6 +33,6 @@ LOCAL_SRC_FILES += ../../src/set_gas_watch.c # Включает общий GAS_W LOCAL_SRC_FILES += ../../src/pcan_gas_slot.c gas_slots_jni.c # Android собирает общий слотный кодек и тонкую JNI-границу без второго алгоритма. LOCAL_SRC_FILES += ../../src/set_invertor_gas.c # Android получает ту же C-реализацию нового профиля без копирования алгоритмов. LOCAL_CFLAGS := -std=c99 -Wall -Wextra -Wpedantic -fvisibility=hidden +LOCAL_SRC_FILES += ../../src/deif_xdi.c set_deif_jni.c # Единственные DEIF-правила компилируются вместе с тонким JNI-портом. LOCAL_LDLIBS := -llog -lm -LOCAL_SRC_FILES += ../../src/deif_xdi.c set_deif_jni.c # Общий DEIF и тонкий JNI без второй реализации. include $(BUILD_SHARED_LIBRARY) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt index 2d9a488..61e1ef2 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt @@ -152,14 +152,17 @@ object BalsamCanProtocol { fun deviceName(device: Int): String = if (NativeSetProtocol.available) { NativeSetProtocol.nativeBalsamDeviceName(device) } else listOf( - "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2", - "УМП 1", "УМП 2", "Двигатель", "ВЭП", "Задатчик", "Узел 10", "Узел 11", + "Трансформатор 1", "Трансформатор 2", "Силовой 1", "Силовой 2", // Имена узлов 1..4 повторяют таблицу адресов 167 и общее C-ядро. + "УМП 1", "УМП 2", "Двигатель", "Задатчик", "ВЭП", "ПМУ", "Узел 11", // Узлы 5..10 соответствуют таблице, включая ПМУ на паре 0xBA0009/0xBA0019; узел 11 остаётся служебным. "Узел 12", "Терминал", ).getOrElse(device - 1) { "Неизвестный узел" } fun registerName(device: Int, address: Int): String = if (NativeSetProtocol.available) NativeSetProtocol.nativeBalsamRegisterName(device, address) else when { + address == 0x0100 -> "Heartbeat: счётчик, с" // CAN_HEARTBEAT_ADDR из ecan.c содержит беззнаковый секундный счётчик по модулю 65536. + address == 0x0101 -> "Heartbeat: Mode" // Второе слово служебного пакета передаёт аппаратный номер Mode платы. + address == 0x0102 -> "Heartbeat: тип платы (Desk)" // Третье слово передаёт Desk, чтобы Android без JNI использовал названия общего C-ядра. device in 1..2 && address in 0x18..0x2B -> "Показания T° ${address - 0x17}" device in 3..4 && address in 0x18..0x27 -> "Показания T° ${address - 0x17}" device == 7 && address in 0x18..0x1F -> "Показания T° ${address - 0x17}" diff --git a/c/set-protocol/ports/stm32g474-hope/board.c b/c/set-protocol/ports/stm32g474-hope/board.c index e053dbf..cf3668f 100644 --- a/c/set-protocol/ports/stm32g474-hope/board.c +++ b/c/set-protocol/ports/stm32g474-hope/board.c @@ -2,7 +2,7 @@ #include "board_config.h" /* Выбирает размещение Flash, USART1 и разрешение физических выводов IGBT. */ #include "transport.h" /* Объявляет запуск общего UART/FDCAN-транспорта после настройки GPIO. */ #include "emulator.h" /* Подключает единый шаг моделей УМП, 2812, IGBT и генератора. */ -#include "igbt_realtime_port.h" /* Объявляет аппаратный IGBT версии 4 с наносекундными compare. */ +#include "igbt_realtime_port.h" /* Объявляет аппаратный IGBT версии 4 без миллисекундного обслуживания фронтов. */ #include "emulator_port.h" /* Даёт доступ к существующей карте сервера до первого транспортного запроса. */ #include "igbt_emulator.h" /* Передаёт физическую маску Vin и получает маску ответов Vstat. */ #include "stm32g4xx_hal.h" /* Предоставляет HAL RCC/PWR/GPIO и определения регистров Cortex-M4. */ @@ -11,8 +11,8 @@ uint32_t SystemCoreClock = 16000000u; /* После reset HSI16 работает const uint8_t AHBPrescTable[16] = {0,0,0,0,0,0,0,0,1,2,3,4,6,7,8,9}; /* CMSIS-кодирует делители HPRE как степени двойки по RM0440. */ const uint8_t APBPrescTable[8] = {0,0,0,0,1,2,3,4}; /* HAL использует кодировку PPRE для вычисления частоты периферии G4. */ static uint8_t usb_clock_ready; /* До настройки HSI48/CRS предотвращает преждевременный запуск USB. */ -#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Не занимает выводы FLASH/CAN2/CAN3 на неподтверждённой ревизии HOPE по умолчанию. */ -static const uint16_t input_pins[8] = {GPIO_PIN_0,GPIO_PIN_1,GPIO_PIN_2,GPIO_PIN_10,GPIO_PIN_11,GPIO_PIN_12,GPIO_PIN_13,GPIO_PIN_14}; /* Ручной профиль Vin PB0/1/2/10..14 совпадает с прежним портом G474. */ +#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Штатный профиль использует свободные линии J5/J6 по схеме HOPE от 14.04.2025, сохраняя все три CAN. */ +static const uint16_t input_pins[8] = {GPIO_PIN_0,GPIO_PIN_1,GPIO_PIN_2,GPIO_PIN_10,GPIO_PIN_11,GPIO_PIN_6,GPIO_PIN_7,GPIO_PIN_14}; /* Vin0…7: J5-13/14/15/22/23, J6-7/8, J5-24; PB6/PB7 заменяют занятые CAN2 линии PB12/PB13. */ #endif /* Без физического порта все команды IGBT остаются доступны через общую карту регистров. */ void SystemInit(void) /* Startup вызывает функцию до инициализации глобальных переменных C runtime. */ { /* До runtime доступны только константы сборки и регистры, поэтому HAL здесь не вызывается. */ @@ -68,7 +68,7 @@ static void peripheral_clock(void) /* Создаёт одинаковую час if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_4) != HAL_OK) board_fatal(3); /* Четыре wait-state соответствуют 160 МГц в Boost Range1 по RM0440. */ peripheral.PeriphClockSelection = RCC_PERIPHCLK_USB | RCC_PERIPHCLK_FDCAN | RCC_PERIPHCLK_USART1; /* Выбирает только реальные блоки HOPE, исключая USART2 профиля Nucleo. */ peripheral.UsbClockSelection = RCC_USBCLKSOURCE_HSI48; /* Подаёт независимые 48 МГц на USB FS. */ - peripheral.FdcanClockSelection = RCC_FDCANCLKSOURCE_PCLK1; /* Общий CAN-порт рассчитывает 250 кбит/с из фактических 80 МГц PCLK1. */ + peripheral.FdcanClockSelection = RCC_FDCANCLKSOURCE_PCLK1; /* Подаёт PCLK1 80 МГц на FDCAN; общий порт получает 125000 бит/с из EMU_CAN_BITRATE при 16 квантах на бит и делителе 40. */ peripheral.Usart1ClockSelection = RCC_USART1CLKSOURCE_PCLK2; /* HAL вычисляет делитель USART1 из 160 МГц APB2. */ if (HAL_RCCEx_PeriphCLKConfig(&peripheral) != HAL_OK) board_fatal(3); /* Ошибка выбора kernel clock обнаруживается до включения транспорта. */ __HAL_RCC_CRS_CLK_ENABLE(); /* Разрешает блок автоматической подстройки HSI48 по USB SOF. */ @@ -91,9 +91,10 @@ void board_init(void) /* Вызывается после создания общ HAL_GPIO_Init(GPIOA, &gpio); /* Применяет HAL-настройку UART к порту A одним вызовом. */ gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1; gpio.Alternate = GPIO_AF9_FDCAN1; /* PD0 RX и PD1 TX соединены с U6 TJA1051T/3; PB8 на этой плате занят BOOT0. */ HAL_GPIO_Init(GPIOD, &gpio); /* Подключает FDCAN1 к штатным CAN1L/CAN1H разъёма J4 по схеме от 14.04.2025. */ -#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Физический профиль включается вручную после проверки занятых выводов FLASH и других разъёмов. */ +#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Подключает физический IGBT через свободные выводы, не затрагивая CAN1/2/3, USB, UART, DAC и дисплей. */ __HAL_RCC_GPIOC_CLK_ENABLE(); /* Порт C нужен только для восьми физических выходов Vstat. */ - gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14; /* Сохраняет отключённый ручной профиль Vin; PB12/PB13 соединены с CAN2 и требуют изменения внешних соединений. */ + gpio.Pin = 0; /* Начинает общую маску Vin с пустого набора, чтобы инициализация и опрос использовали одну таблицу каналов. */ + for (unsigned i = 0; i < 8; ++i) gpio.Pin |= input_pins[i]; /* Объединяет восемь свободных выводов GPIOB; индексы массива задают биты Vin0…7. */ gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLDOWN; HAL_GPIO_Init(GPIOB, &gpio); /* Задаёт нулевой Vin на неподключённых входах выбранного ручного профиля. */ gpio.Pin = 0xFFu; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; /* Биты 0..7 порта C становятся восемью ответами Vstat IGBT. */ HAL_GPIO_WritePin(GPIOC, 0xFFu, GPIO_PIN_RESET); HAL_GPIO_Init(GPIOC, &gpio); /* Сначала записывает нули в ODR, исключая импульс при включении выходов. */ @@ -102,23 +103,23 @@ void board_init(void) /* Вызывается после создания общ gpio.Pin = SERIAL_DE_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; /* Настраивает выбранный внешний вывод DE, не меняя UART TX/RX. */ HAL_GPIO_WritePin(SERIAL_DE_PORT, SERIAL_DE_PIN, GPIO_PIN_RESET); HAL_GPIO_Init(SERIAL_DE_PORT, &gpio); /* Начинает в режиме приёма с выключенным передатчиком RS-485. */ #endif /* Штатный TTL UART не занимает вывод DE. */ -#if EMU_IGBT_REALTIME /* Регистрирует аппаратный сервис только в новой сборке HOPE. */ - hope_igbt_realtime_init(emu_server_state()); /* Подменяет окно 0x1400 до первого запроса, сохраняя остальные модели и транспорты. */ -#endif /* Прежний профиль продолжает использовать миллисекундный IGBT версии 3. */ +#if EMU_IGBT_REALTIME /* Регистрирует наносекундный сервис только для явно выбранной аппаратной сборки HOPE. */ + hope_igbt_realtime_init(emu_server_state()); /* Подменяет окно 0x1400 до запуска UART/CAN, сохраняя остальные сервисы сервера. */ +#endif /* Прежний профиль продолжает использовать окно миллисекундной модели версии 3. */ transport_init(); /* Запускает HAL UART IRQ и Classic CAN после полной настройки GPIO/clock. */ } /* Завершает аппаратный запуск HOPE, не запуская генерацию DAC без команды. */ uint8_t board_usb_ready(void) { return usb_clock_ready; } /* Возвращает main готовность источника USB, общую для обоих вариантов Flash. */ void board_tick(void) /* Выполняется главным циклом один раз на каждую обслуженную миллисекунду. */ { /* Протокольная инъекция IGBT работает и при отключённом физическом GPIO. */ -#if EMU_IGBT_REALTIME /* Выходные сроки обслуживаются аппаратными таймерами независимо от миллисекундного цикла. */ - emu_tick(); /* Сохраняет штатное время моделей и применения выбранного режима MCU. */ - hope_igbt_realtime_poll(); /* Останавливает аппаратный IGBT в том же шаге, в котором выбран другой режим. */ -#else /* Ветка совместимости сохраняет прежние миллисекундные GPIO. */ -#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Считывает восемь физических Vin только для явно включённого ручного профиля. */ +#if EMU_IGBT_REALTIME /* Аппаратные таймеры обслуживают все фронты независимо от этого шага. */ + emu_tick(); /* Сохраняет миллисекундное время УМП, 2812, DAC, DEIF и применения выбора режима. */ + hope_igbt_realtime_poll(); /* Выключает аппаратные выходы в том же шаге, в котором выбран другой режим платы. */ +#else /* Сборка совместимости сохраняет прежний программный IGBT с шагом 1 мс. */ +#if EMU_IGBT_GPIO_ENABLE && !EMU_IGBT_REALTIME /* Считывает восемь физических Vin штатного профиля HOPE с PB6/PB7 вместо CAN2. */ uint8_t inputs = 0, outputs; unsigned i; /* Накопленная маска привязана к порядку input_pins, а не к номерам PB. */ for (i = 0; i < 8; ++i) if (HAL_GPIO_ReadPin(GPIOB, input_pins[i]) == GPIO_PIN_SET) inputs |= (uint8_t)(1u << i); /* Переводит восемь уровней в логические Vin0..Vin7. */ igbt_emu_set_tx(inputs); /* Передаёт физическую маску общей модели, сохраняя её выбор источника через регистр 0x140C. */ -#else /* Штатный профиль HOPE не использует неподтверждённые внешние дискретные линии. */ +#else /* Явное отключение GPIO оставляет модель доступной через регистровый источник TX. */ igbt_emu_set_tx(0); /* При выборе GPIO задаёт нулевой Vin; регистровый источник остаётся независимым. */ #endif /* Далее все модели продвигаются одинаково независимо от наличия дискретного порта. */ emu_tick(); /* Продвигает выбранную модель и общие отложенные настройки ровно на 1 мс. */ @@ -127,5 +128,5 @@ void board_tick(void) /* Выполняется главным циклом од HAL_GPIO_WritePin(GPIOC, (uint16_t)(~outputs & 0xFFu), GPIO_PIN_RESET); /* Сбрасывает только неактивные PC0..PC7 без изменения остальных выводов C. */ HAL_GPIO_WritePin(GPIOC, outputs, GPIO_PIN_SET); /* Устанавливает активные ответы Vstat текущего шага модели. */ #endif /* Завершает опциональное обновление физических ответов. */ -#endif /* Завершает выбор аппаратного либо прежнего логического IGBT. */ +#endif /* Завершает выбор между аппаратным IGBT и прежней миллисекундной моделью. */ } /* Завершает один миллисекундный шаг без обработки моделей из прерываний. */ diff --git a/c/set-protocol/ports/stm32g474-hope/board_config.template.h b/c/set-protocol/ports/stm32g474-hope/board_config.template.h index 5729ffd..1230f6c 100644 --- a/c/set-protocol/ports/stm32g474-hope/board_config.template.h +++ b/c/set-protocol/ports/stm32g474-hope/board_config.template.h @@ -1,7 +1,7 @@ #ifndef HOPE_BOARD_CONFIG_H /* Защищает единый профиль платы HOPE от повторного включения. */ -#ifndef EMU_IGBT_REALTIME /* Разрешает явную сборку прежней логической модели для старого GUI. */ -#define EMU_IGBT_REALTIME 1 /* Штатный HOPE использует HRTIM D/E/F и TIM2/3/4/5/15 с окном IGBT версии 4 в наносекундах. */ -#endif /* Сохраняет явное переопределение параметром компилятора. */ +#ifndef EMU_IGBT_REALTIME /* Позволяет явно собрать прежнюю миллисекундную модель для совместимости со старым GUI. */ +#define EMU_IGBT_REALTIME 1 /* Штатный HOPE использует аппаратные HRTIM D/E/F и TIM2/3/4/5/15 с окном IGBT версии 4 в наносекундах. */ +#endif /* Сохраняет явное переопределение аппаратного режима параметром сборки. */ #define HOPE_BOARD_CONFIG_H /* Используется всеми аппаратными адаптерами и общей композицией моделей. */ #define EMU_USB_SET_V2 1 /* Нативный USB MCU переносит SET v2; UART сохраняет RTU и SETGUI v1/GAS. */ #define USB_CDC_IRQn USB_LP_IRQn /* G474 обслуживает USB FS через низкоприоритетный вектор USB_LP. */ @@ -13,7 +13,7 @@ #define EMU_TMS_ADDRESS 1u /* Отдельный адрес 2812 позволяет отличать CMD_STD от команд настройки эмулятора. */ #define EMU_CAN_MODE 5u /* Общий протокол эмулятора использует extended RX 0xBA0004 и TX 0xBA0024. */ #define EMU_UART_BAUD 115200u /* Начальная скорость приложения в бодах; загрузчик CANdle отдельно использует 512000. */ -#define EMU_CAN_BITRATE 250000u /* Начальная скорость приложения в бит/с; CAN-загрузчик отдельно использует 500000. */ +#define EMU_CAN_BITRATE 125000u /* Скорость CAN приложения HOPE и DEIF по требованию пользователя: 125000 бит/с; при PCLK1 80 МГц и 16 квантах на бит делитель равен 40. */ #define EMU_FLAGS_DEFAULT 1u /* После сброса разрешает синтетический сценарий УМП; модель по умолчанию остаётся УМП. */ #define EMU_UART_INSTANCE USART1 /* Использует USART1 PA9/PA10, подключённый через JP2 к CH340N по схеме HOPE от 14.04.2025. */ #define EMU_UART_IRQn USART1_IRQn /* Направляет RX/TX HAL в аппаратный вектор USART1 вместо USART2 Nucleo. */ @@ -28,9 +28,9 @@ #define EMU_HSE_HZ 8000000u /* Номинал JT1 равен 8 МГц по схеме; HAL и PLL используют одну частоту в герцах. */ #endif /* Сохраняет явно переданную частоту внешнего источника. */ #define EMU_HSE_BYPASS 0 /* Внешний режим рассчитан на кварц; для готового генератора нужно отдельно выбрать bypass. */ -#ifndef EMU_IGBT_GPIO_ENABLE /* Ручной профиль Vin требует устранить подтверждённое схемой пересечение PB12/PB13 с CAN2. */ -#define EMU_IGBT_GPIO_ENABLE 0 /* Модель IGBT доступна через регистры; штатные линии CAN2 не становятся дискретными Vin. */ -#endif /* Позволяет включить физические Vin/Vstat параметром компилятора. */ +#ifndef EMU_IGBT_GPIO_ENABLE /* Позволяет отключить физический IGBT параметром компилятора при использовании только регистрового TX. */ +#define EMU_IGBT_GPIO_ENABLE 1 /* Включает свободные Vin PB0/1/2/10/11/6/7/14 и Vstat PC0…PC7; CAN2 сохраняет PB12/PB13 по схеме HOPE. */ +#endif /* Сохраняет явный выбор потребителя поверх штатного физического профиля. */ #ifndef EMU_FLASH_BASE /* Адрес задаётся одинаково в профиле Keil и его scatter-файле. */ #define EMU_FLASH_BASE 0x08008000u /* Резервирует первые 32 КиБ под найденный CAN/UART-загрузчик CANdle. */ #endif /* Самостоятельный профиль переопределяет адрес на 0x08000000. */ diff --git a/c/set-protocol/src/balsam_can.c b/c/set-protocol/src/balsam_can.c index a8644bd..0e46c42 100644 --- a/c/set-protocol/src/balsam_can.c +++ b/c/set-protocol/src/balsam_can.c @@ -61,7 +61,7 @@ int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, relative = can_id - BALSAM_CAN_BASE_ID; output->direction = relative >= BALSAM_CAN_DATA_OFFSET ? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE; - output->device = (uint8_t)((relative & 0x0FU) + 1U); + output->device = (uint8_t)((relative & 0x0FU) + 1U); /* В ecan.c перед сборкой CAN ID выполняется DevNum--; маска 0x0F выделяет индекс 0..12, а +1 восстанавливает номер Mode. */ if (can_id == 0x00745009UL || can_id == 0x00745019UL) { output->device = 10U; output->direction = can_id == 0x00745019UL @@ -80,9 +80,9 @@ int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, const char *balsam_can_device_name(uint8_t device) { static const char *const names[BALSAM_CAN_NODE_COUNT] = { - "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", - "Силовой блок 2", "УМП 1", "УМП 2", "Двигатель", "ВЭП", - "Задатчик", "Пульт (МПУ)", "Узел 11", "Узел 12", "Терминал" + "Трансформатор 1", "Трансформатор 2", "Силовой 1", /* Первые три узла соответствуют строкам 1..3 предоставленной таблицы адресов 167. */ + "Силовой 2", "УМП 1", "УМП 2", "Двигатель", "Задатчик", /* Строки 4..8 таблицы; VPU1_CAN_DEVICE=8 соответствует задатчику, а не ВЭП. */ + "ВЭП", "ПМУ", "Узел 11", "Узел 12", "Терминал" /* Строка 9 — ВЭП, пара 0xBA0009/0xBA0019 — ПМУ; служебные узлы 11..13 сохраняют прежние назначения. */ }; return (device >= 1U && device <= BALSAM_CAN_NODE_COUNT) ? names[device - 1U] : "Неизвестный узел"; @@ -91,6 +91,13 @@ const char *balsam_can_device_name(uint8_t device) size_t balsam_can_register_name(uint8_t device, uint16_t address, char *output, size_t output_size) { + if (address == 0x0100U) /* CAN_HEARTBEAT_ADDR в ecan.c периферии 167 задаёт служебный адрес вне технологической карты R0..R127. */ + return copy_name("Heartbeat: счётчик, с", output, output_size); /* Первое слово hiword содержит секунды после запуска по модулю 65536. */ + if (address == 0x0101U) /* Следующее слово heartbeat соответствует старшим 16 битам loword в CAN_send. */ + return copy_name("Heartbeat: Mode", output, output_size); /* Прошивка передаёт аппаратный Mode без уменьшения на единицу, применяемого только к CAN ID. */ + if (address == 0x0102U) /* Третье слово heartbeat соответствует младшим 16 битам loword. */ + return copy_name("Heartbeat: тип платы (Desk)", output, output_size); /* Значение Desk задаёт тип платы и отличается от номера Mode и номера CAN-узла. */ + if ((device == 1U || device == 2U) && address < 20U) return indexed_name("Диагностика T°", address + 1U, output, output_size); if ((device == 1U || device == 2U) && address >= 0x18U && address <= 0x2BU) @@ -139,7 +146,7 @@ size_t balsam_can_register_name(uint8_t device, uint16_t address, if (device == 7U && address >= 0x18U && address <= 0x1FU) return indexed_name("Показания T°", address - 0x17U, output, output_size); - if (device == 8U) { + if (device == 9U) { /* Карта напряжений принадлежит ВЭП с adr_SHKF=9; задатчик с номером 8 использует другую карту. */ static const char *const vep_names[] = { "Диагностика 380В Ф1", "Диагностика 380В Ф2", "Диагностика 31В Ф1", "Диагностика 31В Ф2", @@ -164,15 +171,15 @@ size_t balsam_can_register_name(uint8_t device, uint16_t address, return copy_name("Дискретные датчики (14 бит)", output, output_size); } - if (device == 9U && address == 0U) + if (device == 8U && address == 0U) /* У VPU1_CAN_DEVICE=8 слово 0 передаёт обороты ГЭД, а не диагностику ВЭП. */ return copy_name("Обороты ГЭД, об/мин", output, output_size); - if (device == 9U && address == 1U) + if (device == 8U && address == 1U) /* Слово 1 той же платы задатчика содержит обороты гребного вала. */ return copy_name("Обороты ГВ, об/мин", output, output_size); - if (device == 9U && address == 2U) + if (device == 8U && address == 2U) /* ADR_CAN_LAMPS_ON_VPU=2 задаёт регистр первой группы ламп задатчика. */ return copy_name("Лампы (8 бит)", output, output_size); - if (device == 9U && address == 3U) + if (device == 8U && address == 3U) /* ADR_CAN_LAMPS_2_ON_VPU=3 задаёт регистр второй группы индикаторов задатчика. */ return copy_name("Диоды (16 бит)", output, output_size); - if (device == 9U && address == 0x10U) + if (device == 8U && address == 0x10U) /* ADR_CAN_KEY_ON_VPU=16, то есть 0x10, задаёт слово кнопок VPU; адрес не перенумеровывается. */ return copy_name("Кнопки (12 бит)", output, output_size); if (address == 0x16U) diff --git a/c/set-protocol/src/set_deif_gauge.c b/c/set-protocol/src/set_deif_gauge.c new file mode 100644 index 0000000..a0be71c --- /dev/null +++ b/c/set-protocol/src/set_deif_gauge.c @@ -0,0 +1,37 @@ +#include "set_deif_gauge.h" /* Геометрия общая для всех графических портов DEIF. */ +#include /* Тригонометрия связывает компас с физическим азимутом в градусах. */ +#include /* Числа форматируются в ограниченный локальный буфер. */ +static double bounded(double v,double lo,double hi) { return fmax(lo,fmin(hi,v)); } /* Защищает положение риски, не меняя отображаемое число. */ +static double ordinate(double v,double lo) { return 126+(120-v)*270/(120-lo); } /* Шкала SETGUI занимает y=126…396 и заканчивается на 120%. */ +int setp_deif_gauge_hit(double x,double y,double *value) { /* Обратная геометрия используется touch и мышью одинаково. */ + if(!value || !isfinite(x) || !isfinite(y)) return 0; for(unsigned i=0;i<2;++i) { double axis=64+128*i,lo=i?-20:0; if(x>=axis-18 && x<=axis+52 && y>=110 && y<=410) { *value=bounded(120-(y-126)*(120-lo)/270,lo,120); return (int)i+1; } } /* Области шкал и предел реверса −20% совпадают с прибором SETGUI. */ + double dx=x-411,dy=y-264,r=hypot(dx,dy); if(r<12 || r>155) return 0; *value=atan2(dx,-dy)*57.29577951308232; return 3; /* Мёртвая зона 12 пикселей исключает неустойчивый угол в центре; ноль сверху. */ +} /* Возвращает физическое задание без формирования либо передачи CAN-кадров. */ +static void number(const setp_deif_gauge_painter *p,double x,double y,double w,double h,double value,int valid,const char *unit,double size,uint32_t color) { /* В точности сохраняет отдельные числовые подписи оригинального Propulsor. */ + char s[64]; if(valid) snprintf(s,sizeof(s),"%g%s",value,unit); else snprintf(s,sizeof(s),"—"); p->text(p->user,x,y,w,h,s,color,size,0); /* Отсутствие данных обозначается длинным тире; единица выводится только вместе с числом, как в SETGUI. */ +} /* Форматирование не меняет телеметрию и черновики MASTER. */ +static void point(double angle,double x,double y,double scale,double *ox,double *oy) { double a=(angle+90)*0.017453292519943295; *ox=411+(x*cos(a)-y*sin(a))*scale; *oy=264+(x*sin(a)+y*cos(a))*scale; } /* +90° поворачивает исходный направленный влево силуэт SETGUI к нулю сверху компаса. */ +static void segment(const setp_deif_gauge_painter *p,double angle,double x,double y,double xx,double yy,uint32_t color) { double a,b,c,d; point(angle,x-460,y-610,125.0/315,&a,&b); point(angle,xx-460,yy-610,125.0/315,&c,&d); p->line(p->user,a,b,c,d,color,5.3*125/315); } /* Центр исходного контура (460,610), радиус 125/315 и толщина 5,3 повторяют преобразование QPainter оригинала. */ +static void bezier(const setp_deif_gauge_painter *p,double angle,const double *q,uint32_t color) { /* Кубические контуры сохраняют все управляющие точки корпуса и обеих лопастей. */ + double x=q[0],y=q[1]; for(unsigned i=1;i<=40;++i) { double t=i/40.0,u=1-t,xx=u*u*u*q[0]+3*u*u*t*q[2]+3*u*t*t*q[4]+t*t*t*q[6],yy=u*u*u*q[1]+3*u*u*t*q[3]+3*u*t*t*q[5]+t*t*t*q[7]; segment(p,angle,x,y,xx,yy,color); x=xx; y=yy; } /* Сорок сглаженных отрезков на кривую сохраняют форму даже при растяжении на весь HDMI. */ +} /* Дискретизация остаётся общей для framebuffer и будущих других портов. */ +void setp_deif_gauge_draw(const setp_deif_gauge_values *v,const setp_deif_gauge_painter *p) { /* Повторяет полный макет Propulsor SETGUI, сохраняя его геометрию, цвета и порядок слоёв. */ + if(!v || !p || !p->line || !p->rect || !p->text || !p->triangle || !p->azimuth) return; p->rect(p->user,0,0,600,510,0x090c0e); char title[80]; if(v->role==1) snprintf(title,sizeof(title),"SLAVE %u from Node %u",v->node,v->source); else snprintf(title,sizeof(title),"%s",v->role==2?"MASTER":"MONITOR"); p->text(p->user,15,12,570,40,title,0xe9edf4,22,0); /* Заголовок располагается в исходном прямоугольнике; background и основной текст взяты из SETGUI без изменения RGB. */ + double target=v->role==2?v->command[2]:v->value[5]; int has_target=v->role==2?(v->commands&4u)!=0:(v->valid&32u)!=0; p->text(p->user,205,54,95,25,"Задание:",0x8798ad,11,4); char command[48]; if(has_target) snprintf(command,sizeof(command),"%.1f°",target); else snprintf(command,sizeof(command),"—°"); p->text(p->user,300,54,95,25,command,0x8798ad,11,8); /* Сетевое задание остаётся отдельной верхней подписью, а не дополнительной выдуманной стрелкой азимута. */ + for(unsigned i=0;i<2;++i) { /* POWER и RPM используют собственные проценты и абсолютные значения без подмены одним другим. */ + double x=64+128*i,lo=i?-20:0; unsigned f=i?4:3; const double range[4]={lo,0,100,120}; const uint32_t colors[3]={0xae616b,0x657caf,0xb6c799}; /* Точные исходные цвета реверса, рабочего диапазона и перегрузки: красный, синий и зелёный. */ + for(unsigned j=0;j<3;++j) if(range[j+1]>range[j]) p->rect(p->user,x,ordinate(range[j+1],lo),34,ordinate(range[j],lo)-ordinate(range[j+1],lo),colors[j]); /* Цветные зоны присутствуют также при отсутствии данных, затем поверх применяется исходное полупрозрачное затемнение. */ + for(int tick=(int)lo;tick<=120;tick+=20) { double y=ordinate(tick,lo); char s[16]; p->line(p->user,x,y,x+34,y,0x202b3a,1); if(tick==(int)lo || tick==0 || tick==40 || tick==80 || tick==120) { snprintf(s,sizeof(s),"%d",tick); p->text(p->user,x-40,y-12,36,24,s,0xe9edf4,12,0); } } /* Шаг 20%, цвет рисок и расположение опорных чисел полностью соответствуют вертикальным шкалам SETGUI. */ + if(v->valid&(1u<value[f],lo,120),lo); p->line(p->user,x-3,y,x+38,y,0xffffff,4); } else p->rect(p->user,x,126,34,270,0x8210151d); /* Белое показание имеет толщину 4; отсутствие данных перекрывает исходную шкалу цветом (16,21,29,130), сохраняя её оттенки. */ + if(v->commands&(1u<command[i],lo,120),lo); p->line(p->user,x-8,y,x+42,y,0xeeef69,3); } /* Жёлтый черновик MASTER имеет исходную длину и толщину 3 пикселя эталона. */ + number(p,x-35,89,110,30,v->value[f],v->valid&(1u<text(p->user,x-45,410,128,28,i?"RPM":"POWER",0xe9edf4,17,0); number(p,x-52,444,145,30,v->value[i],v->valid&(1u<text(p->user,x-40,478,125,22,i?"rpm":"RAW",0x8798ad,11,0); /* Название, число и единица занимают три отдельные строки исходного макета без сдвига между количеством цифр. */ + } /* Завершает независимые шкалы POWER и RPM. */ + p->azimuth(p->user,1); /* Кольцо, подписи, движитель и указатели образуют круглый узел с одинаковым масштабом X/Y независимо от растяжения остальных шкал. */ + for(unsigned degree=0;degree<360;++degree) { double a=degree*0.017453292519943295,b=(degree+1)*0.017453292519943295; p->line(p->user,411+120*sin(a),264-120*cos(a),411+120*sin(b),264-120*cos(b),degree<180?0x78f033:0xff4935,10); } /* Правая зелёная и левая красная полуокружности толщиной 10 повторяют QPainter drawArc исходного DEIF. */ + for(unsigned degree=0;degree<360;degree+=5) { double a=degree*0.017453292519943295; unsigned major=degree%30==0; double r=125-(major?10:7),rr=125+(major?12:1); p->line(p->user,411+r*sin(a),264-r*cos(a),411+rr*sin(a),264-rr*cos(a),0xe9f3ff,major?2.3:1); if(major) { char label[16]; unsigned value=degree<180?degree:360-degree; snprintf(label,sizeof(label),"%u%s",value,degree%90?"":"°"); p->text(p->user,411+156*sin(a)-26,264-156*cos(a)-16,52,32,label,0xe9f3ff,17,3); } } /* Риски идут через 5°, крупные через 30°; обе стороны подписаны 0…180, кардинальные углы имеют знак градуса и жирный шрифт stretch80%. */ + static const double curves[][8]={{399,550,478,516,577,525,630,562},{630,562,679,594,672,632,626,661},{626,661,570,701,478,702,399,674},{318,563,278,575,278,619,312,642},{315,629,341,659,370,676,399,674},{384,631,348,615,307,555,317,506},{317,506,329,466,350,418,366,438},{366,438,384,466,392,524,384,631},{315,629,304,681,320,780,350,785},{350,785,388,792,386,712,373,670}}; /* Все управляющие точки корпуса, ступицы и обеих лопастей перенесены из исходного Propulsor без упрощения формы. */ + unsigned has_angle=v->role==2 && (v->commands&4u)?1:v->angle_kind; double angle=v->role==2 && (v->commands&4u)?v->command[2]:v->angle; uint32_t outline=has_angle?(v->role==2?0xeeef69:0xffffff):0x657181; if(!has_angle) angle=0; for(unsigned i=0;itriangle(p->user,triangle,outline); } } /* Два заполненных треугольника на исходных радиусах заменяют прежнюю упрощённую радиальную линию. */ + number(p,300,435,225,36,v->angle,v->angle_kind!=0,"°",25,0xe9edf4); p->text(p->user,278,478,275,22,v->angle_kind==2?"AZIMUTH · ЗАДАНИЕ":"AZIMUTH",0x8798ad,11,0); /* Крупное число остаётся принятым показанием; использование сетевого задания подписывается явно и независимо от черновика MASTER. */ + p->azimuth(p->user,0); /* Возвращаем порту исходное преобразование после рисования круглого азимута. */ +} /* Все геометрические размеры и цвета прибора определены только здесь, в templates. */ diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 37e17ea..3516186 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -7,6 +7,8 @@ #include "set_plot.h" #include +#define MIN_VIEW_SPAN 1e-12 /* Минимальная безразмерная доля полного диапазона по X и Y задаёт увеличение до 10^12 раз; запас относительно точности double сохраняет различимые границы нормированной области. */ + static double clamp(double v, double lo, double hi) { return fmin(hi, fmax(lo, v)); } static int finite_values(const double *v, size_t n) { size_t i; @@ -23,13 +25,13 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; - if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 || - v[3] < 1.0/128 || v[3] > 1 || v[0] < 0 || v[1] < 0 || + if (!finite_values(v, 4) || v[2] < MIN_VIEW_SPAN || v[2] > 1 || /* Проверяем конечность смещений и размеров, разрешая ширину до триллионной доли исходного диапазона X. */ + v[3] < MIN_VIEW_SPAN || v[3] > 1 || v[0] < 0 || v[1] < 0 || /* Такой же предел для Y сохраняет возможность дальнейшего перемещения и увеличения уже приближённой области. */ v[0] + v[2] > 1.000000000001 || v[1] + v[3] > 1.000000000001) return 0; out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3]; if (!finite_values(v + 4, 6) || v[4] <= 0 || v[5] <= 0) return 4; - w = clamp(v[2] / v[4], 1.0/128, 1); - h = clamp(v[3] / v[5], 1.0/128, 1); + w = clamp(v[2] / v[4], MIN_VIEW_SPAN, 1); /* Делим текущую долю X на положительный коэффициент приближения; ограничиваем результат между новым пределом и полным диапазоном. */ + h = clamp(v[3] / v[5], MIN_VIEW_SPAN, 1); /* Аналогично масштабируем Y, снимая прежнее ограничение 128 раз и сохраняя ненулевую высоту для координатных преобразований. */ fx = clamp(v[8], 0, 1); fy = clamp(v[9], 0, 1); out[0] = clamp(v[0] + (v[2] - w) * fx - v[6] * w, 0, 1 - w); out[1] = clamp(v[1] + (v[3] - h) * fy - v[7] * h, 0, 1 - h); diff --git a/c/set-protocol/src/set_signal.c b/c/set-protocol/src/set_signal.c index 45b9b7c..e6d80f8 100644 --- a/c/set-protocol/src/set_signal.c +++ b/c/set-protocol/src/set_signal.c @@ -33,14 +33,14 @@ static double evaluate(double t,const double *x,const double *y,size_t n, return (2*u*u*u-3*u*u+1)*y[i]+(u*u*u-2*u*u+u)*h*d[i] +(-2*u*u*u+3*u*u)*y[i+1]+(u*u*u-u*u)*h*d[i+1]; } -int set_signal_reconstruct(const double *input_x,const double *input_y,size_t count, - int method,unsigned degree,size_t output_count,int endpoint,double *out_x, - double *out_y,double *meta,double *work,size_t work_count) { +int set_signal_reconstruct_with_fit(const double *input_x,const double *input_y,size_t count, /* Общая подготовка данных обслуживает исходный и подключаемый решатели. */ + int method,unsigned degree,size_t output_count,int endpoint,double *out_x, /* Метод, степень и временная сетка сохраняют контракт существующего API. */ + double *out_y,double *meta,double *work,size_t work_count,set_signal_fit_fn fit) { /* NULL выбирает прежний МНК; другой callback получает уже нормированные данные. */ point *pairs; double *x,*y,*d,*h,*s,*q,*column; double coef[6]={0},r[6][6]={{0}},origin,span,scale=1,rmse=0; size_t i,j,k,n=0; unsigned pass; if(!input_x||!input_y||!out_x||!out_y||!meta||!work || count<2||count>100000 - ||output_count<2||output_count>10000||work_count<14*count+128 + ||output_count<2||output_count>1000000||work_count<14*count+128 ||method<0||method>3||(endpoint!=0&&endpoint!=1)) return 1; pairs=(point *)work; x=work+2*count; y=x+count; d=y+count; h=d+count; s=h+count; q=s+count; column=q+6*count; @@ -65,6 +65,10 @@ int set_signal_reconstruct(const double *input_x,const double *input_y,size_t co } if(method==SET_SIGNAL_POLYNOMIAL) { if(degree<1||degree>5||n<=degree) return 1; + if(fit) { /* Подключаемый решатель заменяет только вычисление коэффициентов МНК. */ + int status=fit(x,y,n,degree,coef); /* Передаём уникальные нормированные точки и массив до шести коэффициентов. */ + if(status) return status; /* Ошибка ранга или памяти возвращается явно, без скрытого перехода на другой решатель. */ + } else { /* Исходный алгоритм остаётся неизменным вариантом для сравнения и прошивок. */ for(j=0;j<=degree;++j) { double norm=0; for(i=0;i2) { @@ -112,6 +117,11 @@ int set_signal_reconstruct(const double *input_x,const double *input_y,size_t co meta[0]=(double)count; meta[1]=(double)n; meta[2]=sqrt(rmse)*scale; return isfinite(meta[2])?0:2; } +int set_signal_reconstruct(const double *input_x,const double *input_y,size_t count, /* Сохраняем прежний экспорт и поведение всех существующих клиентов. */ + int method,unsigned degree,size_t output_count,int endpoint,double *out_x, /* Параметры передаются без изменения единиц времени и амплитуды. */ + double *out_y,double *meta,double *work,size_t work_count) { /* Владение выходами и рабочим буфером по-прежнему остаётся у вызывающего. */ + return set_signal_reconstruct_with_fit(input_x,input_y,count,method,degree,output_count,endpoint,out_x,out_y,meta,work,work_count,NULL); /* NULL выбирает исходный двухпроходный МНК без зависимости от C++. */ +} /* Завершаем совместимую обёртку исходного C ABI. */ int set_signal_dac12(const double *volts,size_t count,double vref,uint16_t *codes) { size_t i; if(!volts||!codes||count<2||count>10000||!isfinite(vref)||vref<=0) return 1; diff --git a/c/set-protocol/src/set_wavegen.c b/c/set-protocol/src/set_wavegen.c index 1b8d199..e46ef2d 100644 --- a/c/set-protocol/src/set_wavegen.c +++ b/c/set-protocol/src/set_wavegen.c @@ -13,18 +13,41 @@ static size_t seal(uint8_t *p,size_t n) { uint16_t crc=set_crc16_modbus(p,n);p[n]=(uint8_t)crc;p[n+1]=(uint8_t)(crc>>8);return n+2; } void set_wave_init(set_wave_state *s,uint16_t *samples,size_t capacity,const set_wave_port *port) { - memset(s,0,sizeof(*s));s->samples=samples;s->capacity=capacity;s->rate=1000; + memset(s,0,sizeof(*s));s->samples=samples;s->capacity=capacity;s->rate=1000;s->channels=1; /* После reset сохраняет совместимость с прежним одноканальным клиентом. */ if(port) s->port=*port; } +void set_wave_enable_xy(set_wave_state *s,uint32_t *pairs,int (*start)(void *,const uint32_t *,size_t,uint32_t)) { /* Плата явно объявляет доступность второго выхода и его SRAM. */ + s->xy_samples=pairs;s->start_xy=start; /* Нулевой callback оставляет устройство одноканальным, включая прежние F103 и G474RB. */ +} /* Подключение порта не запускает периферию и не меняет таблицу. */ +static uint16_t sample_get(const set_wave_state *s,unsigned index) { /* Читает выбранную координату без перестановки готовых пар перед DMA. */ + return s->channels==2?(uint16_t)((s->xy_samples[index]>>(16u*s->selected))&4095u):s->samples[index]; /* Полуслова DHR12RD разделены 16 битами; код каждого DAC занимает 12 бит. */ +} /* Одноканальный формат uint16_t остаётся прежним. */ +static void sample_set(set_wave_state *s,unsigned index,uint16_t value) { /* Изменяет только выбранную координату, сохраняя парный отсчёт другого канала. */ + if(s->channels==2) { /* Двухканальная таблица уже хранится в формате единственной 32-битной передачи DMA. */ + unsigned shift=16u*s->selected; /* Сдвиг 0 выбирает X/PA4, сдвиг 16 выбирает Y/PA5 по DHR12RD из RM0090/RM0440. */ + s->xy_samples[index]=(s->xy_samples[index]&~(0xffffu<samples[index]=value; /* Сохраняет прежнюю компактную таблицу одиночного канала. */ +} /* Проверки диапазона выполняет вызывающий обработчик до изменения памяти. */ static unsigned write_register(set_wave_state *s,unsigned reg,unsigned value) { if(reg==SET_WAVE_BASE+2 && value==0) { if(s->port.stop) s->port.stop(s->port.context); s->running=0; return 0; } + if(reg==SET_WAVE_BASE+10) { /* Выбор X/Y меняет только окно загрузки и чтения, поэтому доступен и при воспроизведении. */ + uint16_t previous; /* Временно сохраняет счётчик прежней координаты при переключении окна. */ + if(value>=s->channels)return 3; /* Одноканальная плата отвергает Y; произвольные индексы не меняют состояние. */ + if(value!=s->selected) {previous=s->received;s->received=s->received_other;s->received_other=previous;s->selected=(uint16_t)value;} /* Меняет местами счётчики, сохраняя оба независимо от порядка загрузки. */ + return 0; /* Выбор окна не снимает commit и не меняет фазу аппаратного вывода. */ + } /* Остальные изменения таблицы запрещены во время running. */ if(s->running) return 6; + if(reg==SET_WAVE_BASE+9) { /* Число одновременно воспроизводимых каналов задаётся до начала новой загрузки. */ + if(value<1||value>2||(value==2&&(!s->xy_samples||!s->start_xy)))return 3; /* Двойной режим допустим только с буфером пар и аппаратным исполнителем. */ + s->channels=(uint16_t)value;s->selected=0;s->count=s->received=s->received_other=s->ready=0; /* Смена формата инвалидирует обе координаты, исключая запуск смешанной старой таблицы. */ + return 0; /* Пуск остаётся отдельной командой после полного commit. */ + } /* Прежние регистры частоты и длины сохраняют адреса. */ if(reg==SET_WAVE_BASE+2 && value==1) { if(!s->ready||!s->port.start) return 3; - if(s->port.start(s->port.context,s->samples,s->count,s->rate)) return 4; + if(s->channels==2?s->start_xy(s->port.context,s->xy_samples,s->count,s->rate):s->port.start(s->port.context,s->samples,s->count,s->rate)) return 4; /* XY запускается одной аппаратной транзакцией; ошибка порта не выставляет running. */ s->running=1;return 0; } if(reg==SET_WAVE_BASE+3) { @@ -38,37 +61,38 @@ static unsigned write_register(set_wave_state *s,unsigned reg,unsigned value) { } if(reg==SET_WAVE_BASE+4) { if(value<2||value>SET_WAVE_MAX||value>s->capacity) return 3; - s->count=(uint16_t)value;s->received=s->ready=0;return 0; + s->count=(uint16_t)value;s->received=s->received_other=s->ready=0; /* Новая длина начинает загрузку обеих координат с нуля. */return 0; } if(reg>=SET_WAVE_DATA&®=s->count||index>s->received||value>4095||s->ready) return 3; - if(indexreceived) return s->samples[index]==value?0:3; - s->samples[index]=(uint16_t)value;++s->received;return 0; + if(indexreceived) return sample_get(s,index)==value?0:3; /* Повтор подтверждается только при совпадении выбранной координаты; соседнее полуслово пары не учитывается. */ + sample_set(s,index,(uint16_t)value); /* Готовит 16-битный отсчёт либо соответствующее полуслово XY. */++s->received;return 0; } if(reg==SET_WAVE_BASE+6) { - if(value!=0xA55A||s->count<2||s->received!=s->count) return 3; + if(value!=0xA55A||s->count<2||s->received!=s->count||(s->channels==2&&s->received_other!=s->count)) return 3; /* Общий commit требует полные X и Y одинаковой длины. */ s->ready=1;return 0; } return 2; } unsigned set_wave_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) { - set_wave_state *s=(set_wave_state *)ctx;uint16_t w[9];unsigned i; - if(!s||!s->samples||!out||!count||(uint32_t)off+count>9)return 2; + set_wave_state *s=(set_wave_state *)ctx;uint16_t w[13]; /* Девять прежних слов дополнены режимом, селектором, возможностями и вторым счётчиком XY. */unsigned i; + if(!s||!s->samples||!out||!count||(uint32_t)off+count>13)return 2; /* Проверяет весь диапазон расширенного статуса до чтения четырёх дополнительных слов XY. */ w[0]=0x5747;w[1]=3;w[2]=s->running;w[3]=(uint16_t)s->rate; w[4]=s->count;w[5]=s->received;w[6]=s->ready;w[7]=(uint16_t)s->capacity;w[8]=(uint16_t)(s->rate>>16); + w[9]=s->channels;w[10]=s->selected;w[11]=s->start_xy?2:1;w[12]=s->received_other; /* Расширение 0x1309…0x130C не меняет WG v3 и прежний ответ из восьми слов. */ for(i=0;isamples||off>=9)return 2; + if(!s||!s->samples||off>=11)return 2; /* Запись разрешена до селектора 0x130A включительно; возможности и второй счётчик доступны только для чтения. */ return write_register(s,SET_WAVE_BASE+off,value); } unsigned set_wave_samples_read(void *ctx,uint16_t off,uint16_t count,uint16_t *out) { set_wave_state *s=(set_wave_state *)ctx;unsigned i; if(!s||!s->samples||!out||!count||(uint32_t)off+count>s->received)return 2; - for(i=0;isamples[off+i]; + for(i=0;isamples||!req||!out||cap<21||n!=8||device<1||device>247 ||req[0]!=device||set_crc16_modbus(req,n)) return 0; reg=read16(req+2);value=read16(req+4); - if(!((reg>=SET_WAVE_BASE&®=SET_WAVE_DATA&®=SET_WAVE_BASE&®=SET_WAVE_DATA&®rate>>16);length=5; - } else if(reg>=SET_WAVE_DATA&&value==1&®-SET_WAVE_DATAreceived) { - out[2]=2;write16(out+3,s->samples[reg-SET_WAVE_DATA]);length=5; + if(reg>=SET_WAVE_BASE&®=SET_WAVE_DATA&&value==1&®-SET_WAVE_DATAreceived) { /* Чтение одного отсчёта сохраняет старый wire-контракт. */ + out[2]=2;write16(out+3,sample_get(s,reg-SET_WAVE_DATA));length=5; /* XY возвращает выбранную координату, mono возвращает прежний uint16_t. */ } else error=2; } else error=1; if(error) {out[1]|=0x80;out[2]=(uint8_t)error;length=3;} @@ -111,6 +136,9 @@ size_t set_wave_request(unsigned device,unsigned op,unsigned index,unsigned valu case 7: if(index>=SET_WAVE_MAX)return 0;reg=SET_WAVE_DATA+index;fn=3;value=1;break; case 8: reg+=8;fn=3;value=1;break; case 9: reg+=8;if(value>(SET_WAVE_MAX_RATE>>16))return 0;break; + case 10: reg+=9;if(value<1||value>2)return 0;break; /* Выбирает один или два синхронных канала через существующий RTU/SET v2 транспорт. */ + case 11: reg+=10;if(value>1)return 0;break; /* Выбирает X=0 либо Y=1 для прежних команд sample/block/readback. */ + case 12: reg+=9;fn=3;value=4;break; /* Читает режим, селектор, возможности порта и количество отсчётов второй координаты. */ default:return 0; } out[0]=(uint8_t)device;out[1]=(uint8_t)fn;write16(out+2,reg);write16(out+4,value);return seal(out,6); @@ -131,7 +159,7 @@ int set_wave_response(const uint8_t *req,const uint8_t *reply,size_t size,uint16 if(req[1]==6) {if(memcmp(req,reply,8)||cap<1)return -1;words[0]=read16(req+4);return 1;} if(reply[2]!=expected-5||cap<(expected-5)/2)return -1; for(i=0;i<(expected-5)/2;++i)words[i]=read16(reply+3+2*i); - if(read16(req+2)==SET_WAVE_BASE&&(words[0]!=0x5747||(words[1]<1||words[1]>3)))return -2; + if(read16(req+2)==SET_WAVE_BASE&&read16(req+4)>=2&&(words[0]!=0x5747||(words[1]<1||words[1]>3)))return -2; /* Версия проверяется только при наличии двух слов, исключая чтение за границей короткого ответа. */ return (int)((expected-5)/2); } @@ -168,13 +196,13 @@ size_t set_wave_block_rtu(set_wave_state *s,unsigned device,const uint8_t *req, /* Preflight the whole block, including replayed prefix, before writing. */ for(i=0;i4095||(index+ireceived&&s->samples[index+i]!=value)) { + if(value>4095||(index+ireceived&&sample_get(s,index+i)!=value)) { error=3;break; } } if(!error) { if(cap<8)return 0; - for(i=0;isamples[index+i]=read16(req+7+2*i); + for(i=0;is->received)s->received=(uint16_t)(index+count); memcpy(out,req,6);length=6; } @@ -184,7 +212,7 @@ size_t set_wave_block_rtu(set_wave_state *s,unsigned device,const uint8_t *req, else { if(cap<5+2*(size_t)count)return 0; out[2]=(uint8_t)(2*count); - for(i=0;isamples[index+i]); + for(i=0;i None: import_library = build_dir / "setprotocol.lib" jni_flags = " ".join(f'/I"{path}"' for path in JNI_INCLUDES) command = ( - f'call "{vcvars}" && cl /nologo /W4 /std:c11 ' + f'call "{vcvars}" && cl /nologo /W4 /O2 /std:c11 ' # Оптимизируем исходный C-решатель, чтобы сравнение с оптимизированным Eigen не измеряло различие режимов компиляции. f'/DPCAN_ABI_BUILD_DLL /I"{INCLUDE}" {jni_flags} /LD {quoted_sources} ' f'/Fe"{output}" /link /IMPLIB:"{import_library}"' ) @@ -78,7 +79,7 @@ def _build_cc(output: Path, build_dir: Path) -> None: compiler = shutil.which("cc") or shutil.which("clang") or shutil.which("gcc") if compiler is None: raise SystemExit("C compiler was not found") - command = [compiler, "-std=c99", "-Wall", "-Wextra", "-Wpedantic", + command = [compiler, "-O2", "-std=c99", "-Wall", "-Wextra", "-Wpedantic", # Одинаковая оптимизация C99 и C++ делает время настольных вариантов сопоставимым. "-fPIC", "-shared", f"-I{INCLUDE}", *(f"-I{path}" for path in JNI_INCLUDES), *map(str, SOURCES), "-o", str(output), "-lm"] diff --git a/cpp/signal-eigen/README.md b/cpp/signal-eigen/README.md new file mode 100644 index 0000000..e58bb82 --- /dev/null +++ b/cpp/signal-eigen/README.md @@ -0,0 +1,50 @@ +# Альтернативный МНК Eigen QR + +Необязательный настольный модуль выполняет полиномиальный МНК через +`Eigen::ColPivHouseholderQR`. Исходный двухпроходный решатель остаётся вариантом +по умолчанию. Прошивки и основная SETProtocol DLL продолжают собираться как C99. + +Путь вызова: GUI → Python/ctypes → `setsignal_eigen` → общая подготовка +`set_signal.c` → callback Eigen → общие вычисление кривой и RMSE. +Сортировка, усреднение повторов X, нормировка, сетка, рабочий буфер и аннотации +формул совпадают у обоих вариантов. Порог ранга Eigen относительный: `1e-12`; +он может иначе классифицировать пограничные данные, чем исходная ортогонализация. +Модуль не зависит от Qt и не участвует в обмене с устройствами. + +| Файл | Назначение и зависимости | +| --- | --- | +| `signal_eigen.cpp` | Решатель QR и экспорт `set_signal_reconstruct_eigen`; C++14, Eigen, `set_signal.h` | +| `build.py` | Сборка DLL/SO, проверка и распаковка локального архива; Python 3.8+ и C/C++ компиляторы | +| `vendor/eigen-3.4.0.tar.gz` | Оригинальный архив зависимости, без изменений | +| `../../python/set_devices/signal_eigen.py` | Ленивый ctypes-загрузчик отдельного компонента | + +Зависимость: [Eigen 3.4.0, официальный архив](https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz). +SHA-256: `8586084f71f9bde545ee7fa6d00288b264a2b7ac3607b974e54d13e7162c1c72`. +Заголовки компилируются с `EIGEN_MPL2_ONLY`; `Eigen-COPYING.MPL2` копируется +рядом с библиотекой и включается в пакет SETGUI. Сборка не требует интернета. + +Из каталога SETGUI: + +```powershell +.venv/Scripts/python.exe third_party/templates/cpp/signal-eigen/build.py --output src/gui_desktop/native/setsignal_eigen.dll # Собираем 64-битный настольный компонент рядом с основной DLL. +``` + +Для 32-битного приложения добавьте `--arch x86`. Компилятор C обрабатывает +общий алгоритм отдельно от C++14-решателя. Windows-модуль использует статический +CRT и PE-подсистему 6.01; работоспособность на самой Windows 7 требует проверки +на соответствующей машине. Для Linux/macOS укажите имя `libsetsignal_eigen.so` +или `libsetsignal_eigen.dylib`. `scripts/build_exe.py` собирает и упаковывает +компонент автоматически, включая вариант Windows 7 x86. + +Контракт `set_signal_fit_fn` описан в `c/set-protocol/include/set_signal.h`: +callback получает уникальные нормированные X/Y, количество точек и степень 1…5, +возвращает коэффициенты по возрастанию степеней аргумента `2*x-1`. +Владение выходами остаётся у вызывающего; Eigen выделяет временные матрицы +только в настольном порте. Коды ошибок: 0 — успех, 1 — параметры, 2 — нечисловой +результат, 3 — недостаточный ранг, 5 — память/ошибка C++-решателя. +Исключения не пересекают C ABI. Общих изменяемых рабочих буферов нет. + +Загрузчик ищет компонент рядом с выбранной SETProtocol DLL. Переменная +`SETSIGNAL_EIGEN_LIBRARY` позволяет явно задать путь для тестов. Отсутствующая +библиотека вызывает понятную ошибку после выбора Eigen; скрытой замены +на исходный решатель нет. Используется в обработке графиков и генераторе SETGUI. diff --git a/cpp/signal-eigen/build.py b/cpp/signal-eigen/build.py new file mode 100644 index 0000000..45148fb --- /dev/null +++ b/cpp/signal-eigen/build.py @@ -0,0 +1,63 @@ +import argparse # Разбираем путь выходной библиотеки и целевую архитектуру настольной сборки. +import hashlib # Проверяем закреплённый архив Eigen перед использованием заголовков. +import os # Находим стандартный каталог установщика Visual Studio в Windows. +from pathlib import Path # Разрешаем пути независимо от текущего рабочего каталога сборки. +import platform # Выбираем расширение библиотеки и инструментарий целевой ОС. +import shutil # Находим доступный системный компилятор для Unix-подобных ОС. +import subprocess # Запускаем компиляторы с проверкой кода завершения. +import tarfile # Читаем заголовки и лицензии из проверенного архива без сетевого доступа. + +ROOT = Path(__file__).resolve().parent # Все исходники и закреплённая зависимость расположены рядом со скриптом. +CORE = ROOT.parents[1] / "c" / "set-protocol" # Подключаем исходный C99-алгоритм из того же репозитория, не создавая его копию. +SHA256 = "8586084f71f9bde545ee7fa6d00288b264a2b7ac3607b974e54d13e7162c1c72" # Контрольная сумма официального архива Eigen 3.4.0 из GitLab. + +def headers(directory): # Подготавливаем только необходимые заголовки и лицензии в каталоге конкретной сборки. + archive = ROOT / "vendor" / "eigen-3.4.0.tar.gz" # Закреплённый архив позволяет собирать EXE без скачивания зависимостей. + if hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: # Повреждённая или подменённая зависимость не должна попадать в бинарный модуль. + raise RuntimeError("Неверная SHA-256 архива Eigen 3.4.0") # Останавливаем сборку с явным указанием нарушенной проверки. + destination = directory / "eigen-3.4.0" # Раздельный каталог исключает пересечение разных целевых сборок. + with tarfile.open(archive, "r:gz") as source: # Читаем gzip-архив штатной библиотекой Python, совместимой с Python 3.8. + for member in source.getmembers(): # Рассматриваем каждый элемент, но не используем небезопасную полную распаковку архива. + parts = Path(member.name).parts[1:] # Удаляем единственный верхний каталог официального дистрибутива. + if not member.isfile() or not parts or any(part in ("..", "/", "\\") for part in parts): # Разрешаем только обычные файлы с безопасным относительным путём. + continue # Пропускаем ссылки, каталоги и элементы, способные выйти за корень распаковки. + if parts[0] != "Eigen" and not parts[0].startswith("COPYING"): # Для QR достаточно основного дерева Eigen и его лицензионных файлов. + continue # Примеры, тесты и неподдерживаемые модули не нужны при сборке приложения. + target = destination.joinpath(*parts) # Сохраняем исходное расположение внутренних include относительно корня Eigen. + target.resolve().relative_to(destination.resolve()) # Дополнительно проверяем итоговый абсолютный путь перед записью. + target.parent.mkdir(parents=True, exist_ok=True) # Создаём только каталог текущего безопасного файла. + with source.extractfile(member) as content: # Открываем содержимое обычного файла внутри проверенного архива. + target.write_bytes(content.read()) # Сохраняем оригинальные заголовки без изменения стороннего кода. + return destination # Возвращаем корень, который компилятор должен добавить в include-пути. + +def build(output, arch): # Собираем отдельный модуль, сохраняя основное C99-ядро без зависимости от C++. + output = output.resolve() # Абсолютный путь надёжен при смене рабочего каталога компилятора. + directory = output.parent / (".signal-eigen-" + arch) # Объектные файлы x86 и x64 не смешиваются. + directory.mkdir(parents=True, exist_ok=True) # Создаём каталог выходной библиотеки и промежуточных файлов. + include = headers(directory) # Проверяем архив и получаем локальные заголовки Eigen до запуска компилятора. + if platform.system() == "Windows": # На Windows используем тот же установленный MSVC, что основная DLL. + vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe" # Стандартный путь установщика соответствует основному сборщику SETGUI. + installation = subprocess.check_output([str(vswhere), "-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], text=True).strip() # Находим установку с компиляторами C/C++ для обеих архитектур. + if not installation: # Пустой ответ означает отсутствие подходящего инструментария. + raise RuntimeError("Не найдены Visual Studio C++ Build Tools") # Сообщаем причину вместо создания приложения без выбранного варианта Eigen. + vcvars = Path(installation) / "VC/Auxiliary/Build" / ("vcvars32.bat" if arch == "x86" else "vcvars64.bat") # Настраиваем среду именно под разрядность Python и EXE. + common = ["cl", "/nologo", "/O2", "/MT", "/DPCAN_ABI_BUILD_DLL", "/I" + str(CORE / "include")] # Оба варианта C и Eigen используют оптимизацию и статический CRT без новой зависимости от установленного runtime. + commands = [common + ["/std:c11", "/c", str(CORE / "src/set_signal.c"), "/Fo" + str(directory / "signal.obj")], common + ["/std:c++14", "/EHsc", "/I" + str(include), "/c", str(ROOT / "signal_eigen.cpp"), "/Fo" + str(directory / "eigen.obj")], ["link", "/nologo", "/DLL", "/MACHINE:" + arch.upper(), "/SUBSYSTEM:WINDOWS,6.01", "/OUT:" + str(output), str(directory / "signal.obj"), str(directory / "eigen.obj")]] # Отдельно компилируем C99 и C++, а PE-подсистему закрепляем на Windows 7 (6.01). + for command in commands: # Последовательные этапы используют только готовые объектные файлы предыдущих команд. + subprocess.run('call "%s" && %s' % (vcvars, subprocess.list2cmdline(command)), cwd=directory, shell=True, check=True) # Batch-настройка MSVC и компилятор выполняются в одном процессе cmd. + else: # Настольные Linux/macOS используют системные C/C++-компиляторы и тот же исходный алгоритм. + cc, cxx = shutil.which("cc"), shutil.which("c++") # C-исходник компилируем как C99, а Eigen исключительно как C++14. + if not cc or not cxx: # Обе стадии необходимы для сохранения языковой границы общего ядра. + raise RuntimeError("Нужны компиляторы C и C++") # Отсутствие инструментария не приводит к скрытому отключению Eigen. + subprocess.run([cc, "-O2", "-std=c99", "-fPIC", "-I" + str(CORE / "include"), "-c", str(CORE / "src/set_signal.c"), "-o", str(directory / "signal.o")], check=True) # Получаем позиционно-независимый объект оригинального C99-ядра. + subprocess.run([cxx, "-O2", "-std=c++14", "-fPIC", "-shared", "-I" + str(CORE / "include"), "-I" + str(include), str(ROOT / "signal_eigen.cpp"), str(directory / "signal.o"), "-o", str(output)], check=True) # Линкер C++ добавляет свою стандартную библиотеку к общему настольному модулю. + for name in ("COPYING.MPL2",): # Распространяем основной текст лицензии вместе с бинарным компонентом. + shutil.copyfile(include / name, output.parent / ("Eigen-" + name)) # Имя явно указывает, к какой встроенной библиотеке относится лицензия. + print(output) # Показываем точный путь успешно собранного компонента для журналов сборки. + +if __name__ == "__main__": # CLI используется как вручную, так и сборщиком EXE. + parser = argparse.ArgumentParser(description="Сборка альтернативного МНК Eigen QR") # Объясняем назначение отдельного настольного модуля. + parser.add_argument("--output", type=Path, required=True) # Вызывающий определяет выходной путь и расширение DLL/SO. + parser.add_argument("--arch", choices=("x86", "x64"), default="x64") # Windows 7 x86 явно выбирает 32-битный PE вместо значения по умолчанию. + arguments = parser.parse_args() # Проверяем обязательные параметры до работы с файловой системой. + build(arguments.output, arguments.arch) # Полностью выполняем сборку либо завершаем процесс исключением и ненулевым кодом. diff --git a/cpp/signal-eigen/signal_eigen.cpp b/cpp/signal-eigen/signal_eigen.cpp new file mode 100644 index 0000000..6508542 --- /dev/null +++ b/cpp/signal-eigen/signal_eigen.cpp @@ -0,0 +1,40 @@ +#define EIGEN_MPL2_ONLY // Ограничиваем подключаемые части Eigen лицензией MPL-2.0. +#include // Подключаем QR с выбором ведущего столбца для полиномиального МНК. +#include // Проверяем конечность вычисленных коэффициентов перед передачей в C. +#include // Перехватываем ошибку выделения памяти внутри границы C ABI. +#include "set_signal.h" // Используем общую подготовку точек, вычисление кривой и стабильный C ABI. + +static int eigen_fit(const double *x,const double *y,size_t count,unsigned degree,double *coefficients) { // Вычисляем только коэффициенты по уже нормированным уникальным точкам. + try { // Исключения C++ не должны пересекать границу ctypes или C-ядра. + Eigen::MatrixXd design(count,degree+1); // Матрица имеет одну строку на точку и до шести степеней, включая свободный член. + for(size_t i=0;i qr(design); // Используем отражения Хаусхолдера с перестановкой столбцов вместо собственной ортогонализации. + qr.setThreshold(1e-12); // Относительный порог ранга соответствует порядку допуска исходного решателя; он не является абсолютной погрешностью сигнала. + if(qr.rank()!=static_cast(degree+1)) return 3; // Отклоняем вырожденный базис, не выдавая неоднозначные коэффициенты за корректную кривую. + Eigen::Map values(y,count); // Читаем нормированные амплитуды из памяти C без дополнительного копирования входного вектора. + Eigen::VectorXd solution=qr.solve(values); // Находим минимум суммы квадратов без формирования нормальных уравнений AᵀA. + for(unsigned j=0;j<=degree;++j) { // Возвращаем степени в исходном порядке: Eigen отменяет перестановку столбцов при solve. + if(!std::isfinite(solution[j])) return 2; // Не разрешаем NaN или бесконечности попасть в оценку кривой и RMSE. + coefficients[j]=solution[j]; // Передаём коэффициент в буфер общего C-алгоритма вычисления полинома. + } // Весь выходной полином проверен до передачи управления C-ядру. + return 0; // Сообщаем общий для двух реализаций код успешного расчёта. + } catch(const std::bad_alloc &) { // Преобразуем нехватку памяти необязательного настольного решателя в детерминированный код. + return 5; // Код 5 обозначает невозможность выполнить расчёт, включая выделение матриц. + } catch(...) { // Защищаем потребителей C ABI от остальных исключений библиотеки C++. + return 5; // Возвращаем явную ошибку без подмены выбранного пользователем решателя. + } // Завершаем защиту границы между C++ и C. +} // Решатель не хранит глобальных данных и допускает независимые параллельные вызовы. + +extern "C" PCAN_ABI_API int set_signal_reconstruct_eigen( // Отдельный экспорт сохраняет возможность загрузить модуль без изменения основной DLL. + const double *x,const double *y,size_t count,int method,unsigned degree, // Формат точек и предел степени совпадают с исходным API. + size_t output_count,int endpoint,double *out_x,double *out_y, // Временная сетка и включение конечной точки рассчитываются общим C-ядром. + double *meta,double *work,size_t work_count) { // Метаданные и рабочая память сохраняют формат аннотаций формулы GUI. + if(method!=SET_SIGNAL_POLYNOMIAL) return 1; // Eigen доступен только для МНК; остальные методы не маскируются под альтернативный решатель. + return set_signal_reconstruct_with_fit(x,y,count,method,degree,output_count,endpoint,out_x,out_y,meta,work,work_count,eigen_fit); // Повторно используем C99-подготовку, обработку повторов, вычисление кривой и RMSE. +} // Завершаем экспорт с соглашением вызова C для x86 и x64. diff --git a/cpp/signal-eigen/vendor/eigen-3.4.0.tar.gz b/cpp/signal-eigen/vendor/eigen-3.4.0.tar.gz new file mode 100644 index 0000000..20635de Binary files /dev/null and b/cpp/signal-eigen/vendor/eigen-3.4.0.tar.gz differ diff --git a/doc/setcan/DEVICE_REGISTRY.md b/doc/setcan/DEVICE_REGISTRY.md index c9dc88e..fdcd0f0 100644 --- a/doc/setcan/DEVICE_REGISTRY.md +++ b/doc/setcan/DEVICE_REGISTRY.md @@ -11,6 +11,7 @@ Device Name — отображаемое имя адреса, а не допол | `0x2` | `0x0…0xF` | PERIPH 16–31 | Вторая группа; номер PERIPH = 16 + DeviceID | | `0x1` | `0x5` | **УМП1** | PERIPH 5, собственный адрес обычного обмена ProtoCAN и загрузчика | | `0x1` | `0x6` | УМП2 | PERIPH 6, GAS 0x3 — регистры; GAS SLOT 0x4 — слоты 0x0600…0x0642 | +| `0x1` | `0x8` | **Задатчик** | PERIPH 8; для ПМ35 с M95M02 сохраняется ProductType `0x2833` | | `0x6` | `0xE` | Altera Logic | Отдельный существующий потоковый профиль FE00/FF00 | | `0x7` | `0xD` | configurator | Существующий конфигуратор | | `0x7` | `0xE` | **STM Invertor** | Самостоятельный ПЧ STM32G474VET6; отдельный GAS-профиль на 250 слотов 0x4, 750 значений; Word0 — маска | diff --git a/doc/setcan/protocan/GAS_ADDRESS_ALLOCATION_167.md b/doc/setcan/protocan/GAS_ADDRESS_ALLOCATION_167.md new file mode 100644 index 0000000..9d38746 --- /dev/null +++ b/doc/setcan/protocan/GAS_ADDRESS_ALLOCATION_167.md @@ -0,0 +1,97 @@ +# Сквозная нумерация GAS: распределение адресов + +Дата: **9 октября 2026 года**. + +УКСС выделен диапазон **0…11999**: общий блок 0 и блоки узлов 1…11, по **1000 ID** каждый. Соответствие технологических слов Modbus текущему профилю 167/v1 и новым ID УМП приведено в [GAS_MODBUS_MAP.md](GAS_MODBUS_MAP_167.md). Новая нумерация назначена в документации; в прошивке пока действует профиль 167/v1. + +## Диапазоны проектов + +В новой схеме числа без префикса `0x` — **десятичные**. Каждому номеру узла соответствует блок 1000 адресов с базой `номер × 1000`. + +| Проект / семейство | Первый ID | Последний ID включительно | Число адресов | Статус | +|---|---:|---:|---:|---| +| УКСС | 0 | 11999 | 12000 | Выделено: 12 блоков по 1000 ID | +| Остальные проекты | 12000 | 65535 | 53536 | Свободно для последующего распределения | + +Границы УКСС в hex: `0x0000…0x2EDF`; свободная область начинается с `0x2EE0`. + +Резерв диапазона не означает, что каждый адрес уже реализован. Адреса обозначают логические 16-битные слова; память для незадействованных ID не выделяется. Параметр шириной 32 бита занимает два последовательных слова. Служебные слова также должны входить в выделенный диапазон проекта. + +## Узлы УКСС + +Номера узлов: **1…11**; **5 и 6 — УМП**. Назначения 1…9 подтверждены в `package.h` (проект tms_periph/periph_167); служебные адреса 10/11 определены в `cntrl_adr.c` (проект tms_periph/periph_167). + +| Номер | Назначение в текущем коде | Обозначение | +|---:|---|---| +| 1 | Трансформаторный узел 1 | `adr_TRN1` | +| 2 | Трансформаторный узел 2 | `adr_TRN2` | +| 3 | Силовой узел 1 | `adr_POW1` | +| 4 | Силовой узел 2 | `adr_POW2` | +| 5 | УМП 1, LOAD | `adr_LOA1` | +| 6 | УМП 2, LOAD | `adr_LOA2` | +| 7 | Узел ENG1 | `adr_ENG1` | +| 8 | Пульт | `adr_PULT` | +| 9 | Шкаф | `adr_SHKF` | +| 10 | Универсальный адрес / адрес общей посылки | `CNTRL_ADDR_UNIVERSAL`, `ADDR_FOR_ALL` | +| 11 | Адрес терминала | `ADDR_TERMINAL` | + +Номер узла, тип платы `Desk` и ID параметра GAS — отдельные величины. Например, УМП 1 имеет `Mode = 5`, а тип обеих плат УМП равен `dsk_LOAD = 6`. Блок номера 10 зарезервирован для роли общего адреса; существующий номер 10 не идентифицирует отдельную физическую плату. + +## Назначенные блоки УКСС + +Границы включительные. Все блоки содержат по 1000 слов, пересечений нет. + +| Область | Первый ID, dec | Последний ID, dec | База, hex | +|---|---:|---:|---| +| Блок 0: общий резерв УКСС | 0 | 999 | `0x0000` | +| Трансформаторный узел 1, номер 1 | 1000 | 1999 | `0x03E8` | +| Трансформаторный узел 2, номер 2 | 2000 | 2999 | `0x07D0` | +| Силовой узел 1, номер 3 | 3000 | 3999 | `0x0BB8` | +| Силовой узел 2, номер 4 | 4000 | 4999 | `0x0FA0` | +| УМП 1, номер 5 | 5000 | 5999 | `0x1388` | +| УМП 2, номер 6 | 6000 | 6999 | `0x1770` | +| Узел ENG1, номер 7 | 7000 | 7999 | `0x1B58` | +| Пульт, номер 8 | 8000 | 8999 | `0x1F40` | +| Шкаф, номер 9 | 9000 | 9999 | `0x2328` | +| Резерв роли общего адреса, номер 10 | 10000 | 10999 | `0x2710` | +| Терминал, номер 11 | 11000 | 11999 | `0x2AF8` | + +Правило адресации: + +```text +GAS_ID = номер_узла × 1000 + смещение +номер_узла = 0…11 +смещение = 0…999 +``` + +## Технологические слова УМП + +Для штатного технологического Modbus-адреса `n = 0…127` в новой карте назначено смещение `n`: + +```text +УМП 1, номер 5: GAS_ID = 5000 + n → 5000…5127 +УМП 2, номер 6: GAS_ID = 6000 + n → 6000…6127 +``` + +| Параметр | Штатный Modbus, hex | УМП 1: новый GAS, dec | УМП 2: новый GAS, dec | +|---|---|---:|---:| +| Первое технологическое слово | `0x0000` | 5000 | 6000 | +| `DAC_go` | `0x0050` | 5080 | 6080 | +| `DAC_stop` | `0x0051` | 5081 | 6081 | +| `LOAD_CAN_TIMEOUT` | `0x0052` | 5082 | 6082 | +| `LOAD_STATUS` | `0x007C` | 5124 | 6124 | +| `LOAD_ATTEMPTS` | `0x007D` | 5125 | 6125 | +| `LastMode` | `0x007E` | 5126 | 6126 | +| `Commands` | `0x007F` | 5127 | 6127 | + +Смещения **128…999** в каждом блоке остаются резервом для расширений. Служебные параметры и новые данные в этих смещениях пока не назначены. Полная таблица всех 128 технологических слов: [GAS_MODBUS_MAP.md](GAS_MODBUS_MAP_167.md). + +## Связь с текущей прошивкой + +В существующей реализации база технологических GAS-регистров равна **`0x5000` = 20480**, а дескриптор находится на **`0x5F00` = 24320**. Это локальное окно профиля 167/v1, выбранное независимо от новой схемы сквозной нумерации. + +Текущее `0x5000` не означает `5 × 1000`: последнее вычисление даёт **5000 десятичных = `0x1388`**. Для адресации УМП 1 и УМП 2 по номеру узла потребуются разные базы, определяемые по `Mode`. + +Штатные адреса Modbus, служебные окна регистратора, паспорта, защиты и EEPROM уже имеют собственные значения. При переносе сквозной карты нужно определить соответствие GAS ↔ адрес протокола: глобальный ID может отличаться от прежнего Modbus-адреса. Это позволяет отдельно согласовать новую нумерацию и совместимость существующих клиентов. + +Диапазон УКСС и размеры блоков назначены. Перенос этих ID в обработчики прошивки и размещение служебных параметров в резерве — отдельные изменения реализации. diff --git a/doc/setcan/protocan/GAS_MODBUS_MAP_167.md b/doc/setcan/protocan/GAS_MODBUS_MAP_167.md new file mode 100644 index 0000000..13bba27 --- /dev/null +++ b/doc/setcan/protocan/GAS_MODBUS_MAP_167.md @@ -0,0 +1,288 @@ + +# GAS ↔ Modbus: карта регистров Бальзам 167 + +## Использование в SETGUI + +В `Терминал → TMS28335 → periph_28335 → rs232_periph_28335 → Работа с GAS` +доступны словарь 128 технологических слов, поиск, чтение диапазона и запись FC06. +Порт и адрес контроллера используются из общих настроек RS232/485. +Кнопка «Проверить профиль» читает восемь слов с `0x5F00`; запись разрешается +только после подтверждения GAS 167/v1, M95M02 и возможностей RTU/FC06. +Проверка сбрасывается при смене соединения/адреса, ошибке и отмене обмена. +Все 128 слов читаются двумя запросами: смещение 0, количество 124, затем +кнопкой «Прочитать 0x507C…0x507F». Таблица показывает raw и HEX без +неподтверждённых преобразований единиц. + +Машинный словарь: `python/protocan/gas_167_registers.json` проекта templates. +В общей вкладке Modbus имена и пары новых ID УМП 1/2 подставляются для FC03 +по штатным адресам и действующему окну GAS; сохранённые пользовательские +имена имеют приоритет. Обмен этой вкладки GAS реализован через RS232/485. + +Ниже сохранена карта из проекта `tms_periph/periph_167`, коммит `378e06a`. +Ссылки на исходники относятся к дереву того проекта. + +Профиль: **GAS 167/v1**. Соответствие проверено по исходникам проекта на **9 октября 2026 года**. + +УКСС выделен сквозной диапазон **0…11999** с блоком **1000 ID** на номер узла: [GAS_ADDRESS_ALLOCATION.md](GAS_ADDRESS_ALLOCATION_167.md). В полной таблице приведены текущие адреса прошивки 167/v1 и новые назначенные ID УМП 1/2. Новая нумерация пока не перенесена в прошивку. + +GAS предоставляет доступ к тем же значениям `modbus[]`, что и штатные адреса Modbus. В Modbus RTU можно обращаться как по прежнему адресу `0x0000…0x007F`, так и по GAS-адресу `0x5000…0x507F`. Это два адреса одного слова RAM; отдельной копии параметров нет. + +**GAS доступен только при `EEPROM_M95M02=1`.** В текущем `eeprom_config.h` (проект tms_periph/periph_167) по умолчанию задано `0` — сборка для AT25256B, в которой GAS-окна отсутствуют. Фактическая сборка может переопределять этот параметр. + +## Правило соответствия + +```text +Для штатного адреса n = 0…127: + GAS-адрес = 0x5000 + n + Штатный Modbus = GAS-адрес - 0x5000 + Источник значения = modbus[n] + +Пример: modbus[0x52] → штатный адрес 0x0052 (82) + → GAS-адрес 0x5052 (20562) + +Новая сквозная карта (десятичные ID): + УМП 1, номер 5: GAS_ID = 5000 + n + УМП 2, номер 6: GAS_ID = 6000 + n + modbus[0x52] → УМП 1: 5082; УМП 2: 6082 +``` + +Столбцы Modbus и GAS 167/v1 содержат **нулевые адреса из поля запроса**, без прибавления единицы и без префикса `4xxxx`. Столбцы «Новый ID УМП 1/2» содержат назначенные ID новой сквозной карты; текущая прошивка ещё не обслуживает их как новые GAS-адреса. Каждый адрес обозначает одно **16-битное слово raw**. Знаковость, единицы и масштаб зависят от параметра и типа платы `Desk`; GAS передаёт исходное слово. + +| Область | Штатный Modbus | GAS / адрес обращения через Modbus RTU | Слова | Доступ | +|---|---|---|---:|---| +| Технологические регистры | `0x0000…0x007F` | `0x5000…0x507F` | 128 | RW с исключениями LOAD | +| Информация GAS-профиля | Нет штатного аналога | `0x5F00…0x5F07` | 8 | RO | + +`RW` в документе означает, что обработчик разрешает FC06. Измерения и статусы могут затем обновляться прошивкой; разрешение записи само по себе не делает такой регистр уставкой. `RO` означает, что обработчик отклоняет запись. + +## Полное соответствие 128 технологических регистров + +Имена взяты из `package.h` (проект tms_periph/periph_167), `measure.c` (проект tms_periph/periph_167), `ADC.c` (проект tms_periph/periph_167) и `DAC.c` (проект tms_periph/periph_167). Индексы `sens_*` начинаются с нуля. Конкретное назначение каналов зависит от платы; таблица не назначает всем платам одинаковые физические каналы. + +Перекрывающиеся имена относятся к одному слову и используются в разных режимах. Например, `0x0052` входит в общий диапазон `sens_lo_edge[]`, а на LOAD служит `LOAD_CAN_TIMEOUT`. + +| Modbus, hex | Modbus, dec | GAS 167/v1, hex | GAS 167/v1, dec | Новый ID УМП 1, dec | Новый ID УМП 2, dec | Имя / источник | Доступ | +|---|---:|---|---:|---:|---:|---|---| +| `0x0000` | 0 | `0x5000` | 20480 | 5000 | 6000 | `sens_error[0]`; `InputRep0/1/2` — биты A/B/C | RW | +| `0x0001` | 1 | `0x5001` | 20481 | 5001 | 6001 | `sens_error[1]` | RW | +| `0x0002` | 2 | `0x5002` | 20482 | 5002 | 6002 | `sens_error[2]` | RW | +| `0x0003` | 3 | `0x5003` | 20483 | 5003 | 6003 | `sens_error[3]` | RW | +| `0x0004` | 4 | `0x5004` | 20484 | 5004 | 6004 | `sens_error[4]` | RW | +| `0x0005` | 5 | `0x5005` | 20485 | 5005 | 6005 | `sens_error[5]` | RW | +| `0x0006` | 6 | `0x5006` | 20486 | 5006 | 6006 | `sens_error[6]` | RW | +| `0x0007` | 7 | `0x5007` | 20487 | 5007 | 6007 | `sens_error[7]` | RW | +| `0x0008` | 8 | `0x5008` | 20488 | 5008 | 6008 | `sens_error[8]` | RW | +| `0x0009` | 9 | `0x5009` | 20489 | 5009 | 6009 | `sens_error[9]` | RW | +| `0x000A` | 10 | `0x500A` | 20490 | 5010 | 6010 | `sens_error[10]` | RW | +| `0x000B` | 11 | `0x500B` | 20491 | 5011 | 6011 | `sens_error[11]` | RW | +| `0x000C` | 12 | `0x500C` | 20492 | 5012 | 6012 | `sens_error[12]` | RW | +| `0x000D` | 13 | `0x500D` | 20493 | 5013 | 6013 | `sens_error[13]` | RW | +| `0x000E` | 14 | `0x500E` | 20494 | 5014 | 6014 | `sens_error[14]` | RW | +| `0x000F` | 15 | `0x500F` | 20495 | 5015 | 6015 | `sens_error[15]` | RW | +| `0x0010` | 16 | `0x5010` | 20496 | 5016 | 6016 | `sens_error[16]`; `Inputs` | RW | +| `0x0011` | 17 | `0x5011` | 20497 | 5017 | 6017 | `sens_error[17]` | RW | +| `0x0012` | 18 | `0x5012` | 20498 | 5018 | 6018 | `sens_error[18]` | RW | +| `0x0013` | 19 | `0x5013` | 20499 | 5019 | 6019 | `sens_error[19]` | RW | +| `0x0014` | 20 | `0x5014` | 20500 | 5020 | 6020 | `sens_error[20]` | RW | +| `0x0015` | 21 | `0x5015` | 20501 | 5021 | 6021 | `sens_error[21]` | RW | +| `0x0016` | 22 | `0x5016` | 20502 | 5022 | 6022 | `sens_error[22]`; `Buttons` | RW | +| `0x0017` | 23 | `0x5017` | 20503 | 5023 | 6023 | `sens_error[23]`; `Jumpers` | RW | +| `0x0018` | 24 | `0x5018` | 20504 | 5024 | 6024 | `sens_data[0]` | RW | +| `0x0019` | 25 | `0x5019` | 20505 | 5025 | 6025 | `sens_data[1]` | RW | +| `0x001A` | 26 | `0x501A` | 20506 | 5026 | 6026 | `sens_data[2]` | RW | +| `0x001B` | 27 | `0x501B` | 20507 | 5027 | 6027 | `sens_data[3]` | RW | +| `0x001C` | 28 | `0x501C` | 20508 | 5028 | 6028 | `sens_data[4]`; LOAD: расчётный выход 4–20 мА, raw ×10 | RW | +| `0x001D` | 29 | `0x501D` | 20509 | 5029 | 6029 | `sens_data[5]`; LOAD: измеренный вход 4–20 мА, raw ×10 | RW | +| `0x001E` | 30 | `0x501E` | 20510 | 5030 | 6030 | `sens_data[6]` | RW | +| `0x001F` | 31 | `0x501F` | 20511 | 5031 | 6031 | `sens_data[7]` | RW | +| `0x0020` | 32 | `0x5020` | 20512 | 5032 | 6032 | `sens_data[8]` | RW | +| `0x0021` | 33 | `0x5021` | 20513 | 5033 | 6033 | `sens_data[9]` | RW | +| `0x0022` | 34 | `0x5022` | 20514 | 5034 | 6034 | `sens_data[10]` | RW | +| `0x0023` | 35 | `0x5023` | 20515 | 5035 | 6035 | `sens_data[11]` | RW | +| `0x0024` | 36 | `0x5024` | 20516 | 5036 | 6036 | `sens_data[12]` | RW | +| `0x0025` | 37 | `0x5025` | 20517 | 5037 | 6037 | `sens_data[13]` | RW | +| `0x0026` | 38 | `0x5026` | 20518 | 5038 | 6038 | `sens_data[14]` | RW | +| `0x0027` | 39 | `0x5027` | 20519 | 5039 | 6039 | `sens_data[15]` | RW | +| `0x0028` | 40 | `0x5028` | 20520 | 5040 | 6040 | `sens_data[16]` | RW | +| `0x0029` | 41 | `0x5029` | 20521 | 5041 | 6041 | `sens_data[17]` | RW | +| `0x002A` | 42 | `0x502A` | 20522 | 5042 | 6042 | `sens_data[18]` | RW | +| `0x002B` | 43 | `0x502B` | 20523 | 5043 | 6043 | `sens_data[19]` | RW | +| `0x002C` | 44 | `0x502C` | 20524 | 5044 | 6044 | `sens_data[20]` | RW | +| `0x002D` | 45 | `0x502D` | 20525 | 5045 | 6045 | `sens_data[21]` | RW | +| `0x002E` | 46 | `0x502E` | 20526 | 5046 | 6046 | `sens_data[22]` | RW | +| `0x002F` | 47 | `0x502F` | 20527 | 5047 | 6047 | `sens_data[23]` | RW | +| `0x0030` | 48 | `0x5030` | 20528 | 5048 | 6048 | `sens_hi_edge[0]` | RW | +| `0x0031` | 49 | `0x5031` | 20529 | 5049 | 6049 | `sens_hi_edge[1]` | RW | +| `0x0032` | 50 | `0x5032` | 20530 | 5050 | 6050 | `sens_hi_edge[2]` | RW | +| `0x0033` | 51 | `0x5033` | 20531 | 5051 | 6051 | `sens_hi_edge[3]` | RW | +| `0x0034` | 52 | `0x5034` | 20532 | 5052 | 6052 | `sens_hi_edge[4]` | RW | +| `0x0035` | 53 | `0x5035` | 20533 | 5053 | 6053 | `sens_hi_edge[5]` | RW | +| `0x0036` | 54 | `0x5036` | 20534 | 5054 | 6054 | `sens_hi_edge[6]` | RW | +| `0x0037` | 55 | `0x5037` | 20535 | 5055 | 6055 | `sens_hi_edge[7]` | RW | +| `0x0038` | 56 | `0x5038` | 20536 | 5056 | 6056 | `sens_hi_edge[8]` | RW | +| `0x0039` | 57 | `0x5039` | 20537 | 5057 | 6057 | `sens_hi_edge[9]` | RW | +| `0x003A` | 58 | `0x503A` | 20538 | 5058 | 6058 | `sens_hi_edge[10]` | RW | +| `0x003B` | 59 | `0x503B` | 20539 | 5059 | 6059 | `sens_hi_edge[11]` | RW | +| `0x003C` | 60 | `0x503C` | 20540 | 5060 | 6060 | `sens_hi_edge[12]` | RW | +| `0x003D` | 61 | `0x503D` | 20541 | 5061 | 6061 | `sens_hi_edge[13]` | RW | +| `0x003E` | 62 | `0x503E` | 20542 | 5062 | 6062 | `sens_hi_edge[14]` | RW | +| `0x003F` | 63 | `0x503F` | 20543 | 5063 | 6063 | `sens_hi_edge[15]` | RW | +| `0x0040` | 64 | `0x5040` | 20544 | 5064 | 6064 | `sens_hi_edge[16]` | RW | +| `0x0041` | 65 | `0x5041` | 20545 | 5065 | 6065 | `sens_hi_edge[17]` | RW | +| `0x0042` | 66 | `0x5042` | 20546 | 5066 | 6066 | `sens_hi_edge[18]` | RW | +| `0x0043` | 67 | `0x5043` | 20547 | 5067 | 6067 | `sens_hi_edge[19]` | RW | +| `0x0044` | 68 | `0x5044` | 20548 | 5068 | 6068 | `sens_hi_edge[20]` | RW | +| `0x0045` | 69 | `0x5045` | 20549 | 5069 | 6069 | `sens_hi_edge[21]` | RW | +| `0x0046` | 70 | `0x5046` | 20550 | 5070 | 6070 | `sens_hi_edge[22]` | RW | +| `0x0047` | 71 | `0x5047` | 20551 | 5071 | 6071 | `sens_hi_edge[23]` | RW | +| `0x0048` | 72 | `0x5048` | 20552 | 5072 | 6072 | `sens_lo_edge[0]` | RW | +| `0x0049` | 73 | `0x5049` | 20553 | 5073 | 6073 | `sens_lo_edge[1]` | RW | +| `0x004A` | 74 | `0x504A` | 20554 | 5074 | 6074 | `sens_lo_edge[2]` | RW | +| `0x004B` | 75 | `0x504B` | 20555 | 5075 | 6075 | `sens_lo_edge[3]` | RW | +| `0x004C` | 76 | `0x504C` | 20556 | 5076 | 6076 | `sens_lo_edge[4]` | RW | +| `0x004D` | 77 | `0x504D` | 20557 | 5077 | 6077 | `sens_lo_edge[5]` | RW | +| `0x004E` | 78 | `0x504E` | 20558 | 5078 | 6078 | `sens_lo_edge[6]` | RW | +| `0x004F` | 79 | `0x504F` | 20559 | 5079 | 6079 | `sens_lo_edge[7]` | RW | +| `0x0050` | 80 | `0x5050` | 20560 | 5080 | 6080 | `sens_lo_edge[8]`; LOAD: `DAC_go` — параметр начала рампы | RW | +| `0x0051` | 81 | `0x5051` | 20561 | 5081 | 6081 | `sens_lo_edge[9]`; LOAD: `DAC_stop` — параметр конца рампы | RW | +| `0x0052` | 82 | `0x5052` | 20562 | 5082 | 6082 | `sens_lo_edge[10]`; LOAD: `LOAD_CAN_TIMEOUT`, мс | RW; LOAD: 1…60000 | +| `0x0053` | 83 | `0x5053` | 20563 | 5083 | 6083 | `sens_lo_edge[11]` | RW | +| `0x0054` | 84 | `0x5054` | 20564 | 5084 | 6084 | `sens_lo_edge[12]` | RW | +| `0x0055` | 85 | `0x5055` | 20565 | 5085 | 6085 | `sens_lo_edge[13]` | RW | +| `0x0056` | 86 | `0x5056` | 20566 | 5086 | 6086 | `sens_lo_edge[14]` | RW | +| `0x0057` | 87 | `0x5057` | 20567 | 5087 | 6087 | `sens_lo_edge[15]` | RW | +| `0x0058` | 88 | `0x5058` | 20568 | 5088 | 6088 | `sens_lo_edge[16]` | RW | +| `0x0059` | 89 | `0x5059` | 20569 | 5089 | 6089 | `sens_lo_edge[17]` | RW | +| `0x005A` | 90 | `0x505A` | 20570 | 5090 | 6090 | `sens_lo_edge[18]` | RW | +| `0x005B` | 91 | `0x505B` | 20571 | 5091 | 6091 | `sens_lo_edge[19]` | RW | +| `0x005C` | 92 | `0x505C` | 20572 | 5092 | 6092 | `sens_lo_edge[20]` | RW | +| `0x005D` | 93 | `0x505D` | 20573 | 5093 | 6093 | `sens_lo_edge[21]` | RW | +| `0x005E` | 94 | `0x505E` | 20574 | 5094 | 6094 | `sens_lo_edge[22]` | RW | +| `0x005F` | 95 | `0x505F` | 20575 | 5095 | 6095 | `sens_lo_edge[23]` | RW | +| `0x0060` | 96 | `0x5060` | 20576 | 5096 | 6096 | `Cancount[m_FAST]` — пауза быстрых посылок CAN | RW | +| `0x0061` | 97 | `0x5061` | 20577 | 5097 | 6097 | `Cancount[m_SLOW]` — пауза медленных посылок CAN | RW | +| `0x0062` | 98 | `0x5062` | 20578 | 5098 | 6098 | `Brightness` / `Bright[0]` — яркость | RW | +| `0x0063` | 99 | `0x5063` | 20579 | 5099 | 6099 | `Owncount` / `Bright[1]`; LOAD: исходное значение АЦП | RW | +| `0x0064` | 100 | `0x5064` | 20580 | 5100 | 6100 | `Bright[2]`; `Current_count`: исходное значение канала 0 | RW | +| `0x0065` | 101 | `0x5065` | 20581 | 5101 | 6101 | `Bright[3]`; `Current_count`: исходное значение канала 1 | RW | +| `0x0066` | 102 | `0x5066` | 20582 | 5102 | 6102 | `Bright[4]`; `Current_count`: исходное значение канала 2 | RW | +| `0x0067` | 103 | `0x5067` | 20583 | 5103 | 6103 | `Bright[5]`; `Current_count`: исходное значение канала 3 | RW | +| `0x0068` | 104 | `0x5068` | 20584 | 5104 | 6104 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x0069` | 105 | `0x5069` | 20585 | 5105 | 6105 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x006A` | 106 | `0x506A` | 20586 | 5106 | 6106 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x006B` | 107 | `0x506B` | 20587 | 5107 | 6107 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x006C` | 108 | `0x506C` | 20588 | 5108 | 6108 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x006D` | 109 | `0x506D` | 20589 | 5109 | 6109 | `lev_count[]`, запись из `Current_count`; назначение по режиму | RW | +| `0x006E` | 110 | `0x506E` | 20590 | 5110 | 6110 | `DAC_20` — калибровка входа 20 мА | RW | +| `0x006F` | 111 | `0x506F` | 20591 | 5111 | 6111 | `DAC_04` — калибровка входа 4 мА | RW | +| `0x0070` | 112 | `0x5070` | 20592 | 5112 | 6112 | `Zero_lev[0]` — нулевой уровень канала тока 0 | RW | +| `0x0071` | 113 | `0x5071` | 20593 | 5113 | 6113 | `Zero_lev[1]` — нулевой уровень канала тока 1 | RW | +| `0x0072` | 114 | `0x5072` | 20594 | 5114 | 6114 | `Zero_lev[2]` — нулевой уровень канала тока 2 | RW | +| `0x0073` | 115 | `0x5073` | 20595 | 5115 | 6115 | `Zero_lev[3]` — нулевой уровень канала тока 3 | RW | +| `0x0074` | 116 | `0x5074` | 20596 | 5116 | 6116 | `Caliber[0]`: `K380_1` / `KPOW_1` | RW | +| `0x0075` | 117 | `0x5075` | 20597 | 5117 | 6117 | `Caliber[1]`: `K380_2` / `KPOW_2` | RW | +| `0x0076` | 118 | `0x5076` | 20598 | 5118 | 6118 | `Caliber[2]`: `KPOW_3` | RW | +| `0x0077` | 119 | `0x5077` | 20599 | 5119 | 6119 | `Caliber[3]`: `KPOW_4` | RW | +| `0x0078` | 120 | `0x5078` | 20600 | 5120 | 6120 | `Caliber[4]` / `TCaliber[0]`; LOAD: `DAC_max()` | RW | +| `0x0079` | 121 | `0x5079` | 20601 | 5121 | 6121 | `Caliber[5]` / `TCaliber[1]`; LOAD: `DAC_min()` | RW | +| `0x007A` | 122 | `0x507A` | 20602 | 5122 | 6122 | `Caliber[6]` / `TCaliber[2]`; LOAD: `DAC_cal()` | RW | +| `0x007B` | 123 | `0x507B` | 20603 | 5123 | 6123 | `Caliber[7]` / `TCaliber[3]`; LOAD: текущий 12-битный код ЦАП | RW | +| `0x007C` | 124 | `0x507C` | 20604 | 5124 | 6124 | `NormaU` = `Caliber[8]`; LOAD: `LOAD_STATUS` | RO для LOAD; иначе RW | +| `0x007D` | 125 | `0x507D` | 20605 | 5125 | 6125 | `DeltaU` = `Caliber[9]`; LOAD: `LOAD_ATTEMPTS` | RO для LOAD; иначе RW | +| `0x007E` | 126 | `0x507E` | 20606 | 5126 | 6126 | `LastMode` | RW | +| `0x007F` | 127 | `0x507F` | 20607 | 5127 | 6127 | `Commands` — битовая маска команд | RW | + +Слово `modbus[128]` в массиве используется для CRC сохраняемых параметров и **не входит** в эту карту. `0x5080` не является допустимым GAS-адресом. + +## Информация GAS-профиля + +Эти слова читаются через FC03 непосредственно по адресам `0x5F00…0x5F07`. Для них нет соответствующих штатных регистров `0x0000…0x007F`. + +| GAS = адрес Modbus, hex | Адрес, dec | Значение | Назначение | +|---|---:|---|---| +| `0x5F00` | 24320 | `0x4741` | Сигнатура `GA` | +| `0x5F01` | 24321 | `0x5331` | Сигнатура `S1` | +| `0x5F02` | 24322 | `1` | Версия контракта GAS | +| `0x5F03` | 24323 | `167` | Профиль устройства | +| `0x5F04` | 24324 | `0x5000` | База технологических GAS-регистров | +| `0x5F05` | 24325 | `128` | Число технологических регистров | +| `0x5F06` | 24326 | `0x0007` | Возможности: бит 0 — RTU, бит 1 — служебный CAN, бит 2 — FC06 | +| `0x5F07` | 24327 | `1` | Аппаратный вариант EEPROM M95M02 | + +Все восемь слов — **RO**. В сборке AT25256B GAS-адреса, включая этот блок, отклоняются с кодом недопустимого адреса. + +## Маски команд и состояния LOAD + +Регистр `Commands`: штатный Modbus **`0x007F`**, GAS **`0x507F`**. Операция FC06 записывает всё слово; обработчик вызывает `ump_log_rs()`. Смысл и выполнение команд определяет прошивка. + +| Бит | Маска | Имя в исходниках | +|---:|---|---| +| 0 | `0x0001` | `cTestLamp` | +| 1 | `0x0002` | `cDefParam` | +| 2 | `0x0004` | `cSaveParam` | +| 3 | `0x0008` | `cLoadParam` | +| 4 | `0x0010` | `cTermoCal` | +| 5 | `0x0020` | `cKoefCalc` | +| 6 | `0x0040` | `cUMPreset` | +| 7 | `0x0080` | `cUMPstart` | +| 8 | `0x0100` | `cInitDac` | +| 9 | `0x0200` | `cCalibrDac` | +| 10 | `0x0400` | `cSecretBt` | +| 11 | `0x0800` | `cLiteFire` | +| 12 | `0x1000` | `cRawMeat` | +| 13 | `0x2000` | Имя команды в package.h не задано | +| 14 | `0x4000` | Имя команды в package.h не задано | +| 15 | `0x8000` | `cReset` | + +Для `Desk == dsk_LOAD` регистр `LOAD_STATUS` (`0x007C` / `0x507C`) формируется в `DAC.c` (проект tms_periph/periph_167): + +| Бит | Маска | Смысл | +|---:|---|---| +| 0 | `0x0001` | Состояние `LOAD_FAULT`: ошибка после пяти попыток пуска | +| 1 | `0x0002` | Нет свежего напряжения ЗПТ | +| 2 | `0x0004` | Состояние `LOAD_RELOAD`: сброс перед повторным пуском | +| 3 | `0x0008` | Состояние `LOAD_RUN`: работа рампы | +| 4 | `0x0010` | Имитация потери ЗПТ: приём напряжения из CAN игнорируется | +| 5 | `0x0020` | Имитация 0 В для защиты; реальные значения CAN в логгере сохраняются | + +`LOAD_ATTEMPTS` (`0x007D` / `0x507D`) содержит число попыток пуска. Оба слова доступны только для чтения на LOAD. + +## Запросы и ограничения + +| Операция | Штатные адреса `0x0000…0x007F` | GAS-адреса | +|---|---|---| +| FC03 | 1…125 слов в пределах 128 регистров | 1…124 слова; весь блок внутри одного GAS-региона | +| FC06 | Одно 16-битное слово | Одно 16-битное слово; ответ сохраняет GAS-адрес | +| FC04 / FC16 | Не используются этими обработчиками для данной карты | GAS-provider поддерживает функции 3 и 6 | +| LOAD: запись `LOAD_STATUS`, `LOAD_ATTEMPTS` | Код 2 | Код 2 | +| LOAD: запись `LOAD_CAN_TIMEOUT` вне 1…60000 мс | Код 3 | Код 3 | + +GAS-provider возвращает код 1 для неподдерживаемой функции, код 2 для недопустимого адреса, пересечения границы или записи RO-блока; код 3 — для количества слов 0 или >124 и недопустимого LOAD-timeout. В RTU запросы к этой карте маршрутизируются обработчиками FC03/FC06. + +В RTU данные одного слова передаются старшим байтом вперёд. Формат ответа GAS-расширения отличается от исторического ответа низких адресов: у GAS нет дополнительных хвостовых нулей. + +Примеры ниже задают функцию, адрес и количество/значение; это параметры запроса, без адреса устройства и CRC: + +| Действие | Штатный Modbus | Через GAS | +|---|---|---| +| Прочитать информацию профиля | Нет аналога | `FC03(0x5F00, 8)` | +| Прочитать слово 0 | `FC03(0x0000, 1)` | `FC03(0x5000, 1)` | +| Прочитать все 128 слов | `FC03(0x0000, 125)` + `FC03(0x007D, 3)` | `FC03(0x5000, 124)` + `FC03(0x507C, 4)` | +| Прочитать timeout LOAD | `FC03(0x0052, 1)` | `FC03(0x5052, 1)` | +| Задать timeout LOAD = 1000 мс | `FC06(0x0052, 1000)` | `FC06(0x5052, 1000)` | +| Прочитать статус и попытки LOAD | `FC03(0x007C, 2)` | `FC03(0x507C, 2)` | + +Два запроса на чтение всей карты дают два отдельных снимка. FC06 меняет RAM; сохранение параметров в EEPROM выполняется штатным механизмом прошивки. + +Эти же GAS-адреса доступны через **служебный CAN УМП** с маркером `0x1F00`, token и функцией 3/6. CAN-чтение принимает чётное количество **2…124 слова**; каждый фрагмент ответа содержит два слова. Формат запроса приведён в `GAS_167_M95M02.md` проекта tms_periph/periph_167. + +Другие расширения Modbus — регистратор `0x1000…0x117B`, паспорт `0x1200…0x120B`, защита `0x1300…0x1307` и EEPROM `0x8000…0xFFFF` — имеют собственные обработчики. Формула `GAS = 0x5000 + Modbus` применяется только к штатным **128 технологическим словам**. + +## Источники + +- `gas167.h` (проект tms_periph/periph_167) — базы, размеры, версия и лимит GAS. +- `gas167.c` (проект tms_periph/periph_167) — прямое соответствие `modbus[]`, информация профиля и ограничения записи. +- `package.h` (проект tms_periph/periph_167) — группы регистров, имена и маски команд. +- `message.c` (проект tms_periph/periph_167) — FC03/FC06, инициализация и сохранение параметров. +- `measure.c` (проект tms_periph/periph_167), `ADC.c` (проект tms_periph/periph_167), `DAC.c` (проект tms_periph/periph_167) — значения измерений и особенности LOAD. +- `GAS_167_M95M02.md` проекта tms_periph/periph_167 — включение GAS, форматы транспорта и проверки реализации. diff --git a/doc/setcan/protocan/PROTOCOL.md b/doc/setcan/protocan/PROTOCOL.md index b3c0273..94def5d 100644 --- a/doc/setcan/protocan/PROTOCOL.md +++ b/doc/setcan/protocan/PROTOCOL.md @@ -147,3 +147,78 @@ CAN ID = 0x13590702 Этот пример соответствует `ENTER_BOOT`, `SessionID=7`. Машинные варианты находятся в [examples/test-vectors.json](examples/test-vectors.json). + +## GAS: сквозная карта УКСС и профиль 167/v1 + +УКСС назначены десятичные ID **0…11999**, блоки по 1000 слов: +`GAS_ID = номер_узла × 1000 + смещение`. Блок 0 — общий резерв; +узлы 1…11 и все границы описаны в +[GAS_ADDRESS_ALLOCATION_167.md](GAS_ADDRESS_ALLOCATION_167.md). +Диапазон 12000…65535 свободен для последующего распределения. + +УМП 1 (узел 5) назначены ID 5000…5127, УМП 2 (узел 6) — +6000…6127. Смещения 128…999 зарезервированы. Полный словарь 128 слов, +ограничения LOAD, биты Commands/LOAD_STATUS и соответствие Modbus приведены в +[GAS_MODBUS_MAP_167.md](GAS_MODBUS_MAP_167.md). + +Новые ID пока назначены только в документации. Действующий профиль 167/v1 +использует Modbus 0x0000…0x007F и окно GAS 0x5000…0x507F; +например, LOAD_CAN_TIMEOUT: Modbus 0x0052, GAS 0x5052, +новые ID 5082/6082. Десятичный ID 5000 не равен hex-адресу 0x5000. +Служебный CAN УМП с маркером 0x1F00 — отдельный транспорт; +его пакет нельзя интерпретировать как обычный payload ProtoCAN GAS. + +Словарь и lookup находятся в `python/protocan/gas_167.py` и +`gas_167_registers.json` проекта templates. Парсер обычных кадров GAS +показывает назначенные имена, узел, старые адреса и статус для запросов +и каждого слова ответа; неизвестные и резервные адреса сохраняются raw. +Общая сводка `Decoded.summary` содержит имя каждого назначенного параметра, +УМП 1/2, десятичный ID, hex-адрес и значение в dec/hex. Поэтому имена видны +непосредственно в колонке «Расшифровка» всех клиентов общего декодера: +онлайн-монитор, трассировка, фильтр кадров, ручной разбор и DevBoard_V1. +Полный текст длинной строки доступен во всплывающей подсказке. +Тип сообщения отображается как `GAS (General Address Space)`. +Обычные демо-потоки шины и DevBoard также включают назначенные адреса УМП. +Низкие Modbus-адреса и старое окно не определяют профиль прибора автоматически. +DATA обычного ProtoCAN GAS — до четырёх u16 little-endian; Modbus RTU +передаёт каждое слово старшим байтом вперёд. GUI использует этот же словарь +через совместимый импорт, без собственной реализации карты. + +### Демо УМП в SETGUI + +Общий список «Протокол» позволяет выбрать `ProtoCAN / GAS`, +`УМП / Balsam 167 (legacy CAN)`, карту `GAS 167/v1` отдельно для УМП 1/2 +или `УМП / ProtoCAN GAS — новые ID 5000/6000`. +Выбор перерисовывает уже полученные кадры. Для старой карты номер УМП +выбирается оператором и обозначается «выбран вручную»; низкие адреса +0…127 и окно 0x5000…0x507F сами по себе номер узла не кодируют. +Этот режим накладывает словарь на слова ProtoCAN GAS/Modbus Holding; +он не является декодером служебных CAN-пакетов с маркером 0x1F00. +Профиль Balsam принимает только собственный формат и показывает +несоответствие для чужих кадров. В сводке Commands показаны активные +биты с названиями команд; это не подтверждение их выполнения. + +В режиме «Авто» legacy-ID Balsam проверяются первыми. Для корректного +ProtoCAN GAS с целыми словами все адреса кадра сверяются с назначенными +диапазонами УМП. Если все они принадлежат одному узлу, поля Device Name +и Msg Type показывают УМП 1/2; основание выбора записывается в подробности +«Автоопределение». Это распознавание назначенной карты, а не подтверждение +её поддержки прошивкой. Для старых/неизвестных адресов, смешанных диапазонов +и некорректного payload автоматический выбор УМП не выполняется; +сводка сообщает «профиль/узел не определены». Номер Device в ProtoCAN сам +по себе не считается номером УМП. «Демо УМП» запускается с режимом «Авто». + +Открыть `CAN → Шина CAN / мост → Настройки → Демо УМП`. +Генератор `python/set_devices/bus_demo.py:UmpDemoSource` моделирует два +узла с демонстрационными адресами `Device Type=3`, `Device=5/6`. +Он передаёт PULSE раз в секунду и четыре GAS-слова каждого узла каждые +250 мс: ID 5000…5127 и 6000…6127, полный обход за 8 секунд. +Это имитация назначенной карты, а не эмуляция текущего транспорта 167/v1. + +Кадры проходят обычное кадрирование AA 55, CRC и общий парсер ProtoCAN. +Их можно смотреть в онлайн-мониторе, трассировке и адресном пространстве; +в буфере сессии и его CSV источник обозначен `DEMO УМП`. +Значения синтетические, в формате raw u16. LOAD_STATUS показывает RUN +(бит 3), LOAD_ATTEMPTS — 0, LOAD_CAN_TIMEOUT — 1000 мс. +Кнопка «Стоп демо УМП» останавливает поток. Для запуска нужно отключить +COM-порт и CAN-снифер; генератор работает локально без передачи в оборудование. diff --git a/doc/setcan/Протокол CAN и ОАП.md b/doc/setcan/Протокол CAN и ОАП.md index 7048cfe..bb149c8 100644 --- a/doc/setcan/Протокол CAN и ОАП.md +++ b/doc/setcan/Протокол CAN и ОАП.md @@ -642,11 +642,11 @@ CRC32 обнаруживает случайное повреждение, но   64 битовых адресса - +     0x3 -0x0 - 0xF устройств +0x0 - 0xF устройств; прежнее общее назначение F28335 3/7 отменено 0x3. Установка времени     diff --git a/docs/reference-comparison.md b/docs/reference-comparison.md new file mode 100644 index 0000000..2515734 --- /dev/null +++ b/docs/reference-comparison.md @@ -0,0 +1,210 @@ +# Сравнение сигналов с CSV-эталоном + +Общее ядро `set_devices.signal_reference` и Qt-порт +`set_devices.qt_ports.reference_comparison` сравнивают измеренную запись с +эталоном. Они не импортируют приложение, не выбирают его каталоги и не +требуют конкретного движка отрисовки. Ядро использует только стандартную +библиотеку Python; Qt-порт поддерживает PySide6 и PySide2. + +## Численный API + +`Comparison.analysis` содержит локальный параметрический отчёт: +`summary`, `details`, `attention`, `findings`, `features`, `model_report`. +Он рассчитывается `set_devices.signal_diagnostics` по тому же интервалу, +временному сдвигу и допускам. Исходные метрики используют все общие отсчёты; +объём выборки подбора моделей явно указан в `model_report`. +Добавлено необязательное поле с пустым словарём по умолчанию: прежний +конструктор `Comparison` с пятью аргументами сохраняет совместимость. + +Для одинаковых известных обозначений `Uce`, `Uge`, `Ic` анализ различает +повторные полные переходы, усиленный звон Uce после выключения, отсутствие +переключения, растянутые фронты и временной сдвиг. Общие причины включают +смещение, шум и неопределённое изменение формы. Разные известные физические +каналы, недостаточное число точек и неполное покрытие отражаются в выводе. +На неизвестных каналах аппаратные гипотезы о снаббере и транзисторе не строятся. + +`set_devices.signal_fault_models` подбирает смещение, масштаб, дрейф, +задержку, растяжение фронтов, постоянный уровень, кусочные прерывания и +затухающий отклик RLC. `set_devices.signal_joint_models` добавляет локальную +ВАХ `Uce=U0+Rdiff*Ic`, остаточный ток выключенного состояния и совпадение +провалов управления/проводимости по трём каналам. + +`compare(..., circuit=..., channels=...)` принимает необязательные известные +`inductance_h`, `capacitance_f`, `resistance_ohm` в СИ и словарь связанных +каналов `dict(measured=..., reference=...)`. GUI формирует эти снимки сам. +Однозначность физических имён и единиц проверяется до совместного анализа. + +Глобальные модели сравниваются по BIC общей обучающей выборки и RMSE на +каждой третьей отложенной точке. JSON сохраняет всех конкурентов, число +параметров, формулы, критерии и ограничения. BIC не интерпретируется как +вероятность аппаратного отказа. Без известного элемента R/L/C не выдаются +три произвольных номинала: доступны только LC и R/L последовательного +эквивалента полюсов. + +`ComparisonAnalysis` в `qt_ports.reference_results` показывает одну строку +и кнопку отчёта с таблицей моделей и подробным текстом по образцу Android GUI. Компонент +используется и над исходным графиком, и в полной форме сравнения; изменение +входов, чтение другого эталона и ошибки очищают прежний анализ. JSON-экспорт +обоих видов включает `analysis` рядом с `metrics`. Ядро не использует сеть +или обученную модель; пороги признаков и ограничения указаны в подробностях. + +`Trace(name, times, values)` содержит имя, строго возрастающие временные +метки в миллисекундах и амплитуды в единицах канала. `trace_from_pairs` +создаёт такой снимок из пар `(time_ms, value)`; необязательный `transform` +применяет множитель или иное преобразование амплитуды. + +`read_reference(path, cancel=...)` потоково читает UTF-8 CSV с необязательным +BOM. Первый столбец — `time_ms` либо `timestamp` с ISO-временем и явным +часовым поясом. Остальные столбцы имеют непустые уникальные имена каналов. +Поддерживаются запятая, точка с запятой и табуляция. Пустые ячейки и NaN +сохраняются как пропуски; повторное время, бесконечности и повреждённые +строки отклоняются. + +```python +from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Ядро можно использовать без Qt и графика. +measured = trace_from_pairs('U (V)', [(0., 2.), (1., 4.), (2., 6.)]) # Время задано в миллисекундах, амплитуда — в вольтах. +reference = read_reference('reference.csv')['U (V)'] # Столбец выбирается по полному имени канала. +result = compare(measured, reference, absolute_tolerance=.1, relative_tolerance_pct=2.) # Допуск равен 0,1 В плюс 2% модуля эталона. +print(result.metrics['rmse']) # Показатель рассчитан по всем корректным точкам измерения в общем интервале. +``` + +`compare` возвращает `Comparison`: нормализованные кривые `measured` и +`reference`, остаток `difference`, эталон на сетке остатка `expected` и +словарь `metrics`. Статистика включает покрытие, пропуски, среднее смещение, +MAE, RMSE, СКО без смещения, максимальную ошибку и выходы за допуск. + +Эталон линейно интерполируется на исходную сетку измерения без +экстраполяции и соединения через NaN. Остаток равен измерению минус эталон. +Допуск равен `absolute_tolerance + relative_tolerance_pct / 100 * abs(reference)`; +равенство границе допуска не считается превышением. Экранная децимация +не участвует в расчёте. + +По умолчанию начала записей совмещаются. `align_start=False` сохраняет +различие абсолютных начал; положительный `shift_ms` сдвигает эталон вправо. +`interval=(start_ms, stop_ms)` задаётся от начала измерения, обе границы +включены. Callback `cancel()` прерывает подготовку, чтение и сравнение через +`InterruptedError`. + +## Наложение на существующий график + +Для добавления эталона в уже работающий график используйте +`set_devices.qt_ports.reference_overlay.ReferenceOverlayPanel`. Это +компактные параметры без canvas. Оригинальный график +остаётся у потребителя и продолжает показывать измерение при загрузке и +изменении эталона. + +Конструктор принимает `sources`, `parent`, `source_path` и необязательный +`path_provider` с тем же смыслом, что в таблице ниже. `load_reference(path)` +автоматически читает CSV, совмещает выбранные каналы и рассчитывает показатели +в рабочем потоке. Отдельной кнопки запуска расчёта нет. Изменение канала, +сдвига, совмещения начал или допусков обновляет результат автоматически. + +Сигнал `changed` сообщает потребителю, что `overlay()` вернёт актуальные +спецификации и точки одной дополнительной кривой. Потребитель **добавляет** +их к оригинальным рядам и общей легенде своего canvas. `RESULT_KEY` +отличает эталон от измерений; оранжевая кривая имеет подпись +`Эталон: <канал>`. Временные метки эталона уже находятся в исходной шкале +измерения: начало оригинала не обнуляется. Потребитель применяет своё +перебазирование короткой развёртки к обеим кривым одинаково. + +`set_sources(sources)` обновляет кадр прибора или преобразование амплитуд, +сохраняя загруженный эталон. Сигнал `remove_requested` предлагает владельцу +удалить дополнительный слой и параметры. `report()` и раскрываемая строка +«Допуски и отчёт» предоставляют актуальную статистику и экспорт JSON. +Если общей части времени нет, кривая остаётся доступна, а показатели +заменяются объяснением отсутствия перекрытия. `close()` останавливает worker, +не закрывая график потребителя. + +Истории, переданные в одном снимке `sources`, должны быть неизменяемыми; +для нового кадра передайте новую историю через `set_sources`. Порт +поддерживает PySide6 и PySide2 без импорта приложения. + +### Подробные показатели рядом с исходным графиком + +`ReferenceOverlayPanel.results` — виджет `ComparisonResults` с полной +таблицей всех 14 показателей: покрытие, пропуски, смещение, MAE, RMSE, +СКО, максимум и его время, нормированная ошибка и выходы за допуск. +Потребитель размещает его в существующей боковой области рядом с canvas; +в SETGUI он открывается автоматически во вкладке «Отклонения и расчёты». +Сигнал `results_requested` кнопки «Расчёты» позволяет вернуться к этой +области после выбора обычной легенды. + +Флажок `results.delta_curve` добавляет в `overlay()` кривую с ключом +`DIFFERENCE_KEY`. Её временные метки находятся в той же исходной шкале, +что эталон и оригинал. Исходные кривые сохраняются, отдельный canvas не +создаётся. Кнопка JSON рядом с таблицей экспортирует тот же актуальный +результат. Изменение параметров и ошибки очищают устаревшие показатели. + +`comparison_rows(metrics)` из `qt_ports.reference_results` задаёт единые +подписи, порядок и единицы как для этой таблицы, так и для прежнего диалога. + +## Совместимые Qt-панель и отдельное окно + +`ReferenceComparisonPanel` — обычный `QWidget` для существующей страницы +графика. Кнопка «К записи» испускает `back_requested`; потребитель сам +скрывает панель и возвращает исходный вид. При повторном показе той же +панели сохраняются эталон, параметры и рассчитанные показатели. + +`ReferenceComparisonDialog` — отдельный `QDialog` с тем же расчётом и +настройками. Оба конструктора принимают: + +| Параметр | Контракт | +| --- | --- | +| `sources` | Последовательность пар `(source, points)`: `source.name` — имя, `source.display_value(value)` — преобразование амплитуды, `points` — полная неизменяемая история `(time_ms, value)` | +| `parent` | Родительский QWidget; для встроенной панели передайте страницу графика | +| `source_path` | Происхождение измерения для JSON-отчёта, по умолчанию пустая строка | +| `plot_factory` | Обязательная фабрика без аргументов, возвращающая настроенный QWidget графика | +| `series_factory` | Обязательная фабрика `factory(key, name, color=..., timestamps=..., values=...)`, создающая кривую потребителя | +| `path_provider` | Необязательный callback `filename → начальный путь`; пустое имя используется для открытия эталона, `reference_comparison.json` — для отчёта | + +```python +from set_devices.qt_ports.reference_comparison import ReferenceComparisonPanel # Qt загружается только при импорте конкретного порта. +panel = ReferenceComparisonPanel( # Один компонент обслуживает выбор каналов, параметры, фоновый расчёт и экспорт. + sources=recording_channels, # Потребитель фиксирует полные истории и преобразование амплитуд до создания панели. + parent=chart_page, # Сравнение размещается в существующей рабочей области. + source_path=recording_filename, # Имя исходной записи попадёт в отчёт. + plot_factory=create_comparison_canvas, # Адаптер создаёт график с единой шкалой Y для обеих кривых. + series_factory=create_series, # Контейнеры кривых принадлежат используемому движку отрисовки. + path_provider=recording_path, # Папки и правила хранения остаются политикой потребителя. +) # Все функции и объекты адаптера задаются приложением до этого вызова. +chart_page.layout().addWidget(panel, 1) # Панель занимает свободное место страницы. +panel.back_requested.connect(show_recording) # Обработчик скрывает сравнение и возвращает исходный график. +``` + +Canvas должен предоставлять изменяемый список `_series`, методы +`reset_interaction()`, `_sync_bounds()`, стандартные `update()` и `close()`, +флажки `axis_lock_x` и `axis_lock_y` с `setChecked()`, поле `axis_limits`, +`visible_bounds.left/right` в миллисекундах и `viewport` типа +`protocan.plot.Viewport`. `_sync_bounds()` задаёт конечный ненулевой диапазон +X даже для пустого графика. Обе наложенные кривые используют **одну шкалу Y**; +автоматическое разделение каналов на независимые оси отключает адаптер. +Дополнительные кнопки canvas, подписи и правила снимков также настраиваются +в `plot_factory`. + +Панель передаёт фабрике кривых полные массивы: их экранное сокращение остаётся +ответственностью canvas. Временные поля интерфейса показывают микросекунды, +ядро и JSON-отчёт используют миллисекунды. Явно различающиеся единицы в +скобках имён каналов блокируют сравнение; автоматической конвертации нет. + +Истории и настройки источников нельзя менять, пока панель использует их +снимок. При смене записи или преобразования канала закройте старую панель +и создайте новую. `close()` встроенной панели отменяет и дожидается worker; +уничтожение родителя также останавливает поток до удаления дочерних объектов. +Отдельный диалог откладывает закрытие до завершения кооперативной отмены. + +`load_reference(path)` позволяет открыть эталон программно, `report()` +возвращает актуальный словарь отчёта либо `None`. Изменение входных +параметров очищает старые кривые и показатели до следующего расчёта. + +## Проверки + +`python/tests/test_reference_comparison_port.py` проверяет внедрение +стороннего canvas, преобразование амплитуд, отклонение и экспорт с внешним +provider путей, а также API отдельного диалога. Его можно запустить из +каталога `templates/python` командой +`python -m unittest discover -s tests -p test_reference_comparison_port.py`. + +`python/tests/test_shared_library_boundary.py` проверяет независимость +вычислительных модулей от GUI и Qt. Интеграционные проверки потребителя +дополнительно покрывают полные истории, общий масштаб Y, отмену, смену +записи, возврат к графику и полноэкранный режим. diff --git a/python/README.md b/python/README.md index 30ba13f..0a039f2 100644 --- a/python/README.md +++ b/python/README.md @@ -15,6 +15,10 @@ | `set_devices.qt_ports` | UART/mock, SLCAN, Candle/WinUSB, STM/TMS boot, STM settings, UMP CAN | | `set_devices.plot_processing` | Общий контракт графиков: аппроксимация, интерполяция, восстановление, единицы и CSV через C99 | | `set_devices.qt_ports.plot_processing` | Переиспользуемая панель всех методов, фоновый расчёт и отдельный слой кривой | +| `set_devices.signal_reference` | Сравнение полной записи с CSV-эталоном: совмещение времени, допуски, остаток и показатели ошибки без Qt | +| `set_devices.qt_ports.reference_comparison` | Общие встроенная панель и диалог сравнения, фоновое чтение/расчёт, внедряемые график и папки, JSON-отчёт | +| `set_devices.qt_ports.reference_overlay` | Компактные параметры и автоматически подготовленный эталон для наложения на существующий canvas без создания второго графика | +| `set_devices.qt_ports.reference_results` | Полная таблица отклонений, единое форматирование 14 показателей, флажок кривой Δ и экспорт | | `protocan`, `setprotocol` | Существующие обёртки общего C99-ядра и SET v2 | Импорт `logic_analyzer` / `set_devices` не загружает Qt и не зависит от GUI. @@ -24,6 +28,9 @@ Saleae SDK подключается при обращении к устройс в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`: [контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md). +Сравнение с эталоном подключается через фабрики canvas и кривой без +зависимости от приложения: [API ядра, контракт Qt-порта и пример](../docs/reference-comparison.md). + Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет diff --git a/python/logic_analyzer/decoders/set_can.py b/python/logic_analyzer/decoders/set_can.py index 9e8e51e..65fe26e 100644 --- a/python/logic_analyzer/decoders/set_can.py +++ b/python/logic_analyzer/decoders/set_can.py @@ -16,20 +16,9 @@ BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIF def legacy(ident, data, profile='protocan'): if not 0 <= ident <= 0x1fffffff or len(data) > 8: raise ValueError('Invalid classic CAN frame') - relative = ident - 0xba0000 if profile == 'balsam': - if not (0 <= relative < 13 or 16 <= relative < 29): - return None - if len(data) != 8: - raise ValueError('Balsam requires DLC=8') - header = number(data[:2], 'big') - address, mask = header & 0x1fff, header >> 13 - registers = [(address + i, number(data[2+i*2:4+i*2], 'big')) - for i in range(3) if mask & (4 >> i)] - return dict(protocol='Balsam', registers=registers, summary= - 'Balsam node=%d %s mask=%X %s' % ((relative & 15) + 1, - 'data' if relative >= 16 else 'command', mask, - ' '.join('R%04X=%04X' % pair for pair in registers))) + from .template_protocols import decode_can # Единая реализация templates знает порядок value2/value3/header/value1 и специальные ID пульта. + return decode_can(ident, data, 'balsam') # Старый публичный вызов получает исправленную расшифровку без второго варианта wire-формата. msg, body = (ident >> 16) & 15, ident & 0xffff result = dict(protocol='ProtoCAN', priority=(ident >> 28) & 1, route=(ident >> 27) & 1, device_type=(ident >> 24) & 7, diff --git a/python/logic_analyzer/decoders/template_protocols.py b/python/logic_analyzer/decoders/template_protocols.py new file mode 100644 index 0000000..6a65894 --- /dev/null +++ b/python/logic_analyzer/decoders/template_protocols.py @@ -0,0 +1,63 @@ +from pathlib import Path # Путь к поставляемым библиотекам вычисляется относительно ядра декодера, независимо от рабочего каталога DSView. +import sys # Встроенному Python требуется путь к переносимым пакетам, которые поставляются вместе с декодерами. +from types import SimpleNamespace # Общим CAN-парсерам нужен только набор свойств кадра, без подключения транспорта или Qt. + +_vendor = Path(__file__).with_name('templates') # Установщик помещает исходные пакеты templates в закрытый подкаталог общего ядра. +if _vendor.is_dir() and str(_vendor) not in sys.path: # При запуске из исходников используются штатные пакеты; установленный DSView получает собственные копии. + sys.path.insert(0, str(_vendor)) # Локальные версии пакетов имеют приоритет и не требуют установленного SETGUI или внешнего Python. + +from protocan import balsam, legacycan, protocan as pc # Форматы ID, порядок слов и каталоги проектов берутся из действующих библиотек templates. +from set_devices import deif_xdi, ump_can # Профиль DEIF и словарь УМП используются теми же функциями, что в CAN-мониторе. + +CAN_PROFILES = ('protocan', 'auto', 'devboard', 'balsam', 'ump-protocan', 'deif', 'legacy', 'set-v2') # Старые ключи сохранены для ранее настроенных записей DSView. +LEGACY_PROJECTS = tuple(project.name for project in legacycan.PROJECTS) # Список выбора обновляется вместе с единым каталогом исторических проектов. + +def _decoded(result): # Преобразует нейтральную модель CAN-монитора в словарь событий анализатора. + return dict(protocol=result.protocol, summary=result.summary, fields=list(result.fields), registers=result.registers, warnings=list(result.warnings), device=result.device_label) # Копии списков исключают изменение результата другим слоем декодирования. + +def _balsam(ident, payload): # Разбирает фактический порядок слов Balsam 167 вместо упрощённого заголовка в начале данных. + result = balsam.decode(ident, payload, native=pc._SHARED_PROTOCOL) if pc._SHARED_PROTOCOL is not None else balsam._decode_fallback(ident, payload) # Без DLL используется существующий переносимый разбор; повторный поиск DLL на каждом кадре исключён. + if result is None: # Чужой идентификатор не должен получать подпись Balsam. + return None # Отсутствие подходящего кадра не создаёт ложную ошибку на общей шине. + registers = [(item.address, item.value) for item in result.registers] # Общий парсер уже применил маску присутствия и восстановил порядок трёх слов. + fields = [('Узел', result.device_name), ('Маршрут', result.route), ('Начальный адрес', '0x%04X' % result.start_address), ('Маска слов', format(result.present_mask, '03b'))] # Адрес имеет ширину 13 бит, а маска содержит три признака из wire-формата Balsam. + return dict(protocol='Balsam', summary=result.summary, registers=registers, fields=fields, warnings=[]) # Сохраняет совместимое имя протокола и полную сводку с командами общего парсера. + +def decode_can(ident, payload, profile='protocan', *, extended=True, ump_map='gas167', ump_node=1, project=LEGACY_PROJECTS[0]): # Явные настройки предотвращают угадывание несовместимых карт регистров по содержимому данных. + payload = bytes(payload) # Парсеры получают неизменяемые байты вне зависимости от контейнера, переданного sigrok. + if not 0 <= ident <= (0x1fffffff if extended else 0x7ff) or len(payload) > 8: # Classic CAN допускает 29-битный EXT либо 11-битный STD ID и не более восьми байт. + raise ValueError('Invalid classic CAN frame') # Повреждённый ID или DLC отклоняется до интерпретации прикладных полей. + if profile not in CAN_PROFILES or profile == 'set-v2': # Сегментированный SET v2 обрабатывается отдельной машиной состояний с тайм-аутом. + raise ValueError('Unsupported CAN profile: ' + profile) # Неизвестный профиль не подменяется ProtoCAN молча. + if not extended and profile != 'deif': # Все поставляемые профили SET используют EXT; DEIF работает со стандартными CANopen ID. + return None # Стандартный кадр не интерпретируется как адресное поле ProtoCAN. + frame = SimpleNamespace(can_id=ident, data=payload, ide=extended, rtr=False, is_error=False) # Минимальный кадр соответствует контракту общих функций CAN-монитора. + if profile == 'deif': # CANopen/DEIF использует собственную адресацию и проверку селектора. + return _decoded(deif_xdi.decode(frame)) # Поля, физические единицы и замечания полностью определяет имеющийся парсер. + if profile == 'balsam' or profile == 'auto' and balsam.is_balsam_id(ident): # В автоматическом режиме Balsam распознаётся только по его документированным идентификаторам. + return _balsam(ident, payload) # Включает отдельную пару CAN ID пульта, присутствующую в общей библиотеке. + if profile == 'legacy': # Старые проектные форматы требуют явного проекта, поскольку диапазоны ID могут пересекаться. + selected = next((item for item in legacycan.PROJECTS if item.name == project), None) # Поиск использует общий каталог без копирования ID в анализатор. + if selected is None: # Ошибка выбора не должна давать расшифровку другого проекта. + raise ValueError('Unknown legacy CAN project: ' + project) # Сообщение остаётся видимым на дорожке ошибок. + node = selected.node_for(ident) # Маршрутизация зависит от формата: точный ID либо старшие биты ID. + if node is None: # На общей шине допустимы кадры других устройств и проектов. + return None # Чужие ID остаются только в дорожках базового CAN-декодера. + packet = legacycan.decode(selected.format, node, ident, payload) # Общая реализация восстанавливает адрес, маску и слова с нужным порядком байтов. + if packet is None: # Известный ID с неподходящим DLC обозначает повреждённый кадр выбранного проекта. + raise ValueError('Invalid legacy CAN payload') # Некорректные данные не выдаются как достоверные регистры. + registers = list(packet.present_values) # Получает только отмеченные маской слова в исходном адресном порядке. + commands = ['%s: %s' % ('R%04X' % address, ', '.join(selected.command_names[bit] for bit in range(16) if value & (1 << bit))) for address, value in registers if address == 127] # Регистр 127 хранит 16 командных битов с названиями выбранного проекта. + summary = '%s · %s · %s · %s' % (project, node.name, packet.source.value, ' '.join('R%04X=%04X' % pair for pair in registers)) # Краткая подпись связывает проект, устройство, направление и значения слов. + return dict(protocol='Legacy CAN', summary=summary, fields=[('Проект', project), ('Устройство', node.name), ('Направление', packet.source.value)] + [('Команды', text) for text in commands], registers=registers, warnings=[]) # Все подробности доступны на самостоятельных дорожках DSView. + if profile == 'ump-protocan' and ump_map == 'gas167': # Текущая карта 167/v1 назначается вручную и не смешивается с новыми глобальными ID. + if ump_node not in (1, 2): # В профиле имеются два УМП; номер не извлекается из локального адреса регистра. + raise ValueError('UMP node must be 1 or 2') # Ошибочный номер узла должен быть исправлен в настройках. + return _decoded(ump_can.decode_gas167(frame, ump_node)) # Имена параметров и активные биты берутся из действующей карты templates. + if profile == 'ump-protocan' and ump_map != 'assigned': # Назначенная глобальная карта обрабатывается штатным ProtoCAN ниже. + raise ValueError('Unknown UMP map: ' + ump_map) # Неизвестную карту нельзя интерпретировать как другую раскладку регистров. + from .set_can import legacy # Поздний импорт сохраняет совместимость существующего ядра и предотвращает циклическую загрузку модулей. + result = legacy(ident, payload) # Сохраняет строгие проверки DLC GAS/Boot, сегментных длин и старые структурированные поля событий. + application = _decoded(pc.decode(ident, payload)) # Общий ProtoCAN добавляет имена устройств, GAS, Settings, дискретов и служебных полей. + result.update(fields=application['fields'], warnings=application['warnings'], summary=result['summary'] + ' · ' + application['summary']) # Старые поля сохраняются, а полная прикладная расшифровка становится доступной на осциллограмме. + return result # Кадр выдаётся только после проверки wire-формата обоими существующими уровнями. diff --git a/python/logic_analyzer/decoders/tms_uart.py b/python/logic_analyzer/decoders/tms_uart.py new file mode 100644 index 0000000..6b41528 --- /dev/null +++ b/python/logic_analyzer/decoders/tms_uart.py @@ -0,0 +1,29 @@ +from collections import deque # Кольцевой буфер хранит только координаты одного максимального кадра. +from . import template_protocols # Подключает поставляемые пакеты templates в отдельном Python-процессе DSView. +from set_devices import tms_terminal as tms # Раскладки, CRC и потоковый поиск границ используются из действующего парсера терминала. + +LAYOUTS = tuple(tms.PRESETS) + ('custom',) # Имена готовых раскладок совпадают с настройками терминала SETGUI. + +class TmsStreamParser: # Адаптирует общий байтовый парсер к событиям с координатами sigrok. + def __init__(self, preset=tms.DEFAULT_PRESET, digit_bytes=38, analog_count=34, crc='MODBUS'): # Значения 38/34 соответствуют штатной раскладке message2.h. + selected = tms.PRESETS[preset] if preset != 'custom' else tms.Layout(digit_bytes, analog_count, crc) # Индивидуальная раскладка проверяется существующей моделью Layout. + self.layout = tms.Layout(selected.digit_bytes, selected.analog_count, crc) # CRC выбирается отдельно, поскольку в источниках есть MODBUS и CCITT-FALSE. + self.parser = tms.TmsParser(self.layout) # Поиск границ и подтверждение CRC выполняет общий парсер. + self.positions = deque(maxlen=self.layout.frame_size) # Число хранимых координат ограничено длиной одного кадра в байтах. + + def flush(self): # Пауза либо ошибка UART запрещает объединять части разных посылок. + self.parser.reset() # Общий парсер отбрасывает недособранные байты и состояние синхронизации. + self.positions.clear() # Координаты старого хвоста также исключаются из следующего события. + return [] # Формат без сигнатуры не позволяет отличить неполный кадр от произвольного шума. + + def feed(self, value, ss, es): # Один UART-байт приходит вместе с точными границами в отсчётах. + self.positions.append((ss, es)) # Последние frame_size позиций соответствуют окну поиска общего парсера. + events = [] # За один байт может завершиться один кадр фиксированной длины. + for record in self.parser.feed(bytes([value])): # Только CRC-подтверждённые записи выдаются прикладному слою. + fields = [('Команда', record.command), ('Адрес', str(record.address)), ('CRC', '%04X' % record.crc_frame)] # Служебные поля берутся из готовой модели TmsRecord. + fields += [(tms.analog_name(index), str(word)) for index, word in enumerate(record.analogs)] # Имена аналогов и знаковые int16 совпадают с терминалом SETGUI. + fields += [('Дискреты', ', '.join(record.set_bits()) or 'нет активных битов')] # Активные биты выдаются с исходной нумерацией байтов и битов. + result = dict(protocol='TMS UART', summary=record.summary, payload=record.raw[2:-3], raw=record.raw, fields=fields, analogs=record.analogs, digits=record.digits) # CRC и завершающий байт исключены из полезной нагрузки по контракту TMS_TO_TERMINAL. + events.append((self.positions[0][0], es, result, None)) # Начало подтверждённого окна сохраняется даже после пропущенного шума. + self.positions.clear() # Координаты завершённого кадра не удерживаются до следующей посылки. + return events # События имеют тот же контракт, что парсер SET UART. diff --git a/python/protocan/balsam.py b/python/protocan/balsam.py index 8b7fef8..c5c536c 100644 --- a/python/protocan/balsam.py +++ b/python/protocan/balsam.py @@ -15,6 +15,19 @@ DATA_OFFSET = 0x10 NODE_COUNT = 13 PULT_COMMAND_ID = 0x00745009 PULT_DATA_ID = 0x00745019 +HEARTBEAT_ADDRESS = 0x0100 # CAN_HEARTBEAT_ADDR в ecan.c периферии 167 начинает три служебных слова вне технологической карты R0..R127. +HEARTBEAT_NAMES = { # Назначения слов соответствуют hiword и loword ветки CAN_send для CAN_HEARTBEAT_ADDR. + HEARTBEAT_ADDRESS: "Heartbeat: счётчик, с", # Первое слово содержит секунды после запуска по модулю 65536, а не знаковое измерение. + HEARTBEAT_ADDRESS + 1: "Heartbeat: Mode", # Второе слово передаёт аппаратный номер Mode платы без преобразования CAN-индекса. + HEARTBEAT_ADDRESS + 2: "Heartbeat: тип платы (Desk)", # Третье слово содержит код Desk из прошивки, без подмены его именем CAN-узла. +} # Все три адреса общие для поддерживаемых узлов периферии 167. +COMMON_REGISTER_NAMES = { # Общие назначения подтверждены package.h; единицы времени зависят от версии планировщика CAN. + 0x60: "Настройка быстрого потока CAN", # Cancount[m_FAST]: в новом планировщике ноль отключает поток, ненулевое значение включает его. + 0x61: "Настройка медленного потока CAN", # Cancount[m_SLOW]: исторический период нельзя переводить в миллисекунды без версии прошивки. + 0x62: "Яркость индикации", # Brightness в package.h содержит исходный уровень яркости, а не подтверждённый процент. + 0x7E: "Последний режим", # LastMode хранится в предпоследнем слове технологической карты R0–R127. + 0x7F: "Команды", # Commands — битовая маска, которую дополнительно расшифровывает command_description. +} # Таблица доступна с любой версией DLL и при использовании резервного Python-декодера. @dataclass(frozen=True) @@ -38,10 +51,21 @@ class BalsamFrame: present_mask: int registers: tuple[BalsamRegister, ...] - @property - def route(self) -> str: - return ("%s → ПМ" % self.device_name if self.from_device - else "ПМ → %s" % self.device_name) + @property # Признак доступен адаптерам монитора и общей текстовой расшифровке без повторного разбора байтов. + def is_heartbeat(self) -> bool: # Штатный CAN_heartbeat_service передаёт ответ с адресом 0x0100 и всеми тремя словами. + return self.from_device and self.start_address == HEARTBEAT_ADDRESS and self.present_mask == 7 # Маска 111 из ecan.c исключает команды и неполные служебные пакеты. + + @property # Имя отправителя определяется направлением кадра, тогда как device_name сохраняет имя периферийного узла. + def sender_name(self) -> str: # Все устройства 167 подписываются по источнику кадра, как в предоставленной таблице адресов. + return self.device_name if self.from_device else "TMS" # Команды базового диапазона передаёт TMS, а ответы со смещением 0x10 — соответствующий периферийный узел. + + @property # Получатель дополняет отправителя, чтобы подробная расшифровка совпадала с подписью CAN ID. + def recipient_name(self) -> str: # Имя адресата нужно для обоих направлений каждой пары устройств. + return "TMS" if self.from_device else self.device_name # Периферия возвращает данные в TMS; получателем команды остаётся узел, выбранный CAN ID. + + @property # Краткий маршрут используется также в сводке и истории CAN-монитора. + def route(self) -> str: # Собираем направление из тех же имён, которые показаны в подробностях кадра. + return "%s → %s" % (self.sender_name, self.recipient_name) # Единые подписи исключают различия между строкой ID и полями отправителя и получателя. def command_description(self, value: int) -> str: """Command register 127; named UMP bits from BALZAM_167 automats.h.""" @@ -56,13 +80,16 @@ class BalsamFrame: for bit in range(16) if value & (1 << bit)) @property - def summary(self) -> str: - direction = "данные" if self.from_device else "команда" - values = ", ".join( - "%s=0x%04X (%d)" % (item.name or "R%04X" % item.address, - item.value, item.signed_value) + def summary(self) -> str: # Сводка показывает служебный пакет компактно, а технологические регистры — с адресами и известными назначениями. + if self.is_heartbeat: # Только полный ответ с адреса 0x0100 содержит три служебных слова в гарантированном порядке. + seconds, mode, desk = (item.value for item in self.registers) # Значения heartbeat беззнаковые; счётчик циклически проходит 0–65535 секунд. + return "BALZAM · %s · Heartbeat · %d с · Mode=%d · Desk=%d" % (self.route, seconds, mode, desk) # Один heartbeat получает одну подпись; исходные HEX остаются в байтах и подробных полях. + direction = "данные" if self.from_device else "команда" # Направление технологического кадра определяется диапазоном CAN ID. + values = ", ".join( # Каждое отмеченное маской слово выводится ровно один раз вместе с его назначением. + "R%d=0x%04X (%d)" % (item.address, item.value, item.signed_value) # Десятичный адрес совпадает с таблицей R0–R127, HEX сохраняет точное исходное слово. + + (" — " + item.name if item.name else "") # Для неизвестного адреса сохраняем только достоверные номер и значение, не придумывая назначение. + (" — " + self.command_description(item.value) - if not self.from_device and item.address == 0x7F else "") + if item.address == 0x7F else "") for item in self.registers ) or "нет отмеченных регистров" return "BALZAM · %s · %s · %s" % (self.route, direction, values) @@ -93,7 +120,7 @@ def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None: device, direction, mask, start, values = decoded registers = tuple( BalsamRegister(start + index, values[index], - native.balsam_register_name(device, start + index)) + HEARTBEAT_NAMES.get(start + index) or COMMON_REGISTER_NAMES.get(start + index) or native.balsam_register_name(device, start + index)) # Общие настройки получают одинаковые подписи со старой DLL; назначения каналов определяет C-ядро. for index in range(3) if mask & (4 >> index) ) return BalsamFrame(can_id & 0x1FFFFFFF, device, @@ -107,7 +134,7 @@ def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None: if len(data) != 8: raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes") relative = (can_id & 0x1FFFFFFF) - BASE_ID - device = (relative & 0x0F) + 1 + device = (relative & 0x0F) + 1 # Прошивка ecan.c уменьшает DevNum перед сборкой ID; маска выделяет нулевой CAN-индекс, а +1 возвращает номер Mode. from_device = relative >= DATA_OFFSET if can_id & 0x1FFFFFFF in (PULT_COMMAND_ID, PULT_DATA_ID): device = 10 @@ -117,12 +144,12 @@ def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None: for offset in (6, 0, 2)) names = { 1: "Трансформатор 1", 2: "Трансформатор 2", - 3: "Силовой блок 1", 4: "Силовой блок 2", 5: "УМП 1", 6: "УМП 2", - 7: "Двигатель", 8: "ВЭП", 9: "Задатчик", 10: "Пульт (МПУ)", 13: "Терминал", + 3: "Силовой 1", 4: "Силовой 2", 5: "УМП 1", 6: "УМП 2", # Названия силовых узлов и УМП соответствуют строкам 3..6 предоставленной таблицы адресов 167. + 7: "Двигатель", 8: "Задатчик", 9: "ВЭП", 10: "ПМУ", 13: "Терминал", # Таблица задаёт задатчик 8, ВЭП 9 и ПМУ для пары 0xBA0009/0xBA0019; служебный терминал сохраняет номер 13. } start, mask = header & 0x1FFF, (header >> 13) & 7 registers = tuple(BalsamRegister(start + i, values[i], - "Команды" if start + i == 0x7F else "") + HEARTBEAT_NAMES.get(start + i) or COMMON_REGISTER_NAMES.get(start + i, "")) # Без DLL сохраняем подтверждённые назначения heartbeat и общих настроек, оставляя неизвестные каналы без подписи. for i in range(3) if mask & (4 >> i)) return BalsamFrame(can_id & 0x1FFFFFFF, device, names.get(device, "Узел %d" % device), diff --git a/python/protocan/can_boot.py b/python/protocan/can_boot.py index ca2960b..9f9f9e7 100644 --- a/python/protocan/can_boot.py +++ b/python/protocan/can_boot.py @@ -20,6 +20,7 @@ class CanBootTarget: hardware_min: int = 0 hardware_max: int = 255 session_id: int = 1 + memory: str = "eeprom" # Назначение выбирается явно; старые вызовы сохраняют EEPROM A/B и прежний wire-протокол. def __post_init__(self): if not 0 <= self.device_type <= 7 or not 0 <= self.device <= 15: @@ -30,6 +31,10 @@ class CanBootTarget: raise ValueError("Неверный диапазон аппаратных ревизий") if not 1 <= self.session_id <= 255: raise ValueError("Session ID должен быть 1…255") + if self.memory not in ("eeprom", "flash", "stm32") or self.memory == "flash" and self.product_type != 0x2833: # F28335 требует отдельного подтверждения выбора Flash; STM32 использует штатный односекционный поток. + raise ValueError("Внутренняя Flash по CAN доступна только для F28335 / Product Type 0x2833") # Ошибка назначения обнаруживается до первого CAN-кадра. + if self.memory == "stm32" and self.product_type != 0: # BOOT CANdle G474VET6 публикует Product Type 0 в своём аппаратном профиле. + raise ValueError("CANdle G474VET6 требует Product Type 0") # Не допускает случайную передачу образа с ранее выбранным типом платы Бальзам. class CanBootTransfer: @@ -41,6 +46,19 @@ class CanBootTransfer: raise ValueError("F28335 M95M02 A/B: образ не должен превышать 130560 байт") if not 0 <= image.version <= 0xFFFFFFFF: raise ValueError("Версия образа вне диапазона u32") + if target.memory == "stm32": # Односекционная Flash G474 проверяется до первой команды ENTER_BOOT. + from set_devices.mcu_profiles import CANDLE_G474_BOOT # Размер приложения и адрес векторов берутся из общего профиля платы. + profile = CANDLE_G474_BOOT # Границы соответствуют 32 КиБ BOOT и 480 КиБ приложения STM32G474VET6. + if not 8 <= len(image.data) <= profile["app_size"]: # Первые восемь байтов содержат SP и Reset_Handler; запись не должна выходить за Flash. + raise ValueError("CANdle G474VET6: приложение должно занимать 8…491520 байт") # Ошибка обнаруживается до стирания целевого приложения. + sp, pc = struct.unpack_from(" None: return for adr, v in res.registers: res.fields.append((" Reg 0x%04X" % adr, "0x%04X (%d)" % (v, v))) + _append_gas_metadata(res, adr) + if not vals: + _append_gas_metadata(res, start) if vals: res.summary = "GAS: %d рег. с 0x%04X = %s" % ( len(vals), start, " ".join("%04X" % v for v in vals)) else: res.summary = "GAS: запрос по адресу 0x%04X" % start + # Every monitor, manual decoder and export uses this shared summary. + # Keep names in the summary as well as in the expandable field list. + addresses = res.registers if vals else [(start, None)] + if any(assigned_metadata(address) for address, _value in addresses): + words = [] + for address, value in addresses: + entry = assigned_metadata(address) + label = "ID %d (0x%04X)" % (address, address) + if entry: + label = "УМП %d · %s · %s" % ( + entry['node'] - 4, label, entry['name']) + else: + label += " · имя не назначено" + if value is not None: + label += " = %d [0x%04X]" % (value, value) + description = describe_word(entry['modbus'], value) if entry else '' + if description: + label += " — " + description + res.fields.append((" GAS ID %d / биты" % address, description)) + words.append(label) + res.summary = ("GAS: " if vals else "GAS запрос: ") + "; ".join(words) + + +def _append_gas_metadata(res: Decoded, address: int) -> None: + entry = assigned_metadata(address) + if not entry: + return + label = " GAS ID %d" % address + res.fields += [ + (label, "УМП %d — %s" % (entry['node'] - 4, entry['name'])), + (label + " / статус", entry['status']), + (label + " / Modbus 167/v1", "0x%04X" % entry['modbus']), + (label + " / GAS 167/v1", "0x%04X" % entry['legacy_address']), + (label + " / доступ", entry['access']), + ] def _decode_modbus(res: Decoded) -> None: # Единый тип 0x5 разбирается по номеру функции, а не по прежним трём MsgType. diff --git a/python/protocan/tms_firmware.py b/python/protocan/tms_firmware.py index 2531ca9..081d896 100644 --- a/python/protocan/tms_firmware.py +++ b/python/protocan/tms_firmware.py @@ -73,6 +73,7 @@ class TmsTarget: kind: str = "tms" board: int = 0 load_only: bool = False + verify: bool = True def validate(self, size: int) -> None: if not 0 <= self.controller <= 255: @@ -116,6 +117,8 @@ def programming_steps(data: bytes, target: TmsTarget): raise ValueError(f"Проверка EEPROM не пройдена: код {error}, обработано {words} слов") return yield tflash(t.controller, t.ram, t.flash, len(data)), 6, 180000, "CMD_TFLASH: запись Flash", 85 + if not t.verify: + return for offset in range(0, len(data), 256): block = data[offset:offset + 256] count = (len(block) + 1) & ~1 diff --git a/python/pyproject.toml b/python/pyproject.toml index 37f1664..4486f96 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,4 +19,4 @@ include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*" [tool.setuptools.package-data] set_devices = ["gas_logger_maps/*.json"] -protocan = ["native/*.dll", "native/*.so", "native/*.dylib"] +protocan = ["*.json", "native/*.dll", "native/*.so", "native/*.dylib"] diff --git a/python/pysetterminal/README.md b/python/pysetterminal/README.md index f15c4f3..cbce5c6 100644 --- a/python/pysetterminal/README.md +++ b/python/pysetterminal/README.md @@ -86,3 +86,119 @@ self.byte_page.set_status("Чтение завершено") Каждому независимому соединению нужен собственный протокол. Если вкладки используют одно соединение, владелец транспорта должен запрещать одновременные запросы и маршрутизировать ответы. В SETGUI это делает существующая очередь TMS2812. + +## PBUS, SBUS и общие макросы + +В SETGUI, на странице «Работа с байтом → Команды / макросы», слева находится +«SBUS (Сериальная шина)», справа — «PBUS (Параллельная шина)». Под обоими +блоками расположен единственный общий список макросов на всю ширину. +Один макрос может содержать команды обеих шин в произвольном порядке. +Создание, редактирование, удаление, запуск и остановка макросов сохранены. +Отдельных нижних полей «Ответ контроллера» и «Считанное слово» нет. + +SBUS сохраняет прежние чтение и запись через `0x200A`, `0x200B`, `0x200F`. +Старый JSON без поля `bus` исполняется как SBUS, новые команды явно +сохраняют `bus: "sbus"` или `bus: "pbus"`. Публичные элементы `bus_*` +по-прежнему относятся к SBUS. Таблица результатов доступна как `pbus_table`. + +В PBUS достаточно ввести адрес платы и регистр, затем нажать «Добавить в +таблицу» или Enter. Таблица показывает плату, регистр, HEX, DEC и состояние +считывания. Повторное добавление того же источника не создаёт дубликат. +«Убрать из таблицы» и Delete удаляют выделенные строки; Ctrl и Shift +позволяют удалить несколько источников сразу. После удаления оставшиеся +регистры уплотняются и заново записываются в аппаратную таблицу с правильными +адресами буфера. «Изменить строку» заменяет выбранную строку адресом и +регистром из полей выше. После удаления последнего регистра список +становится пустым, аппаратный и фоновый опрос остаются остановленными; +прежнее содержимое аппаратного буфера не используется как результат. + +В блоке PBUS поле «Порог ошибок (1–15)» задаёт число допустимых ошибок. +Начальное значение — 10 (`0xA`), поэтому две записи запускаются словом +`0x8A01`. Выбранный порог применяется при добавлении, изменении таблицы +или нажатии «Обновить». Обычный фоновый цикл использует уже применённые +параметры и передаёт только запросы чтения памяти. Команда макроса может +явно задать свой порог; новое редактирование поля во время обмена не +меняет снимок уже поставленной команды. + +Перенастройка выполняется одной операцией общей очереди: остановка через +`0x200C`, полная запись всех пар индекса `0x200D` и дескриптора `0x200E`, +затем запуск через `0x200C`. Остановка сохраняет порог и последний индекс +ранее подтверждённой активной таблицы, сбрасывая только бит 15. Запуск +содержит новый последний индекс `N-1` в младшем байте и порог в битах 11:8. +Для пустого списка выполняется только остановка, без переполнения `N-1` +и без команд запуска, включая `0x80FE`. + +Дескриптор равен `(плата << 12) | (регистр << 8) | CD`. Индекс таблицы +независим от CD, а результат читается по словному адресу `0x2000 | CD`, +без умножения на размер слова. При обычном добавлении CD начинается с +`0x40`, поэтому строки читаются из `0x2040`, `0x2041` и далее. Полный +снимок настройки может содержать явный `cd` у каждой записи: это назначение +сохраняется при редактировании и перестройке таблицы. Максимум — 64 записи. +Регистр `0x2012` не читается для проверки состояния или блокировки данных. + +После подтверждения запуска выдерживается поле «После запуска» в +миллисекундах; начальное значение — 100 мс. Оно настраивается по времени +обновления аппаратного буфера отдельно от периода чтения. До окончания +паузы и успешного первого чтения таблица не считается применённой и +прежние значения не показываются. Ошибка записи останавливает дальнейшие +шаги и запрещает автоматическое применение или повторный запуск. + +Пример двух регистров платы `0xE` с порогом `0xA`: + +```text +write(0x200C, 0x0A01) +write(0x200D, 0x0000) +write(0x200E, 0xE040) +write(0x200D, 0x0001) +write(0x200E, 0xE141) +write(0x200C, 0x8A01) +ожидание аппаратного обновления +read(0x2040) +read(0x2041) +``` + +«Обновить» получает значения списка. «Циклическое чтение» повторяет чтение +PBUS с выбранным периодом в миллисекундах и сохраняет выделение строк. +Циклическое чтение передаёт только ReadMemory назначений 20CD, не меняет +таблицу и не перезапускает шину. При выключении галочки отменяются будущие +повторы и неотправленные чтения; после завершения текущего пакета или +логической операции в очередь передаётся остановка `0x200C` с очищенным +битом 15 и сохранёнными параметрами активной таблицы. Повторное включение +галочки запускает аппаратно остановленную таблицу перед чтением её буфера. При изменённой или неподтверждённой +таблице необходимо явное нажатие «Обновить» для полного применения. +Изменение адреса контроллера останавливает опрос до явного применения +списка к новому устройству. В SBUS собственные галочка и период повторяют +чтение устройства и регистра из текущих полей; изменения применяются к +следующему запросу, уже отправленные параметры сохраняются. + +Кнопки и поля остаются доступными во время обмена. Нажатия фиксируют +параметры и становятся в общую очередь соединения. В одной логической +операции подготовка регистра и получение результата выполняются целиком, +без перемешивания с другой операцией. Проверка записанного слова чтением +также удерживает соединение до завершения. Ручные действия обслуживаются +раньше следующего фонового задания. Периодический источник имеет не более +одного ожидающего или выполняющегося запуска; пропущенные циклы не копятся. + +Макросы размещаются слева направо с переносом по ширине панели и +вертикальной прокруткой, сохраняя прежнюю высоту и естественную ширину. +Длинное имя увеличивает ширину кнопки. У каждого макроса есть галочка +«Циклически»; поле «Период повторения» в редакторе сохраняется как +`period_ms` в его JSON. Старые макросы получают период 1000 мс. +Включение сразу запускает макрос, период отсчитывается между реальными +началами повторов. Незавершённый запуск того же макроса исключает повтор, +а однократная кнопка выполнения остаётся доступной отдельно. + +Общий редактор команд PBUS поддерживает `add`, `remove`, `poll`, `start` +и `stop`. Команды и будущий размер списка проверяются до первого запроса. +Ошибки порта, CRC, неверный ответ и таймаут прекращают оставшиеся команды +соответствующего запуска. Остановка снимает будущие повторения и ещё +не отправленные команды выбранного задания; текущий пакет завершается +ответом или штатным таймаутом. Отключение порта очищает всю очередь. +Уже переданные аппаратные записи не откатываются. + +Рабочие панели SETGUI используют существующие `QDockWidget` и +`QMainWindow`: перемещаются влево, вправо, вверх, вниз и в общую группу +вкладок со штатной подсветкой области вставки. Разделители меняют размеры. +Перенос сохраняет экземпляры панелей, соединение и выполняемые задания. +Раскладка сохраняется между запусками; «Сбросить раскладку» возвращает +стандартное расположение без пересоздания содержимого. diff --git a/python/pysetterminal/bus_operations.py b/python/pysetterminal/bus_operations.py new file mode 100644 index 0000000..1398388 --- /dev/null +++ b/python/pysetterminal/bus_operations.py @@ -0,0 +1,113 @@ +"""Операции PBUS по myXilinx/x_parallel_bus.c прошивки TMS2812 / 22220.""" + +from .exchange_queue import ExchangeDelay # Пауза после запуска остаётся внутри одной атомарной операции общей очереди. + +PBUS_SETTLE_MS = 100 # Начальная настраиваемая пауза обновления аппаратного буфера после старта, миллисекунды. +PBUS_COMMAND = 0x200C # Spartan2E_Adr.h: управление запуском циклического опроса параллельной шины. +PBUS_TABLE_INDEX = 0x200D # Spartan2E_Adr.h: выбор изменяемой строки таблицы опроса FPGA. +PBUS_TABLE_DATA = 0x200E # Spartan2E_Adr.h: дескриптор строки, содержащий адрес платы, регистр и адрес буфера. +PBUS_BUFFER = 0x2040 # Spartan2E_Adr.h: ADR_FIRST_FREE, первое слово буфера результатов циклического опроса. +PBUS_CAPACITY = 64 # Spartan2E_Adr.h: буфер 0x2040–0x207F содержит 64 слова, поэтому ограничиваем число строк таблицы. +PBUS_ERROR_LIMIT = 0xA # Начальный порог SETGUI соответствует рабочему ручному запуску 0x8Axx; пользователь может задать от 1 до 15 ошибок. + + +def validate_command(command): # Проверяем все параметры до передачи, чтобы неверный макрос не выполнился частично. + if not isinstance(command, dict): # Повреждённый JSON не должен приводить к исключению интерфейса вместо понятного отказа валидации. + raise ValueError("Команда макроса должна содержать именованные параметры") # Останавливаем всю последовательность до передачи первого управляющего запроса. + bus = command.get("bus", "sbus") # Старые JSON-макросы без поля bus относятся к существовавшей последовательной шине. + if bus not in ("pbus", "sbus"): # Не допускаем исполнения повреждённого имени шины другим алгоритмом. + raise ValueError("Неизвестная шина макроса") # Сообщаем причину отказа до передачи управляющих данных. + operations = ("add", "remove", "poll", "configure", "write", "read", "start", "stop") if bus == "pbus" else ("write", "read") # PBUS управляет общим списком регистров; прежние команды сохраняются для совместимости JSON. + if command["type"] not in operations: # Направление операции должно явно поддерживаться выбранной шиной. + raise ValueError("Неизвестная команда макроса") # Не превращаем неизвестную команду в чтение. + limits = (("tetrad", 15 if bus == "pbus" else 14), ("register", 15), ("data", 0xFFFF)) # PBUS имеет четырёхбитный адрес, прежний SBUS сохраняет диапазон 0–14; данные — 16 бит. + if bus == "pbus" and command["type"] in ("write", "read", "start", "stop"): # Только прежние команды явно задают индекс и длину аппаратной таблицы. + limits += (("slot", PBUS_CAPACITY - 1), ("count", PBUS_CAPACITY), ("errors", 15)) # Индекс буфера — 0–63, число строк — 1–64, порог — четыре бита. + defaults = {"data": 0, "slot": 0, "count": 1, "errors": PBUS_ERROR_LIMIT} # Стабильные значения позволяют сохранять компактные команды PBUS. + for name, maximum in limits: # Проверяем все значения одним правилом до упаковки битовых полей. + value = command.get(name, defaults[name]) if name in defaults else command[name] # Адрес и регистр обязательны, параметры таблицы имеют штатные значения. + minimum = 1 if name in ("count", "errors") else 0 # Пустую таблицу и нулевой порог запрещаем: библиотека считает count_error >= 0 ошибкой. + if not isinstance(value, int) or not minimum <= value <= maximum: # Исключаем строки, отрицательные числа и переполнение аппаратных полей. + raise ValueError(f"{name}: допустим диапазон {minimum}…{maximum}") # Указываем конкретный неверный параметр и его пределы. + if bus == "pbus" and (not isinstance(command.get("errors", PBUS_ERROR_LIMIT), int) or not 1 <= command.get("errors", PBUS_ERROR_LIMIT) <= 15): # Новые команды также сохраняют целочисленный четырёхбитный ненулевой порог ошибки PBUS. + raise ValueError("errors: допустим диапазон 1…15") # Нулевой порог иначе сделал бы любой статус аппаратной ошибкой. + if bus == "pbus" and command["type"] in ("write", "read", "start", "stop") and command.get("slot", 0) >= command.get("count", 1): # Проверяем явную адресацию только у прежнего формата команды. + raise ValueError("PBUS: индекс строки должен быть меньше числа строк таблицы") # Не запускаем опрос, который исключает выбранный результат. + if bus == "pbus" and "entries" in command: # Явная замена таблицы валидируется целиком до постановки макроса в очередь. + validated_entries(command["entries"]) # Повреждённая поздняя строка исключает передачу начала перенастройки. + if bus == "pbus" and "settle_ms" in command and (not isinstance(command["settle_ms"], int) or not 1 <= command["settle_ms"] <= 60000): # Пауза обновления задаётся в положительных миллисекундах. + raise ValueError("PBUS: пауза после запуска должна быть 1…60000 мс") # Отказываем до любого WriteMemory контроллеру. + return bus # Возвращаем проверенную шину для выбора алгоритма и адресного отображения результата. + + +def pbus_result_address(entry, index): # Номер записи таблицы и CD её результата являются разными полями аппаратного протокола. + cd = entry.get("cd", (PBUS_BUFFER & 0xFF) + index) # При отсутствии явного CD сохраняем обычное размещение результатов с младшего байта 0x40. + if not isinstance(cd, int) or not 0 <= cd <= 0xFF: # Поле CD занимает ровно восемь бит дескриптора. + raise ValueError("PBUS: CD должен быть в диапазоне 00…FF") # Не допускаем усечения адреса результата при упаковке дескриптора. + return 0x2000 | cd # Адрес памяти словный: страница 0x2000 объединяется с CD без умножения на размер слова. + + +def pbus_descriptor(slave, register, cd): # Формируем дескриптор отдельно от индекса записи, записываемого в 0x200D. + return (slave << 12) | (register << 8) | cd # Четыре бита платы, четыре бита регистра и восемь бит CD соответствуют заданному формату 0x200E. + + +def pbus_control(count, errors): # Управляющее слово содержит параметры таблицы без бита запуска. + if not isinstance(count, int) or not 0 <= count <= PBUS_CAPACITY or not isinstance(errors, int) or not 1 <= errors <= 15: # Проверяем ёмкость таблицы и четырёхбитный аппаратный порог до отправки. + raise ValueError("PBUS: неверный размер таблицы или порог ошибок") # Неверные параметры не меняют работающую шину. + return (errors << 8) | (count - 1 if count else 0) # Для пустой таблицы младший байт равен нулю, выражение N-1 не вычисляется с переполнением. + + +def validated_entries(entries): # Проверяем полный снимок таблицы до её остановки и возвращаем независимые адреса источников. + if not isinstance(entries, (list, tuple)) or len(entries) > PBUS_CAPACITY: # Аппаратный буфер штатной панели ограничен шестьюдесятью четырьмя источниками. + raise ValueError("PBUS: в таблице нет места, максимум 64 регистра") # Ошибка поздней строки не должна вызвать частичную перенастройку. + result = [] # Снимок не содержит считанных значений и временных состояний интерфейса. + for index, entry in enumerate(entries): # Каждый индекс таблицы проверяется независимо от CD результата. + validate_command({"bus": "pbus", "type": "add", "tetrad": entry["tetrad"], "register": entry["register"]}) # Плата и регистр должны помещаться в свои четырёхбитные поля. + pbus_result_address(entry, index) # Явный CD также проверяется до первой аппаратной записи. + result.append({name: entry[name] for name in ("tetrad", "register", "cd") if name in entry}) # Явное отображение CD сохраняется при добавлении и удалении других строк. + return result # Полный снимок используется очередью на протяжении всей перенастройки. + + +def table_entries_after(entries, command): # Вычисляем список регистров до передачи, чтобы целиком проверить общий макрос. + result = validated_entries(command.get("entries", entries)) # Сохраняем адреса без устаревших считанных значений и промежуточных статусов. + key = (command["tetrad"], command["register"]) # Одна пара плата/регистр однозначно определяет источник PBUS. + if command["type"] == "add" and key not in [(entry["tetrad"], entry["register"]) for entry in result]: # Повторное добавление уже наблюдаемого регистра не создаёт дубликат. + result.append({"tetrad": key[0], "register": key[1], **({"cd": command["cd"]} if "cd" in command else {})}) # Явный CD сохраняется независимо от индекса новой строки, без него буфер выбирается последовательно. + if command["type"] == "remove": # Удаление по адресам не зависит от смещения строк после предыдущих удалений. + removed = set(tuple(item) for item in command.get("remove_keys", [key])) # Кнопка и Delete могут удалить сразу несколько выделенных регистров. + for slave, register in removed: # Проверяем также параметры множественного удаления, приходящие из JSON. + validate_command({"bus": "pbus", "type": "remove", "tetrad": slave, "register": register}) # Недопустимая пара исключает исполнение всей последовательности заранее. + result = [entry for entry in result if (entry["tetrad"], entry["register"]) not in removed] # Оставшиеся источники уплотняются, сохраняя порядок отображения. + if len(result) > PBUS_CAPACITY: # Ограничиваем число строк реальным буфером 0x2040–0x207F. + raise ValueError("PBUS: в таблице нет места, максимум 64 регистра") # Переполнение не должно отправлять частичную аппаратную конфигурацию. + return validated_entries(result) # Все CD и адреса новой полной таблицы проверены ещё до постановки аппаратных команд в очередь. + + +def pbus_table_steps(entries, errors=PBUS_ERROR_LIMIT, *, configure=True, stop_control=None, settle_ms=PBUS_SETTLE_MS, on_control=None): # Перенастройка и ожидание свежего буфера выполняются одной последовательной операцией очереди. + entries = validated_entries(entries) # Фиксируем все платы, регистры, порядок и CD до первой записи управления. + control = pbus_control(len(entries), errors) # Для новой таблицы младший байт задаёт её последний используемый индекс. + if not isinstance(settle_ms, int) or not 1 <= settle_ms <= 60000: # Пауза аппаратного обновления явно задаётся в миллисекундах, от одной до минуты. + raise ValueError("PBUS: пауза после запуска должна быть 1…60000 мс") # Некорректное время не допускает запуска таблицы с немедленным чтением старого буфера. + if configure: # Изменение любой строки требует полной остановки и переписи всей новой таблицы. + stopped = (control if stop_control is None else stop_control) & 0x7FFF # У активной таблицы сохраняем порог и последний индекс, сбрасывая только бит 15. + yield PBUS_COMMAND, stopped # Ждём подтверждения остановки прежде, чем менять индекс или дескриптор. + if on_control is not None: # Панель отслеживает только уже подтверждённые управляющие записи. + on_control(stopped) # После ошибки следующей записи прежняя таблица остаётся известной как остановленная. + if not entries: # Пустая таблица не содержит последнего индекса и не должна запускаться для очистки. + return {"entries": [], "rows": [], "control": stopped} # Оставляем PBUS остановленной без 0x80FE, переполнения N-1 и чтения прежнего буфера. + for index, entry in enumerate(entries): # Переписываем все записи, даже если изменилась только одна из них. + yield PBUS_TABLE_INDEX, index # Индекс таблицы нумеруется с нуля и не подменяется адресом результата. + cd = pbus_result_address(entry, index) & 0xFF # CD извлекается из фактического адреса чтения этой строки на странице 0x2000. + yield PBUS_TABLE_DATA, pbus_descriptor(entry["tetrad"], entry["register"], cd) # Дескриптор хранит плату, регистр и CD выбранной записи в 0x200E. + yield PBUS_COMMAND, control | 0x8000 # Только подтверждение всех пар 0x200D/0x200E разрешает запуск новой длины N-1. + if on_control is not None: # Успешный ответ запуска фиксирует реальные параметры работающей таблицы. + on_control(control | 0x8000) # Последующая остановка сохранит именно этот порог и последний индекс. + yield ExchangeDelay(settle_ms) # Не читаем старые слова буфера сразу после подтверждения запуска: ждём настроенный аппаратный интервал. + rows = [] # Результаты соответствуют порядку снимка, но их адреса определяются полем CD. + for index, entry in enumerate(entries): # Обычное обновление не отправляет ни одной управляющей записи и не перезапускает PBUS. + address = pbus_result_address(entry, index) # Полный словный адрес равен 0x2000 | CD независимо от номера платы и регистра. + reply = yield address, None # Существующий ReadMemory возвращает одно шестнадцатибитное слово контроллера TMS. + if not isinstance(reply, dict) or not isinstance(reply.get("value"), int) or not 0 <= reply["value"] <= 0xFFFF: # Проверяем реальный формат результата чтения, не обращаясь к статусу 0x2012. + raise ValueError(f"PBUS: некорректный ответ памяти 0x{address:04X}") # Ошибка указывает фактический адрес CD, а не индекс строки. + rows.append(reply) # HEX и DEC каждого результата будут заполнены только после успешного цикла. + return {"entries": entries, "rows": rows, "control": control | 0x8000 if entries and configure else stop_control} # Заполненная таблица подтверждается данными после паузы, пустая остаётся остановленной. diff --git a/python/pysetterminal/exchange_queue.py b/python/pysetterminal/exchange_queue.py new file mode 100644 index 0000000..5bb5c82 --- /dev/null +++ b/python/pysetterminal/exchange_queue.py @@ -0,0 +1,144 @@ +"""Последовательная очередь логических операций одного соединения UART.""" # Все панели разделяют владение портом на протяжении многошагового действия. +from collections import deque # Две очереди обеспечивают приоритет ручных действий без перестановки их порядка. +from PySide6.QtCore import QObject, QTimer, Qt # Точный таймер сохраняет аппаратную паузу без преждевременного чтения памяти контроллера. + + +class ExchangeDelay: # Пауза аппаратного обновления является частью той же операции, а не отдельным обменом UART. + def __init__(self, milliseconds): # Время ожидания задаётся явно вызывающим алгоритмом в миллисекундах. + self.milliseconds = milliseconds # Очередь сохраняет владение операцией, пока контроллер обновляет буфер результатов. + + +class ExchangeQueue(QObject): # Один экземпляр привязан к протоколу и используется всеми рабочими панелями. + def __init__(self, protocol): # Протокол остаётся ответственным за кодирование, CRC и таймаут отдельного пакета. + super().__init__() # Таймеры очереди принадлежат объекту Qt с тем же временем жизни. + self.protocol, self.active = protocol, None # Активная операция удерживает порт до завершения всех своих шагов. + self.manual, self.background = deque(), deque() # Пользовательские действия выбираются раньше следующего фонового задания. + self.timer = QTimer(self) # Таймер также позволяет дождаться ранее начатого внешнего обмена. + self.timer.setSingleShot(True) # Каждое продолжение назначается однократно, исключая накопление событий. + self.timer.timeout.connect(self._pump) # Новая операция выбирается только после освобождения соединения. + + def contains(self, key): # Периодический источник проверяет наличие своего предыдущего задания. + return any(job["key"] == key for job in ([self.active] if self.active else []) + list(self.manual) + list(self.background)) # Проверка охватывает ожидание и выполнение, поэтому пропущенные циклы не копятся. + + def submit_external(self, start, pending, callback, *, key=None, background=False, immediate=False): # Прежние обработчики SETGUI выполняются под тем же владением соединением. + def wait(done): # Адаптер удерживает очередь до завершения внешнего асинхронного обработчика. + start() # Параметры внешней операции уже зафиксированы вызывающей панелью. + def poll(): # Проверяем завершение через события Qt, не блокируя UART и интерфейс. + if pending(): # Прежний обработчик ещё получает ответ или выполняет следующий шаг проверки записи. + QTimer.singleShot(10, poll) # Период проверки в миллисекундах не порождает дубликатов команд. + else: # Внешний обмен завершился и самостоятельно отобразил результат или ошибку. + done(True, None) # Следующая логическая операция теперь может владеть портом. + poll() # Синхронное действие без ожидаемого ответа освобождает очередь сразу. + def steps(): # Общий исполнитель использует одинаковое завершение обычных и внешних операций. + yield wait # Callable получает callback и удерживает очередь до его вызова. + return None # Результат внешнего действия уже обработан его существующей панелью. + accepted = self.submit(steps, None, callback, key=key, background=background) # Сохраняем приоритет и защиту от повторов для общего соединения. + if accepted and immediate: # Прежние обработчики могут сохранить немедленную отправку при свободном соединении. + self._pump() # Занятый порт всё равно оставляет действие ожидающим в общей очереди. + return accepted # Возвращаем факт принятия задания либо подавления периодического дубликата. + + def submit(self, factory, controller, callback, *, key=None, background=False): # Фабрика создаёт генератор при начале операции, параметры передаются уже зафиксированными. + if key is not None and self.contains(key): # Один периодический источник допускает только одно незавершённое повторение. + return False # Не добавляем дубликат в очередь и не создаём отложенного повторения. + job = {"factory": factory, "controller": controller, "callback": callback, "key": key, "cancelled": False, "waiting": False, "iterator": None} # Снимок контроллера принадлежит всей логической операции. + (self.background if background else self.manual).append(job) # Фоновая операция не вытесняет уже ожидающее нажатие пользователя. + self.timer.start(0) # Запуск откладывается до следующего события Qt, оставляя интерфейс отзывчивым. + return True # Вызывающий код может отличить постановку задания от подавленного дубликата. + + def cancel(self, key): # Остановка задания удаляет будущие команды, сохраняя текущий пакет до ответа или таймаута. + for pending in (self.manual, self.background): # Удаляем задания из обеих очередей независимо от их приоритета. + removed = [job for job in pending if job["key"] == key] # Сохраняем callbacks отменённых заданий для завершения состояния владельца. + pending_copy = [job for job in pending if job["key"] != key] # Остальные источники продолжают ожидать в прежнем порядке. + pending.clear() # Пересобираем только контейнер очереди, не изменяя задания других панелей. + pending.extend(pending_copy) # Возвращаем ожидающие операции, которые не принадлежат остановленному источнику. + for job in removed: # Каждый владелец получает однозначное уведомление об отмене своего ожидания. + job["callback"](False, "Остановлено") # Ни один пакет снятой операции ещё не был отправлен. + if self.active and self.active["key"] == key: # Выполняющееся задание прекращаем после уже отправленного пакета. + self.active["cancelled"] = True # Следующий шаг генератора не будет отправлен, даже при успешном ответе. + if not self.active["waiting"]: # Между пакетами отмена может завершиться немедленно. + self._complete(False, "Остановлено") # Освобождаем порт без искусственного транспортного таймаута. + + def clear(self): # Отключение соединения удаляет все задания и будущие продолжения. + keys = {job["key"] for job in list(self.manual) + list(self.background) + ([self.active] if self.active else [])} # Снимок ключей не меняется при вызове callbacks отмены. + for key in keys: # Применяем одинаковую отмену ко всем источникам данного соединения. + self.cancel(key) # Активный пакет корректно завершится либо будет отменён транспортом при закрытии порта. + + def _transport_busy(self): # Учитываем прежние операции встроенного окна, начатые до использования очереди. + host = getattr(self.protocol, "host", None) # В SETGUI протокол использует общий QSerialPort родительской вкладки. + return bool(getattr(self.protocol, "_callback", None) or getattr(self.protocol, "callback", None) or getattr(self.protocol, "_loop", None) or (host and host._pending_address is not None)) # Занятый внешний обмен нельзя замещать новым callback. + + def _pump(self): # Выбираем операцию только между целостными чтениями или записями регистра. + if self.active: # Пока генератор удерживает соединение, очередь не начинает другие операции. + return # Продолжение активной операции приходит через её транспортный callback. + if not self.manual and not self.background: # Пустая очередь не создаёт периодических событий сама по себе. + return # Отсутствие заданий позволяет соединению оставаться полностью свободным. + if self._transport_busy(): # Ждём завершения внешней операции вместо отказа пользователю. + self.timer.start(10) # Короткое ожидание в миллисекундах не блокирует обработку UART и интерфейса. + return # Параметры ожидающих нажатий при этом остаются неизменными. + self.active = (self.manual if self.manual else self.background).popleft() # FIFO ручных действий имеет приоритет над следующим фоновым циклом. + self._advance(None) # Первый шаг генератора отправляется только после получения владения соединением. + + def _advance(self, reply): # Генератор выдаёт следующий запрос только после завершения предыдущего. + job = self.active # Сохраняем владельца, чтобы поздний ответ не продолжил другую операцию. + if job is None: # Очередь могла быть очищена отключением соединения. + return # Без владельца никакие команды не отправляются. + delay = job.pop("delay_timer", None) # Завершившийся таймер аппаратного ожидания больше не нужен текущему генератору. + if delay is not None: # Обычные UART-продолжения не создают отдельного таймера аппаратной паузы. + delay.stop() # Завершённое или отменённое ожидание не назначает повторного события. + delay.deleteLater() # Освобождаем QObject после обработки текущего события Qt. + if job["cancelled"]: # Остановка отменяет все шаги, которые ещё не переданы контроллеру. + self._complete(False, "Остановлено") # Текущий пакет к этому моменту уже получил ответ или таймаут. + return # Не возобновляем остановленный генератор. + try: # Ошибки параметров и результата завершают логическую операцию без повреждения очереди. + if job["iterator"] is None: # Фабрика использует актуальную таблицу PBUS при зафиксированном действии пользователя. + job["iterator"] = job["factory"]() # Генератор создаётся один раз на целую операцию. + request = next(job["iterator"]) # Первое чтение не получает фиктивного ответа предыдущей операции. + else: # Последующие шаги получают реальное подтверждение или считанное слово. + request = job["iterator"].send(reply) # Запись подготовки и чтение результата не могут перемешаться с другим заданием. + except StopIteration as completed: # Генератор закончил все шаги одной логической операции. + self._complete(True, completed.value) # Возвращаем итог владельцу и освобождаем соединение. + return # Завершённый генератор больше не используется. + except (ValueError, RuntimeError, TypeError, KeyError) as error: # Неверные параметры или подтверждения не должны разрывать цикл событий Qt. + self._complete(False, str(error)) # Сохраняем действительную причину ошибки в панели инициатора. + return # Следующая ожидающая операция сможет продолжить обмен. + if isinstance(request, ExchangeDelay): # После запуска PBUS аппаратуре даётся время обновить слова памяти до первого чтения. + delay = QTimer(self) # Отдельный таймер принадлежит данной аппаратной паузе, а не планировщику выбора операций. + delay.setSingleShot(True) # Одна пауза заканчивается одним продолжением генератора. + delay.setTimerType(Qt.PreciseTimer) # Штатный грубый таймер может сработать раньше, что недопустимо перед чтением нового буфера PBUS. + delay.timeout.connect(lambda: self._advance(None) if self.active is job else None) # Продолжение остаётся связано с владельцем всей перенастройки. + job["delay_timer"] = delay # Остановка задания сможет немедленно отменить его ещё не завершённую паузу. + delay.start(request.milliseconds) # Ожидаем заданное время в миллисекундах после подтверждения запуска контроллером. + return # UART-пакет не отправляется; остановка может немедленно отменить эту паузу и все следующие команды. + job["waiting"] = True # До вызова транспорта отмечаем ожидание, включая синхронный отказ отключённого порта. + try: # Защищаем очередь также от исключения драйвера при отправке. + if callable(request): # Прежняя операция SETGUI или RAW выполняется только после получения общего владения UART. + request(lambda ok, result: self._reply(job, ok, result)) # Внешний обработчик завершает ожидание через тот же callback очереди. + return # Не трактуем внешнее действие как адрес памяти PEEK/POKE. + address, value = request # Формат PEEK/POKE остаётся прежним для обеих шин. + self.protocol.request_async(address, value, job["controller"], lambda ok, result: self._reply(job, ok, result)) # Существующий протокол проверяет ответ и задаёт таймаут пакета. + except (ValueError, RuntimeError, OSError) as error: # Ошибка порта завершает текущую операцию и освобождает очередь. + self._reply(job, False, str(error)) # Используем тот же путь, что для асинхронного отказа обмена. + + def _reply(self, job, success, result): # Ответ всегда относится к владельцу пакета, а не к последнему нажатию. + if self.active is not job: # Поздний callback закрытого соединения не меняет новое задание. + return # Исключаем продвижение чужого генератора устаревшим ответом. + job["waiting"] = False # Порт освободился после полного ответа либо обработанного таймаута. + if job["cancelled"] or not success: # Остановка и реальная ошибка запрещают отправку следующих пакетов. + self._complete(False, "Остановлено" if job["cancelled"] else result) # Отмена не маскируется как успешное чтение. + return # Остальные команды остановленного задания не попадут в UART. + QTimer.singleShot(0, lambda: self._advance(result) if self.active is job else None) # Даём обработать остановку между пакетами, удерживая владение операцией. + + def _complete(self, success, result): # Завершение освобождает соединение до уведомления владельца операции. + job, self.active = self.active, None # Callback может поставить следующую команду макроса в общую очередь. + delay = job.pop("delay_timer", None) # Пользовательская остановка может завершить операцию прямо во время аппаратной паузы. + if delay is not None: # Завершение обычного пакета не содержит такого таймера. + delay.stop() # Отменённая пауза больше не продолжит чтение не применённой таблицы. + delay.deleteLater() # Освобождаем временный QObject вместе с завершённым заданием. + job["callback"](success, result) # Результат отображается только инициатором данной операции. + self.timer.start(0) # Между командами макроса выбираем ожидающее ручное действие раньше фонового продолжения. + + +def exchange_queue(protocol): # Общая очередь создаётся лениво для существующих и тестовых протоколов. + if not hasattr(protocol, "operation_queue"): # Панели одного соединения должны получать один и тот же экземпляр. + protocol.operation_queue = ExchangeQueue(protocol) # Владение протоколом сохраняется без изменения формата пакетов. + return protocol.operation_queue # Возвращаем единый планировщик ручного обмена и периодических заданий. diff --git a/python/pysetterminal/mainterm.py b/python/pysetterminal/mainterm.py index 720e88b..d63ef61 100644 --- a/python/pysetterminal/mainterm.py +++ b/python/pysetterminal/mainterm.py @@ -155,7 +155,7 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Ошибка", f"Ошибка подключения: {e}") def closeEvent(self, event): - self.serial_tab.stop_macro() + self.serial_tab.stop() # Отключение протокола прекращает общий макрос и последующие фоновые чтения PBUS. self.raw_protocol.cancel_async() self.raw_protocol.disconnect() super().closeEvent(event) diff --git a/python/pysetterminal/panel.py b/python/pysetterminal/panel.py index 4fa8540..98387d7 100644 --- a/python/pysetterminal/panel.py +++ b/python/pysetterminal/panel.py @@ -30,7 +30,8 @@ class ByteWorkPanel(QScrollArea): self.device_address = device_address self._start_word = start_word self._word_running = False - self._word_generation = 0 + self._word_generation = 0 # Поколение сохраняет совместимость с прежними обработчиками результатов. + self._word_keys = set() # Каждый принятый запрос слова получает независимый ключ отмены. self.setWidgetResizable(True) content = QWidget() self.setWidget(content) @@ -112,9 +113,6 @@ class ByteWorkPanel(QScrollArea): self.byte_read_binary.setText(self._binary(value)) def start_byte(self, write): - if self._word_running: - self._report("Дождитесь завершения текущего обмена") - return try: field = self.byte_write_address if write else self.byte_read_address text = field.text().strip() @@ -132,46 +130,42 @@ class ByteWorkPanel(QScrollArea): if self._start_word is not None: self._start_word(write, address, value) return - controller = self.device_address() if callable(self.device_address) else self.device_address - self._word_generation += 1 - generation = self._word_generation - self._set_word_running(True) - self._report("Запись и проверка чтением…" if write else "Чтение…") - self.protocol.request_async(address, value, controller, - lambda success, result: self._word_reply(generation, address, value, controller, success, result)) + controller = self.device_address() if callable(self.device_address) else self.device_address # Адрес контроллера фиксируется до ожидания занятого соединения. + key = object() # Повторное нажатие создаёт самостоятельное действие, а не заменяет предыдущее. + self._word_keys.add(key) # Все ожидающие слова учитываются для остановки страницы. + self._set_word_running(True) # Признак состояния не меняет доступность полей и кнопок. + self.bus_widget.queue.submit(lambda: self._word_steps(address, value), controller, lambda ok, reply: self._word_done(key, address, value, ok, reply), key=key) # Запись и проверка чтением выполняются целиком без вмешательства другой шины. - def _word_reply(self, generation, address, expected, controller, success, result): - if generation != self._word_generation or not self._word_running: - return - if not success: - self._set_word_running(False) - self._report(str(result)) - return - if expected is not None and "value" not in result: - self.protocol.request_async(address, None, controller, - lambda ok, reply: self._word_reply(generation, address, expected, controller, ok, reply)) - return - value = result["value"] - self.set_read_value(address, value) - self._set_word_running(False) - if expected is not None and value != expected: - self._report(f"Запись 0x{address:08X} не подтверждена: прочитано {value:04X}, ожидалось {expected:04X}") - else: - self._report("Запись проверена чтением" if expected is not None else "Чтение завершено") + def _word_steps(self, address, value): # Существующий протокол сохраняет проверку записанного слова чтением того же адреса. + if value is not None: # Чистое чтение не требует предварительного WriteMemory. + yield address, value # Подтверждение записи должно прийти до запроса проверки. + result = yield address, None # ReadMemory возвращает одно шестнадцатибитное слово по выбранному адресу. + if not isinstance(result, dict) or not isinstance(result.get("value"), int) or not 0 <= result["value"] <= 0xFFFF: # Неверный ответ не выдаётся за прочитанное слово. + raise ValueError("Некорректный ответ чтения памяти") # Ошибка формата остаётся доступна в статусе страницы. + return result # Только финальный ответ используется для отображения HEX и BIN. - def _set_word_running(self, running): - self._word_running = running - self.byte_write_button.setEnabled(not running) - self.byte_read_button.setEnabled(not running) + def _word_done(self, key, address, expected, success, result): # Результат связан с параметрами конкретного нажатия, а не с изменёнными полями. + if key not in self._word_keys: # Поздний callback закрытой страницы не перезаписывает её значения. + return # Остановленное действие не продолжает обмен. + self._word_keys.remove(key) # Этот запрос слова больше не ожидает соединение. + self._set_word_running(bool(self._word_keys)) # Признак учитывает остальные принятые нажатия. + if not success: # Таймаут, ошибка CRC или отключение отображаются как действительные ошибки связи. + self._report(str(result)) # Пользователь видит причину отказа данного запроса. + return # Не показываем недостоверное слово памяти. + value = result["value"] # Проверенное слово принадлежит завершённому ReadMemory. + self.set_read_value(address, value) # Заполняем имеющиеся поля HEX и BIN без пересоздания панели. + if expected is not None and value != expected: # Прежняя проверка записи сохраняется для несовпадающего прочитанного значения. + self._report(f"Запись 0x{address:08X} не подтверждена: прочитано {value:04X}, ожидалось {expected:04X}") # Указываем адрес, фактическое слово и ожидаемое значение. + else: # Успешное чтение либо совпадающая проверка записи завершают действие. + self._report("Запись проверена чтением" if expected is not None else "Чтение завершено") # Сохраняем понятный итог операции. - def stop(self): - """Cancel this page's scheduled work before closing its connection.""" - self.bus_widget.stop_macro() - if self._word_running: - self._word_generation += 1 - self._set_word_running(False) - self.protocol.cancel_async() - self._report("Обмен прерван; переданные команды могли быть выполнены") + def _set_word_running(self, running): # Состояние обмена хранится отдельно от доступности интерфейса. + self._word_running = running # Кнопки и поля принимают новые действия даже во время текущего обмена. + + def stop(self): # Отключение страницы отменяет задания всего общего соединения. + self._word_keys.clear() # Поздние результаты отключения не меняют поля закрываемой страницы. + self._set_word_running(False) # Завершаем состояние ожидания собственных запросов слова. + self.bus_widget.stop() # Общая очередь снимает неотправленные задания и отменяет текущий транспорт при закрытии соединения. def closeEvent(self, event): self.stop() diff --git a/python/pysetterminal/pbus_panel.py b/python/pysetterminal/pbus_panel.py new file mode 100644 index 0000000..28a2c47 --- /dev/null +++ b/python/pysetterminal/pbus_panel.py @@ -0,0 +1,346 @@ +"""Таблица наблюдаемых регистров PBUS внутри общей панели команд SETGUI.""" + +from PySide6.QtCore import Qt, QEvent, QTimer, QItemSelectionModel, QSize, QRect # Геометрия Qt позволяет компактно переносить связанные элементы в узком доке. +from PySide6.QtGui import QIntValidator # Проверяем четырёхбитные адреса платы и регистра ещё при вводе. +from PySide6.QtWidgets import QWidget, QLayout, QSizePolicy, QGroupBox, QVBoxLayout, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, QPlainTextEdit # Адаптивная строка сохраняет компактные действия независимо от ширины таблицы результатов. +from .bus_operations import table_entries_after, pbus_table_steps, pbus_control, pbus_result_address, PBUS_COMMAND, PBUS_SETTLE_MS, PBUS_ERROR_LIMIT # Полная конфигурация, адреса CD и остановка используют единый формат слов памяти TMS. + + +class _FlowLayout(QLayout): # Связанные элементы располагаются рядом и переносятся целиком при недостатке ширины. + def __init__(self, parent, spacing): # Промежуток задаётся в пикселях одинаково для горизонтального и вертикального переноса. + super().__init__(parent) # Компоновка принадлежит только своему компактному контейнеру. + self.items = [] # Храним порядок полей и действий без изменения их владельцев при переносе. + self.setContentsMargins(0, 0, 0, 0) # Контейнер не добавляет второго слоя отступов формы. + self.setSpacing(spacing) # Сохраняем небольшое постоянное расстояние между связанными элементами. + self.setProperty("fixedPanelLayout", True) # Общий менеджер панелей не должен заменять перенос элементов сплиттером. + + def addItem(self, item): # Qt вызывает этот метод при добавлении виджета в адаптивную строку. + self.items.append(item) # Сохраняем порядок элементов, заданный формой пользователя. + + def count(self): # Предоставляем Qt фактическое число элементов компоновки. + return len(self.items) # Перенос не создаёт дополнительных или скрытых копий действий. + + def itemAt(self, index): # Менеджер геометрии получает элемент по индексу без удаления. + return self.items[index] if 0 <= index < len(self.items) else None # Невалидный индекс завершает стандартный обход компоновки. + + def takeAt(self, index): # Qt извлекает элемент при удалении или уничтожении контейнера. + return self.items.pop(index) if 0 <= index < len(self.items) else None # Ссылки на элементы удаляются вместе с компоновкой. + + def expandingDirections(self): # Свободное пространство не должно растягивать промежутки между действиями. + return Qt.Orientations(0) # Элементы используют собственные размеры, оставляя лишнюю ширину справа. + + def hasHeightForWidth(self): # Высота зависит от числа строк после переноса по доступной ширине. + return True # Владелец учитывает дополнительную строку, не обрезая кнопку или результат. + + def heightForWidth(self, width): # Измеряем необходимую высоту до назначения окончательной геометрии. + return self._arrange(QRect(0, 0, width, 0), False) # Расчёт не перемещает виджеты при измерении размеров. + + def minimumSize(self): # Узкий док должен вмещать самый широкий отдельный элемент, а не сумму всех кнопок. + size = QSize(0, 0) # Начальная минимальная область не занимает места до добавления элементов. + for item in self.items: # Каждая кнопка или карточка переносится целиком и сохраняет читаемость. + size = size.expandedTo(item.minimumSize()) # Минимальная ширина равна самому широкому элементу, высота — минимальной строке. + return size # Перенос позволяет сохранить равные доли ширины команд и RAW-панели. + + def sizeHint(self): # Естественный размер подстраивается к ширине, которую даст внешний контейнер. + return self.minimumSize() # Дополнительная высота вычисляется через heightForWidth при реальном размещении. + + def setGeometry(self, rect): # Qt назначает доступную область адаптивной строки. + super().setGeometry(rect) # Сохраняем стандартную геометрию компоновки для родительского виджета. + self._arrange(rect, True) # Размещаем элементы по строкам внутри выделенной области. + + def _arrange(self, rect, apply): # Один расчёт используется измерением и реальным размещением элементов. + left, x, y, row_height = rect.x(), rect.x(), rect.y(), 0 # Начинаем каждую строку у левого края контейнера. + for item in self.items: # Порядок управления сохраняется при любом размере дока. + size = item.sizeHint().expandedTo(item.minimumSize()) # Не сжимаем подписи, стрелки интервала или считанное слово. + if x > left and x + size.width() > left + rect.width(): # Элемент переносится, если целиком не помещается после предыдущего. + x, y, row_height = left, y + row_height + self.spacing(), 0 # Следующая строка начинается без пустого промежутка слева. + if apply: # Во время измерения геометрия виджетов остаётся неизменной. + item.setGeometry(QRect(x, y, size.width(), size.height())) # Поля и действия занимают свою естественную ширину рядом друг с другом. + x += size.width() + self.spacing() # Следующий элемент получает только небольшой постоянный промежуток. + row_height = max(row_height, size.height()) # Высота строки вмещает самый высокий её элемент. + return y + row_height - rect.y() # Внешняя компоновка получает общую высоту всех занятых строк. + + +class CompactControls(QWidget): # Компактная строка переносит связанные поля, карточки или действия без растягивания по краям окна. + def __init__(self, widgets, parent=None, spacing=8): # По умолчанию элементы разделены восьмью пикселями. + super().__init__(parent) # Контейнер остаётся частью существующего блока своей шины. + policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Доступная ширина используется для переноса, высота соответствует содержимому. + policy.setHeightForWidth(True) # Узкий док выделяет высоту для следующей строки вместо увеличения минимальной ширины окна. + self.setSizePolicy(policy) # Внешняя область корректно учитывает адаптивную высоту формы. + layout = _FlowLayout(self, spacing) # Все элементы используют один расчёт переноса и минимального размера. + for widget in widgets: # Связанные действия добавляются в пользовательском порядке. + layout.addWidget(widget) # Перенос не изменяет обработчики команд и результаты операции. + + +class PbusPanel(QGroupBox): # Хранит один общий список источников PBUS и результаты их считывания. + def __init__(self, owner): # Владелец предоставляет единую очередь PBUS, SBUS и смешанных макросов. + super().__init__("PBUS (Параллельная шина)", owner) # Русская подпись обозначает правый блок существующей панели SETGUI. + self.owner, self.entries, self.applied = owner, [], False # Видимый список отдельно учитывает подтверждение его аппаратной конфигурации. + self.controller = None # Таблица считается применённой только для контроллера, которому отправлена конфигурация. + self._controls, self._applied_signature = {}, None # Последние подтверждённые управляющие слова хранятся отдельно для каждого контроллера. + self.table_errors = PBUS_ERROR_LIMIT # Начальный аппаратный порог равен десяти ошибкам, как в рабочем слове запуска 0x8Axx. + self.setObjectName("pbusGroup") # Стабильное имя сохраняет поиск панели в интеграциях и проверках интерфейса. + layout = QVBoxLayout(self) # Таблица и журнал получают свободную область независимо от ширины полей добавления. + layout.setContentsMargins(10, 14, 10, 10) # Отступы в пикселях совпадают с соседним блоком SBUS. + layout.setSpacing(8) # Параметры, действия и результаты образуют плотные связанные области. + layout.setProperty("fixedPanelLayout", True) # Общий менеджер панелей не должен превращать компактные формы в растягиваемые области. + controls = QWidget(self) # Адрес платы и регистр не делят столбцы с широкой таблицей результатов. + controls.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Контейнер ограничивается естественной шириной подписей и полей. + form = QGridLayout(controls) # Два компактных столбца сохраняют значения рядом с подписями. + form.setContentsMargins(0, 0, 0, 0) # Вложенная форма не добавляет второго уровня внешних отступов. + form.setHorizontalSpacing(10) # Подпись и её поле разделены десятью пикселями вместо ширины всей панели. + form.setVerticalSpacing(6) # Строки адреса и регистра остаются визуально связанными. + form.setProperty("fixedPanelLayout", True) # Форма сохраняет компактную геометрию при применении общей раскладки приложения. + self.controls = {} # Публичные элементы доступны владельцу без копирования их состояния. + for row, (name, caption) in enumerate((("tetrad_edit", "Адрес платы (0–15):"), ("register_edit", "Регистр (0–15):"))): # Для добавления достаточно указать источник данных, индексы буфера вычисляются автоматически. + field = QLineEdit("0") # Начальное значение позволяет добавить первый регистр без заполнения дополнительных аппаратных полей. + field.setValidator(QIntValidator(0, 15, field)) # Каждый адрес занимает одну тетраду дескриптора x_parallel_bus_add_table. + field.setFixedWidth(90) # Девяносто пикселей достаточно для адреса; поле не уезжает к правому краю широкого дока. + form.addWidget(QLabel(caption), row, 0) # Подпись объясняет допустимый адрес платы или регистра. + form.addWidget(field, row, 1) # Поле находится непосредственно рядом со своей подписью. + self.controls[name] = field # Сохраняем адресные поля для кнопки добавления и внешних проверок. + field.returnPressed.connect(self.add_register) # Enter в адресных полях добавляет регистр без перемещения к кнопке. + self.error_limit = QSpinBox(self) # Отдельный параметр правой колонки задаёт аппаратный порог ошибок PBUS. + self.error_limit.setRange(1, 15) # Порог занимает четыре бита управляющего слова; допустимы целые значения от одного до пятнадцати. + self.error_limit.setValue(PBUS_ERROR_LIMIT) # Значение десять соответствует шестнадцатеричному порогу 0xA. + self.error_limit.setFixedWidth(90) # Поле порога выровнено с существующими компактными адресными полями. + self.error_limit.setToolTip("Порог в битах 11:8 регистра 0x200C. 10 = 0xA. Применяется при добавлении, изменении таблицы или нажатии «Обновить».") # Пользователь видит единицы и момент аппаратного применения выбранного числа ошибок. + form.addWidget(QLabel("Порог ошибок (1–15):"), 2, 0) # Подпись отличает аппаратный порог от периода чтения и паузы после запуска. + form.addWidget(self.error_limit, 2, 1) # Настройка находится в блоке PBUS рядом с платой и регистром. + self.controls["error_limit"] = self.error_limit # Порог доступен владельцу как pbus_error_limit вместе с остальными элементами PBUS. + layout.addWidget(controls, 0, Qt.AlignLeft) # Компактная форма начинается слева и не растягивается вместе с таблицей. + self.add_btn, self.remove_btn = QPushButton("Добавить в таблицу"), QPushButton("Убрать из таблицы") # Подписи соответствуют работе со списком вместо ручного чтения и записи. + self.edit_btn = QPushButton("Изменить строку", self) # Адрес и регистр выделенной строки заменяются значениями полей без ручного удаления и добавления. + self.edit_btn.setFixedHeight(30) # Действие сохраняет существующую высоту кнопок управления таблицей. + self.edit_btn.setToolTip("Заменить выделенную строку адресом платы и регистром из полей выше.") # Пользователь видит, какие параметры будут применены к выбранному источнику. + self.edit_btn.clicked.connect(self.edit_selected) # Редактирование ставит полную перенастройку в ту же очередь памяти контроллера. + self.add_btn.clicked.connect(self.add_register) # Добавление само применяет таблицу и получает первое считанное значение. + self.remove_btn.clicked.connect(self.remove_selected) # Кнопка удаляет все выделенные строки независимо от текущей ячейки. + layout.addWidget(CompactControls((self.add_btn, self.edit_btn, self.remove_btn), self)) # Действия находятся рядом под адресом и переносятся в узком доке вместо сжатия RAW-панели. + self.auto_update = QCheckBox("Циклическое чтение") # Переключатель управляет запросами буфера, а не созданием независимого макроса. + self.auto_update.setChecked(True) # После явного добавления регистра таблица продолжает показывать текущие считанные значения. + self.interval = QSpinBox() # Интервал ограничивает частоту запросов ПК к контроллеру. + self.interval.setRange(1, 3600000) # Период чтения задаётся в миллисекундах от одной до одного часа. # Интервал от 100 мс до 60 с позволяет выбрать удобную частоту обновления без непрерывного цикла Qt. + self.interval.setValue(1000) # По умолчанию обновляем результаты раз в секунду, не перегружая последовательное соединение. + self.interval.setSuffix(" мс") # Единицы измерения видны непосредственно в поле интервала. + self.interval.setFixedWidth(120) # Сто двадцать пикселей вмещают число, единицы и стрелки темы без растягивания поля по всей панели. + self.startup_delay = QSpinBox(self) # Отдельная пауза относится только к аппаратному обновлению после запуска новой таблицы. + self.startup_delay.setRange(1, 60000) # Пользователь задаёт ожидание контроллера от одной миллисекунды до минуты. + self.startup_delay.setValue(PBUS_SETTLE_MS) # Начальное ожидание составляет сто миллисекунд и не заменяет период чтения. + self.startup_delay.setSuffix(" мс") # Поле явно обозначает единицу измерения задержки. + self.startup_delay.setFixedWidth(120) # Пауза занимает компактное место рядом со своей подписью. + self.startup_delay.setToolTip("После подтверждения запуска PBUS ждать аппаратного обновления памяти; прежние значения буфера до этого не показываются.") # Подсказка объясняет отличие от периода обычного опроса. + self.refresh_btn = QPushButton("Обновить") # Ручное обновление полезно при выключенном автоопросе или после подключения порта. + self.refresh_btn.clicked.connect(self.refresh) # Получаем все слова текущего списка одной последовательностью запросов. + for widget in (self.auto_update, self.interval, self.refresh_btn): # Размещаем все средства обновления в одной строке. + widget.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Переключатель и действия занимают необходимую ширину вместо распределения по краям окна. + layout.addWidget(CompactControls((self.auto_update, self.interval, self.refresh_btn), self)) # Средства обновления остаются компактной группой и переносятся при недостатке ширины. + layout.addWidget(CompactControls((QLabel("После запуска:"), self.startup_delay), self)) # Пауза настройки отделена от строки периодического чтения буфера. + self.table = QTableWidget(0, 5) # Одна строка соответствует одному добавленному регистру PBUS. + self.table.setObjectName("pbusResultsTable") # Имя позволяет проверять и находить новую таблицу результатов в SETGUI. + self.table.setHorizontalHeaderLabels(["Плата", "Регистр", "HEX", "DEC", "Состояние"]) # Показываем источник, оба представления слова и достоверность результата. + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) # Выделение целой строки делает удаление регистра однозначным. + self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) # Shift и Ctrl позволяют удалить несколько регистров одним действием. + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) # Изменение считанных значений не должно подменять аппаратный результат. + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) # Короткие адреса и значения занимают только необходимую ширину. + self.table.horizontalHeader().setStretchLastSection(True) # Состояние использует оставшуюся ширину, не обрезая описание ошибки. + self.table.setMinimumHeight(150) # Таблица остаётся пригодной для нескольких источников даже в компактной раскладке доков. + self.table.installEventFilter(self) # Клавиша Delete удаляет выбранные источники, когда таблица имеет фокус. + self.table.itemSelectionChanged.connect(self._selection_changed) # Доступность удаления соответствует текущему выделению строк. + layout.addWidget(self.table, 1) # Таблица результатов получает свободную высоту внутри блока PBUS. + self.result_label = QLabel("Добавьте регистры для считывания") # Начальный статус объясняет первое действие пользователя. + self.result_label.setWordWrap(True) # Диагностика остаётся внутри правого блока при изменении размера окна. + layout.addWidget(self.result_label) # Последнее состояние относится непосредственно к таблице PBUS. + self.output = QPlainTextEdit() # Собственный небольшой журнал сохраняет результаты изменения таблицы и пакеты ответов. + self.output.setReadOnly(True) # Пользователь может копировать ответы без изменения истории операций. + self.output.setMaximumBlockCount(200) # Ограничиваем историю, чтобы автообновление не увеличивало память бесконечно. + self.output.setFixedHeight(55) # Две-три строки журнала не вытесняют общую область макросов. + layout.addWidget(self.output) # Ответы PBUS сохраняются в правом блоке, без общей нижней панели результатов. + self.timer = QTimer(self) # Таймер принадлежит панели и прекращает существование вместе с ней. + self.timer.setTimerType(Qt.PreciseTimer) # Период циклического чтения не должен сокращаться из-за неточности таймера. + self.timer.timeout.connect(self._poll_tick) # Каждый такт читает только уже применённую аппаратную таблицу. + self.interval.valueChanged.connect(self._update_timer) # Новый интервал применяется к следующим циклам считывания. + self.auto_update.toggled.connect(self._auto_changed) # Пользователь может остановить обновление, не удаляя источники таблицы. + self.controls.update({"add_btn": self.add_btn, "remove_btn": self.remove_btn, "table": self.table, "result_label": self.result_label, "output": self.output}) # Экспортируем единственную таблицу и её действия для владельца. + self._selection_changed() # До выделения первой строки удаление недоступно. + + def command(self, operation, **parameters): # Создаём одинаковые команды для ручных действий и общего исполнителя макросов. + return {"bus": "pbus", "type": operation, "tetrad": 0, "register": 0, "errors": self.error_limit.value(), "settle_ms": self.startup_delay.value(), **parameters} # Действие фиксирует текущие параметры таблицы и паузу при постановке в очередь. + + def add_register(self): # Добавляем заданный адрес платы и регистра, не требуя номера строки буфера. + try: # Пустое поле не должно запускать аппаратную запись с неверным адресом. + command = self.command("add", tetrad=int(self.controls["tetrad_edit"].text()), register=int(self.controls["register_edit"].text())) # Фиксируем источник перед асинхронным обменом. + self.owner._start_commands([command], macro=False) # Таблица применяется через тот же транспорт, что SBUS и общие макросы. + except ValueError as error: # Ошибка ввода остаётся в области PBUS и не влияет на другой блок. + self.result_label.setText(f"PBUS: проверьте адрес и регистр: {error}") # Пользователь видит причину отказа непосредственно возле таблицы. + + def edit_selected(self): # Одна выбранная строка заменяется целым снимком таблицы с сохранением остальных источников и CD. + selected = self.table.selectionModel().selectedRows() # Фиксируем выделение в момент нажатия до ожидания очереди. + if len(selected) != 1: # Множественное выделение не должно случайно заменить несколько регистров одним адресом. + return # Пользователь должен выбрать конкретную редактируемую строку. + try: # Проверяем новые адреса ещё до остановки аппаратного опроса. + entries = [dict(entry) for entry in self.entries] # Полный снимок сохраняет порядок и явные CD всех остальных записей. + entries[selected[0].row()].update(tetrad=int(self.controls["tetrad_edit"].text()), register=int(self.controls["register_edit"].text())) # Меняются только плата и регистр выбранной строки, индекс и назначение результата сохраняются. + self.replace_entries(entries) # Очередь выполнит остановку, перепись всех записей и запуск как одну операцию. + except (ValueError, TypeError) as error: # Неверный ввод не меняет подтверждённую аппаратную таблицу. + self.result_label.setText(f"PBUS: проверьте адрес и регистр: {error}") # Причина отказа остаётся в собственной области PBUS. + + def remove_selected(self): # Удаляем все выделенные регистры кнопкой или клавишей Delete. + selected = sorted({index.row() for index in self.table.selectionModel().selectedRows()}) # Множественное выделение собираем один раз до переписи списка. + if not selected: # При отсутствии выделения список и аппаратная конфигурация остаются прежними. + return # Не создаём пустую аппаратную операцию удаления. + keys = [(self.entries[row]["tetrad"], self.entries[row]["register"]) for row in selected] # Удаление фиксируется по адресам, поэтому смещение индексов не удалит соседний регистр. + self.owner._start_commands([self.command("remove", remove_keys=keys)], macro=False) # Оставшиеся строки уплотняются и заново записываются в аппаратную таблицу. + + def eventFilter(self, watched, event): # Обеспечиваем привычное удаление строк клавишей Delete в таблице результатов. + if watched is self.table and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Delete: # Обрабатываем Delete только внутри своей таблицы PBUS. + self.remove_selected() # Используем то же действие, что кнопка удаления выделенных строк. + event.accept() # Исключаем дополнительную обработку Delete таблицей Qt. + return True # Событие полностью обработано удалением выбранных регистров. + return super().eventFilter(watched, event) # Остальные клавиши сохраняют штатное выделение и навигацию таблицы. + + def _selection_changed(self): # Согласуем кнопку удаления с реальным выделением таблицы. + self.remove_btn.setEnabled(bool(self.table.selectionModel().selectedRows())) # Пользователь сразу видит, когда выбран регистр для удаления. + self.edit_btn.setEnabled(len(self.table.selectionModel().selectedRows()) == 1) # Изменение строки доступно для единственного выделенного источника, независимо от занятости UART. + + def _render_entries(self): # Индексы видимой таблицы всегда соответствуют порядку аппаратного буфера после переписи. + selected = {(self.table.item(index.row(), 0).text(), self.table.item(index.row(), 1).text()) for index in self.table.selectionModel().selectedRows() if self.table.item(index.row(), 0) is not None} # Сохраняем выделение по адресам, чтобы обновление и уплотнение не выбирали другой регистр. + self.table.blockSignals(True) # Промежуточное заполнение не должно сбрасывать доступность удаления несколько раз за цикл. + self.table.setRowCount(len(self.entries)) # Добавление и удаление изменяют единую таблицу результатов. + self.table.clearSelection() # Старые числовые индексы после удаления могут относиться уже к другой плате. + for row, entry in enumerate(self.entries): # Каждая строка содержит стабильные адреса платы и регистра. + values = (str(entry["tetrad"]), str(entry["register"]), "—", "—", "Ожидает считывания") # До подтверждения новой таблицы не показываем старое слово как свежие данные. + for column, text in enumerate(values): # Заполняем источник, два представления значения и начальный статус. + self.table.setItem(row, column, QTableWidgetItem(text)) # Ячейки доступны для выделения и копирования без редактирования результата. + if (str(entry["tetrad"]), str(entry["register"])) in selected: # Сохраняем только те выделенные источники, которые ещё присутствуют в списке. + self.table.selectionModel().select(self.table.model().index(row, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows) # Восстанавливаем также множественное выделение после фонового считывания. + self.table.blockSignals(False) # Возвращаем обработку пользовательского изменения выделения. + self._selection_changed() # Кнопка удаления снова соответствует восстановленному выделению. + + def _signature(self, entries, errors): # Отпечаток содержит именно аппаратно значимые поля и порядок всей таблицы. + return (errors, tuple((entry["tetrad"], entry["register"], pbus_result_address(entry, index)) for index, entry in enumerate(entries))) # Различаем индекс записи и фактический адрес 20CD каждой строки. + + def replace_entries(self, entries): # Изменение состава, порядка или адресов передаётся как полный снимок новой таблицы. + self.owner._start_commands([self.command("configure", entries=[dict(entry) for entry in entries])], macro=False) # Повторный макрос и фоновый опрос не смогут вмешаться в атомарную перепись всего снимка. + + def sequence(self, command): # Одна логическая операция очереди охватывает остановку, все пары таблицы, запуск, паузу и первое чтение. + operation = command["type"] # Действие определяет изменение таблицы либо чтение уже подтверждённого буфера. + updated = table_entries_after(self.entries, command) # Полный снимок сохраняет явные CD и проверяет все источники до остановки. + if updated != self.entries: # Любое изменение источника, порядка или CD требует применения всей новой таблицы. + self.entries, self.applied = updated, False # Старый результат не подтверждает новые дескрипторы аппаратного опроса. + self._render_entries() # Очищаем отображение прежних слов ещё до начала первой управляющей записи. + controller, errors = self.owner._macro_controller, command.get("errors", self.table_errors) # Контроллер и аппаратный порог фиксируются для всей операции очереди. + signature = self._signature(self.entries, errors) # Сравниваем полный порядок и адреса, а не только длину списка. + configure = not self.applied or self.controller != controller or self._applied_signature != signature or operation in ("configure", "start") # Простое чтение не переписывает неизменённую применённую таблицу. + if command.get("read_only") and configure: # Периодическое обновление не имеет права автоматически переписывать неподтверждённую таблицу. + raise ValueError("PBUS: таблица изменена или не применена; выполните явное обновление") # Ошибка не отправляет ни управления, ни запуска контроллеру. + if configure: # До первого WriteMemory таблица перестаёт считаться успешно применённой. + self.applied, self.table_errors = False, errors # При отказе любая автоматическая попытка запуска останется запрещённой. + self._render_entries() # При явном повторном запуске старое слово также не выдаётся за подтверждение новой настройки. + self.result_label.setText("Применение таблицы; ожидание обновления аппаратного буфера…") # Состояние объясняет отсутствие прежних значений во время паузы. + def confirmed_control(value): # Вызывается только после успешного подтверждения соответствующей записи 0x200C. + self._controls[controller] = value # Следующая остановка сохранит реальные параметры прежней активной таблицы. + result = yield from pbus_table_steps(self.entries, errors, configure=configure, stop_control=self._controls.get(controller), settle_ms=command.get("settle_ms", self.startup_delay.value()), on_control=confirmed_control) # Существующий транспорт подтверждает каждый пакет, очередь удерживает всю перенастройку целиком. + result["controller"], result["signature"] = controller, signature # Применённость относится к точному снимку и тому контроллеру, который подтвердил обмен. + result["configured"], result["form_errors"] = configure, command.get("_form_errors", errors) # Сохраняем начало редактирования порога, чтобы не затереть более новое пользовательское значение завершением старой операции. + result["operation"], result["key"] = operation, (command["tetrad"], command["register"]) # Видимая строка добавления выбирается по исходным параметрам действия. + return result # Отображение подтверждается только после успешного чтения обновлённого аппаратного буфера. + + def stop_sequence(self, command): # Остановка сохраняет всю текущую таблицу и аппаратные параметры, сбрасывая только бит запуска. + self.timer.stop() # Будущие периодические чтения остановленной шины больше не назначаются. + self._set_cycle_checked(False) # Неотправленные фоновые шаги снимаются обработчиком переключателя. + controller = self.owner._macro_controller # Используем тот же контроллер, что зафиксирован общей очередью для этого действия. + control = self._controls.get(controller, pbus_control(len(self.entries), self.table_errors)) & 0x7FFF # Подтверждённые порог и последний индекс сохраняются даже при изменённом списке интерфейса. + reply = yield PBUS_COMMAND, control # Ждём реального подтверждения остановки через существующий WriteMemory. + self._controls[controller] = control # Только подтверждённая запись переводит аппаратное состояние в остановленное. + return reply # Считанные значения прежней таблицы не переобъявляются результатом новой конфигурации. + + def show_result(self, result): # Применяем считанные слова к тем строкам, чьи адреса подтверждены аппаратной конфигурацией. + self.entries = result["entries"] # Список из завершённого цикла соответствует порядку прочитанных слов буфера. + self.applied, self.controller = True, result["controller"] # Подтверждённая таблица допускает дальнейшее чтение без повторной записи дескрипторов. + self._applied_signature = result.get("signature", self._signature(self.entries, self.table_errors)) # Дальнейшие чтения разрешены только для того же состава, порядка и CD. + if result.get("configured") and self.error_limit.value() == result.get("form_errors"): # Явный порог выполненного макроса отражается в форме только при отсутствии более нового ввода пользователя. + self.error_limit.setValue(self.table_errors) # Поле показывает реально применённый порог, не заставляя следующее ручное действие возвращаться к старому значению. + self._render_entries() # Перестраиваем ячейки после добавления, удаления или смены контроллера. + for row, reply in enumerate(result["rows"]): # Каждое считанное слово отображается рядом с собственным источником. + value = reply["value"] # Получаем 16-битное слово из стандартного ответа PEEK транспорта TMS2812. + self.table.item(row, 2).setText(f"{value:04X}") # HEX занимает четыре цифры, как данные SBUS. + self.table.item(row, 3).setText(str(value)) # Десятичное значение помогает читать числовые регистры без ручного перевода. + self.table.item(row, 4).setText("OK") # Успешный цикл подтверждает достоверность слова для данного источника. + self.table.item(row, 2).setToolTip(reply.get("raw_response", {}).get("raw", b"").hex(" ").upper()) # Пакет конкретного чтения доступен в подсказке своей строки. + if result.get("operation") == "add" and not self.owner._macro_is_macro: # Ручное добавление позволяет сразу выделить новый источник для копирования или Delete. + for row, entry in enumerate(self.entries): # Находим добавленный регистр по адресу, независимо от числа уже наблюдаемых строк. + if (entry["tetrad"], entry["register"]) == result["key"]: # Выделяем именно выбранный источник, включая повторное добавление существующей строки. + self.table.selectRow(row) # Следующая клавиша Delete относится к добавленному или найденному регистру. + self.table.setFocus() # Удобное удаление не требует дополнительного щелчка после добавления кнопкой. + break # Источник уникален, поэтому поиск завершается после первой найденной строки. + self._update_timer() # Возобновляем обновление только для применённого непустого списка регистров. + + def show_error(self, message): # Ошибка аппаратной записи или чтения не должна оставлять прежние слова выглядеть новыми. + self.applied = False # После ошибки явное обновление повторно применяет список перед считыванием. + self.timer.stop() # Исключаем автоматические повторные записи после ошибки или неопределённого исхода операции. + self._set_cycle_checked(False) # Состояние переключателя честно показывает остановку фоновых запросов. + for row in range(self.table.rowCount()): # Недостоверность отображается в каждой затронутой строке общей таблицы. + self.table.item(row, 2).setText("—") # HEX не сохраняет устаревшее слово после отказа обмена. + self.table.item(row, 3).setText("—") # Десятичное значение также очищается, чтобы представления оставались согласованными. + self.table.item(row, 4).setText("Ошибка") # Краткий статус остаётся виден рядом с адресом регистра. + self.table.item(row, 4).setToolTip(message) # Полная причина доступна для конкретной строки при наведении. + + def refresh(self): # Обновление получает все текущие регистры, повторяя настройку только когда она не подтверждена. + self.owner._start_commands([self.command("poll")], macro=False) # Используем единую очередь, поэтому ручное обновление не вмешивается в другой макрос. + + def _update_timer(self, *unused): # Таймер сохраняет период от начала чтения, не перезапускаясь после каждого результата. + if self.auto_update.isChecked() and self.entries and self.applied: # Автоматическое чтение разрешено только для подтверждённой непустой таблицы. + if not self.timer.isActive() or self.timer.interval() != self.interval.value(): # Завершение текущего чтения не сдвигает уже отсчитываемый период. + self.timer.start(self.interval.value()) # Новый интервал применяется при включении или изменении настройки. + else: # Пустая таблица и выключенное задание не создают новых чтений. + self.timer.stop() # Отменяем дальнейшие срабатывания таймера этого источника. + + def _set_cycle_checked(self, checked): # Внутреннее изменение галочки не является новым нажатием пользователя и не порождает повторных управляющих записей. + previous = self.auto_update.blockSignals(True) # Отключение из обработки ошибки или закрытия не должно рекурсивно планировать WriteMemory. + self.auto_update.setChecked(checked) # Видимое состояние соответствует фактически разрешённым повторениям. + self.auto_update.blockSignals(previous) # Возвращаем прежний режим доставки пользовательских сигналов Qt. + self._update_timer() # Таймер согласуется с программно установленным состоянием. + if not checked: # Неотправленные периодические чтения больше не нужны выключенному источнику. + self.owner._cancel_source(self.owner._pbus_key) # Текущий UART-пакет завершится корректно, последующие шаги этого цикла не отправляются. + + def _auto_changed(self, checked): # Пользовательский переключатель управляет также аппаратной остановкой PBUS. + self._update_timer() # Будущие программные повторы выключаются сразу, до ожидания общего порта. + if not checked: # Выключение галочки должно прекратить и обновление интерфейса, и опрос самой параллельной шины. + controller = self.owner.device_address() if callable(self.owner.device_address) else self.owner.device_address # Остановка относится к выбранному соединению контроллера. + active = bool(self._controls.get(controller, 0) & 0x8000) # Подтверждённый бит запуска показывает, что аппаратный PBUS ещё работает. + pending_configuration = any(run["controller"] == controller and any(command.get("bus") == "pbus" and command["type"] in ("add", "remove", "configure", "start") for command in run["jobs"][run["index"]:]) for run in self.owner._runs) # Уже принятая полная настройка может ещё завершить запуск перед очередью остановки. + self.owner._cancel_source(self.owner._pbus_key) # Снимаем ожидающий цикл и ещё не переданные чтения, не повреждая текущий пакет UART. + if active or pending_configuration: # Начальное выключение пустой панели не должно отправлять беспричинный управляющий запрос. + self.owner._start_commands([self.command("stop")], macro=False) # Очередь дождётся текущей логической операции и очистит только бит 15 подтверждённых параметров активной таблицы. + elif self.entries and self.applied: # Включение ранее остановленной настроенной таблицы должно возобновлять также аппаратный опрос. + if not self._controls.get(self.controller, 0) & 0x8000: # После аппаратной остановки одного чтения буфера недостаточно для возобновления обмена. + self.owner._start_commands([self.command("start")], macro=False) # Явное включение пользователя полностью применяет таблицу и выбранный порог перед началом чтения. + else: # Уже работающая применённая таблица не требует перезапуска для включения периодического чтения интерфейса. + self._poll_tick() # Первый обычный цикл читает только назначения результатов 20CD. + elif self.entries: # Неподтверждённая после ошибки таблица по-прежнему требует явного применения. + self.result_label.setText("Нажмите «Обновить», чтобы применить таблицу перед циклическим чтением.") # Выключенный аппаратный опрос не маскируется чтением старых значений памяти. + + def _poll_tick(self): # Один источник добавляет не более одного незавершённого чтения таблицы PBUS. + controller = self.owner.device_address() if callable(self.owner.device_address) else self.owner.device_address # Адрес контроллера снимается для нового цикла. + if self.applied and controller != self.controller: # Таблица прежнего контроллера не применяется другому устройству автоматически. + self.timer.stop() # Смена адреса прекращает фоновые чтения, сохраняя известные параметры других контроллеров. + self.applied = False # Новый контроллер требует явного подтверждения своей таблицы. + self._set_cycle_checked(False) # Галочка отражает остановку опроса прежнего устройства. + self.result_label.setText("Адрес контроллера изменён. Нажмите «Обновить», чтобы применить таблицу.") # Сообщаем необходимое действие рядом с результатами. + return # Новый контроллер не получает неподтверждённую конфигурацию от таймера. + if self.applied and self._applied_signature != self._signature(self.entries, self.table_errors): # Изменение порядка, источников или CD не должно пройти незамеченным фоновым чтением. + self.applied = False # Старые подтверждения относятся к другому снимку таблицы. + self.timer.stop() # Периодическое обновление не пытается автоматически запустить новый список. + self._render_entries() # Прежние слова не показываются как результат изменённых строк. + self.result_label.setText("Таблица изменена. Нажмите «Обновить», чтобы применить её.") # Пользователь явно подтверждает аппаратную перенастройку. + if self.entries and self.applied and self.auto_update.isChecked(): # Чтение буфера использует только подтверждённые строки таблицы. + self.owner._start_commands([self.command("poll", read_only=True, errors=self.table_errors)], macro=False, background=True, key=self.owner._pbus_key) # Занятость ставит один цикл в очередь, повторные такты подавляются ключом задания. + + def stop_updates(self): # Отключение и команда остановки запрещают будущие запросы данного источника. + self._set_cycle_checked(False) # Отключение соединения не создаёт новую команду остановки уже закрываемому порту. + self.timer.stop() # Больше не назначаем периодические чтения. + self.owner._cancel_source(self.owner._pbus_key) # Отменяем неотправленные шаги циклического чтения без повреждения UART. + self.applied = False # После нового подключения таблица требует явного применения. + self._controls.clear() # После отключения нельзя считать прежние управляющие слова состоянием нового соединения. + self._applied_signature = None # Новое подключение должно подтвердить весь снимок таблицы заново. diff --git a/python/pysetterminal/rawprotocol.py b/python/pysetterminal/rawprotocol.py index 4738e79..2c18150 100644 --- a/python/pysetterminal/rawprotocol.py +++ b/python/pysetterminal/rawprotocol.py @@ -3,7 +3,8 @@ from PySide6.QtWidgets import * from PySide6.QtCore import * from PySide6.QtGui import * from PySide6.QtSerialPort import QSerialPort -from protocan import tms_terminal as memory +from protocan import tms_terminal as memory # Существующий кодек сохраняет формат запросов памяти и проверку CRC. +from .exchange_queue import exchange_queue # RAW и обращения памяти разделяют очередь с шинами и макросами. class RawProtocol: @@ -33,8 +34,10 @@ class RawProtocol: self.port.setBaudRate(baudrate) return self.port.open(QIODevice.ReadWrite) - def disconnect(self): - self.port.close() + def disconnect(self): # Отключение снимает все задания данного соединения. + exchange_queue(self).clear() # Неотправленные операции не должны перейти на новое соединение. + self.cancel_async("COM отключён") # Ожидающий callback завершается реальной ошибкой закрытия порта. + self.port.close() # После завершения состояния обмена закрываем существующий UART. def _receive(self): self.feed(bytes(self.port.readAll())) @@ -138,7 +141,6 @@ class RawProtocol: self._settle.setSingleShot(True) self._settle.timeout.connect(loop.quit) if self.host: - self.host.byte_page.setEnabled(False) self.host._pending_address = self._controller self.host._pending_kind = "pysetterminal" self.host._log("TX", request) @@ -162,7 +164,6 @@ class RawProtocol: self._loop = None if self.host: self.host._pending_address = self.host._pending_kind = None - self.host.byte_page.setEnabled(True) def poke(self, mem_address, data_value, device_address=None): return self._exchange(mem_address, data_value, device_address) @@ -206,10 +207,16 @@ class RawProtocolWidget(QWidget): def init_ui(self): layout = QVBoxLayout() + layout.setProperty("fixedPanelLayout", True) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) # Группа для ПРЯМОГО ДОСТУПА К ПАМЯТИ (PEEK/POKE) mem_group = QGroupBox("Прямой доступ к памяти (PEEK/POKE)") + mem_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) mem_layout = QGridLayout() + mem_layout.setContentsMargins(6, 6, 6, 6) + mem_layout.setSpacing(4) mem_layout.addWidget(QLabel("Адрес памяти (hex):"), 0, 0) self.mem_addr_edit = QLineEdit("2000") @@ -239,6 +246,9 @@ class RawProtocolWidget(QWidget): # Группа для RAW отправки raw_group = QGroupBox("RAW отправка/чтение") raw_layout = QVBoxLayout() + raw_layout.setProperty("fixedPanelLayout", True) + raw_layout.setContentsMargins(6, 6, 6, 6) + raw_layout.setSpacing(4) self.raw_send_edit = QLineEdit() self.raw_send_edit.setPlaceholderText("Введите hex данные для отправки") @@ -246,6 +256,7 @@ class RawProtocolWidget(QWidget): raw_layout.addWidget(self.raw_send_edit) hbox = QHBoxLayout() + hbox.setSpacing(4) self.raw_send_btn = QPushButton("Отправить RAW") self.raw_send_btn.clicked.connect(self.send_raw_data) hbox.addWidget(self.raw_send_btn) @@ -260,66 +271,59 @@ class RawProtocolWidget(QWidget): self.raw_output = QTextEdit() self.raw_output.setReadOnly(True) - self.raw_output.setMaximumHeight(100) - raw_layout.addWidget(self.raw_output) + self.raw_output.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + raw_layout.addWidget(self.raw_output, 1) self.raw_clear_btn = QPushButton("Очистить логи") self.raw_clear_btn.clicked.connect(self.clear_logs) raw_layout.addWidget(self.raw_clear_btn, 0, Qt.AlignLeft) + for button in (self.mem_write_btn, self.mem_read_btn, self.raw_send_btn, + self.raw_read_btn, self.raw_clear_btn): + button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) raw_group.setLayout(raw_layout) - layout.addWidget(raw_group) + layout.addWidget(raw_group, 1) self.setLayout(layout) - def memory_write(self): - """Запись в память (POKE)""" - try: - device_addr = self.device_address() if callable(self.device_address) else self.device_address - mem_addr = int(self.mem_addr_edit.text(), 16) - data = int(self.mem_data_edit.text(), 16) + def memory_write(self): # Фиксируем адрес и слово при нажатии без блокировки других элементов. + self._memory_action(True) # Запись занимает одну логическую операцию общей очереди. - success, result = self.raw_protocol.poke(mem_addr, data, device_addr) + def memory_read(self): # Ручное чтение памяти принимается даже во время циклического опроса шин. + self._memory_action(False) # Параметры текущего поля снимаются до ожидания соединения. - if success: - self.raw_output.append(f"POKE 0x{mem_addr:04X} = 0x{data:04X}") - self.raw_output.append(f"Ответ: {result['raw'].hex()}") - else: - self.raw_output.append(f"Ошибка POKE: {result}") + def _memory_action(self, write): # Оба направления памяти используют прежний протокол через общий планировщик. + try: # Проверка ввода выполняется до передачи любого пакета контроллеру. + controller = self.device_address() if callable(self.device_address) else self.device_address # Контроллер сохраняется на момент нажатия. + address = int(self.mem_addr_edit.text(), 16) # Адрес фиксируется независимо от последующего редактирования поля. + value = int(self.mem_data_edit.text(), 16) if write else None # Значение участвует только в WriteMemory. + if not 0 <= address <= 0xFFFFFFFF or (value is not None and not 0 <= value <= 0xFFFF): # Сохраняем разрядность адреса и шестнадцатибитного слова. + raise ValueError("Проверьте адрес и данные памяти") # Неверные поля не создают задания UART. + except ValueError as error: # Промежуточный неправильный ввод остаётся локальной ошибкой интерфейса. + self.mem_result_label.setText(str(error)) # Пользователь видит причину прямо возле результата памяти. + return # Очередь текущего соединения не изменяется. + def steps(): # Одна операция возвращает итог существующего PEEK или POKE. + result = yield address, value # Направление обмена определяется прежним соглашением None для чтения. + return result # Результат отправляется только виджету инициатора действия. + exchange_queue(self.raw_protocol).submit(steps, controller, lambda ok, reply: self._memory_done(address, value, ok, reply)) # Запрос ожидает завершения текущей логической операции шин или макроса. - except ValueError as e: - self.raw_output.append(f"Ошибка в данных: {e}") + def _memory_done(self, address, value, success, result): # Результат отображается по сохранённым параметрам конкретного нажатия. + if not success: # Ошибка CRC, таймаут и отключение не подменяются успешным результатом. + self.mem_result_label.setText(f"Ошибка: {result}") # Показываем отказ непосредственно в области памяти. + self.raw_output.append(f"Ошибка памяти: {result}") # История сохраняет причину неудачной операции. + elif value is None: # ReadMemory должен вернуть проверенное шестнадцатибитное слово. + word = result["value"] # Слово декодировано существующим кодеком протокола. + self.mem_result_label.setText(f"0x{word:04X} ({word})") # Показываем HEX и DEC одного результата чтения. + self.raw_output.append(f"PEEK 0x{address:04X} = 0x{word:04X}") # Адрес отражает выполненное действие, даже если поле уже изменилось. + else: # Подтверждённая запись сохраняет прежний формат диагностического журнала. + self.raw_output.append(f"POKE 0x{address:04X} = 0x{value:04X}") # Отображаем записанное слово и адрес снимка параметров. - def memory_read(self): - """Чтение из памяти (PEEK)""" - try: - device_addr = self.device_address() if callable(self.device_address) else self.device_address - mem_addr = int(self.mem_addr_edit.text(), 16) - - success, result = self.raw_protocol.peek(mem_addr, device_addr) - - if success: - value = result['value'] - self.mem_result_label.setText(f"0x{value:04X} ({value})") - self.raw_output.append(f"PEEK 0x{mem_addr:04X} = 0x{value:04X}") - self.raw_output.append(f"Ответ: {result['raw_response']['raw'].hex()}") - else: - self.mem_result_label.setText(f"Ошибка: {result}") - self.raw_output.append(f"Ошибка PEEK: {result}") - - except ValueError as e: - self.mem_result_label.setText(f"Ошибка: {e}") - self.raw_output.append(f"Ошибка в данных: {e}") - - def send_raw_data(self): - """Отправка сырых данных""" - hex_str = self.raw_send_edit.text() - success, result = self.raw_protocol.send_raw(hex_str) - - if success: - self.raw_output.append(result) - else: - self.raw_output.append(f"Ошибка: {result}") + def send_raw_data(self): # Произвольная ручная отправка также ждёт завершения целой операции шины. + text = self.raw_send_edit.text() # HEX-пакет фиксируется при нажатии кнопки отправки. + def send(): # Отправка выполняется после получения общей очередью свободного UART. + success, result = self.raw_protocol.send_raw(text) # Существующий RAW-протокол сохраняет формат и обработку ошибки порта. + self.raw_output.append(result if success else f"Ошибка: {result}") # Итог принадлежит сохранённому пакету данного нажатия. + exchange_queue(self.raw_protocol).submit_external(send, lambda: False, lambda ok, reply: None) # RAW не ожидает типизированного ответа, сохраняя прежнее поведение панели. def read_raw_data(self): """Чтение сырых данных""" diff --git a/python/pysetterminal/serialbus.py b/python/pysetterminal/serialbus.py index 27dc9c3..7301783 100644 --- a/python/pysetterminal/serialbus.py +++ b/python/pysetterminal/serialbus.py @@ -6,6 +6,9 @@ from copy import deepcopy from PySide6.QtWidgets import * from PySide6.QtCore import * from PySide6.QtGui import * +from .bus_operations import validate_command, table_entries_after, PBUS_ERROR_LIMIT # Обе шины и общий макрос используют один проверенный алгоритм адресации. +from .pbus_panel import PbusPanel, CompactControls, _FlowLayout # Макросы используют существующий перенос элементов слева направо. +from .exchange_queue import exchange_queue # Один протокол разделяет одну очередь между шинами, макросами и памятью. class _MacroActionIcon(QIconEngine): @@ -69,7 +72,7 @@ class SerialBus: else self.raw.poke(address, value, device_address)) if not success: return False, result - return True, "Запись в шину подтверждена" if write else result + return True, result # Сохраняем пакет последнего POKE также для отображения подтверждения записи в собственном журнале SBUS. def write_bus(self, data_word, address_tetrad, register_num=0, device_address=1): return self._execute(True, address_tetrad, register_num, data_word, device_address) @@ -96,13 +99,24 @@ class MacroCommandDialog(QDialog): type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Тип команды:")) self.cmd_type = QComboBox() - self.cmd_type.addItems(["Запись", "Чтение"]) + self.cmd_type.addItem("Запись", "write") # Сохраняем код операции отдельно от русской подписи редактора. + self.cmd_type.addItem("Чтение", "read") # Код чтения используется общим макросом независимо от языка интерфейса. type_layout.addWidget(self.cmd_type) layout.addLayout(type_layout) + bus_layout = QHBoxLayout() # Отдельный выбор шины позволяет смешивать PBUS и SBUS в одном макросе. + bus_layout.addWidget(QLabel("Шина:")) # Подпись объясняет назначение переключателя для каждой команды. + self.bus_type = QComboBox() # Выбор хранится в команде, независимо от последней ручной операции. + self.bus_type.addItem("SBUS (Сериальная шина)", "sbus") # Существующая последовательная шина остаётся первой в выборе общей команды. + self.bus_type.addItem("PBUS (Параллельная шина)", "pbus") # Вторая шина управляет тем же общим макросом и таблицей регистров справа. + self.bus_type.setCurrentIndex(self.bus_type.findData(self.command.get("bus", "sbus"))) # Старые команды без поля bus сохраняют принадлежность SBUS. + bus_layout.addWidget(self.bus_type) # Размещаем выбор шины перед адресом платы и регистра. + layout.addLayout(bus_layout) # Добавляем строку выбора в общую форму редактора. + # Адрес устройства addr_layout = QHBoxLayout() - addr_layout.addWidget(QLabel("Адрес устройства (0-14):")) + self.device_addr_label = QLabel("Адрес устройства (0–14):") # Диапазон подписи расширяется до 0–15 при выборе новой PBUS. + addr_layout.addWidget(self.device_addr_label) # Сохраняем подпись для синхронизации с допустимым адресом. self.device_addr = QSpinBox() self.device_addr.setRange(0, 14) addr_layout.addWidget(self.device_addr) @@ -124,18 +138,23 @@ class MacroCommandDialog(QDialog): data_layout.addWidget(self.data_label) data_layout.addWidget(self.data_edit) layout.addLayout(data_layout) - - # Загружаем данные команды, если она передана - if self.command: - if self.command.get('type') == 'write': - self.cmd_type.setCurrentText("Запись") - self.device_addr.setValue(self.command.get('tetrad', 0)) - self.register_num.setValue(self.command.get('register', 0)) - self.data_edit.setText(f"{self.command.get('data', 0):04X}") - else: - self.cmd_type.setCurrentText("Чтение") - self.device_addr.setValue(self.command.get('tetrad', 0)) - self.register_num.setValue(self.command.get('register', 0)) + table_form = QGridLayout() # Для PBUS пользователь задаёт источник; индексы и длину таблицы рассчитывает панель автоматически. + self.pbus_error_label, self.pbus_errors = QLabel("Порог ошибок (1–15):"), QSpinBox() # Единственный дополнительный параметр соответствует порогу x_parallel_bus.h. + self.pbus_errors.setRange(1, 15) # Порог занимает четыре бита и не допускает нуля, который объявил бы любой статус ошибкой. + self.pbus_errors.setValue(self.command.get("errors", PBUS_ERROR_LIMIT)) # Новая команда использует порог 0xA, явно сохранённое значение существующего макроса сохраняется. + table_form.addWidget(self.pbus_error_label, 0, 0) # Подпись описывает аппаратное условие остановки опроса PBUS. + table_form.addWidget(self.pbus_errors, 0, 1) # Поле применяется к добавлению, удалению и обновлению таблицы. + layout.addLayout(table_form) # Дополнительный параметр находится под адресом платы и регистра. + self.bus_type.currentIndexChanged.connect(self.on_bus_changed) # Выбор шины меняет набор команд в общем редакторе, не создавая отдельного макроса. + self.on_bus_changed() # Применяем диапазоны и допустимые операции до восстановления сохранённой команды. + if self.command: # При редактировании восстанавливаем источник и код операции из общего JSON. + operation = self.command.get("type", "read") # Старые макросы SBUS продолжают использовать коды read и write. + if self.bus_type.currentData() == "pbus": # Ранее сохранённые одиночные операции PBUS переводятся к действиям таблицы. + operation = {"write": "add", "read": "poll"}.get(operation, operation) # Запись соответствует добавлению, чтение — обновлению значений общей таблицы. + self.cmd_type.setCurrentIndex(self.cmd_type.findData(operation)) # Выбираем операцию по коду, без зависимости от порядка или русской подписи. + self.device_addr.setValue(self.command.get("tetrad", 0)) # Восстанавливаем адрес платы для добавления или удаления регистра. + self.register_num.setValue(self.command.get("register", 0)) # Восстанавливаем номер наблюдаемого регистра. + self.data_edit.setText(f"{self.command.get('data', 0):04X}") # Данные прежней команды записи SBUS сохраняются без изменения. # Подключаем изменение типа команды self.cmd_type.currentTextChanged.connect(self.on_cmd_type_changed) @@ -153,9 +172,27 @@ class MacroCommandDialog(QDialog): self.setLayout(layout) + def on_bus_changed(self): # Переключаем отдельную команду внутри одного общего макроса PBUS и SBUS. + parallel = self.bus_type.currentData() == "pbus" # У PBUS доступны операции над общей таблицей наблюдаемых регистров. + maximum = 15 if parallel else 14 # PBUS использует четырёхбитный адрес, SBUS сохраняет прежний диапазон 0–14. + self.device_addr.setRange(0, maximum) # Исключаем адреса, неподдерживаемые алгоритмом выбранной шины. + self.device_addr_label.setText(f"Адрес устройства (0–{maximum}):") # Подпись соответствует реальному ограничению адресного поля. + previous = self.cmd_type.currentData() # Сохраняем код прежней операции независимо от надписи в списке. + self.cmd_type.blockSignals(True) # Не обновляем форму для промежуточного пустого набора операций. + self.cmd_type.clear() # Удаляем операции предыдущей шины перед добавлением нового набора. + operations = (("Добавить в таблицу", "add"), ("Убрать из таблицы", "remove"), ("Обновить значения", "poll"), ("Запуск опроса", "start"), ("Остановка опроса", "stop")) if parallel else (("Запись", "write"), ("Чтение", "read")) # PBUS работает со списком, SBUS сохраняет ручное чтение и запись слова. + for caption, code in operations: # Коды действий хранятся отдельно от русских названий элементов интерфейса. + self.cmd_type.addItem(caption, code) # Общий редактор использует один список команд выбранной шины. + index = self.cmd_type.findData(previous) # Проверяем, поддерживается ли прежняя операция новой шиной. + self.cmd_type.setCurrentIndex(max(0, index)) # Если прежнее действие недоступно, выбираем первое допустимое действие. + self.cmd_type.blockSignals(False) # Возвращаем обработку обычного изменения операции пользователем. + self.pbus_error_label.setVisible(parallel) # Порог ошибок PBUS не загромождает редактирование команды SBUS. + self.pbus_errors.setVisible(parallel) # Поле сохраняется в команде только для параллельной шины. + self.on_cmd_type_changed(self.cmd_type.currentText()) # Видимость данных согласуется с окончательным выбором операции. + def on_cmd_type_changed(self, text): """Показать/скрыть поле данных в зависимости от типа команды""" - if text == "Запись": + if self.bus_type.currentData() == "sbus" and self.cmd_type.currentData() == "write": # Данные периферии доступны только при записи существующей SBUS. self.data_label.show() self.data_edit.show() else: @@ -163,7 +200,7 @@ class MacroCommandDialog(QDialog): self.data_edit.hide() def _accept_command(self): - if self.cmd_type.currentText() == "Запись": + if self.bus_type.currentData() == "sbus" and self.cmd_type.currentData() == "write": # Проверяем HEX-слово лишь для операции, которая действительно передаёт данные. try: value = int(self.data_edit.text(), 16) if not 0 <= value <= 0xFFFF: @@ -171,24 +208,32 @@ class MacroCommandDialog(QDialog): except ValueError: QMessageBox.warning(self, "Ошибка", "Данные должны быть в диапазоне 0000…FFFF") return - self.accept() + try: # Проверяем взаимосвязь строки и длины таблицы до сохранения команды макроса. + validate_command(self.get_command()) # Одна проверка используется редактором и исполнителем команд обеих шин. + except (KeyError, ValueError, TypeError) as error: # Некорректная команда остаётся открытой для исправления. + QMessageBox.warning(self, "Ошибка", str(error)) # Показываем конкретный неверный параметр пользователю редактора. + return # Не сохраняем команду, которая остановит выполнение макроса. + self.accept() # Закрываем редактор только после полной проверки команды. def get_command(self): """Получить команду из диалога""" - cmd_type = 'write' if self.cmd_type.currentText() == "Запись" else 'read' + cmd_type = self.cmd_type.currentData() # Код выбранного действия сохраняется в общем JSON независимо от русской подписи. command = { 'type': cmd_type, 'tetrad': self.device_addr.value(), 'register': self.register_num.value() } - if cmd_type == 'write': + if cmd_type == 'write' and self.bus_type.currentData() == "sbus": # Поле data относится только к записи данных последовательной шины. try: command['data'] = int(self.data_edit.text(), 16) except ValueError: command['data'] = 0 - return command + command['bus'] = self.bus_type.currentData() # Сохраняем шину в JSON, чтобы повторный запуск и редактирование использовали тот же алгоритм. + if command["bus"] == "pbus": # Запоминаем настройки таблицы независимо от последнего состояния ручной панели. + command["errors"] = self.pbus_errors.value() # Номера строк и размер таблицы вычисляются автоматически из общего списка регистров PBUS. + return command # Возвращаем адрес, регистр, данные и выбранную шину как одну команду макроса. class MacroEditorDialog(QDialog): @@ -211,6 +256,14 @@ class MacroEditorDialog(QDialog): self.macro_name = QLineEdit(self.macro.get('name', 'Новый макрос')) name_layout.addWidget(self.macro_name) layout.addLayout(name_layout) + period_layout = QHBoxLayout() # Каждый макрос сохраняет собственный период повторения в редакторе. + period_layout.addWidget(QLabel("Период повторения:")) # Подпись обозначает время между началами повторных запусков. + self.repeat_period = QSpinBox(self) # Значение хранится в настройках данного макроса, независимо от шин. + self.repeat_period.setRange(1, 3600000) # Период задаётся в миллисекундах от одной до одного часа. + self.repeat_period.setSuffix(" мс") # Единица измерения доступна непосредственно при редактировании. + self.repeat_period.setValue(self.macro.get("period_ms", 1000)) # Старые макросы получают период одной секунды без изменения команд. + period_layout.addWidget(self.repeat_period) # Поле расположено рядом с подписью в одной строке. + layout.addLayout(period_layout) # Период относится ко всему макросу и расположен перед списком команд. # Список команд layout.addWidget(QLabel("Команды:")) @@ -260,11 +313,14 @@ class MacroEditorDialog(QDialog): """Загрузить команды в список""" self.commands_list.clear() for cmd in self.macro['commands']: - if cmd['type'] == 'write': + if cmd.get("bus", "sbus") == "pbus": # Команды таблицы требуют собственного описания вместо HEX-данных SBUS. + action = {"add": "Добавить в таблицу", "remove": "Убрать из таблицы", "poll": "Обновить значения", "write": "Добавить в таблицу", "read": "Обновить значения", "start": "Запуск опроса", "stop": "Остановка опроса"}.get(cmd["type"], "Неизвестная команда") # Общий макрос явно показывает действия со списком параллельной шины. + text = f"{action}: плата {cmd['tetrad']}, регистр {cmd['register']}" if cmd["type"] in ("add", "remove", "write") else action # Добавление и удаление задают источник; остальные операции относятся ко всему списку PBUS. + elif cmd['type'] == 'write': # Существующие команды записи SBUS сохраняют прежнее отображение данных. text = f"Запись: устр.{cmd['tetrad']}, рег.{cmd['register']}, 0x{cmd['data']:04X}" else: text = f"Чтение: устр.{cmd['tetrad']}, рег.{cmd['register']}" - self.commands_list.addItem(text) + self.commands_list.addItem(f"{cmd.get('bus', 'sbus').upper()}: {text}") # Показываем шину каждой строки; старые команды обозначаем существовавшей SBUS. def add_command(self): """Добавить новую команду""" @@ -311,14 +367,15 @@ class MacroEditorDialog(QDialog): def get_macro(self): """Получить макрос из диалога""" return { - 'name': self.macro_name.text(), + 'name': self.macro_name.text(), # Имя используется кнопкой выполнения и сохраняется при редактировании. + 'period_ms': self.repeat_period.value(), # Независимый период сохраняется в JSON данного макроса в миллисекундах. 'commands': self.macro['commands'].copy() } class SerialTab(QWidget): - """Вкладка для работы с серийной шиной (ТОЛЬКО операции с шиной)""" + """Два независимых блока PBUS и SBUS с общей областью управления макросами.""" # Историческое имя класса сохранено для совместимости встраиваемых панелей. MACROS_FILE = "macros.json" # Файл для сохранения макросов @@ -333,6 +390,11 @@ class SerialTab(QWidget): self.macro_buttons = [] # Список кнопок макросов self._macro_running = False self._macro_generation = 0 + self._background = False # Фоновые чтения таблицы отличаются от общего макроса и уступают ручным действиям. + self._macro_is_macro = False # Совместимое состояние показывает наличие выполняемого макроса. + self.queue = exchange_queue(raw_protocol) # Все панели данного соединения используют один планировщик UART. + self._runs, self._macro_cycles = [], {} # Запуски и независимые таймеры сохраняют собственное состояние. + self._sbus_key, self._pbus_key = (id(self), "sbus"), (id(self), "pbus") # По одному ключу на каждое периодическое задание исключают дубликаты. from pathlib import Path self.MACROS_FILE = str(macros_file if macros_file is not None else @@ -343,54 +405,133 @@ class SerialTab(QWidget): self.init_ui() + def _build_bus_group(self, bus, title): # Создаём левую область существующей SBUS с собственными результатами. + group = QGroupBox(title) # Русская подпись однозначно обозначает последовательную шину. + group.setObjectName(bus + "Group") # Стабильное имя сохраняет поиск блока для встраивающего приложения. + outer, panel = QVBoxLayout(group), {} # Широкий журнал и компактные поля получают независимые области компоновки. + outer.setContentsMargins(10, 14, 10, 10) # Отступы в пикселях отделяют содержимое от рамки, сохраняя компактность панели. + outer.setSpacing(8) # Между формой, статусом и журналом оставляем одинаковые небольшие промежутки. + outer.setProperty("fixedPanelLayout", True) # Автоматическое преобразование панелей не должно растягивать эту пользовательскую форму. + controls = QWidget(group) # Отдельный контейнер не делит столбцы полей с широким журналом результатов. + controls.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Форма занимает только необходимую ширину и высоту даже в полноэкранном доке. + form = QGridLayout(controls) # Подпись и её поле остаются рядом в двух компактных столбцах. + form.setContentsMargins(0, 0, 0, 0) # У вложенной формы нет лишнего второго слоя отступов. + form.setHorizontalSpacing(10) # Между подписью и полем оставляем десять пикселей вместо свободной ширины всей панели. + form.setVerticalSpacing(6) # Строки параметров образуют плотный визуально связанный блок. + form.setProperty("fixedPanelLayout", True) # Поля остаются формой при изменении размера и применении общего менеджера панелей. + fields = (("Данные (HEX):", "data_edit", "0000"), ("Адрес (0–14):", "tetrad_edit", "0"), ("Регистр (0–15):", "register_edit", "0")) # Сохраняем прежние параметры и допустимые диапазоны SBUS. + for row, (caption, name, default) in enumerate(fields): # Размещаем параметры в едином порядке без изменения аппаратных операций. + form.addWidget(QLabel(caption), row, 0) # Подпись находится непосредственно слева от соответствующего поля. + field = QLineEdit(default) # Исходные нули сохраняют прежнее поведение ручных команд. + field.setFixedWidth(100) # Сто пикселей достаточно для шестнадцатибитного HEX и двухзначного адреса. + field.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9A-Fa-f]{1,4}"), field) if name == "data_edit" else QIntValidator(0, 15 if name == "register_edit" else 14, field)) # Данные и адреса проверяются до аппаратной передачи. + panel[name] = field # Прежние ссылки bus_* остаются совместимыми с существующими вкладками SETGUI. + form.addWidget(field, row, 1) # Значение остаётся рядом с подписью независимо от ширины журнала. + buttons = QHBoxLayout() # Действия следуют сразу за компактными параметрами одной шины. + buttons.setSpacing(6) # Кнопки чтения и записи воспринимаются как одна группа действий. + for write, name, caption in ((True, "write_btn", "Записать"), (False, "read_btn", "Прочитать")): # Каждая кнопка сохраняет своё направление аппаратной операции. + button = QPushButton(caption) # Краткие подписи не добавляют лишнего текста в рабочую форму. + button.clicked.connect(lambda checked=False, writing=write: self._manual_bus_operation("sbus", writing)) # Выполнение по-прежнему использует существующий протокол SBUS. + panel[name] = button # Публичные элементы и блокировка общего макроса остаются прежними. + buttons.addWidget(button) # Оба действия расположены под адресными полями. + buttons.addStretch(1) # Свободная ширина располагается после кнопок, не раздвигая их друг от друга. + form.addLayout(buttons, 3, 0, 1, 2) # Кнопки занимают ширину только компактной формы управления. + self.sbus_cycle = QCheckBox("Циклическое чтение", group) # Повторное чтение работает одновременно с доступными ручными кнопками. + self.sbus_interval = QSpinBox(group) # Период SBUS задаётся отдельно от PBUS и макросов. + self.sbus_interval.setRange(1, 3600000) # Миллисекундный период допускает значения до одного часа. + self.sbus_interval.setValue(1000) # Начальный период повторения составляет одну секунду. + self.sbus_interval.setSuffix(" мс") # Поле явно обозначает единицу измерения времени. + self.sbus_interval.setFixedWidth(120) # Форма сохраняет компактную ширину при растяжении панели. + cycle_controls = CompactControls((self.sbus_cycle, self.sbus_interval), group) # Галочка и период используют ширину панели, а не узкую двухколоночную форму адресов. + self.sbus_timer = QTimer(self) # Таймер порождает задания общего планировщика без параллельного обмена. + self.sbus_timer.setTimerType(Qt.PreciseTimer) # Таймер не начинает повтор раньше выбранного интервала в миллисекундах. + self.sbus_timer.timeout.connect(self._sbus_tick) # Новый цикл снимает актуальный адрес и регистр из полей панели. + self.sbus_cycle.toggled.connect(self._sbus_cycle_changed) # Выключение отменяет будущие циклы и неотправленные шаги. + self.sbus_interval.valueChanged.connect(self._sbus_period_changed) # Изменение периода применяется к дальнейшему планированию. + card = QFrame(group) # Считанное слово выделяется отдельной карточкой внутри SBUS. + card.setObjectName("sbusReadCard") # Локальный стиль не изменяет остальные подписи и результаты приложения. + card.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Карточка остаётся рядом с параметрами и не превращается в широкую пустую область. + card.setMinimumWidth(260) # Ширина 260 пикселей вмещает крупное шестнадцатибитное слово и его десятичное представление. + card.setStyleSheet("QFrame#sbusReadCard { background: #0d2a29; border: 1px solid #287e6a; border-radius: 8px; } QLabel { background: transparent; border: none; color: #b6d7cf; } QLabel#sbusReadValue { color: #90aaa3; font-size: 24px; font-weight: 600; } QLabel#sbusReadValue[busValueState=ready] { color: #61f0bc; } QLabel#sbusReadValue[busValueState=error] { color: #ff8b8b; }") # Тёмный зелёный фон и яркое слово сохраняют высокий контраст в светлой и тёмной теме; размер значения — 24 пикселя. + card_layout = QVBoxLayout(card) # Краткая подпись и крупное слово образуют одну заметную область результата. + card_layout.setContentsMargins(14, 10, 14, 12) # Внутренние отступы отделяют цифры от рамки без избыточной пустоты. + card_layout.setSpacing(4) # Подпись расположена близко к считанному значению. + card_layout.addWidget(QLabel("Считано", card)) # Короткая подпись описывает последнее успешное чтение. + panel["read_value_label"] = QLabel("---", card) # До первого ответа не показываем фиктивное нулевое значение. + panel["read_value_label"].setObjectName("sbusReadValue") # Стиль выделяет только считанное слово этой шины. + panel["read_value_label"].setProperty("busValueState", "empty") # Пустой результат имеет спокойный цвет до успешного чтения. + panel["read_value_label"].setFont(QFont("Consolas")) # Моноширинные цифры упрощают сравнение HEX-слов при повторном считывании. + panel["read_value_label"].setTextInteractionFlags(Qt.TextSelectableByMouse) # Крупное значение остаётся доступным для выделения и копирования. + card_layout.addWidget(panel["read_value_label"]) # Считанное слово отображается сразу под подписью внутри контрастной карточки. + panel["read_card"] = card # Сохраняем карточку для проверки её расположения и визуальной доступности. + self.sbus_header = CompactControls((controls, card), group, spacing=14) # Форма и карточка остаются рядом в широком доке и переносятся целиком в узком. + outer.addWidget(self.sbus_header) # Параметры и карточка не делят столбцы с широким журналом. + outer.addWidget(cycle_controls) # Настройки циклического чтения располагаются одной компактной строкой сразу под параметрами и карточкой результата. + panel["result_label"] = QLabel("Нет операций") # Под карточкой сохраняем краткую диагностику текущей операции. + panel["result_label"].setWordWrap(True) # Ошибка переносится в пределах своего блока без растягивания формы. + outer.addWidget(panel["result_label"]) # Статус располагается сразу после связанных параметров и значения. + panel["output"] = QPlainTextEdit() # Журнал использует оставшуюся область SBUS и не определяет расстояния между полями. + panel["output"].setReadOnly(True) # История ответов сохраняет возможность копирования без редактирования. + panel["output"].setMaximumBlockCount(200) # Повторное выполнение команд не увеличивает память журнала без ограничений. + panel["output"].setMinimumHeight(100) # Несколько строк ответа видны и в компактном доке. + outer.addWidget(panel["output"], 1) # Свободная высота выделяется истории, а не промежуткам между параметрами. + self.bus_panels[bus] = panel # Регистрируем единственный набор элементов последовательной шины. + return group # Владелец размещает этот блок слева от таблицы PBUS. + + def _show_bus_result(self, bus, message, result=None, *, failed=False): # Единая точка отображения предотвращает смешивание ответов PBUS и SBUS. + panel = self.bus_panels[bus] # Выбираем только элементы шины, выполнившей текущую команду. + panel["result_label"].setText(message) # Последний статус или ошибка видны без открытия RAW. + if bus == "pbus": # Параллельная шина отображает все значения в единственной таблице наблюдаемых регистров. + if failed: # Аппаратная ошибка делает неподтверждённые слова таблицы недостоверными. + self.pbus_group.show_error(message) # Причина отказа остаётся в каждой строке и в правом блоке PBUS. + elif isinstance(result, dict) and "entries" in result: # Успешный цикл возвращает список источников и отдельные ответы их буфера. + self.pbus_group.show_result(result) # Обновляем HEX, DEC и статус каждой строки общей таблицы PBUS. + elif failed: # После неудачной SBUS прежнее слово не должно выглядеть новым результатом. + panel["read_value_label"].setText("---") # Убираем недостоверное слово только у затронутой последовательной шины. + panel["read_value_label"].setProperty("busValueState", "error") # Цвет карточки сообщает об отказе вместо сохранения прежнего успешного значения. + elif result is not None and "value" in result: # SBUS сохраняет одиночное считанное слово непосредственно в своём блоке. + value = result["value"] # Сохраняем декодированное 16-битное слово ответа контроллера. + panel["read_value_label"].setText(f"0x{value:04X} ({value})") # HEX помогает сверять маски, десятичное представление — числовые значения. + panel["read_value_label"].setProperty("busValueState", "ready") # Успешное чтение выделяется ярким зелёным словом на контрастном фоне. + if bus == "sbus": # Локальный стиль результата должен обновиться при переходе между пустым, успешным и ошибочным состоянием. + label = panel["read_value_label"] # Перерисовываем только считанное слово, сохраняя остальные стили приложения. + label.style().unpolish(label) # Снимаем прежнее оформление динамического свойства состояния. + label.style().polish(label) # Применяем контрастный цвет, соответствующий текущему результату. + label.update() # Обновлённый результат становится видимым сразу после ответа контроллера. + raw = result.get("raw_response", result).get("raw", b"") if isinstance(result, dict) else b"" # Поддерживаем структуру ответов чтения и подтверждений записи RawProtocol. + text = message + ("\nRX: " + raw.hex(" ").upper() if raw else "") # Пакет ответа остаётся в журнале своей шины вместе с смыслом операции. + panel["output"].appendPlainText(text) # История позволяет видеть все команды смешанного макроса по соответствующим шинам. + self.last_result = text # Сохраняем совместимость с вызывающими приложениями, запрашивающими последний результат. + self.raw_widget.append_text(text) # Общая отладочная RAW-панель продолжает получать журнал обмена. + def init_ui(self): layout = QVBoxLayout() - # Группа для операций с шиной - bus_group = QGroupBox("Операции с шиной (через регистры 0x200A/0x200B/0x200F)") - bus_layout = QGridLayout() - - bus_layout.addWidget(QLabel("Данные для шины (hex):"), 0, 0) - self.bus_data_edit = QLineEdit("0000") - self.bus_data_edit.setMaximumWidth(100) - bus_layout.addWidget(self.bus_data_edit, 0, 1) - - bus_layout.addWidget(QLabel("Адреса (0-14):"), 0, 2) - self.bus_tetrad_edit = QLineEdit("0") - self.bus_tetrad_edit.setMaximumWidth(50) - bus_layout.addWidget(self.bus_tetrad_edit, 0, 3) - - bus_layout.addWidget(QLabel("Регистр (0-15):"), 0, 4) - self.bus_register_edit = QLineEdit("0") - self.bus_register_edit.setMaximumWidth(50) - bus_layout.addWidget(self.bus_register_edit, 0, 5) - - self.bus_write_btn = QPushButton("Записать в шину") - self.bus_write_btn.clicked.connect(self.write_to_bus) - - self.bus_read_btn = QPushButton("Прочитать из шину") - self.bus_read_btn.clicked.connect(self.read_from_bus) - 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) + self.bus_panels = {} # Каждая шина владеет собственными полями, результатом и журналом операций. + buses_layout = QHBoxLayout() # Горизонтальный контейнер удерживает PBUS и SBUS рядом в верхней части окна. + self.sbus_group = self._build_bus_group("sbus", "SBUS (Сериальная шина)") # Существующая последовательная шина располагается слева согласно выбранному пользователем порядку. + self.pbus_group = PbusPanel(self) # Таблица наблюдаемых регистров PBUS становится правой частью той же панели SETGUI. + self.bus_panels["pbus"] = self.pbus_group.controls # Результаты и действия PBUS доступны общему исполнителю без второго набора макросов. + buses_layout.addWidget(self.sbus_group, 1) # Левый блок SBUS получает половину доступной ширины. + buses_layout.addWidget(self.pbus_group, 1) # Правый блок PBUS получает такую же долю ширины. + layout.addLayout(buses_layout) # Размещаем оба блока перед общей областью макросов. + for name, widget in self.bus_panels["sbus"].items(): # Прежние публичные поля относятся к существовавшей последовательной шине. + setattr(self, "bus_" + name, widget) # Имена bus_data_edit, bus_read_btn и других полей остаются совместимыми. + for name, widget in self.bus_panels["sbus"].items(): # Явные имена SBUS позволяют отличать существующую шину от добавленной PBUS. + setattr(self, "sbus_" + name, widget) # Поля SBUS никогда не ссылаются на результат PBUS. + for name, widget in self.bus_panels["pbus"].items(): # Новая параллельная шина имеет независимые доступные интеграциям элементы. + setattr(self, "pbus_" + name, widget) # Поля pbus_* не меняют старые ссылки bus_* последовательной шины. # --- Группа для макросов --- - macros_group = QGroupBox("Макросы") + macros_group = QGroupBox("Макросы") # Краткий заголовок обозначает управление макросами без пояснений внутренней организации команд. macros_layout = QVBoxLayout() # Кнопка создания нового макроса self.create_macro_btn = QPushButton("Создать новый макрос") self.create_macro_btn.clicked.connect(self.create_macro) self.stop_macro_btn = QPushButton("Остановить макрос") - self.stop_macro_btn.setEnabled(False) + self.stop_macro_btn.setEnabled(True) # Постоянная доступность остановки исключает мигание кнопки во время опроса. self.stop_macro_btn.clicked.connect(self.stop_macro) macro_actions = QHBoxLayout() macro_actions.setSpacing(6) @@ -398,56 +539,28 @@ class SerialTab(QWidget): 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): + self.macros_status = QLabel("Макросы готовы к запуску") # Общий статус сообщает запуск, завершение и остановку, не дублируя результаты шин. + self.macros_status.setWordWrap(True) # Длинная ошибка макроса переносится в пределах общей панели. + macros_layout.addWidget(self.macros_status) # Статус располагается внутри управления макросами, над их списком. + for button in (self.bus_write_btn, self.bus_read_btn, self.pbus_add_btn, self.pbus_remove_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(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 | 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) + self.macros_scroll_area = QScrollArea() # Единственная область содержит все макросы независимо от используемых шин. + self.macros_scroll_area.setAccessibleName("Макросы") # Доступное имя повторяет краткий заголовок, который видит пользователь. + self.macros_scroll_area.setWidgetResizable(True) # Общий список занимает всю доступную ширину под обоими блоками. + self.macros_scroll_area.setMinimumHeight(120) # Список остаётся доступным для прокрутки в компактном окне. + self.macros_scroll_content = QWidget() # Все кнопки макросов принадлежат одному контейнеру. + self.macros_scroll_layout = _FlowLayout(self.macros_scroll_content, 8) # Перенос выполняется по ширине, промежуток между макросами составляет восемь пикселей. + self.macros_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # При недостаточной высоте показывается вертикальная прокрутка. + self.macros_scroll_layouts = [self.macros_scroll_layout] # Сохраняем совместимый доступ к компоновкам, теперь с единственным общим списком. + self.macros_scroll_area.setWidget(self.macros_scroll_content) # Встраиваем общий контейнер в прокручиваемую область. + macros_layout.addWidget(self.macros_scroll_area, 1) # Вся оставшаяся ширина и высота доступны общей области макросов. macros_group.setLayout(macros_layout) - layout.addWidget(macros_group) + layout.addWidget(macros_group, 1) - # --- ВНЕ ГРУППЫ: Результаты чтения --- - results_layout = QGridLayout() - - # Считанное слово - results_layout.addWidget(QLabel("Считанное слово:"), 0, 0) - self.bus_read_value_label = QLabel("---") - self.bus_read_value_label.setStyleSheet("font-weight: bold; font-size: 14px; border: 1px solid #ccc; padding: 5px;") - results_layout.addWidget(self.bus_read_value_label, 0, 1) - - # Ответ контроллера - results_layout.addWidget(QLabel("Ответ контроллера:"), 1, 0) - self.bus_result_label = QLabel("---") - self.bus_result_label.setStyleSheet("border: 1px solid #ccc; padding: 5px; font-family: monospace;") - self.bus_result_label.setWordWrap(True) - 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) - layout.addWidget(results_frame) - - layout.addStretch() self.setLayout(layout) # После создания интерфейса создаем кнопки для загруженных макросов @@ -455,69 +568,61 @@ class SerialTab(QWidget): for i, macro in enumerate(self.macros): self.add_macro_button_with_index(macro, i) - def write_to_bus(self): - """Запись в шину через регистры""" - try: - device_addr = self.device_address() if callable(self.device_address) else self.device_address - data = int(self.bus_data_edit.text(), 16) - tetrad = int(self.bus_tetrad_edit.text()) - register_num = int(self.bus_register_edit.text()) + def write_to_bus(self): # Прежний публичный метод продолжает выполнять запись существующей SBUS. + self._manual_bus_operation("sbus", True) # Выбираем последовательную шину явно, независимо от последней операции PBUS. - success, result = self.serial_bus.write_bus(data, tetrad, register_num, device_addr) + def read_from_bus(self): # Прежний публичный метод сохраняет чтение последовательной шины для вызывающих вкладок. + self._manual_bus_operation("sbus", False) # Результат направляется в SBUS и не изменяет область PBUS. - if success: - self.raw_widget.append_text(f"Запись в шину: адрес={tetrad}, рег={register_num}, данные=0x{data:04X} - OK") - self.last_result = f"Запись в шину: данные=0x{data:04X}, адрес=0x{tetrad:X}, регистр=0x{register_num:X}" - self.last_result += f"\n{result}" - else: - self.raw_widget.append_text(f"Запись в шину: адрес={tetrad}, рег={register_num} - Ошибка: {result}") - self.last_result = f"Ошибка записи в шину: {result}" + def _manual_bus_operation(self, bus, write): # Фиксируем поля при нажатии и ставим логическую операцию в общую очередь. + try: # Неверный ввод отклоняется до постановки задания и не влияет на выполняющийся обмен. + command = self._sbus_command(write) # Адрес, регистр и данные сохраняются одним снимком. + self._start_commands([command], macro=False) # Ручные чтение и запись имеют приоритет перед следующим фоновым заданием. + except (KeyError, ValueError, TypeError, RuntimeError) as error: # Ошибка поля отображается только в собственной области SBUS. + self._show_bus_result(bus, f"SBUS: ошибка: {error}", failed=True) # Протокол не получает неверную команду. - except ValueError as e: - self.raw_widget.append_text(f"Ошибка в данных: {e}") - self.last_result = f"Ошибка в данных: {e}" + def _sbus_command(self, write=False): # Параметры периодического чтения берутся заново для каждого следующего запроса. + panel = self.bus_panels["sbus"] # Используем существующие поля последовательной шины. + command = {"bus": "sbus", "type": "write" if write else "read", "tetrad": int(panel["tetrad_edit"].text()), "register": int(panel["register_edit"].text())} # Адрес и регистр фиксируются независимо от последующих изменений формы. + if write: # Данные участвуют только в ручной записи SBUS. + command["data"] = int(panel["data_edit"].text(), 16) # Шестнадцатибитное значение считывается в момент нажатия. + validate_command(command) # Сохраняем прежние диапазоны и формат последовательного протокола. + return command # Возвращаем готовый неизменяемый для данного запуска снимок параметров. - def read_from_bus(self): - """Чтение из шины через регистры""" - try: - device_addr = self.device_address() if callable(self.device_address) else self.device_address - tetrad = int(self.bus_tetrad_edit.text()) - register_num = int(self.bus_register_edit.text()) + def _sbus_cycle_changed(self, checked): # Переключение управляет только циклическим источником SBUS. + if checked: # Включение запускает первый запрос сразу, остальные — по выбранному периоду. + self.sbus_timer.start(self.sbus_interval.value()) # Время между началами задано в миллисекундах. + self._sbus_tick() # Начальное чтение не требует ожидания целого периода. + else: # Выключение не прерывает уже переданный пакет UART. + self.sbus_timer.stop() # Будущие повторения не назначаются. + self._cancel_source(self._sbus_key) # Неотправленные команды задания снимаются с общей очереди. - success, result = self.serial_bus.read_bus(tetrad, register_num, device_addr) + def _sbus_period_changed(self, value): # Новый период относится только к дальнейшим повторениям SBUS. + if self.sbus_cycle.isChecked(): # Не запускаем таймер выключенного задания изменением поля. + self.sbus_timer.start(value) # Перезапускаем интервал в миллисекундах без накопления пропущенных циклов. - if success: - # result теперь словарь с данными - hex_str = ' '.join(f'{b:02X}' for b in result['raw_response']['raw']) - self.bus_result_label.setText(f"{hex_str}") + def _sbus_tick(self): # Один такт ставит не более одного чтения выбранной пары устройство/регистр. + if not self.sbus_cycle.isChecked() or self._source_pending(self._sbus_key): # Ожидающий или выполняющийся цикл исключает ещё один запуск. + return # Пропущенное повторение не добавляется в очередь задним числом. + try: # Изменённые пользователем поля применяются к следующему успешно созданному запросу. + self._start_commands([self._sbus_command()], macro=False, background=True, key=self._sbus_key) # Фоновое чтение уступает уже ожидающим ручным действиям. + except (ValueError, TypeError, KeyError) as error: # Промежуточный неверный ввод не вызывает неверного пакета UART. + self.bus_result_label.setText(f"SBUS: проверьте адрес и регистр: {error}") # Диагностика остаётся в области последовательной шины. - # Извлекаем считанное слово - read_word = result['value'] - self.bus_read_value_label.setText(f"0x{read_word:04X} ({read_word})") - - self.raw_widget.append_text(f"Чтение из шины: адрес={tetrad}, рег={register_num} = 0x{read_word:04X}") - - self.last_result = f"Чтение из шины: адрес=0x{tetrad:X}, регистр=0x{register_num:X}" - self.last_result += f"\nСчитано: 0x{read_word:04X} ({read_word})" - self.last_result += f"\nОтвет контроллера: {hex_str}" - else: - self.bus_result_label.setText(f"Ошибка: {result}") - self.bus_read_value_label.setText("---") - self.raw_widget.append_text(f"Чтение из шины: адрес={tetrad}, рег={register_num} - Ошибка: {result}") - self.last_result = f"Ошибка чтения из шины: {result}" - - except ValueError as e: - self.bus_result_label.setText(f"Ошибка: {e}") - self.bus_read_value_label.setText("---") - self.raw_widget.append_text(f"Ошибка в данных: {e}") - self.last_result = f"Ошибка в данных: {e}" - - except ValueError as e: - self.bus_result_label.setText(f"Ошибка: {e}") - self.bus_read_value_label.setText("---") - error_text = f"Ошибка в данных: {e}" - self.raw_widget.append_text(f"✗ {error_text}") - self.last_result = error_text + def _command_summary(self, command, result): # Формируем единое понятное описание ручной операции и команды макроса. + if isinstance(result, dict) and "entries" in result: # PBUS возвращает целую таблицу источников и результатов вместо одного прочитанного слова. + action = {"add": "Регистр добавлен", "remove": "Выделенные регистры удалены", "poll": "Значения обновлены", "configure": "Таблица применена", "start": "Опрос запущен"}.get(command["type"], "Таблица обновлена") # Статус описывает действие над единственным списком PBUS. + if not result["entries"]: # Успешная пустая таблица означает остановленную шину, даже при исходной команде start. + action = "таблица пуста, опрос остановлен" # Сообщение не должно объявлять запуск аппаратуры, которой отправили только остановку. + return f"PBUS: {action}; регистров {len(result['entries'])}" # Значения отдельных источников остаются в соответствующих строках таблицы. + bus, operation = command.get("bus", "sbus"), command["type"] # Старые макросы без поля bus продолжают принадлежать SBUS. + if bus == "pbus": # Остановка аппаратной таблицы возвращает подтверждение без отдельного считанного слова. + return f"PBUS: опрос остановлен; регистров {len(self.pbus_group.entries)}" # Состояние относится к текущему видимому списку, а не к прежнему ручному числу строк. + context = f"плата={command['tetrad']}, регистр={command['register']}" # Показываем источник данных в каждой записи журнала соответствующей шины. + if operation == "read": # Успешное чтение всегда включает считанное слово и его источник. + return f"{bus.upper()}: {context}, считано 0x{result['value']:04X}" # Представление HEX удобно для сопоставления с документацией регистров. + if operation == "write": # Запись последовательной шины и подготовка таблицы PBUS имеют разные аппаратные результаты. + return f"SBUS: {context}, запись 0x{command['data']:04X} подтверждена" # Данные и подтверждение последовательной записи остаются в собственной области результатов. def create_macro(self): """Создать новый макрос""" @@ -537,20 +642,18 @@ class SerialTab(QWidget): def add_macro_button_with_index(self, macro, index): """Добавить кнопку макроса в интерфейс с указанным индексом""" - items_per_row = 2 - row = len(self.macro_buttons) // items_per_row - col = len(self.macro_buttons) % items_per_row + # Создаем контейнер для кнопки и кнопки редактирования 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) + container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Элемент макроса сохраняет естественную ширину у левого края панели. # Основная кнопка макроса macro_btn = QPushButton(macro['name']) + macro_btn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Название макроса и его действия остаются рядом вместо растягивания кнопки на всю ширину окна. macro_btn.setFixedHeight(30) macro_btn.setMinimumWidth(120) macro_btn.setToolTip(macro["name"]) @@ -588,9 +691,13 @@ class SerialTab(QWidget): container_layout.addWidget(macro_btn) container_layout.addWidget(edit_btn) container_layout.addWidget(delete_btn) + cycle = QCheckBox("Циклически", container) # Каждый макрос имеет независимый переключатель повторения. + cycle.setChecked(id(macro) in self._macro_cycles) # Перестроение списка сохраняет состояние таймера по объекту макроса. + cycle.toggled.connect(lambda checked, item=macro: self._macro_cycle_changed(item, checked)) # Связь с объектом сохраняется при удалении соседних макросов. + container_layout.addWidget(cycle) # Галочка расположена рядом с кнопками выполнения, редактирования и удаления. container.setLayout(container_layout) - self.macros_scroll_layout.addWidget(container, row, col, 1, 1) # span изменен на 1 + self.macros_scroll_layout.addWidget(container) # FlowLayout размещает элемент без растягивания и пересчитывает перенос при изменении ширины. self.macro_buttons.append(container) @@ -603,7 +710,7 @@ class SerialTab(QWidget): def execute_macro_by_index(self, macro_index): """Выполнить макрос по индексу""" if 0 <= macro_index < len(self.macros): - self.execute_macro_commands(self.macros[macro_index]['commands']) + self._start_commands(self.macros[macro_index]["commands"], macro=True, source=self.macros[macro_index]) # Однократный запуск снимает параметры при нажатии независимо от режима повторения. def edit_macro_by_index(self, macro_index): """Редактировать макрос по индексу""" @@ -611,14 +718,18 @@ class SerialTab(QWidget): dialog = MacroEditorDialog(self, self.macros[macro_index]) if dialog.exec(): edited_macro = dialog.get_macro() - self.macros[macro_index] = edited_macro + original = self.macros[macro_index] # Сохраняем идентичность объекта, с которым связан таймер повторения. + original.update(edited_macro) # Уже запущенные команды используют снимок, следующие получают отредактированные значения. + self._restart_macro_timer(original) # Применяем новый период только к этому макросу. # Обновляем кнопку - находим ее по индексу if macro_index < len(self.macro_buttons): container = self.macro_buttons[macro_index] macro_btn = container.layout().itemAt(0).widget() if macro_btn: - macro_btn.setText(edited_macro['name']) + macro_btn.setText(edited_macro['name']) # Обновляем название без изменения размера остальных элементов. + container.updateGeometry() # Длина названия влияет на естественную ширину элемента. + self.macros_scroll_layout.invalidate() # Пересчитываем перенос следующих макросов по доступной ширине. self.save_macros() @@ -638,7 +749,8 @@ class SerialTab(QWidget): QMessageBox.No ) - if reply == QMessageBox.Yes: + if reply == QMessageBox.Yes: # Удаление действует только после подтверждения пользователя. + self._macro_cycle_changed(self.macros[macro_index], False) # Отменяем повторы и неотправленные команды удаляемого макроса. # Удаляем макрос из списка del self.macros[macro_index] @@ -648,80 +760,164 @@ class SerialTab(QWidget): # Сохраняем изменения self.save_macros() - def execute_macro_commands(self, commands): - """Run a macro as Qt callbacks, without loops or waiting in the UI thread.""" - if self._macro_running: - self.raw_widget.append_text("Дождитесь завершения макроса") - return - from protocan.tms_terminal import bus_steps - try: - controller = self.device_address() if callable(self.device_address) else self.device_address - jobs = [] - for command in commands: - if command['type'] not in ('write', 'read'): - raise ValueError("Неизвестная команда макроса") - jobs.extend(bus_steps(command['type'] == 'write', command['tetrad'], - command['register'], command.get('data', 0))) - except (KeyError, ValueError, RuntimeError, TypeError) as error: - self.raw_widget.append_text(f"Макрос остановлен: {error}") - return - if not jobs: - self.raw_widget.append_text("Макрос пуст") - return - self._macro_controller = controller - self._macro_jobs = jobs - self._macro_index = 0 - self._macro_running = True - self._macro_generation += 1 - self._set_macro_controls(True) - generation = self._macro_generation - QTimer.singleShot(0, lambda: self._next_macro_command(generation)) + def execute_macro_commands(self, commands): # Сохраняем публичный запуск макроса, добавляя возможность смешивать PBUS и SBUS. + self._start_commands(commands, macro=True) # Ручной PBUS и макросы используют одинаковое выполнение и защиту от поздних ответов. - def _set_macro_controls(self, running): - self.stop_macro_btn.setEnabled(running) - for widget in (self.bus_write_btn, self.bus_read_btn, self.create_macro_btn, - self.macros_scroll_area, self.raw_widget): - widget.setEnabled(not running) + def _sbus_sequence(self, command): # Адаптируем существующий двухшаговый SBUS к общему асинхронному исполнителю. + from protocan.tms_terminal import bus_steps # Планирование SBUS остаётся в прежнем общем C-ядре протокола TMS2812. + result = None # Итогом станет ответ последнего запроса чтения или подтверждения записи. + for request in bus_steps(command["type"] == "write", command["tetrad"], command["register"], command.get("data", 0)): # Формируем те же адреса и управляющие слова, что до добавления PBUS. + result = yield request # Ответ передаётся генератору только после завершения текущего запроса транспорта. + if command["type"] == "read" and (not isinstance(result, dict) or not isinstance(result.get("value"), int) or not 0 <= result["value"] <= 0xFFFF): # ReadMemory существующей SBUS должен вернуть одно шестнадцатибитное слово. + raise ValueError("SBUS: некорректный ответ чтения памяти") # Повреждённый ответ прекращает только текущую последовательность. + return result # Возвращаем финальное слово или подтверждение для области результатов SBUS. - def _next_macro_command(self, generation): - if not self._macro_running or generation != self._macro_generation: - return - if self._macro_index >= len(self._macro_jobs): - self._finish_macro("Макрос выполнен") - return - generation = self._macro_generation - address, value = self._macro_jobs[self._macro_index] - self.raw_protocol.request_async(address, value, self._macro_controller, - lambda success, result: self._macro_reply(generation, success, result)) + def _pbus_sequence(self, command): # Все пути PBUS используют полную таблицу, сохранённые параметры остановки и адреса CD результатов. + if command["type"] == "stop": # Остановка не вычисляет N-1 из новой или пустой таблицы интерфейса. + return (yield from self.pbus_group.stop_sequence(command)) # Единственная запись сохраняет подтверждённые параметры активного аппаратного опроса. + return (yield from self.pbus_group.sequence(command)) # Настройка и чтение исполняются общей очередью без чередования пакетов с другой операцией. - def _macro_reply(self, generation, success, result): - if not self._macro_running or generation != self._macro_generation: - return - if not success: - self._finish_macro(f"Макрос остановлен: {result}") - return - if 'value' in result: - value = result['value'] - self.bus_read_value_label.setText(f"0x{value:04X} ({value})") - self.bus_result_label.setText(result['raw_response']['raw'].hex(" ")) - self.raw_widget.append_text(f"Макрос: прочитано 0x{value:04X}") - else: - self.raw_widget.append_text("Макрос: запись подтверждена") - self._macro_index += 1 - QTimer.singleShot(0, lambda: self._next_macro_command(generation)) + def cancel_background(self): # Прежний публичный вызов больше не обрывает пакет фонового чтения. + return # Ручное действие получает приоритет очереди после завершения текущей логической операции. - def _finish_macro(self, message): - self._macro_running = False - self._set_macro_controls(False) - self.last_result = message - self.raw_widget.append_text(message) + def _source_pending(self, key): # Учитываем целый макрос между его отдельными логическими командами. + return any(run["key"] == key for run in self._runs) or self.queue.contains(key) # Одно задание не имеет одновременно ожидающего и повторного запуска. - def stop_macro(self): - if not self._macro_running: - return - self._macro_generation += 1 - self._finish_macro("Макрос остановлен пользователем; переданные команды могли быть выполнены") - self.raw_protocol.cancel_async() + def _sync_running(self): # Совместимые признаки состояния остаются доступны встраивающему SETGUI. + self._macro_running = bool(self._runs) # Признак показывает активные и ожидающие последовательности без блокировки интерфейса. + self._macro_is_macro = any(run["macro"] for run in self._runs) # Макросы отличаются от ручного чтения или записи своей последовательностью. + self._background = bool(self._runs) and all(run["background"] for run in self._runs) # Признак фонового режима не влияет на доступность кнопок. + + def _start_commands(self, commands, *, macro, background=False, key=None, source=None): # Параметры фиксируются до ожидания общей очереди соединения. + if key is not None and self._source_pending(key): # Периодические задания не накапливаются, пока прежний запуск не завершён. + return False # Пропущенный цикл не создаёт отложенного повторения. + try: # Проверяем весь макрос до его первой записи и сохраняем независимый снимок. + jobs = deepcopy(list(commands)) # Редактирование макроса или полей не меняет уже поставленное действие. + planned = deepcopy(self.pbus_group.entries) # Начальное состояние используется для проверки максимальной ёмкости таблицы. + for existing in self._runs: # Учитываем ещё не выполненные изменения таблицы из ранее принятых нажатий. + for command in existing["jobs"][existing["index"]:]: # Изменения применяются в ожидаемом порядке пользовательских действий. + if command.get("bus", "sbus") == "pbus": # SBUS не изменяет список строк параллельной шины. + planned = table_entries_after(planned, command) # Проверка сохраняет ограничение буфера из шестидесяти четырёх слов. + for command in jobs: # Ошибка даже последней команды не должна допускать частичного исполнения макроса. + validate_command(command) # Старые макросы без поля bus продолжают работать через SBUS. + if command.get("bus", "sbus") == "pbus": # Совместимые одиночные команды переводятся в существующие действия таблицы. + command.setdefault("_form_errors", self.pbus_group.error_limit.value()) # Фиксируем состояние поля при постановке команды, чтобы поздний результат макроса не затёр последующий ввод. + command["type"] = {"write": "add", "read": "add"}.get(command["type"], command["type"]) # Источник сохраняется без введения второго списка PBUS. + planned = table_entries_after(planned, command) # Проверяем диапазоны адресов, удаление и вместимость до первого запроса. + controller = self.device_address() if callable(self.device_address) else self.device_address # Контроллер фиксируется в момент нажатия или создания цикла. + except (KeyError, ValueError, TypeError, RuntimeError) as error: # Неверные параметры не затрагивают активную операцию соединения. + self.macros_status.setText(f"Макрос остановлен: {error}") # Указываем конкретную причину отказа валидации. + self.raw_widget.append_text(str(error)) # Сохраняем диагностический текст в прежнем журнале. + return False # Ни одного пакета данного задания не было отправлено. + if not jobs: # Пустой макрос не создаёт операции очереди и не запускает фоновых повторов. + self.macros_status.setText("Макрос пуст") # Показываем состояние в общей области макросов. + return False # Нет команд для передачи контроллеру. + run = {"jobs": jobs, "index": 0, "controller": controller, "macro": macro, "background": background, "key": key if key is not None else object(), "source": source, "cancelled": False, "started": False} # Каждый запуск сохраняет своё положение и владельца отмены. + self._runs.append(run) # Несколько принятых нажатий не заменяют друг друга. + self._sync_running() # Обновляем признаки выполнения, оставляя все элементы доступными. + if not background: # Фоновый цикл не подменяет последний итог явно запущенного макроса. + self.macros_status.setText("Макрос в очереди" if macro else "Команда в очереди") # Нажатие подтверждается даже при занятом соединении. + self._queue_command(run) # Первая логическая операция получает соответствующий приоритет. + return True # Периодический источник знает, что предыдущий запуск ещё ожидает или выполняется. + + def _queue_command(self, run): # Макрос передаёт команды по порядку, уступая порт между логическими операциями. + if run["cancelled"] or run not in self._runs: # Остановка запрещает позднее назначение следующей команды. + return # Завершённый или снятый запуск больше не использует UART. + command = run["jobs"][run["index"]] # Снимок команды принадлежит именно этому запуску. + self.queue.submit(lambda: self._begin_command(run, command), run["controller"], lambda ok, result: self._command_done(run, command, ok, result), key=run["key"], background=run["background"]) # Все пакеты выбранной команды выполняются атомарно относительно других операций. + + def _begin_command(self, run, command): # Фабрика вызывается после получения владения соединением общей очередью. + if run["key"] == self._sbus_key and not run["started"]: # Ожидающий циклический SBUS берёт актуальные поля при фактическом начале запроса. + command.update(self._sbus_command()) # Устройство и регистр следующего запроса меняются до отправки, параметры текущего генератора сохраняются. + self._macro_controller = run["controller"] # Существующая таблица PBUS получает зафиксированный контроллер текущей операции. + if not run["started"]: # Период измеряется от реального начала первого шага данного запуска. + run["started"] = True # Продолжения внутри макроса не меняют время начала повторения. + self._repeat_started(run) # Следующий таймер отсчитывается от начала обмена, а не завершения макроса. + return self._pbus_sequence(command) if command.get("bus", "sbus") == "pbus" else self._sbus_sequence(command) # Существующие алгоритмы шин сохраняют формат своих WriteMemory и ReadMemory. + + def _command_done(self, run, command, success, result): # Результат завершается только после всей логической операции регистра. + if run not in self._runs or run["cancelled"]: # Callback остановленного задания не продолжает его макрос. + return # Ответ отправленного пакета уже обработан протоколом безопасно для следующего обмена. + bus = command.get("bus", "sbus") # Ответ относится к шине снимка команды, независимо от изменённых полей. + if not success: # Таймаут, неверный ответ и отключение прекращают дальнейшие команды текущего макроса. + self._show_bus_result(bus, f"{bus.upper()}: ошибка: {result}", failed=True) # Реальная транспортная ошибка остаётся видна в своей области результатов. + self._finish_run(run, f"Макрос остановлен: {result}") # Освобождаем состояние запуска без отмены других независимых заданий. + return # После ошибки не выполняются следующие записи этого макроса. + self._show_bus_result(bus, self._command_summary(command, result), result) # Значения выводятся только в области соответствующей шины. + run["index"] += 1 # Продвигаем макрос после подтверждения всех пакетов текущей команды. + if run["index"] < len(run["jobs"]): # Следующая команда сохраняет заданный порядок внутри макроса. + self._queue_command(run) # Ручные действия смогут обслуживаться перед следующим фоновым шагом. + else: # Последняя команда завершила весь однократный или циклический запуск. + self._finish_run(run, "Макрос выполнен" if run["macro"] else "Команда выполнена") # Повторение планирует независимый таймер, не цепочка немедленных запусков. + + def _finish_run(self, run, message): # Удаляем состояние только завершённого запуска, сохраняя остальные задания. + if run in self._runs: # Повторный callback отмены не должен удалять чужой запуск. + self._runs.remove(run) # Периодический источник снова может поставить следующий цикл. + self._sync_running() # Состояние учитывает другие ожидающие ручные действия и независимые макросы. + if not run["background"]: # Циклическое чтение не меняет явный итог последнего макроса. + self.macros_status.setText(message) # Показываем результат явно запущенного действия. + self.raw_widget.append_text(message) # Прежний общий журнал сохраняет итог выполнения. + + def _cancel_source(self, key): # Остановка источника запрещает будущие команды всех его незавершённых запусков. + for run in list(self._runs): # Снимок позволяет безопасно удалить несколько запусков выбранного источника. + if run["key"] == key: # Остальные макросы и чтения шин не затрагиваются. + run["cancelled"] = True # Позднее подтверждение уже отправленного пакета не продолжит последовательность. + self._finish_run(run, "Остановлено") # Освобождаем состояние задания до получения транспортного ответа. + self.queue.cancel(key) # Очередь снимет неотправленные шаги и дождётся ответа текущего пакета. + + def _repeat_started(self, run): # Период отсчитывается между реальными началами повторов. + if run["key"] == self._sbus_key and self.sbus_cycle.isChecked(): # Старт чтения SBUS задаёт начало следующего периода только этой шины. + self.sbus_timer.start(self.sbus_interval.value()) # Таймер не зависит от продолжительности текущего обмена. + elif run["key"] == self._pbus_key and self.pbus_group.auto_update.isChecked(): # Циклический PBUS использует собственное поле периода. + self.pbus_group.timer.start(self.pbus_group.interval.value()) # Отсчёт начинается от начала чтения таблицы. + elif run["source"] is not None and run["background"]: # Циклический макрос получает независимый таймер своего владельца. + self._restart_macro_timer(run["source"]) # Пропущенные повторы не создают дополнительного запуска. + + def _restart_macro_timer(self, macro): # Период одного макроса применяется отдельно от всех других заданий. + timer = self._macro_cycles.get(id(macro)) # Таймер сохраняется по идентичности макроса при переносе и перестроении списка. + if timer is not None: # Редактирование выключенного макроса не запускает циклическое выполнение. + timer.start(int(macro.get("period_ms", 1000))) # Старые JSON-макросы получают штатный период одной секунды. + + def _macro_cycle_changed(self, macro, checked): # Галочка управляет повторениями только выбранного макроса. + identity, key = id(macro), (id(self), "macro", id(macro)) # Общая очередь получает независимый ключ источника повторов. + if checked and identity not in self._macro_cycles: # Повторное включение не создаёт второго таймера. + timer = QTimer(self) # Таймер принадлежит существующей панели и не зависит от положения дока. + timer.setTimerType(Qt.PreciseTimer) # Индивидуальный период макроса отсчитывается без преждевременного срабатывания. + timer.timeout.connect(lambda item=macro: self._macro_tick(item)) # Следующий цикл берёт актуальную сохранённую последовательность макроса. + self._macro_cycles[identity] = timer # Сохраняем таймер до выключения галочки или удаления макроса. + self._restart_macro_timer(macro) # Период берётся из индивидуальных настроек этого макроса. + self._macro_tick(macro) # Первый запуск происходит сразу после включения. + elif not checked: # Выключение немедленно запрещает будущие повторы и ещё не переданные команды. + timer = self._macro_cycles.pop(identity, None) # Удаляем только таймер выбранного макроса. + if timer is not None: # Галочка может выключаться также при удалении никогда не запускавшегося макроса. + timer.stop() # Новые периодические события больше не назначаются. + timer.deleteLater() # Освобождаем объект Qt после обработки текущих событий. + self._cancel_source(key) # Текущий пакет завершится корректно, остальные шаги задания отменяются. + + def _macro_tick(self, macro): # Периодический запуск не допускает перекрытия с незавершённым запуском того же макроса. + key = (id(self), "macro", id(macro)) # Ключ одинаков для всех повторов одного выбранного макроса. + if id(macro) not in self._macro_cycles or any(run["source"] is macro for run in self._runs) or self.queue.contains(key): # Учитываем также ещё завершающийся пакет остановленного задания. + return # Пропущенные циклы не копятся ни в таймере, ни в очереди. + self._start_commands(macro["commands"], macro=True, background=True, key=key, source=macro) # Команды исполняются в заданном порядке с фоновым приоритетом. + + def stop_macro(self): # Общая кнопка останавливает макросы, оставляя ручное чтение шин доступным. + for macro in list(self.macros): # Галочки снимаются у всех сохранённых циклических макросов. + self._macro_cycle_changed(macro, False) # Отменяем будущие повторения выбранного источника безопасно для текущего пакета. + for run in list(self._runs): # Однократные макросы также могут ожидать или выполнять очередную команду. + if run["macro"]: # Ручные действия SBUS и PBUS не являются остановленным макросом. + self._cancel_source(run["key"]) # Отменяем только ещё не переданные шаги данного запуска. + for container in self.macro_buttons: # Видимое состояние галочек отражает остановку независимых таймеров. + container.findChild(QCheckBox).setChecked(False) # Повторный обработчик выключения безопасен и не запускает обмен. + + def stop(self): # Закрытие страницы или отключение порта очищает очередь всего соединения. + self.sbus_cycle.setChecked(False) # Циклический SBUS больше не ставит запросов отключаемому порту. + self.sbus_timer.stop() # Таймер останавливается также при уже выключенной галочке. + self.pbus_group.stop_updates() # Прекращаем дальнейшие чтения аппаратного буфера PBUS. + self.stop_macro() # Макросы не продолжаются после отключения соединения. + for run in list(self._runs): # Снимаем оставшиеся ручные действия панели. + self._cancel_source(run["key"]) # Поздние ответы не продвигают отключённые задания. + self.queue.clear() # Операции памяти и остальных панелей того же соединения также отменяются. + self.raw_protocol.cancel_async() # При закрытии соединения текущий транспортный запрос завершается отказом. def load_macros(self): """Загрузить макросы из файла""" @@ -748,10 +944,11 @@ class SerialTab(QWidget): def rebuild_macros_layout(self): """Перестроить layout макросов""" # Очищаем layout - for i in reversed(range(self.macros_scroll_layout.count())): - item = self.macros_scroll_layout.itemAt(i) - if item and item.widget(): - item.widget().setParent(None) + for grid in self.macros_scroll_layouts: + while grid.count(): + item = grid.takeAt(0) + if item.widget(): + item.widget().deleteLater() # Очищаем список кнопок self.macro_buttons.clear() diff --git a/python/set_devices/background_work.py b/python/set_devices/background_work.py new file mode 100644 index 0000000..9e67465 --- /dev/null +++ b/python/set_devices/background_work.py @@ -0,0 +1,29 @@ +import time # Монотонные часы и короткое ожидание позволяют фоновому Python-коду регулярно освобождать GIL для обработчиков Qt. + + +class WorkBudget: # Длинный расчёт добровольно уступает процессор, чтобы поток интерфейса не ждал GIL после каждого вызова Qt. + def __init__(self, cancelled=None): # Необязательное threading.Event отменяет устаревшее задание без зависимости общего алгоритма от Qt. + self.cancelled = cancelled # Событие принадлежит одному заданию; его установку проверяем до и после уступки интерфейсу. + self.deadline = time.perf_counter() + .002 # Две миллисекунды непрерывной работы ограничивают конкуренцию с короткими Python-обработчиками GUI. + + def __call__(self): # Рабочие проходы вызывают контрольную точку примерно раз в тысячу элементов, без проверки часов на каждом отсчёте. + if self.cancelled is not None and self.cancelled.is_set(): # Запрос отмены должен действовать даже до истечения бюджета вычислений. + raise InterruptedError("Фоновое задание отменено") # Вызывающий worker освобождает ресурсы и не публикует частичный результат. + if time.perf_counter() < self.deadline: # Несколько быстрых контрольных точек не должны создавать лишние ожидания. + return # Продолжаем текущую небольшую порцию вычислений до исчерпания её бюджета. + if self.cancelled is None: # Неотменяемая подготовка также обязана оставлять время главному потоку. + time.sleep(.001) # Пауза в одну миллисекунду освобождает GIL; sleep(0) не гарантирует времени для цепочки обработчиков Qt. + elif self.cancelled.wait(.001): # Ожидание освобождает GIL и немедленно заканчивается при отмене вместо полной паузы. + raise InterruptedError("Фоновое задание отменено") # Не продолжаем вычисление после отмены, полученной во время уступки интерфейсу. + self.deadline = time.perf_counter() + .002 # Новый бюджет начинается после ожидания, чтобы не накапливать пропущенные интервалы. + + +def work_items(items, checkpoint): # Оборачиваем полный последовательный проход без копирования и без потери исходных элементов. + if checkpoint is None: # Обычные синхронные вызовы сохраняют прежнее поведение и не получают искусственных задержек. + yield from items # Передаём значения в неизменном порядке без счётчика контрольных точек. + return # Ветвь без фонового бюджета не выполняет проверки часов. + for index, item in enumerate(items): # Счётчик определяет небольшие порции независимо от типа исходного контейнера. + if index % 1024 == 0: # Не более 1024 элементов между проверками ограничивают задержку отмены и удержание GIL. + checkpoint() # Уступаем время интерфейсу либо прерываем устаревшее задание. + yield item # Отдаём каждый исходный элемент ровно один раз, сохраняя точность и порядок измерений. + checkpoint() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока. diff --git a/python/set_devices/balsam_can.py b/python/set_devices/balsam_can.py index 53b7574..574aeac 100644 --- a/python/set_devices/balsam_can.py +++ b/python/set_devices/balsam_can.py @@ -34,16 +34,16 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True, pm=1 if frame.from_device else 0, priority=0, ) result = pc.Decoded(pid, len(data), bytes(data), ide=ide, rtr=rtr, - protocol="BALZAM 167", device_label=frame.device_name, - message_label=("BALZAM данные" if frame.from_device + protocol="BALZAM 167", device_label=frame.sender_name, # Все CAN ID 167 подписываются источником: команды — TMS, ответы — именем устройства из таблицы адресов. + message_label=("BALZAM Heartbeat" if frame.is_heartbeat else "BALZAM данные" if frame.from_device # Тип сообщения явно выделяет heartbeat по адресу и маске, сохраняя имя отправителя. else "BALZAM команды")) result.summary = frame.summary result.fields = [ ("Протокол", "BALZAM 167 legacy eCAN"), ("Узел", "%d — %s" % (frame.device, frame.device_name)), ("Направление", frame.route), - ("Отправитель", frame.device_name if frame.from_device else "ПМ"), - ("Получатель", "ПМ" if frame.from_device else frame.device_name), + ("Отправитель", frame.sender_name), # Используем ту же подпись источника, что в маршруте и имени кадра любого устройства 167. + ("Получатель", frame.recipient_name), # Показываем адресата отдельно, сохраняя имя периферийного узла в поле «Узел». ("Начальный адрес", "0x%04X" % frame.start_address), ("Маска трёх слов", "0b%s" % format(frame.present_mask, "03b")), ] @@ -51,10 +51,10 @@ def decode(raw_id: int, data: bytes, *, ide: bool = True, for item in frame.registers: result.fields.append(( "0x%04X %s" % (item.address, item.name or "регистр"), - "0x%04X (%d)" % (item.value, item.signed_value), + "0x%04X (%d)" % (item.value, item.value if frame.is_heartbeat else item.signed_value), # Счётчик секунд и идентификаторы heartbeat беззнаковые, в отличие от технологических int16. )) - if not frame.from_device and item.address == 0x7F: - result.fields.append(("Команды ПМ", frame.command_description(item.value))) + if item.address == 0x7F: + result.fields.append(("Активные биты Commands", frame.command_description(item.value))) if frame.present_mask == 0: result.warnings.append("В адресном слове не отмечен ни один регистр") return result diff --git a/python/set_devices/bus_demo.py b/python/set_devices/bus_demo.py index 56a46e4..aff466a 100644 --- a/python/set_devices/bus_demo.py +++ b/python/set_devices/bus_demo.py @@ -13,15 +13,59 @@ from __future__ import annotations import random +import math from datetime import datetime from . import can_transport as tr from protocan import protocan as pc +from protocan.gas_167 import REGISTERS #: Типы сообщений, которые генератор выдаёт по кругу случайно. KINDS = ("pulse", "analog", "modbus", "gas", "status", "error", "discrete") +class UmpDemoSource: + """Two simulated UMP nodes using the assigned global GAS map. + + Call next_frames every 250 ms: four words per node per tick and a + heartbeat every second. Device Type=3, Device=5/6 are demo addresses, + not discovery results or a claim about firmware 167/v1 support. + """ + + def __init__(self) -> None: + self._tick = 0 + self._seq = 0 + + def _frame(self, node: int, kind: int, body: int, data: bytes) -> bytes: + self._seq = (self._seq + 1) & 0xFF + identifier = pc.ProtoCanId.build(1, 1, 3, node, kind, body) + return tr.Frame(seq=self._seq, flags=tr.FLAG_IDE, + can_id=identifier, data=data).encode() + + def next_frames(self) -> list[bytes]: + """Return encoded bridge frames; one full map takes eight seconds.""" + packets = [] + # Show LOAD state immediately, then walk the entire dictionary. + start = (124 + self._tick * 4) % len(REGISTERS) + for node, key in ((5, "ump1_id"), (6, "ump2_id")): + if self._tick % 4 == 0: + packets.append(self._frame( + node, pc.MsgType.PULSE, 0, + bytes([(self._tick // 4) & 0xFF]))) + values = [] + for entry in REGISTERS[start:start + 4]: + offset = entry["modbus"] + value = int(2000 + 700 * math.sin(self._tick / 12 + offset / 8 + node)) + command = 0x80 if (self._tick // 32) % 2 == 0 else 0x40 + value = {82: 1000, 124: 8, 125: 0, 126: node, 127: command}.get(offset, value) + values.append(value.to_bytes(2, "little")) + packets.append(self._frame( + node, pc.MsgType.GENERAL_ADDRESS_SPACE, + REGISTERS[start][key], b"".join(values))) + self._tick += 1 + return packets + + class BusDemoSource: """@brief Источник демонстрационных кадров шины. @@ -58,11 +102,15 @@ class BusDemoSource: data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little") for _ in range(cnt)) elif kind == "gas": - adr = self._rng.randint(0, 0x400) + # Exercise the shared named map in the ordinary demo too. + dev_type, dev_id = 3, self._rng.choice((5, 6)) + key = "ump1_id" if dev_id == 5 else "ump2_id" + count = self._rng.randint(1, 4) + adr = REGISTERS[self._rng.randrange(len(REGISTERS) - count + 1)][key] cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.GENERAL_ADDRESS_SPACE, adr) data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little") - for _ in range(self._rng.randint(1, 4))) + for _ in range(count)) elif kind == "status": now = datetime.now() cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.BROADCAST, diff --git a/python/set_devices/can485_board.py b/python/set_devices/can485_board.py index 27f829e..53ad30c 100644 --- a/python/set_devices/can485_board.py +++ b/python/set_devices/can485_board.py @@ -32,6 +32,8 @@ from collections import deque from dataclasses import dataclass, field from itertools import chain from typing import Iterable +from protocan import protocan as pc +from protocan.gas_167 import REGISTERS #: Распространённые скорости UART для подключения платы и RS485-приёмника. UART_BAUDS = ( @@ -596,7 +598,14 @@ class BoardDemoSource: """ #: Идентификаторы, по которым генератор идёт по кругу. - IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF)) + IDS = ( + (True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF), + # Assigned UMP map, simulated to demonstrate the common GAS decoder. + (True, pc.ProtoCanId.build(1, 1, 3, 5, pc.MsgType.GENERAL_ADDRESS_SPACE, + REGISTERS[124]['ump1_id'])), + (True, pc.ProtoCanId.build(1, 1, 3, 6, pc.MsgType.GENERAL_ADDRESS_SPACE, + REGISTERS[124]['ump2_id'])), + ) def __init__(self, rng: random.Random | None = None) -> None: self._rng = rng or random.Random() diff --git a/python/set_devices/can_adapter_settings.py b/python/set_devices/can_adapter_settings.py new file mode 100644 index 0000000..2e484e8 --- /dev/null +++ b/python/set_devices/can_adapter_settings.py @@ -0,0 +1,147 @@ +"""Проверяемые настройки физических CAN-адаптеров без зависимости от Qt.""" # Общая модель используется транспортами и редактором настроек. + +from __future__ import annotations # Отложенная оценка объединений типов сохраняет импорт в сборке Python 3.8 для Windows 7. +from dataclasses import dataclass # Неизменяемые значения исключают смену режима во время работающего сеанса. + +FD_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) # Таблица DLC стандарта CAN FD переводит четырёхбитный код в число байтов. + + +@dataclass(frozen=True) # Полный тайминг передаётся в драйвер одной согласованной структурой. +class CanTiming: # Сегменты измеряются в квантах времени CAN, BRP задаёт делитель тактовой частоты. + prop_seg: int = 1 # Квант компенсации задержки распространения входит в TSEG1. + phase_seg1: int = 12 # Вместе с PROP_SEG даёт TSEG1=13 для обычного тайминга 48 МГц. + phase_seg2: int = 2 # Два кванта после точки выборки дают суммарно 16 квантов на бит. + sjw: int = 1 # Один квант ограничивает коррекцию при ресинхронизации. + brp: int = 6 # 48 МГц / 6 / 16 соответствует скорости 500 кбит/с. + + def validate(self, cap, bitrate: int) -> None: # Проверяем ограничения, реально объявленные выбранным каналом. + if cap.fclk_can <= 0 or cap.brp_min < 1 or self.brp < 1: # Нулевая частота или делитель означают повреждённый ответ либо ошибочный ручной ввод. + raise ValueError("Некорректная частота CAN или нулевой BRP") # Не допускаем деления на ноль при диагностике скорости. + tseg1 = self.prop_seg + self.phase_seg1 # Контроллер ограничивает суммарный сегмент перед выборкой. + if self.prop_seg < 0 or self.phase_seg1 < 1: # Нулевая задержка допустима, но фазовый сегмент обязан существовать. + raise ValueError("PROP_SEG ≥ 0, PHASE_SEG1 ≥ 1") # Не отправляем в USB недопустимые сегменты. + if not cap.tseg1_min <= tseg1 <= cap.tseg1_max or not cap.tseg2_min <= self.phase_seg2 <= cap.tseg2_max: # Пределы различаются для номинальной и быстрой фазы. + raise ValueError("Сегменты тайминга вне диапазона адаптера") # Сообщаем об аппаратном ограничении до запуска канала. + if not 1 <= self.sjw <= min(cap.sjw_max, self.phase_seg2): # SJW не должен превышать второй фазовый сегмент. + raise ValueError("SJW превышает предел адаптера или PHASE_SEG2") # Исключаем некорректную ресинхронизацию. + if not cap.brp_min <= self.brp <= cap.brp_max or (self.brp - cap.brp_min) % max(1, cap.brp_inc): # Делитель учитывает шаг аппаратного регистра. + raise ValueError("BRP вне диапазона или шага адаптера") # Не округляем вручную заданный делитель. + actual = cap.fclk_can / (self.brp * (1 + tseg1 + self.phase_seg2)) # Единицы результата — бит/с; дополнительный квант относится к SYNC_SEG. + if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Допуск 0,01 % позволяет записать 83 333 вместо периодической дроби. + raise ValueError(f"Тайминг задаёт {actual:g} бит/с вместо {bitrate}") # Статус и запись захвата не должны сообщать ложную скорость. + + +def calculate_timing(cap, bitrate: int, sample_point: float = 87.5) -> CanTiming: # Подбираем тайминг по частоте и пределам канала вместо фиксированной таблицы 48 МГц. + if cap.fclk_can <= 0 or not 1 <= cap.brp_min <= cap.brp_max <= 65536 or cap.tseg1_min < 1 or cap.tseg2_min < 1: # Ограничиваем также объём поиска при повреждённом ответе USB. + raise ValueError("Адаптер вернул некорректные пределы тайминга") # Не выдаём мусорные аппаратные поля за рабочую конфигурацию. + if bitrate <= 0 or not 50 <= sample_point <= 95: # Процент выборки ограничен практически полезным диапазоном. + raise ValueError("Скорость должна быть положительной, точка выборки — 50…95 %") # Не запускаем поиск с бессмысленными параметрами. + best = None # Сохраняем наиболее точное приближение скорости и точки выборки. + for brp in range(cap.brp_min, cap.brp_max + 1, max(1, cap.brp_inc)): # Рассматриваем только допустимые делители аппаратной частоты. + total = round(cap.fclk_can / (brp * bitrate)) # Число квантов в бите включает синхронизацию и оба сегмента. + if total < 3: # Минимум нужен для SYNC_SEG и двух ненулевых фазовых сегментов. + continue # Этот делитель не позволяет получить корректную структуру бита. + lower, upper = max(cap.tseg2_min, total - 1 - cap.tseg1_max), min(cap.tseg2_max, total - 1 - cap.tseg1_min) # Допустимый TSEG2 одновременно ограничивается обоими аппаратными сегментами. + if lower > upper: # Выбранное число квантов не раскладывается на допустимые TSEG1 и TSEG2. + continue # Другой делитель может дать ту же точную скорость при корректных сегментах. + tseg2 = min(upper, max(lower, round(total * (1 - sample_point / 100)))) # Выбираем ближайшую достижимую точку внутри пересечения аппаратных ограничений. + tseg1 = total - 1 - tseg2 # Оставшийся бюджет квантов принадлежит сегменту перед выборкой. + if not cap.tseg1_min <= tseg1 <= cap.tseg1_max: # Проверяем аппаратные пределы до вычисления оценки. + continue # Не пытаемся отправлять неподдерживаемую комбинацию сегментов. + error = abs(cap.fclk_can / (brp * total) - bitrate) / bitrate # Относительная ошибка скорости важнее ошибки точки выборки. + score = (error, abs((1 + tseg1) / total * 100 - sample_point), -total) # При равенстве предпочитаем больше квантов для точной синхронизации. + if best is None or score < best[0]: # Сравнение кортежей сохраняет установленный приоритет критериев. + best = (score, CanTiming(0, tseg1, tseg2, 1, brp)) # PROP_SEG=0 допустим в gs_usb: драйвер использует суммарный TSEG1. + if best is None or best[0][0] > 0.0001: # Не разрешаем скрытое округление к другой скорости шины. + raise ValueError(f"Адаптер не может точно установить {bitrate} бит/с") # Пользователь должен выбрать достижимую скорость. + best[1].validate(cap, bitrate) # Одинаково проверяем автоматический и ручной путь установки. + return best[1] # Возвращаем параметры, готовые для управляющего USB-запроса. + + +@dataclass(frozen=True) # Настройки SLCAN фиксируются до отправки первой команды конфигурации. +class SlcanOptions: # Различия команд выбираются явным профилем прошивки. + profile: str = "lawicel" # Lawicel использует подтверждения CR/BEL и команду L для пассивного режима. + loopback: bool = False # Команда l доступна только у прошивок, реализующих это расширение Lawicel. + retransmit: bool | None = None # None сохраняет настройку прошивки; A0/A1 применяются только для CANable. + timestamps: bool | None = None # None не записывает EEPROM Lawicel и оставляет текущее состояние Z. + acceptance_code: int | None = None # Регистр M задаётся вместе с маской и только у Lawicel. + acceptance_mask: int | None = None # Биты маски имеют семантику прошивки Lawicel, а не экранного фильтра ID. + btr: str = "" # Четыре HEX-цифры BTR0/BTR1 включают нестандартную скорость через команду s. + command_timeout_ms: int = 500 # Полсекунды ограничивает ожидание подтверждения отдельной команды. + fd: bool = False # Расширенные команды d/D/b/B поддерживает профиль CANable 2.0. + data_bitrate: int = 2_000_000 # В документированной CANable 2.0 доступны быстрая фаза 2 или 5 Мбит/с. + + def commands(self, bitrate: int, listen_only: bool) -> list[str]: # Формируем весь план до открытия COM, чтобы ошибка не меняла аппаратное состояние. + if self.profile not in ("lawicel", "canable", "canable2") or not 10 <= self.command_timeout_ms <= 10000: # Ограничиваем профиль и тайм-аут 10 мс…10 с. + raise ValueError("Неверный профиль SLCAN или тайм-аут команды") # Произвольные команды через настройки не исполняются. + if self.loopback and (listen_only or self.profile != "lawicel"): # Для SLCAN нужны отдельные команды открытия l и L. + raise ValueError("Loopback требует профиль Lawicel и отключённый режим только приёма") # Не подменяем молча выбранный пользователем режим. + rates = {10000: "S0", 20000: "S1", 50000: "S2", 100000: "S3", 125000: "S4", 250000: "S5", 500000: "S6", 1000000: "S8"} # Общая часть стандартных скоростей задана спецификацией ASCII-команд. + rates[800000 if self.profile == "lawicel" else 750000] = "S7" # Одинаковая команда означает разные скорости в двух семействах прошивок. + if self.profile == "canable2": # Дополнительная скорость описана только для CANable 2.0. + rates[83333] = "S9" # Целое значение соответствует округлённым 83,333 кбит/с. + if self.btr: # Ручной тайминг имеет приоритет над выбором стандартного Sn. + if self.profile != "lawicel" or len(self.btr) != 4 or any(c not in "0123456789abcdefABCDEF" for c in self.btr): # Разрешаем ровно два байта регистров SJA1000. + raise ValueError("BTR0/BTR1: четыре HEX-цифры, только профиль Lawicel") # Исключаем внедрение дополнительных строк в команду. + speed = "s" + self.btr.upper() # Прошивка сама интерпретирует регистры тайминга своего контроллера. + btr0, btr1 = int(self.btr[:2], 16), int(self.btr[2:], 16) # В классическом Lawicel SJA1000 работает от генератора 16 МГц. + actual = 16_000_000 / (2 * ((btr0 & 63) + 1) * (3 + (btr1 & 15) + ((btr1 >> 4) & 7))) # SJA1000 делит частоту на 2·BRP и число квантов SYNC+TSEG1+TSEG2. + if abs(actual - bitrate) > max(1, bitrate * 0.0001): # Сохраняем честную скорость в статусе и захвате даже при ручном BTR. + raise ValueError(f"BTR задаёт {actual:g} бит/с при 16 МГц; выберите эту скорость подключения") # Не маскируем рассогласование ручных регистров и выбранного битрейта. + else: # Обычный путь требует наличия точной скорости в выбранном профиле. + speed = rates.get(bitrate) # Не используем S7 от другого семейства адаптеров. + if speed is None: # Произвольная скорость без BTR не имеет команды в этом профиле. + raise ValueError(f"Профиль {self.profile} не поддерживает {bitrate} бит/с") # Сообщаем профиль вместе с отклонённой скоростью. + result = [speed] # Настройка начинается после закрытия прежнего канала командой C. + if self.profile != "lawicel": # CANable выбирает режим до открытия O и не использует L. + result.append("M1" if listen_only else "M0") # Явно сбрасываем сохранённый silent при активном подключении. + if self.retransmit is not None: # Сохраняем поведение прошивки, пока пользователь не выбрал A0/A1. + result.append("A1" if self.retransmit else "A0") # Отключение повторов даёт однократную попытку передачи. + elif self.retransmit is not None: # Буква A у Lawicel имеет другую семантику и не должна отправляться как CANable. + raise ValueError("Управление повторами A0/A1 доступно только в профиле CANable") # Предотвращаем смешивание диалектов. + if self.timestamps is not None: # Команда Z может сохраняться прошивкой в EEPROM. + if self.profile != "lawicel": # CANable не документирует поддержку этого управления. + raise ValueError("Временные метки Z0/Z1 доступны только в профиле Lawicel") # Не игнорируем пользовательскую настройку. + result.append("Z1" if self.timestamps else "Z0") # Состояние задаётся только при явном выборе. + if self.acceptance_code is not None or self.acceptance_mask is not None: # Фильтр задаётся атомарной парой регистров. + if self.profile != "lawicel" or any(v is None or not 0 <= v <= 0xFFFFFFFF for v in (self.acceptance_code, self.acceptance_mask)): # Каждый регистр содержит ровно 32 бита. + raise ValueError("Фильтр Lawicel требует код и маску в диапазоне 00000000…FFFFFFFF") # M0/M1 CANable не должны трактоваться как фильтры. + result.extend((f"M{self.acceptance_code:08X}", f"m{self.acceptance_mask:08X}")) # Порядок байтов регистра соответствует документации Lawicel. + if self.fd: # Для FD требуется документированное расширение CANable 2.0. + if self.profile != "canable2" or self.data_bitrate not in (2_000_000, 5_000_000): # Y2/Y5 — единственные поддержанные этой прошивкой скорости данных. + raise ValueError("CAN FD требует CANable 2.0 и скорость данных 2 либо 5 Мбит/с") # Не открываем несовместимый канал. + result.append("Y2" if self.data_bitrate == 2_000_000 else "Y5") # Настраиваем быструю фазу до открытия CAN. + result.append("l" if self.loopback else "L" if listen_only and self.profile == "lawicel" else "O") # Выбираем одну команду открытия без опасного возврата к активному режиму. + return result # Последовательный транспорт проверит ответ на каждый этап там, где прошивка выдаёт подтверждения. + + +@dataclass(frozen=True) # Флаги запуска Candle хранятся вместе с таймингами и настройкой терминатора. +class CandleOptions: # Все расширения проверяются по маске возможностей конкретного канала. + loopback: bool = False # Приём собственных кадров нужен для диагностики контроллера. + one_shot: bool = False # Одна попытка передачи вместо автоматических повторов при отсутствии ACK. + triple_sample: bool = False # Тройная выборка применяется только при аппаратной поддержке. + hardware_timestamp: bool | None = None # None сохраняет автоматическое включение аппаратных меток. + termination: bool | None = None # None сохраняет состояние встроенного терминатора, не меняя топологию сети. + sample_point: float = 87.5 # Целевой процент номинального бита для автоматического расчёта. + timing: CanTiming | None = None # Ручные сегменты заменяют автоматический расчёт номинального тайминга. + fd: bool = False # Разрешает FD только после проверки возможности и настройки быстрой фазы. + data_bitrate: int = 2_000_000 # Скорость быстрой фазы задаётся в бит/с. + data_sample_point: float = 80.0 # Для быстрой фазы обычно нужна более ранняя точка выборки. + data_timing: CanTiming | None = None # Ручной тайминг быстрой фазы проверяется по расширенным пределам. + bus_errors: bool = True # Сохраняем сообщения об ошибках на шине при наличии управляемого режима. + + def flags(self, listen_only: bool, features: int) -> int: # Маска gs_usb объединяет независимо выбираемые режимы. + flags = (1 if listen_only else 0) | (2 if self.loopback else 0) | (4 if self.triple_sample else 0) | (8 if self.one_shot else 0) | (256 if self.fd else 0) # Значения битов заданы протоколом gs_usb. + if self.hardware_timestamp is True: # Явный выбор требует возможности аппаратных временных меток. + flags |= 16 # Бит 4 означает HW_TIMESTAMP. + elif self.hardware_timestamp is None: # Автоматический режим включает метки только при поддержке устройства. + flags |= features & 16 # Отсутствие возможности не является ошибкой автоматической настройки. + if self.bus_errors: # Старые прошивки выдают ошибки всегда и не требуют этого флага. + flags |= features & 4096 # Управляемый BERR_REPORTING включается лишь при объявленном бите 12. + elif not features & 4096: # У старых прошивок нет подтверждаемого выключателя сообщений об ошибках. + raise ValueError("Прошивка не поддерживает отключение сообщений об ошибках CAN") # Не показываем фактически невыполнимую настройку как применённую. + if flags & ~features: # Ни один явный запрос не должен тихо исчезнуть из маски запуска. + raise ValueError(f"Канал не поддерживает выбранные режимы: 0x{flags & ~features:X}") # Маска помогает сопоставить отказ с диагностикой адаптера. + if self.termination is not None and not features & 2048: # Встроенный переключаемый терминатор объявляется битом 11. + raise ValueError("Адаптер не поддерживает программное управление терминатором") # Внешний резистор программой не переключается. + return flags # Готовая маска передаётся в CANDLE_DEVMODE_START. diff --git a/python/set_devices/can_bridge.py b/python/set_devices/can_bridge.py index 2ac0add..a6d9be9 100644 --- a/python/set_devices/can_bridge.py +++ b/python/set_devices/can_bridge.py @@ -16,6 +16,7 @@ from __future__ import annotations from . import can_transport as tr from protocan import protocan as pc +from protocan.gas_167 import assigned_metadata from . import balsam_can #: Расшифровка поля LEC регистра CAN_ESR. @@ -64,8 +65,27 @@ def decode_frame(frame: tr.Frame) -> pc.Decoded: decoded_balsam = balsam_can.decode( frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) if decoded_balsam is not None: + decoded_balsam.fields.append(("Автоопределение", "Balsam 167: идентификатор legacy CAN")) return decoded_balsam - return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) + result = pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) + if (frame.ide and not frame.rtr + and result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE + and len(frame.data) <= 8 and len(frame.data) % 2 == 0 + and result.registers is not None): + addresses = [address for address, _value in result.registers] or [result.id.body] + entries = [assigned_metadata(address) for address in addresses] + if all(entries) and len({entry['node'] for entry in entries}) == 1: + node = entries[0]['node'] - 4 + result.protocol = "УМП / ProtoCAN GAS (назначенные ID)" + result.device_label = "УМП %d" % node + result.message_label = "GAS · УМП %d" % node + result.fields.append(("Автоопределение", + "УМП %d: все GAS ID входят в назначенную карту; поддержка прошивкой не подтверждена" % node)) + return result + result.fields.append(("Автоопределение", "Разбор ProtoCAN; профиль прибора не определён")) + if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: + result.summary += " · профиль/узел не определены — выберите протокол вручную" + return result dec = pc.Decoded(id=pc.ProtoCanId.parse(frame.can_id), dlc=len(frame.data), data=frame.data, ide=frame.ide, rtr=frame.rtr) diff --git a/python/set_devices/can_log_trends.py b/python/set_devices/can_log_trends.py index 6ba5cbc..27d752d 100644 --- a/python/set_devices/can_log_trends.py +++ b/python/set_devices/can_log_trends.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from protocan.legacycan import PROJECTS, LegacyCanFormat, decode from .can485_board import BoardFrame, SdLogImport, parse_sd_log +from .trend_limits import MAX_TOTAL_POINTS @dataclass @@ -63,7 +64,7 @@ def _values(frame: BoardFrame): def read_can_log(lines, *, reference_timestamp: float, capacity: int, - max_total_points: int = 1_000_000, + max_total_points: int = MAX_TOTAL_POINTS, frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]: imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit) channels = {} diff --git a/python/set_devices/can_monitor.py b/python/set_devices/can_monitor.py index c76cda1..6e26f32 100644 --- a/python/set_devices/can_monitor.py +++ b/python/set_devices/can_monitor.py @@ -8,16 +8,43 @@ from pathlib import Path from protocan import protocan as pc from .can_bridge import decode_frame -PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"), - ("devboard", "DevBoard_V1 / SETCAN"), - ("deif", "DEIF XDi-192 / CANopen")) +PROTOCOLS = (("auto", "Авто (ProtoCAN / Balsam)"), # Автоматический разбор выбирает формат по кадру, не требуя ручного указания прибора. + ("devboard", "DevBoard_V1 / SETCAN"), # Формат DevBoard сохраняет самостоятельный пункт для существующих мониторов платы. + ("deif", "DEIF XDi-192 / CANopen"), # Прибор DEIF использует собственные правила CANopen и остаётся отдельным форматом. + ("protocan", "ProtoCAN / GAS"), # Общий ProtoCAN разбирает кадры без ручного назначения карты технологических слов УМП. + ("ump_balsam", "УМП / Balsam 167 (legacy CAN)"), # Старый Balsam имеет другой формат кадра и требует отдельного декодера. + ("ump_protocan", "УМП / ProtoCAN GAS")) # Единый формат УМП отделяет выбор карты адресов и номера устройства от протокола. + +UMP_MAPS = (("gas167", "167/v1 (текущая)"), # Действующая карта использует слова 0…127 и окно 0x5000…0x507F из профиля прошивки 167/v1. + ("assigned", "Новые ID 5000/6000 (планируется)")) # Десятичные базы 5000 и 6000 назначены документацией, но ещё не реализованы прошивкой. -def decode_protocol(frame, protocol="auto"): +def decode_protocol(frame, protocol="auto", *, ump_map="gas167", ump_node=1): # Карта 167/v1 и УМП 1 служат исходным выбором единого формата; параметры карты и узла передаются по имени. if protocol == "deif": from .deif_xdi import decode return decode(frame) - if protocol == "devboard": + if protocol == "ump_balsam": + from .balsam_can import decode + result = decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) + if result is not None: + return result + result = pc.Decoded(pc.ProtoCanId.parse(frame.can_id), len(frame.data), + bytes(frame.data), ide=frame.ide, rtr=frame.rtr, + protocol="BALZAM 167", message_label="Не Balsam 167") + result.summary = "УМП / Balsam 167: CAN ID или формат кадра не соответствует протоколу" + result.warnings.append(result.summary) + return result + if protocol in ("ump1_gas167", "ump2_gas167"): + from .ump_can import decode_gas167 + return decode_gas167(frame, node=1 if protocol == "ump1_gas167" else 2) + if protocol == "ump_protocan": # Новые элементы интерфейса используют единый формат, а старые ключи выше сохраняют прежний разбор. + if ump_map == "gas167": # Действующие локальные адреса сами не определяют номер УМП, поэтому он задаётся отдельно. + from .ump_can import decode_gas167 # Словарь 167/v1 подключается только при явном выборе этой карты. + return decode_gas167(frame, node=ump_node) # Один декодер разбирает технологические слова обоих УМП с выбранной подписью устройства. + if ump_map == "assigned": # Планируемая сквозная карта определяет УМП по десятичным диапазонам 5000…5127 и 6000…6127. + return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) # Общий ProtoCAN уже содержит имена назначенных ID и не требует ручного номера УМП. + raise ValueError("Неизвестная карта адресов УМП: %s" % ump_map) # Ошибочный ключ карты не должен молча давать расшифровку другого профиля. + if protocol in ("devboard", "protocan", "ump_gas"): return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) return decode_frame(frame) @@ -44,6 +71,36 @@ def parse_filter(text): raise ValueError("Введите CAN ID в hex, например 701 или 0x701, 201") from None +def parse_gas_filter(text): # GAS ID вводятся десятичными числами либо в HEX с явным префиксом 0x. + if not text.strip(): # Пустое поле не добавляет ограничений к независимому фильтру CAN ID. + return None # Значение None обозначает отсутствие условия, а не пустой набор разрешённых адресов. + try: # Ошибки ввода превращаются в понятное сообщение общей панели монитора. + parts = [part.strip() for part in text.replace(";", ",").split(",")] # Поддерживаем те же разделители списка, что и у существующего CAN-фильтра. + addresses = {int(part, 16 if part.lower().startswith("0x") else 10) for part in parts} # Число 5000 означает назначенный десятичный GAS ID, а 0x5000 — адрес локального окна. + if any(address < 0 or address > 0xFFFF for address in addresses): # Поле Body ProtoCAN содержит 16-битный адрес слова GAS в диапазоне 0…65535. + raise ValueError() # Выход за пределы адресного пространства отклоняется вместе с остальными ошибками формата. + return addresses # Повторяющиеся адреса объединяются без изменения логики отбора кадров. + except ValueError: # Неполные, отрицательные и некорректно записанные значения не должны проходить фильтр молча. + raise ValueError("Введите GAS ID: 5000, 5127 или 0x5000; диапазон 0…65535") from None # Сообщаем систему счисления и допустимые границы непосредственно рядом с фильтром. + + +def matches_filters(identifier, data, extended, rtr=False, *, can_ids=None, gas_ids=None): # Оба условия объединяются по И, а значения внутри одного списка — по ИЛИ. + if can_ids is not None and identifier not in can_ids: # Быстрый отбор по CAN ID выполняется до декодирования полезной нагрузки. + return False # Несовпадение любого включённого ограничения исключает кадр. + if gas_ids is None: # При выключенном или пустом GAS-фильтре сохраняется прежний отбор CAN-кадров всех форматов. + return True # CAN ID уже проверен либо не ограничен. + if not extended or rtr or len(data) > 8 or len(data) % 2: # GAS использует расширенный кадр данных с полными 16-битными словами и DLC не больше 8 байт. + return False # STD, RTR и повреждённая нагрузка не обозначают достоверные GAS-регистры. + if pc.ProtoCanId.parse(identifier).msg_type != pc.MsgType.GENERAL_ADDRESS_SPACE: # Совпадение младших битов ID другого типа сообщения не является совпадением адреса GAS. + return False # Остальные протоколы остаются видимыми только при выключенном GAS-условии. + decoded = pc.decode(identifier, data, ide=extended, rtr=rtr) # Общий декодер определяет все регистры кадра и отличает их от текстового GAS-отклика. + if not data: # Пустой кадр данных GAS представляет запрос по начальному адресу Body. + return decoded.id.body in gas_ids # Запрос учитывается только для указанного в нём адреса. + if not decoded.registers or any(address > 0xFFFF for address, _value in decoded.registers): # Отклоняем служебный текст и пакет, выходящий за конец 16-битного пространства. + return False # Такие кадры не содержат допустимого набора регистров для адресного фильтра. + return any(address in gas_ids for address, _value in decoded.registers) # Совпадение второго, третьего или четвёртого слова выбирает весь исходный кадр. + + class CanSession: """All frames spool to disk; retain recent frames and unique-frame totals. @@ -74,6 +131,7 @@ class CanSession: self.unique_frames[key] = (row, previous[1] + 1 if previous else 1) if self.output is not None: csv.writer(self.output, delimiter=";").writerow(row) + return key # Возвращаем ключ уникального кадра, чтобы интерфейс обновлял только поступившие записи без повторного обхода сессии. def start(self, directory): directory = Path(directory) / datetime.now().strftime("session_%Y%m%d_%H%M%S_%f") diff --git a/python/set_devices/firmware_identity.py b/python/set_devices/firmware_identity.py index 453c21e..e8ff861 100644 --- a/python/set_devices/firmware_identity.py +++ b/python/set_devices/firmware_identity.py @@ -57,6 +57,29 @@ def decode_identity(words): raise ValueError("Некорректные данные версии: " + str(error)) from error +def validate_image_device(meta, expected_product, expected_target="TMS320F28335"): # Сопоставляет назначение в паспорте с настройкой загрузчика без обращения к подключённой плате. + product, target = meta.get('product', 'не указана'), meta.get('target', 'не указан') # Отсутствующее назначение тоже даёт понятный отказ, включая Flash RS, где KeyError не является пользовательской ошибкой. + if product == expected_product and target == expected_target: # Совпадение обоих полей сохраняет прежнее разрешение на дальнейшие проверки BIN. + return # Не заменяет проверку размера, SHA-256 и формата, которую вызывающий код выполняет отдельно. + models = {'Balsam-166-PM35': 'Бальзам-166 ПМ35', 'Balsam-167-PM35': 'Бальзам-167 ПМ35'} # Человекочитаемые названия соответствуют двум моделям из списка «Модель платы». + selected = models.get(str(expected_product), str(expected_product)) # Для других устройств сохраняет исходное обозначение, не выдавая неизвестную модель за 166 или 167. + actual = models.get(str(product), str(product)) # Показывает назначение из паспорта; ошибочный тип поля тоже не мешает вывести причину отказа. + differences = [] # Собирает только действительно различающиеся признаки, чтобы общий процессор не выглядел причиной отказа. + if product != expected_product: # Различие моделей проверяется независимо от процессора, который может совпадать у разных плат. + differences.append('модели платы') # Указывает на расхождение обозначений 166/167 даже при одинаковом TMS320F28335. + if target != expected_target: # Отдельно выявляет несовместимый процессор без ошибочного обвинения модели платы. + differences.append('процессора') # Дополняет причину вторым признаком, только если его значение отличается. + raise ValueError( # Блокирует загрузку с подробным объяснением; метаданные не доказывают аппаратное назначение содержимого BIN. + 'Назначение прошивки не совпадает с выбранным устройством. Загрузка заблокирована.\n' # Сразу сообщает результат проверки и его влияние на загрузку. + f'Выбрано в программе: {selected} ({expected_product}); процессор {expected_target}.\n' # Связывает ожидаемые значения с пользовательской настройкой, а не с результатом опроса платы. + f'В паспорте файла: {actual} ({product}); процессор {target}.\n' # Показывает фактические значения полей паспорта рядом с ожидаемыми. + f'Причина: несовпадение {" и ".join(differences)}. Модель подключённой платы этой проверкой не определялась.\n' # Разделяет установленное расхождение метаданных и неизвестное состояние оборудования. + f'Для платы {selected} выберите BIN с паспортом для этой модели и процессора {expected_target}. ' # Предлагает комплект для нужной платы, сохраняя выбранную пользователем модель. + f'Если эта прошивка собрана для {selected}, проверьте назначение в настройках сборки и заново сформируйте выпуск с паспортом. ' # Объясняет исправление ошибочной маркировки сборки без подмены значений готового паспорта. + 'Поле «Модель платы» меняйте, только если в программе выбрана не ваша плата.' # Не предлагает выбирать 167 ради обхода сообщения при реальной плате 166. + ) # Завершает единое сообщение, одинаковое для EEPROM BIN и Flash RS BIN. + + 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): @@ -82,8 +105,7 @@ def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None 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})") + validate_image_device(meta, 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 не совпадает") diff --git a/python/set_devices/generator_data.py b/python/set_devices/generator_data.py index c18f667..9b02d96 100644 --- a/python/set_devices/generator_data.py +++ b/python/set_devices/generator_data.py @@ -8,6 +8,7 @@ import io import math import json from datetime import datetime +from functools import lru_cache # Таблица достижимых частот строится один раз для каждого аппаратного таймера MCU. from set_devices.waveform import Waveform, generate as interpolate, load_recipe as load_interpolated_recipe from set_devices.signal_reconstruction import dac12 @@ -15,13 +16,14 @@ from set_devices.signal_reconstruction import dac12 # TIM6 clock of the supported STM32F407 emulator firmware. The device protocol # does not currently expose its timer clock; keep this hardware profile explicit. F407_TIMER_HZ = 72_000_000 -F407_SAMPLE_RATES = tuple(sorted({ - rate - for divisor in range(1, math.isqrt(F407_TIMER_HZ) + 1) - if F407_TIMER_HZ % divisor == 0 - for rate in (divisor, F407_TIMER_HZ // divisor) - if 1 <= rate <= 1_000_000 -})) +@lru_cache(maxsize=16) # Небольшой кэш хранит аппаратные профили без повторного перебора делителей при построении сигнала. +def timer_sample_rates(timer_hz, sample_max=1_000_000): # Частоты задаются целочисленным делением TIM6 выбранного MCU, единицы обоих параметров — Гц. + if timer_hz <= 0 or sample_max <= 0: # Нулевые и отрицательные аппаратные параметры не описывают рабочий таймер. + raise ValueError("Частота таймера и предел выборок должны быть положительными") # Ошибка профиля выявляется до формирования таблицы DAC. + return tuple(sorted({rate for divisor in range(1, math.isqrt(timer_hz) + 1) if timer_hz % divisor == 0 for rate in (divisor, timer_hz // divisor) if 1 <= rate <= sample_max})) # Парные делители охватывают все точные частоты без перебора каждого значения до тактовой частоты. + + +F407_SAMPLE_RATES = timer_sample_rates(F407_TIMER_HZ) # Старый публичный экспорт сохраняет прежние результаты для профиля F407 72 МГц. def nearest_f407_sample_rate(rate): @@ -29,9 +31,9 @@ def nearest_f407_sample_rate(rate): return min(F407_SAMPLE_RATES, key=lambda candidate: (abs(candidate - rate), candidate)) -def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2): +def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Прозрачно передаём выбранный настольный решатель общему генератору. if method != "samples": - return interpolate(points, sample_rate, vref, method, degree) + return interpolate(points, sample_rate, vref, method, degree, backend=backend) # Расчёт остаётся в нативном порте; обработчик готовых отсчётов не требует МНК. if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000: raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") if not math.isfinite(vref) or not 0 < vref <= 3.6: diff --git a/python/set_devices/mcu_profiles.py b/python/set_devices/mcu_profiles.py index ff7f2d9..13866b2 100644 --- a/python/set_devices/mcu_profiles.py +++ b/python/set_devices/mcu_profiles.py @@ -36,7 +36,7 @@ EMULATOR_PROFILES = { # Каталог содержит платы с реал "STM32F407": {"title": "STM32F407VET6", "timer_hz": 72_000_000, "sample_max": 1_000_000, "settings": "stm_ump", "model": "STM32F407-EMU-EXPERIMENT", "igbt_inputs": tuple("PB%d" % pin for pin in range(8)), "igbt_gpio": True, "wave_port": "stm32f407-periph", "tools": tuple(EMULATOR_TOOLS)}, # TIM6 72 МГц и прежний ключ stm_ump сохраняют совместимость F407. "STM32F103ZET6": {"title": "STM32F103ZET6", "timer_hz": 72_000_000, "sample_max": 1_000_000, "settings": "stm_emulator/STM32F103ZET6", "model": "STM32F103ZE-EMU-EXPERIMENT", "igbt_inputs": ("PB0", "PB1", "PB2", "PB10", "PB11", "PB12", "PB13", "PB14"), "igbt_gpio": True, "wave_port": "stm32f103-periph", "tools": tuple(EMULATOR_TOOLS)}, # High-density F103ZE имеет DAC/TIM6 72 МГц, UART/GAS и bxCAN; малые F103 без DAC не объявляются совместимыми автоматически. "STM32G474RB": {"title": "STM32G474RB", "timer_hz": 160_000_000, "sample_max": 1_000_000, "settings": "stm_emulator/STM32G474RB", "model": "STM32G474RB-EMU-EXPERIMENT", "igbt_inputs": ("PB0", "PB1", "PB2", "PB10", "PB11", "PB12", "PB13", "PB14"), "igbt_gpio": True, "wave_port": "stm32g474-periph", "tools": tuple(EMULATOR_TOOLS)}, # RB использует свой USB BOOT, FDCAN и TIM6 160 МГц. - "STM32G474VET6": {"title": "HOPE STM32G474VET6", "timer_hz": 160_000_000, "sample_max": 1_000_000, "settings": "stm_emulator/STM32G474VET6", "model": "STM32G474VE-HOPE-EMU", "igbt_inputs": ("PB0", "PB1", "PB2", "PB10", "PB11", "PB12", "PB13", "PB14"), "igbt_gpio": False, "wave_port": "stm32g474-periph", "tools": tuple(EMULATOR_TOOLS)}, # HOPE подтверждает собственную USB-модель, USART1 PA9/PA10 и CAN1 PD0/PD1; GPIO IGBT отключены из-за CAN2 на PB12/PB13. + "STM32G474VET6": {"title": "HOPE STM32G474VET6", "timer_hz": 160_000_000, "sample_max": 1_000_000, "settings": "stm_emulator/STM32G474VET6", "model": "STM32G474VE-HOPE-EMU", "igbt_inputs": ("PB0", "PB1", "PB2", "PB10", "PB11", "PB6", "PB7", "PB14"), "igbt_gpio": True, "wave_port": "stm32g474-periph", "tools": tuple(EMULATOR_TOOLS)}, # HOPE использует свободные Vin на J5/J6; PB6/PB7 освобождают линии CAN2 PB12/PB13, а Vstat остаётся PC0…PC7. } # Модели общие, а идентичность, GPIO, частоты и сохранённое подключение принадлежат выбранной плате. CANDLE_G474_BOOT = { # Профиль воспроизводит CAN_BOOTLOADER_G474.md и boot_profile.h проекта CANdle_stm32. "app_base": 0x08008000, "app_size": 480 * 1024, # Начальные 32 КиБ из 512 КиБ Flash заняты BOOT; приложение занимает оставшиеся 480 КиБ. diff --git a/python/set_devices/oscilloscope.py b/python/set_devices/oscilloscope.py index a2b6db2..1369400 100644 --- a/python/set_devices/oscilloscope.py +++ b/python/set_devices/oscilloscope.py @@ -2,28 +2,45 @@ from __future__ import annotations import math +from itertools import islice # Блоки по 1024 элемента ограничивают задержку отмены длинных измерений без изменения порядка суммирования. +from set_devices.plot_ticks import tick_precision # Общая точность подписей позволяет различать малые деления времени на фоне большого абсолютного смещения. -def time_label(milliseconds): +def time_label(milliseconds, step=None): # Необязательный шаг в миллисекундах повышает точность только для подписей шкалы; остальные вызовы сохраняют прежний вид. """Readable time without rounding nanosecond divisions to zero.""" magnitude = abs(milliseconds) + precision = tick_precision(milliseconds, step, 6) if step is not None else 6 # Шесть значащих цифр остаются стандартом; малый шаг требует дополнительных разрядов до точности double. if magnitude < .001: - return f"{milliseconds * 1e6:.6g} нс" + return f"{milliseconds * 1e6:.{precision}g} нс" # В миллисекунде 10^6 наносекунд; выбранная точность сохраняет различие соседних малых делений. if magnitude < 1: - return f"{milliseconds * 1000:.6g} мкс" + return f"{milliseconds * 1000:.{precision}g} мкс" # В миллисекунде 1000 микросекунд; шаг и координата масштабируются одинаково, поэтому число значащих цифр не меняется. if magnitude < 1000: - return f"{milliseconds:.6g} мс" - return f"{milliseconds / 1000:.6g} с" + return f"{milliseconds:.{precision}g} мс" # Средние интервалы остаются в исходных миллисекундах с точностью, достаточной для текущего шага шкалы. + return f"{milliseconds / 1000:.{precision}g} с" # Деление на 1000 переводит миллисекунды в секунды без потери нужных значащих цифр. -def measurements(points): - values = [value for _, value in points if math.isfinite(value)] +def _measurement_items(items, check_cancelled): # Добавляем необязательную отмену к любому проходу статистики, сохраняя все исходные элементы и их порядок. + if check_cancelled is None: # Обычные вызовы измерений сохраняют прежний контракт без дополнительных проверок отмены. + yield from items # Передаём исходную последовательность без копирования всего ряда или численных преобразований. + return # Не создаём блоки для синхронных измерений, которым не передано средство отмены. + source = iter(items) # Один итератор обеспечивает последовательный обход без повторов на границах блоков. + while True: # Продолжаем обработку до исчерпания исходной последовательности или исключения отмены. + check_cancelled() # Новый жест навигации прерывает даже этап среднего, СКЗ или поиска экстремумов. + block = tuple(islice(source, 1024)) # Не более 1024 элементов между проверками удерживают задержку отмены малой на многомиллионной записи. + if not block: # Пустой блок означает, что все исходные отсчёты уже переданы численной операции. + return # Последняя проверка отмены выполнена до завершения прохода. + yield from block # Сохраняем порядок элементов для прежней точности math.fsum и поиска пересечений. + + +def measurements(points, check_cancelled=None): # Необязательный обработчик отмены расширяет API, не меняя существующие вызовы с одним аргументом. + values = [value for _, value in _measurement_items(points, check_cancelled) if math.isfinite(value)] # Проверяем отмену и при отборе конечных амплитуд, включая полностью нечисловой ряд. if not values: return None - low, high = min(values), max(values) - mean = math.fsum(value / len(values) for value in values) + low = min(_measurement_items(values, check_cancelled)) # Точный минимум вычисляется по всем конечным значениям с ограниченной задержкой отмены. + high = max(_measurement_items(values, check_cancelled)) # Точный максимум сохраняет прежнюю семантику, в том числе порядок равных значений. + mean = math.fsum(value / len(values) for value in _measurement_items(values, check_cancelled)) # Отмена проверяется внутри суммирования; порядок и нормировка слагаемых остаются прежними. scale = max(abs(low), abs(high)) - rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in values) / len(values)) if scale else 0. + rms = scale * math.sqrt(math.fsum((value / scale) ** 2 for value in _measurement_items(values, check_cancelled)) / len(values)) if scale else 0. # Прерываем длинный расчёт СКЗ без потери прежней защиты от переполнения. # Hysteresis rejects small excursions around the midpoint. Frequency is # only reported after two complete rising crossings, never from one edge. middle = (low + high) / 2 @@ -32,7 +49,7 @@ def measurements(points): crossings = [] previous = None if high > low: - for stamp, value in points: + for stamp, value in _measurement_items(points, check_cancelled): # Проверяем отмену и при полном проходе поиска частоты, сохраняя исходную гистерезисную обработку пропусков. if value < middle - hysteresis: armed = True if armed and previous and previous[1] < middle <= value and stamp > previous[0]: diff --git a/python/set_devices/plot_decimation.py b/python/set_devices/plot_decimation.py index a3ce466..77ba7be 100644 --- a/python/set_devices/plot_decimation.py +++ b/python/set_devices/plot_decimation.py @@ -1,19 +1,31 @@ """Pixel-column envelope for drawing; measurement samples are never changed.""" import math from bisect import bisect_left, bisect_right +from .background_work import work_items # Фоновая подготовка длинных временных массивов должна уступать GIL во время заполнения и проверки порядка. + + +class PointTimes: # Представление координат X для bisect без параметра key, отсутствующего в Python 3.8 сборки Win7. + def __init__(self, points): # Принимаем индексируемые пары (X, Y), порядок которых проверяется вызывающим кодом. + self.points = points # Храним ссылку на исходные точки, чтобы не копировать большой ряд при каждом изменении окна. + + def __len__(self): # Предоставляем bisect длину исходного ряда для определения границ двоичного поиска. + return len(self.points) # Получаем размер за постоянное время без перебора измерений. + + def __getitem__(self, index): # Извлекаем одну координату по индексу, выбранному алгоритмом двоичного поиска. + return self.points[index][0] # В паре (X, Y) индекс 0 обозначает время или частоту; значения Y не участвуют в сравнении. class IndexedTimes(list): """Random-access timestamps, with ordering validated once when loaded.""" - def __init__(self, points): - super().__init__(points) - self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) - for i, x in enumerate(self)) + def __init__(self, points, checkpoint=None): # Необязательная контрольная точка включает кооперативную подготовку только для фоновых файловых заданий. + super().__init__(work_items(points, checkpoint)) # Заполняем полный массив, регулярно освобождая GIL при наличии фонового бюджета. + self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) # Конечность и порядок проверяются по всем исходным меткам без прореживания. + for i, x in work_items(enumerate(self), checkpoint)) # Второй длинный проход также уступает интерфейсу и реагирует на отмену. def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None): """Select only the viewport in logarithmic time for indexed records.""" - if isinstance(timestamps, (list, tuple, range)) and getattr(timestamps, "ordered", True): + if getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Проверенные индексируемые представления со смещением X также допускают двоичный поиск без копирования меток времени. start = bisect_left(timestamps, left) stop = bisect_right(timestamps, right) if neighbours: @@ -37,6 +49,10 @@ def pixel_envelope(timestamps, values, left, right, width): and zooming retain lines crossing the viewport boundaries. """ width = max(1, int(width)) + index = getattr(values, "envelope_index", None) # Подготовленный файловый ряд содержит индекс точных экстремумов для быстрого выбора экранных столбцов. + if index is not None and index.count == len(values) and getattr(timestamps, "ordered", isinstance(timestamps, (list, tuple, range))): # Индекс применим к неизменённым значениям и проверенным координатам, включая ленивое смещение короткой развёртки. + yield from index.points(timestamps, left, right, width) # Выдаём не более четырёх конечных точек на пиксель плюс соседей для отсечения краёв. + return # Исключаем повторное линейное сканирование миллионов измерений при каждом изменении масштаба. scale = width / (right - left) if right > left else 1. bucket = None first = low = high = last = None @@ -73,6 +89,61 @@ def pixel_envelope(timestamps, values, left, right, width): yield from emit() +def render_envelope(timestamps, values, left, right, width): # Экранная огибающая ограничивает плотный упорядоченный ряд двумя экстремумами на логический пиксель, сохраняя исходные измерения отдельно. + width = max(1, int(width)) # Вызывающий виджет передаёт логическую ширину области: увеличение DPI не должно умножать число почти совпадающих вершин. + ordered = getattr(timestamps, 'ordered', isinstance(timestamps, (list, tuple, range))) # Двоичный поиск и экранные столбцы применимы только к проверенному порядку временных координат. + if not ordered: # Неупорядоченный поток нельзя безопасно переставлять по экранным столбцам или обрезать двоичным поиском. + yield from pixel_envelope(timestamps, values, left, right, width) # Сохраняем прежнюю последовательность и разрывы для нестандартного источника без предположения об упорядоченности. + return # Новый ограниченный экранный режим не меняет семантику неупорядоченных данных. + count = min(len(timestamps), len(values)) # Общая длина исключает обращения за границу при несовпадающих размерах координат и амплитуд. + start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Находим точное число исходных отсчётов видимого окна без полного обхода файла. + if stop - start <= width: # При сильном увеличении все измерения помещаются в экранный бюджет и не требуют никакого сокращения. + for position in range(max(0, start - 1), min(count, stop + 1)): # Сохраняем по одному соседу для линий, пересекающих края видимого окна. + stamp, value = timestamps[position], values[position] # Извлекаем точную исходную пару, включая нечисловые амплитуды между конечными отсчётами. + yield (stamp, value) if math.isfinite(stamp) and math.isfinite(value) else None # Все конечные точки остаются без изменений, а NaN и бесконечности разрывают путь. + return # Разреженный зум не должен проходить через экстремумы столбцов и терять близкие исходные точки. + index = getattr(values, 'envelope_index', None) # Подготовленный файловый ряд позволяет выбирать экстремумы без повторного линейного обхода миллионов значений. + if index is not None and index.count == len(values): # Индекс допустим только для исходной неизменённой длины ряда. + yield from index.points(timestamps, left, right, width, extrema_only=True) # Сохраняем максимум два экстремума на столбец, два конца окна и два внешних соседа. + return # Быстрый запрос не должен дополнительно запускать линейный запасной путь. + columns = {} # Для неиндексированного ряда храним лишь пару экстремумов на экранный столбец, а не полную запись. + first_visible = last_visible = None # Дополнительно сохраняем два конца видимой линии для правильного соединения с внешними соседями. + segment = 0 # Каждый нечисловой отсчёт увеличивает номер непрерывного участка; разные номера запрещают соединять выбранные точки. + seen = False # Полностью нечисловое окно должно вернуть один разрыв, а пустая запись не создаёт искусственных точек. + scale = width / (right - left) if right > left else 1. # Масштаб переводит время в столбцы; совпадающие временные координаты не допускают деления на ноль. + for ordinal, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)): # Запасной путь читает каждый исходный отсчёт окна ровно один раз и поэтому также сохраняет одиночные пики. + seen = True # Фиксируем наличие хотя бы одного конечного либо нечислового исходного измерения. + if not math.isfinite(stamp) or not math.isfinite(value): # Пропуски не участвуют в экстремумах, но должны разделять любые выбранные точки по разные стороны. + segment += 1 # Номер участка переносит информацию о пропуске без хранения каждого маркера плотного ряда. + continue # Нечисловая амплитуда не имеет экранной координаты кривой. + column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale)) # Соседи вынесены в два отдельных столбца за пределами видимой области. + point = (ordinal, stamp, value, segment) # Исходный порядок и номер участка позволяют вернуть экстремумы без ложных соединений через NaN. + pair = columns.get(column) # В каждом экранном столбце сохраняются только точные минимум и максимум. + if pair is None: # Первый конечный отсчёт одновременно задаёт обе роли нового столбца. + columns[column] = [point, point] # Два совпадающих индекса позднее объединяются в одну вершину. + else: # Дальнейшие измерения меняют только действительно достигнутые экстремумы. + if value < pair[0][2]: # Строгое сравнение сохраняет первое вхождение равного минимума. + pair[0] = point # Одиночный отрицательный выброс остаётся в экранной огибающей. + if value > pair[1][2]: # Строгое сравнение сохраняет первое вхождение равного максимума. + pair[1] = point # Одиночный положительный выброс также остаётся видимым при любом сжатии. + if left <= stamp <= right: # Внешние соседи не подменяют начало и конец собственно видимых измерений. + first_visible = point if first_visible is None else first_visible # Первый конечный видимый отсчёт сохраняется один раз независимо от числа столбцов. + last_visible = point # Последний конечный видимый отсчёт обновляется до правого края окна. + selected = {point[0]: point for pair in columns.values() for point in pair} # Уникальные исходные индексы устраняют совпадение ролей без изменения времени или амплитуды. + for point in (first_visible, last_visible): # Всего две дополнительные конечные точки сохраняют соединения на краях видимого окна. + if point is not None: # Полностью нечисловое окно не имеет конечных краевых измерений. + selected[point[0]] = point # Повторное добавление совпавшего экстремума не увеличивает экранный бюджет. + previous_segment = 0 # Нулевой участок соответствует началу исходной последовательности до первого NaN. + for ordinal in sorted(selected): # Возвращаем выбранные точки в исходном порядке, включая совпадающие метки времени. + _, stamp, value, current_segment = selected[ordinal] # Экранная вершина сохраняет исходные координаты и номер непрерывного участка. + if current_segment != previous_segment: # Различные номера подтверждают хотя бы один NaN между выбранными конечными отсчётами. + yield None # Маркер разрыва запрещает рисовать линию через отсутствующие измерения. + yield stamp, value # Амплитуда и время возвращаются точно, без усреднения и сдвига коротких пиков. + previous_segment = current_segment # Следующая вершина сравнивается с участком предыдущего выбранного измерения. + if seen and (not selected or previous_segment != segment): # Сохраняем полностью нечисловое окно и разрыв после последней конечной точки. + yield None # Длинная последовательность хвостовых пропусков ограничивается одним экранным маркером. + + def preview_window_count(requested, preview_count, original_count): """Translate a raw-sample window to a display-envelope window.""" if not original_count or original_count <= preview_count: diff --git a/python/set_devices/plot_envelope_index.py b/python/set_devices/plot_envelope_index.py new file mode 100644 index 0000000..0e4d6a4 --- /dev/null +++ b/python/set_devices/plot_envelope_index.py @@ -0,0 +1,174 @@ +import math # Проверяем конечность измерений, чтобы пропуски не соединялись линиями и не участвовали в экстремумах. +from array import array # Храним индексы в компактных массивах вместо отдельных объектов Python для каждого узла дерева. +from bisect import bisect_left, bisect_right # Находим границы пиксельных столбцов двоичным поиском по упорядоченным меткам времени. + + +class EnvelopeIndex: # Индекс неизменяемого ряда позволяет выбирать точные экстремумы без повторного обхода миллионов отсчётов. + BLOCK_SIZE = 32 # Лист дерева содержит 32 отсчёта: крайние неполные блоки малы, а индекс занимает около 2,5 байта на отсчёт. + EMPTY = (-1, -1, -1, -1, 0) # Первые четыре поля обозначают индексы первого, минимального, максимального и последнего отсчёта; пятое считает пропуски. + + def __init__(self, values, checkpoint=None): # Необязательная контрольная точка сохраняет отзывчивость Qt при первоначальном построении большого индекса. + self.values = values # Сохраняем исходные значения по ссылке; сам индекс не сокращает и не изменяет измерения. + self.count = len(values) # Запоминаем размер снимка для проверки пригодности индекса после очистки графика. + blocks = (self.count + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Округление вверх включает неполный последний блок записи. + self.leaves = 1 # Для обычного двоичного дерева число листьев должно быть степенью двух. + while self.leaves < blocks: # Добавляем пустые листья только до ближайшей степени двух, не удваивая размер без необходимости. + self.leaves *= 2 # Два дочерних узла соответствуют объединённому диапазону родителя. + self.nodes = tuple(array('q', [field]) * (2 * self.leaves) for field in self.EMPTY) # Пять знаковых 64-битных массивов содержат индексы, включая признак отсутствия -1. + for block in range(blocks): # Полный обход исходных значений нужен только при первоначальном построении индекса. + if checkpoint is not None and block % 32 == 0: # Тридцать два листа по 32 отсчёта дают контроль примерно через 1024 исходных значения. + checkpoint() # Отпускаем GIL и проверяем отмену между ограниченными порциями подготовки экстремумов. + start = block * self.BLOCK_SIZE # Переводим номер блока в начальный индекс исходного отсчёта. + self._store(self.leaves + block, self._scan(start, min(self.count, start + self.BLOCK_SIZE))) # Сводка листа сохраняет точные экстремумы и число нечисловых отсчётов. + for node in range(self.leaves - 1, 0, -1): # Строим родителей снизу вверх, когда сводки обоих детей уже готовы. + if checkpoint is not None and node % 1024 == 0: # Объединение большого дерева также делится на порции по 1024 узла. + checkpoint() # Не допускаем непрерывного удержания GIL после завершения чтения исходных измерений. + self._store(node, self._merge(self._load(2 * node), self._load(2 * node + 1))) # Родитель объединяет соседние диапазоны с сохранением временного порядка. + + def _store(self, node, summary): # Записываем одну сводку в параллельные компактные массивы дерева. + for column, field in zip(self.nodes, summary): # Каждое поле имеет одинаковый номер узла во всех пяти массивах. + column[node] = field # Индексы ссылаются на исходный ряд, поэтому значения Y не дублируются в памяти. + + def _load(self, node): # Читаем сводку готового узла без обращения ко всем его исходным отсчётам. + first, low, high, last, gaps = self.nodes # Разбираем пять массивов один раз, исключая создание генератора при каждом посещении узла дерева. + return first[node], low[node], high[node], last[node], gaps[node] # Возвращаем те же пять полей напрямую; тысячи запросов кадра не создают промежуточных итераторов. + + def _scan(self, start, stop): # Сканируем полуинтервал только при построении листа либо на неполных краях запроса. + first = low = high = last = -1 # Отрицательный индекс означает, что конечное значение ещё не найдено. + gaps = 0 # Подсчёт пропусков позволяет проверять разрыв линии между выбранными экстремумами. + values, isfinite = self.values, math.isfinite # Локальные ссылки исключают повторный поиск атрибутов для каждого отсчёта неполного края блока. + low_value = high_value = 0. # Значения обоих экстремумов устанавливаются первым конечным отсчётом; ноль не участвует в сравнении до этого момента. + for index in range(start, stop): # Полуинтервал исключает stop и совпадает с правилами срезов Python. + value = values[index] # Читаем исходное измерение ровно один раз, сохраняя значение для обоих сравнений экстремумов. + if not isfinite(value): # NaN и бесконечности должны разрывать путь, а не менять масштаб или экстремумы. + gaps += 1 # Учитываем пропуск даже в диапазоне, где совсем нет конечных значений. + continue # Исключаем нечисловой отсчёт из поиска первого, последнего, минимального и максимального значений. + if first < 0: # Первый конечный отсчёт одновременно задаёт оба начальных экстремума. + first = low = high = index # Все три индекса указывают на одно измерение до появления следующих точек. + low_value = high_value = value # Сохраняем амплитуды начальных экстремумов, чтобы следующие отсчёты не читали их повторно из большого ряда. + else: # Для остальных отсчётов обновляем только реально изменившиеся экстремумы. + if value < low_value: # Строгое сравнение с сохранённой амплитудой сохраняет первый по времени минимум при равных значениях. + low = index # Запоминаем точный индекс минимального исходного значения. + low_value = value # Обновляем амплитуду вместе с индексом, исключая отдельное чтение минимума на следующей итерации. + if value > high_value: # Первый по времени максимум также остаётся стабильным при равенстве значений. + high = index # Запоминаем точный индекс максимального исходного значения. + high_value = value # Сохраняем амплитуду нового максимума для дальнейших сравнений без обращения к исходному контейнеру. + last = index # Последний конечный отсчёт нужен для правильного соединения соседних пиксельных столбцов. + return first, low, high, last, gaps # Сводка содержит только индексы и счётчик; измерения остаются в исходном ряду. + + def _merge(self, before, after): # Объединяем сводки соседних диапазонов, расположенных в исходном временном порядке. + gaps = before[4] + after[4] # Число пропусков аддитивно и не зависит от расположения экстремумов. + if before[0] < 0: # Левый диапазон может быть пустым либо полностью состоять из пропусков. + return (*after[:4], gaps) # Сохраняем конечные точки правого диапазона и пропуски обоих диапазонов. + if after[0] < 0: # Пустой правый диапазон не меняет конечные точки левого диапазона. + return (*before[:4], gaps) # Добавляем только число пропусков справа. + low = after[1] if self.values[after[1]] < self.values[before[1]] else before[1] # При равных минимумах сохраняем более ранний отсчёт. + high = after[2] if self.values[after[2]] > self.values[before[2]] else before[2] # При равных максимумах также сохраняем более ранний отсчёт. + return before[0], low, high, after[3], gaps # Крайние точки берём из внешних границ объединённого диапазона. + + def between(self, start, stop): # Возвращаем точную сводку произвольного диапазона индексов без полного сканирования ряда. + start, stop = max(0, start), min(self.count, stop) # Ограничиваем запрос существующими измерениями снимка. + if stop <= start: # Пустое или обратное окно не содержит ни точек, ни пропусков. + return self.EMPTY # Единый пустой результат упрощает объединение диапазонов на границах записи. + if not self.nodes[4][1] and isinstance(self.values, (list, tuple, array)): # Нулевой счётчик пропусков корня доказывает конечность всего неизменяемого ряда; обычные контейнеры допускают короткие срезы краёв. + return self._finite_between(start, stop) # Плотные записи без NaN обходятся без пяти-польных сводок и проверок конечности на каждом кадре. + first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Находим первый полностью включённый блок после левого неполного края. + last_block = stop // self.BLOCK_SIZE # Этот блок уже не входит целиком в запрос и задаёт начало правого края. + if first_block >= last_block: # Короткий запрос дешевле прочитать напрямую, чем обходить дерево. + return self._scan(start, stop) # Здесь читается не более двух неполных блоков исходных отсчётов. + before = self._scan(start, first_block * self.BLOCK_SIZE) # Получаем сводку левого неполного края, максимум из 31 отсчёта. + after = self._scan(last_block * self.BLOCK_SIZE, stop) # Получаем сводку правого неполного края, также максимум из 31 отсчёта. + left, right = self.leaves + first_block, self.leaves + last_block # Переводим границы полных блоков в номера листьев дерева. + while left < right: # За логарифмическое число шагов выбираем узлы, полностью покрывающие внутренний диапазон. + if left % 2: # Правый ребёнок слева включается отдельно, чтобы не захватить отсчёты перед запросом. + before = self._merge(before, self._load(left)) # Добавляем очередной диапазон после уже выбранной левой части. + left += 1 # Переходим к следующему соседнему узлу перед подъёмом к родителю. + if right % 2: # Правая исключённая граница позволяет включить её левого соседа отдельно. + right -= 1 # Перемещаем границу к последнему целиком включённому узлу текущего уровня. + after = self._merge(self._load(right), after) # Добавляем узел перед уже выбранной правой частью, сохраняя порядок. + left //= 2 # Поднимаем левую границу на уровень объединённых диапазонов. + right //= 2 # Правая граница поднимается на тот же уровень дерева. + return self._merge(before, after) # Соединяем выбранные части в одну точную сводку запрошенного полуинтервала. + + def _finite_between(self, start, stop): # Быстрый запрос применяется только после проверки отсутствия NaN и бесконечностей во всём исходном ряду. + values = self.values # Сохраняем локальную ссылку на полные исходные амплитуды, не создавая их копию. + first_block = (start + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # Левый неполный край заканчивается перед первым целиком включённым блоком. + last_block = stop // self.BLOCK_SIZE # Последний целый блок предшествует правому неполному краю запроса. + if first_block >= last_block: # Короткий диапазон занимает не более двух блоков по 32 отсчёта и не требует обхода дерева. + chunk = values[start:stop] # Временный срез содержит максимум 63 исходных амплитуды, независимо от полного размера лога. + return start, start + chunk.index(min(chunk)), start + chunk.index(max(chunk)), stop - 1, 0 # Встроенные операции находят точные первые экстремумы на C; первый, последний и нулевые пропуски уже известны. + low = high = start # Любой непустой конечный диапазон имеет допустимое начальное измерение на левой границе. + low_value = high_value = values[start] # Амплитуды экстремумов храним локально, чтобы не перечитывать их из большого массива после каждого узла. + for begin, end in ((start, first_block * self.BLOCK_SIZE), (last_block * self.BLOCK_SIZE, stop)): # Сканируем только два неполных края, каждый не длиннее 31 исходного отсчёта. + if begin < end: # Совпадение с границей блока исключает создание пустого среза и вызов min/max без элементов. + chunk = values[begin:end] # Короткий временный срез позволяет выполнить поиск экстремумов встроенными операциями C. + minimum, maximum = min(chunk), max(chunk) # Исходные конечные амплитуды сравниваются без повторной проверки math.isfinite. + if minimum < low_value: # Края идут слева направо, поэтому при равенстве оставляем уже найденный более ранний минимум. + low, low_value = begin + chunk.index(minimum), minimum # index возвращает первое вхождение точного минимума внутри неполного края. + if maximum > high_value: # При равенстве максимумов также сохраняем более раннее измерение. + high, high_value = begin + chunk.index(maximum), maximum # Индекс исходной амплитуды вычисляется смещением от начала короткого среза. + lows, highs = self.nodes[1], self.nodes[2] # Для конечного ряда нужны только индексы минимумов и максимумов; остальные три массива не читаются. + left, right = self.leaves + first_block, self.leaves + last_block # Переводим полуинтервал целых блоков в номера листьев компактного дерева. + while left < right: # Как и общий алгоритм, выбираем лишь логарифмическое число готовых сводок диапазона. + if left % 2: # Отдельный правый ребёнок слева входит в запрос целиком, а его сосед остаётся за границей. + candidate = lows[left] # Готовый индекс минимума узла ссылается непосредственно на исходный ряд. + value = values[candidate] # Читаем только амплитуду найденного в узле минимума. + if value < low_value or (value == low_value and candidate < low): # Узлы посещаются не строго слева направо, поэтому равные значения сравниваем и по исходному индексу. + low, low_value = candidate, value # Обновляем точное первое вхождение минимума без создания промежуточной сводки. + candidate = highs[left] # Второй готовый индекс узла соответствует точному исходному максимуму. + value = values[candidate] # Амплитуда максимума также читается один раз на выбранный узел. + if value > high_value or (value == high_value and candidate < high): # Равные максимумы должны сохранять прежний выбор самого раннего отсчёта. + high, high_value = candidate, value # Обновляем максимум и его амплитуду вместе для следующего сравнения. + left += 1 # Исключаем уже обработанный узел перед подъёмом обеих границ на следующий уровень. + if right % 2: # Левый сосед исключённой правой границы входит в диапазон полностью. + right -= 1 # Получаем номер последнего целиком включённого узла текущего уровня дерева. + candidate = lows[right] # Читаем индекс готового минимума правого узла без распаковки пяти полей сводки. + value = values[candidate] # Только выбранный минимум требует обращения к исходной амплитуде. + if value < low_value or (value == low_value and candidate < low): # Порядок обхода не влияет на первый по времени минимум при равенстве амплитуд. + low, low_value = candidate, value # Сохраняем лучший исходный минимум для объединённого запроса. + candidate = highs[right] # Индекс максимума берём из второго массива готовых экстремумов. + value = values[candidate] # Читаем точную амплитуду исходного максимума правого узла. + if value > high_value or (value == high_value and candidate < high): # При одинаковых амплитудах выбираем наименьший исходный индекс. + high, high_value = candidate, value # Объединяем максимум напрямую, исключая вызовы _load и _merge на каждом узле. + left //= 2 # Переходим от листьев или текущих узлов к покрывающим их родителям. + right //= 2 # Правая исключённая граница поднимается на тот же уровень дерева. + return start, low, high, stop - 1, 0 # Все точки конечны, поэтому внешние отсчёты и отсутствие пропусков не требуют отдельного вычисления. + + def points(self, timestamps, left, right, width, *, extrema_only=False): # Обычная огибающая сохраняет четыре роли на столбец, а отдельный экранный режим оставляет только точные минимум и максимум. + count = min(len(timestamps), self.count) # Общий размер защищает от несовпадающих длин координат и значений. + start, stop = bisect_left(timestamps, left, 0, count), bisect_right(timestamps, right, 0, count) # Оба края видимого временного окна включаются полностью. + ranges = [] # Список содержит только диапазоны пикселей и по одному соседнему отсчёту для отсечения на краях. + if start: # Сосед слева сохраняет линию, пересекающую левую границу без отсчёта непосредственно внутри окна. + ranges.append((start - 1, start)) # Только один внешний отсчёт нужен для правильного геометрического отсечения. + if right <= left: # Совпадающие границы X могут содержать много отсчётов с одной меткой времени, но не допускают деления на ширину окна. + if stop > start: # Пустое окно не создаёт искусственный диапазон измерений. + ranges.append((start, stop)) # Экстремумы всех совпадающих меток времени ограничены четырьмя точками одного столбца. + else: # Любое окно делим по экранным координатам, чтобы неравномерные и повторяющиеся метки также соблюдали бюджет на столбец. + edge = start # Первый пиксель начинается с первого отсчёта внутри видимого окна. + for column in range(width): # Число итераций зависит только от ширины изображения в физических пикселях. + boundary = left + (right - left) * ((column + 1) / width) # Правая граница столбца задана в единицах исходной оси X. + end = stop if column == width - 1 else bisect_left(timestamps, boundary, edge, stop) # Последний столбец включает отсчёты точно на правой границе окна. + if end > edge: # Пустые пиксели не создают искусственных измерений. + ranges.append((edge, end)) # Запоминаем исходный диапазон для выбора первого, минимального, максимального и последнего отсчётов. + edge = end # Следующий столбец продолжает ряд без потерь и повторного включения границы. + if stop < count: # Сосед справа сохраняет линию, пересекающую правую границу окна. + ranges.append((stop, stop + 1)) # Внешний сосед добавляет не более одной конечной точки к пиксельному бюджету. + has_gaps = bool(self.nodes[4][1]) # Нулевой счётчик корня доказывает отсутствие пропусков во всём ряду и исключает лишние запросы между компактными экстремумами. + previous = None # Индекс предыдущей выбранной конечной точки нужен для проверки разрыва между столбцами. + for begin, end in ranges: # Обрабатываем только экранные диапазоны и двух возможных внешних соседей. + summary = self.between(begin, end) # Получаем точные экстремумы через индекс, читая лишь неполные края блоков. + roles = summary[1:3] if extrema_only else summary[:4] # Новый экранный режим уменьшает число вершин, не изменяя исходные экстремумы или прежний публичный результат. + if extrema_only and begin == start: # Первый видимый столбец сохраняет также начало линии, чтобы она правильно пересекала левый край окна. + roles += (summary[0],) # Дополнительная роль нужна только одному столбцу, а не каждому пикселю. + if extrema_only and end == stop: # Последний видимый столбец сохраняет конец линии для правильного пересечения правого края. + roles += (summary[3],) # Два внешних конца увеличивают общий бюджет только на две конечные точки. + selected = sorted({index for index in roles if index >= 0}) # Убираем совпадающие роли и восстанавливаем исходный временной порядок выбранных экстремумов. + if not selected: # Полностью нечисловой диапазон разрывает линию один раз независимо от числа NaN внутри пикселя. + yield None # Маркер разрыва не является измерением и обрабатывается обоими движками отрисовки. + previous = None # Следующая конечная точка начинает новый путь. + continue # В пустом пикселе нет конечных экстремумов для отрисовки. + for index in selected: # На один непустой пиксель приходится не более четырёх исходных измерений. + if has_gaps and previous is not None and previous + 1 < index and (summary[4] or previous < begin) and self.between(previous + 1, index)[4]: # Проверяем разрывы только при наличии NaN в записи; соседние индексы и доказанно конечные ряды не требуют дополнительных запросов дерева. + yield None # Разрыв сохраняет пропуски между выбранными экстремумами без повторного запроса того же диапазона дерева. + yield timestamps[index], self.values[index] # Возвращаем исходные координаты и значения без усреднения или изменения амплитуды. + previous = index # Запоминаем конец текущего пути для следующей выбранной точки. diff --git a/python/set_devices/plot_processing.py b/python/set_devices/plot_processing.py index ef4fa9a..9805b1a 100644 --- a/python/set_devices/plot_processing.py +++ b/python/set_devices/plot_processing.py @@ -9,9 +9,34 @@ are separate objects, never channels in the acquisition or measurement model. """ from __future__ import annotations import csv +from bisect import bisect_left, bisect_right +from .plot_decimation import PointTimes # Используем координаты X без копирования ряда и без недоступного в Python 3.8 параметра key. from dataclasses import dataclass from datetime import datetime, timezone -from .signal_reconstruction import METHODS, reconstruct +from .signal_reconstruction import BACKENDS, METHODS, reconstruct # Общие подписи решателей используются в легенде и CSV, чтобы различать результаты сравнения. + + +class FrozenPoints(tuple): + """Normalized immutable samples, reusable across processing requests.""" + + def __new__(cls, points): + if isinstance(points, cls): + return points + result = super().__new__(cls, ((float(x), float(y)) for x, y in points)) + result.ordered = all(result[i - 1][0] <= result[i][0] for i in range(1, len(result))) + return result + + def between(self, left, right): + if self.ordered: + timestamps = PointTimes(self) # Создаём индексируемое представление X, сохраняя логарифмическую сложность поиска границ. + start = bisect_left(timestamps, left) # Начинаем с первой точки X >= left, включая все повторы левой границы. + stop = bisect_right(timestamps, right) # Заканчиваем перед первой точкой X > right, включая все повторы правой границы. + if start == 0 and stop == len(self): + return self + result = tuple.__new__(type(self), self[start:stop]) + result.ordered = True + return result + return FrozenPoints((x, y) for x, y in self if left <= x <= right) @dataclass(frozen=True) @@ -36,7 +61,7 @@ class Series: def __post_init__(self): # A source may reuse mutable buffers immediately after publication. - object.__setattr__(self, "points", tuple((float(x), float(y)) for x, y in self.points)) + object.__setattr__(self, "points", FrozenPoints(self.points)) @dataclass(frozen=True) @@ -71,6 +96,7 @@ class Request: output_count: int degree: int x_range: tuple | None + backend: str = "native" # Вариант входит в равенство запросов, поэтому две расчётные кривые могут сосуществовать. @dataclass(frozen=True) @@ -81,6 +107,7 @@ class Curve: unique_count: int rmse: float pieces: tuple = () + elapsed_ms: float = 0.0 # Сохраняем полное время расчёта отдельно от геометрии и оценки точности. def piece_at(self, x): for piece in self.pieces: @@ -90,10 +117,11 @@ class Curve: @property def label(self): - return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})" + variant = " · " + BACKENDS[self.request.backend] if self.request.method == "polynomial" else "" # Дополняем название только у метода, имеющего альтернативный решатель. + return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]}{variant})" # Подпись однозначно связывает слой графика и экспорт CSV с вариантом МНК. -def prepare(snapshot, key, method="pchip", output_count=1000, degree=2): +def prepare(snapshot, key, method="pchip", output_count=1000, degree=2, backend="native"): # Новый последний параметр не меняет существующие вызовы подготовки снимка. """Return a comparable request, or None when the source is not processable.""" if snapshot.blocked_reason: return None @@ -103,16 +131,16 @@ def prepare(snapshot, key, method="pchip", output_count=1000, degree=2): if snapshot.x_range is not None: left, right = snapshot.x_range selected = Series(selected.key, selected.label, - tuple((x, y) for x, y in selected.points if left <= x <= right), + selected.points.between(left, right), y_unit=selected.y_unit) - return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range) + return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range, backend) # Включаем выбранный решатель в идентичность запроса и проверку устаревших результатов. def process(request): if request is None: raise ValueError("Нет доступного аналогового канала") - result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True) - return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces) + result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True, backend=request.backend) # Передаём явный выбор пользователя в общий тонкий порт без численных вычислений в UI. + return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces, result.elapsed_ms) # Результат сохраняет время и метрики, относящиеся к одному конкретному запуску. def write_csv(curve, stream): diff --git a/python/set_devices/plot_ticks.py b/python/set_devices/plot_ticks.py new file mode 100644 index 0000000..7db48d8 --- /dev/null +++ b/python/set_devices/plot_ticks.py @@ -0,0 +1,10 @@ +"""Точность подписей шкал при приближении до небольших долей исходного диапазона.""" # Форматирование подписей отделено от расчёта координат и не требует Qt. +import math # Логарифмы определяют число значащих цифр, необходимое для различения соседних делений. + + +def tick_precision(value, step, minimum=4): # Значение и шаг задаются в одинаковых единицах; минимум сохраняет обычный вид подписей при крупном масштабе. + if not math.isfinite(value) or not math.isfinite(step) or value == 0 or step <= 0: # Для нуля и непригодного шага порядок числа не определён, поэтому используем исходную точность. + return minimum # Без вычисления логарифма сохраняем безопасное форматирование специальных значений. + value_order = math.floor(math.log10(abs(value))) # Порядок абсолютной координаты учитывает значащие цифры перед десятичной точкой и большие смещения оси. + step_order = math.floor(math.log10(step)) # Порядок положительного шага показывает, до какого десятичного разряда нужно различать деления. + return min(16, max(minimum, value_order - step_order + 1)) # Ограничение 16 цифрами соответствует полезной точности double, а разность порядков сохраняет отдельную подпись для каждого деления. diff --git a/python/set_devices/qt_ports/can_monitor.py b/python/set_devices/qt_ports/can_monitor.py index 311c350..d753419 100644 --- a/python/set_devices/qt_ports/can_monitor.py +++ b/python/set_devices/qt_ports/can_monitor.py @@ -2,27 +2,40 @@ from datetime import datetime from pathlib import Path -from PySide6.QtCore import QTimer +from PySide6.QtCore import QTimer, Signal # Таймер обновляет буфер, а общий сигнал сообщает владельцу об изменении любых настроек расшифровки. from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem, QMessageBox, QApplication, QAbstractItemView, QFileDialog) from ..can_transport import build_frame -from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data, decode_protocol +from ..can_monitor import CanSession, PROTOCOLS, UMP_MAPS, parse_filter, parse_gas_filter, matches_filters, format_data, decode_protocol # Общие декодер, карты и независимые фильтры обеспечивают одинаковое поведение всех CAN-мониторов. class MonitorControls(QWidget): + decoding_changed = Signal() # Владельцы обновляют онлайн и трассировку при смене формата, карты адресов или номера УМП. + def __init__(self, owner, directory, default="auto"): super().__init__(owner) self.owner = owner self.directory = directory self.session = CanSession() self._dirty = False - layout = QHBoxLayout(self) + self._pending_frames = {} # Упорядоченные ключи объединяют повторы между тактами таймера и сохраняют порядок первого поступления новых кадров. + self._history_rows = {} # Ключ уникального кадра указывает на строку и последний показанный счётчик, исключая поиск по всей таблице. + self._history_total = 0 # Сумма показанных повторов обновляется по разнице счётчиков, без перебора накопленной истории. + self._history_needs_reset = False # Частичная ошибка записи требует однократной сверки таблицы с фактически сохранённой сессией. + controls_layout = QVBoxLayout(self) # Отдельная строка параметров УМП сохраняет компактную ширину основной панели монитора. + controls_layout.setContentsMargins(0, 0, 0, 0) # Панель встраивается в существующую вкладку без дополнительных внешних отступов. + layout = QHBoxLayout() # Основные кнопки остаются в прежнем горизонтальном порядке над параметрами УМП. + controls_layout.addLayout(layout) # Общие элементы занимают первую строку вертикального контейнера. layout.setContentsMargins(0, 0, 0, 0) self.protocol = QComboBox() for key, label in PROTOCOLS: self.protocol.addItem(label, key) - self.protocol.setCurrentIndex(self.protocol.findData(default)) + self.protocol.setMinimumContentsLength(28) + self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + self.protocol.setToolTip("Выберите формат CAN. Для УМП / ProtoCAN GAS карта адресов и устройство выбираются отдельно.") # Подсказка объясняет разделение протокола и прикладного профиля. + legacy_ump = default in ("ump1_gas167", "ump2_gas167", "ump_gas") # Старые значения исходного профиля поддерживаются без возврата дублирующихся пунктов в список. + self.protocol.setCurrentIndex(self.protocol.findData("ump_protocan" if legacy_ump else default)) # Прежние профили УМП открывают единый формат с отдельно восстановленными параметрами. layout.addWidget(QLabel("Протокол")) layout.addWidget(self.protocol) self.raw = QCheckBox("Без парса") @@ -51,15 +64,52 @@ class MonitorControls(QWidget): self.status = QLabel("Буфер: 0") layout.addWidget(self.status) layout.addStretch(1) + self.ump_options = QWidget() # Контейнер дополнительных параметров показывается только для формата УМП / ProtoCAN GAS. + ump_layout = QHBoxLayout(self.ump_options) # Карта и номер УМП расположены рядом на второй строке панели. + ump_layout.setContentsMargins(0, 0, 0, 0) # Вложенная строка выравнивается с основными элементами монитора. + ump_layout.addWidget(QLabel("Карта адресов")) # Подпись отличает нумерацию регистров от формата CAN-кадра. + self.ump_map = QComboBox() # Выбор карты определяет словарь параметров при неизменном формате ProtoCAN. + for key, label in UMP_MAPS: # Оба монитора используют единый каталог действующей и планируемой карт. + self.ump_map.addItem(label, key) # Пользователь видит статус карты, а декодер получает устойчивый ключ профиля. + self.ump_map.setToolTip("167/v1: слова 0…127 и 0x5000…0x507F. Новые десятичные ID 5000…5127 и 6000…6127 назначены, но ещё не перенесены в прошивку; устройство определяется по ID.") # Подсказка указывает диапазоны обеих карт и состояние планируемой адресации. + self.ump_map.setCurrentIndex(self.ump_map.findData("assigned" if default == "ump_gas" else "gas167")) # Прежний профиль новых ID восстанавливает сквозную карту, остальные начинают с текущей. + ump_layout.addWidget(self.ump_map) # Карта выбирается независимо от списка форматов CAN. + self.ump_node_label = QLabel("Устройство") # Номер УМП относится к устройству, а не к отдельному протоколу. + ump_layout.addWidget(self.ump_node_label) # Подпись находится перед селектором номера УМП. + self.ump_node = QComboBox() # Текущая локальная карта требует ручного назначения УМП 1 или УМП 2. + self.ump_node.addItem("УМП 1", 1) # Число 1 обозначает первый УМП и передаётся декодеру как номер устройства. + self.ump_node.addItem("УМП 2", 2) # Число 2 обозначает второй УМП; карта регистров остаётся общей для обоих приборов. + self.ump_node.setCurrentIndex(self.ump_node.findData(2 if default == "ump2_gas167" else 1)) # Старый профиль второго УМП сохраняет выбранный прибор, остальные используют первый. + self.ump_node.setToolTip("Номер УМП для карты 167/v1 задаётся вручную. В новой карте устройство определяется по GAS ID.") # Подсказка объясняет, почему ручной выбор скрывается для сквозных ID. + ump_layout.addWidget(self.ump_node) # Устройство занимает собственное поле после карты адресов. + ump_layout.addStretch(1) # Свободное место справа удерживает дополнительные поля у левого края панели. + controls_layout.addWidget(self.ump_options) # Параметры УМП добавляются под основной строкой без расширения списка протоколов. + self._update_ump_options() # Исходный формат определяет видимость дополнительной строки до первого показа вкладки. self.timer = QTimer(self) self.timer.setInterval(250) self.timer.timeout.connect(self.refresh) self.timer.start() QApplication.instance().aboutToQuit.connect(self.session.close) self.destroyed.connect(lambda: self.session.close()) - self.protocol.currentIndexChanged.connect(lambda *_: self.refilter()) + self.protocol.currentIndexChanged.connect(self._change_protocol) # Смена формата обновляет параметры УМП и все представления принятых кадров. + self.ump_map.currentIndexChanged.connect(self._change_protocol) # Смена карты перерасшифровывает буфер и обновляет необходимость ручного номера устройства. + self.ump_node.currentIndexChanged.connect(self._change_protocol) # Смена устройства применяется к уже принятым кадрам текущей локальной карты. self.raw.toggled.connect(lambda *_: self.refilter()) + def _update_ump_options(self): # Видимость дополнительных полей зависит от формата и способности карты определить устройство по адресу. + self.ump_options.setVisible(self.protocol.currentData() == "ump_protocan") # Остальные форматы обходятся без настроек карты УМП. + manual_node = self.ump_map.currentData() == "gas167" # Только действующая локальная карта требует вручную выбранного номера УМП. + self.ump_node_label.setVisible(manual_node) # Подпись устройства скрывается вместе с ненужным ручным селектором новой карты. + self.ump_node.setVisible(manual_node) # Сквозные ID сами задают УМП, поэтому ручной номер не должен противоречить адресу кадра. + + def _change_protocol(self, *_args): # Сигналы трёх селекторов используют общий путь обновления, не зависящий от индекса изменённого поля. + self._update_ump_options() # Сначала интерфейс показывает только применимые к выбранному формату параметры. + self.refilter() # Таблица уникальных кадров сразу получает расшифровку с новой картой и устройством. + self.decoding_changed.emit() # Владелец обновляет онлайн, трассировку и сведения о ранее принятых кадрах. + + def decode(self, frame): # Все таблицы монитора разбирают кадры с одним набором выбранных параметров. + return decode_protocol(frame, self.protocol.currentData(), ump_map=self.ump_map.currentData(), ump_node=self.ump_node.currentData()) # Формат, карта и номер УМП передаются независимо, исключая расхождение онлайн и буфера. + def format_data(self, data): return format_data(data, self.data_format.isChecked()) @@ -73,13 +123,28 @@ class MonitorControls(QWidget): layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) row = QHBoxLayout() - row.addWidget(QLabel("Фильтр ID (hex)")) + self.filter_enabled = QCheckBox("Фильтр CAN ID (hex)") # Первый флажок управляет существующим отбором по идентификатору кадра. + self.filter_enabled.setChecked(True) # Сохраняем прежнее поведение: ввод CAN ID сразу фильтрует нижнюю таблицу. + row.addWidget(self.filter_enabled) # Флажок занимает место прежней подписи фильтра. self.filter = QLineEdit() self.filter.setPlaceholderText("701 или 0x701, 201") - self.filter.setToolTip("Фильтр только нижней таблицы; сверху отображаются все ID") + self.filter.setToolTip("CAN ID в HEX. Оба включённых фильтра работают по И; фильтруется только нижняя таблица, сверху остаются все кадры.") # Поясняем совместный отбор и сохраняем независимость основных таблиц. self.filter.setClearButtonEnabled(True) self.filter.textChanged.connect(self.refilter) row.addWidget(self.filter) + self.gas_filter_enabled = QCheckBox("Фильтр GAS (dec / 0xHEX)") # Второй флажок позволяет включить адреса GAS отдельно или совместно с CAN ID. + row.addWidget(self.gas_filter_enabled) # Размещаем новый критерий в той же строке рядом с существующим фильтром. + self.gas_filter = QLineEdit() # Поле содержит список адресов слов GAS, независимо от списка идентификаторов кадров. + self.gas_filter.setPlaceholderText("5000, 5127 или 0x5000") # Примеры различают десятичные назначенные ID и шестнадцатеричное локальное окно. + self.gas_filter.setToolTip("GAS ID: DEC либо HEX с 0x, через запятую. Учитываются все слова кадра. При включённых обоих фильтрах должны совпасть и CAN ID, и GAS ID.") # Объясняем формат и поиск адреса внутри многословной посылки. + self.gas_filter.setClearButtonEnabled(True) # Очистка временно убирает GAS-условие без изменения флажка CAN ID. + self.gas_filter.setEnabled(False) # Новый фильтр включается пользователем явно и не меняет прежний начальный экран. + self.gas_filter.textChanged.connect(self.refilter) # Изменение списка сразу пересчитывает строки и счётчики сохранённого буфера. + self.filter_enabled.toggled.connect(self.filter.setEnabled) # Выключенный CAN-критерий сохраняет введённый текст для повторного включения. + self.filter_enabled.toggled.connect(self.refilter) # Снятие флажка сразу убирает ограничение CAN ID. + self.gas_filter_enabled.toggled.connect(self.gas_filter.setEnabled) # Состояние поля показывает, применяется ли GAS-условие. + self.gas_filter_enabled.toggled.connect(self.refilter) # Каждый флажок независимо пересчитывает пересечение двух критериев. + row.addWidget(self.gas_filter) # Поле GAS расположено перед общим счётчиком совпавших кадров. self.count = QLabel("Кадров: 0") row.addWidget(self.count) layout.addLayout(row) @@ -100,8 +165,10 @@ class MonitorControls(QWidget): def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None): try: - self.session.append(identifier, data, extended, rtr, source, timestamp) + key = self.session.append(identifier, data, extended, rtr, source, timestamp) # Сессия возвращает точную идентичность кадра с учётом канала, STD/EXT, RTR и данных. + self._pending_frames[key] = None # Один ключ объединяет любое число повторов до следующего обновления экрана. except OSError as exc: + self._history_needs_reset = True # Ошибка могла возникнуть после изменения буфера, поэтому следующий такт заново сверит все строки. self.status.setText("Ошибка записи: " + str(exc)) self.session.stop() self.record.setText("● Запись") @@ -153,46 +220,68 @@ class MonitorControls(QWidget): if not self.owner._paused and self._dirty: self._dirty = False self.status.setText("Буфер: %d" % sum(self.session.counts.values())) - self.refilter() + self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи. - def refilter(self): - if not hasattr(self, "filter"): - return - try: - ids = parse_filter(self.filter.text()) - except ValueError as exc: - self.count.setText(str(exc)) - ids = set() - else: - count = sum(value for key, value in self.session.counts.items() - if ids is None or key in ids) - unique_count = sum(1 for values, _ in self.session.unique_frames.values() - if ids is None or int(values[3], 16) in ids) - self.count.setText("Кадров: %d; уникальных: %d" % (count, unique_count)) - self.history.horizontalHeaderItem(7).setText( - "Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") - self.history.setColumnHidden(8, self.raw.isChecked()) - self.history.setVisible(ids is not None) - if ids is None: - self.history.setRowCount(0) - return - frames = [(values, count) for values, count in self.session.unique_frames.values() - if int(values[3], 16) in ids] - self.history.setRowCount(len(frames)) - for row, (values, count) in enumerate(frames): - data = bytes.fromhex(values[6]) - summary = "" - if not self.raw.isChecked(): - frame = build_frame(int(values[3], 16), data, - ide=values[2] == "EXT", rtr=bool(values[4]), - to_can=values[1] == "TX") - summary = decode_protocol(frame, self.protocol.currentData()).summary - display = values[:6] + (count, self.format_data(data), summary) - for column, value in enumerate(display): - item = self.history.item(row, column) - if item is None: - self.history.setItem(row, column, QTableWidgetItem(str(value))) - elif item.text() != str(value): - item.setText(str(value)) - if column == 8: - self.history.item(row, column).setToolTip(summary) + def refilter(self): # Изменение фильтра, протокола или формата требует однократного пересчёта существующих строк. + self._update_history(True) # Полная перестройка применяется только по явному изменению настроек либо сбросу буфера. + + def _update_history(self, rebuild): # Таймер и настройки используют один путь отбора, но разный объём входных записей. + if not hasattr(self, "filter"): # При сборке панели сигналы могут прийти до создания нижней таблицы. + return # Отложим отображение до завершения построения элементов интерфейса. + error = None # Ошибка активного фильтра заменяет счётчики понятным сообщением и очищает выборку. + try: # Разбор коротких условий выполняется одинаково для полного и добавочного обновления. + ids = parse_filter(self.filter.text()) if self.filter_enabled.isChecked() else None # Текст выключенного CAN-фильтра не влияет на выборку и не вызывает ошибок ввода. + gas_ids = parse_gas_filter(self.gas_filter.text()) if self.gas_filter_enabled.isChecked() else None # Независимо разбираем только включённое условие адресов GAS. + except ValueError as exc: # Неверный ввод не должен оставлять на экране строки от прежнего корректного фильтра. + error = str(exc) # Сохраняем объяснение для итоговой подписи после очистки строк. + ids, gas_ids = set(), None # Пустое множество CAN ID даёт пустую видимую выборку. + filtered = ids is not None or gas_ids is not None # Нижняя таблица нужна лишь при наличии хотя бы одного непустого условия. + self.history.horizontalHeaderItem(7).setText("Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") # Заголовок соответствует выбранному представлению байтов. + self.history.setColumnHidden(8, self.raw.isChecked()) # Сырой режим скрывает расшифровку независимо от способа обновления. + self.history.setVisible(filtered) # Без ограничений сохраняем компактный вид основной таблицы. + updates = self.history.updatesEnabled() # Запоминаем исходный режим отрисовки, чтобы не включать её вопреки владельцу. + self.history.setUpdatesEnabled(False) # Пачка изменений не должна вызывать промежуточную перерисовку и перерасчёт геометрии. + blocked = self.history.blockSignals(True) # Программное заполнение не должно вызывать обработчики отдельных изменённых ячеек. + try: # Восстановление состояния Qt гарантируется также при ошибке декодирования. + if rebuild or not filtered or error is not None: # Смена условий и недопустимый ввод отменяют прежнее соответствие ключей строкам. + self.history.setRowCount(0) # Полная очистка выполняется по настройке, а не каждые 250 мс при приёме. + self._history_rows.clear() # Индексы строк больше не относятся к новой выборке. + self._history_total = 0 # Сумма повторов пересчитывается для нового набора подходящих кадров. + if filtered and error is None: # Скрытая таблица и некорректные условия не требуют фильтрации или декодирования истории. + keys = self.session.unique_frames if rebuild else self._pending_frames # Только явная перестройка перебирает всю сессию; таймер берёт изменённые ключи. + for key in keys: # Словари сохраняют порядок первого появления уникальных кадров, даже если между ними пришли повторы. + values, count = self.session.unique_frames[key] # Получаем последние время и счётчик с учётом всех повторов в текущей пачке. + previous = self._history_rows.get(key) # Уже показанный кадр прошёл неизменные фильтры и не нуждается в повторной расшифровке. + if previous is not None: # У повторов меняются только время последнего приёма и число физических кадров. + row, old_count = previous # Сохраняем прежнюю сумму, чтобы добавить только новые приёмы. + display = ((0, values[0]), (6, count)) # Колонки 0 и 6 содержат время и счётчик, остальные данные идентичны по ключу. + else: # Новый ключ необходимо проверить по обоим активным условиям. + data = bytes.fromhex(values[6]) # Данные нужны для GAS-отбора и однократной расшифровки новой строки. + if not matches_filters(int(values[3], 16), data, values[2] == "EXT", bool(values[4]), can_ids=ids, gas_ids=gas_ids): # Проверяем пересечение CAN/GAS с учётом формата и RTR. + continue # Неподходящий кадр остаётся в полной сессии для сохранения и последующей смены фильтров. + row, old_count = self.history.rowCount(), 0 # Новая уникальная посылка добавляется в конец с её полным счётчиком с начала сессии. + self.history.insertRow(row) # Существующие строки и выделение не пересоздаются при поступлении новой посылки. + summary = "" # В режиме без парса не вызываем декодер даже при первом отображении кадра. + if not self.raw.isChecked(): # Расшифровка вычисляется только для новой строки либо при явной перестройке настроек. + frame = build_frame(int(values[3], 16), data, ide=values[2] == "EXT", rtr=bool(values[4]), to_can=values[1] == "TX") # Восстанавливаем исходную метаинформацию сохранённой посылки. + summary = self.decode(frame).summary # Используем выбранные протокол, карту адресов и номер устройства. + display = enumerate(values[:6] + (count, self.format_data(data), summary)) # Новая строка заполняется целиком в порядке заголовков таблицы. + for column, value in display: # У повторов этот цикл касается двух ячеек вместо всей накопленной таблицы. + item = self.history.item(row, column) # Повторно используем существующий объект ячейки для сохранения выделения. + if item is None: # Ячейки создаются только при добавлении новой уникальной посылки. + item = QTableWidgetItem() # Пустой объект получает текст общим путём ниже. + self.history.setItem(row, column, item) # Таблица принимает владение созданной ячейкой. + if item.text() != str(value): # Не уведомляем модель Qt о фактически неизменившемся значении. + item.setText(str(value)) # Преобразование сохраняет прежнее отображение чисел и меток времени. + if column == 8: # Последний столбец содержит длинную расшифровку, которую удобно читать в подсказке. + item.setToolTip(str(value)) # Подсказка обновляется вместе с новой расшифровкой при смене настроек. + self._history_rows[key] = (row, count) # Запоминаем актуальные индекс и показанный счётчик для следующей пачки повторов. + self._history_total += count - old_count # Добавляем только новые физические приёмы без суммирования всей истории. + self._pending_frames.clear() # Все поступившие ключи учтены либо скрыты неизменными фильтрами. + self._history_needs_reset = False # Сверка после ошибки успешно завершена и больше не требует полного обхода. + total = self._history_total if filtered else sum(self.session.counts.values()) # Без фильтра подпись относится ко всей сессии, хотя сама таблица скрыта. + unique = len(self._history_rows) if filtered else len(self.session.unique_frames) # Число уникальных кадров берём из готового индекса без повторного отбора. + self.count.setText(error if error is not None else "Кадров: %d; уникальных: %d" % (total, unique)) # При ошибке оставляем причину, иначе показываем согласованные счётчики выборки. + finally: # Любой выход должен вернуть исходное поведение сигналов и перерисовки Qt. + self.history.blockSignals(blocked) # Восстанавливаем состояние, установленное владельцем до обновления. + self.history.setUpdatesEnabled(updates) # Готовая пачка отображается за одно обновление виджета. diff --git a/python/set_devices/qt_ports/candle_adapter.py b/python/set_devices/qt_ports/candle_adapter.py index 76e81b0..1681787 100644 --- a/python/set_devices/qt_ports/candle_adapter.py +++ b/python/set_devices/qt_ports/candle_adapter.py @@ -12,13 +12,15 @@ from __future__ import annotations import ctypes as ct import os -from dataclasses import dataclass +from dataclasses import dataclass, field # Имя продукта хранится рядом с адресом канала, но не меняет идентичность физического подключения. from pathlib import Path import sys import threading import re from .qt_compat import QObject, Signal +from ..can_adapter_settings import CandleOptions, CanTiming, FD_LENGTHS, calculate_timing # Общая модель проверяет флаги, DLC и аппаратные пределы тайминга. +from collections import deque # Ограниченный журнал FD сохраняет кадры даже при закрытом окне диагностики. _EXTENDED_ID = 0x80000000 @@ -36,12 +38,13 @@ class CandleError(RuntimeError): class CandleChannel: path: str channel: int + product: str = field(default="", compare=False) # Настоящая USB-строка продукта; смена подписи не создаёт другое подключение с тем же путём и каналом. @property def label(self) -> str: lower = self.path.lower() - product = "CANnectivity" if "vid_1209&pid_ca01" in lower else "candle" - return f"{product} — канал {self.channel}" + product = self.product.strip() or ("CANnectivity" if "vid_1209&pid_ca01" in lower else "candle") # Предпочитает имя из прошивки, сохраняя прежние подписи при отсутствии USB-строки или старой DLL. + return f"{product} — канал {self.channel}" # Показывает пользовательский номер в имени продукта и независимый индекс CAN-канала. @property def adapter_kind(self) -> str: @@ -58,6 +61,23 @@ class _Frame(ct.Structure): ] +class _FdFrame(ct.Structure): # gs_usb сохраняет общий заголовок Classic и FD, расширяя только поле данных. + _pack_ = 1 # USB-пакет не содержит выравнивающих отступов компилятора. + _fields_ = _Frame._fields_[:-2] + [("data", ct.c_uint8 * 64), ("timestamp_us", ct.c_uint32)] # 64 байта — максимальная длина данных CAN FD. + + +class _Capability(ct.Structure): # Поля соответствуют candle_capability_t и ответу BT_CONST. + _fields_ = [(name, ct.c_uint32) for name in ("feature", "fclk_can", "tseg1_min", "tseg1_max", "tseg2_min", "tseg2_max", "sjw_max", "brp_min", "brp_max", "brp_inc")] # Частота измеряется в герцах, сегменты — в квантах CAN. + + +class _Timing(ct.Structure): # Структура передаёт пять параметров в управляющий endpoint USB. + _fields_ = [(name, ct.c_uint32) for name in ("prop_seg", "phase_seg1", "phase_seg2", "sjw", "brp")] # Порядок совпадает с публичным C ABI. + + +def _timing(value: CanTiming) -> _Timing: # Преобразуем проверенную переносимую модель в ctypes без зависимости модели от Windows. + return _Timing(value.prop_seg, value.phase_seg1, value.phase_seg2, value.sjw, value.brp) # Передаём сегменты и делитель без округления. + + def _library_path() -> Path: explicit = os.environ.get('CANDLE_LIBRARY') if explicit: @@ -83,6 +103,8 @@ class _Api: self._fn("candle_list_length", [handle, ct.POINTER(ct.c_uint8)]) self._fn("candle_dev_get", [handle, ct.c_uint8, ct.POINTER(handle)]) self._fn("candle_dev_get_path", [handle], ct.c_wchar_p) + if hasattr(self.dll, "candle_dev_get_product"): # Поддерживает прежние версии библиотеки без функции чтения имени USB. + self._fn("candle_dev_get_product", [handle, ct.c_wchar_p, ct.c_uint32]) # Согласует выходной UTF-16-буфер и его размер в кодовых единицах с публичным C ABI. self._fn("candle_dev_open", [handle]) self._fn("candle_dev_close", [handle]) self._fn("candle_dev_free", [handle]) @@ -95,12 +117,32 @@ class _Api: self._fn("candle_frame_read", [handle, ct.POINTER(_Frame), ct.c_uint32]) self._fn("candle_frame_type", [ct.POINTER(_Frame)], ct.c_int) self._fn("candle_frame_id", [ct.POINTER(_Frame)], ct.c_uint32) + self._fn("candle_channel_get_capabilities", [handle, ct.c_uint8, ct.POINTER(_Capability)]) # Базовый API возможностей доступен и у прежней библиотеки. + self._fn("candle_channel_set_timing", [handle, ct.c_uint8, ct.POINTER(_Timing)]) # Ручной и рассчитанный тайминги используют один USB-запрос. + optional = {"candle_channel_start_configured": [handle, ct.c_uint8, ct.c_uint32], "candle_channel_get_data_capabilities": [handle, ct.c_uint8, ct.POINTER(_Capability)], "candle_channel_set_termination": [handle, ct.c_uint8, ct.c_uint32], "candle_channel_get_termination": [handle, ct.c_uint8, ct.POINTER(ct.c_uint32)], "candle_channel_get_diagnostics": [handle, ct.c_uint8, ct.POINTER(ct.c_uint32), ct.POINTER(ct.c_uint32), ct.POINTER(ct.c_uint32)], "candle_channel_bus_off_recover": [handle, ct.c_uint8], "candle_channel_set_data_timing": [handle, ct.c_uint8, ct.POINTER(_Timing)], "candle_fd_frame_send": [handle, ct.c_uint8, ct.POINTER(_FdFrame)], "candle_fd_frame_read": [handle, ct.POINTER(_FdFrame), ct.c_uint32], "candle_fd_frame_type": [ct.POINTER(_FdFrame)]} # Необязательные экспорты позволяют сообщить понятную ошибку старой DLL. + for name, arguments in optional.items(): # Регистрация выполняется один раз при загрузке библиотеки. + if hasattr(self.dll, name): # Отсутствующее расширение проверяется при его явном выборе. + self._fn(name, arguments, ct.c_int if name.endswith("frame_type") else ct.c_bool) # Тип кадра — enum, остальные функции возвращают bool. def _fn(self, name: str, args: list, result=ct.c_bool) -> None: function = getattr(self.dll, name) function.argtypes = args function.restype = result + def enter_dfu(self, path): # Открывает отдельный runtime-интерфейс выбранного адаптера через проверяемый экспорт DLL. + function = getattr(self.dll, "candle_dev_enter_dfu", None) # Старая библиотека не должна молча имитировать успешный переход. + if function is None: # Проверяет ABI до остановки действующего CAN-сеанса. + raise CandleError("Обновите candle.dll: переход в DFU недоступен") # Оставляет пользователю понятную причину отсутствующего расширения. + function.argtypes, function.restype = [ct.c_wchar_p, ct.POINTER(ct.c_uint8)], ct.c_uint32 # Передаёт точный USB-путь UTF-16, шестибайтовый статус и код Windows фиксированной ширины. + status = (ct.c_uint8 * 6)() # DFU 1.1 возвращает bStatus, три байта задержки, bState и iString. + error = function(path, status) # DLL выбирает интерфейс обновления по общему родителю с CAN-интерфейсом. + if error: # Отказ доступа, отсутствие интерфейса и потеря USB не являются подтверждением DFU. + detail = "Интерфейс обновления не найден: нужна прошивка CANable 2.5 / weCANdle и драйвер WinUSB для Firmware Update Interface" if error == 1168 else ct.FormatError(ct.c_int32(error).value).strip() # ERROR_NOT_FOUND равен 1168; знаковое представление сохраняет 32 бита кодов SetupAPI 0xE000xxxx без OverflowError в ctypes. + if error in (0xE000020B, 0xE000021C, 0xE0000225): # SetupAPI обозначает этими кодами отсутствующий узел, удалённый интерфейс или неизвестный путь USB. + detail = "Выбранный USB-адаптер больше не найден. Переподключите его и обновите список устройств." # Даёт понятный следующий шаг вместо отсутствующего системного описания SetupAPI. + raise CandleError(f"Не удалось подтвердить переход в DFU: {detail}") # Не предлагает записывать flash до обнаружения настоящего загрузчика. + return bytes(status) # Сохраняет точные поля ответа прошивки для проверки протокола вызывающим методом. + def scan(self) -> list[CandleChannel]: result: list[CandleChannel] = [] physical_devices: set[str] = set() @@ -128,8 +170,8 @@ class _Api: key = re.sub(r"&[0-9a-f]{4}(?=#\{)", "", key) if key not in physical_devices: physical_devices.add(key) - result.extend(CandleChannel(path, channel) - for channel in range(channels.value)) + product = self.product_name(device) # Читает имя один раз для всех каналов уже открытого физического USB-адаптера. + result.extend(CandleChannel(path, channel, product) for channel in range(channels.value)) # Передаёт настоящую подпись общим спискам снифера, терминала и прошивки. self.dll.candle_dev_close(device) finally: self.dll.candle_dev_free(device) @@ -137,6 +179,12 @@ class _Api: self.dll.candle_list_free(listing) return result + def product_name(self, device): # Читает отображаемое имя устройства без изменения его USB-пути и состояния CAN. + function = getattr(self.dll, "candle_dev_get_product", None) # Не требует нового экспорта от старой совместимой библиотеки. + if function is None: return "" # Отсутствие функции включает существующую резервную подпись вместо отказа сканирования. + product = ct.create_unicode_buffer(128) # USB-строка содержит максимум 126 кодовых единиц UTF-16 плюс завершающий ноль. + return product.value.strip() if function(device, product, len(product)) else "" # Не выдаёт пустой или неудачно прочитанный дескриптор за новое имя адаптера. + def acquire(self, wanted_path: str) -> ct.c_void_p: listing = ct.c_void_p() if not self.dll.candle_list_scan(ct.byref(listing)): @@ -165,6 +213,7 @@ class CandleAdapter(QObject): transmit_echo = Signal(int, bytes, bool, bool) bus_error = Signal(int, bytes, int) connection_lost = Signal(str) + fd_received = Signal(int, bytes, bool, int, int) # FD получает отдельный маршрут: ID, данные, EXT, флаги gs_usb и аппаратная метка в микросекундах. def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -175,6 +224,10 @@ class CandleAdapter(QObject): self._reader: threading.Thread | None = None self._send_lock = threading.Lock() self._listen_only = False + self.options = CandleOptions() # Настройки сеанса не меняются после запуска канала. + self.capabilities = None # Реальная маска и пределы становятся известны после открытия устройства. + self.nominal_timing, self.fd_timing = None, None # Диагностика покажет фактические сегменты и достигнутую точку выборки. + self.fd_history = deque(maxlen=500) # Последние 500 FD-кадров доступны в окне диагностики без неограниченного роста памяти. def _get_api(self) -> _Api: if self._api is None: @@ -189,18 +242,37 @@ class CandleAdapter(QObject): return self._device is not None def connect_channel(self, channel: CandleChannel, bitrate: int, - listen_only: bool = False) -> None: + listen_only: bool = False, *, options: CandleOptions | None = None) -> None: # Необязательный объект сохраняет совместимость прежних вызовов. self.disconnect_channel() + selected = options or CandleOptions() # При отсутствии расширенных настроек сохраняются обычный режим и автоматические метки. api = self._get_api() device = api.acquire(channel.path) try: if not api.dll.candle_dev_open(device): raise CandleError("Не удалось открыть candle-адаптер") - if not api.dll.candle_channel_set_bitrate(device, channel.channel, bitrate): - raise CandleError(f"Адаптер не поддерживает {bitrate} бит/с") - mode = _MODE_LISTEN_ONLY if listen_only else 0 - if not api.dll.candle_channel_start(device, channel.channel, mode): - raise CandleError("Не удалось запустить CAN-канал") + cap = _Capability() # У каждого канала могут быть собственные ограничения контроллера. + self._check(api.dll.candle_channel_get_capabilities(device, channel.channel, ct.byref(cap)), "прочитать возможности канала") # Отсутствие ответа не считается отсутствием всех возможностей. + mode = selected.flags(listen_only, cap.feature) # Неподдерживаемый явный флаг останавливает подключение до настройки шины. + nominal = selected.timing or calculate_timing(cap, bitrate, selected.sample_point) # Автоматический расчёт поддерживает частоты, отличные от 48 МГц. + nominal.validate(cap, bitrate) # Ручной тайминг также обязан совпадать с выбранной скоростью. + data = None # Отсутствие FD оставляет быструю фазу неподключённой и не показывает фиктивный тайминг. + timing = _timing(nominal) # ctypes-объект живёт до завершения синхронного USB-запроса. + self._check(api.dll.candle_channel_set_timing(device, channel.channel, ct.byref(timing)), "установить номинальный тайминг") # Скорость не округляется по старой таблице фиксированных делителей. + if selected.fd: # До запуска FD настраиваем и проверяем быструю фазу. + for name in ("candle_channel_get_data_capabilities", "candle_channel_set_data_timing", "candle_fd_frame_read", "candle_fd_frame_send", "candle_fd_frame_type"): # Полноценная работа требует и чтения, и записи FD. + self._require(name) # Устаревшая DLL выдаёт явную ошибку вместо обрезания 64-байтовых кадров. + data_cap = _Capability() # Расширенный запрос возвращает отдельные пределы фазы данных. + self._check(api.dll.candle_channel_get_data_capabilities(device, channel.channel, ct.byref(data_cap)), "прочитать пределы CAN FD") # Без пределов нельзя безопасно рассчитать быструю фазу. + data = selected.data_timing or calculate_timing(data_cap, selected.data_bitrate, selected.data_sample_point) # Быстрая фаза получает собственную скорость и точку выборки. + data.validate(data_cap, selected.data_bitrate) # Проверяем ручной тайминг по ограничениям FD, а не номинальной фазы. + data_timing = _timing(data) # Подготавливаем структуру стандартного DATA_BITTIMING. + self._check(api.dll.candle_channel_set_data_timing(device, channel.channel, ct.byref(data_timing)), "установить тайминг CAN FD") # Канал не запускается при отказе быстрой фазы. + if selected.termination is not None: # Состояние терминатора меняется только по явному выбору пользователя. + self._check(self._require("candle_channel_set_termination")(device, channel.channel, int(selected.termination)), "переключить терминатор") # Проверенная возможность исключает попытку управления внешним резистором. + starter = getattr(api.dll, "candle_channel_start_configured", None) # Новый экспорт не добавляет HW_TIMESTAMP к точной маске настроек. + if starter is None and selected.hardware_timestamp is False: # Старый start автоматически включает аппаратные метки. + raise CandleError("Для отключения аппаратных меток обновите candle.dll") # Не показываем настройку выполненной, когда старая DLL её отменяет. + self._check((starter or api.dll.candle_channel_start)(device, channel.channel, mode), "запустить CAN-канал") # Только подтверждённый запуск означает успешное подключение. except Exception: api.dll.candle_dev_close(device) api.dll.candle_dev_free(device) @@ -208,6 +280,10 @@ class CandleAdapter(QObject): self._device = device self._channel = channel.channel self._listen_only = listen_only + self.options = selected # Читатель выбирает размер структуры по реально запущенному режиму. + self.capabilities = cap # Диагностика показывает фактическую маску функций и частоту канала. + self.nominal_timing, self.fd_timing = nominal, data # Сохраняем действительно переданные сегменты, а не только желаемый процент выборки. + self.fd_history.clear() # Кадры предыдущего устройства не смешиваются с новым сеансом. self._stop.clear() self._reader = threading.Thread(target=self._read_loop, name="candle-rx", daemon=True) @@ -227,6 +303,29 @@ class CandleAdapter(QObject): self._reader = None self._listen_only = False + def enter_dfu_mode(self): # Останавливает текущий CAN-сеанс перед обращением к отдельному USB-интерфейсу загрузчика. + api = self._get_api() # Использует уже загруженную библиотеку транспорта, не создавая второго владельца CAN. + if not hasattr(api.dll, "candle_dev_enter_dfu"): # Не закрывает рабочий канал при заведомо несовместимой версии DLL. + raise CandleError("Обновите candle.dll: переход в DFU недоступен") # Объясняет необходимость обновления встроенной библиотеки SETGUI. + with self._send_lock: # Запрещает одновременную передачу кадра во время остановки и освобождения USB. + if not self.connected: # Команда должна относиться к конкретному подключённому адаптеру пользователя. + raise CandleError("Сначала подключите Candle / weCANdle-адаптер") # Не выбирает первый найденный USB-адаптер автоматически. + path = api.dll.candle_dev_get_path(self._device) # Копирует точный путь до освобождения нативного handle устройства. + if not path: # Пустой путь не позволяет надёжно выбрать физический интерфейс обновления. + raise CandleError("Не удалось определить USB-путь выбранного адаптера") # Сохраняет подключение при невозможности адресовать команду. + self.disconnect_channel() # Останавливает RX-поток, сбрасывает канал CAN и освобождает WinUSB-сеанс до DETACH. + self.connection_lost.emit("CAN-сеанс остановлен для перехода адаптера в DFU") # Обновляет владельца соединения и отменяет его пинг, обмен и отправку кадров. + status = api.enter_dfu(path) # Отдельный управляющий сеанс не использует освобождённый CAN-handle. + if len(status) != 6: # Неполный ответ не должен выглядеть как подтверждённый статус DFU. + raise CandleError("Неполный ответ DFU_GETSTATUS") # Защищает разбор полей при несовместимой обёртке DLL. + if status[0] or status[4] == 10: # bStatus ненулевой либо bState=dfuERROR по DFU 1.1 означают отказ прошивки. + raise CandleError(f"Прошивка отклонила DFU: статус {status[0]}, код {status[5]}") # iString передаёт дополнительный код ошибки CANable 2.5. + if status[4] == 1: # appDETACH означает необходимость аппаратного сброса после восстановления настройки BOOT0. + return "Для входа в DFU отключите и снова подключите USB-кабель адаптера." # Не утверждает, что устройство уже появилось в загрузчике. + if status[4] != 0: # CANable 2.5 подтверждает отложенный переход только состоянием appIDLE. + raise CandleError(f"Неожиданное состояние DFU: {status[4]}") # Не принимает неизвестное состояние за успешную команду. + return "Команда входа в DFU принята. Откройте STM32CubeProgrammer → USB и обновите список устройств." # Отделяет подтверждение команды от фактического обнаружения ROM-загрузчика. + def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None: device = self._device if device is None: @@ -243,34 +342,92 @@ class CandleAdapter(QObject): frame.channel = self._channel frame.data[:len(data)] = data with self._send_lock: + if self._device is not device: # Переход в DFU мог освободить handle, пока отправитель ожидал блокировку передачи. + raise CandleError("CAN-сеанс уже остановлен") # Не передаёт сохранённый указатель в DLL после закрытия USB-адаптера. if not self._get_api().dll.candle_frame_send( device, self._channel, ct.byref(frame)): raise CandleError("Candle API не передал CAN-кадр") + def _require(self, name): # Проверяем необязательный экспорт непосредственно перед использованием расширения. + function = getattr(self._get_api().dll, name, None) # Имя берётся из внутреннего списка функций, а не от пользователя. + if function is None: # Старый бинарный файл не должен приводить к необработанному AttributeError. + raise CandleError(f"Обновите candle.dll: отсутствует {name}") # Указываем конкретное несовместимое расширение. + return function # Возвращаем функцию с уже зарегистрированной сигнатурой ctypes. + + @staticmethod # Проверка результата не зависит от состояния Qt-объекта. + def _check(result, action): # Все USB-настройки требуют подтверждённого результата bool. + if not result: # false означает, что устройство не приняло настройку или запрос завершился ошибкой. + raise CandleError(f"Candle: не удалось {action}") # Контекст этапа помогает исправить выбранную конфигурацию. + + def diagnostics(self) -> str: # Чтение выполняется только у уже открытого канала без повторного захвата устройства. + if not self.connected: # Опрос не должен неявно подключать CAN или менять выбранный режим. + raise CandleError("Сначала подключите адаптер") # Пользователь явно управляет жизненным циклом соединения. + cap = self.capabilities # Используем сохранённый при подключении ответ BT_CONST. + lines = [f"Возможности: 0x{cap.feature:08X}; частота CAN: {cap.fclk_can} Гц", f"TSEG1 {cap.tseg1_min}…{cap.tseg1_max}; TSEG2 {cap.tseg2_min}…{cap.tseg2_max}; SJW ≤ {cap.sjw_max}; BRP {cap.brp_min}…{cap.brp_max}, шаг {cap.brp_inc}"] # Единицы и пределы показываются вместе с маской. + for name, value in (("Номинальная фаза", self.nominal_timing), ("Данные FD", self.fd_timing)): # Обе фазы могут иметь разные достижимые точки выборки. + if value is not None: # Classic-сеанс не должен показывать параметры неиспользуемой быстрой фазы. + total = 1 + value.prop_seg + value.phase_seg1 + value.phase_seg2 # Полное число квантов в бите включает SYNC_SEG. + lines.append(f"{name}: {cap.fclk_can / value.brp / total:g} бит/с, выборка {(total - value.phase_seg2) / total * 100:.2f} %, PROP/PH1/PH2/SJW/BRP = {value.prop_seg}/{value.phase_seg1}/{value.phase_seg2}/{value.sjw}/{value.brp}") # Показываем фактически настроенную скорость и точку вместо обещания недостижимого точного процента. + if cap.feature & 8192: # GET_STATE объявляется битом 13 протокола gs_usb. + state, rx, tx = ct.c_uint32(), ct.c_uint32(), ct.c_uint32() # Все три значения относятся к одному ответу контроллера. + self._check(self._require("candle_channel_get_diagnostics")(self._device, self._channel, ct.byref(state), ct.byref(rx), ct.byref(tx)), "прочитать состояние") # Не подменяем отказ нулевыми счётчиками. + names = ("Error-active", "Error-warning", "Error-passive", "Bus-off", "Stopped", "Sleeping") # Стандартные состояния контроллера CAN согласно gs_usb. + lines.append(f"Состояние: {names[state.value] if state.value < len(names) else state.value}; RX errors: {rx.value}; TX errors: {tx.value}") # Показываем счётчики ошибок отдельно от числа принятых кадров. + if cap.feature & 2048: # Только аппаратно переключаемый терминатор имеет читаемое состояние. + state = ct.c_uint32() # Нулевое значение означает OFF, единица — ON. + self._check(self._require("candle_channel_get_termination")(self._device, self._channel, ct.byref(state)), "прочитать терминатор") # GET_TERMINATION не меняет сопротивление на шине. + lines.append("Терминатор: " + ("120 Ом" if state.value else "выключен")) # Указываем физический смысл переключателя. + return "\n".join(lines) # Текст пригоден для диагностики и копирования без дополнительных файлов. + + def recover_bus_off(self) -> None: # Восстановление выполняется только по явной кнопке диагностики. + if not self.connected or not self.capabilities.feature & 0x40000: # Расширение BUS_OFF_RECOVERY не является универсальной функцией gs_usb. + raise CandleError("Адаптер не объявил поддержку восстановления Bus-off") # Не отправляем неизвестный vendor-запрос старой прошивке. + self._check(self._require("candle_channel_bus_off_recover")(self._device, self._channel), "восстановить Bus-off") # Результат определяется реальным ответом устройства. + + def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Отдельный API не меняет семантику Classic CAN send. + if not self.connected or self._listen_only or not self.options.fd: # Передача требует активного канала с настроенной быстрой фазой. + raise CandleError("Передача FD требует активного подключения CAN FD") # Пассивный режим защищён и при прямом вызове API. + if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Стандарт FD допускает только длины из таблицы DLC. + raise CandleError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Не дополняем незаметно пользовательский пакет. + frame = _FdFrame() # Полная структура предотвращает выход за пределы восьмибайтового буфера Classic. + frame.can_id = can_id | (_EXTENDED_ID if extended else 0) # Старший бит указывает расширенный 29-битный идентификатор. + frame.can_dlc, frame.flags = FD_LENGTHS.index(len(data)), 2 | (4 if brs else 0) # Биты 1 и 2 означают FD и переключение скорости BRS. + frame.data[:len(data)] = data # Остаток буфера обнулён конструктором ctypes. + with self._send_lock: # Classic и FD используют один USB OUT endpoint и один native event. + self._check(self._require("candle_fd_frame_send")(self._device, self._channel, ct.byref(frame)), "передать CAN FD") # Ошибка записи остаётся видимой вызывающему коду. + def _read_loop(self) -> None: device = self._device if device is None: return api = self._get_api() while not self._stop.is_set() and self._device is device: - frame = _Frame() - if not api.dll.candle_frame_read(device, ct.byref(frame), 50): + frame = _FdFrame() if self.options.fd else _Frame() # В FD-сеансе библиотека возвращает оба формата в безопасной структуре на 64 байта. + reader = api.dll.candle_fd_frame_read if self.options.fd else api.dll.candle_frame_read # Выбор функции согласован с размером выходного буфера. + if not reader(device, ct.byref(frame), 50): # 50 мс ограничивает задержку остановки фонового потока. continue if frame.channel != self._channel: continue - kind = api.dll.candle_frame_type(ct.byref(frame)) + kind = (api.dll.candle_fd_frame_type if self.options.fd else api.dll.candle_frame_type)(ct.byref(frame)) # ctypes не смешивает указатели на разные структуры. + if frame.flags & 2: # FD даже с короткими данными нельзя передавать декодерам как Classic CAN. + if frame.can_dlc > 15: # DLC имеет только четыре значащих бита согласно CAN FD. + continue # Повреждённый пакет не должен индексировать таблицу длин вне диапазона. + record = (frame.can_id & 0x1FFFFFFF, bytes(frame.data[:FD_LENGTHS[frame.can_dlc]]), bool(frame.can_id & _EXTENDED_ID), frame.flags, frame.timestamp_us) # Сохраняем полный payload и исходную метку в микросекундах. + self.fd_history.append(("TX echo" if kind == _FRAME_ECHO else "RX", record)) # Журнал явно отличает эхо от получения кадра с шины. + self.fd_received.emit(*record) # Просмотр FD использует самостоятельный маршрут и не попадает в мост RS485. + continue # Короткий FD-кадр также не маскируется под Classic. size = min(frame.can_dlc, 8) data = bytes(frame.data[:size]) if kind == _FRAME_ERROR or frame.flags & 0x01: self.bus_error.emit(frame.can_id & 0x1FFFFFFF if kind == _FRAME_ERROR else 0, data, frame.flags) if kind == _FRAME_ECHO: - self.transmit_echo.emit(api.dll.candle_frame_id(ct.byref(frame)), data, + self.transmit_echo.emit(frame.can_id & 0x1FFFFFFF, data, # Маска выделяет идентификатор без приведения FD-структуры к указателю Classic. bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000)) elif kind == _FRAME_RECEIVE: - self.capture_received.emit(api.dll.candle_frame_id(ct.byref(frame)), + self.capture_received.emit(frame.can_id & 0x1FFFFFFF, # Сохраняем STD/EXT ID независимо от выбранной структуры приёма. data, bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000)) - self.frame_received.emit(api.dll.candle_frame_id(ct.byref(frame)), + self.frame_received.emit(frame.can_id & 0x1FFFFFFF, # Совместимый сигнал получает только Classic-кадры, обработанные выше. data) def close(self) -> None: diff --git a/python/set_devices/qt_ports/circuit_parameters.py b/python/set_devices/qt_ports/circuit_parameters.py new file mode 100644 index 0000000..b659a8b --- /dev/null +++ b/python/set_devices/qt_ports/circuit_parameters.py @@ -0,0 +1,33 @@ +"""Необязательные известные элементы последовательного эквивалента RLC.""" # Ввод не смешивает номиналы реального RC-снаббера с параметрами идентифицируемых полюсов. +try: # Поддерживаем современные и совместимые Windows-сборки. + from PySide6.QtCore import Signal # Одно изменение любого элемента делает старый подбор моделей неактуальным. + from PySide6.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Используем стандартные числовые поля Qt6. +except ImportError: # Qt5 получает тот же интерфейс без дополнительных зависимостей. + from PySide2.QtCore import Signal # Контракт сигналов одинаков на Windows 7 и новых системах. + from PySide2.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Набор полей и единицы не зависят от версии Qt. + + +class CircuitParameters(QWidget): # Компонент можно встроить в подробные параметры любого вида сравнения. + changed = Signal() # Владелец решает, запускать ли автоматический пересчёт или ожидать кнопку. + + def __init__(self, parent=None): # По умолчанию ни один электрический элемент не предполагается известным. + super().__init__(parent) # Жизненным циклом компонента управляет родительская панель сравнения. + row = QHBoxLayout(self) # Три поля и пояснение занимают одну строку. + row.setContentsMargins(0, 0, 0, 0) # Сохраняем существующие отступы формы. + row.addWidget(QLabel('Эквивалентный последовательный RLC:')) # Явно называем топологию, к которой относятся формулы R/L и LC. + self.inductance, self.capacitance, self.resistance = QDoubleSpinBox(), QDoubleSpinBox(), QDoubleSpinBox() # Отдельное неизвестное значение разрешено у каждого элемента. + self.controls = (self.inductance, self.capacitance, self.resistance) # Общий список позволяет блокировать ввод во время рабочего задания. + for title, box, suffix in (('L', self.inductance, ' нГн'), ('C', self.capacitance, ' нФ'), ('R', self.resistance, ' Ом')): # Удобные единицы соответствуют обычным масштабам коммутационного контура. + row.addWidget(QLabel(title)) # Подпись отделяет физическую величину от единицы в поле. + box.setDecimals(6) # Дробные нГн, нФ и Ом сохраняются без грубого округления. + box.setRange(0., 1e12) # Ноль означает неизвестное значение; отрицательные пассивные элементы не допускаются. + box.setSpecialValueText('неизвестно') # Не скрываем отсутствие исходных электрических данных за произвольным номиналом. + box.setSuffix(suffix) # Единица видна при вводе любого положительного значения. + box.setMaximumWidth(170) # Ширина в логических пикселях оставляет место всем трём элементам. + box.setToolTip('Введите известный элемент последовательного эквивалента. Ноль — неизвестно. Это не автоматическая модель топологии RC-снаббера.') # Объяснение предотвращает неверную интерпретацию восстановленных номиналов. + box.valueChanged.connect(lambda _value: self.changed.emit()) # Любое изменение требует нового численного подбора и отчёта. + row.addWidget(box) # Поля располагаются в порядке L, C и R. + row.addStretch(1) # Свободная ширина не растягивает числовые поля. + + def values(self): # Снимок значений фиксируется до запуска фоновой идентификации. + return dict(inductance_h=self.inductance.value() * 1e-9, capacitance_f=self.capacitance.value() * 1e-9, resistance_ohm=self.resistance.value()) # нГн и нФ переводятся в СИ; Ом уже соответствует внутренним формулам. diff --git a/python/set_devices/qt_ports/console.py b/python/set_devices/qt_ports/console.py index a2efe2b..d223377 100644 --- a/python/set_devices/qt_ports/console.py +++ b/python/set_devices/qt_ports/console.py @@ -2,6 +2,8 @@ from collections import deque # Кольцевая история ограничивает память при непрерывном обмене. from html import escape # Текст устройства не должен становиться HTML-разметкой журнала. from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8. +import csv # Стандартный CSV корректно экранирует кавычки, разделители и пустые строки журнала. +import json # JSON сохраняет строки с кириллицей без потери символов и дополнительного разбора протоколов. import re # Метки направления извлекаются только из начала строки после необязательного времени. try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7. from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала. @@ -48,12 +50,12 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк. self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории. filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли. - self.show_tx, self.show_rx = QCheckBox("TX"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению. + self.show_tx, self.show_rx = QCheckBox("TX", self.panel), QCheckBox("RX", self.panel) # Родитель назначается до setVisible, чтобы фильтры направлений не появлялись отдельными окнами при сборке консоли. for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории. control.setChecked(True) # Первое открытие показывает весь обмен. control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы. filters.addWidget(control) # Направления расположены рядом с текстовым фильтром. - self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload. + self.display = QComboBox(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами. self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются. self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки. self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload. @@ -103,7 +105,6 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления. self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок. self.records.clear() # Сохраняется только актуальный результат форматирования потребителя. - if not self.pause.isChecked(): super().clear() # При паузе прежний экран сохраняется до возобновления. if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись. self.refresh() # Поиск и фильтры применяются к новому снимку целиком. @@ -124,6 +125,7 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки. text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением. bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа. + horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Автопрокрутка по вертикали не должна сбрасывать просмотр длинной строки по горизонтали. mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем. if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку. hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами. @@ -132,25 +134,47 @@ class ConsoleView(QPlainTextEdit): # Наследование сохраняе elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения. else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML. bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения. + horizontal.setValue(offset) # Дописывание строки сохраняет выбранный горизонтальный участок журнала. def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст. self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение. if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия. bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа. + horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Фильтрация и пакетное обновление не должны возвращать длинные строки к левому краю. super().clear() # Очищаем только документ, сохраняя ограниченную историю. for record in self.records: # Показываем оставшиеся записи в исходном порядке. if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра. bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном. + horizontal.setValue(offset) # Qt ограничивает прежнее смещение текущей шириной документа после перестроения. self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи. def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра. QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения. - def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога. - Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов. + def save_to(self, path, file_format=None): # Явный формат используется диалогом, а прямой вызов определяет формат по расширению файла. + destination = Path(path) # Путь может поступить строкой от Qt или объектом Path от вызывающего кода. + file_format = (file_format or destination.suffix.lstrip(".") or "txt").lower() # Имена расширений нечувствительны к регистру; без расширения сохраняется прежний TXT. + if file_format not in ("csv", "json", "txt"): # Не допускаем сохранения текста под видом неподдерживаемого формата. + raise ValueError("Выберите формат CSV, JSON или TXT") # Ошибка доступна как прямому вызывающему коду, так и диалогу. + text = self.toPlainText() # Единый снимок учитывает фильтры, формат байтов и замороженный на паузе экран. + rows = [{"line": index, "text": line} for index, line in enumerate(text.split("\n"), 1)] if text else [] # Нумерация с единицы сохраняет порядок и пустые строки расшифровки. + if file_format == "csv": # Табличное представление содержит номер строки и её полный текст. + with destination.open("w", encoding="utf-8-sig", newline="") as stream: # BOM помогает Excel распознать кириллицу; newline исключает лишние пустые строки в Windows. + writer = csv.DictWriter(stream, fieldnames=("line", "text")) # Стабильные имена столбцов позволяют обрабатывать журналы скриптами. + writer.writeheader() # Даже пустой журнал получает корректную схему CSV. + writer.writerows(rows) # Стандартный писатель экранирует кавычки и запятые без изменения содержимого. + elif file_format == "json": # Массив объектов сохраняет ту же схему строк, что CSV. + destination.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Читаемый UTF-8 сохраняет кириллицу и валидный пустой массив. + else: # TXT остаётся точной текстовой копией показанного журнала. + destination.write_text(text, encoding="utf-8") # Прежний контракт сохранения TXT не добавляет заголовки или номера строк. def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала. - path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене. + filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым. + path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса. if path: # Записываем только явно выбранный файл. - try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных. - except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору. + file_format = Path(path).suffix.lstrip(".").lower() # Явно введённое поддерживаемое расширение имеет приоритет над фильтром диалога. + if file_format not in ("csv", "json", "txt"): # Если расширение отсутствует или неизвестно, применяем выбранный формат. + file_format = "json" if "*.json" in selected_filter else "txt" if "*.txt" in selected_filter else "csv" # При отсутствии фильтра Qt используется первый предложенный формат CSV. + path += "." + file_format # Итоговое расширение всегда соответствует фактически записанному формату. + try: self.save_to(path, file_format) # Обрабатываем отказ файловой системы без прерывания приёма данных. + except (OSError, ValueError) as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору. diff --git a/python/set_devices/qt_ports/plot_drag.py b/python/set_devices/qt_ports/plot_drag.py index da1f936..88c8e00 100644 --- a/python/set_devices/qt_ports/plot_drag.py +++ b/python/set_devices/qt_ports/plot_drag.py @@ -14,7 +14,7 @@ except ImportError: DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — ' - 'перемещение по X; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. ' + 'перемещение по X; Ctrl + ЛКМ — перемещение по Y; Shift + ЛКМ — по Y со стороны начала жеста. ' # Для двух шкал Shift выбирает сторону в точке нажатия и сохраняет её до отпускания кнопки. 'Щелчок мимо маркеров — одиночный курсор.') @@ -34,7 +34,7 @@ def cursor_hit(plot, position, value, event, radius=8): class PlotDragInteraction: """Capture a hit marker or pan one axis, chosen at the start of the drag.""" - def _capture_y_pan(self): + def _capture_y_pan(self, event=None): # Необязательное событие нажатия позволяет владельцу двух шкал выбрать одну Y; клавиатура передаёт только исходную область. return self.viewport def _apply_y_pan(self, fraction, initial): @@ -58,7 +58,7 @@ class PlotDragInteraction: self.setFocus(Qt.MouseFocusReason) self._press = QPointF(pos) self._press_viewport = self.viewport - self._press_y_pan = self._capture_y_pan() + self._press_y_pan = self._capture_y_pan(event) # Фиксируем выбранную шкалу и её исходный диапазон при нажатии, чтобы пересечение центра не меняло цель перетаскивания. self._press_markers = self.markers self._press_bounds = self.visible_bounds self._pan_vertical = vertical_pan_requested(event) diff --git a/python/set_devices/qt_ports/plot_dual_axis.py b/python/set_devices/qt_ports/plot_dual_axis.py index e1606f5..5defb37 100644 --- a/python/set_devices/qt_ports/plot_dual_axis.py +++ b/python/set_devices/qt_ports/plot_dual_axis.py @@ -8,18 +8,28 @@ except ImportError: from PySide2.QtCore import QPointF, Qt from PySide2.QtGui import QColor from protocan.plot import Bounds, Viewport +from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении. + + +def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета. + step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ. + labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы. + return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел. def small_amplitude(values, threshold=30): + extent = getattr(values, "finite_extent", None) + if extent is not None: + return max(abs(extent[0]), abs(extent[1])) <= threshold magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None) return magnitude is not None and magnitude <= threshold -def split_amplitudes(series, threshold=30): +def split_amplitudes(series, threshold=30, *, include_hidden=False): # Порог 30 в единицах канала разделяет шкалы; файловый canvas может сохранить в них скрытые ряды. """Channels with finite peak magnitude <= 30 use the right amplitude axis.""" result = set() for channel in series: - if not channel.visible or channel.discrete: + if (not channel.visible and not include_hidden) or channel.discrete: # По умолчанию скрытые каналы исключены; независимая политика границ может учитывать их для сохранения масштаба. continue if small_amplitude(channel.values, threshold): result.add(channel.key) @@ -29,7 +39,8 @@ def split_amplitudes(series, threshold=30): class DualAxisPlot: """Mixin: left axis for large channels, right for small channels. - Shift-wheel selects the axis by the pointer's half of the analog pane. + Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем. + Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии. The host supplies series_bounds/project_series rendering hooks. """ def __init__(self, *args, **kwargs): @@ -40,8 +51,8 @@ class DualAxisPlot: super().__init__(*args, **kwargs) def _sync_bounds(self): - keys = split_amplitudes(self._series) - if keys != self.right_axis_keys: + keys = split_amplitudes(self._bounds_series(), include_hidden=True) # Выбираем правые каналы из того же набора, что задаёт общие границы: LogsPlot сохраняет в нём скрытые источники. + if not keys.intersection(self.right_axis_keys): # Сохраняем масштаб при добавлении или временном пересоздании эталона, пока на правой Y остаётся хотя бы один прежний канал. self.right_viewport = Viewport() self.right_axis_keys = keys super()._sync_bounds() @@ -49,10 +60,11 @@ class DualAxisPlot: def set_plot_bounds(self, bounds, has_data=True, x_origin=0): if self.right_axis_keys and self.has_left_axis(): groups = [[], []] - for series in self._series: - if series.visible and not series.discrete: + for series in self._bounds_series(): # Обе независимые Y соблюдают политику canvas, чтобы снятие флажка не подменяло диапазон токов диапазоном напряжений. + if not series.discrete: # Дискретные дорожки не имеют общей амплитудной шкалы и не участвуют в её пределах. groups[series.key in self.right_axis_keys].extend( - v for v in series.values if math.isfinite(v)) + getattr(series.values, "finite_extent", None) or + (v for v in series.values if math.isfinite(v))) ranges = [] for values in groups: low, high = (min(values), max(values)) if values else (0, 1) @@ -62,8 +74,8 @@ class DualAxisPlot: super().set_plot_bounds(bounds, has_data, x_origin) def has_left_axis(self): - return any(s.visible and not s.discrete and s.values and s.key not in self.right_axis_keys - for s in self._series) + return any(not s.discrete and s.values and s.key not in self.right_axis_keys # Наличие левой координатной системы определяется теми же рядами, что и её границы. + for s in self._bounds_series()) # Файловый график сохраняет обе шкалы при скрытии линий; живой график учитывает только видимые каналы. @property def right_visible_bounds(self): @@ -111,23 +123,28 @@ class DualAxisPlot: def plot_rect(self): rect = super().plot_rect() if self.right_axis_keys: - rect.setWidth(max(1, rect.width() - 72)) + rect.setWidth(max(1, rect.width() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета. return rect def right_axis_margin(self): - return 72 if self.right_axis_keys else 0 + return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен. def reset_viewport(self): self.right_viewport = Viewport() super().reset_viewport() - def _capture_y_pan(self): - return super()._capture_y_pan(), self.right_viewport + def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста. + left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок. + if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос. + position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5. + return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом. + return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область. def _apply_y_pan(self, fraction, initial): left, right = initial - super()._apply_y_pan(fraction, left) - if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): + if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии. + super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси. + if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y. self.right_viewport = right.transform(self.core, pan_y=fraction) def reset_interaction(self): @@ -135,7 +152,7 @@ class DualAxisPlot: super().reset_interaction() def wheelEvent(self, event): - if not event.modifiers() & Qt.ShiftModifier: + if not event.modifiers() & Qt.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X. return super().wheelEvent(event) pos = event.position() if hasattr(event, 'position') else event.posF() rect = self.value_rect() @@ -173,7 +190,7 @@ def paint_right_axis(painter, core, rect, bounds): for index in range(count): value = (first + index) * step y = rect.top() + bounds.fraction(core, value, True) * rect.height() - painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') + painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении. painter.restore() @@ -185,7 +202,7 @@ class SingleAmplitudeAxis: def plot_rect(self): rect = super().plot_rect() if not self.has_left_axis(): - rect.setWidth(max(1, rect.width() - 72)) + rect.setWidth(max(1, rect.width() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении. return rect def paint_axes(self, painter): diff --git a/python/set_devices/qt_ports/plot_navigation.py b/python/set_devices/qt_ports/plot_navigation.py index 0fbbe27..72dfe4a 100644 --- a/python/set_devices/qt_ports/plot_navigation.py +++ b/python/set_devices/qt_ports/plot_navigation.py @@ -10,7 +10,8 @@ except ImportError: from PySide2.QtCore import Qt from PySide2.QtWidgets import QScrollArea -NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. ' +NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо — масштаб Y со стороны указателя; ' # Поясняем выбор левой или правой амплитудной шкалы по положению указателя при удерживаемом Ctrl. + 'Ctrl + +/− — масштаб Y. ' # Клавиатурный масштаб Y сохраняет прежнее поведение без выбора стороны указателем. 'ЛКМ — перемещение по X; Ctrl + ЛКМ — по Y; ' '←/→ — перемещение по X; ↑/↓ — по Y; Home/End — начало/конец.') diff --git a/python/set_devices/qt_ports/plot_processing.py b/python/set_devices/qt_ports/plot_processing.py index 9fb49ca..d4aba63 100644 --- a/python/set_devices/qt_ports/plot_processing.py +++ b/python/set_devices/qt_ports/plot_processing.py @@ -21,7 +21,7 @@ except ImportError: QVBoxLayout, QWidget, ) -from set_devices.signal_reconstruction import METHODS +from set_devices.signal_reconstruction import BACKENDS, MAX_OUTPUT_POINTS, METHODS # Используем единый список вариантов расчёта для панели и подписей результатов. from set_devices.plot_processing import Snapshot, prepare, process, write_csv RESULT_COLORS = ("#FF70D0", "#50D5FF", "#FFD166", "#83E377", "#B99AFF", "#FF9870") @@ -78,6 +78,11 @@ class SignalProcessingPanel(QWidget): self.method.addItem(title, key) self.method.setCurrentIndex(2) row.addWidget(self.method) + self.backend = QComboBox() # Явный переключатель позволяет сравнить прежний МНК и новый Eigen на одном снимке данных. + for key, title in BACKENDS.items(): # Порядок оставляет исходный решатель значением по умолчанию. + self.backend.addItem(title, key) # В данных элемента хранится стабильный ключ, независимый от русского названия. + self.backend.setToolTip("Вариант полиномиального МНК. Рассчитайте оба: кривые сохранятся вместе, подписи и время помогут сравнению.") # Объясняем практический способ сравнения непосредственно рядом с выбором. + row.addWidget(self.backend) # Располагаем вариант рядом с методом, к которому он относится. row.addStretch(1) layout.addLayout(row) row = QHBoxLayout() @@ -88,8 +93,8 @@ class SignalProcessingPanel(QWidget): row.addWidget(self.degree) row.addWidget(QLabel("Точек результата")) self.count = QSpinBox() - self.count.setRange(2, 10000) - self.count.setValue(1000) + self.count.setRange(2, MAX_OUTPUT_POINTS) + self.count.setValue(10000) row.addWidget(self.count) self.apply_button = QPushButton("Рассчитать") self.apply_button.clicked.connect(self.calculate) @@ -110,15 +115,18 @@ class SignalProcessingPanel(QWidget): "Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n" "PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n" "Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.") - for widget in (self.channel, self.method): - widget.currentIndexChanged.connect(self._parameters_changed) + for widget in (self.channel, self.method, self.backend): # Смена решателя делает текущий запрос новым, сохраняя предыдущие слои. + widget.currentIndexChanged.connect(self._parameters_changed) # Общая проверка предотвращает принятие результата для уже изменённого выбора. for widget in (self.degree, self.count): widget.valueChanged.connect(self._parameters_changed) self._update_actions() def _signature(self): return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(), - self.count.value(), self.degree.value()) + self.count.value(), self.degree.value(), self._backend()) # Вариант участвует в идентичности снимка и результата. + + def _backend(self): # Для методов без альтернативы всегда используем исходный алгоритм, сохраняя последний выбор МНК в списке. + return self.backend.currentData() if self.method.currentData() == "polynomial" else "native" # Переключение на интерполяцию не выдаёт её за расчёт Eigen. def _current_request_for(self, signature): if not self.preserve_on_view_change or signature is None: @@ -126,7 +134,7 @@ class SignalProcessingPanel(QWidget): # Validate the original calculation domain, independently of navigation. return prepare(replace(self._snapshot, x_range=signature.x_range), self.channel.currentData(), self.method.currentData(), - self.count.value(), self.degree.value()) + self.count.value(), self.degree.value(), self._backend()) # Сохранённое окно проверяется вместе с текущим вариантом решателя. def set_snapshot(self, snapshot): previous = self._job.signature if self._job is not None else self._signature() @@ -154,7 +162,7 @@ class SignalProcessingPanel(QWidget): # depend on samples/units, not the source curve's checkbox. source = replace(source, series=tuple(replace(series, visible=True) for series in source.series)) - if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree) == request: + if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree, request.backend) == request: # Проверяем каждый слой с его собственным решателем, чтобы оба варианта оставались на графике. retained.append(result) if len(retained) != len(self._results): self._results = retained @@ -173,6 +181,7 @@ class SignalProcessingPanel(QWidget): def _update_actions(self): self.degree.setEnabled(self.method.currentData() == "polynomial") + self.backend.setEnabled(self.method.currentData() == "polynomial") # Выбор решателя доступен только для метода МНК, где есть два разных алгоритма. request = self._signature() self.apply_button.setEnabled(self._job is None and request is not None and len(request.series.points) >= 2) @@ -250,9 +259,12 @@ class SignalProcessingPanel(QWidget): duplicates = result.input_count - result.unique_count self.status.setText( f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; " - f"СКО на измерениях: {result.rmse:.6g}. " + f"СКО на измерениях: {result.rmse:.6g}; расчёт: {result.elapsed_ms:.3f} мс. " # Показываем точность и время одного и того же результата, включая преобразование буферов. + (f"Повторы X усреднены: {duplicates}. " if duplicates else "") + f"Расчётных кривых: {len(self._results)}. Исходные данные сохранены.") + comparable = [curve for curve in self._results if replace(curve.request, backend=signature.backend) == signature] if signature.method == "polynomial" else [] # Сопоставляем только одинаковые данные, степень, сетку и окно; различается исключительно решатель. + if len(comparable) > 1: # Сравнение появляется после явного расчёта обоих вариантов на одном наборе данных. + self.status.setText(self.status.text() + "\n" + " | ".join(f"{BACKENDS[curve.request.backend]}: СКО {curve.rmse:.6g}, {curve.elapsed_ms:.3f} мс" for curve in comparable)) # Сохраняем метрики обоих запусков рядом, не объявляя победителя по единичному замеру. self._update_actions() self.changed.emit() diff --git a/python/set_devices/qt_ports/plot_workers.py b/python/set_devices/qt_ports/plot_workers.py index 27d5b97..1c296b3 100644 --- a/python/set_devices/qt_ports/plot_workers.py +++ b/python/set_devices/qt_ports/plot_workers.py @@ -1,24 +1,27 @@ """Reusable background workers for dense plots and viewport measurements.""" +from collections import deque # Кольцевые очереди живого графика требуют единого снимка между проходами амплитудной статистики и частоты. try: from PySide6.QtCore import QObject, QRunnable, Signal except ImportError: from PySide2.QtCore import QObject, QRunnable, Signal -from set_devices.plot_decimation import pixel_envelope, viewport_samples +from set_devices.plot_decimation import pixel_envelope, render_envelope, viewport_samples # Рабочее задание поддерживает точный прежний формат и компактную экранную огибающую для аналогового графика. from set_devices.oscilloscope import measurements +from set_devices.background_work import WorkBudget # Даже фоновый Python-расчёт должен регулярно отдавать GIL многочисленным обработчикам интерфейса Qt. class EnvelopeSignals(QObject): ready = Signal(str, object, object) class EnvelopeJob(QRunnable): - def __init__(self, key, geometry, timestamps, values, left, right, width, signals): + def __init__(self, key, geometry, timestamps, values, left, right, width, signals, compact=False): # Необязательный режим оставляет прежний контракт внешних пользователей, а аналоговый график включает два экстремума на столбец. super().__init__() self.args = (timestamps, values, left, right, width) self.key, self.geometry, self.signals = key, geometry, signals + self.select = render_envelope if compact else pixel_envelope # Выбор алгоритма выполняется один раз при создании задания, не внутри многомиллионного обхода. def run(self): try: - points = tuple(pixel_envelope(*self.args)) + points = tuple(self.select(*self.args)) # Фоновый результат сохраняет точные пики и разрывы в согласованном с рисователем экранном бюджете. except Exception: points = None try: @@ -31,18 +34,43 @@ class NavigationSignals(QObject): ready = Signal(int, object, object) +class _NavigationSamples: # Повторяемая выборка окна позволяет измерять исходные ряды без копии миллионов пар координат. + def __init__(self, times, values, bounds, check_cancelled): # Храним источники задания; вызывающий код создаёт снимок изменяемых очередей перед численными проходами. + self.times, self.values, self.bounds, self.check_cancelled = times, values, bounds, check_cancelled # Ссылки сохраняют исходную точность и порядок без экранного сокращения. + self.count = 0 # Число отсчётов определяется при первом полном проходе и включает нечисловые амплитуды, как прежний список. + + def __iter__(self): # Статистики и частота независимо обходят то же окно, не удерживая промежуточные пары в памяти. + count = 0 # Каждый проход считает только точки внутри включительных временных границ. + for index, (x, y) in enumerate(viewport_samples(self.times, self.values, self.bounds.left, self.bounds.right)): # Индексируемый источник ищет окно двоичным поиском; потоковый сохраняет прежний порядок. + if index % 1024 == 0: # Проверяем отмену через 1024 просмотренных отсчёта, даже когда неупорядоченный ряд лежит вне окна. + self.check_cancelled() # Не продолжаем чтение старого диапазона после нового жеста навигации. + if self.bounds.left <= x <= self.bounds.right: # Включаем обе границы и все дубликаты временных меток, как исходная реализация. + count += 1 # Счётчик соответствует прежнему len(points), независимо от конечности амплитуды. + yield x, y # Пара существует только на время текущего численного прохода и не копируется в большой список. + self.count = count # Публикуем количество только после полного прохода, чтобы отменённая выборка не выдавала частичный результат. + + class NavigationJob(QRunnable): - def __init__(self, revision, series, bounds, emitter): + def __init__(self, revision, series, bounds, emitter, cancelled=None): # Необязательное событие отмены сохраняет совместимость общих графиков и останавливает устаревшие измерения. super().__init__() self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter + self.cancelled = cancelled # Событие принадлежит одному заданию и не сбрасывается при запуске расчёта нового окна. + self.work_budget = WorkBudget(cancelled) # Короткие добровольные паузы предотвращают многосекундное ожидание GIL главным потоком. + + def _check_cancelled(self): # Проверяем отмену на границах этапов и небольших блоков исходных отсчётов. + self.work_budget() # Одновременно прекращаем устаревшие измерения и регулярно освобождаем GIL для рисования и событий мыши. def run(self): stats = {} try: for key, times, values in self.series: - points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right) - if self.bounds.left <= x <= self.bounds.right] - stats[key] = measurements(points), len(points) + self._check_cancelled() # Не начинаем обход следующего канала после возобновления перемещения или изменения масштаба. + samples = _NavigationSamples(times, values, self.bounds, self._check_cancelled) # Повторяемый вид файлового окна устраняет копирование миллионов пар и сохраняет отмену чтения источника. + ordered = getattr(times, "ordered", isinstance(times, (list, tuple, range))) # Применяем тот же контракт упорядоченности, что и viewport_samples, включая смещённые файловые координаты. + points = tuple(samples) if isinstance(times, deque) or isinstance(values, deque) or not ordered else samples # Только живые очереди и неупорядоченный путь получают единый снимок для согласованных амплитуд и частоты. + result = measurements(points, self._check_cancelled) # Передаём отмену внутрь всех численных этапов, чтобы новый жест не ждал окончания полного среднего или СКЗ. + stats[key] = result, samples.count # Количество относится к завершённому проходу того же окна либо к первоначальному снимку живого ряда. + self._check_cancelled() # При отмене во время вычислений не продолжаем остальные каналы и не публикуем устаревшие значения. self.emitter.ready.emit(self.revision, stats, self.bounds) except Exception: try: diff --git a/python/set_devices/qt_ports/qcustomplot_renderer.py b/python/set_devices/qt_ports/qcustomplot_renderer.py index 726ad4f..111d179 100644 --- a/python/set_devices/qt_ports/qcustomplot_renderer.py +++ b/python/set_devices/qt_ports/qcustomplot_renderer.py @@ -6,6 +6,8 @@ from pathlib import Path import subprocess import tempfile from threading import Lock +import math # Кодируем разрывы кривой нечисловыми значениями Y, сохраняя упорядоченные координаты X для нативного движка. +from set_devices.plot_decimation import pixel_envelope # Передаём движку только точки, различимые при текущем масштабе изображения. try: from PySide6.QtCore import QObject, QRunnable, Signal except ImportError: @@ -44,19 +46,26 @@ class NativePlotEngine: self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent) - key = (id(times), id(values)) + key = (id(times), id(values), len(times), len(values), bounds.left, bounds.right, width) # Выборка зависит от исходного снимка, окна X и физической ширины изображения; масштаб Y её не меняет. record = self.records.get(key) if record is None: self.serial += 1 path = Path(self.directory.name) / f'{self.serial}.bin' + points = [] # Временный список ограничен экранной шириной и не содержит миллионов исходных измерений. + for point in pixel_envelope(times, values, bounds.left, bounds.right, width): # Сохраняем первый, минимальный, максимальный и последний отсчёты каждого пиксельного столбца. + if point is not None: # Конечные измерения передаются с исходными координатами и амплитудами. + points.append(point) # Экстремумы остаются видимыми даже для импульса длиной в один исходный отсчёт. + elif points and math.isfinite(points[-1][1]): # Повторные маркеры одного разрыва не должны увеличивать размер передаваемого ряда. + points.append((points[-1][0], math.nan)) # Дублированная метка времени и NaN в Y разрывают путь без нарушения порядка X. with path.open('wb') as stream: - array('d', times).tofile(stream) - array('d', values).tofile(stream) - record = (path, times, values) + array('d', (point[0] for point in points)).tofile(stream) # Записываем только экранные координаты X в прежнем формате 64-битных чисел. + array('d', (point[1] for point in points)).tofile(stream) # Массив Y соответствует сокращённому X и включает маркеры разрывов. + record = (path, times, values, len(points)) # Ссылки сохраняют идентичность исходного снимка; длина описывает только передаваемый экранный ряд. self.records[key] = record if len(self.records) > 4: - self.records.pop(next(iter(self.records))) - request = dict(file=str(record[0]), count=len(times), width=width, height=height, + expired = self.records.pop(next(iter(self.records))) # Оставляем четыре последние экранные выборки, как и в кэше нативного процесса. + expired[0].unlink(missing_ok=True) # Освобождаем устаревший временный файл, чтобы навигация не накапливала данные на диске. + request = dict(file=str(record[0]), count=record[3], width=width, height=height, # Нативному процессу сообщается фактическое число отрисовываемых точек вместо полного размера записи. left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color) self.process.stdin.write((json.dumps(request)+'\n').encode()) self.process.stdin.flush() diff --git a/python/set_devices/qt_ports/reference_comparison.py b/python/set_devices/qt_ports/reference_comparison.py new file mode 100644 index 0000000..33e55ea --- /dev/null +++ b/python/set_devices/qt_ports/reference_comparison.py @@ -0,0 +1,375 @@ +"""Переиспользуемые Qt-панель и диалог сравнения с CSV-эталоном.""" # График, контейнер кривой и папки передаются потребителем. +import json # Отчёт сохраняет показатели и параметры сравнения в переносимом формате. +from pathlib import Path # Имя файла используется в статусе, полный путь — при записи отчёта. +import re # Единицы в скобках проверяются до сравнения каналов. + +try: # Один порт поддерживает современные и совместимые Windows-сборки. + from PySide6.QtCore import QThread, Qt, Signal # Qt6 доставляет результаты рабочего потока в интерфейс. + from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Используем стандартные элементы Qt6; создание ячеек с единицами и подсказками выполняет общий заполнитель таблицы. +except ImportError: # Qt5 доступен на системах, где Qt6 не поддерживается. + from PySide2.QtCore import QThread, Qt, Signal # Сохраняем тот же контракт сигналов и отмены на Qt5. + from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Qt5 использует тот же заполнитель ячеек с единицами и подсказками, что современный порт. + +from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Единственное вычислительное ядро работает по всем исходным отсчётам. +from set_devices.qt_ports.circuit_parameters import CircuitParameters # Необязательные известные R/L/C ограничивают идентификацию электрического эквивалента. +from protocan.plot import Viewport # Общий контейнер области просмотра не зависит от конкретного виджета графика. +from set_devices.qt_ports.reference_results import ComparisonAnalysis, populate_comparison_table # Анализ, единицы показателей и подсказки одинаковы в отдельном и встроенном виде. + + +def _model_channels(sources, references, cache, cancel): # Подготовка связанных физических каналов выполняется только в рабочем потоке. + measured = {} # Сохраняем исходные временные метки для совместной проверки Uce/Uge/Ic. + for index, (signal, points) in enumerate(sources): # Истории и преобразования соответствуют одному снимку измерительной записи. + if re.match(r'^\s*(uce|uge|ic)\b', signal.name, re.IGNORECASE): # Не копируем произвольные дополнительные каналы, не участвующие в электрической модели. + if not points: # Отсутствующий связанный канал не должен ломать корректное сравнение выбранного измерения. + continue # Совместный анализ сообщит недостаточный набор физических наблюдений. + trace = cache.get(index) # Выбранный канал уже подготовлен основным сравнением. + if trace is None: # Остальные физические каналы преобразуются только при первом использовании. + trace = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Множители применяются в точности как на исходном графике. + cache[index] = trace # Следующая настройка RLC не требует повторной копии полной истории. + measured[str(index)] = trace # Индекс сохраняет неоднозначные одинаковые имена для проверки, не затирая их словарём. + return dict(measured=measured, reference=references) # Сдвиг и общие начала координат добавляет независимое вычислительное ядро. + + +def _stop_owned_worker(state): # Уничтожение родителя QWidget может обойти closeEvent вложенной панели. + worker = state[0] # Состояние не обращается к уже уничтожаемой Python-обёртке виджета. + if worker is not None: # Завершённые задания удаляются из держателя заранее. + worker.requestInterruption() # Операция увидит отмену через штатный callback QThread. + worker.wait() # QObject удаляет дочерний QThread только после выхода его run. + + +class ComparisonWorker(QThread): # Чтение CSV и численный проход выполняются вне главного потока Qt. + result = Signal(object) # Готовый результат передаётся интерфейсу без изменения исходных данных. + failed = Signal(str) # Ошибка передаётся текстом и не оставляет частичный отчёт. + + def __init__(self, operation, parent): # Операция принимает callback проверки отмены. + super().__init__(parent) # Диалог владеет временем жизни рабочего потока. + self.operation = operation # Одинаковый worker обслуживает загрузку и расчёт. + + def run(self): # Qt вызывает этот метод в отдельном потоке. + try: # Любая ошибка должна вернуться в UI, а не прервать главный цикл событий. + self.result.emit(self.operation(self.isInterruptionRequested)) # Передаём операции штатный запрос отмены QThread. + except (ValueError, OSError, InterruptedError) as error: # Ошибки данных, файлов и отмена уже содержат понятную причину. + self.failed.emit(str(error)) # Показываем исходную причину без дополнительной обёртки. + except Exception as error: # Неожиданная ошибка также должна завершить состояние занятости. + self.failed.emit(f'Ошибка сравнения: {error}') # Уточняем источник неизвестной ошибки для пользователя. + + +class _ReferenceComparisonView: # Панель и отдельный диалог используют одну реализацию параметров, worker и результата. + def __init__(self, sources, parent=None, source_path='', *, plot_factory, series_factory, path_provider=None): # sources содержит пары объекта с name/display_value и неизменяемой полной истории. + super().__init__(parent) # Родитель определяет окно и владение диалогом. + self._series_factory = series_factory # Фабрика адаптирует общие Trace к кривым графика потребителя. + self._dialog_path = path_provider or (lambda filename: filename) # Без callback используем стандартные относительные имена файлов. + self.setWindowTitle('Сравнение с эталоном') # Заголовок одинаков для всех приложений, использующих порт. + self.sources = sources # История фиксируется вызывающей стороной на время жизни диалога. + self.source_path = source_path # Происхождение измерения включается в JSON-отчёт. + self.references = {} # Каналы эталона появятся после успешного чтения CSV. + self.reference_path = '' # Путь изменяется только после принятия загруженного эталона. + self._source_cache = {} # Повторный расчёт не должен повторно применять множители к измерению. + self._worker = None # Одновременно допускается только одна загрузка или вычисление. + self._worker_state = [None] # Независимый держатель доступен обработчику destroyed без обращения к self. + self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # Останавливаем поток до удаления C++-детей даже при deleteLater родителя. + self._closing = False # Флаг откладывает закрытие до завершения отменённого worker. + self.comparison = None # До расчёта отчёт и график результата отсутствуют. + root = QVBoxLayout(self) # Вертикальный макет объединяет параметры, результат и экспорт. + row = QHBoxLayout() # Первая строка отвечает за выбор файлов и каналов. + self.load_button = QPushButton('Открыть эталон CSV…') # Эталон загружается отдельно от исходного измерения. + self.load_button.clicked.connect(self._open_reference) # Выбор файла запускает фоновое чтение. + self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Каналы измерения и эталона выбираются независимо. + for signal, _ in sources: # Показываем все переданные каналы с непустой историей. + self.source_combo.addItem(signal.name) # Имя берётся из общего контракта источника. + row.addWidget(self.load_button) # Кнопка открытия предшествует выбору каналов. + for label, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Подписи явно различают обе стороны сравнения. + row.addWidget(QLabel(label)) # Подпись относится к следующему списку каналов. + row.addWidget(control, 1) # Оба списка делят свободное место поровну. + root.addLayout(row) # Завершаем строку выбора данных. + row = QHBoxLayout() # Вторая строка задаёт временное совмещение и допуски. + self.align_start = QCheckBox('Совместить начала записей') # Позволяет сравнивать записи с разными абсолютными началами. + self.align_start.setChecked(True) # По умолчанию сравниваем форму от начала каждой записи. + self.align_start.setToolTip('Совмещает первые отсчёты. Задержка самого переключения сохраняется.') # Объясняем отличие совмещения начала от поиска фронта. + self.shift = self._number(-1e12, 1e12, 6) # Диапазон ±10^12 мкс и шесть знаков сохраняет прежнюю точность ввода сдвига. + self.shift.setSuffix(' мкс') # В интерфейсе время вводится в микросекундах. + self.shift.setToolTip('Положительное значение сдвигает эталон вправо') # Явно задаём знак временного сдвига. + self.absolute = self._number(0., 1e12, 6) # Абсолютный допуск неотрицателен и измеряется в единицах канала. + self.relative = self._number(0., 10000., 4) # Относительная часть допуска задаётся в процентах с четырьмя знаками. + self.relative.setSuffix(' %') # Единица отделяет проценты от абсолютной амплитуды. + row.addWidget(self.align_start) # Опция совмещения находится рядом со сдвигом. + for label, control in (('Сдвиг эталона', self.shift), ('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Подписи отражают сумму абсолютной и процентной частей. + row.addWidget(QLabel(label)) # Связываем пояснение с соответствующим полем. + row.addWidget(control) # Ограниченные по ширине поля оставляют место всем параметрам. + root.addLayout(row) # Добавляем настройки совмещения в общий макет. + row = QHBoxLayout() # Третья строка управляет интервалом, расчётом и видом результата. + self.limit_interval = QCheckBox('Только интервал, мкс') # Флажок включает ограничение расчёта по времени. + self.start, self.stop = self._number(-1e12, 1e12, 6), self._number(-1e12, 1e12, 6) # Границы используют ту же шкалу мкс и точность, что сдвиг. + self.stop.setValue(300.) # Начальный интервал 0–300 мкс сохраняет прежнее поведение диалога. + self.visible_button = QPushButton('Из видимого участка') # Текущий масштаб графика можно использовать как интервал расчёта. + self.visible_button.clicked.connect(self._visible_interval) # Переводим границы графика из мс в мкс. + self.calculate_button = QPushButton('Сравнить') # Расчёт запускается явно после выбора параметров. + self.calculate_button.clicked.connect(self._calculate) # Параметры фиксируются перед запуском worker. + self.cancel_button = QPushButton('Отмена расчёта') # Длинную загрузку или расчёт можно прервать. + self.cancel_button.clicked.connect(self._cancel) # Используем кооперативную отмену без остановки потока извне. + self.mode = QComboBox() # Режим отображения не меняет уже вычисленные показатели. + self.mode.addItems(['Наложение', 'Отклонение: измерение − эталон']) # Сначала показываем две кривые в общем масштабе. + self.mode.currentIndexChanged.connect(self._show_plot) # Переключаем кривые без повторного численного прохода. + for control in (self.limit_interval, self.start, QLabel('…'), self.stop, self.visible_button, self.calculate_button, self.cancel_button, self.mode): # Сохраняем порядок интервал → расчёт → вид. + row.addWidget(control) # Каждый элемент относится к одной строке управления результатом. + root.addLayout(row) # Завершаем область параметров. + self.status = QLabel('Откройте эталон. Сравниваются все отсчёты выбранного канала после преобразования амплитуды.') # Общий порт не предполагает конкретный формат преобразования источника. + self.status.setWordWrap(True) # Длинные ошибки CSV не расширяют окно. + root.addWidget(self.status) # Состояние видно над графиком. + self.circuit = CircuitParameters(self) # Неизвестные элементы по умолчанию не подменяются учебными номиналами. + self.circuit.changed.connect(self._invalidate) # Изменение электрического ограничения делает прежние параметры модели неактуальными. + root.addWidget(self.circuit) # Опциональные R/L/C доступны перед строкой анализа и результатами. + self.analysis = ComparisonAnalysis(self) # Строка возможной причины и подробный отчёт повторяют подход Android GUI. + root.addWidget(self.analysis) # Анализ располагается рядом с результатом, не изменяя исходные кривые. + self.legend = QLabel() # Цветная легенда поясняет текущие кривые. + root.addWidget(self.legend) # Легенда отделена от численной таблицы. + splitter = QSplitter(Qt.Orientation.Horizontal) # Пользователь меняет соотношение ширины графика и таблицы. + self.plot = plot_factory() # Конкретный canvas создаётся и настраивается адаптером приложения. + splitter.addWidget(self.plot) # QSplitter принимает любой совместимый QWidget графика. + self.table = QTableWidget(0, 2) # Каждому показателю соответствует название и значение. + self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Заголовки поясняют две колонки отчёта. + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Показатели расчёта недоступны для ручной правки. + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # Названия и числа видны полностью по содержимому. + self.table.setMinimumWidth(370) # Минимум 370 логических пикселей сохраняет читаемость показателей. + splitter.addWidget(self.table) # Таблица располагается справа от кривых. + splitter.setSizes([920, 380]) # Начальные ширины в логических пикселях отдают большую часть окна графику. + root.addWidget(splitter, 1) # Результат получает всё свободное место по высоте. + row = QHBoxLayout() # Нижняя строка содержит экспорт и пояснение алгоритма. + self.report_button = QPushButton('Сохранить отчёт JSON…') # Экспорт доступен только для актуального результата. + self.report_button.clicked.connect(self._save_report) # Имя файла предлагает внедрённый provider путей. + row.addWidget(self.report_button) # Кнопка экспорта расположена перед пояснением. + note = QLabel('Δ = измерение − эталон. Линейная интерполяция эталона; без экстраполяции и заполнения пропусков.') # Формула и ограничения относятся ко всем приложениям-потребителям. + note.setWordWrap(True) # Текст остаётся читаемым при изменении размера окна. + row.addWidget(note, 1) # Пояснение занимает оставшуюся ширину строки. + root.addLayout(row) # Завершаем макет экспорта. + self._inputs = [self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, self.limit_interval, self.start, self.stop, self.visible_button] # Во время расчёта входные данные не меняются. + self._inputs.extend(self.circuit.controls) # Электрические ограничения также фиксируются на время рабочего задания. + for control in (self.shift, self.absolute, self.relative, self.start, self.stop): # Любое числовое изменение делает результат устаревшим. + control.valueChanged.connect(self._invalidate) # Сбрасываем старые кривые и отчёт до следующего расчёта. + for control in (self.align_start, self.limit_interval): # Флажки также влияют на вычисляемую временную сетку. + control.toggled.connect(self._invalidate) # Показатели не должны относиться к прежним условиям. + self.source_combo.currentIndexChanged.connect(self._match_reference) # При смене измерения ищем одноимённый эталон. + self.reference_combo.currentIndexChanged.connect(self._invalidate) # Смена эталонного канала требует нового расчёта. + self._busy(False) # Настраиваем начальную доступность кнопок. + QApplication.instance().aboutToQuit.connect(self._shutdown) # Завершение приложения дожидается отмены рабочего потока. + + @staticmethod # Фабрика числового поля не зависит от состояния диалога. + def _number(low, high, decimals): # Границы и число десятичных знаков задаются для конкретной величины. + box = QDoubleSpinBox() # Штатный элемент проверяет числовой ввод. + box.setDecimals(decimals) # Сохраняем требуемую точность времени или допуска. + box.setRange(low, high) # Ограничиваем значения допустимым диапазоном параметра. + box.setMaximumWidth(150) # Ширина 150 логических пикселей сохраняет компактную строку параметров. + return box # Вызывающий код задаёт единицы и обработчик изменений. + + def _busy(self, busy): # Единый переход состояния используется загрузкой и расчётом. + for control in self._inputs: # Все параметры относятся к одному снимку задания. + control.setEnabled(not busy) # Исключаем изменение входа работающего worker. + self.calculate_button.setEnabled(not busy and bool(self.references) and bool(self.sources)) # Без измерения или эталона расчёт невозможен. + self.cancel_button.setEnabled(busy) # Отменять можно только существующее задание. + self.report_button.setEnabled(not busy and self.comparison is not None) # Отчёт относится исключительно к завершённому результату. + + def _start_work(self, operation, receive): # Операция и получатель отделяют чтение файла от численного расчёта. + self._busy(True) # Блокируем параметры до запуска потока. + worker = ComparisonWorker(operation, self) # Диалог сохраняет владение worker до его завершения. + self._worker = worker # Ссылка используется для отмены и отложенного закрытия. + self._worker_state[0] = worker # Сигнал уничтожения владельца получает актуальную задачу. + worker.result.connect(receive) # Результат применяется в главном потоке Qt. + worker.failed.connect(self.status.setText) # Ошибка появляется в общей строке состояния. + worker.finished.connect(self._finished) # Освобождение worker восстанавливает доступность элементов. + worker.start() # Начинаем фоновое чтение или расчёт. + + def _finished(self): # Вызывается после штатного завершения и после отмены. + worker, self._worker = self._worker, None # Снимаем состояние занятости до возможного закрытия диалога. + self._worker_state[0] = None # Уничтожение родителя больше не обращается к завершённому worker. + if worker is None: # Синхронное закрытие могло уже очистить поток до доставки queued-сигнала. + return # Не повторяем удаление и не обновляем закрытую панель. + worker.deleteLater() # Qt безопасно удаляет завершившийся поток через очередь событий. + self._busy(False) # Разрешаем новый расчёт либо экспорт принятого результата. + if self._closing: # Запрос закрытия во время работы должен завершиться автоматически. + self.close() # Повторное закрытие уже не встречает активный worker. + + def _cancel(self): # Отмена кооперативна и проверяется вычислительным ядром. + if self._worker is not None: # Не обращаемся к уже завершённому потоку. + self._worker.requestInterruption() # Ядро увидит запрос в ближайшей контрольной точке. + self.status.setText('Остановка расчёта…') # Пользователь видит, что запрос принят. + + def _shutdown(self): # Перед выходом процесса нельзя уничтожать работающий QThread. + self._closing = True # Поздние сигналы не должны публиковать результат в закрывающемся интерфейсе. + if self._worker is not None: # Ожидание требуется только при активном задании. + worker = self._worker # Сохраняем объект до очистки общего держателя. + worker.requestInterruption() # Просим ядро прервать чтение или расчёт. + worker.wait() # Дожидаемся остановки до уничтожения объектов Qt. + self._worker = None # Смена записи может немедленно удалить закрытую панель. + self._worker_state[0] = None # Сигнал destroyed не обращается к удалённому потоку повторно. + worker.deleteLater() # Завершённый QThread безопасно освобождается очередью Qt. + + def _invalidate(self, *_): # Аргументы сигналов Qt не нужны для сброса результата. + self.comparison = None # Старые показатели больше не соответствуют выбранным параметрам. + self.analysis.set_result(None) # После изменения ввода прежняя гипотеза больше не является актуальной. + self.table.setRowCount(0) # Убираем устаревшие численные значения. + self.report_button.setEnabled(False) # Запрещаем экспорт прежнего результата. + self.plot._series = [] # Старые кривые также не должны выглядеть актуальными. + self.legend.clear() # Легенда очищается вместе с кривыми. + self.plot._sync_bounds() # Адаптер графика пересчитывает пустую область просмотра. + self.plot.update() # Запрашиваем перерисовку в главном потоке. + if self.references: # После загрузки эталона подсказываем следующий шаг. + self.status.setText('Параметры изменены — нажмите «Сравнить».') # Новый результат получается только явным расчётом. + + def _match_reference(self, *_): # Смена канала измерения ищет одноимённый столбец эталона. + name = self.source_combo.currentText().strip().casefold() # Регистр и крайние пробелы не мешают совпадению имён. + for index in range(self.reference_combo.count()): # Проверяем доступные каналы текущего эталона. + if self.reference_combo.itemText(index).strip().casefold() == name: # Сопоставляем полное имя вместе с единицами. + self.reference_combo.setCurrentIndex(index) # Предлагаем канал, соответствующий измерению. + break # Уникальные имена не требуют дальнейшего поиска. + self._invalidate() # Даже без совпадения старые показатели должны быть удалены. + + def _open_reference(self): # Правила начальной папки задаёт callback приложения. + path, _ = QFileDialog.getOpenFileName(self, 'Открыть эталон', self._dialog_path(''), 'CSV (*.csv)') # Общая библиотека не создаёт каталоги самостоятельно. + if path: # Отмена выбора файла сохраняет текущий эталон. + self.load_reference(path) # Чтение выполняется отдельным worker. + + def load_reference(self, path): # Публичный вход позволяет потребителю открыть эталон программно. + self._invalidate() # Новая загрузка не должна показывать старый отчёт как актуальный. + self.status.setText('Загрузка эталона…') # Состояние обновляется до запуска чтения. + def receive(traces): # Этот обработчик выполняется в потоке интерфейса. + if self._closing: # Синхронное закрытие могло опередить доставку готового чтения. + return # Не обновляем элементы закрываемой панели поздним результатом. + self.references = traces # Принимаем только полностью прочитанный набор каналов. + self.reference_path = str(path) # Путь отчёта соответствует принятому файлу. + self.reference_combo.clear() # Удаляем имена каналов предыдущего эталона. + self.reference_combo.addItems(list(traces)) # Сохраняем порядок столбцов CSV. + self._match_reference() # Автоматически выбираем одноимённый канал при наличии. + self.status.setText(f'Эталон: {Path(path).name}. Выберите каналы и нажмите «Сравнить».') # Подтверждаем успешную загрузку файла. + self._start_work(lambda cancel: read_reference(path, cancel), receive) # Ядро получает callback отмены чтения. + + def _visible_interval(self): # Видимая область графика задаёт границы расчёта. + bounds = self.plot.visible_bounds # Контракт графика возвращает время в миллисекундах. + left, right = bounds.left * 1000, bounds.right * 1000 # Множитель 1000 переводит миллисекунды в микросекунды UI. + self.limit_interval.setChecked(True) # Явно включаем использование выбранных границ. + self.start.setValue(left) # Левая граница отсчитывается от начала измерения. + self.stop.setValue(right) # Правая граница выражена в той же шкале. + + def _calculate(self): # Фиксируем параметры до передачи задания в поток. + if not self.references or not self.sources or self._worker is not None: # Не запускаем неполное или параллельное сравнение. + return # Сохраняем существующее задание и состояние входов. + source_index = self.source_combo.currentIndex() # Индекс связывает выбранное имя с исходным снимком. + signal, points = self.sources[source_index] # Полная история не зависит от экранного окна. + reference = self.references[self.reference_combo.currentText()] # Выбираем один канал загруженного эталона. + units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Единицы распознаются по конечным скобкам имени. + if all(units) and units[0].group(1) != units[1].group(1): # Явно разные единицы нельзя сравнивать как одинаковые амплитуды. + self._invalidate() # Предыдущий результат не относится к новому сочетанию каналов. + self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Пользователь выбирает корректную пару. + return # Не выполняем численный проход с несовместимыми единицами. + options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., # Деление на 1000 переводит микросекунды UI в миллисекунды ядра. + absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value(), # Допуски уже заданы в единицах ядра. + interval=(self.start.value() / 1000., self.stop.value() / 1000.) if self.limit_interval.isChecked() else None) # Ограничение времени также переводится из мкс в мс. + self._invalidate() # До расчёта удаляем старые кривые и показатели. + self.status.setText('Сравнение всех отсчётов…') # Явно указываем работу с полной записью. + options['circuit'] = self.circuit.values() # Значения GUI переводятся в СИ до перехода в рабочий поток. + sources, references = self.sources, self.references # Сохраняем единый снимок связанных каналов и выбранного CSV. + def operation(cancel): # Подготовка амплитуд и сравнение выполняются в worker. + measured = self._source_cache.get(source_index) # Кэш привязан к неизменяемому снимку канала. + if measured is None: # Преобразуем исходные значения только при первом использовании канала. + measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Применяем предоставленное источником преобразование амплитуд. + self._source_cache[source_index] = measured # Повторные настройки допуска не требуют новой подготовки измерения. + channels = _model_channels(sources, references, self._source_cache, cancel) # Синхронные Uce/Uge/Ic подготавливаются теми же преобразованиями амплитуд. + return compare(measured, reference, cancel=cancel, channels=channels, **options) # Общее ядро выполняет полную статистику и идентификацию электрических моделей. + self._start_work(operation, self._received) # Получатель публикует целый результат в главном потоке. + + def _received(self, result): # Таблица и график обновляются из одного результата. + if self._closing: # Готовый результат мог стоять в очереди во время смены записи. + return # Закрытая панель не должна публиковать устаревшие метрики. + self.comparison = result # Сохраняем данные для переключения вида и экспорта. + self.analysis.set_result(result) # Краткий вывод обновляется вместе с полной таблицей сравнения. + populate_comparison_table(self.table, result) # Единицы и подсказки названий и чисел совпадают с боковой таблицей исходного графика. + self.status.setText(f'{result.measured.name} ↔ {Path(self.reference_path).name}: {result.reference.name}. ' # Статус указывает фактически сравниваемые каналы и файл. + 'Время от начала измерения; абсолютные ошибки — в единицах выбранного канала.') # Поясняем шкалу и единицы результата. + self._show_plot() # Показываем текущий режим кривых без повторного расчёта. + + def _show_plot(self, *_): # Сигнал выбора режима может передавать неиспользуемый индекс. + if self.comparison is None: # До успешного расчёта график результата отсутствует. + return # Не создаём кривые из устаревших данных. + result = self.comparison # Кривые и остаток принадлежат одному расчёту. + curves = [(result.measured, 'Измерение', '#2f91ff'), (result.reference, 'Эталон', '#ffae42')] # Синий и оранжевый различают стороны наложения. + self.legend.setText('━━ Измерение   ' # Цвет легенды совпадает с измеренной кривой. + '━━ Эталон — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу. + if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность. + curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых. + self.legend.setText('━━ Δ = измерение − эталон') # Формула фиксирует знак ошибки. + self.plot._series = [self._series_factory(str(i), f'{label}: {trace.name}', color=color, timestamps=trace.times, values=trace.values) # Фабрика создаёт контейнер конкретного canvas без импорта приложения. + for i, (trace, label, color) in enumerate(curves)] # Передаём полные массивы без экранного сокращения. + self.plot.reset_interaction() # Новый вид начинает с нейтрального состояния навигации. + self.plot.axis_lock_x.setChecked(False) # Освобождаем X для корректного показа нового результата. + self.plot.axis_lock_y.setChecked(False) # Общий масштаб Y не наследует ручную фиксацию предыдущего режима. + self.plot.axis_limits = None # Удаляем прежние ручные границы осей. + self.plot._sync_bounds() # Адаптер рассчитывает общую область кривых. + interval = result.metrics['interval_ms'] # При ограниченном расчёте показываем выбранный интервал. + if interval is not None: # Полная запись остаётся видимой при отсутствии ограничения. + bounds = self.plot.visible_bounds # После сброса границы соответствуют полной области результата. + span = bounds.right - bounds.left # Нормируем миллисекунды в долю общей длительности. + self.plot.viewport = Viewport(x=(interval[0] - bounds.left) / span, width=(interval[1] - interval[0]) / span) # Масштаб X соответствует выбранному интервалу ядра. + self.plot.update() # Отложенная перерисовка выполняется главным потоком Qt. + + def report(self): # Структуру можно получить программно без диалога сохранения. + if self.comparison is None: # Экспортировать устаревший или отсутствующий результат запрещено. + return None # Отсутствие результата обозначается явно. + return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, # Источник измерения сохраняется рядом с выбранным каналом. + reference_file=self.reference_path, reference_channel=self.comparison.reference.name, # Эталонный файл и канал определяют вторую сторону сравнения. + time_unit='ms from first measured sample', # Машиночитаемый отчёт использует миллисекунды ядра. + interpolation='linear; no extrapolation; gaps excluded', # Ограничения интерполяции сохраняются вместе с результатом. + tolerance='absolute + relative_pct / 100 * abs(reference)', # Формула позволяет воспроизвести коридор допуска. + metrics=self.comparison.metrics, analysis=self.comparison.analysis) # Отчёт сохраняет также гипотезы, признаки и ограничения анализа. + + def _save_report(self): # Сохранение файла выполняется только по явному действию пользователя. + if self.comparison is None: # Защищаемся от программного вызова без расчёта. + return # Не создаём пустой отчёт. + path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._dialog_path('reference_comparison.json'), 'JSON (*.json)') # Provider определяет начальную папку приложения. + if path: # Отмена выбора не меняет состояние результата. + try: # Файловые ошибки должны отображаться без закрытия диалога. + Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 и отступ в два пробела сохраняют читаемость русских имён. + self.status.setText(f'Отчёт сохранён: {path}') # Подтверждаем фактический путь записанного отчёта. + except OSError as error: # Недоступный каталог или файл не уничтожает рассчитанные данные. + self.status.setText(str(error)) # Пользователь может выбрать другое место и повторить сохранение. + + +class ReferenceComparisonPanel(_ReferenceComparisonView, QWidget): # Встроенный вид занимает область существующего графика без отдельного окна. + back_requested = Signal() # Потребитель решает, какую страницу показать при возврате к записи. + + def __init__(self, *args, **kwargs): # Контракт данных и фабрик совпадает с отдельным диалогом. + super().__init__(*args, **kwargs) # Создаём общие параметры, таблицу и график через QWidget. + self.back_button = QPushButton('К записи') # Возврат сохраняет загруженный эталон и параметры скрытой панели. + self.back_button.clicked.connect(self.back_requested.emit) # Не связываем общую библиотеку с компоновкой конкретной вкладки. + row = QHBoxLayout() # Отдельная короткая строка не сжимает параметры сравнения. + row.addWidget(self.back_button) # Кнопка размещается у начала панели. + row.addStretch(1) # Свободная ширина остаётся справа от кнопки возврата. + self.layout().insertLayout(0, row) # Навигация предшествует выбору файлов и каналов. + + def closeEvent(self, event): # Родитель может удалить панель сразу после close при смене записи. + self._closing = True # Сигнал завершения worker больше не должен возобновлять использование панели. + self._shutdown() # Синхронно отменяем и дожидаемся потока перед возможным deleteLater родителя. + self.plot.close() # Освобождаем ресурсы графика после завершения вычислений. + super().closeEvent(event) # QWidget принимает закрытие встроенной панели. + + +class ReferenceComparisonDialog(_ReferenceComparisonView, QDialog): # Отдельное окно остаётся доступным другим потребителям общего модуля. + def __init__(self, *args, **kwargs): # Используем тот же контракт источников и внедряемых фабрик. + super().__init__(*args, **kwargs) # Общая реализация строит элементы внутри QDialog. + self.setWindowModality(Qt.WindowModality.WindowModal) # Модальность ограничивается родительским окном. + self.resize(1350, 880) # Размер в логических пикселях сохраняет прежний макет отдельного окна. + + def reject(self): # Escape и программное закрытие откладываются до остановки worker. + if self._worker is not None: # Работающий поток должен закончиться раньше удаления диалога. + self._closing = True # Запоминаем запрос закрытия до сигнала finished. + self._cancel() # Кооперативно прерываем операцию ядра. + return # Слот _finished завершит закрытие после остановки. + self.plot.close() # Освобождаем ресурсы конкретного canvas. + super().reject() # Сохраняем стандартный API и сигналы QDialog. + + def closeEvent(self, event): # Системная кнопка закрытия соблюдает тот же порядок отмены. + if self._worker is not None: # Не уничтожаем QThread во время операции. + self._closing = True # Запоминаем отложенное закрытие. + self._cancel() # Запрос будет замечен ядром в контрольной точке. + event.ignore() # Диалог существует до завершения worker. + else: # Завершённый расчёт не препятствует закрытию. + self.plot.close() # График завершает собственную фоновую работу. + super().closeEvent(event) # Передаём QDialog штатное закрытие. diff --git a/python/set_devices/qt_ports/reference_overlay.py b/python/set_devices/qt_ports/reference_overlay.py new file mode 100644 index 0000000..852c7a1 --- /dev/null +++ b/python/set_devices/qt_ports/reference_overlay.py @@ -0,0 +1,299 @@ +"""Компактные параметры эталона для наложения на существующий график.""" # Порт не создаёт canvas и не меняет компоновку исходного графика. +import json # Показатели можно сохранить независимо от используемого рендерера. +from pathlib import Path # Файл эталона и отчёт используют пути, предоставленные потребителем. +import re # Явно разные единицы каналов нельзя накладывать на общую шкалу. +try: # Поддерживаем обе настольные версии Qt. + from PySide6.QtCore import QSignalBlocker, QTimer, Signal # Таймер объединяет последовательные изменения сдвига в один расчёт. + from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget # Панель содержит только параметры, без второго графика. +except ImportError: # Qt5 работает непосредственно без слоя совместимости приложения. + from PySide2.QtCore import QSignalBlocker, QTimer, Signal # Контракт сигналов и отложенного пересчёта совпадает с Qt6. + from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget # Используем тот же компактный набор элементов. +from set_devices.signal_reference import Trace, compare, read_reference, trace_from_pairs # Чтение, преобразование амплитуд и показатели остаются общими. +from set_devices.qt_ports.reference_comparison import ComparisonWorker, _model_channels, _stop_owned_worker # Рабочий поток и подготовка физических каналов имеют общую реализацию. +from set_devices.qt_ports.circuit_parameters import CircuitParameters # Известные параметры последовательного эквивалента задаются независимо от выбранного графика. +from set_devices.qt_ports.reference_results import ComparisonAnalysis, ComparisonResults # Общая строка анализа и таблица используют один результат сравнения. + + +class TracePoints: # Представление кривой парами не копирует полные массивы в список кортежей. + def __init__(self, trace): # Временная шкала уже совмещена с исходной записью потребителя. + self.timestamps, self.values = trace.times, trace.values # Сохраняем прямые ссылки на подготовленные столбцы. + def __len__(self): # Рендереру и легенде нужно знать количество исходных точек. + return len(self.timestamps) # Оба столбца имеют одинаковую длину. + def __iter__(self): # Обычный график может последовательно читать пары отсчётов. + return iter(zip(self.timestamps, self.values)) # Пары создаются только по мере обхода. + def __getitem__(self, index): # В частности, легенда читает последнюю амплитуду по индексу -1. + return self.timestamps[index], self.values[index] # Возвращаем время и значение одной и той же точки. + + +class ReferenceOverlayPanel(QWidget): # Этот компонент добавляет только параметры к уже существующему графику. + changed = Signal() # Владелец добавляет или обновляет одну эталонную кривую в своём canvas. + remove_requested = Signal() # Удаление эталона не затрагивает исходную запись и её график. + results_requested = Signal() # Владелец показывает подробные показатели в прежней боковой области. + RESULT_KEY = '__reference_overlay__' # Стабильный ключ отделяет эталон от измерений и вычисленных интерполяций. + DIFFERENCE_KEY = '__reference_overlay__difference' # Остаток имеет отдельный ключ, поэтому его можно показывать вместе с оригиналом и эталоном. + + def __init__(self, sources, parent=None, source_path='', path_provider=None): # Источники содержат имя, преобразование амплитуды и полную историю. + super().__init__(parent) # Панель принадлежит той же странице, что исходный график. + self.sources, self.source_path = sources, source_path # Сохраняем происхождение измерения для расчёта и отчёта. + self._path = path_provider or (lambda name: name) # Правила папок остаются политикой приложения. + self.references, self.reference_path = {}, '' # Эталон появляется после успешного чтения CSV. + self.trace, self.comparison, self.difference = None, None, None # Полные кривые эталона и остатка отделены от исходного измерения владельца. + self._source_cache = {} # Множитель измерения применяется один раз к неизменяемой истории. + self._worker, self._worker_state = None, [None] # Независимый держатель позволяет остановить поток при уничтожении родителя. + self._closing, self._pending = False, False # Не применяем поздние результаты и объединяем изменения параметров. + self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # QObject останавливает поток до удаления C++-детей. + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) # Панель занимает только высоту своих строк и не отнимает свободное место у графика. + root = QVBoxLayout(self) # Две основные строки располагаются непосредственно над исходным canvas. + root.setContentsMargins(0, 0, 0, 0) # Используем уже имеющиеся отступы страницы графика. + row = QHBoxLayout() # Первая строка содержит файл, каналы и совмещение времени. + self.load_button = QPushButton('Эталон CSV…') # Повторный выбор файла меняет только эталонную кривую. + self.load_button.clicked.connect(self._open_reference) # Запускаем штатный выбор файла. + row.addWidget(self.load_button) # Кнопка находится рядом с каналами сравнения. + self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Измерение и столбец эталона выбираются независимо. + self.source_combo.addItems([source.name for source, _ in sources]) # Имена соответствуют исходным каналам графика. + for title, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Компактные подписи поясняют обе стороны наложения. + row.addWidget(QLabel(title)) # Подпись располагается непосредственно перед списком. + control.setMinimumWidth(110) # Минимум 110 логических пикселей оставляет читаемое имя канала. + row.addWidget(control) # Списки не растягиваются на ширину всего окна. + self.align_start = QCheckBox('Совместить начала') # Опция сдвигает эталон к первому отсчёту выбранного измерения. + self.align_start.setChecked(True) # По умолчанию накладываем форму от начала каждой записи. + row.addWidget(self.align_start) # Совмещение времени доступно без дополнительного диалога. + row.addWidget(QLabel('Сдвиг')) # Единица задаётся суффиксом следующего поля. + self.shift = self._number(-1e12, 1e12, ' мкс') # ±10^12 мкс сохраняет прежний диапазон временного сдвига. + row.addWidget(self.shift) # Положительный сдвиг перемещает эталон вправо. + self.details_button = QPushButton('Допуски и отчёт') # Второстепенные настройки не занимают высоту графика постоянно. + self.details_button.setCheckable(True) # Повторное нажатие сворачивает ту же строку параметров. + row.addWidget(self.details_button) # Детали находятся рядом с основными действиями. + self.results_button = QPushButton('Расчёты') # Полная таблица доступна явно, независимо от краткой строки RMSE. + self.results_button.clicked.connect(self.results_requested.emit) # Потребитель выбирает вкладку результатов рядом с исходным canvas. + row.addWidget(self.results_button) # Не прячем подробные расчёты внутри настроек допуска. + self.remove_button = QPushButton('Убрать эталон') # Удаление относится только к дополнительной кривой. + self.remove_button.clicked.connect(self.remove_requested.emit) # Владелец удаляет панель и обновляет существующий график. + row.addWidget(self.remove_button) # Исходная запись остаётся на месте после нажатия. + row.addStretch(1) # Свободная ширина остаётся справа, не раздвигая параметры. + root.addLayout(row) # Основная строка не содержит второго canvas или отдельной таблицы. + self.details = QWidget(self) # Дополнительная строка скрыта до явного запроса пользователя. + row = QHBoxLayout(self.details) # Допуски и экспорт располагаются в одной компактной строке. + row.setContentsMargins(0, 0, 0, 0) # Не создаём лишние поля вокруг скрываемой строки. + self.absolute, self.relative = self._number(0., 1e12), self._number(0., 10000., ' %') # Допуск состоит из абсолютной части и процента модуля эталона. + for title, control in (('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Формула подписей соответствует общему численному ядру. + row.addWidget(QLabel(title)) # Подпись определяет смысл следующего поля. + row.addWidget(control) # Оба допуска неотрицательны. + self.report_button = QPushButton('Отчёт JSON…') # Отчёт доступен после получения показателей. + self.report_button.clicked.connect(self._save_report) # Сохраняем показатели без отдельного рабочего окна. + self.report_button.setEnabled(False) # До сравнения экспортировать нечего. + row.addWidget(self.report_button) # Экспорт относится к той же паре каналов и допускам. + row.addStretch(1) # Дополнительная строка также остаётся компактной. + root.addWidget(self.details) # Детали добавляются без изменения положения исходного графика в splitter. + self.details.hide() # По умолчанию графику доступны все свободные пиксели. + self.details_button.toggled.connect(self.details.setVisible) # Пользователь сам раскрывает подробные параметры. + self.status = QLabel('Выберите эталон: он будет наложен на исходный график.') # Измерение продолжает отображаться уже до чтения CSV. + self.status.setWordWrap(True) # Длинное имя или ошибка файла не расширяет окно. + root.addWidget(self.status) # Краткие показатели остаются над canvas, полный отчёт размещается в боковой области. + self.circuit = CircuitParameters(self) # Поля доступны вместе с подробными параметрами сравнения. + self.circuit.changed.connect(self._parameters_changed) # Новый известный элемент автоматически обновляет физический эквивалент и JSON. + root.addWidget(self.circuit) # Отдельная строка предотвращает чрезмерную минимальную ширину основных контролов. + self.circuit.hide() # При обычном просмотре необязательные элементы не отнимают высоту графика. + self.details_button.toggled.connect(self.circuit.setVisible) # Кнопка «Допуски и отчёт» раскрывает также настройки математических моделей. + self.analysis = ComparisonAnalysis(self) # Строка возможной неисправности видна непосредственно над исходным графиком. + root.addWidget(self.analysis) # Android-подобный анализ не требует перехода на другую вкладку. + self.results = ComparisonResults(self) # Таблица принадлежит тому же результату, но может быть размещена в боковой области владельца. + self.results.hide() # До встраивания не показываем дочерний виджет поверх компактных параметров. + self.results.export_requested.connect(self._save_report) # Боковая таблица и кнопка над графиком сохраняют один и тот же JSON. + self.results.delta_curve.toggled.connect(lambda _checked: self.changed.emit()) # Отбрасываем булево значение флажка: сигнал обновления слоя не принимает аргументов. + self._timer = QTimer(self) # Изменения числовых полей объединяются перед фоновым расчётом. + self._timer.setSingleShot(True) # Одно завершённое редактирование запускает одно задание. + self._timer.setInterval(120) # Пауза 120 мс не запускает проход по всей записи на каждый шаг ввода. + self._timer.timeout.connect(self._calculate) # Готовые параметры применяются автоматически. + self.source_combo.currentIndexChanged.connect(self._match_reference) # Одноимённый эталон выбирается при смене измерения. + self.reference_combo.currentIndexChanged.connect(self._parameters_changed) # Смена канала обновляет наложение автоматически. + self.align_start.toggled.connect(self._parameters_changed) # Совмещение начал влияет на временные метки эталона. + for control in (self.shift, self.absolute, self.relative): # Изменение сдвига и допусков не требует отдельного нажатия «Сравнить». + control.valueChanged.connect(self._parameters_changed) # Исходная кривая остаётся видимой во время пересчёта. + QApplication.instance().aboutToQuit.connect(self._shutdown) # Выход приложения отменяет фоновые операции до удаления Qt-объектов. + + @staticmethod # Фабрика не зависит от состояния панели. + def _number(low, high, suffix=''): # Границы и единицы задаются для конкретного параметра. + box = QDoubleSpinBox() # Используем штатную проверку числового ввода Qt. + box.setDecimals(6) # Шесть десятичных знаков сохраняют точность прежних параметров сравнения. + box.setRange(low, high) # Запрещаем значения вне диапазона параметра. + box.setSuffix(suffix) # Время вводится в мкс, относительный допуск — в процентах. + box.setMaximumWidth(145) # Не растягиваем поле на ширину рабочей области. + return box # Обработчик изменений подключается после создания всех элементов. + + def _parameters_changed(self, *_): # Параметры могут меняться сигналом Qt или программно. + self.comparison = None # Старые показатели больше не соответствуют текущему вводу. + self.analysis.set_result(None) # Смена канала, файла, сдвига или допуска сразу удаляет прежнюю гипотезу. + self.difference = None # Кривая старого остатка не относится к изменённым параметрам. + self.results.set_result(None, 'Параметры изменены. Выполняется пересчёт…') # Полная таблица сразу очищает устаревшие числа. + self.report_button.setEnabled(False) # Не разрешаем экспорт устаревшего отчёта. + if self.references and not self._closing: # До загрузки эталона пересчитывать нечего. + self.status.setText('Обновление эталона…') # Прежние показатели не должны выглядеть актуальными после изменения источника или сдвига. + self._pending = True # Изменение не теряется, даже если worker ещё читает файл. + self._timer.start() # Объединяем последовательные изменения одного поля. + + def _match_reference(self, *_): # Имя измерения помогает автоматически выбрать подходящий столбец CSV. + index = self.reference_combo.findText(self.source_combo.currentText()) # Сопоставляем полное имя, включая единицы. + if index >= 0: # При отсутствии совпадения сохраняем явно выбранный канал эталона. + self.reference_combo.setCurrentIndex(index) # Одноимённые каналы не требуют второго ручного выбора. + self._parameters_changed() # Новое измерение требует новых показателей и временного совмещения. + + def set_sources(self, sources): # Прибор может обновить исходный кадр, сохранив тот же файл эталона. + if len(sources) == len(self.sources) and all(old_signal == signal and old_points is points for (old_signal, old_points), (signal, points) in zip(self.sources, sources)): # Настройки и идентичность историй однозначно определяют прежний снимок. + return # Навигация по тому же графику не требует повторной подготовки измерения. + selected = self.source_combo.currentText() # Сохраняем выбранное пользователем имя при замене истории. + self.sources, self._source_cache = sources, {} # Новому снимку соответствует отдельный кэш преобразованных амплитуд. + blocker = QSignalBlocker(self.source_combo) # Промежуточный пустой список не запускает расчёт с индексом -1. + self.source_combo.clear() # Удаляем состав каналов старого кадра. + self.source_combo.addItems([signal.name for signal, _ in sources]) # Новые имена берутся из фактической записи владельца. + self.source_combo.setCurrentIndex(max(0, self.source_combo.findText(selected))) # При наличии прежнего имени продолжаем сравнивать тот же канал. + del blocker # Возвращаем обработку пользовательского выбора после целостного обновления. + self._parameters_changed() # Эталон сохраняется и пересчитывается относительно нового оригинала. + + def _start_work(self, operation, receive): # Загрузка и расчёт используют один кооперативно отменяемый поток. + worker = ComparisonWorker(operation, self) # Владелец сохраняет worker до штатного завершения. + self._worker = self._worker_state[0] = worker # Независимый держатель обеспечивает безопасное уничтожение родителя. + for control in (self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, *self.circuit.controls): # Полный снимок включает также известные элементы контура. + control.setEnabled(False) # Исключаем параллельную загрузку и изменение активного ввода. + worker.result.connect(receive) # Результат применяется в главном потоке Qt. + worker.failed.connect(self._failed) # Ошибка отображается и в кратком статусе, и в подробной таблице. + worker.finished.connect(self._finished) # Завершение запускает отложенный пересчёт при необходимости. + worker.start() # Файловые операции и полная статистика не выполняются в обработчике кнопки. + + def _finished(self): # Обрабатываем также завершение после запроса отмены. + worker, self._worker = self._worker, None # Поток больше не считается активным. + self._worker_state[0] = None # Обработчик destroyed не обращается к удаляемому QThread повторно. + if worker is None or self._closing: # Закрытие могло синхронно очистить задачу до доставки сигнала. + return # Не обновляем элементы уничтожаемой панели. + worker.deleteLater() # Завершённый поток удаляется очередью событий. + for control in (self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, *self.circuit.controls): # После подбора снова доступны каналы, допуски и электрические ограничения. + control.setEnabled(True) # Исходный график всё время оставался доступным. + self.report_button.setEnabled(self.comparison is not None) # Экспорт относится только к актуальной статистике. + if self._pending: # Успешное чтение и изменения параметров автоматически обновляют эталон. + self._calculate() # Отдельная кнопка запуска сравнения не требуется. + + def _failed(self, message): # Недоступный файл или ошибка вычислений не должны оставлять прежние показатели. + if self._closing: # Поздний сигнал не обновляет закрытую панель. + return # Ресурсы исходного графика не принадлежат этому worker. + self.comparison, self.difference = None, None # Удаляем только устаревший численный результат и его остаток. + self.status.setText(message) # Причина остаётся видимой над исходным графиком. + self.results.set_result(None, message) # Та же причина поясняет отсутствие строк в подробной таблице. + self.analysis.set_result(None) # Ошибка чтения или расчёта удаляет также прежнюю гипотезу над графиком. + self.report_button.setEnabled(False) # Ошибку нельзя экспортировать как успешный отчёт. + self.changed.emit() # Снимаем прежнюю кривую остатка, сохраняя оригинал и последний загруженный эталон. + + def _open_reference(self): # Потребитель может вызвать выбор файла из своей кнопки рядом с графиком. + path, _ = QFileDialog.getOpenFileName(self, 'Наложить эталон CSV', self._path(''), 'CSV (*.csv)') # Начальная папка задаётся приложением. + if path: # Отмена выбора не удаляет прежний эталон и измерение. + self.load_reference(path) # После чтения кривая добавляется автоматически. + + def load_reference(self, path): # Программный вход также сохраняет исходный график на время загрузки. + if self._worker is not None or self._closing: # Не заменяем ссылку на ещё работающий QThread. + return # Новое чтение доступно после завершения текущего. + self.status.setText('Загрузка эталона…') # Строка состояния показывает текущую фоновую операцию. + self.comparison = None # Предыдущий отчёт не относится к выбираемому файлу. + self.difference = None # Остаток предыдущего эталона не должен выглядеть актуальным. + self.results.set_result(None, 'Загрузка эталона…') # Подробная таблица объясняет временное отсутствие показателей. + self.analysis.set_result(None) # Во время чтения другого файла анализ старого эталона не показывается. + self.report_button.setEnabled(False) # Блокируем экспорт до нового расчёта. + def receive(traces): # Qt доставляет полностью прочитанный CSV в интерфейс. + if self._closing: # Уничтожение панели могло опередить доставку результата. + return # Не меняем состояние после закрытия. + self.references, self.reference_path = traces, str(path) # Принимаем файл вместе с его каналами. + blocker = QSignalBlocker(self.reference_combo) # Наполнение списка не запускает промежуточный расчёт. + self.reference_combo.clear() # Удаляем имена предыдущего эталона. + self.reference_combo.addItems(list(traces)) # Сохраняем порядок каналов CSV. + index = self.reference_combo.findText(self.source_combo.currentText()) # Предпочитаем одноимённый канал. + self.reference_combo.setCurrentIndex(max(0, index)) # При отсутствии совпадения предлагаем первый столбец. + del blocker # Восстанавливаем обработку ручного выбора после целостного обновления списка. + self._pending = True # После завершения чтения автоматически подготавливаем кривую и показатели. + self._start_work(lambda cancel: read_reference(path, cancel), receive) # Чтение поддерживает отмену на больших файлах. + + def _calculate(self): # Формируем эталон в той же временной шкале, что исходные данные графика. + if self._worker is not None or self._closing or not self.references or not self.sources: # Расчёт ждёт окончания чтения и полного набора входа. + return # Флаг pending сохраняет необходимость следующего задания. + self._timer.stop() # Запланированный таймер не должен повторить уже начатый расчёт. + self._pending = False # Текущее задание соответствует последнему снимку параметров. + source_index = self.source_combo.currentIndex() # Индекс связывает выбор с полной исходной историей. + signal, points = self.sources[source_index] # Преобразование амплитуды предоставляется потребителем. + reference = self.references[self.reference_combo.currentText()] # Выбранный столбец не копируется повторно. + units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Сверяем явные единицы общей амплитудной оси. + if all(units) and units[0].group(1) != units[1].group(1): # Разные единицы нельзя представить как количественное наложение. + self.trace = None # Предыдущий эталон не должен выглядеть результатом нового несовместимого выбора. + self.results.set_result(None, 'Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Полная таблица не сохраняет показатели другой пары каналов. + self.analysis.set_result(None) # Разные единицы не должны сохранять вывод предыдущего корректного сравнения. + self.changed.emit() # Убираем только дополнительную кривую, сохраняя оригинальное измерение. + self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Измерение при этом продолжает отображаться. + return # Не добавляем кривую с несовместимыми амплитудами. + options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value()) # Переводим микросекунды UI в миллисекунды ядра. + options['circuit'] = self.circuit.values() # Рабочий поток получает значения элементов в СИ, не обращаясь к виджетам. + cache = self._source_cache # Активный worker не должен записать старый снимок в кэш уже обновлённого кадра. + sources, references = self.sources, self.references # Смена кадра не должна смешивать новые связанные каналы со старым измерением. + self.status.setText('Подготовка наложения эталона…') # Исходная кривая не очищается во время обработки. + def operation(cancel): # Подготовка полной записи выполняется в рабочем потоке. + measured = cache.get(source_index) # Повторный сдвиг не требует повторного применения множителя. + if measured is None: # Первый расчёт готовит неизменяемый снимок амплитуд. + measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Используем все исходные точки с теми же единицами, что оригинал. + cache[source_index] = measured # Кэш принадлежит конкретной истории, переданной при создании панели. + origin = reference.times[0] if options['align_start'] else measured.times[0] # Совмещение начал не обнуляет координаты исходного графика. + trace = Trace(reference.name, [stamp - origin + options['shift_ms'] + measured.times[0] for stamp in reference.times], reference.values) # Добавляем эталон непосредственно в исходной временной шкале измерения. + try: # Отсутствие перекрытия мешает статистике, но не показу двух исходных кривых. + channels = _model_channels(sources, references, cache, cancel) # Подготавливаем совместный снимок Uce/Uge/Ic без дополнительных экранных графиков. + result = compare(measured, reference, cancel=cancel, channels=channels, **options) # Полная статистика и электрические модели используют одно совмещение, допуски и параметры RLC. + difference = Trace(result.difference.name, [stamp + measured.times[0] for stamp in result.difference.times], result.difference.values) # Подготавливаем остаток в той же исходной временной шкале без большого прохода в UI. + return trace, result, '', difference # Эталон, остаток и показатели относятся к одному снимку параметров. + except ValueError as error: # Например, эталон сдвинут за пределы времени измерения. + return trace, None, str(error), None # Показываем эталон и честно сообщаем отсутствие статистики и остатка. + self._start_work(operation, self._received) # Готовая кривая попадёт в существующий canvas через сигнал changed. + + def _received(self, payload): # Получатель не создаёт новый график и не меняет исходные серии. + if self._closing or self._pending: # Поздний результат уже не соответствует состоянию панели. + return # Следующее задание применит актуальные параметры. + self.trace, self.comparison, error, self.difference = payload # Дополнительные кривые не заменяют исходное измерение владельца. + self.results.set_result(self.comparison, error) # Подробная таблица автоматически показывает все показатели завершённого расчёта. + self.analysis.set_result(self.comparison) # Краткий анализ и подробности относятся к той же паре кривых, что таблица. + if self.comparison is None: # Кривую можно показать даже при отсутствии пересечения по времени. + self.status.setText(f'Эталон: {Path(self.reference_path).name}. {error}') # Ошибка статистики не скрывает ни оригинал, ни эталон. + else: # Краткая строка дополняет полный набор показателей в боковой области. + m = self.comparison.metrics # Все значения относятся к полным исходным отсчётам. + self.status.setText(f'Эталон: {Path(self.reference_path).name} · RMSE {m["rmse"]:.7g} · max |Δ| {m["max_abs"]:.7g} · за допуском {m["outside_tolerance"]}/{m["count"]}') # Единицы ошибки совпадают с выбранным измерением. + self.changed.emit() # Владелец добавляет эталон и при выбранном флажке кривую остатка к исходным рядам. + + def overlay(self): # Потребитель объединяет результат с собственными кривыми и легендой. + if self.trace is None: # До загрузки эталона исходный график не меняется. + return [], {} # Пустой дополнительный слой не означает очистку измерений. + label = f'Эталон: {self.trace.name}' # Подпись отличает эталон от одноимённого оригинального канала. + specs, data = [(self.RESULT_KEY, label, False, '#ffae42', True)], {self.RESULT_KEY: TracePoints(self.trace)} # Эталон сохраняет оранжевый цвет на общих осях исходного canvas. + if self.results.delta_curve.isChecked() and self.difference is not None: # Остаток показывается только по явному выбору пользователя и для актуального результата. + specs.append((self.DIFFERENCE_KEY, 'Δ: измерение − эталон', False, '#ff7b7b', True)) # Красноватая подпись отличает разность от двух исходных кривых. + data[self.DIFFERENCE_KEY] = TracePoints(self.difference) # Значения остатка не пересчитываются и используют исходную временную шкалу графика. + return specs, data # Потребитель отображает все выбранные дополнительные кривые вместе с оригиналом. + + def report(self): # Программный экспорт использует тот же формат, что отдельный диалог библиотеки. + if self.comparison is None: # Отсутствующая или устаревшая статистика не экспортируется. + return None # Явно обозначаем отсутствие актуального отчёта. + return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, reference_file=self.reference_path, reference_channel=self.comparison.reference.name, time_unit='ms from first measured sample', interpolation='linear; no extrapolation; gaps excluded', tolerance='absolute + relative_pct / 100 * abs(reference)', metrics=self.comparison.metrics, analysis=self.comparison.analysis) # JSON сохраняет численные показатели, наблюдаемые признаки и возможные причины неисправности. + + def _save_report(self): # Экспорт не открывает отдельный рабочий график. + if self.comparison is None: # Защищаемся от программного вызова до получения статистики. + return # Не записываем пустой отчёт. + path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._path('reference_comparison.json'), 'JSON (*.json)') # Начальный каталог предоставляет приложение. + if path: # Отмена выбора не влияет на показ кривых. + try: # Ошибка доступа к файлу не должна удалять готовое наложение. + Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 сохраняет русские имена каналов в читаемом виде. + except OSError as error: # Пользователь может выбрать другое место для отчёта. + self.status.setText(str(error)) # Объясняем причину неуспешного сохранения. + + def _shutdown(self): # Перед удалением панели прекращаем обращение worker к её источникам. + self._closing = True # Поздние queued-сигналы больше не применяются. + self._timer.stop() # Отложенный пересчёт не запускается после закрытия. + if self._worker is not None: # Синхронное ожидание необходимо только при активной задаче. + worker = self._worker # Сохраняем поток до очистки держателя. + worker.requestInterruption() # Чтение и сравнение проверяют штатный запрос отмены. + worker.wait() # Родитель может безопасно удалить панель сразу после close. + self._worker = self._worker_state[0] = None # Повторное уничтожение не обращается к старому QThread. + worker.deleteLater() # Завершённый поток освобождается очередью Qt. + + def closeEvent(self, event): # Закрытие удаляет только параметры и слой эталона у владельца. + self._shutdown() # Исходный canvas не принадлежит панели и не закрывается здесь. + super().closeEvent(event) # QWidget завершает собственный жизненный цикл. diff --git a/python/set_devices/qt_ports/reference_results.py b/python/set_devices/qt_ports/reference_results.py new file mode 100644 index 0000000..9dc0bd3 --- /dev/null +++ b/python/set_devices/qt_ports/reference_results.py @@ -0,0 +1,155 @@ +"""Общая таблица отклонений и показателей сравнения без собственного графика.""" # Потребитель размещает её рядом с исходным canvas. +import re # Извлекаем обозначение единицы из конечных скобок имени канала по тому же правилу, что проверка совместимости каналов. +try: # Поддерживаем современные и совместимые настольные сборки. + from PySide6.QtCore import Qt, Signal # Сигналы и режим обычного текста поддерживают локальный отчёт без внешних сервисов. + from PySide6.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QPushButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget # Таблица и подробный текст используют штатные элементы Qt6. +except ImportError: # Qt5 использует тот же контракт таблицы и сигналов. + from PySide2.QtCore import Qt, Signal # Qt5 сохраняет те же сигналы и режим обычного текста. + from PySide2.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QPushButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget # Набор элементов анализа совпадает с современным портом. + + +class ComparisonAnalysis(QWidget): # Как в Android, краткий анализ дополняется кнопкой открытия подробного отчёта. + def __init__(self, parent=None): # Компонент одинаково работает над графиком и рядом с таблицей. + super().__init__(parent) # Владелец сравнения управляет размещением и уничтожением строки. + self._analysis, self._dialog = {}, None # До завершения сравнения подробности отсутствуют. + row = QHBoxLayout(self) # Одна строка не отнимает значительную высоту у графика. + row.setContentsMargins(0, 0, 0, 0) # Используем отступы содержащей панели. + self.summary = QLabel() # Краткий вывод остаётся виден без открытия полной таблицы. + self.summary.setTextFormat(Qt.TextFormat.PlainText) # Диагностическая строка всегда является обычным текстом. + self.summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) # Длинная гипотеза не увеличивает минимальную ширину окна. + row.addWidget(self.summary, 1) # Подпись получает свободную ширину, сохраняя кнопку доступной. + self.report_button = QPushButton('Отчёт анализа…') # Название отличает подробности гипотезы от сохранения JSON. + self.report_button.clicked.connect(self._show_report) # Открываем текст только по явному действию пользователя. + row.addWidget(self.report_button) # Кнопка находится справа, как в панели Android. + self.set_result(None) # Начальное состояние не содержит ложного диагноза. + + def set_result(self, result): # Любое изменение параметров удаляет прежнюю гипотезу вместе с численными показателями. + self._analysis = result.analysis if result is not None else {} # Принимаем только анализ актуального сравнения. + text = 'Анализ: ' + self._analysis.get('summary', 'нет актуального сравнения с эталоном.') # Общая подпись объясняет пустое состояние. + self.summary.setText(text) # Строка обновляется одновременно с результатом worker. + self.summary.setToolTip(text) # Полный вывод доступен даже при узком окне и обрезанной подписи. + self.summary.setStyleSheet('color: #ef9a73;' if self._analysis.get('attention') else '') # Цвет замечания различает требующий проверки результат, не меняя общую тему. + self.report_button.setEnabled(bool(self._analysis)) # Нельзя открыть пустые или устаревшие подробности. + if self._dialog is not None: # Открытый отчёт не должен оставаться текущим после нового расчёта. + self._dialog.close() # Смена результата закрывает окно со старым снимком. + self._dialog.deleteLater() # Освобождаем скрытые текстовые окна при многократных пересчётах. + self._dialog = None # Следующее открытие создаст отчёт по актуальным данным. + + def _show_report(self): # Подробности не запускают повторного анализа и не требуют подключения к сети. + if not self._analysis: # Защищаем программный вызов при отсутствии результата. + return # Не открываем пустое окно. + if self._dialog is not None: # Повторное нажатие возвращает существующее окно текущего отчёта. + self._dialog.show() # Повторно показываем скрытое окно с тем же результатом. + self._dialog.raise_() # Поднимаем готовое окно без дублирования виджетов. + return # Текст относится к тому же актуальному сравнению. + dialog = QDialog(self) # Родитель уничтожает подробности вместе со строкой анализа. + dialog.setWindowTitle('Анализ сравнения с эталоном') # Заголовок обозначает источник диагностических гипотез. + dialog.resize(1020, 740) # Размер в логических пикселях оставляет место таблице конкурирующих моделей и подробным параметрам. + root = QVBoxLayout(dialog) # Текст занимает свободную высоту над кнопкой закрытия. + report = self._analysis.get('model_report', {}) # Старые или неполные результаты могут не содержать идентифицированных моделей. + models = report.get('models', []) # В одну таблицу входят только конкуренты на общей сетке; отдельные RLC-окна описаны ниже. + if models: # Не создаём пустую таблицу при ошибке выбора каналов или недостатке точек. + caption = QLabel(f'Сравнение моделей: {report.get("sampled_points", 0)} точек. RMSE — в единицах канала; большее ΔBIC означает преимущество над эталоном. * — выполнены критерии поддержки модели.') # Подпись объясняет величины и отметку подтверждения без зависимости от шрифтов специальных символов. + caption.setWordWrap(True) # Пояснение не расширяет окно при небольшом экране. + root.addWidget(caption) # Критерии видны перед подробным техническим текстом. + table = QTableWidget(len(models), 5, dialog) # Каждый кандидат имеет собственные показатели ошибки и подтверждения. + table.setHorizontalHeaderLabels(['Модель', 'RMSE', 'Контроль RMSE', 'ΔBIC', 'Ошибка описана, %']) # Критерий сложности отделён от амплитудной погрешности. + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Пользователь не изменяет результаты численного подбора вручную. + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) # Колонки используют всю доступную ширину окна отчёта. + table.setMaximumHeight(260) # До 260 логических пикселей оставляют место формулам и ограничениям ниже. + names = dict(reference='Исходный эталон', offset='Смещение', gain='Масштаб и ноль', drift='Линейный дрейф', timing='Задержка', slow_switching='Растяжение фронтов', constant='Постоянный уровень', contact_bounce='Прерывания контакта') # Русские названия сохраняют стабильные машинные коды в JSON. + for row, model in enumerate(models): # Порядок уже соответствует возрастанию BIC на общей обучающей выборке. + name = names.get(model['code'], model['code']) + (' *' if model['supported'] else '') # Обычная звёздочка означает выполнение критериев модели и корректно отображается в системных шрифтах Qt5 и Qt6. + values = [name, f'{model["rmse"]:.6g}', f'{model["validation_rmse"]:.6g}', f'{model["delta_bic"]:.6g}', f'{model["explained_error_pct"]:.4g}'] # Числа сохраняют достаточно значащих цифр для сравнения близких кандидатов. + for column, value in enumerate(values): # Каждая ячейка относится к тому же снимку параметров worker. + item = QTableWidgetItem(value) # Текстовая ячейка поддерживает штатное выделение таблицы Qt. + item.setToolTip(f'{model["formula"]}\n{model["parameters"]}') # Формула и оценённые параметры доступны без прокрутки полного текста. + table.setItem(row, column, item) # Устанавливаем готовое значение без повторного расчёта модели. + root.addWidget(table) # Таблица дополняет существующий подробный отчёт, не подменяя основной график. + details = QPlainTextEdit(self._analysis['details'], dialog) # Обычный текст поддерживает выделение и копирование без HTML. + details.setReadOnly(True) # Пользователь не меняет автоматически рассчитанный отчёт. + root.addWidget(details, 1) # Длинный отчёт прокручивается внутри окна. + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) # Стандартная кнопка сохраняет поведение Qt5 и Qt6. + buttons.button(QDialogButtonBox.StandardButton.Close).setText('Закрыть') # Русская подпись не зависит от наличия переводчика стандартных строк Qt. + buttons.rejected.connect(dialog.reject) # Кнопка закрывает только подробный отчёт. + root.addWidget(buttons) # Действие доступно под текстом независимо от прокрутки. + self._dialog = dialog # Сохраняем окно для повторного открытия и сброса устаревших подробностей. + dialog.show() # Немодальный отчёт позволяет продолжать просмотр кривых. + + +def comparison_rows(metrics, *channel_names): # Имена измерения и эталона позволяют обеим таблицам показывать фактическую единицу амплитудных ошибок. + units = [re.search(r'\(([^()]*)\)\s*$', name) for name in channel_names] # Проверяем конечные скобки сначала у измерения, затем у эталона, если измерение не содержит единицы. + unit = next((match.group(1).strip() for match in units if match and match.group(1).strip()), 'ед. канала') # При отсутствии явной единицы сохраняем честное общее обозначение без догадки о физической величине. + fmt = lambda value: '—' if value is None else f'{value:.7g}' # Семь значащих цифр сохраняют читаемость; None означает отсутствие определённого значения. + return [('Сравнено отсчётов', str(metrics['count'])), ('Покрытие интервала, %', fmt(metrics['coverage_pct'])), # Показываем число корректных сравнений и долю покрытия записи. + ('Вне времени эталона, отсчётов', str(metrics['outside_reference'])), ('Пропуски, отсчётов', str(metrics['invalid_points'])), # Единица количества отличает отсутствие покрытия и пропуски от длительности в микросекундах. + (f'Среднее Δ (смещение), {unit}', fmt(metrics['bias'])), (f'Среднее |Δ| (MAE), {unit}', fmt(metrics['mae'])), # Среднее смещение и средний модуль ошибки имеют ту же единицу амплитуды, что выбранный канал. + (f'СКЗ Δ (RMSE), {unit}', fmt(metrics['rmse'])), (f'СКО Δ без смещения, {unit}', fmt(metrics['error_std'])), # Корень среднего квадрата и стандартное отклонение выражаются в единицах канала, а не в квадрате этих единиц. + (f'Максимум |Δ|, {unit}', fmt(metrics['max_abs'])), ('Время max |Δ|, мкс', fmt(metrics['max_abs_time_ms'] * 1000)), # Максимальная ошибка сохраняет единицу амплитуды; множитель 1000 переводит время из миллисекунд ядра в микросекунды интерфейса. + ('RMSE / размах эталона, %', fmt(metrics['nrmse_span_pct'])), # Постоянный эталон не имеет размаха, поэтому нормированная ошибка может отсутствовать. + ('За допуском, отсчётов', str(metrics['outside_tolerance'])), ('За допуском, %', fmt(metrics['outside_tolerance_pct'])), # Количество и процент превышений относятся к корректным сравнениям. + ('Первый выход, мкс', fmt(None if metrics['first_outside_time_ms'] is None else metrics['first_outside_time_ms'] * 1000))] # None означает отсутствие превышения, иначе время выводится в микросекундах. + + +def populate_comparison_table(table, result): # Обе формы сравнения получают одинаковые единицы и подсказки для названия и значения каждого показателя. + descriptions = ( # Порядок пояснений соответствует строкам comparison_rows и сохраняет привычное расположение показателей. + 'Число отсчётов выбранного интервала, для которых корректны и измерение, и эталон.', # Счётчик включает только точки, вошедшие в расчёт ошибок. + 'Доля отсчётов выбранного интервала, которые удалось сравнить с эталоном. Пропуски и точки вне времени эталона уменьшают покрытие.', # Знаменатель включает все выбранные точки измерения. + 'Число отсчётов выбранного интервала вне временных границ эталона после совмещения и сдвига. Эти точки не сравниваются.', # Отличаем отсутствие эталона по времени от некорректной амплитуды. + 'Число точек в общей области времени с отсутствующим или некорректным измерением либо эталоном. Они исключены из расчёта ошибок.', # Разрыв интерполяции также считается пропуском. + 'Средняя разность «измерение − эталон» со знаком. Положительное значение означает, что измерение в среднем выше эталона.', # Среднее отклонение оценивает постоянное смещение. + 'Среднее абсолютное отклонение от эталона (MAE). Показывает среднюю величину ошибки без учёта её знака.', # Модули не позволяют положительным и отрицательным ошибкам взаимно сократиться. + 'Корень из среднего квадрата отклонения (RMSE). Крупные ошибки влияют на него сильнее, чем на среднее абсолютное отклонение.', # После извлечения корня единица совпадает с единицей канала. + 'Стандартное отклонение ошибки относительно её среднего. Показывает разброс после исключения постоянного смещения.', # Расчёт характеризует всю сравниваемую запись, без выборочной поправки. + 'Наибольший модуль разности между измерением и эталоном среди всех корректно сравнённых отсчётов.', # Экстремум относится к полной записи, а не к экранной выборке. + 'Время наибольшей абсолютной ошибки от начала измерения, в микросекундах. При повторении максимума указано первое время.', # Ноль времени не переносится на начало выбранного интервала. + 'RMSE в процентах от разности максимума и минимума эталона на сравниваемом участке. Прочерк означает нулевой размах эталона.', # Постоянный эталон не позволяет определить нормированную ошибку. + 'Число отсчётов, где |измерение − эталон| строго больше допуска: абсолютный допуск + относительный допуск / 100 × |эталон|.', # Равенство границе коридора считается допустимым. + 'Доля отсчётов за допуском в процентах от числа корректно сравнённых отсчётов.', # Пропуски и отсутствие временного покрытия не входят в знаменатель. + 'Время первого превышения допуска от начала измерения, в микросекундах. Прочерк означает, что превышений не было.', # Отсутствие события не подменяется нулём времени. + ) # Все четырнадцать показателей имеют отдельное краткое пояснение. + rows = comparison_rows(result.metrics, result.measured.name, result.reference.name) if result is not None else [] # Единицы относятся к актуальной паре каналов; сброс результата удаляет прежние строки и подсказки. + table.setRowCount(len(rows)) # Размер обеих таблиц точно соответствует доступному набору показателей. + for row, (label, value) in enumerate(rows): # Сохраняем единый порядок показателей и пояснений во всех формах. + for column, text in enumerate((label, value)): # Одинаковая подсказка доступна при наведении как на название, так и на число. + item = QTableWidgetItem(text) # Каждая ячейка хранит свой текст и стандартную подсказку Qt. + item.setToolTip(descriptions[row]) # Краткое объяснение появляется при наведении без открытия отдельного окна. + table.setItem(row, column, item) # Таблица принимает владение ячейкой и заменяет прежнюю подсказку при пересчёте. + + +class ComparisonResults(QWidget): # Панель сохраняет все подробные расчёты рядом с прежним графиком. + export_requested = Signal() # Запись отчёта использует пути и актуальное сравнение владельца. + + def __init__(self, parent=None): # Компонент не требует фабрики canvas и не меняет состав исходных кривых. + super().__init__(parent) # Родитель управляет размещением таблицы в боковой области. + root = QVBoxLayout(self) # Короткая строка действий располагается над полной таблицей. + root.setContentsMargins(0, 0, 0, 0) # Используем пространство прежней правой панели без лишних внешних полей. + self.description = QLabel('Загрузите эталон для расчёта отклонений.') # До чтения файла таблица не содержит устаревших чисел. + self.description.setWordWrap(True) # Имена каналов и ошибки не расширяют исходное окно. + root.addWidget(self.description) # Подпись объясняет знак ошибки и единицы результата. + self.analysis = ComparisonAnalysis(self) # Возможная причина доступна и на вкладке полных расчётов. + root.addWidget(self.analysis) # Строка гипотезы предшествует численным показателям. + row = QHBoxLayout() # График отклонения и экспорт доступны без раскрытия параметров над canvas. + self.delta_curve = QCheckBox('Показать кривую Δ') # Флажок добавляет остаток к оригиналу и эталону на существующих осях. + self.delta_curve.setEnabled(False) # Остаток отсутствует до завершения корректного сравнения. + row.addWidget(self.delta_curve) # Управление кривой располагается рядом с подробными показателями. + self.export_button = QPushButton('Отчёт JSON…') # Сохраняем полный набор расчётов вместе с параметрами сравнения. + self.export_button.clicked.connect(self.export_requested.emit) # Файловый диалог открывает владелец численного результата. + self.export_button.setEnabled(False) # Нельзя экспортировать отсутствующую или устаревшую статистику. + row.addWidget(self.export_button) # Кнопка остаётся видимой рядом с таблицей. + row.addStretch(1) # Не растягиваем действия на всю ширину боковой области. + root.addLayout(row) # Действия занимают одну строку независимо от числа показателей. + self.table = QTableWidget(0, 2) # Каждый показатель имеет отдельную строку с численным значением. + self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Две колонки сохраняют привычный формат полного отчёта. + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Пользователь не должен вручную менять вычисленные значения. + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Названия показателей остаются читаемыми. + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Колонка значений использует оставшуюся ширину. + self.table.verticalHeader().setDefaultSectionSize(22) # Высота 22 логических пикселя позволяет показать полный набор в обычной боковой области. + root.addWidget(self.table, 1) # Таблица получает свободную высоту и при необходимости прокручивается. + + def set_result(self, result, message=''): # Все строки обновляются из одного результата или очищаются при его утрате. + self.analysis.set_result(result) # Гипотезы очищаются при тех же условиях, что таблица и экспорт. + populate_comparison_table(self.table, result) # Общий заполнитель обновляет единицы и подсказки обеих колонок либо полностью очищает устаревший результат. + self.delta_curve.setEnabled(result is not None) # Кривую отклонения можно включить только при наличии общих корректных точек. + self.export_button.setEnabled(result is not None) # Экспорт относится только к актуальному численному результату. + self.description.setText(f'Δ = измерение − эталон: {result.measured.name} ↔ {result.reference.name}. Ошибки — в единицах канала; время — в мкс.' if result is not None else message or 'Нет актуального результата сравнения.') # Явно различаем роли одноимённых каналов и показываем причину отсутствия результата. diff --git a/python/set_devices/qt_ports/slcan_adapter.py b/python/set_devices/qt_ports/slcan_adapter.py index 1a6748a..a09787e 100644 --- a/python/set_devices/qt_ports/slcan_adapter.py +++ b/python/set_devices/qt_ports/slcan_adapter.py @@ -7,8 +7,11 @@ from __future__ import annotations from dataclasses import dataclass +from collections import deque # Храним ограниченную историю FD для отдельного просмотра диагностики. +from ..can_adapter_settings import SlcanOptions, FD_LENGTHS # Профиль прошивки определяет команды и допустимые длины CAN FD. +import re # Поиск ближайшего служебного байта за один проход сохраняет скорость при пакетном приёме большого числа CAN-кадров. -from .qt_compat import QObject, Signal +from .qt_compat import QObject, Signal, QElapsedTimer, QTimer # Таймеры ограничивают ожидание ответов и откладывают выдачу кадров до окончания настройки. from .qt_compat import QSerialPort, QSerialPortInfo @@ -33,11 +36,7 @@ class SlcanChannel: return "slcan" -_BITRATE_COMMANDS = { - 10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3", - 125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7", - 1_000_000: "S8", -} +_RECORD_END = re.compile(rb"[\r\n\x07]") # CR, LF и BEL имеют коды ASCII 13, 10 и 7; любой из них завершает запись протокола. def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes: @@ -80,6 +79,7 @@ class SlcanAdapter(QObject): frame_received = Signal(int, bytes) capture_received = Signal(int, bytes, bool, bool) connection_lost = Signal(str) + fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN. def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -88,10 +88,19 @@ class SlcanAdapter(QObject): self._serial.errorOccurred.connect(self._error) self._buffer = bytearray() self._listen_only = False + self.options = SlcanOptions() # Без расширенных параметров остаётся прежний подтверждаемый протокол Lawicel. + self.fd_history = deque(maxlen=500) # Не более 500 последних FD-кадров сохраняется между открытиями окна просмотра. + self._query_prefix = None # Диагностические ответы отличаем от подтверждений настройки и CAN-кадров. + self._query_reply = None # Полная строка ответа сохраняется только во время явного диагностического запроса. + self._initializing = False # Открытие COM-порта ещё не подтверждает настройку CAN-контроллера. + self._waiting_command = False # Отличаем подтверждение текущей команды от служебных строк рабочего потока. + self._command_reply: bool | None = None # None означает ожидание, True — CR/ACK, False — BEL/отказ прошивки. + self._after_cr = False # Следующий LF после CR относится к тому же окончанию строки, даже при раздельных USB-пакетах. + self._pending_frames: list[tuple[int, bytes, bool]] = [] # Кадры при открытии CAN сохраняются до готовности подписчиков монитора. @property def connected(self) -> bool: - return self._serial.isOpen() + return self._serial.isOpen() and not self._initializing # Считаем соединение готовым только после подтверждения всех команд настройки. def scan(self) -> list[SlcanChannel]: # Windows не предоставляет надёжного признака протокола SLCAN. @@ -102,11 +111,11 @@ class SlcanAdapter(QObject): def connect_channel(self, channel: SlcanChannel, bitrate: int, host_baudrate: int = 115200, - listen_only: bool = False) -> None: + listen_only: bool = False, *, options: SlcanOptions | None = None) -> None: # Расширенные параметры передаются отдельно от прежнего контракта подключения. + selected = options or SlcanOptions() # Дефолт сохраняет C, Sn и O/L с ожиданием CR/BEL. + commands = selected.commands(bitrate, listen_only) # Проверяем весь профиль до закрытия прежнего соединения и открытия COM. self.disconnect_channel() - command = _BITRATE_COMMANDS.get(bitrate) - if command is None: - raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с") + self.options = selected # Настройка ожидания команд нужна уже во время инициализации порта. self._serial.setPortName(channel.path) if host_baudrate not in (9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600): @@ -119,23 +128,67 @@ class SlcanAdapter(QObject): if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite): raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}") self._buffer.clear() + self.fd_history.clear() # Журнал нового подключения не содержит сообщений прежнего устройства. self._listen_only = listen_only - # Close first so reconnecting also works after an interrupted session. - open_command = "L" if listen_only else "O" - if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0: - message = self._serial.errorString() - self._serial.close() - self._listen_only = False - raise SlcanError(f"Не удалось настроить SLCAN: {message}") + self._initializing = True # Во время настройки не выдаём ложный статус готовности и первые кадры с прежней скоростью CAN. + try: # Любой отказ настройки должен закрыть порт и разрешить повторную попытку подключения. + self._command("C", allow_rejection=True) # Закрываем прежний канал; BEL допустим, если канал уже закрыт по правилам Lawicel. + for command in commands: # Профиль определяет порядок скорости, режима, фильтров, повторов и открытия. + self._command(command) # Любой отказ прекращает настройку без опасного возврата к активному режиму. + except SlcanError: # Отказ прошивки, неполная запись и отсутствие ответа одинаково означают неудачное подключение. + self._serial.close() # Освобождаем COM-порт, чтобы пользователь мог исправить скорость или тип адаптера. + self._buffer.clear() # Остатки неудачного обмена не должны повреждать строки следующего подключения. + self._pending_frames.clear() # Не отправляем в монитор данные сеанса, который не удалось открыть. + self._listen_only = False # Сбрасываем ограничение режима вместе с неудачным подключением. + raise # Сохраняем точную причину отказа для статусной строки вызывающей вкладки. + finally: # Признак настройки должен сбрасываться как при успехе, так и при исключении. + self._initializing = False # После возврата вызывающая вкладка сможет установить правильную скорость и статус. + QTimer.singleShot(0, self._read) # Выдаём накопленные первые кадры в следующем цикле Qt, после обновления настроек монитора. + + def _command(self, command: str, *, allow_rejection: bool = False) -> None: # Последовательный обмен сохраняет синхронный контракт подключения всех вкладок. + self._read() # Убираем ранее пришедшие ответы до начала ожидания новой команды. + self._command_reply = None # Старое подтверждение не может подтверждать следующую настройку. + self._waiting_command = True # Пустая строка CR или отдельный BEL теперь относятся к текущей команде. + packet = (command + "\r").encode("ascii") # Каждая команда Lawicel передаётся отдельной строкой с завершающим ASCII CR. + elapsed = QElapsedTimer() # Монотонное время не зависит от перевода системных часов Windows. + elapsed.start() # Общий срок ожидания включает запись команды и все фрагменты ответа. + try: # Флаг ожидания обязательно очищается при любом результате обмена. + if self._serial.write(packet) != len(packet): # Частичная локальная запись также не гарантирует передачу полной команды адаптеру. + raise SlcanError(f"SLCAN: не удалось передать {command}: {self._serial.errorString()}") # Указываем неотправленную команду и причину драйвера. + if self.options.profile in ("canable", "canable2"): # Эти прошивки не предоставляют CR/BEL для подтверждения конфигурации. + if self._serial.bytesToWrite() and not self._serial.waitForBytesWritten(self.options.command_timeout_ms): # Ждём фактическую передачу очереди Qt в драйвер, а не выдуманный ACK. + raise SlcanError(f"CANable: не удалось отправить {command}") # Ошибка локального драйвера не считается успешным подключением. + self._serial.waitForReadyRead(20) # Пауза до 20 мс даёт прошивке обработать отдельную команду USB CDC. + self._read() # Сохраняем уже пришедшие CAN-кадры, не требуя неподдерживаемого служебного ответа. + if self._command_reply is False or not self._serial.isOpen(): # Не игнорируем явный BEL и отключение даже в профиле без ACK. + raise SlcanError(f"CANable: отказ при выполнении {command}") # Пользователь получает конкретный этап неудачной настройки. + return # Статус интерфейса отдельно сообщает, что профиль не подтверждает команды. + while self._command_reply is None and self._serial.isOpen(): # CAN-кадры между служебными ответами не заменяют подтверждение команды. + remaining = self.options.command_timeout_ms - elapsed.elapsed() # Общий выбранный тайм-аут включает все фрагменты ответа команды. + if remaining <= 0: # Поток посторонних байтов не должен удерживать подключение в ожидании навсегда. + break # Далее сформируем понятную ошибку отсутствия SLCAN-ответа. + if self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining): # Используем уже поступившие байты или ждём только в пределах общего срока. + self._read() # Тот же обработчик разбирает ответы CR/BEL и сохраняет встреченные CAN-кадры. + else: # Драйвер завершил ожидание без новых данных либо порт был отключён. + break # Не запускаем повторный полный тайм-аут на ту же команду. + if not self._serial.isOpen(): # Физическое отключение во время настройки не является подтверждением команды. + raise SlcanError(f"SLCAN: порт закрыт при выполнении {command}: {self._serial.errorString()}") # Сообщаем о потере порта на конкретном этапе. + if self._command_reply is None: # Успешное открытие COM и запись в драйвер сами по себе не доказывают поддержку Lawicel. + raise SlcanError(f"SLCAN: нет ответа на {command}; проверьте тип адаптера и скорость COM") # Направляем пользователя к настройкам, влияющим на обмен ASCII-командами. + if not self._command_reply and not allow_rejection: # Только отказ повторного C при уже закрытом канале допускается протоколом настройки. + raise SlcanError(f"SLCAN: адаптер отклонил команду {command} (BEL)") # Отказ скорости или режима CAN больше не маскируется статусом подключения. + finally: # После команды подтверждения рабочего потока больше не меняют результат настройки. + self._waiting_command = False # Возвращаем обработчик приёма в обычный режим. def disconnect_channel(self) -> None: - if not self._serial.isOpen(): - return - self._serial.write(b"C\r") - self._serial.waitForBytesWritten(100) - self._serial.close() - self._buffer.clear() - self._listen_only = False + if self._serial.isOpen(): # Команда закрытия нужна только для реально открытого COM-порта. + self._serial.write(b"C\r") # Просим прошивку остановить CAN до освобождения порта. + self._serial.waitForBytesWritten(100) # Ограничиваем ожидание отправки закрытия ста миллисекундами, чтобы не задерживать выход. + self._serial.close() # Освобождаем порт независимо от доступности прошивки. + self._buffer.clear() # Частичная строка прежнего сеанса не должна приклеиваться к первому кадру нового подключения. + self._pending_frames.clear() # Отложенный обработчик Qt не должен выдавать кадры после отключения или переподключения. + self._after_cr = False # Новый сеанс начинает разбор окончаний строк без состояния предыдущего CR. + self._listen_only = False # Отключённый транспорт не сохраняет ограничение старого режима. def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None: if not self.connected: @@ -149,24 +202,109 @@ class SlcanAdapter(QObject): raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}") def _read(self) -> None: - self._buffer.extend(bytes(self._serial.readAll())) - while b"\r" in self._buffer: - raw, _, remainder = self._buffer.partition(b"\r") - self._buffer[:] = remainder - frame = decode_frame(raw) - if frame is not None: - self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False) - self.frame_received.emit(*frame) + if not self._serial.isOpen(): # Отложенное чтение после закрытия не должно обращаться к неоткрытому драйверу. + return # Отключение уже очистило очередь и неполную строку. + self._buffer.extend(bytes(self._serial.readAll())) # Накапливаем фрагменты USB/COM до полного кадра или служебного ответа. + while self._buffer: # За одно чтение драйвер может вернуть несколько кадров и подтверждений команд. + if self._after_cr: # CR+LF считается одним окончанием, даже если LF пришёл в следующем чтении. + self._after_cr = False # Состояние относится только к ближайшему байту после CR. + if self._buffer[0] == 10: # ASCII LF имеет код 10 и не является началом следующего SLCAN-кадра. + del self._buffer[0] # Удаляем дополнительный LF, чтобы он не мешал распознаванию t/T и не давал второй ACK. + continue # Продолжаем с первого байта следующей записи, если он уже поступил. + boundary = _RECORD_END.search(self._buffer) # Ищем первый разделитель, не сканируя весь остаток пакета отдельно для отсутствующих LF и BEL. + if boundary is None: # Неполную строку нельзя декодировать до поступления окончания. + break # Сохраняем её в буфере для следующего readyRead. + end = boundary.start() # Ближайшая граница сохраняет исходный порядок кадров и подтверждений команд. + raw = bytes(self._buffer[:end]) # Получаем строку без служебного разделителя. + separator = self._buffer[end] # Тип разделителя отличает подтверждение команды от BEL. + del self._buffer[:end + 1] # Удаляем обработанную запись, не теряя оставшуюся часть потока. + self._after_cr = separator == 13 # ASCII CR имеет код 13; следующий LF принадлежит этому же окончанию. + if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды. + if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки. + self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами. + continue # BEL не должен приклеиваться к следующему CAN-кадру. + if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы. + continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам. + frame = decode_frame(raw) # Кадры STD/EXT декодируются прежним публичным кодеком. + if frame is not None: # Служебные строки и ответы прошивки не попадают в общий монитор CAN. + self._pending_frames.append((frame[0], frame[1], raw[:1] == b"T")) # Сохраняем данные и признак EXT, включая первые кадры во время открытия. + elif raw and self._query_prefix is not None and raw.startswith(self._query_prefix): # Диагностические строки распознаются только после исключения CAN-кадров. + self._query_reply = raw.decode("ascii", errors="replace") # Версия CANable не имеет префикса V, поэтому допускается пустой ожидаемый префикс. + elif not raw and self._waiting_command and self._command_reply is None: # Пустая строка CR означает ACK по Lawicel; строка с CAN-кадром не подтверждает команду. + self._command_reply = True # Разрешаем отправку следующей команды только после реального подтверждения. + if not self._initializing: # Первые кадры выдаются после возврата подключения, когда вкладка уже установила скорость CAN. + pending, self._pending_frames = self._pending_frames, [] # Отделяем очередь до вызова подписчиков, чтобы повторное чтение не дублировало кадры. + for can_id, data, extended in pending: # Выдаём все накопленные кадры в исходном порядке. + self.capture_received.emit(can_id, data, extended, False) # Полные метаданные наполняют общий CAN-монитор. + self.frame_received.emit(can_id, data) # Совместимый сигнал сохраняет работу существующих клиентов ProtoCAN. def _error(self, error: QSerialPort.SerialPortError) -> None: - if error in (QSerialPort.SerialPortError.NoError, - QSerialPort.SerialPortError.NotOpenError): - return + if error in (QSerialPort.SerialPortError.NoError, # Сигнал NoError не означает потерю соединения. + QSerialPort.SerialPortError.NotOpenError, # Обращение после закрытия не требует повторного отключения. + QSerialPort.SerialPortError.TimeoutError): # Тайм-аут синхронного ожидания проверяет сама команда; порт при этом остаётся исправным. + return # Не превращаем отсутствие ответа на команду в ошибку физического соединения. if self._serial.isOpen(): message = self._serial.errorString() self._serial.close() self._listen_only = False - self.connection_lost.emit(f"Связь с SLCAN прервана: {message}") + if not self._initializing: # При настройке исключение возвращает connect_channel, избегая повторного отключения из обработчика GUI. + self.connection_lost.emit(f"Связь с SLCAN прервана: {message}") # Во время обычного приёма уведомляем вкладку о настоящем обрыве. def close(self) -> None: self.disconnect_channel() + + def _receive_fd(self, raw: bytes) -> bool: # Результат отличает корректный FD от диагностической строки с похожей первой буквой. + extended = raw[:1] in (b"D", b"B") # Заглавная буква означает идентификатор длиной восемь HEX-цифр. + width = 8 if extended else 3 # Стандартный ID занимает три HEX-цифры в ASCII-протоколе. + try: # Сохраняем устойчивость потока при повреждённых USB/COM-строках. + identifier = int(raw[1:1 + width], 16) # Извлекаем числовой ID без префикса команды. + length = FD_LENGTHS[int(raw[1 + width:2 + width], 16)] # Четырёхбитный DLC переводится в фактическое число байтов. + payload = bytes.fromhex(raw[2 + width:].decode("ascii")) # Для CANable FD не документирован дополнительный суффикс временной метки. + except (ValueError, UnicodeError, IndexError): # Неполный ID, DLC или HEX-пара отклоняют только текущую запись. + return False # Неподходящая строка ещё может оказаться ответом версии CANable. + if len(payload) != length or not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF): # Не обрезаем лишние байты и не принимаем отрицательный ID или идентификатор другого формата. + return False # Повреждённые данные не должны выглядеть успешным приёмом. + record = (identifier, payload, extended, 2 | (4 if raw[:1] in (b"b", b"B") else 0), 0) # CANable не предоставляет аппаратную метку; ноль явно обозначает её отсутствие. + self.fd_history.append(("RX", record)) # История ограничена размером deque и доступна после открытия диагностики. + self.fd_received.emit(*record) # В мост и прикладной декодер Classic этот кадр не поступает. + return True # Вызывающий обработчик не должен повторно трактовать этот кадр как служебный ответ. + + def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Используем документированные команды расширения CANable 2.0. + if not self.connected or self._listen_only or not self.options.fd: # Проверка защищает передачу независимо от состояния кнопок GUI. + raise SlcanError("Передача FD требует активного подключения CAN FD") # Пассивный режим не допускает обхода через API. + if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Разрешаем только точные длины DLC без скрытого дополнения. + raise SlcanError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Сообщаем фактический набор допустимых размеров. + prefix = ("B" if extended else "b") if brs else ("D" if extended else "d") # BRS выбирается буквой команды, а не полем ASCII-пакета. + packet = f"{prefix}{can_id:0{8 if extended else 3}X}{FD_LENGTHS.index(len(data)):X}{data.hex().upper()}\r".encode("ascii") # ID, DLC и данные образуют одну завершённую строку SLCAN. + if self._serial.write(packet) != len(packet): # Частичная запись не гарантирует доставку последнего CR в прошивку. + raise SlcanError("SLCAN не передал CAN FD") # Причина остаётся видимой в диагностическом окне отправки. + self.fd_history.append(("TX queued", (can_id, bytes(data), extended, 2 | (4 if brs else 0), 0))) # Локальная запись не выдаётся за подтверждение передачи на CAN. + + def diagnostics(self) -> str: # Явная диагностика не меняет режим CAN и не выполняется фоновым таймером. + if not self.connected: # Чтение версии не должно захватывать ещё не выбранный COM-порт. + raise SlcanError("Сначала подключите адаптер") # Настройка и жизненный цикл подключения остаются под управлением пользователя. + lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel. + canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR. + if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии. + lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y. + for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки. + self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса. + self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии. + elapsed = QElapsedTimer() # Ограничиваем полный запрос монотонным временем, а не числом фрагментов. + elapsed.start() # Тайм-аут включает отправку и все поступившие строки CAN. + try: # Состояние запроса очищается также при ошибке записи или обрыве порта. + packet = (command + "\r").encode("ascii") # Все команды завершаются одиночным CR протокола Lawicel. + if self._serial.write(packet) != len(packet): # Локальная ошибка передачи должна быть видна вызывающему коду. + raise SlcanError(f"Не удалось отправить {command}") # Указываем конкретный отказавший диагностический запрос. + while self._query_reply is None and self.connected: # Приём CAN-кадров продолжается тем же обработчиком. + remaining = self.options.command_timeout_ms - elapsed.elapsed() # Поток кадров не должен бесконечно продлевать ожидание версии. + if remaining <= 0 or not (self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining)): # Отсутствие ответа завершает только текущий запрос. + break # Следующие строки диагностики покажут, какая команда не поддерживается. + self._read() # Ответ распознаётся по префиксу, а CAN продолжает поступать обычным подписчикам. + if command == "E" and self._buffer.startswith(b"CANable Error Register:"): # Документированная исходная прошивка может возвращать E без CR. + self._query_reply = bytes(self._buffer).decode("ascii", errors="replace") # Такой ответ принимаем только после завершения ожидания, не обрезая поток CAN. + self._buffer.clear() # Нетерминированная служебная строка не должна приклеиваться к следующему CAN-кадру. + lines.append(self._query_reply or f"{command}: нет ответа") # Отсутствие данных не заменяем выдуманными версией или нулевыми ошибками. + finally: # Следующий обычный приём не должен сохранять посторонние строки как ответ старого запроса. + self._query_prefix = None # Завершаем явную диагностическую транзакцию. + return "\n".join(lines) # Текст можно скопировать для анализа состояния устройства. diff --git a/python/set_devices/qt_ports/tms_bootloader.py b/python/set_devices/qt_ports/tms_bootloader.py index 510dffe..d338f1a 100644 --- a/python/set_devices/qt_ports/tms_bootloader.py +++ b/python/set_devices/qt_ports/tms_bootloader.py @@ -11,12 +11,15 @@ from protocan import tms_firmware as wire class TmsBootloader(QObject): progress = Signal(int, str) finished = Signal(bool, str) + request_sent = Signal(bytes) def __init__(self, parent=None): super().__init__(parent) self._serial = QSerialPort(self) self._serial.readyRead.connect(self._read) self._serial.errorOccurred.connect(self._error) + self._owned_serial = self._serial + self._owns_serial = True self._timeout = QTimer(self) self._timeout.setSingleShot(True) self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}")) @@ -31,6 +34,7 @@ class TmsBootloader(QObject): self._active = False self._waiting = False self._rx = bytearray() + self.readback = bytearray() self._stage = "" def start(self, image, port: str, baud: int, target: wire.TmsTarget): @@ -45,6 +49,8 @@ class TmsBootloader(QObject): return self._image, self._target = image, target self._active = True + self._serial = self._owned_serial + self._owns_serial = True self._serial.setPortName(port) self._serial.setBaudRate(baud) self._serial.setDataBits(QSerialPort.DataBits.Data8) @@ -54,8 +60,29 @@ class TmsBootloader(QObject): if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite): self._finish(False, self._serial.errorString()) return + self._begin() + + def start_connected(self, image, serial, target: wire.TmsTarget): + """Use an existing port; its owner must forward RX and handle errors.""" + if self._active: + return + try: + target.validate(len(image.data)) + if not serial.isOpen() or serial.baudRate() <= 0: + raise ValueError("Подключите COM-порт в настройках 2812") + except ValueError as error: + self.finished.emit(False, str(error)) + return + self._image, self._target = image, target + self._serial = serial + self._owns_serial = False + self._active = True + self._begin() + + def _begin(self): + self.readback.clear() self._response = None - self._steps = wire.programming_steps(image.data, target) + self._steps = wire.programming_steps(self._image.data, self._target) self._advance() def _advance(self): @@ -66,6 +93,7 @@ class TmsBootloader(QObject): except StopIteration: self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6" + else "Прошивка записана; проверка отключена" if not self._target.verify else "Прошивка завершена, записанные данные проверены") return except (ValueError, RuntimeError) as error: @@ -79,11 +107,15 @@ class TmsBootloader(QObject): if self._serial.write(request) != len(request): self._finish(False, "Ошибка передачи " + self._stage) return + self.request_sent.emit(request) if self._active: - self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate())) + self._timeout.start(timeout + int((len(request) + self._size) * 10000 / self._serial.baudRate())) def _read(self): - chunk = bytes(self._serial.readAll()) + self.receive(bytes(self._serial.readAll())) + + def receive(self, chunk): + """Forward fragments from a shared port without reading it twice.""" if not self._waiting: return self._rx.extend(chunk) @@ -116,6 +148,10 @@ class TmsBootloader(QObject): self._timeout.stop() self._settle.stop() self._waiting = False + if self._command == wire.CMD_UPLOAD and self._target.kind == "tms": + data = wire.decode_upload_reply(reply, self._target.controller, self._size - 8) + remaining = len(self._image.data) - len(self.readback) + self.readback.extend(data[:remaining]) self._response = reply self._delay.start() @@ -133,8 +169,10 @@ class TmsBootloader(QObject): self._active = self._waiting = False for timer in (self._timeout, self._settle, self._delay): timer.stop() - if self._serial.isOpen(): + if self._owns_serial and self._serial.isOpen(): self._serial.close() + if not success: + self.readback.clear() if success: self.progress.emit(100, message) self.finished.emit(success, message) diff --git a/python/set_devices/qt_ports/viewport_navigation.py b/python/set_devices/qt_ports/viewport_navigation.py index a662265..f872b0e 100644 --- a/python/set_devices/qt_ports/viewport_navigation.py +++ b/python/set_devices/qt_ports/viewport_navigation.py @@ -1,5 +1,6 @@ """Debounced viewport statistics shared by desktop charts.""" import time +from threading import Event # Сигнал отмены позволяет фоновому расчёту прекращать обработку старого окна при продолжении масштабирования. from dataclasses import replace try: from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt @@ -16,6 +17,7 @@ class ViewportNavigation(QObject): self.chart = chart self._navigation_revision = 0 self._navigation_busy = False + self._navigation_cancel = Event() # Отдельное потокобезопасное событие относится к текущему фоновому расчёту измерений. self._navigation_emitter = _NavigationSignals(self) self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection) self._navigation_timer = QTimer(self) @@ -23,9 +25,13 @@ class ViewportNavigation(QObject): self._navigation_timer.setInterval(180) self._navigation_timer.timeout.connect(self._navigation_finished) + def needs_worker(self): # Общая нагрузка всех каналов определяет допустимость синхронного расчёта, а не длина самого длинного ряда. + return sum(len(series.values) for series in self.chart.plot._series) > 20000 # Бюджет 20000 исходных значений сохраняет быстрый путь малых записей и отправляет многоканальные окна в фон. + def _navigation_changed(self): self._navigation_revision += 1 - if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000: + self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов. + if not self.needs_worker(): # Проверяем суммарный объём тем же правилом, что используют панель измерений и легенда. self.chart._scope_measurements() self.chart._refresh_processing() self.chart._refresh_legend() @@ -35,7 +41,7 @@ class ViewportNavigation(QObject): def _navigation_finished(self): if False: return - if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000: + if not self.needs_worker(): # После задержки повторно проверяем текущие данные: источник мог измениться во время ожидания. self.chart._scope_measurements() self.chart._refresh_processing() return @@ -43,15 +49,16 @@ class ViewportNavigation(QObject): self._navigation_timer.start() return self._navigation_busy = True + self._navigation_cancel = Event() # После окончания перемещения создаём новый независимый сигнал отмены для актуального окна. bounds = self.chart.plot.visible_bounds panel = self.chart.processing offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0. # Navigation changes the next calculation's domain; retained curves keep # their original domain. Avoid preparing millions of inputs on the UI. panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset)) - series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete] - QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds, - self._navigation_emitter)) + series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if s.visible and not s.discrete] # Скрытые каналы не занимают процессор повторным расчётом статистики при перемещении графика. + QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds, # Фоновый расчёт использует полные исходные значения только актуального видимого окна. + self._navigation_emitter, self._navigation_cancel)) # Передаём событие отмены вместе с существующими ревизией и каналом результата. def _navigation_ready(self, revision, stats, bounds): self._navigation_busy = False diff --git a/python/set_devices/qt_ports/wavegen.py b/python/set_devices/qt_ports/wavegen.py index a2ede47..ba060ba 100644 --- a/python/set_devices/qt_ports/wavegen.py +++ b/python/set_devices/qt_ports/wavegen.py @@ -57,7 +57,14 @@ class WaveTransfer(QRunnable): result = protocol.status(words) if result['version'] >= 2: result = protocol.status(words, exchange(8)[0]) - return result + try: # Дополнительные регистры объявляют XY независимо от версии прежнего восьмисловного статуса. + channels,selected,maximum,other=exchange(12) # Четыре слова 0x1309…0x130C читаются общим C-кодеком. + result.update(channels=channels,selected=selected,max_channels=maximum,received_other=other) # Сохраняет фактические возможности порта и состояние обеих таблиц. + except ValueError as error: # Старые WG v1/v2/v3 отвечают исключением адреса либо имеют прежний native-кодек. + if "(-102)" not in str(error) and "Недопустимая команда" not in str(error): # Ошибки CRC и структуры ответа нельзя скрывать как одноканальную плату. + raise # Сохраняет штатный отказ передачи при повреждённом ответе. + result.update(channels=1,max_channels=1) # Старый клиент и прошивка сохраняют одноканальную работу без новых записей. + return result # Возможности XY проверяются до stop и изменения таблиц устройства. status = read_status() # Positive device identification before any write. if self.action == "upload": @@ -66,27 +73,40 @@ class WaveTransfer(QRunnable): raise ValueError("Таблица не помещается в память устройства") if status['version'] == 1 and wave.sample_rate > 50000: raise ValueError("Для частоты выше 50 000 отсчётов/с обновите прошивку МК: нужна карта генератора версии 2") + channel_codes=(wave.codes,) if not hasattr(wave,"codes_y") else (wave.codes,wave.codes_y) # Одна временная сетка содержит один канал либо обе координаты XY. + if len(channel_codes)>status["max_channels"]: # Устройство обязано явно подтвердить аппаратный порт второго канала. + raise ValueError("Для XY нужны прошивка F407/HOPE и native-кодек templates с поддержкой двух каналов") # Отказ происходит до остановки ранее работающего выхода. + if any(len(codes)!=len(wave.codes) for codes in channel_codes): # Нельзя загружать пару с разными длинами даже из вручную созданного задания. + raise ValueError("X и Y должны иметь одинаковую длину") # Не меняет оборудование при несовместимой временной сетке. exchange(1) + if status["max_channels"]>=2: # Новый порт явно сбрасывает формат перед каждой загрузкой, включая возврат XY→mono. + exchange(10,value=len(channel_codes)) # Выбирает число одновременно воспроизводимых каналов и инвалидирует обе прежние таблицы. if status['version'] >= 2: exchange(9, value=wave.sample_rate >> 16) exchange(2, value=wave.sample_rate & 0xffff) exchange(3, value=len(wave.codes)) blocks = status['version'] >= 3 and getattr(port, 'supports_wave_blocks', False) step = 120 if blocks else 1 - for index in range(0, len(wave.codes), step): - values = wave.codes[index:index + step] - if blocks: - transfer(protocol.block_request(index, values), protocol.block_response) - else: - exchange(4, index, values[0]) - self.signals.progress.emit(int((index + len(values)) * 50 / len(wave.codes))) - for index in range(0, len(wave.codes), step): - values = wave.codes[index:index + step] - actual = (transfer(protocol.block_request(index, count=len(values)), protocol.block_response) - if blocks else exchange(7, index)) - if tuple(actual) != tuple(values): - raise ValueError(f"Проверка таблицы не пройдена: отсчёты с {index}") - self.signals.progress.emit(50 + int((index + len(values)) * 50 / len(wave.codes))) + total=len(wave.codes)*len(channel_codes)*2 # Прогресс учитывает запись и чтение каждого кода обеих координат. + completed=0 # Общий счётчик исключает откат процентов при переходе от X к Y. + for channel,codes in enumerate(channel_codes): # Загружает и проверяет X, затем Y без промежуточного пуска. + if status["max_channels"]>=2: # Прежняя прошивка не получает неподдерживаемый селектор окна. + exchange(11,value=channel) # Одинаковые block/sample команды адресуют выбранную координату через общий регистр 0x130A. + for reading in (False,True): # Обратное чтение всей координаты обязательно до общего commit пары. + for index in range(0,len(codes),step): # Блочный транспорт ограничен прежними 120 кодами на запрос. + values=codes[index:index+step] # Последний блок содержит только действительные отсчёты без дополнения. + if reading: # Проверка использует ту же границу блока и тот же селектор, что запись. + actual=transfer(protocol.block_request(index,count=len(values)),protocol.block_response) if blocks else exchange(7,index) # Читает реальные коды из памяти устройства. + if tuple(actual)!=tuple(values): # Любое расхождение запрещает общий commit и дальнейший пуск. + raise ValueError(f"Проверка {'XY'[channel]} не пройдена: отсчёты с {index}") # Показывает ошибочную координату и начало блока. + elif blocks: # SET v2 переносит тот же атомарно проверяемый RTU-блок из общего ядра templates. + transfer(protocol.block_request(index,values),protocol.block_response) # Подтверждает запись всего блока до следующего запроса. + else: # Старый UART/RTU клиент сохраняет поотсчётную загрузку без смены wire-контракта. + exchange(4,index,values[0]) # Записывает очередной код выбранной координаты. + completed+=len(values) # Каждый подтверждённый код учитывается ровно один раз на фазу. + self.signals.progress.emit(int(completed*100/total)) # Общий прогресс монотонно достигает 100 % после проверки обеих таблиц. + if status["max_channels"]>=2: # Итоговый статус возвращается к X для одинакового отображения mono и XY. + exchange(11,value=0) # Не меняет содержимое пар и не запускает аппаратный вывод. exchange(5) elif self.action == "start": if not status["ready"]: @@ -98,6 +118,7 @@ class WaveTransfer(QRunnable): raise ValueError("Неизвестное действие") result = read_status() if self.action == "upload" and (not result["ready"] or result["running"] or + result.get("channels",1)!=(2 if hasattr(self.waveform,"codes_y") else 1) or # Итоговое число каналов обязано совпасть с подготовленным mono/XY заданием. result["count"] != len(self.waveform.codes) or result["rate"] != self.waveform.sample_rate): raise ValueError("Устройство не подтвердило загруженную таблицу") if self.action in ("start", "stop") and result["running"] != (self.action == "start"): diff --git a/python/set_devices/signal_diagnostics.py b/python/set_devices/signal_diagnostics.py new file mode 100644 index 0000000..a071da4 --- /dev/null +++ b/python/set_devices/signal_diagnostics.py @@ -0,0 +1,131 @@ +"""Локальные гипотезы неисправностей по сравнению осциллограмм с эталоном.""" # Правила не требуют сети, модели ИИ или знания имени файла. +import math # Конечность отсчётов и масштабы защищают признаки от пропусков и нулевого размаха. +import re # Обозначения физических каналов позволяют отделить Uce, Uge и Ic. +from set_devices.signal_fault_models import identify_fault_models # Параметрические модели конкурируют по BIC и ошибке контрольных наблюдений. + + +def _role(name): # Название файла не используется как доказательство неисправности. + match = re.match(r'^\s*(uce|uge|ic)\b', name, re.IGNORECASE) # Узнаём только явные обозначения напряжений ключа, затвора и тока. + return match.group(1).lower() if match else '' # Неизвестные обозначения получают только общие статистические гипотезы. + + +class _Edges: # Гистерезис отделяет полное повторное переключение от небольшого звона и шума. + def __init__(self, low, high): # Пороги задаются относительно размаха эталона в единицах канала. + self.low, self.high = low, high # Нижняя и верхняя границы определяют устойчивые состояния. + self.state, self.start, self.previous = None, None, None # Начальное состояние и разрыв не считаются фронтами. + self.events, self.count = [], 0 # Полный счётчик дополняется ограниченным списком для оценки задержки. + + def reset(self): # Через NaN или большой разрыв нельзя восстанавливать ненаблюдавшееся переключение. + self.state, self.start, self.previous = None, None, None # После разрыва ждём нового устойчивого состояния. + + def add(self, stamp, value): # Последовательный обход не создаёт дополнительную копию многомиллионной записи. + state = 0 if value <= self.low else 1 if value >= self.high else None # Зона между порогами сохраняет предыдущее устойчивое состояние. + if self.state is None: # Первая наблюдаемая полка задаёт исходное состояние. + self.state = state # Попадание только в середину фронта ещё не определяет направление. + elif state == self.state: # Возврат к прежней полке отменяет незавершённый переход. + self.start = None # Шум внутри полосы не накапливает ложную длительность фронта. + else: # Сигнал покинул прежнюю полку и может достичь противоположной. + if self.start is None: # Запоминаем начало только один раз на каждый фронт. + self.start = self.previous if self.previous is not None else stamp # Шаг дискретизации ограничивает точность начала перехода. + if state is not None: # Полное пересечение гистерезиса подтверждает переключение. + self.count += 1 # Считаем все наблюдаемые фронты независимо от длины записи. + if len(self.events) < 256: # Первых 256 переходов достаточно для сводки; память остаётся ограниченной. + self.events.append(((self.start + stamp) / 2., stamp - self.start, state)) # Сохраняем середину, длительность в мс и направление фронта. + self.state, self.start = state, None # Новая полка становится исходной для следующего перехода. + self.previous = stamp # Предыдущая временная метка нужна для оценки длительности на дискретной сетке. + + +def analyze_comparison(result, cancel=lambda: False, *, circuit=None, channels=None): # Подбор получает необязательные параметры электрического эквивалента и связанные физические каналы. + metrics = result.metrics # Покрытие и допуски уже рассчитаны по полным исходным данным. + measured_role, reference_role = _role(result.measured.name), _role(result.reference.name) # Физические роли важнее совпадения единицы «В». + findings, notes = [], [] # Несколько одновременно наблюдаемых признаков сохраняются в одном отчёте. + features = {} # Численные признаки экспортируются для проверки и воспроизведения решения. + model_report = {} # До проверки входных данных никакая математическая модель не считается идентифицированной. + def finish(summary, attention=True): # Краткая строка и подробности всегда относятся к одному результату. + details = [summary, f'Каналы: {result.measured.name} ↔ {result.reference.name}.', # Обозначаем фактически выбранные измерение и эталон. + f'Сравнено {metrics["count"]} отсчётов; покрытие {metrics["coverage_pct"]:.4g}%; за допуском {metrics["outside_tolerance"]}.', # Не смешиваем непокрытые точки с превышениями. + f'RMSE {metrics["rmse"]:.7g}; максимум |Δ| {metrics["max_abs"]:.7g} в единицах канала.', # Ошибка сохраняет физические единицы выбранной пары. + *[item['text'] for item in findings if item['text'] != summary], *notes, # Совместные причины и ограничения сохраняются без повторения единственного краткого вывода. + 'Диагностические гипотезы основаны на параметрических моделях. BIC и контрольная ошибка оценивают согласие модели с данными, но не доказывают отказ конкретного элемента.'] # Отделяем идентификацию динамики от причинного диагноза оборудования. + if features: # Наблюдаемые численные признаки доступны только после полного прохода по данным. + details.insert(4, f'Полных переходов: эталон {features["reference_edges"]}, измерение {features["measured_edges"]}; смен знака после выключения {features["turn_off_reversals"]}; выброс после выключения {features["turn_off_overshoot"]:.7g} в единицах канала.') # Объясняем гипотезы дребезга и снаббера фактическими числами. + for model in model_report.get('models', []): # Подробности показывают также конкурентов выбранного объяснения. + details.append(f'Модель {model["code"]}: {model["formula"]}\nПараметры: {model["parameters"]}\nRMSE={model["rmse"]:.6g}; контроль={model["validation_rmse"]:.6g}; BIC={model["bic"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; объяснено ошибки {model["explained_error_pct"]:.3g}%.') # Каждый вывод сопровождается фактическими параметрами и штрафом за сложность. + for pair in model_report.get('ringing', []): # Отклики измерения и эталона имеют отдельные динамические оценки. + for side in ('measured', 'reference'): # Не выдаём качество одного сигнала за проверку другой стороны сравнения. + model = pair.get(side) # Эталон может не содержать пригодного колебательного участка. + if model: # Отсутствующая оценка не форматируется как нулевая частота или затухание. + details.append(f'RLC {side}: {model["parameters"]}\nR²={model["r_squared"]}; контроль RMSE={model["validation_rmse"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; модель подтверждена: {model["supported"]}.') # Приводим качество именно подбора затухающего отклика. + details.append(f'Эквивалентный контур: {pair["equivalent"]}') # Введённые элементы, восстановленные значения и неопределённость сохраняются в отчёте. + joint = model_report.get('joint', {}) # Совместная электрическая проверка не должна теряться в одном только JSON. + if joint: # Даже отсутствие каналов является существенным ограничением модели. + details.append(f'Совместные модели Uce/Uge/Ic: {joint}') # Сохраняем результаты ВАХ, остаточного тока, совпадения провалов и причины неидентифицируемости. + details.extend(model_report.get('assumptions', [])) # Предположения BIC, выборки и эквивалентной топологии видны рядом с результатами. + if model_report.get('criteria'): # Критерии поддержки модели не должны оставаться неявными константами кода. + details.append(f'Начальные критерии принятия моделей: {model_report["criteria"]}. Это настройки анализа, не паспортные нормы оборудования.') # Отделяем математическую идентификацию от выбранной чувствительности диагностического решения. + return dict(summary=summary, details='\n\n'.join(details), attention=attention, findings=findings, features=features, model_report=model_report, version=2) # Новый отчёт сохраняет совместимые поля и добавляет структурированные математические модели. + def finding(code, text): # Коды не зависят от русской формулировки и позволяют проверять сценарии. + findings.append(dict(code=code, text=text)) # Сохраняем причину вместе с объясняющим наблюдением. + if measured_role and reference_role and measured_role != reference_role: # Uce и Uge нельзя диагностировать как один физический сигнал даже при одинаковых единицах. + return finish('Выбраны разные физические каналы; сопоставьте одинаковые Uce, Uge или Ic.') # Не выдаём ложную неисправность при ошибке выбора на экране. + units = [re.search(r'\(([^()]*)\)\s*$', trace.name) for trace in (result.measured, result.reference)] # Проверяем явные единицы также при независимом вызове ядра без Qt. + if all(units) and units[0].group(1) != units[1].group(1): # Одинаковые обозначения с разными единицами не позволяют строить аппаратные гипотезы. + return finish('Единицы каналов различаются; сопоставьте сигналы с одинаковыми единицами.') # Численные показатели доступны, но физическая причина не определяется. + if metrics['coverage_pct'] < 100.: # Любое неполное покрытие ограничивает вывод областью реально наблюдаемых данных. + notes.append('Покрытие неполное: пропуски и время вне эталона исключены; состояние всей записи не определено.') # Даже нулевая ошибка на остатке не подтверждает исправность всей записи. + if metrics['count'] < 16: # Минимум 16 общих точек нужен для распознавания формы, а не для вычисления RMSE. + return finish('Недостаточно общих отсчётов для гипотезы неисправности.') # Короткий набор не должен давать уверенное заключение. + low, high = metrics['reference_min'], metrics['reference_max'] # Пределы относятся к интерполированному эталону выбранного интервала. + span = high - low # Размах определяет относительные пороги переключения. + scale = max(span, abs(low), abs(high), 1e-12) # Для постоянной ненулевой полки используем её уровень; 10^-12 исключает деление на ноль. + switching_span = span >= .5 * scale # Полный переход должен занимать не менее половины масштаба; малый звон около 400 В не является включением ключа. + expected_edges = _Edges(low + .3 * span, low + .7 * span) # Уровни 30% и 70% подавляют малые колебания на полках. + measured_edges = _Edges(expected_edges.low, expected_edges.high) # Одинаковые пороги позволяют сравнивать число и длительность переходов. + minimum, maximum, step = math.inf, -math.inf, math.inf # Границы измерения и минимальный шаг рассчитываются без дополнительного массива. + previous, residual_sign, reversals = None, 0, 0 # Знак значимого остатка нужен для распознавания затухающего звона. + off_sign, off_reversals, off_overshoot = 0, 0, 0. # Снаббер проверяется по колебаниям после выключения Uce, отдельно от помех на включённой полке. + band = max(.02 * scale, metrics['absolute_tolerance']) # 2% масштаба и абсолютный допуск отсекают небольшую погрешность и фоновый шум. + for index, (stamp, expected, delta) in enumerate(zip(result.difference.times, result.expected, result.difference.values)): # Анализируем каждый сравниваемый отсчёт без экранного прореживания. + if index % 16384 == 0 and cancel(): # Проверка отмены порциями по 16384 точки сохраняет отзывчивость больших записей. + raise InterruptedError('Анализ отменён') # Устаревшие гипотезы не публикуются после смены записи. + if not math.isfinite(expected) or not math.isfinite(delta): # Пропуск обрывает и фронт, и последовательность знаков остатка. + expected_edges.reset(); measured_edges.reset() # Обе стороны имеют общую область достоверного наблюдения. + previous, residual_sign = None, 0 # Через NaN нельзя считать смену знака или длительность фронта. + off_sign = 0 # Пропуск также прерывает подтверждение звона после выключения. + continue # Некорректные значения не влияют на признаки. + if previous is not None: # Первый корректный отсчёт ещё не определяет шаг времени. + distance = stamp - previous # Внутренняя единица времени — миллисекунда. + if distance > 5. * step: # Разрыв более пяти минимальных шагов не интерполируется диагностикой. + expected_edges.reset(); measured_edges.reset() # Не считаем фронт через ненаблюдавшийся промежуток времени. + residual_sign = 0 # Звон после большого разрыва начинается новой последовательностью. + off_sign = 0 # Колебания по разные стороны временного разрыва не образуют одну последовательность. + step = min(step, distance) # Минимальный наблюдаемый шаг задаёт нижнюю границу временной точности. + value = expected + delta # Восстанавливаем амплитуду на общей сетке без поиска по исходному массиву. + minimum, maximum = min(minimum, value), max(maximum, value) # Выброс сравнивается с огибающей эталона. + if switching_span: # Небольшие колебания отдельной полки не должны считаться полными переключениями транзистора. + expected_edges.add(stamp, expected); measured_edges.add(stamp, value) # Считаем переходы обеих кривых на одной временной сетке. + sign = 1 if delta > band else -1 if delta < -band else 0 # Малая ошибка внутри полосы не образует новую полуволну звона. + if sign: # Нулевая зона между полуволнами сохраняет последний значимый знак. + reversals += int(residual_sign != 0 and residual_sign != sign) # Смена положительного и отрицательного отклонения подтверждает колебание. + residual_sign = sign # Следующая значимая полуволна сравнивается с текущей. + if expected_edges.count and expected_edges.state == 1 and expected >= expected_edges.high: # Для Uce высокая полка после наблюдаемого фронта соответствует выключенному транзистору. + off_overshoot = max(off_overshoot, value - high, low - value) # Выброс на включённой полке не является признаком неисправности снаббера. + if sign: # Для звона учитываем только значимые знакопеременные отклонения на выключенной полке. + off_reversals += int(off_sign != 0 and off_sign != sign) # Пересчитываем смены знака отдельно от всех помех записи. + off_sign = sign # Последняя полуволна сохраняется до следующего выключенного отсчёта. + else: # Включённая полка и область фронта прерывают проверяемую последовательность звона. + off_sign = 0 # Шум между переключениями не связывается со следующим выключением. + previous = stamp # Временная непрерывность проверяется только по корректным точкам. + if cancel(): # Проверяем отмену также после последней неполной порции. + raise InterruptedError('Анализ отменён') # Не возвращаем результат закрытой панели. + features.update(reference_edges=expected_edges.count, measured_edges=measured_edges.count, residual_reversals=reversals, # Количественные признаки объясняют гипотезы дребезга и звона. + overshoot=max(0., maximum - high, low - minimum), turn_off_reversals=off_reversals, turn_off_overshoot=off_overshoot, sample_step_ms=step if math.isfinite(step) else None) # Общий выброс отделён от признаков после выключения; шаг выражен в мс. + physical = bool(measured_role and measured_role == reference_role) # Электрические гипотезы допустимы только для одинаковых явно обозначенных физических каналов. + model_report, findings = identify_fault_models(result, expected_edges.events, features, physical, measured_role, circuit, channels, cancel) # Конкуренты подбираются по данным и проверяются на отложенных точках. + if metrics['outside_tolerance'] == 0 and not findings: # Совпадение выбранного Uce не скрывает нарушение, подтверждённое другими синхронными каналами. + summary = 'Отклонений от эталона в заданных допусках не обнаружено.' if metrics['absolute_tolerance'] > 0. or metrics['relative_tolerance_pct'] > 0. else 'Сигнал совпадает с эталоном; допуски не заданы.' # Совпадение формы не является полной проверкой исправности устройства. + return finish(summary + (' Покрытие неполное.' if notes else ''), bool(notes)) # Сохраняем ограничение неполного покрытия и параметры найденных моделей. + if metrics['absolute_tolerance'] == 0. and metrics['relative_tolerance_pct'] == 0.: # Нулевой коридор должен быть явно отличим от заданного норматива. + notes.append('Допуски не заданы: любое ненулевое отклонение считается превышением; задайте погрешность измерения.') # Обычный шум не должен восприниматься как доказанный аппаратный отказ. + summary = ' '.join(item['text'] for item in findings) # Разные модели совместного дефекта остаются видимыми в одной строке. + return finish(summary + (' Покрытие неполное.' if metrics['coverage_pct'] < 100. else '')) # Краткий вывод связан с тем же покрытием, что полный математический отчёт. diff --git a/python/set_devices/signal_eigen.py b/python/set_devices/signal_eigen.py new file mode 100644 index 0000000..b821216 --- /dev/null +++ b/python/set_devices/signal_eigen.py @@ -0,0 +1,18 @@ +import ctypes as C # Загружаем необязательный настольный модуль через стабильный C ABI. +from functools import lru_cache # Удерживаем DLL и её функции, пока расчётные задачи используют указатели. +import os # Разрешаем явно выбрать альтернативную библиотеку в тестах или нестандартной установке. +from pathlib import Path # Ищем модуль рядом с уже выбранной основной SETProtocol DLL. +import sys # Расширение нативного модуля соответствует текущей настольной ОС. + +@lru_cache(maxsize=1) # Один экземпляр DLL безопасно обслуживает независимые расчёты без глобальных рабочих буферов. +def library(core_path): # Путь основной библиотеки задаёт согласованный каталог установки обоих модулей. + name = {"win32": "setsignal_eigen.dll", "darwin": "libsetsignal_eigen.dylib"}.get(sys.platform, "libsetsignal_eigen.so") # Отдельное имя сохраняет возможность запуска с прежней основной DLL. + path = Path(os.environ.get("SETSIGNAL_EIGEN_LIBRARY", str(Path(core_path).resolve().with_name(name)))) # Явный путь окружения имеет приоритет над соседним компонентом приложения. + try: # Отсутствующий или несовместимый компонент показывается пользователю как ошибка выбранного варианта. + result = C.CDLL(str(path)) # Загружаем только явно вычисленный путь, не подбирая случайную DLL из PATH. + pointer = C.POINTER(C.c_double) # Входные и выходные массивы содержат значения double общего алгоритма. + result.set_signal_reconstruct_eigen.argtypes = [pointer, pointer, C.c_size_t, C.c_int, C.c_uint, C.c_size_t, C.c_int, pointer, pointer, pointer, pointer, C.c_size_t] # Сигнатура повторяет исходную реконструкцию и сохраняет размер size_t для x86/x64. + result.set_signal_reconstruct_eigen.restype = C.c_int # Ошибки ранга и нехватки памяти возвращаются целочисленным кодом. + return result # Удерживаем объект DLL для всего времени жизни привязанной функции. + except (AttributeError, OSError) as error: # Отдельно обрабатываем отсутствие экспорта и невозможность загрузки библиотеки. + raise ValueError("Eigen QR недоступен: соберите модуль cpp/signal-eigen/build.py для разрядности приложения") from error # Не подменяем явно выбранный Eigen текущим МНК. diff --git a/python/set_devices/signal_fault_models.py b/python/set_devices/signal_fault_models.py new file mode 100644 index 0000000..97a644e --- /dev/null +++ b/python/set_devices/signal_fault_models.py @@ -0,0 +1,396 @@ +"""Подбор конкурирующих моделей отклонения и эквивалентного колебательного контура.""" # Численный модуль использует стандартную библиотеку и не зависит от Qt. +from bisect import bisect_left # Поиск соседних отсчётов сохраняет реальные, в том числе неравномерные временные сетки. +import math # Экспоненты, тригонометрия и логарифмы задают динамические модели и критерий BIC. +from statistics import median # Робастные начальные оценки уменьшают влияние отдельных выбросов. + +MAX_FIT_POINTS = 2048 # Ограничение относится только к подбору моделей; исходные метрики и поиск событий сохраняют все точки. + + +def checkpoint(cancel): # Подбор нелинейных параметров обязан прерываться при смене записи или закрытии окна. + if cancel(): # Callback передаётся из существующего рабочего потока сравнения. + raise InterruptedError('Анализ моделей отменён') # Не публикуем незавершённый подбор как достоверный результат. + + +def bounded_minimum(operation, left, right, iterations=24): # Ограниченный золотой поиск уточняет одну нелинейную координату без внешнего оптимизатора. + ratio = (math.sqrt(5.) - 1.) / 2. # Золотое сечение переиспользует одну оценку функции на каждом шаге. + a, b = right - ratio * (right - left), left + ratio * (right - left) # Две внутренние точки определяют следующую область поиска. + fa, fb = operation(a), operation(b) # Операция возвращает модель с обучающей ошибкой или отсутствие допустимого решения. + error = lambda model: model['training_sse'] if model else math.inf # Некорректный кандидат не может выигрывать ограниченную оптимизацию. + for _ in range(iterations): # Фиксированное число итераций ограничивает время и задаёт воспроизводимую точность. + if error(fa) < error(fb): # Минимум остаётся в левой части текущей области. + right, b, fb = b, a, fa # Правая внутренняя точка переиспользует уже вычисленное значение. + a = right - ratio * (right - left) # Новая точка лежит внутри уменьшенного интервала. + fa = operation(a) # Пересчитываем только одну модель за итерацию. + else: # Лучшее направление лежит справа или обе оценки одинаковы. + left, a, fa = a, b, fb # Левая точка также переиспользуется без повторного подбора. + b = left + ratio * (right - left) # Вычисляем новую внутреннюю правую точку. + fb = operation(b) # Контрольные наблюдения по-прежнему не участвуют в целевой функции. + return fa if error(fa) < error(fb) else fb # Возвращаем лучший допустимый набор параметров найденного интервала. + + +def interpolate(times, values, stamp): # Интерполяция для моделей не продолжает кривую за границы записи. + index = bisect_left(times, stamp) # Находим первую опору не левее запрошенного времени. + if index < len(times) and times[index] == stamp: # Точное попадание не зависит от соседнего пропуска. + return values[index] if math.isfinite(values[index]) else None # Неконечная опора остаётся отсутствующим наблюдением. + if index == 0 or index == len(times): # За границами данных экстраполяция запрещена. + return None # Модель с отсутствующей опорой не получает искусственного крайнего значения. + left, right = values[index - 1], values[index] # Две соседние амплитуды определяют отрезок интерполяции. + if not math.isfinite(left) or not math.isfinite(right): # Через явный разрыв NaN модель не строится. + return None # Невалидный участок будет исключён до сравнения моделей. + return left + (right - left) * (stamp - times[index - 1]) / (times[index] - times[index - 1]) # Вес вычисляется по фактическому времени, а не номеру точки. + + +def least_squares(columns, values): # Небольшой QR-решатель избегает неустойчивых нормальных уравнений при разном масштабе параметров. + count, size = len(values), len(columns) # Число наблюдений и неизвестных определяет размеры ортогональной системы. + if count <= size or not size: # Для оценки ошибки нужны наблюдения сверх числа параметров. + return None # Недоопределённую модель нельзя использовать для аппаратной гипотезы. + q, upper = [], [[0.] * size for _ in range(size)] # Храним только малую треугольную матрицу и ортогональные столбцы. + scales = [math.sqrt(sum(value * value for value in column)) for column in columns] # Нормируем столбцы перед проверкой независимости параметров. + for index, column in enumerate(columns): # Модифицированный Грамм–Шмидт выполняется отдельно для каждого неизвестного. + if scales[index] <= 1e-30: # Нулевой столбец означает, что параметр не наблюдается в выбранных данных. + return None # Не выдаём произвольный коэффициент для ненаблюдаемого воздействия. + vector = [value / scales[index] for value in column] # Безразмерная нормировка снижает влияние единиц В, А и мкс на численную устойчивость. + for _ in range(2): # Повторная ортогонализация уменьшает потерю точности у почти зависимых столбцов. + for previous, basis in enumerate(q): # Удаляем уже описанные направления изменения сигнала. + projection = sum(a * b for a, b in zip(basis, vector)) # Скалярное произведение определяет долю предыдущего базиса. + upper[previous][index] += projection # Обе ортогонализации относятся к одной треугольной системе. + vector = [value - projection * base for value, base in zip(vector, basis)] # Остаток содержит только новое независимое направление. + norm = math.sqrt(sum(value * value for value in vector)) # Длина остатка показывает идентифицируемость следующего параметра. + if norm < 1e-9: # Относительная независимость меньше 10^-9 считается численно вырожденной. + return None # Например, постоянный ток не позволяет раздельно определить U0 и сопротивление. + upper[index][index] = norm # Диагональ задаёт масштаб нового ортогонального направления. + q.append([value / norm for value in vector]) # Единичный столбец сохраняет устойчивую проекцию правой части. + target = [sum(a * b for a, b in zip(basis, values)) for basis in q] # Проецируем измерение на полученный ортонормированный базис. + coefficients = [0.] * size # Обратный ход начинается с последнего неизвестного. + for index in reversed(range(size)): # Верхнетреугольная система решается без дополнительной факторизации. + coefficients[index] = (target[index] - sum(upper[index][j] * coefficients[j] for j in range(index + 1, size))) / upper[index][index] # Вычитаем уже найденные параметры справа от диагонали. + return [value / scale for value, scale in zip(coefficients, scales)] # Возвращаем параметры в исходных физических единицах столбцов. + + +def evaluate(columns, coefficients): # Одна формула вычисляет прогноз как для обучающих, так и для контрольных точек. + return [sum(column[index] * value for column, value in zip(columns, coefficients)) for index in range(len(columns[0]))] # Сумма базисных функций не обращается к измеренной амплитуде. + + +def fit_model(code, formula, columns, values, names, extra_parameters=0, params=None): # Каждый кандидат оценивается на одних и тех же наблюдениях. + training = [index for index in range(len(values)) if index % 3 != 1] # Каждая третья точка оставляется для проверки интерполяционного прогноза. + coefficients = least_squares([[column[i] for i in training] for column in columns], [values[i] for i in training]) # Контрольные амплитуды не участвуют в оценке линейных коэффициентов. + if coefficients is None: # Вырожденная модель не должна выигрывать из-за произвольных параметров. + return None # Вызывающая сторона честно сообщает отсутствие идентифицируемой оценки. + prediction = evaluate(columns, coefficients) # Один набор параметров применяется также к отложенным точкам. + parameters = dict(zip(names, coefficients)) # Имена отражают физический смысл оценённых коэффициентов. + parameters.update(params or {}) # Нелинейные параметры включаются в тот же машиночитаемый отчёт. + return score_model(code, formula, values, prediction, len(columns) + extra_parameters, parameters) # BIC учитывает и линейные, и нелинейные степени свободы. + + +def score_model(code, formula, values, prediction, parameters, params): # Показатели качества имеют одинаковое определение для всех конкурентов. + residuals = [value - fitted for value, fitted in zip(values, prediction)] # Остаток всегда сохраняет знак «измерение минус модель». + square = sum(value * value for value in residuals) # SSE измеряется в квадрате единиц анализируемого канала. + validation = [value for index, value in enumerate(residuals) if index % 3 == 1] # Считаем ошибку только по точкам, не использованным для линейного подбора. + training_square = sum(value * value for index, value in enumerate(residuals) if index % 3 != 1) # Нелинейный поиск оптимизирует только обучающую часть. + training_count = len(values) - len(validation) # BIC вычисляется на тех же наблюдениях, на которых оценивались параметры модели. + mean = sum(values) / len(values) # Среднее определяет постоянную модель для вычисления R². + variance = sum((value - mean) ** 2 for value in values) # Полный разброс нужен только для описания качества, а не для постановки диагноза. + residual_mean = sum(residuals) / len(residuals) # Среднее остатка вычисляем один раз, сохраняя линейную сложность оценки кандидата. + centered = [value - residual_mean for value in residuals] # Автокорреляция оценивается после удаления среднего остатка. + denominator = sum(value * value for value in centered) # Нулевой остаток не имеет определённой автокорреляции. + floor = max(1e-24, max(abs(value) for value in values) ** 2 * 1e-24) # Численный пол 10^-12 амплитудного масштаба не является допуском исправности. + return dict(code=code, formula=formula, parameters=params, parameter_count=parameters, count=len(values), sse=square, training_sse=training_square, # Сохраняем параметры и число степеней свободы для независимого воспроизведения. + rmse=math.sqrt(square / len(values)), validation_rmse=math.sqrt(sum(value * value for value in validation) / max(1, len(validation))), # Контрольная ошибка отделена от ошибки по всей выборке. + bic=training_count * math.log(max(training_square / training_count, floor)) + parameters * math.log(training_count), bic_count=training_count, # Для гауссовой ошибки сравниваем BIC обучения, сохраняя контрольные точки вне оценки параметров и критерия. + r_squared=1. - square / variance if variance > floor else None, residual_lag1=sum(a * b for a, b in zip(centered, centered[1:])) / denominator if denominator > floor else None) # Корреляция остатка показывает неописанную динамику и ограничение гауссового критерия. + + +def _samples(result, maximum=MAX_FIT_POINTS, cancel=lambda: False): # Стратифицированная выборка сохраняет экстремумы ошибки каждого временного блока и поддерживает отмену. + count = len(result.difference.times) # Метрики и старые признаки уже рассчитаны по всем исходным отсчётам. + width = max(1, math.ceil(count / max(1, maximum // 4))) # В каждом блоке оставляем начало, конец и два экстремума остатка. + selected = set() # Совпадающие индексы не должны менять вес отдельного наблюдения. + for start in range(0, count, width): # Число выбранных точек ограничено независимо от размера исходного CSV. + checkpoint(cancel) # Подготовка многомиллионной записи прерывается между небольшими временными блоками. + stop = min(count, start + width) # Последний неполный блок также участвует в подборе. + valid = [i for i in range(start, stop) if math.isfinite(result.expected[i]) and math.isfinite(result.difference.values[i])] # Пропуски остаются исключёнными из подбора. + if valid: # Пустой блок не создаёт фиктивного измерения. + selected.update((valid[0], valid[-1], min(valid, key=lambda i: result.difference.values[i]), max(valid, key=lambda i: result.difference.values[i]))) # Узкие импульсы сохраняются даже при длинной записи. + return [(result.difference.times[i] * 1000., result.expected[i], result.expected[i] + result.difference.values[i]) for i in sorted(selected)] # Модели используют микросекунды и исходные единицы амплитуды. + + +def fit_reference_models(result, events, cancel=lambda: False): # Конкурируют нормальный эталон, калибровка, дрейф, задержка, растяжение и разрывы. + samples = _samples(result, cancel=cancel) # Подбор ограничен по объёму и не меняет полную статистику сравнения. + if len(samples) < 24: # Трёхчастное разбиение требует достаточно обучающих и контрольных точек. + return dict(models=[], best=None, sampled_points=len(samples), sampling='стратифицированные экстремумы остатка') # Не называем короткую запись пригодной для идентификации модели. + duration = samples[-1][0] - samples[0][0] # Длительность выражена в микросекундах. + margin = .1 * duration if events else 0. # Общий интервал опор исключает экстраполяцию при поиске задержки до 10% длительности. + reference_times = result.reference.times # Повторно используем полную миллисекундную сетку без многомиллионной дополнительной копии. + samples = [point for point in samples if reference_times[0] * 1000. + margin <= point[0] <= reference_times[-1] * 1000. - margin] # Все глобальные модели сравниваются на строго одинаковой области поддержки в мкс. + if len(samples) < 24: # Слишком короткое перекрытие не позволяет честно сравнивать кандидатов. + return dict(models=[], best=None, sampled_points=len(samples), sampling='нет общего интервала моделей') # Не подменяем отсутствующие точки экстраполяцией. + times, reference, measured = map(list, zip(*samples)) # Параллельные массивы имеют одинаковую длину и порядок. + ones = [1.] * len(times) # Свободный член описывает смещение нуля в единицах канала. + models = [score_model('reference', 'y(t)=r(t)', measured, reference, 0, {})] # Исходный эталон является обязательной нулевой гипотезой. + offset = fit_model('offset', 'y(t)=r(t)+b', [ones], [value - base for value, base in zip(measured, reference)], ['offset']) # Смещение подбирается по остатку при фиксированном единичном усилении. + if offset: # Ошибки остатка совпадают с ошибками исходной аддитивной модели. + models.append(offset) # Не вводим лишний коэффициент усиления для постоянного смещения. + for code, formula, columns, names in [('gain', 'y(t)=a*r(t)+b', [reference, ones], ['gain', 'offset']), # Аффинная модель отделяет масштаб измерения от аппаратной динамики. + ('drift', 'y(t)=a*r(t)+b+c*(t-t0)', [reference, ones, [stamp - times[0] for stamp in times]], ['gain', 'offset', 'drift_per_us']), # Линейный дрейф описывает изменение питания или нуля по времени. + ('constant', 'y(t)=b', [ones], ['level'])]: # Постоянная модель проверяет отсутствие ожидаемого переключения. + candidate = fit_model(code, formula, columns, measured, names) # Линейные коэффициенты получают независимую контрольную ошибку. + if candidate: # Вырожденные варианты не входят в рейтинг. + models.append(candidate) # Все допустимые кандидаты сохраняются для полного отчёта. + centers = [event[0] * 1000. for event in events] # Центры эталонных переходов задают локальное растяжение без изменения паузы между импульсами. + if centers and len(centers) <= 32 and margin > 0. and result.metrics['outside_tolerance']: # Не подбираем сложную динамику для допустимого сигнала или более 32 неоднозначных переходов. + def temporal(lag, stretch, code): # Нелинейные параметры подбираются отдельно от линейных усиления и нуля. + checkpoint(cancel) # Каждый кандидат проверяет отмену до большого прохода интерполяции. + warped = [] # Прогноз эталона строится на той же сетке всех глобальных моделей. + for stamp in times: # Сдвиг и растяжение не меняют метки фактического измерения. + center = min(centers, key=lambda value: abs(stamp - lag - value)) # Ближайший фронт определяет локальную систему времени. + query = center + (stamp - lag - center) / stretch # Положительный lag задерживает измерение, stretch>1 растягивает фронт. + value = interpolate(reference_times, result.reference.values, query / 1000.) # Перевод мкс в мс сохраняет исходную полную сетку без копирования. + if value is None: # Отсутствующая опора делает именно этого кандидата недопустимым. + return None # Нельзя улучшать SSE уменьшением числа контрольных точек. + warped.append(value) # Временная модель сохраняет все наблюдения общей области. + return fit_model(code, 'y(t)=a*r(te+(t-delay-te)/stretch)+b', [warped, ones], measured, ['gain', 'offset'], 1 if code == 'timing' else 2, dict(delay_us=lag, stretch=stretch)) # BIC штрафует оба нелинейных параметра растяжения. + best_delay = None # Чистая задержка конкурирует с изменением формы фронта. + for index in range(21): # Грубая сетка из 21 точки охватывает ±10% общей длительности. + candidate = temporal(-margin + 2. * margin * index / 20., 1., 'timing') # Растяжение фиксировано единицей для отдельной модели задержки. + if candidate and (best_delay is None or candidate['training_sse'] < best_delay['training_sse']): # Нелинейный поиск использует только обучающие остатки. + best_delay = candidate # Контрольные точки не выбирают оптимальную задержку. + if best_delay: # При наличии перекрытия уточняем задержку последовательным уменьшением шага. + step = margin / 10. # Начальный шаг равен расстоянию между грубыми кандидатами. + for _ in range(9): # Девять уточнений дают разрешение существенно меньше исходного шага фронта. + for lag in (best_delay['parameters']['delay_us'] - step / 2., best_delay['parameters']['delay_us'] + step / 2.): # Симметричный поиск допускает опережение и запаздывание. + candidate = temporal(lag, 1., 'timing') # Линейные параметры заново оптимизируются для каждой задержки. + if candidate and candidate['training_sse'] < best_delay['training_sse']: # Контрольная ошибка остаётся независимой от решения. + best_delay = candidate # Принимаем только реальное улучшение обучающего описания. + step /= 2. # Локальный шаг уменьшается геометрически. + models.append(best_delay) # Чистая задержка получает самостоятельную строку отчёта. + best_stretch = best_delay # Единичное растяжение служит допустимой исходной точкой. + for stretch in (.4, .6, .8, 1., 1.3, 1.7, 2.3, 3., 4.): # Диапазон 0,4–4 охватывает ускоренные и замедленные фронты без неограниченного переобучения. + candidate = temporal(best_delay['parameters']['delay_us'], stretch, 'slow_switching') # Одновременное уточнение задержки выполняется следующим координатным проходом. + if candidate and candidate['training_sse'] < best_stretch['training_sse']: # Выбираем старт по обучающим точкам. + best_stretch = candidate # Лучший локальный масштаб определяет следующую область поиска. + lag_step, scale_step = margin / 10., .5 # Начальные интервалы координатного уточнения заданы в мкс и безразмерном масштабе. + for _ in range(9): # Ограниченное число шагов сохраняет предсказуемое время работы. + lag, stretch = best_stretch['parameters']['delay_us'], best_stretch['parameters']['stretch'] # Обе координаты уточняются относительно текущего решения. + for next_lag, next_stretch in ((lag - lag_step, stretch), (lag + lag_step, stretch), (lag, max(.25, stretch - scale_step)), (lag, min(5., stretch + scale_step))): # Ограничения 0,25–5 исключают вырожденное сжатие времени. + candidate = temporal(next_lag, next_stretch, 'slow_switching') # Для каждой комбинации коэффициенты a и b оцениваются QR-методом. + if candidate and candidate['training_sse'] < best_stretch['training_sse']: # Контрольные точки не участвуют в поиске. + best_stretch = candidate # Сохраняем лучшее согласование динамики фронтов. + lag_step /= 2.; scale_step /= 2. # Обе сетки последовательно уточняются. + if best_stretch['code'] == 'slow_switching': # Совпадение с чистой задержкой не создаёт дублирующую модель. + models.append(best_stretch) # Штраф за дополнительное растяжение учитывается при окончательном выборе BIC. + span = result.metrics['reference_max'] - result.metrics['reference_min'] # Масштаб состояния берётся из полного сравниваемого эталона. + if span > 0.: # Для постоянной полки невозможно определить противоположное состояние контакта. + active, intervals = None, [] # Кусочная модель оценивает границы каждого дополнительного переключения. + training = [index for index in range(len(times)) if index % 3 != 1] # Моменты разрывов ищутся только на обучающих точках. + for index in training: # Принадлежность состоянию определяется относительно двух эталонных полок. + target = result.metrics['reference_max'] if reference[index] < (result.metrics['reference_min'] + result.metrics['reference_max']) / 2. else result.metrics['reference_min'] # Противоположная полка соответствует прерыванию нормального состояния. + contact = abs(measured[index] - target) < .3 * span and abs(measured[index] - reference[index]) > .5 * span # Пространство состояний ограничено наблюдаемым полным размахом. + if contact and active is None: # Новый интервал начинается с первого подтверждённого противоположного состояния. + active = index # Сохраняем индекс, а не округлённое время. + elif not contact and active is not None: # Возврат завершает кусок неправильного состояния. + if times[index] > times[active] and len(intervals) < 12: # Максимум 12 сегментов ограничивает сложность и вычислительную стоимость модели. + intervals.append((times[active], times[index])) # Длительность измеряется по исходной временной сетке. + active = None # Следующий разрыв подбирается отдельно. + if intervals: # Без завершённых прерываний кусочная модель отсутствует. + boxes = [[float(left <= stamp < right) for stamp in times] for left, right in intervals] # Границы, найденные по обучению, применяются также к контрольным временам. + candidate = fit_model('contact_bounce', 'y(t)=a*r(t)+b+sum(cj*I[tj0,tj1])', [reference, ones, *boxes], measured, ['gain', 'offset', *[f'event_{i}_amplitude' for i in range(len(boxes))]], 2 * len(boxes), dict(events=[dict(start_us=left, stop_us=right, duration_us=right - left) for left, right in intervals])) # BIC включает амплитуду и обе границы каждого прерывания. + if candidate: # Линейно зависимые сегменты не должны попадать в рейтинг. + models.append(candidate) # Совместный дефект может оставить звон в остатке этой кусочной модели. + baseline = models[0] # Исходный эталон задаёт общую точку отсчёта улучшения. + floor = max(1e-24, max(abs(value) for value in measured) ** 2 * 1e-24) # Все глобальные модели используют один численный пол дисперсии даже при подгонке аддитивного остатка. + for model in models: # Единый масштаб предотвращает искусственное преимущество модели при почти нулевой численной ошибке. + count = model['bic_count'] # Число обучающих точек одинаково для всех глобальных конкурентов. + model['bic'] = count * math.log(max(model['training_sse'] / count, floor)) + model['parameter_count'] * math.log(count) # Штраф и дисперсия оцениваются на одной и той же обучающей выборке. + for model in models: # Все показатели сравнения моделей вычисляются на одном наборе времён. + model['delta_bic'] = baseline['bic'] - model['bic'] # Положительное значение означает преимущество относительно неизменённого эталона. + model['explained_error_pct'] = 100. * (1. - model['sse'] / baseline['sse']) if baseline['sse'] > 0. else 0. # Показываем, какую долю исходной ошибки описала модель. + model['supported'] = model['delta_bic'] >= 10. and model['validation_rmse'] < .8 * baseline['validation_rmse'] and model['explained_error_pct'] > 50. # Решение требует штрафованного улучшения и снижения контрольной ошибки минимум на 20%. + models.sort(key=lambda model: model['bic']) # Рейтинг показывает конкурентов, а не единственный заранее выбранный диагноз. + best = models[0] if models[0]['supported'] else baseline # При недостатке доказательств сохраняем исходную гипотезу без аппаратного вывода. + return dict(models=models, best=best['code'], sampled_points=len(times), interval_us=[times[0], times[-1]], sampling='до 2048 стратифицированных точек, включая экстремумы; контроль — каждая третья точка') # Отчёт честно отделяет полную статистику от ограниченного подбора. + + +def fit_damped_ring(times_us, values, cancel=lambda: False): # Свободный отклик второго порядка описывается экспонентой и синусом с неизвестной фазой. + if len(values) < 24 or times_us[-1] <= times_us[0]: # Короткая или нулевая временная область не позволяет идентифицировать колебание. + return None # Отсутствующая оценка не заменяется произвольной частотой. + full_level = median(values[-max(8, len(values) // 4):]) # Начальную частоту оцениваем до прореживания, чтобы не принять алиас за реальный резонанс. + full_amplitude = max(abs(value - full_level) for value in values) # Полоса переходов задаётся полным наблюдаемым откликом. + full_crossings, previous = [], 0 # Сначала используем полную временную сетку для оценки доступной частоты. + for index, (stamp, value) in enumerate(zip(times_us, values)): # Исходные пересечения уровня не зависят от ограниченной выборки нелинейного подбора. + if index % 16384 == 0: # Подготовку большого окна также можно прервать. + checkpoint(cancel) # Не ждём окончания длинного прохода после запроса отмены. + sign = 1 if value - full_level > .05 * full_amplitude else -1 if value - full_level < -.05 * full_amplitude else 0 # Гистерезис подавляет поздний шум относительно начального колебания. + if sign and previous and sign != previous: # Противоположные значимые полуволны определяют половину периода. + full_crossings.append(stamp) # Время сохраняется до любой децимации. + if sign: # Промежуточная зона не меняет последнюю подтверждённую полуволну. + previous = sign # Дальнейшее пересечение сравнивается с наблюдаемым знаком. + if len(full_crossings) < 4: # Короткий звон или одиночный выброс не дают устойчивой начальной частоты. + return None # Не создаём резонансную модель без нескольких наблюдаемых полупериодов. + initial_frequency = .5 / median(b - a for a, b in zip(full_crossings, full_crossings[1:])) # Частота определяется до возможного понижения частоты дискретизации. + full_step = median(b - a for a, b in zip(times_us, times_us[1:])) # Фактический шаг исходных наблюдений задаёт допустимую децимацию. + if full_step <= 0.: # Невозрастающая сетка не имеет физически определённой частоты выборки. + return None # Не исправляем повреждённые метки времени молча. + maximum_stride = max(1, int(.2 / (initial_frequency * full_step))) # Сохраняем минимум пять отсчётов на исходно наблюдаемый период. + stride = min(maximum_stride, max(1, math.ceil(len(values) / 512))) # Ограничение памяти не должно создавать ложную низкочастотную модель из высокочастотного звона. + times = [stamp - times_us[0] for stamp in times_us[::stride][:512]] # Если безопасная децимация недостаточна, сокращаем окно, сохраняя разрешение периода. + measured = list(values[::stride][:512]) # Подбор получает не более 512 реально наблюдаемых амплитуд без алиасного понижения частоты. + duration = times[-1] - times[0] # Длительность участка выражена в мкс. + step = median(b - a for a, b in zip(times, times[1:])) # Медианный шаг устойчив к отдельным неравномерным промежуткам. + if max(b - a for a, b in zip(times, times[1:])) > 5. * step: # Через большой ненаблюдавшийся промежуток колебание не подбирается. + return None # Спектральная интерпретация разорванного участка была бы неоднозначной. + level = median(measured[-max(8, len(measured) // 4):]) # Хвост задаёт начальную оценку установившегося уровня. + amplitude = max(abs(value - level) for value in measured) # Оценка амплитуды нужна только для устойчивого поиска начальной частоты. + previous, crossings = 0, [] # Переходы через небольшой гистерезис задают начальную частоту, но не сам диагноз. + for stamp, value in zip(times, measured): # Анализируем обе полуволны колебания относительно хвостовой полки. + sign = 1 if value - level > .05 * amplitude else -1 if value - level < -.05 * amplitude else 0 # Полоса 5% начальной амплитуды подавляет шум после затухания. + if sign and previous and sign != previous: # Между противоположными полуволнами проходит приблизительно половина периода. + crossings.append(stamp) # Сохраняем времена для робастной начальной оценки периода. + if sign: # Нулевая полоса не создаёт дополнительных смен знака. + previous = sign # Последняя значимая полуволна остаётся опорной. + if len(crossings) < 4: # Менее двух наблюдаемых периодов недостаточно для уверенной идентификации затухания. + return None # Одиночный выброс не объявляется резонансом. + initial = .5 / median(b - a for a, b in zip(crossings, crossings[1:])) # Частота в циклах/мкс соответствует МГц. + ones, best = [1.] * len(times), None # Постоянный уровень подбирается вместе с двумя фазовыми компонентами. + def candidate(frequency, alpha): # Метод переменной проекции отделяет нелинейные частоту/затухание от линейных коэффициентов. + checkpoint(cancel) # Длительный перебор можно кооперативно отменить. + if not 1.5 / duration <= frequency <= .2 / step or alpha < 0.: # Требуем наблюдаемые периоды и минимум пять отсчётов на период, сохраняя запас до границы Найквиста. + return None # Неоднозначные или нарастающие решения этой пассивной модели отклоняются. + cosine = [math.exp(-alpha * stamp) * math.cos(math.tau * frequency * stamp) for stamp in times] # Первая фазовая компонента затухающего отклика. + sine = [math.exp(-alpha * stamp) * math.sin(math.tau * frequency * stamp) for stamp in times] # Вторая компонента допускает произвольную начальную фазу. + return fit_model('damped_rlc', 'v(t)=Vinf+exp(-alpha*t)*(A*cos(2*pi*f*t)+B*sin(2*pi*f*t))', [ones, cosine, sine], measured, ['level', 'cosine', 'sine'], 2, dict(frequency_hz=frequency * 1e6, alpha_per_us=alpha)) # BIC учитывает пять оцениваемых параметров. + for fi in range(17): # Начальная частота уточняется в диапазоне ±35% оценки по переходам через уровень. + frequency = initial * (.65 + .7 * fi / 16.) # Ограниченный диапазон снижает риск выбора гармоники вместо основного колебания. + for alpha in [0.] + [10. ** (-1.5 + index * 3. / 10.) / duration for index in range(11)]: # Логарифмическая сетка охватывает слабое и быстрое затухание относительно окна наблюдения. + model = candidate(frequency, alpha) # Линейные параметры подбираются QR-методом для каждой пары частоты и затухания. + if model and (best is None or model['training_sse'] < best['training_sse']): # Контрольные точки не выбирают параметры нелинейного поиска. + best = model # Сохраняем лучшее объяснение обучающих наблюдений. + if best is None: # Частота могла оказаться выше разрешения исходной записи. + return None # Недостаточная дискретизация не компенсируется выдуманными параметрами. + frequency_step, alpha_step = .05 * initial, max(best['parameters']['alpha_per_us'] * .5, 1. / duration) # Локальное уточнение начинается с масштаба грубой сетки. + for _ in range(10): # Десять координатных уточнений дают воспроизводимое ограниченное время подбора. + frequency, alpha = best['parameters']['frequency_hz'] / 1e6, best['parameters']['alpha_per_us'] # Возвращаем текущие параметры к внутренним мкс. + for next_frequency, next_alpha in ((frequency - frequency_step, alpha), (frequency + frequency_step, alpha), (frequency, max(0., alpha - alpha_step)), (frequency, alpha + alpha_step)): # Частота и затухание уточняются независимо с неотрицательным ограничением потерь. + model = candidate(next_frequency, next_alpha) # Линейные коэффициенты пересчитываются при каждом изменении динамики. + if model and model['training_sse'] < best['training_sse']: # Принимаем только улучшение на обучающей части. + best = model # Контрольный остаток остаётся показателем проверки модели. + frequency_step /= 2.; alpha_step /= 2. # Постепенно уменьшаем обе области поиска. + for _ in range(4): # Повторное точное уточнение снимает взаимное влияние ошибки частоты и оценённого затухания. + frequency, alpha = best['parameters']['frequency_hz'] / 1e6, best['parameters']['alpha_per_us'] # Исходная точка уже найдена грубой сеткой и локальными шагами. + refined = bounded_minimum(lambda value: candidate(value, alpha), frequency * .97, frequency * 1.03) # Частота уточняется внутри локального диапазона ±3%. + if refined and refined['training_sse'] < best['training_sse']: # Золотой поиск не должен ухудшать уже полученное решение. + best = refined # Новая частота уменьшает систематическую ошибку фазы по всему окну. + frequency = best['parameters']['frequency_hz'] / 1e6 # Следующее уточнение затухания использует уже исправленную частоту. + refined = bounded_minimum(lambda value: candidate(frequency, value), 0., max(3. * alpha, 5. / duration)) # Неотрицательное затухание уточняется независимо от положения грубой логарифмической сетки. + if refined and refined['training_sse'] < best['training_sse']: # Сохраняем только реальное улучшение обучающего описания. + best = refined # Время затухания больше не наследует грубую ошибку предыдущей сетки частоты. + params = best['parameters'] # Дополнительные физические параметры вычисляются из найденных полюсов второго порядка. + omega = math.tau * params['frequency_hz'] # Угловая частота затухающего колебания выражается в рад/с. + alpha = params['alpha_per_us'] * 1e6 # Переводим скорость затухания из 1/мкс в 1/с. + natural = math.hypot(omega, alpha) # Для недодемпфированного отклика omega0²=omega_d²+alpha². + params.update(amplitude=math.hypot(params['cosine'], params['sine']), decay_time_us=1e6 / alpha if alpha > 0. else None, damping_ratio=alpha / natural, # Амплитуда относится к началу выбранного участка; отсутствие потерь не создаёт Infinity в JSON. + natural_frequency_hz=natural / math.tau, quality_factor=natural / (2. * alpha) if alpha > 0. else None, lc_product=1. / natural ** 2, r_over_l=2. * alpha) # LC и R/L идентифицируются без задания отдельного R, L или C для последовательного эквивалента. + constant = fit_model('constant', 'v(t)=Vinf', [ones], measured, ['level']) # Проверяем, что сложный отклик лучше простой постоянной полки. + best.update(delta_bic=constant['bic'] - best['bic'], interval_us=[times_us[0], times_us[0] + times[-1]], frequency_resolution_hz=1e6 / duration, input_points=len(values), decimation_stride=stride) # Отчёт сохраняет фактическое укороченное окно и безопасный шаг подбора. + best['supported'] = best['delta_bic'] >= 10. and best['r_squared'] is not None and best['r_squared'] >= .8 and best['validation_rmse'] < .5 * constant['validation_rmse'] # Пригодность отклика проверяется штрафом сложности, R² и контрольной ошибкой. + return best # Возвращаем также неподтверждённый подбор, чтобы отчёт показывал причину отказа от гипотезы. + + +def equivalent_rlc(model, circuit=None): # Параметры относятся только к явно выбранному последовательному эквиваленту второго порядка. + if not model or not model.get('supported'): # По плохо описанному колебанию нельзя вычислять электрические элементы. + return dict(identifiable=False, reason='Затухающий отклик не подтверждён данными.') # Честно сохраняем отсутствие физической оценки. + supplied = {key: float(value) for key, value in (circuit or {}).items() if key in ('inductance_h', 'capacitance_f', 'resistance_ohm') and math.isfinite(value) and value > 0.} # Нулевые значения означают неизвестные элементы, единицы — СИ. + product, ratio = model['parameters']['lc_product'], model['parameters']['r_over_l'] # Найденные полюса определяют только LC и R/L без дополнительного ограничения. + if 'inductance_h' in supplied: # Известная индуктивность однозначно задаёт оставшиеся параметры эквивалента. + inductance = supplied['inductance_h'] # Используем явно введённое значение в генри. + elif 'capacitance_f' in supplied: # Известная ёмкость позволяет восстановить L из произведения LC. + inductance = product / supplied['capacitance_f'] # Произведение LC имеет единицу с². + elif 'resistance_ohm' in supplied and ratio > 0.: # При ненулевом затухании известное сопротивление определяет индуктивность. + inductance = supplied['resistance_ohm'] / ratio # Деление R на R/L возвращает генри. + else: # Одна осциллограмма без дополнительного элемента не определяет все три неизвестных. + return dict(identifiable=False, lc_product=product, r_over_l=ratio, supplied=supplied, reason='Нужен хотя бы один известный R, L или C; по отклику определяются только LC и R/L.') # Не выдаём произвольные значения отдельных элементов. + estimated = dict(inductance_h=inductance, capacitance_f=product / inductance, resistance_ohm=ratio * inductance) # Это эквивалентные параметры полюсов, а не автоматически номиналы RC-снаббера. + mismatch = {key: (estimated[key] / value - 1.) * 100. for key, value in supplied.items()} # Несколько известных элементов позволяют проверить согласованность предположенной топологии. + return dict(identifiable=True, topology='последовательный эквивалент RLC', estimated=estimated, supplied=supplied, deviation_pct=mismatch) # Отделяем введённые значения от восстановленных и не скрываем противоречия. + + +def fit_ringing_pair(result, events, circuit=None, cancel=lambda: False, delay_us=0.): # Компенсируем оценённую задержку перед сравнением полюсов, чтобы сдвиг фронта не выглядел отказом снаббера. + rising = [event for event in events if event[2] == 1][-4:] # Не более четырёх последних выключений ограничивают стоимость нелинейного подбора. + pairs = [] # Каждый участок имеет собственные границы, качество и параметры. + low, high = result.metrics['reference_min'], result.metrics['reference_max'] # Огибающая взята только из реально сравниваемого эталона. + for center, width, _ in rising: # Время и длительность события выражены во внутренних миллисекундах. + checkpoint(cancel) # Смена записи может отменить подбор между отдельными выключениями. + start = bisect_left(result.difference.times, center + width / 2.) # Начинаем поиск установившейся полки после пересечения верхнего порога фронта. + stop_time = min(result.difference.times[-1], result.measured.times[-1] - delay_us / 1000., center + max(40. * width, .002)) # Ограничиваем обе стороны реальными опорами после компенсации задержки без экстраполяции. + stop = bisect_left(result.difference.times, stop_time) # Конечный индекс не выходит за реально наблюдаемое перекрытие. + for index in range(max(1, start), min(stop - 1, len(result.expected) - 1)): # Ищем первый максимум эталона после завершения основного роста напряжения. + if result.expected[index] > low + .85 * (high - low) and result.expected[index - 1] <= result.expected[index] > result.expected[index + 1]: # Первый пик на высокой полке отделяет колебательный отклик от незавершённого фронта. + start = index # Неизвестная фаза синуса допускает начало подбора с любого пика. + break # Последующие колебания не должны сдвигать начало до почти нулевой амплитуды. + indices = range(start, stop) # Обе кривые подбираются на строго одинаковом временном интервале. + if stop - start < 24 or any(not math.isfinite(result.expected[i]) or not math.isfinite(result.difference.values[i]) for i in indices): # Разрыв внутри окна не маскируется удалением отдельных отсчётов колебания. + continue # Такому выключению не приписываем достоверный резонансный контур. + times = [result.difference.times[i] * 1000. for i in indices] # Подбор использует микросекунды без потери дробной части. + reference = [result.expected[i] for i in indices] # Эталон уже интерполирован на фактическую сетку измерения. + measured = [interpolate(result.measured.times, result.measured.values, result.difference.times[i] + delay_us / 1000.) for i in indices] # Положительная задержка сдвигает окно измерения к соответствующему выключению. + if any(value is None for value in measured): # Через пропуск или отсутствие опоры после сдвига отклик не подбирается. + continue # Окно не уменьшается отдельно для одной из сравниваемых моделей. + fitted_reference = fit_damped_ring(times, reference, cancel) # Затухание исправного отклика определяется по данным, а не фиксированной константой. + fitted_measured = fit_damped_ring(times, measured, cancel) # Частота, затухание и фаза измерения подбираются независимо. + if fitted_measured: # Сохраняем также неудачное по качеству согласование для объяснения неопределённости. + pair = dict(measured=fitted_measured, reference=fitted_reference, equivalent=equivalent_rlc(fitted_measured, circuit), compensated_delay_us=delay_us) # Эквивалент и компенсация времени сохраняются рядом с качеством подбора. + if fitted_reference and fitted_reference['supported']: # Без пригодного эталонного колебания нельзя оценить отношение затуханий. + a, b = fitted_measured['parameters'], fitted_reference['parameters'] # Параметры имеют одинаковое начало выбранного участка и одинаковые единицы. + pair.update(amplitude_ratio=a['amplitude'] / b['amplitude'] if b['amplitude'] > 1e-12 else None, # Отношение амплитуд не зависит от единицы напряжения. + decay_ratio=a['decay_time_us'] / b['decay_time_us'] if a['decay_time_us'] is not None and b['decay_time_us'] else None, frequency_change_pct=(a['frequency_hz'] / b['frequency_hz'] - 1.) * 100.) # Показываем рост длительности звона и изменение резонансной частоты относительно эталона. + pairs.append(pair) # Отдельные выключения остаются доступны в JSON для проверки устойчивости оценки. + return pairs # Отсутствие пригодных окон является явным отсутствием модели, а не нормальным состоянием снаббера. + + +def identify_fault_models(result, events, features, physical, role, circuit=None, channels=None, cancel=lambda: False): # Формируем гипотезы из подтверждённых моделей, а не только из фиксированных порогов формы. + from set_devices.signal_joint_models import fit_joint_channels # Ленивый импорт устраняет цикл между совместными моделями и общими численными методами. + checkpoint(cancel) # Не начинаем дорогой подбор после отмены исходного сравнения. + report = fit_reference_models(result, events, cancel) # Все глобальные конкуренты оцениваются на одинаковых временах. + best = next((model for model in report['models'] if model['code'] == report['best']), None) # Временная модель нужна также для корректного выбора окна выключения. + delay = best['parameters'].get('delay_us', 0.) if best and best['supported'] else 0. # Компенсируем только подтверждённый по BIC и контролю временной сдвиг. + rings = fit_ringing_pair(result, events, circuit, cancel, delay) if physical and role == 'uce' else [] # Электрическая интерпретация колебания допустима только для силового напряжения. + report.update(ringing=rings, assumptions=['BIC — сравнительный критерий при предположении гауссовой ошибки, не вероятность неисправности.', 'Контрольные точки чередуются во времени; это проверка интерполяционного прогноза, не независимый эксперимент.', 'Подвыборка сохраняет экстремумы: статистические показатели условны относительно выбранных точек.', 'RLC — последовательный эквивалент полюсов; номиналы реального RC-снаббера автоматически не определяются.']) # Ограничения метода сохраняются рядом с численными результатами. + report['criteria'] = dict(delta_bic_min=10., validation_improvement_min_pct=20., explained_error_min_pct=50., ring_r_squared_min=.8, ring_amplitude_or_decay_ratio=1.5, ring_frequency_change_pct=15., stretch_min=1.15) # Начальные критерии принятия модели явно доступны пользователю и не выдаются за паспортные нормы транзистора. + report['circuit_supplied_si'] = dict(circuit or {}) # Известные элементы сохраняются даже при отсутствии пригодного колебательного участка. + contact_events = best['parameters'].get('events', []) if best and best['code'] == 'contact_bounce' else [] # Сегменты кусочной модели проверяются независимыми физическими каналами. + joint, joint_findings = fit_joint_channels(result, channels, contact_events, cancel) if physical else (dict(available=False, reason='Физическая роль канала не определена.'), []) # Не выполняем электрические расчёты по произвольному неизвестному каналу. + report['joint'] = joint # Совместная идентификация проводимости, утечки и провалов управления экспортируется вместе с глобальным рейтингом. + findings = list(joint_findings) # Несколько моделей могут описывать разные участки совместного дефекта. + if result.metrics['outside_tolerance'] == 0: # Даже хорошее улучшение на малом шуме не отменяет пользовательский критерий допустимости. + return report, findings # Допустимый выбранный канал не отменяет отдельно подтверждённые совместные гипотезы. + if best and best['supported']: # Аппаратная формулировка требует проверки качества, а не только наличия признака. + code, params = best['code'], best['parameters'] # Параметры выбранной модели будут показаны в подробном отчёте. + if code == 'contact_bounce' and physical and features['measured_edges'] >= features['reference_edges'] + 2: # Кусочное объяснение подтверждается полными переходами на всей записи. + text = f'Модель прерываний: {len(params["events"])} эпизода; возможны плохой контакт (дребезг) или нестабильное управление затвором.' # Длительности каждого эпизода доступны в параметрах модели. + findings.append(dict(code=code, text=text, model=code)) # Причина ссылается на конкретный численно проверенный кандидат. + elif code == 'slow_switching' and physical and params['stretch'] > 1.15: # Замедление должно превышать 15% при уже доказанном модельном улучшении. + findings.append(dict(code=code, text=f'Модель растяжения фронтов: ×{params["stretch"]:.3g}; возможны ослабленный драйвер или изменение цепи затвора.', model=code)) # Формулируем гипотезу через фактически оценённый масштаб времени. + elif code in ('timing', 'slow_switching') and abs(params['delay_us']) > 3. * (features['sample_step_ms'] or 0.) * 1000.: # Задержка должна превышать три исходных шага, даже если BIC чувствителен к малому шуму. + findings.append(dict(code='timing', text=f'Модель задержки: {params["delay_us"]:+.4g} мкс; проверьте синхронизацию записей и задержку управления.', model=code)) # Отдельная причина аппаратной задержки по одному каналу не доказывается. + elif code == 'constant' and physical and features['reference_edges']: # Постоянная амплитуда выигрывает у динамического эталона с ожидаемыми переходами. + state = 'высоким' if params['level'] > (result.metrics['reference_min'] + result.metrics['reference_max']) / 2. else 'низким' # Середина эталонных состояний определяет тип отсутствующего переключения. + text = f'Модель постоянного уровня: {result.measured.name} остаётся {state}; возможны отказ ключа, обрыв или ошибка управления.' # Направление неисправности остаётся возможной причиной, а не доказанным компонентом. + findings.append(dict(code='no_switching', text=text, model=code)) # Сохраняем совместимый код группы отсутствия переключения. + elif code in ('offset', 'gain', 'drift'): # Калибровочные и медленные изменения конкурируют с аппаратной динамикой. + texts = dict(offset=f'Модель смещения: Δ0={params.get("offset", 0.):+.4g}; проверьте ноль датчика, общую точку и режим питания.', # Постоянное смещение задано в единицах выбранного канала. + gain=f'Модель масштаба: коэффициент {params.get("gain", 1.):.5g}, смещение {params.get("offset", 0.):+.4g}; возможна ошибка измерительного тракта или изменение режима.', # Усиление является безразмерной оценкой соответствия эталону. + drift=f'Модель дрейфа: {params.get("drift_per_us", 0.):+.4g} ед./мкс; возможны нестабильность питания или уход нуля измерения.') # Производная уровня явно содержит единицу времени. + findings.append(dict(code=code, text=texts[code], model=code)) # Сохраняем идентифицированный вид отклонения отдельно от аппаратной причины. + gain = abs(best['parameters'].get('gain', 1.)) if best else 1. # Общий масштаб измерительного тракта не должен ошибочно удваивать амплитуду «неисправного» снаббера. + for pair in rings: # Разные выключения могут подтверждать дополнительный резонансный дефект поверх дребезга. + measured, reference = pair['measured'], pair['reference'] # Каждая сторона имеет собственные показатели качества подбора. + ratio = pair.get('amplitude_ratio') # Без пригодного эталонного отклика относительная амплитуда не определена. + worse = ratio is not None and ratio / max(gain, 1e-9) > 1.5 or pair.get('decay_ratio') is not None and pair['decay_ratio'] > 1.5 or abs(pair.get('frequency_change_pct', 0.)) > 15. # Параметры должны измениться заметно, а не только улучшить микроскопический остаток. + if measured['supported'] and reference and reference['supported'] and worse: # Аппаратная гипотеза требует пригодных моделей обеих осциллограмм. + params = measured['parameters'] # Оценённые частота и затухание делают вывод проверяемым. + text = f'Модель звона RLC: f={params["frequency_hz"] / 1e6:.3g} МГц, τ={params["decay_time_us"]:.3g} мкс; изменилось демпфирование — возможны нарушение снаббера или паразитного контура.' if params['decay_time_us'] is not None else f'Модель звона RLC: f={params["frequency_hz"] / 1e6:.3g} МГц, затухание не разрешено; проверьте снаббер и паразитный контур.' # Нулевое оценённое затухание не превращается в бесконечность JSON. + findings.append(dict(code='snubber', text=text, model='damped_rlc')) # Привязываем причину к параметрической модели второго порядка. + break # Несколько выключений не дублируют одинаковую строку неисправности. + if not findings: # Недостаточно описанная форма должна оставаться неопределённой. + code = 'noise' if features['residual_reversals'] >= 5 else 'deviation' # Остаточные колебания обозначают помеху без приписывания конкретного электрического отказа. + text = 'Остаток не объяснён проверенными моделями: возможны шум, помехи или неучтённая динамика.' if code == 'noise' else 'Ни одна модель не дала достаточного подтверждения причины отклонения; проверьте режим и измерительный тракт.' # Отсутствие подходящей модели не считается доказательством исправности. + findings.append(dict(code=code, text=text, model=None)) # Явная неопределённость сохраняется в краткой строке и JSON. + return report, findings # Численные результаты и текстовые гипотезы принадлежат одному снимку данных. diff --git a/python/set_devices/signal_joint_models.py b/python/set_devices/signal_joint_models.py new file mode 100644 index 0000000..b5f8d13 --- /dev/null +++ b/python/set_devices/signal_joint_models.py @@ -0,0 +1,79 @@ +"""Совместные электрические модели Uce, Uge и Ic на общей временной сетке.""" # Наличие трёх каналов даёт дополнительные проверяемые ограничения аппаратных гипотез. +import math # Корни дисперсий задают оценку неопределённости коэффициентов проводящего состояния. +import re # Физические каналы определяются по явным обозначениям, а не имени CSV. +from statistics import median # Медианные уровни и MAD устойчивы к отдельным коммутационным выбросам. +from set_devices.signal_fault_models import checkpoint, fit_model, interpolate # Переиспользуем численные методы без зависимости от GUI и циклического импорта ядра. + + +def _channels(traces): # Дублирующиеся обозначения нельзя выбирать произвольно для физических расчётов. + found = {} # Для каждой физической роли разрешён только один однозначный канал. + for trace in traces.values(): # Имена и значения предоставляются снимком исходной записи. + match = re.match(r'^\s*(uce|uge|ic)\s*\((V|A)\)\s*$', trace.name, re.IGNORECASE) # Требуем явные единицы В для напряжений и А для тока. + if match and match.group(2).lower() == ('a' if match.group(1).lower() == 'ic' else 'v'): # Не используем ток в мА как амперы без явного преобразования. + role = match.group(1).lower() # Внутренние обозначения не зависят от регистра заголовка. + found[role] = trace if role not in found else None # Повторная роль помечается неоднозначной и не участвует в выводе. + return {role: trace for role, trace in found.items() if trace is not None} # Неоднозначность не маскируется выбором первого столбца. + + +def fit_joint_channels(result, channels, events=None, cancel=lambda: False): # Все связанные каналы интерполируются на общую фактическую сетку сравнения. + if not channels: # Синхронные осциллограммы могут отсутствовать у одиночного источника. + return dict(available=False, reason='Для совместной проверки нужны Uce (V), Uge (V) и Ic (A) измерения и эталона.'), [] # Не подменяем отсутствующий ток или затвор выдуманными данными. + measured, reference = _channels(channels.get('measured', {})), _channels(channels.get('reference', {})) # Две стороны сравнения проверяются независимо. + if any(role not in measured or role not in reference for role in ('uce', 'uge', 'ic')): # Физические ограничения требуют полного набора каналов с однозначными единицами. + return dict(available=False, reason='Недостаточно однозначных синхронных каналов Uce/Uge/Ic в В/А.'), [] # Пропущенный канал остаётся ограничением диагностического вывода. + count = len(result.difference.times) # Общая область уже ограничена интервалом и временем эталона. + stride = max(1, math.ceil(count / 4096)) # До 4096 точек ограничивают дополнительную стоимость совместной электрической модели. + points = [] # Каждая строка содержит синхронные тройки фактического измерения и эталона. + for index in range(0, count, stride): # Изменение масштаба графика не влияет на используемую область записи. + if index % 1024 == 0: # Порционная проверка отмены поддерживает отзывчивость на больших записях. + checkpoint(cancel) # Не продолжаем подготовку после смены источника. + stamp = result.difference.times[index] # Время отсчитывается от первого измеренного отсчёта в миллисекундах. + a = {role: interpolate(trace.times, trace.values, stamp + channels['measured_origin_ms']) for role, trace in measured.items()} # Сохраняем временное взаимное положение измеренных каналов. + b = {role: interpolate(trace.times, trace.values, stamp + channels['reference_origin_ms'] - channels['shift_ms']) for role, trace in reference.items()} # К эталону применяется тот же пользовательский сдвиг, что в основном сравнении. + if all(value is not None for value in (*a.values(), *b.values())): # Только полностью наблюдаемые тройки пригодны для совместной физической проверки. + points.append((stamp * 1000., a, b)) # Человеко-читаемое время событий хранится в микросекундах. + if len(points) < 30: # Дополнительные модели требуют не менее 30 совместных наблюдений. + return dict(available=False, reason='Недостаточно общих наблюдений трёх физических каналов.'), [] # Число точек явно ограничивает возможность идентификации. + low = min(point[2]['uce'] for point in points) # Низкая полка эталона определяет проводящее состояние транзистора. + high = max(point[2]['uce'] for point in points) # Высокая полка соответствует выключенному ключу. + gate_high = max(point[2]['uge'] for point in points) # Опорный высокий уровень затвора определяется по данным эталона. + on = [point for point in points if point[2]['uce'] < low + .02 * (high - low) and point[1]['uce'] < low + .25 * (high - low) and min(point[1]['uge'], point[2]['uge']) > .98 * gate_high] # ВАХ оценивается на устойчивой эталонной полке с высоким затвором и фактически проводящим измеренным ключом. + off = [point for point in points if min(point[1]['uce'], point[2]['uce']) > low + .85 * (high - low) and max(point[1]['uge'], point[2]['uge']) < .3 * gate_high] # Утечка допустима как модель только при высоком Uce и низком Uge с обеих сторон; пробой с низким Uce рассматривается отдельно. + report, findings = dict(available=True, count=len(points), on_count=len(on), off_count=len(off), contact_events=[]), [] # Совместные выводы сохраняют фактическую область подтверждения. + fits = {} # Две модели проводимости отдельно оценивают исправный и измеренный ключ. + for side, position in (('measured', 1), ('reference', 2)): # Коэффициенты U0 и Rd вычисляются из собственных тока и напряжения каждой стороны. + if len(on) < 30: # Мало наблюдений проводящего состояния не позволяет оценить наклон ВАХ. + continue # Не выдаём эквивалентное сопротивление по одному значению тока. + currents = [point[position]['ic'] for point in on] # Ток задаётся в амперах, напряжение — в вольтах. + if max(currents) - min(currents) < .1 * max(max(abs(value) for value in currents), 1e-12): # Размах менее 10% уровня не разделяет надёжно U0 и дифференциальное сопротивление. + continue # Почти постоянная нагрузка не даёт идентифицировать две независимые величины. + model = fit_model('conduction', 'Uce=Rdiff*Ic+U0', [currents, [1.] * len(on)], [point[position]['uce'] for point in on], ['resistance_ohm', 'voltage_offset_v']) # Модель является локальной линейной ВАХ, а не постоянным сопротивлением IGBT во всех режимах. + if model: # QR-решатель дополнительно проверяет независимость тока и свободного члена. + training_current = [value for index, value in enumerate(currents) if index % 3 != 1] # Оценка неопределённости использует только наблюдения, участвовавшие в подборе коэффициентов. + average = sum(training_current) / len(training_current) # Центр обучающей выборки определяет ковариацию наклона и свободного члена. + spread = sum((value - average) ** 2 for value in training_current) # Знаменатель ковариации имеет единицу А² и не увеличивается отложенными контрольными точками. + model['slope_standard_error_ohm'] = math.sqrt(model['training_sse'] / max(1, len(training_current) - 2) / spread) # Стандартная ошибка оценивается по обучающим остаткам при независимом шуме и не покрывает систематическую ошибку датчика. + fits[side] = model # Сохраняем полные параметры и контрольную ошибку каждой ВАХ. + report['conduction'] = fits or dict(identifiable=False, reason='Нужны устойчивое включённое состояние и заметное изменение тока нагрузки.') # Неидентифицируемый наклон явно отличим от нулевого сопротивления. + if len(fits) == 2: # Сравнение деградации требует оценок обеих сторон на одном режиме нагрузки. + delta = fits['measured']['parameters']['resistance_ohm'] - fits['reference']['parameters']['resistance_ohm'] # Изменение дифференциального сопротивления измеряется в омах. + uncertainty = math.hypot(fits['measured']['slope_standard_error_ohm'], fits['reference']['slope_standard_error_ohm']) # Независимые статистические ошибки наклонов складываются по дисперсии. + report['conduction_change_ohm'] = delta # Численная разница доступна даже при недостаточной статистической значимости. + if delta > max(5. * uncertainty, 1e-9): # Порог пяти стандартных ошибок ограничивает реакции на один только случайный шум. + findings.append(dict(code='conduction', text=f'Совместная модель ВАХ: дифференциальное сопротивление выросло на {delta:.4g} Ом; возможны деградация ключа или сопротивление силового контакта.', model='Uce=Rdiff*Ic+U0')) # Обе физические причины дают похожий наклон и остаются альтернативами. + if len(on) >= 30 and len(off) >= 30: # Разность токов включённого и выключенного состояний отделяет утечку от постоянного нуля датчика. + on_error = [point[1]['ic'] - point[2]['ic'] for point in on] # Ошибка тока во включённом состоянии служит проверкой общей калибровки. + off_error = [point[1]['ic'] - point[2]['ic'] for point in off] # Остаточный ток при низком затворе является кандидатом модели утечки. + on_level, off_level = median(on_error), median(off_error) # Медианы подавляют отдельные импульсы коммутации. + noise = max(1e-9, 1.4826 * median([abs(value - on_level) for value in on_error] + [abs(value - off_level) for value in off_error])) # Множитель 1,4826 переводит MAD в оценку СКО нормального шума. + report['leakage'] = dict(formula='ΔIc_off=Ileak+b; ΔIc_on=b', off_current_a=off_level, on_offset_a=on_level, contrast_a=off_level - on_level, noise_sigma_a=noise) # Двухрежимная модель явно оценивает постоянную ошибку датчика b. + if off_level > 5. * noise and off_level - on_level > 5. * noise: # Одновременно нужен положительный ток выключения и отличие от общей ошибки нуля. + findings.append(dict(code='leakage', text=f'Совместная модель остаточного тока: при низком Uge дополнительный Ic≈{off_level - on_level:.4g} А; возможны утечка ключа или параллельная токовая цепь.', model='off_current')) # Посторонняя ветвь тока остаётся альтернативой утечке самого транзистора. + for event in events or []: # Сегменты предполагаемого дребезга проверяются по синхронным затвору и току. + interval = [point for point in points if event['start_us'] <= point[0] < event['stop_us']] # Проверяем только реально наблюдаемые точки внутри найденного прерывания. + if not interval: # Слишком короткий эпизод может не попасть в ограниченную совместную выборку. + continue # Отсутствие наблюдений не считается отсутствием провала. + gate_ratio = sum(point[1]['uge'] < .5 * point[2]['uge'] for point in interval) / len(interval) # Доля одновременного провала управления количественно проверяет причину повторного выключения. + current_ratio = sum(abs(point[1]['ic']) < .5 * abs(point[2]['ic']) for point in interval) / len(interval) # Провал тока ключа дополнительно подтверждает прерывание проводимости. + report['contact_events'].append(dict(**event, gate_drop_fraction=gate_ratio, current_drop_fraction=current_ratio, supported=gate_ratio >= .8 and current_ratio >= .8)) # Подтверждение требует совпадения обоих провалов хотя бы в 80% наблюдений эпизода. + return report, findings # Одновременные нарушения проводимости, утечки и контакта не скрывают друг друга. diff --git a/python/set_devices/signal_reconstruction.py b/python/set_devices/signal_reconstruction.py index 470c031..81b82b9 100644 --- a/python/set_devices/signal_reconstruction.py +++ b/python/set_devices/signal_reconstruction.py @@ -8,6 +8,10 @@ import ctypes as C from dataclasses import dataclass from functools import lru_cache from protocan.native import NativeProtocol +from time import perf_counter # Измеряем полное время обработки запроса, включая преобразование входных и выходных буферов. + +MAX_OUTPUT_POINTS = 1_000_000 +BACKENDS = {"native": "Текущий МНК", "eigen": "Eigen QR"} # Стабильные ключи сохраняются в запросах и рецептах, а подписи показываются в GUI. METHODS = {"polynomial": "Аппроксимация: полином МНК", "linear": "Интерполяция: линейная", "pchip": "Интерполяция: PCHIP", "spline": "Восстановление: кубический сплайн"} @@ -19,6 +23,8 @@ class Reconstruction: unique_count: int rmse: float pieces: tuple = () + backend: str = "native" # Сохраняем выбранный вариант расчёта для сравнения и подписей результатов. + elapsed_ms: float = 0.0 # Время полного вызова в миллисекундах позволяет оценивать стоимость преобразований вместе с решателем. @lru_cache(maxsize=1) def library(): @@ -34,29 +40,37 @@ def library(): except (AttributeError, OSError, RuntimeError) as error: raise ValueError("Пересоберите SETProtocol с set_signal.c и set_wavegen.c") from error -def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True, with_model=False): - if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= 10000: - raise ValueError("Неизвестный метод или число выходных точек вне 2…10000") +def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True, with_model=False, backend="native"): # Новый именованный параметр сохраняет все существующие позиционные вызовы. + started = perf_counter() # Начинаем замер до подготовки ctypes-массивов, одинаковой для двух решателей. + if backend not in BACKENDS or (backend == "eigen" and method != "polynomial"): # Eigen предлагается только для полиномиального МНК, а неизвестные ключи отклоняются явно. + raise ValueError("Eigen QR доступен только для полинома МНК; выберите корректный вариант расчёта") # Исключаем скрытое использование другого метода при сравнении. + if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= MAX_OUTPUT_POINTS: + raise ValueError(f"Неизвестный метод или число выходных точек вне 2…{MAX_OUTPUT_POINTS}") count = len(points) if not 2 <= count <= 100000 or type(degree) is not int or not 1 <= degree <= 5: raise ValueError("Нужно 2…100000 исходных точек, степень 1…5") x, y = (C.c_double * count)(*(p[0] for p in points)), (C.c_double * count)(*(p[1] for p in points)) ox, oy, meta = (C.c_double * output_count)(), (C.c_double * output_count)(), (C.c_double * 3)() work = (C.c_double * (14 * count + 128))() - code = library().set_signal_reconstruct(x, y, count, list(METHODS).index(method), degree, - output_count, int(endpoint), ox, oy, meta, work, len(work)) + core = library() # Существующая библиотека остаётся источником базового алгоритма и каталога установленного модуля. + operation = core.set_signal_reconstruct # По умолчанию сохраняем исходный C99-решатель и его бинарный контракт. + if backend == "eigen": # Загружаем дополнительный компонент только после явного выбора пользователя. + from .signal_eigen import library as eigen_library # Ленивый импорт не требует наличия Eigen при обычной работе и в прошивках. + operation = eigen_library(core._name).set_signal_reconstruct_eigen # Соседняя DLL реализует тот же контракт входов, выходов и work. + code = operation(x, y, count, list(METHODS).index(method), degree, output_count, int(endpoint), ox, oy, meta, work, len(work)) # Оба решателя получают одинаковые данные и настройки сетки. if code: raise ValueError({1: "Недостаточно точек для выбранной степени или неверные параметры", 2: "Нужны конечные значения и минимум две различные временные точки", - 3: "Неустойчивая аппроксимация: уменьшите степень"}.get(code, "Ошибка расчёта")) + 3: "Неустойчивая аппроксимация: уменьшите степень", 5: "Недостаточно памяти или ошибка решателя Eigen"}.get(code, "Ошибка расчёта")) # Дополнительная настольная ошибка не меняет прежние коды C99. pieces = () if with_model: from .plot_formula import reconstruction_pieces if method == 'polynomial' and output_count <= degree: - pieces = reconstruct(points, method, degree + 1, degree, with_model=True).pieces + pieces = reconstruct(points, method, degree + 1, degree, endpoint=endpoint, with_model=True, backend=backend).pieces # Короткий выход требует отдельной сетки для формулы; сохраняем решатель и режим конечной точки. else: pieces = reconstruction_pieces(work, count, int(meta[1]), method, degree, oy, endpoint) - return Reconstruction(list(zip(ox, oy)), int(meta[0]), int(meta[1]), meta[2], pieces) + result_points = list(zip(ox, oy)) # Включаем формирование выходных Python-точек в отображаемое время обработки. + return Reconstruction(result_points, int(meta[0]), int(meta[1]), meta[2], pieces, backend, (perf_counter() - started) * 1000) # Переводим секунды монотонного таймера в миллисекунды без влияния изменения системных часов. def dac12(volts, vref=3.3): data = (C.c_double * len(volts))(*volts) diff --git a/python/set_devices/signal_reference.py b/python/set_devices/signal_reference.py new file mode 100644 index 0000000..f61049d --- /dev/null +++ b/python/set_devices/signal_reference.py @@ -0,0 +1,168 @@ +"""Сравнение полной записи с CSV-эталоном независимо от Qt и приложения.""" # Ядро сохраняет все исходные отсчёты. +from array import array # Массивы double компактно хранят амплитуды без потери точности. +from dataclasses import dataclass, field # Контейнеры связывают кривые, показатели и независимый словарь анализа каждого результата. +from set_devices.signal_diagnostics import analyze_comparison # Общий локальный анализ работает без GUI и выполняется вместе со сравнением в worker. +import csv # Потоковый parser поддерживает разделители и кавычки CSV. +from datetime import datetime # Абсолютное ISO-время переводится в миллисекунды. +import math # Проверки конечности отделяют корректные измерения от пропусков. + + +@dataclass # Создаём конструктор независимого от GUI контейнера кривой. +class Trace: # Временная шкала каждого канала измеряется в миллисекундах. + name: str # Имя канала используется в выборе, легенде и отчёте. + times: list # Строго возрастающие метки могут быть общими для каналов CSV. + values: array # Амплитуды в единицах канала; NaN обозначает разрыв записи. + + +@dataclass # Передаём вычисленный результат между слоями приложения единым объектом. +class Comparison: # Полные кривые отделены от экранной огибающей. + measured: Trace # Измерение со временем относительно первого отсчёта. + reference: Trace # Эталон после совмещения начал и временного сдвига. + difference: Trace # Остаток на общей части временной сетки измерения. + expected: array # Интерполированный эталон на сетке остатка, включая пропуски. + metrics: dict # Показатели ошибки и параметры воспроизводимого расчёта. + analysis: dict = field(default_factory=dict) # Необязательное поле сохраняет совместимость прежнего конструктора с пятью аргументами. + + +def checkpoint(cancel): # Callback позволяет прерывать расчёт без зависимости от потоковой библиотеки. + if cancel(): # Проверяем запрос в контрольных точках прохода по данным. + raise InterruptedError('Расчёт отменён') # Не публикуем частичный результат после отмены. + + +def trace_from_pairs(name, points, transform=float, cancel=lambda: False): # Преобразование амплитуд задаёт потребитель, например множитель и IQ. + times, values = [], array('d') # Сохраняем время списком, амплитуды — 64-битными double. + previous = -math.inf # Первая конечная метка допустима независимо от её знака. + for index, (stamp, value) in enumerate(points): # Обходим полную историю без экранного сокращения. + if index % 16384 == 0: # Проверяем отмену порциями по 16384 точки для снижения накладных расходов. + checkpoint(cancel) # Прерываем подготовку по запросу вызывающей стороны. + if not math.isfinite(stamp) or stamp <= previous: # Повторы и обратный ход времени делают интерполяцию неоднозначной. + raise ValueError('Время должно строго возрастать, без повторов') # Не исправляем исходную сетку молча. + times.append(stamp) # Дробное время сохраняется без округления. + values.append(transform(value)) # Применяем масштаб до сравнения ровно один раз. + previous = stamp # Следующая точка должна находиться строго правее текущей. + if not times: # Пустая запись не имеет начала для совмещения. + raise ValueError('В канале нет отсчётов') # Сообщаем отсутствие исходных данных. + return Trace(name, times, values) # Возвращаем снимок, независимый от изменяемого живого буфера. + + +def read_reference(path, cancel=lambda: False): # Путь задаётся потребителем без правил хранения конкретного приложения. + """Читает CSV потоково, сохраняя пустые ячейки и NaN как пропуски.""" # Разрывы впоследствии исключаются из статистики. + with open(path, encoding='utf-8-sig', newline='') as stream: # Поддерживаем UTF-8 с необязательным BOM и штатную обработку CSV-переносов. + first = stream.readline() # Заголовок определяет разделитель столбцов. + delimiter = ';' if ';' in first else '\t' if '\t' in first else ',' # Принимаем точку с запятой, табуляцию и запятую. + stream.seek(0) # Заголовок должен также попасть в CSV-parser. + rows = csv.reader(stream, delimiter=delimiter) # Читаем построчно без хранения текста всего файла. + header = [s.strip() for s in next(rows, [])] # Пробелы вокруг имён не являются частью имени канала. + if len(header) < 2 or header[0] not in ('time_ms', 'timestamp'): # Требуем явную единицу времени и хотя бы один канал. + raise ValueError('Эталон: первый столбец должен быть time_ms или timestamp') # Не угадываем шкалу неизвестного формата. + names = header[1:] # Остальные столбцы содержат амплитуды каналов. + if any(not name for name in names) or len(set(names)) != len(names): # Имена должны однозначно определять столбец. + raise ValueError('Эталон: требуются непустые уникальные имена каналов') # Предотвращаем потерю повторного столбца в словаре. + times = [] # Одна временная сетка разделяется всеми каналами файла. + traces = {name: Trace(name, times, array('d')) for name in names} # Каждый канал получает собственный массив амплитуд. + for index, row in enumerate(rows, 2): # Первая строка данных имеет номер 2 после заголовка. + if index % 16384 == 0: # Проверяем отмену раз в 16384 строки большого CSV. + checkpoint(cancel) # Закрытие окна не требует дочитывать весь файл. + if len(row) != len(header): # Каждая строка должна соответствовать заявленным столбцам. + raise ValueError(f'Эталон, строка {index}: неверное число столбцов') # Номер строки помогает найти повреждение. + if header[0] == 'time_ms': # Относительное время уже выражено во внутренних миллисекундах. + stamp = float(row[0]) # Сохраняем дробные доли миллисекунды. + else: # timestamp задаёт абсолютное ISO-время с часовым поясом. + date = datetime.fromisoformat(row[0].replace('Z', '+00:00')) # Суффикс UTC нормализуется также для Python 3.8. + if date.tzinfo is None: # Время без пояса зависит от настроек компьютера. + raise ValueError('timestamp эталона должен содержать часовой пояс') # Исключаем неоднозначное абсолютное время. + stamp = date.timestamp() * 1000 # Множитель 1000 переводит UNIX-секунды в миллисекунды. + if not math.isfinite(stamp) or (times and stamp <= times[-1]): # Сетка обязана быть конечной и строго возрастающей. + raise ValueError(f'Эталон, строка {index}: время должно строго возрастать') # Не сортируем и не удаляем отсчёты автоматически. + times.append(stamp) # Добавляем общую метку всех каналов текущей строки. + for trace, text in zip(traces.values(), row[1:]): # Порядок каналов соответствует порядку заголовка. + value = float(text) if text.strip() else math.nan # Пустая ячейка обозначает разрыв, а не нулевую амплитуду. + if math.isinf(value): # Бесконечность считается повреждением данных, в отличие от NaN. + raise ValueError(f'Эталон, строка {index}: бесконечное значение') # Указываем строку недопустимой амплитуды. + trace.values.append(value) # Сохраняем каждое значение без децимации. + if len(times) < 2: # Для линейной интерполяции необходим хотя бы один отрезок. + raise ValueError('Эталон должен содержать хотя бы две временные точки') # Одноточечный эталон не публикуется. + checkpoint(cancel) # Проверяем отмену и для короткого файла перед возвратом. + return traces # Выбор конкретного канала остаётся вызывающей стороне. + + +def compare(measured, reference, *, align_start=True, shift_ms=0., # Положительный сдвиг задерживает эталон на заданные миллисекунды. + interval=None, absolute_tolerance=0., relative_tolerance_pct=0., # Интервал отсчитывается от начала измерения; допуск состоит из двух частей. + cancel=lambda: False, circuit=None, channels=None): # Необязательные параметры контура и связанные каналы расширяют анализ без изменения прежних вызовов. + """Интерполирует эталон на сетку измерения без экстраполяции и заполнения NaN.""" # Статистика использует все корректные исходные точки. + if not measured.times or len(reference.times) < 2: # Нужны начало измерения и две опоры эталона. + raise ValueError('Недостаточно данных для сравнения') # Пустые данные не должны давать нулевую ошибку. + if any(not math.isfinite(x) for x in (shift_ms, absolute_tolerance, relative_tolerance_pct)): # Неконечные параметры нарушают координаты и допуск. + raise ValueError('Параметры сравнения должны быть конечными числами') # Сообщаем причину до начала прохода. + if absolute_tolerance < 0 or relative_tolerance_pct < 0: # Коридор допуска имеет неотрицательную ширину. + raise ValueError('Допуск должен быть неотрицательным') # Отрицательный допуск не имеет физического смысла. + if interval is not None and (not all(math.isfinite(x) for x in interval) or interval[0] >= interval[1]): # Обе границы должны образовывать конечный непустой интервал. + raise ValueError('Начало интервала должно быть меньше конца') # Не переставляем ошибочно введённые границы. + origin = measured.times[0] # Начало измерения задаёт ноль результирующей шкалы времени. + ref_origin = reference.times[0] if align_start else origin # Опция совмещения отделяет относительное сравнение от абсолютного. + mt = [t - origin for t in measured.times] # Нормализуем временную сетку измерения без изменения амплитуд. + rt = [t - ref_origin + shift_ms for t in reference.times] # Совмещаем начало и применяем сдвиг эталона вправо. + left, right = interval if interval is not None else (mt[0], mt[-1]) # По умолчанию используем полную длительность измерения. + times, differences, expected = [], array('d'), array('d') # Остаток и интерполированный эталон получают общую сетку. + count = candidates = outside = invalid = beyond = 0 # Считаем сравнения, выбранные точки, отсутствие покрытия, пропуски и превышения. + mean = m2 = mean_abs = mean_square = 0. # Потоковые накопители среднего, дисперсии, MAE и среднего квадрата. + peak, peak_time, first_outside = -1., None, None # Начальный максимум ниже нуля регистрирует и полностью совпадающие кривые. + ref_min, ref_max = math.inf, -math.inf # Размах определяется на реально сравниваемой части эталона. + j = 0 # Указатель отрезка эталона перемещается только вперёд. + rv = reference.values # Локальная ссылка уменьшает накладные расходы основного прохода. + for index, (stamp, value) in enumerate(zip(mt, measured.values)): # Перебираем каждый исходный отсчёт измерения. + if index % 16384 == 0: # Проверка раз в 16384 точки ограничивает стоимость поддержки отмены. + checkpoint(cancel) # Прерываем устаревший или отменённый расчёт. + if stamp < left or stamp > right: # Границы интервала включаются, внешние точки исключаются. + continue # Посторонние участки записи не влияют на статистику. + candidates += 1 # Покрытие относится ко всем выбранным точкам измерения. + if stamp < rt[0] or stamp > rt[-1]: # Вне временной сетки эталона экстраполяция запрещена. + outside += 1 # Отдельно учитываем отсутствие покрытия по времени. + continue # Не подставляем крайнее значение за пределами записи. + while j + 1 < len(rt) and rt[j + 1] <= stamp: # Находим левую опору без повторного поиска с начала. + j += 1 # Однонаправленное продвижение сохраняет линейную сложность. + target = rv[j] # Точное совпадение с узлом не зависит от соседних пропусков. + if stamp != rt[j]: # Между узлами используем две соседние опоры. + fraction = (stamp - rt[j]) / (rt[j + 1] - rt[j]) # Доля положения внутри временного отрезка безразмерна. + target = target + fraction * (rv[j + 1] - target) # NaN любой опоры сохраняет разрыв интерполяции. + times.append(stamp) # Сохраняем общую временную сетку, включая пропуски. + expected.append(target) # Интерполированные амплитуды доступны для дальнейшего анализа. + if not math.isfinite(value) or not math.isfinite(target): # Некорректные значения любой кривой исключаются из показателей. + invalid += 1 # Пропуски уменьшают процент покрытия интервала. + differences.append(math.nan) # График остатка сохраняет разрыв на этом месте. + continue # Не загрязняем численные накопители значениями NaN. + difference = value - target # Знак ошибки всегда означает измерение минус эталон. + differences.append(difference) # Каждая корректная точка получает собственный остаток. + count += 1 # Число сравнений служит знаменателем средних показателей. + delta = difference - mean # Алгоритм Уэлфорда использует предыдущее среднее. + mean += delta / count # Обновляем среднее смещение без накопления большой суммы. + m2 += delta * (difference - mean) # Центральный момент устойчиво оценивает разброс остатка. + magnitude = abs(difference) # Модуль нужен для MAE, максимума и допуска. + mean_abs += (magnitude - mean_abs) / count # Потоково обновляем среднюю абсолютную ошибку. + mean_square += (difference * difference - mean_square) / count # Средний квадрат затем преобразуется в RMSE. + ref_min, ref_max = min(ref_min, target), max(ref_max, target) # Нормализация использует фактический размах эталона. + if magnitude > peak: # При равных максимумах оставляем время первого. + peak, peak_time = magnitude, stamp # Величина и время максимума относятся к одной точке. + if magnitude > absolute_tolerance + relative_tolerance_pct * .01 * abs(target): # Множитель 0,01 переводит процент в долю модуля эталона. + beyond += 1 # Равенство границе допустимо; учитываем строгое превышение. + if first_outside is None: # Время первого выхода не заменяется последующими. + first_outside = stamp # Сохраняем миллисекунды от начала измерения. + checkpoint(cancel) # Не публикуем результат, отменённый в последней порции. + if not count: # Без корректного перекрытия показатели не определены. + raise ValueError('Нет общих корректных отсчётов в выбранном интервале') # Вызывающая сторона сбрасывает старые показатели. + rmse = math.sqrt(max(0., mean_square)) # Защита от отрицательного округления перед извлечением корня. + metrics = dict(count=count, selected_points=candidates, outside_reference=outside, # Различаем использованные, выбранные и непокрытые точки. + invalid_points=invalid, coverage_pct=100. * count / candidates, # Покрытие выражается в процентах от выбранных точек. + bias=mean, mae=mean_abs, rmse=rmse, error_std=math.sqrt(max(0., m2 / count)), # СКО характеризует всю сравниваемую запись. + max_abs=peak, max_abs_time_ms=peak_time, # Максимум хранится в единицах канала, время — в миллисекундах. + nrmse_span_pct=100. * rmse / (ref_max - ref_min) if ref_max > ref_min else None, # Постоянный эталон не имеет ненулевого размаха для нормализации. + outside_tolerance=beyond, outside_tolerance_pct=100. * beyond / count, # Превышения считаются только среди корректных сравнений. + first_outside_time_ms=first_outside, # None явно означает отсутствие выхода за допуск. + absolute_tolerance=absolute_tolerance, relative_tolerance_pct=relative_tolerance_pct, # Сохраняем параметры коридора для отчёта. + align_start=align_start, shift_ms=shift_ms, interval_ms=interval, reference_min=ref_min, reference_max=ref_max) # Настройки и пределы сравниваемого эталона обеспечивают воспроизводимость анализа. + result = Comparison(Trace(measured.name, mt, measured.values), # Повторно используем полный массив амплитуд измерения. + Trace(reference.name, rt, reference.values), # Полный эталон доступен для наложения на график. + Trace('Измерение − эталон', times, differences), expected, metrics) # Остаток и показатели относятся ко всем корректным исходным точкам. + context = dict(channels, measured_origin_ms=origin, reference_origin_ms=ref_origin, shift_ms=shift_ms) if channels else None # Связанные каналы сохраняют взаимное положение относительно выбранного измерения и единого сдвига эталона. + result.analysis = analyze_comparison(result, cancel, circuit=circuit, channels=context) # Идентификация моделей использует тот же снимок данных и явно заданные электрические ограничения. + return result # Публикуем только целое сравнение вместе с завершёнными гипотезами. diff --git a/python/set_devices/tms_terminal.py b/python/set_devices/tms_terminal.py index d51f17a..6d4ef17 100644 --- a/python/set_devices/tms_terminal.py +++ b/python/set_devices/tms_terminal.py @@ -43,9 +43,9 @@ CRC_TAIL = 3 #: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает. MIN_DIGIT_BYTES = 1 -MAX_DIGIT_BYTES = 64 +MAX_DIGIT_BYTES = 96 # Ответ 22220 содержит 70 дискретных байт; предел 96 оставляет запас в пределах буфера UART прошивки на 512 байт. MIN_ANALOG_COUNT = 1 -MAX_ANALOG_COUNT = 64 +MAX_ANALOG_COUNT = 128 # Ответ 22220 содержит 80 знаковых слов; 128 слов вместе с дискретами помещаются в 512-байтовый буфер прошивки. #: Номера команд, enum в конце RS_Functions.h. COMMAND_NAMES: Dict[int, str] = { diff --git a/python/set_devices/trend_limits.py b/python/set_devices/trend_limits.py new file mode 100644 index 0000000..569d515 --- /dev/null +++ b/python/set_devices/trend_limits.py @@ -0,0 +1,5 @@ +"""Shared limits for host-side trend history buffers.""" + +DEFAULT_CAPACITY = 100_000 +MAX_CAPACITY = 5_000_000 +MAX_TOTAL_POINTS = 10_000_000 diff --git a/python/set_devices/ump_can.py b/python/set_devices/ump_can.py new file mode 100644 index 0000000..10cd4bb --- /dev/null +++ b/python/set_devices/ump_can.py @@ -0,0 +1,50 @@ +"""Explicit interpretation of ProtoCAN registers with the UMP 167/v1 map. + +This is a manually selected dictionary, not detection of the legacy service +CAN packet format or a claim that a particular firmware implements ProtoCAN. +""" +from protocan import protocan as pc +from protocan.gas_167 import REGISTERS, describe_word + + +def decode_gas167(frame, node): + result = pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr) + result.device_label = "УМП %d (выбран вручную)" % node + result.protocol = "УМП %d / ProtoCAN GAS 167/v1" % node + if not frame.ide or frame.rtr or frame.is_error: + return result + if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE, + pc.MsgType.MODBUS_HOLDING): + return result + if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: + start = result.id.body + else: + start, _count = pc.split_modbus(result.id.body) + words = result.registers + if words is None: + return result + labels = [] + for address, value in words or [(start, None)]: + offset = address if 0 <= address < 128 else address - 0x5000 + label = "0x%04X" % address + if 0 <= offset < len(REGISTERS): + entry = REGISTERS[offset] + label += " · " + entry['description'] + result.fields += [ + (" УМП 167/v1 0x%04X" % address, entry['description']), + (" Назначенные ID УМП 1/2", "%d / %d" % (entry['ump1_id'], entry['ump2_id'])), + (" Доступ", entry['access']), + ] + else: + label += " · вне карты 167/v1" + if value is not None: + label += " = %d [0x%04X]" % (value, value) + description = describe_word(offset, value) + if description: + label += " — " + description + result.fields.append((" Биты 0x%04X" % address, description)) + labels.append(label) + result.summary = "УМП %d · GAS 167/v1%s: %s" % ( + node, " запрос" if not words else "", "; ".join(labels)) + result.fields.append((" Карта", "167/v1 выбрана вручную")) + return result diff --git a/python/set_devices/waveform.py b/python/set_devices/waveform.py index 6660730..c42a428 100644 --- a/python/set_devices/waveform.py +++ b/python/set_devices/waveform.py @@ -17,7 +17,7 @@ class Waveform: vref: float period_ms: float -def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2): +def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Именованный выбор решателя сохраняет прежние вызовы генератора. """One cyclic period, last endpoint excluded (no duplicated seam sample).""" if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000: raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") @@ -36,23 +36,23 @@ def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2): raise ValueError("Период × частота должны давать от 2 до 4096 отсчётов") if abs(exact_count - count) > 1e-7: raise ValueError("Период должен содержать целое число отсчётов при выбранной частоте") - result = reconstruct(points, method, count, degree, endpoint=False) + result = reconstruct(points, method, count, degree, endpoint=False, backend=backend) # Формируем циклическую таблицу выбранным пользователем вариантом МНК. # Check the endpoint too: polynomial fitting can move its value. - checked = reconstruct(points, method, count + 1, degree) + checked = reconstruct(points, method, count + 1, degree, backend=backend) # Конечную точку проверяем тем же решателем, исключая расхождение проверки диапазона ЦАП и результата. dac12([p[1] for p in checked.points], vref) codes = dac12([p[1] for p in result.points], vref) return Waveform(tuple(result.points), codes, sample_rate, vref, period) -def recipe(points, sample_rate, vref, method, degree): +def recipe(points, sample_rate, vref, method, degree, *, backend="native"): # Новый необязательный параметр сохраняет совместимость ранее записанных рецептов версии 1. return {"version": 1, "points_ms_volts": [list(pair) for pair in points], "sample_rate": sample_rate, - "vref": vref, "method": method, "degree": degree} + "vref": vref, "method": method, "degree": degree, "backend": backend} # Сохраняем выбранный решатель для воспроизводимого восстановления сигнала. def load_recipe(text): data = json.loads(text) if not isinstance(data, dict) or data.get("version") != 1: raise ValueError("Неизвестная версия задания генератора") points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"]) - generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"]) + generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"], backend=data.get("backend", "native")) # Старые рецепты без варианта используют прежний алгоритм, новые проверяют именно сохранённый решатель. return points, data def c_header(wave): diff --git a/python/set_devices/waveform_examples.py b/python/set_devices/waveform_examples.py new file mode 100644 index 0000000..3c55adc --- /dev/null +++ b/python/set_devices/waveform_examples.py @@ -0,0 +1,242 @@ +"""Двадцать синхронных заданий для двухканального ЦАП и векторного экрана XY.""" # Общая математика не зависит от Qt и используется предпросмотром и готовыми CSV. +import math # Аналитические траектории и несущие вычисляются в нормированных координатах. +from dataclasses import dataclass # Неизменяемый результат связывает описание, контуры и общую временную сетку. + +SAMPLE_RATE = 40_000 # 40 кГц делит без остатка TIM6 72 МГц F407 и 160 МГц G474. +SAMPLE_COUNT = 1000 # Тысяча пар укладывается в аппаратный предел 4096 и даёт повторение 40 Гц при 40 кГц. +CAT_SAMPLE_RATE = 4_000 # 4000 пар/с воспроизводят 32 позы по 125 точек за одну секунду: 32 кадра/с и один шаговый цикл в секунду. +ANIMATION_SAMPLE_RATE = 4_000 # Полный геометрический цикл из 4000 пар длится одну секунду и использует точный делитель TIM6 обоих MCU. +ANIMATION_MODES = (("auto", "Движение фигуры"), ("twist", "Скручивание"), ("rotate", "Вращение"), ("pulse", "Пульсация"), ("none", "Статично")) # Параметр выбирает изменение координат всей фигуры, а не способ подсветки обходящего её луча. +WALKING_CAT = ("walking_cat", "Кот идёт · усы, лапы и хвост", "Полный цикл походки: 32 плавные позы, округлые щёки и спина, овальные глаза, четыре лапы, усы и изогнутый хвост.") # Дополнительный анимированный пример не заменяет десять основных XY-фигур. +BOAT_ON_WAVES = ("boat_waves", "Кораблик на волнах", "Кораблик, мачта, паруса и флажок неподвижны; два ряда волн движутся вправо под корпусом.") # Специальная сцена сохраняет неизменную геометрию кораблика во всех позах движения воды. +XY_EXTRA_EXAMPLES = (WALKING_CAT, BOAT_ON_WAVES) # Общий список дополнительных сцен синхронизирует каталог UI, готовые CSV и математическую модель. +DUAL_EXAMPLES = ( # Два канала используют общий период и воспроизводятся синхронно через PA4 и PA5. + ("quadrature", "Синус и косинус · 90°", "Одинаковая частота, квадратурный сдвиг 90°; в XY получается окружность."), # Базовая проверка амплитуды, полярности и синхронности каналов. + ("phase_120", "Два синуса · 120°", "Два синуса со сдвигом 120° для проверки фазовых измерений."), # Фазовый сдвиг воспроизводится без изменения временной сетки. + ("harmonic_1_2", "Основная частота и вторая гармоника", "CH1 содержит один период, CH2 — два периода за общий кадр."), # Целое отношение частот замыкает оба сигнала на границе таблицы. + ("beats_5_6", "Биения · 5:6", "Пять и шесть колебаний за кадр; сложение каналов показывает биения."), # Разность частот равна частоте повторения общего кадра. + ("triangle_sine", "Треугольник и синус", "Два периода треугольника и синуса для сравнения формы и гармоник."), # Обе формы имеют одинаковые амплитуду и смещение. + ("square_quadrature", "Два меандра · 90°", "Квадратурные прямоугольные сигналы с заполнением 50 %."), # Смещение на четверть периода удобно для проверки квадратурного счётчика. + ("pwm_25_75", "Импульсы · заполнение 25 / 75 %", "Два синхронных импульсных сигнала с разной длительностью высокого уровня."), # Общий старт периода позволяет сравнивать заполнение на осциллографе. + ("amplitude_modulation", "АМ-сигнал и огибающая", "CH1 — несущая с восемью колебаниями, CH2 — её медленная огибающая."), # Глубина модуляции не выводит сигнал за диапазон ЦАП. + ("chirp_iq", "ЛЧМ · квадратура I/Q", "Частота растёт от 2 до 14 колебаний на кадр; второй канал сдвинут на 90°."), # Интеграл линейной частоты даёт восемь полных оборотов фазы за кадр. + ("ring_down", "Затухающие колебания · два канала", "Восемь квадратурных колебаний с экспоненциальным затуханием; пакет повторяется."), # Переход через границу кадра является началом следующего пакета. +) # Стабильные ключи совпадают с именами готовых CSV. +XY_EXAMPLES = ( # Контуры рассчитаны для одинакового масштаба X и Y и входной связи DC. + ("smiley", "Улыбка", "Лицо, глаза и улыбка; быстрые переходы между четырьмя контурами."), # Первый пример сохраняет прежний тест генератора. + ("circle", "Окружность", "Один непрерывный круг — проверка квадратуры и масштаба каналов."), # Простейшая замкнутая траектория без переходов между деталями. + ("heart", "Сердце", "Параметрическое сердце с плавным замкнутым контуром."), # Классическая сумма гармоник формирует узнаваемую фигуру. + ("star", "Пятиконечная звезда", "Непрерывный контур звезды с пятью внешними и пятью внутренними вершинами."), # Луч проходит все стороны в порядке обхода контура. + ("rose", "Лазерный цветок · 5 лепестков", "Полярная роза с пятью лепестками и общим центром."), # Повторяющиеся дуги напоминают узоры векторного лазерного шоу. + ("lissajous", "Лиссажу · 3:2", "Замкнутая сетка из синусоид с отношением частот 3:2."), # Целые множители обеспечивают непрерывное периодическое замыкание. + ("spirograph", "Спирограф · орбиты", "Гипотрохоида с восемью лепестками и переплетающимися орбитами."), # Разность основной и обратной гармоник создаёт тонкий симметричный узор. + ("butterfly", "Бабочка", "Аналитическая кривая бабочки: симметричные крылья и волнистый край."), # Несколько оборотов параметра прорисовывают все детали замкнутой фигуры. + ("saturn", "Сатурн", "Круглая планета и наклонное эллиптическое кольцо."), # Два контура сохраняются отдельно, чтобы предпросмотр показывал реальный переход. + ("laser_tunnel", "Лазерный туннель", "Внешняя рамка неподвижна, внутренние скручиваются и раскручиваются в замкнутом цикле."), # Движение меняет относительные углы рамок, сохраняя центр и размер внешнего контура. +) # Ровно десять XY-примеров доступны в интерфейсе и на диске. + +@dataclass(frozen=True) # Подготовленный кадр нельзя незаметно изменить после его выбора или экспорта. +class ExampleData: # Один результат содержит всё необходимое для просмотра и подготовки обоих выходов. + key: str # Постоянный идентификатор используется в CSV и каталоге примеров. + kind: str # Значение dual или xy определяет временной либо геометрический предпросмотр. + title: str # Русское название отображается в окне выбора и документации. + description: str # Описание объясняет форму, отношение частот и особенности обхода. + rate: int # Общая частота обоих каналов измеряется в парах отсчётов в секунду. + samples: tuple # Каждая строка содержит время в миллисекундах, X/CH1 и Y/CH2 в вольтах. + strokes: tuple # Контуры XY сохраняют границы деталей для честного отображения переходов луча. + frames: tuple = () # Анимация хранит отдельные позы, тогда как аппаратная таблица последовательно содержит их все. + animation: str = "none" # Название фактического движения связывает предпросмотр, аппаратные кадры и метаданные экспортированного примера. + +def _curve(function, count): # Аналитическая траектория преобразуется в фиксированное число отсчётов одного периода. + return tuple(function(2 * math.pi * index / count) for index in range(count)) # Конечная точка периода не дублируется, замыкание происходит при повторении DMA-таблицы. + +def _polygon(vertices, count): # Равномерное движение вдоль ломаной не зависит от разной длины её сторон. + lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(vertices, vertices[1:])] # Длины сторон измеряются в нормированных координатах фигуры. + total, segment, origin = sum(lengths), 0, 0.0 # Суммарный путь и начало текущей стороны задают положение движущегося луча. + result = [] # Координаты сохраняются в порядке обхода, без скачков между соседними сторонами. + for index in range(count): # Аппаратный кадр получает ровно запрошенное число координат. + distance = total * index / count # Равный шаг по длине пути удерживает примерно одинаковую яркость прямых участков. + while segment < len(lengths) - 1 and distance > origin + lengths[segment]: # Переходим к следующей стороне после достижения её начала. + origin += lengths[segment] # Пройденная длина предыдущей стороны исключается из локальной координаты. + segment += 1 # Последующая интерполяция использует следующую пару вершин. + fraction = (distance - origin) / lengths[segment] # Доля текущей стороны лежит между её началом и концом. + a, b = vertices[segment:segment + 2] # Две соседние вершины определяют единственный прямой участок. + result.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная интерполяция не добавляет закругления к углам геометрической фигуры. + return tuple(result) # Готовая последовательность подходит для периодического воспроизведения и предпросмотра. + +def smiley_strokes(vref): # Прежний рисунок сохраняет четыре контура и ровно 1000 синхронных отсчётов. + if not math.isfinite(vref) or not 0 < vref <= 3.6: # Предел опорного напряжения совпадает с контрактом 12-битного ЦАП. + raise ValueError("Vref должен быть в диапазоне 0…3,6 В") # Недопустимая шкала отвергается до расчёта таблицы. + contours = ((0, 0, .85, 0, 2 * math.pi, 500), (-.27, .25, .055, 0, 2 * math.pi, 80), (.27, .25, .055, 0, 2 * math.pi, 80), (0, .10, .50, 7 * math.pi / 6, 11 * math.pi / 6, 340)) # Лицо, два глаза и нижняя дуга улыбки сохраняют прежние размеры и порядок. + return tuple(tuple(((1 + cx + radius * math.cos(start + (stop - start) * i / (count - 1))) * vref / 2, (1 + cy + radius * math.sin(start + (stop - start) * i / (count - 1))) * vref / 2) for i in range(count)) for cx, cy, radius, start, stop, count in contours) # Обе конечные точки дуг включены, а отступ лица от шин питания составляет 7,5 % Vref. + +def _xy_strokes(key): # Нормированные траектории отделены от электрического масштаба и поворота рисунка. + if key == "smiley": # Совместимость старого теста важна для экспорта и настройки осциллографа. + return tuple(tuple((x - 1, y - 1) for x, y in stroke) for stroke in smiley_strokes(2.0)) # При Vref 2 В вычитание единицы восстанавливает исходные координаты вокруг нуля. + functions = { # Каждый аналитический контур укладывается в один непрерывный аппаратный период. + "circle": lambda a: (math.cos(a), math.sin(a)), # Одинаковые амплитуды синуса и косинуса дают точный круг. + "heart": lambda a: (16 * math.sin(a) ** 3, 13 * math.cos(a) - 5 * math.cos(2 * a) - 2 * math.cos(3 * a) - math.cos(4 * a)), # Классическая параметризация сердца использует безразмерные коэффициенты гармоник. + "rose": lambda a: (math.cos(5 * a) * math.cos(a), math.cos(5 * a) * math.sin(a)), # Полярный радиус cos(5a) создаёт пять симметричных лепестков. + "lissajous": lambda a: (math.sin(3 * a + math.pi / 2), math.sin(2 * a)), # Частоты 3 и 2 колебания за период формируют замкнутую фигуру Лиссажу. + "spirograph": lambda a: (.65 * math.cos(a) + .35 * math.cos(7 * a), .65 * math.sin(a) - .35 * math.sin(7 * a)), # Противоположное вращение седьмой гармоники создаёт восемь радиальных лепестков. + "butterfly": lambda a: (math.sin(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5), math.cos(a) * (math.exp(math.cos(a)) - 2 * math.cos(4 * a) - math.sin(a / 12) ** 5)), # Полный период аналитической бабочки составляет 24π по параметру a. + } # Геометрическое масштабирование ниже оставляет запас до нуля и Vref при любом повороте. + if key in functions: # Большинство фигур формируются одним аналитическим контуром. + function = functions[key] # Выбор по ключу не зависит от перевода отображаемого названия. + strokes = (_curve((lambda a: function(12 * a)) if key == "butterfly" else function, SAMPLE_COUNT),) # Двенадцать оборотов замыкают полный период бабочки; остальные кривые обходятся за 2π. + elif key == "star": # Чередование двух радиусов формирует выпуклые и внутренние вершины звезды. + vertices = tuple(((1 if i % 2 == 0 else .42) * math.cos(math.pi / 2 + i * math.pi / 5), (1 if i % 2 == 0 else .42) * math.sin(math.pi / 2 + i * math.pi / 5)) for i in range(10)) # Внешние вершины смотрят вверх; внутренний радиус равен 42 % внешнего. + strokes = (_polygon(vertices + vertices[:1], SAMPLE_COUNT),) # Дополнительная первая вершина задаёт последнюю замыкающую сторону. + elif key == "saturn": # Планета и кольцо требуют двух отдельных обходов. + strokes = (_curve(lambda a: (.54 * math.cos(a), .54 * math.sin(a)), 500), _curve(lambda a: (math.cos(a), .28 * math.sin(a) + .30 * math.cos(a)), 500)) # Равные бюджеты отсчётов дают круг и наклонное эллиптическое кольцо. + elif key == "laser_tunnel": # Четыре вложенных рамки создают перспективную композицию. + strokes = tuple(_polygon(tuple((radius * math.cos(math.pi / 4 + i * math.pi / 2 + turn), radius * math.sin(math.pi / 4 + i * math.pi / 2 + turn)) for i in range(5)), 250) for radius, turn in ((1, 0), (.72, .12), (.45, .24), (.20, .36))) # По 250 отсчётов на рамку сохраняют общий размер 1000 пар и постепенный поворот вложенных квадратов. + else: # Неизвестный ключ не должен незаметно подменяться случайной фигурой. + raise ValueError("Неизвестный XY-пример: " + key) # Ошибка данных каталога выявляется до подготовки аппаратного задания. + points = [point for stroke in strokes for point in stroke] # Общий центр и масштаб вычисляются по всем деталям рисунка. + cx = (min(x for x, _ in points) + max(x for x, _ in points)) / 2 # Центр ограничивающего прямоугольника убирает постоянное смещение сложной фигуры. + cy = (min(y for _, y in points) + max(y for _, y in points)) / 2 # Вертикальное центрирование сохраняет запас до обеих шин ЦАП. + radius = max(math.hypot(x - cx, y - cy) for x, y in points) # Ограничение радиуса сохраняет диапазон напряжений при любом угле поворота. + return tuple(tuple((.85 * (x - cx) / radius, .85 * (y - cy) / radius) for x, y in stroke) for stroke in strokes) # Радиус 0,85 оставляет минимум 7,5 % Vref с каждой стороны при полном масштабе. + +def _dual_point(key, t): # Значение t задаёт долю общего повторяющегося периода двух каналов. + a = 2 * math.pi * t # Основная фаза за один аппаратный кадр проходит полный оборот. + functions = { # Нормированные амплитуды всех форм не превышают единицу по модулю. + "quadrature": lambda: (math.sin(a), math.cos(a)), # Квадратурная пара имеет одинаковые частоты и сдвиг 90°. + "phase_120": lambda: (math.sin(a), math.sin(a + 2 * math.pi / 3)), # Сдвиг 2π/3 соответствует 120 электрическим градусам. + "harmonic_1_2": lambda: (math.sin(a), math.sin(2 * a)), # Вторая координата содержит вторую гармонику первой. + "beats_5_6": lambda: (math.sin(5 * a), math.sin(6 * a)), # Разность частот составляет одно колебание за общий кадр. + "triangle_sine": lambda: (1 - 4 * abs((2 * t) % 1 - .5), math.sin(2 * a)), # Треугольник и синус совершают по два колебания за кадр. + "square_quadrature": lambda: (1 if t < .5 else -1, 1 if (t + .25) % 1 < .5 else -1), # Два меандра заполнения 50 % смещены на четверть периода. + "pwm_25_75": lambda: (1 if t < .25 else -1, 1 if t < .75 else -1), # Длительности высоких уровней составляют 25 и 75 % периода. + "amplitude_modulation": lambda: ((.6 + .4 * math.sin(a)) * math.sin(8 * a), .6 + .4 * math.sin(a)), # Второй канал совпадает с верхней огибающей восьмипериодной несущей относительно общего смещения Vref/2. + "chirp_iq": lambda: (math.sin(2 * math.pi * (2 * t + 6 * t * t)), math.cos(2 * math.pi * (2 * t + 6 * t * t))), # Производная фазы задаёт линейный рост частоты от 2 до 14 циклов на кадр. + "ring_down": lambda: (math.exp(-4 * t) * math.sin(8 * a), math.exp(-4 * t) * math.cos(8 * a)), # За кадр амплитуда уменьшается в e⁴ раз, после чего начинается следующий пакет. + } # Каждый ключ каталога имеет самостоятельную аналитическую форму пары. + return tuple(.85 * value for value in functions[key]()) # Ограничение амплитуды 85 % полудиапазона оставляет запас 7,5 % Vref до шин питания. + +def _cat_bezier(start, first, second, end, steps): # Кубическая кривая возвращает только новые вершины; общий конец соседних дуг не дублируется. + return tuple(((1-t)**3*start[0]+3*(1-t)**2*t*first[0]+3*(1-t)*t*t*second[0]+t**3*end[0], (1-t)**3*start[1]+3*(1-t)**2*t*first[1]+3*(1-t)*t*t*second[1]+t**3*end[1]) for t in (i/steps for i in range(1,steps+1))) # Формула Бернштейна сохраняет гладкие щёки, спину и лапы внутри выпуклой оболочки контрольных точек. + +def cat_strokes(phase): # Одна поза содержит 125 точек плавного силуэта; 32 позы сохраняют прежнюю аппаратную таблицу из 4000 пар. + angle=2*math.pi*phase; bob=.018*math.cos(2*angle) # Один замкнутый шаговый цикл задаёт плавное движение диагональных лап и небольшое покачивание корпуса. + body=((.26,.38),) # Начинаем у загривка: плечи, спина, круп и живот образуют органичный контур вместо правильного овала. + for first,second,end,steps in (((.08,.52),(-.30,.48),(-.44,.40),4),((-.60,.39),(-.67,.25),(-.60,.13),4),((-.53,-.06),(-.40,-.13),(-.20,-.06),4),((.12,-.11),(.28,.02),(.27,.20),5),((.31,.30),(.29,.35),(.26,.38),3)): # Пять дуг распределяют 20 новых точек между спиной, крупом, животом, грудью и шеей. + body+=_cat_bezier(body[-1],first,second,end,steps) # Касательные на соседних дугах меняются плавно и замыкают туловище за 21 точку. + head=((.20,.43),)+_cat_bezier((.20,.43),(.12,.55),(.17,.69),(.26,.72),4)+((.26,.90),(.39,.76)) # Левая щека округлена, а две вершины сохраняют естественный острый кончик левого уха. + head+=_cat_bezier(head[-1],(.45,.80),(.55,.80),(.61,.75),4)+((.78,.87),(.75,.63)) # Плавный лоб соединяет два уха; правое ухо имеет отдельные кончик и наружное основание. + head+=_cat_bezier(head[-1],(.89,.52),(.89,.36),(.74,.29),6)+_cat_bezier((.74,.29),(.63,.18),(.39,.17),(.26,.30),6)+_cat_bezier((.26,.30),(.21,.34),(.18,.38),(.20,.43),4) # Щёки и подбородок замыкаются округлой мордочкой; всего 29 точек вместо прежнего угловатого многоугольника. + tail=((-.54,.29),)+_cat_bezier((-.54,.29),(-.77,.30),(-.99,.61),(-.84,.83),5)+_cat_bezier((-.84,.83),(-.74,1.00),(-.48,.98),(-.56,.78),6) # Две плавные дуги образуют приподнятый хвост с округлым загнутым кончиком, всего 12 координат. + tail=tuple((x+.05*math.sin(angle+.8*i/11)*(i/11)**2,y+bob+.02*math.cos(angle+i/11)*i/11) for i,(x,y) in enumerate(tail)) # Покачивание усиливается к концу хвоста; основание следует за корпусом без разрывов. + legs=[] # Каждая лапа сохраняет собственный контур и фазу, чтобы передняя и дальняя пары не слипались. + for root,offset in ((-.45,0),(-.28,math.pi),(.07,math.pi),(.20,0)): # Диагональные пары чередуют опору и перенос, сохраняя узнаваемую походку. + stride=.14*math.sin(angle+offset); foot_y=-.41+.10*max(0,math.cos(angle+offset))**2 # Во время переноса лапа идёт вперёд и поднимается по гладкой дуге; у земли вертикальная скорость равна нулю. + start=(root,.07+bob); ankle=(root+stride,foot_y+.015) # Корень спрятан внутри туловища, голень заканчивается немного выше подушечки лапы. + legs.append((start,)+_cat_bezier(start,(root+stride*.18,-.04),(root+stride*.25,foot_y+.09),ankle,4)+((ankle[0]+.045,foot_y-.015),(ankle[0]+.09,foot_y+.005))) # Семь точек сглаживают колено/голень и добавляют округлый носок вместо прямоугольного окончания. + whiskers=tuple(((.62 if side>0 else .49,.41+bob),(x,y+bob)) for side,x,y in ((1,.97,.54),(1,.99,.41),(1,.96,.29),(-1,.24,.54),(-1,.22,.41),(-1,.25,.29))) # Шесть независимых усов по две точки остаются хорошо различимыми по сторонам мордочки. + eyes=tuple(tuple((cx+.045*math.cos(2*math.pi*i/7),.54+bob+.035*math.sin(2*math.pi*i/7)) for i in range(8)) for cx in (.40,.66)) # Два небольших замкнутых овальных глаза получают по восемь точек вместо двух коротких прямых. + nose=(((.50,.43+bob),(.61,.43+bob),(.555,.38+bob),(.50,.43+bob)),) # Маленький треугольный нос занимает четыре координаты между глазами и усами. + mouth=(((.48,.35+bob),(.555,.30+bob),(.63,.35+bob)),) # Короткая улыбка делает мордочку узнаваемой, не увеличивая аппаратный бюджет фигуры. + strokes=(tuple((x,y+bob) for x,y in body),tuple((x,y+bob) for x,y in head),tail,*legs,*whiskers,*eyes,*nose,*mouth) # Сохраняем порядок деталей: тело/голова/хвост, четыре лапы, шесть усов, глаза, нос и рот. + return tuple(tuple((.72*x,.72*y) for x,y in stroke) for stroke in strokes) # Общий масштаб 0,72 удерживает новый округлый контур и хвост в допустимом диапазоне при любом повороте XY. + + +def boat_strokes(phase): # Один кадр содержит 30 неподвижных точек кораблика и 50 точек двух движущихся рядов волн. + hull = ((-.56, -.05), (-.20, -.05), (.20, -.05), (.58, -.05), (.42, -.26), (0, -.29), (-.36, -.26), (-.56, -.05)) # Восемь вершин замыкают корпус с поднятыми бортами и выделенным днищем без зависимости от фазы волн. + mast = ((0, -.05), (0, .72)) # Мачта закреплена на палубе и остаётся вертикальной во всех кадрах сцены. + main_sail = ((-.035, .65), (-.035, .34), (-.035, .03), (-.47, .03), (-.25, .34), (-.035, .65)) # Шесть координат левого треугольного паруса сохраняют его полный замкнутый контур. + fore_sail = ((.035, .55), (.25, .30), (.47, .03), (.25, .03), (.035, .03), (.035, .55)) # Шесть координат переднего паруса создают второй узнаваемый треугольник без поворота или качания. + pennant = ((0, .72), (.21, .65), (0, .58), (0, .72)) # Четыре точки замыкают неподвижный флажок на вершине мачты. + stripe = ((-.43, -.13), (-.15, -.16), (.20, -.16), (.46, -.13)) # Полоса на борту подчёркивает форму корпуса и также не зависит от движения воды. + upper_wave, lower_wave = [], [] # Два самостоятельных контура воды движутся с одной скоростью и имеют разные высоты и фазы. + for index in range(25): # По 25 точек на ряд дают 50 координат воды и общий бюджет ровно 80 пар на кадр. + x = -.85 + 1.70 * index / 24 # Фиксированное горизонтальное окно содержит обе границы воды и два полных периода волны. + angle = 2 * math.pi * (2 * index / 24 - phase) # Убывание фазы при фиксированном X перемещает гребни вправо, а полный цикл возвращает исходную воду. + upper_wave.append((x, -.36 + .05 * math.sin(angle) + .012 * math.sin(2 * angle))) # Небольшая вторая гармоника делает гребни выразительнее; верхняя вода остаётся ниже неподвижного днища. + lower_wave.append((x, -.55 + .04 * math.sin(angle + .8))) # Нижняя волна сдвинута на 0,8 радиана и создаёт глубину воды без изменения положения кораблика. + return tuple(tuple((.78 * x, .78 * y) for x, y in stroke) for stroke in (hull, mast, main_sail, fore_sail, pennant, stripe, upper_wave, lower_wave)) # Общий коэффициент 0,78 удерживает даже крайние точки воды в допустимом радиусе при любом пользовательском повороте сцены. + +def _resample_strokes(strokes, count): # Аппаратный лимит делится между позами без удаления отдельных деталей фигуры. + total = sum(map(len, strokes)) # Доли исходных контуров определяют бюджет точек для каждой видимой детали. + budgets = [max(2, round(count * len(stroke) / total)) for stroke in strokes] # Минимум две точки сохраняет даже короткий отдельный контур. + largest = max(range(len(strokes)), key=lambda index: len(strokes[index])) # Коррекция округления применяется к самому длинному контуру с достаточным запасом точек. + budgets[largest] += count - sum(budgets) # Каждый геометрический кадр получает ровно согласованное число пар. + result = [] # Контуры остаются отдельными и сохраняют прежний порядок аппаратного обхода. + for stroke, budget in zip(strokes, budgets): # Уменьшаем только плотность точек внутри одной детали, не соединяя разные контуры. + closed = stroke[0] == stroke[-1] # Явно замкнутая кривая должна сохранить совпадение первой и последней координат. + samples = [] # Новые опорные точки распределяются вдоль исходного параметрического порядка. + for index in range(budget): # Бюджет фиксирует одинаковую длительность всех поз анимации. + position = index * (len(stroke) - 1) / (budget - 1) if closed else index * len(stroke) / budget # Замкнутые контуры включают оба конца, а периодические без дубликата учитывают последний переход к началу. + source = int(position) # Целая часть выбирает исходный линейный участок фигуры. + a, b = stroke[source], stroke[(source + 1) % len(stroke)] # Циклический сосед сохраняет замыкание параметрических контуров. + fraction = position - source # Дробная часть задаёт положение между двумя исходными вершинами. + samples.append((a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Линейная выборка не создаёт выход за диапазон исходной геометрии. + result.append(tuple(samples)) # Границы отдельных деталей сохраняются для предпросмотра и аппаратной пары. + return tuple(result) # Один компактный кадр пригоден для многократных геометрических преобразований. + +def _square_stroke(radius, angle): # Компактная рамка сохраняет все четыре точных угла и полное замыкание при бюджете 20 пар. + vertices = tuple((radius * math.cos(angle + index * math.pi / 2), radius * math.sin(angle + index * math.pi / 2)) for index in range(4)) # Четыре вершины лежат на окружности заданного нормированного радиуса с шагом 90 градусов. + points = [] # Отдельные стороны включают свои конечные вершины до перехода к следующему контуру. + for side in range(4): # Каждая сторона получает одинаковую длительность аппаратного обхода. + a, b = vertices[side], vertices[(side + 1) % 4] # Последняя сторона возвращается точно к исходной вершине квадрата. + for sample in range(5): # Пять точек на сторону, включая оба конца, дают ровно 20 пар на рамку. + fraction = sample / 4 # Четыре равных интервала покрывают всю сторону, сохраняя последние 20 % контура перед переходом к другой детали. + points.append(b if sample == 4 else (a[0] + fraction * (b[0] - a[0]), a[1] + fraction * (b[1] - a[1]))) # Явная конечная вершина замыкает квадрат без разрыва или потери угла из-за округления. + return tuple(points) # Первая и последняя координаты совпадают, поэтому яркий контур целиком виден в каждой позе. + +def _figure_frames(key, motion): # Создаёт замкнутый цикл изменения самой геометрии и общий бюджет не более 4000 пар. + if key == "laser_tunnel" and motion == "twist": # Для туннеля важны неподвижная внешняя рамка и разные углы вложенных квадратов. + frames = [] # Пятьдесят поз дают плавное скручивание и обратный ход за один аппаратный период. + for index in range(50): # По 80 точек на позу сохраняют полный объём 4000 пар. + twist = 1.8 * (1 - math.cos(2 * math.pi * index / 50)) / 2 # Угол от 0 до 1,8 радиана и обратно меняется плавно, без разрыва скорости на границе периода. + frames.append(tuple(_square_stroke(radius, math.pi / 4 + twist * depth / 3) for depth, radius in enumerate((.85, .612, .3825, .17)))) # Глубина 0 фиксирует внешнюю рамку; по 20 пар сохраняют все углы и полностью замкнутые стороны каждого вложенного квадрата. + return tuple(frames) # Одинаковые контуры и индексы вершин позволяют проверить обратимость геометрического движения. + count, frame_count = (200, 20) if key == "butterfly" else (100, 40) # Сложной бабочке оставляем 200 точек на позу; остальным фигурам достаточно 100 при 40 кадрах/с. + original = _resample_strokes(_xy_strokes(key), count) # Исходная форма подготавливается один раз, а не заново на каждом тике GUI. + frames = [] # Весь цикл хранится в неизменяемом задании и попадает в CSV целиком. + for index in range(frame_count): # Последняя поза не дублирует первую; возврат замыкается следующим аппаратным периодом. + phase = 2 * math.pi * index / frame_count # Полный оборот фазы соответствует одному геометрическому циклу. + deformation = (1 - math.cos(phase)) / 2 # Плавный ход 0…1…0 обеспечивает скручивание и последующее раскручивание. + strokes = [] # Каждая поза сохраняет исходный набор деталей и их порядок. + for stroke in original: # Деформация применяется ко всем координатам фигуры вокруг общего центра. + points = [] # Точки одной детали преобразуются независимо от частоты обхода контура. + for x, y in stroke: # Нормированные координаты находятся внутри радиуса 0,85 до электрического масштабирования. + angle = phase if motion == "rotate" else 2.4 * deformation * max(0, 1 - math.hypot(x, y) / .85) if motion == "twist" else 0 # Скручивание усиливается к центру, а обычное вращение сохраняет относительную форму. + scale_x = 1 - .35 * deformation if motion in ("pulse", "breathe") else 1 # Пульсация плавно уменьшает ширину фигуры, сохраняя запас до шин ЦАП. + scale_y = 1 - (.15 if motion == "breathe" else .35) * deformation if motion in ("pulse", "breathe") else 1 # Разные масштабы дыхания превращают окружность в эллипс, чтобы движение было видно у симметричной фигуры. + points.append((scale_x * (x * math.cos(angle) - y * math.sin(angle)), scale_y * (x * math.sin(angle) + y * math.cos(angle)))) # Поворот и сжатие сохраняют допустимый радиус при любой фазе цикла. + strokes.append(tuple(points)) # Полный контур одной позы виден одновременно, без бегущего светового хвоста. + frames.append(tuple(strokes)) # Все детали текущей позы сохраняются перед переходом к следующей фазе движения. + return tuple(frames) # Размер каждого цикла равен 4000 пар и укладывается в аппаратный предел 4096. + +def generate_example(kind, key, vref=3.3, rate=None, amplitude=1.0, rotation=0.0, animation="none"): # Параметр анимации выбирает полный аппаратный цикл движения фигуры; статический API остаётся совместимым с прежними заданиями. + if kind not in ("dual", "xy"): # Список режимов ограничен двухканальными временными сигналами и геометрией XY. + raise ValueError("Неизвестный тип примера") # Ошибка вызывающего кода не должна выбирать другой каталог. + catalogue = DUAL_EXAMPLES if kind == "dual" else XY_EXAMPLES + XY_EXTRA_EXAMPLES # Кот и кораблик добавляются после десяти основных фигур без подмены прежних примеров. + metadata = next((entry for entry in catalogue if entry[0] == key), None) # Постоянный ключ определяет русское имя и описание выбранного примера. + if metadata is None: # Неизвестные или перепутанные ключи явно отвергаются. + raise ValueError("Неизвестный пример: " + key) # Сообщение содержит конкретный отсутствующий идентификатор. + if animation not in dict(ANIMATION_MODES): # Явная проверка исключает незаметную подмену опечатанного режима статической картинкой. + raise ValueError("Неизвестная анимация фигуры: " + animation) # Ошибка выявляется до создания и загрузки частичной таблицы. + motion = ({"circle": "breathe", "heart": "pulse", "laser_tunnel": "twist", "butterfly": "pulse"}.get(key, "rotate") if animation == "auto" else animation) if kind == "xy" else "none" # Режим по фигуре выбирает заметное движение даже для вращательно-симметричной окружности. + motion = {"walking_cat": "walk", "boat_waves": "waves"}.get(key, motion) # Специальные сцены сохраняют заданное движение: у кота работают лапы и хвост, а у кораблика движется исключительно вода. + if rate is None: # Частота по умолчанию зависит от полного размера анимированного цикла. + rate = ANIMATION_SAMPLE_RATE if motion != "none" else SAMPLE_RATE # Любой полный цикл из 4000 пар длится одну секунду; статический контур сохраняет прежние 40 кГц. + if not math.isfinite(vref) or not 0 < vref <= 3.6 or not math.isfinite(amplitude) or not 0 < amplitude <= 1 or not math.isfinite(rotation): # Положительный масштаб до 100 % и Vref до 3,6 В сохраняют электрический диапазон обоих выходов. + raise ValueError("Проверьте Vref, масштаб 0…100 % и угол поворота") # Некорректные параметры не формируют частично пригодный кадр. + if type(rate) is not int or not 1 <= rate <= 1_000_000: # Частота ограничена тем же пределом 1 Мвыб/с, что основной генератор. + raise ValueError("Частота отсчётов должна быть 1…1000000 Гц") # Аппаратный профиль дополнительно проверяется при переносе в редактор. + if key in ("walking_cat", "boat_waves"): # Специальная анимация строится напрямую из неизменных и движущихся деталей сцены. + frame_generator = cat_strokes if key == "walking_cat" else boat_strokes # Выбор функции не смешивает общий поворот фигуры с движением её отдельных элементов. + frame_count = 32 if key == "walking_cat" else 50 # Коту нужны 125 точек на плавный силуэт, кораблик сохраняет прежние 50 поз по 80 точек. + frames = tuple(frame_generator(index / frame_count) for index in range(frame_count)) # Оба специальных примера сохраняют ровно 4000 пар, одну секунду цикла и аппаратный предел 4096. + else: # Обычные геометрические фигуры продолжают использовать существующие деформации и статичный режим. + frames = _figure_frames(key, motion) if motion != "none" else () # Предыдущие контуры сохраняют свой бюджет и поведение без изменений. + normalized = tuple(stroke for frame in frames for stroke in frame) if frames else _xy_strokes(key) if kind == "xy" else (tuple(_dual_point(key, i / SAMPLE_COUNT) for i in range(SAMPLE_COUNT)),) # Аппаратный кадр кота последовательно содержит все позы, а обычные примеры сохраняют 1000 пар. + angle = math.radians(rotation if kind == "xy" else 0) # Поворот изменяет только геометрические фигуры и не смешивает диагностические временные сигналы. + cosine, sine = math.cos(angle), math.sin(angle) # Тригонометрия рассчитывается один раз для всего кадра, а не для каждого отсчёта. + strokes = tuple(tuple(((1 + amplitude * (x * cosine - y * sine)) * vref / 2, (1 + amplitude * (x * sine + y * cosine)) * vref / 2) for x, y in stroke) for stroke in normalized) # Однополярные выходы центрируются на Vref/2, а XY поворачивается вокруг этого центра. + samples = tuple((index * 1000 / rate, x, y) for index, (x, y) in enumerate(point for stroke in strokes for point in stroke)) # Обе координаты каждого индекса имеют строго одно время в миллисекундах. + frame_size = len(frames[0]) if frames else 0 # Любая геометрическая анимация сохраняет одинаковое число контуров и пар во всех позах. + display_frames = tuple(strokes[index:index + frame_size] for index in range(0, len(strokes), frame_size)) if frames else () # Преобразованные позы сохраняют точные напряжения той же аппаратной последовательности. + return ExampleData(key, kind, metadata[1], metadata[2], rate, samples, display_frames[0] if frames else strokes, display_frames, motion) # Предпросмотр, экспорт и аппаратная пара получают одно движение и все его кадры. diff --git a/python/set_devices/waveform_xy.py b/python/set_devices/waveform_xy.py new file mode 100644 index 0000000..3a1d018 --- /dev/null +++ b/python/set_devices/waveform_xy.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, fields # Неизменяемая таблица наследует прежнюю модель, сохраняя сравнение обеих координат перед пуском. +from .waveform import Waveform # Общие частота, точки X, Vref и период принадлежат прежней модели генератора. +from .signal_reconstruction import dac12 # Напряжения Y квантует то же C-ядро, что используется одиночным выходом X. +@dataclass(frozen=True) # Изменение любого кода Y создаёт другое задание и требует повторной загрузки перед start. +class XYWaveform(Waveform): # Дополняет один общий период второй синхронной координатой без дублирования настройки таймера. + codes_y: tuple # Ровно один 12-битный код Y соответствует каждому коду X с тем же индексом и временем. +def with_y(wave, volts): # Добавляет вертикальное отклонение к уже построенной и проверенной временной сетке X. + values=tuple(volts) # Однократно фиксирует последовательность, включая генераторы Python. + if len(values)!=len(wave.codes): # Оба DMA-канала должны содержать строго одинаковое число пар, без дополнения и обрезки. + raise ValueError("X и Y должны иметь одинаковое число отсчётов") # Ошибка выявляется до открытия порта и изменения состояния устройства. + codes=dac12(values,wave.vref) # Общая проверка отвергает NaN, бесконечность и выход Y за диапазон 0…Vref. + return XYWaveform(**{field.name:getattr(wave,field.name) for field in fields(Waveform)},codes_y=codes) # Сохраняет точные коды X и добавляет Y без интерполяции и изменения частоты. diff --git a/python/tests/test_byte_work_panel.py b/python/tests/test_byte_work_panel.py index 3fda8c2..a488b36 100644 --- a/python/tests/test_byte_work_panel.py +++ b/python/tests/test_byte_work_panel.py @@ -120,9 +120,11 @@ def test_read_accepts_address_formats_and_displays_both_values(page, address): widget, protocol = page widget.byte_read_address.setText(address) widget.byte_read_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert protocol.requests == [(0x1000, None, 7)] - assert not widget.byte_read_button.isEnabled() + assert widget.byte_read_button.isEnabled() # Ручная кнопка принимает новые запросы, пока текущее чтение ожидает ответ. protocol.reply(True, {"value": 0xABCD}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert widget.byte_read_address.text() == "$1000" assert widget.byte_read_value.text() == "ABCD" assert widget.byte_read_binary.text() == "1010 1011 1100 1101" @@ -144,6 +146,7 @@ def test_invalid_inputs_do_not_send(page, address, value, binary): widget.byte_write_value.setText(value) widget.byte_write_binary.setText(binary) widget.byte_write_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert protocol.requests == [] assert "Проверьте" in widget.status.text() @@ -156,11 +159,14 @@ def test_write_is_verified_and_controller_is_captured_for_whole_operation(page, widget.byte_write_value.setText("0301") widget.sync_byte_value("0301", 16) widget.byte_write_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert protocol.requests == [(0x1000, 0x0301, 13)] controller[0] = 21 protocol.reply(True, {"raw": b"ack"}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert protocol.requests[-1] == (0x1000, None, 13) protocol.reply(True, {"value": readback}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert widget.byte_read_value.text() == f"{readback:04X}" assert ("Запись проверена" in widget.status.text()) == confirmed if not confirmed: @@ -171,18 +177,23 @@ def test_write_is_verified_and_controller_is_captured_for_whole_operation(page, def test_failure_and_cancellation_restore_controls_and_ignore_late_reply(page): widget, protocol = page widget.byte_read_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. protocol.reply(False, "CRC ответа не совпадает") + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert widget.status.text() == "CRC ответа не совпадает" assert widget.byte_read_button.isEnabled() widget.byte_read_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. late_reply = protocol.callback widget.stop() assert protocol.cancelled == 1 widget.byte_read_button.click() + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. late_reply(True, {"value": 0x9999}) assert widget.byte_read_value.text() == "" - assert not widget.byte_read_button.isEnabled() + assert widget.byte_read_button.isEnabled() # Ручная кнопка принимает новые запросы, пока текущее чтение ожидает ответ. protocol.reply(True, {"value": 0x1234}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. assert widget.byte_read_value.text() == "1234" assert widget.byte_read_button.isEnabled() @@ -193,19 +204,31 @@ def test_controller_provider_is_used_by_bus_raw_memory_and_macros(app, tmp_path) widget = ByteWorkPanel(protocol, device_address=lambda: controller[0], macros_file=tmp_path / "address.json") try: - widget.raw_widget.memory_write() - widget.raw_widget.memory_read() - assert protocol.requests == [(0x2000, 0, 21), (0x2000, None, 21)] - widget.bus_widget.read_from_bus() - assert protocol.requests[-2:] == [(0x200B, 0, 21), (0x200F, None, 21)] + widget.raw_widget.memory_write() # Первая операция сохраняет контроллер до ожидания общей очереди. + widget.raw_widget.memory_read() # Второе нажатие принимается до завершения первой записи. + QTest.qWait(15) # Запись получает владение общим соединением. + protocol.reply(True, {"raw": b"ack"}) # Подтверждаем текущую запись перед следующим чтением. + QTest.qWait(15) # Следующая ручная операция начинает PEEK. + protocol.reply(True, {"value": 0x1234}) # Возвращаем слово ручного чтения памяти. + QTest.qWait(15) # Освобождаем очередь после завершения второго действия. + assert protocol.requests == [(0x2000, 0, 21), (0x2000, None, 21)] # Оба нажатия используют контроллер своего исходного снимка. + widget.bus_widget.read_from_bus() # Последовательное чтение также выполняется асинхронно через общую очередь. + QTest.qWait(15) # Отправляем подготовку SBUS. + protocol.reply(True, {"raw": b"ack"}) # Подтверждение позволяет перейти к буферу SBUS. + QTest.qWait(15) # Отправляем чтение существующего регистра результата. + protocol.reply(True, {"value": 0x1234, "raw_response": {"raw": b"reply"}}) # Возвращаем слово последовательной шины. + QTest.qWait(15) # Завершаем логическую операцию перед явным макросом. + assert protocol.requests[-2:] == [(0x200B, 0, 21), (0x200F, None, 21)] # Адреса и формат существующей SBUS остаются прежними. widget.bus_widget.execute_macro_commands([{"type": "read", "tetrad": 3, "register": 5}]) controller[0] = 22 QTest.qWait(20) assert protocol.requests[-1] == (0x200B, 0x35, 21) protocol.reply(True, {"raw": b"ack"}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. QTest.qWait(20) assert protocol.requests[-1] == (0x200F, None, 21) protocol.reply(True, {"value": 0x4321, "raw_response": {"raw": b"reply"}}) + QTest.qWait(15) # Доставляем назначенный шаг общей очереди через события Qt без синхронной блокировки GUI. QTest.qWait(20) assert not widget.bus_widget._macro_running assert widget.bus_widget.bus_read_value_label.text() == "0x4321 (17185)" diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 142b547..fa89240 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -53,6 +53,39 @@ class PlotTests(unittest.TestCase): crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100) self.assertEqual(-800, self.core.delta(crossed.a, crossed.b)) + def test_deep_x_zoom_preserves_anchor_and_allows_pan_and_zoom_out(self): + view = Viewport() + for _ in range(65): + view = view.transform(self.core, zoom_x=1.25, focus_x=.3) + self.assertAlmostEqual(.3, view.x + .3 * view.width) + self.assertLess(view.width * 5_000_000, 3) + moved = view.transform(self.core, pan_x=.2) + self.assertLess(moved.x, view.x) + self.assertEqual(view.width, moved.width) + expanded = moved.transform(self.core, zoom_x=.8) + self.assertGreater(expanded.width, moved.width) + limited = view.transform(self.core, zoom_x=1e15) # Заведомо превышаем новый предел 10^12 раз, чтобы проверить ограничение без обнуления ширины. + self.assertEqual(1e-12, limited.width) # Минимальная доля полного диапазона X должна позволять приближение в 100 000 раз сильнее прежнего. + self.assertEqual(limited, limited.transform(self.core, zoom_x=2)) + + def test_deep_zoom_keeps_both_axes_navigable(self): # Проверяем совместное приближение X и Y, независимое перемещение, границы и возвращение к полному диапазону. + view = Viewport() # Начинаем с полного диапазона обеих осей без фиксации. + for _ in range(160): # 160 шагов с коэффициентом 1,25 проходят оба прежних предела и достигают нового ограничения 10^12 раз. + view = view.transform(self.core, zoom_x=1.25, zoom_y=1.25, focus_x=.3, focus_y=.7) # Увеличиваем вокруг разных опорных точек, чтобы обнаружить смешение осей. + self.assertAlmostEqual(.3, view.x + .3 * view.width, places=14) # Координата опоры X должна оставаться на месте даже при минимальной ширине. + self.assertAlmostEqual(.7, view.y + .7 * view.height, places=14) # Независимая опора Y не должна дрейфовать при многократном увеличении. + self.assertEqual((1e-12, 1e-12), (view.width, view.height)) # Обе оси достигают триллионной доли полного диапазона и не превращаются в нулевой интервал. + self.assertEqual(view, view.transform(self.core, zoom_x=2, zoom_y=2)) # Дополнительное увеличение на пределе сохраняет область без накопления смещения. + moved = view.transform(self.core, pan_x=.2, pan_y=-.2) # Сдвигаем X и Y в противоположные стороны на пятую часть текущего окна. + self.assertLess(moved.x, view.x) # Положительное экранное перемещение X уменьшает смещение области. + self.assertGreater(moved.y, view.y) # Отрицательное перемещение Y увеличивает её нормированное смещение. + self.assertEqual((view.width, view.height), (moved.width, moved.height)) # Перемещение не должно самопроизвольно менять достигнутое увеличение. + for direction in (-1e15, 1e15): # Большое перемещение в обе стороны проверяет ограничение краями полного диапазона. + edge = moved.transform(self.core, pan_x=direction, pan_y=direction) # Заведомо выходим за исходные границы по каждой оси. + self.assertGreaterEqual(min(edge.x, edge.y), 0) # Начало области остаётся внутри диапазона, а не уходит в отрицательные координаты. + self.assertLessEqual(max(edge.x + edge.width, edge.y + edge.height), 1) # Конец области не выходит за исходные данные. + self.assertEqual(Viewport(), moved.transform(self.core, zoom_x=1e-15, zoom_y=1e-15)) # Уменьшение с большим запасом возвращает полный диапазон и нулевые смещения. + def test_invalid_numeric_inputs_do_not_escape_to_painter(self): for value in (math.nan, math.inf, -math.inf): self.assertEqual(Viewport(), Viewport().transform(self.core, zoom_x=value)) diff --git a/python/tests/test_plot_processing.py b/python/tests/test_plot_processing.py index 37cd575..ac83b7a 100644 --- a/python/tests/test_plot_processing.py +++ b/python/tests/test_plot_processing.py @@ -21,6 +21,20 @@ from set_devices.signal_reconstruction import METHODS class PlotProcessingTests(unittest.TestCase): + def test_million_output_points_for_all_methods(self): + snapshot = Snapshot([Series("a", "A", [(0, 1), (1, 3), (2, 5)])]) + for method in METHODS: + with self.subTest(method=method): + curve = process(prepare(snapshot, "a", method, 1_000_000, 1)) + self.assertEqual(1_000_000, len(curve.points)) + self.assertEqual((0., 2.), (curve.points[0][0], curve.points[-1][0])) + for x, y in curve.points[::10000]: + self.assertAlmostEqual(1 + 2 * x, y) + self.assertAlmostEqual(5., curve.points[-1][1]) + self.assertTrue(curve.pieces) + with self.assertRaises(ValueError): + process(prepare(snapshot, "a", "linear", 1_000_001)) + def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self): points = [[0, 0], [1, 1]] channel = Series("a", "Analog", points) diff --git a/python/tests/test_plot_processing_qt.py b/python/tests/test_plot_processing_qt.py index b0194e8..9d3e50d 100644 --- a/python/tests/test_plot_processing_qt.py +++ b/python/tests/test_plot_processing_qt.py @@ -56,6 +56,7 @@ class ProcessingQtTests(unittest.TestCase): self.panel.method.setCurrentIndex(self.panel.method.findData(method)) self.calculate() self.assertEqual(method, self.panel.curve.request.method) + self.panel.count.setValue(self.panel.count.value() + 1) self.assertEqual(index + 1, len(self.panel.curves)) self.assertTrue(self.panel.export_button.isEnabled()) @@ -67,6 +68,12 @@ class ProcessingQtTests(unittest.TestCase): self.assertEqual((), self.panel.curves) self.assertIsNone(self.panel.curve) + def test_large_output_count_reaches_worker(self): + self.assertEqual(10000, self.panel.count.value()) + self.panel.count.setValue(1_000_000) + self.calculate() + self.assertEqual(1_000_000, len(self.panel.curve.points)) + def test_repeated_request_does_not_duplicate_curve(self): self.calculate() self.calculate() diff --git a/python/tests/test_reference_comparison_port.py b/python/tests/test_reference_comparison_port.py new file mode 100644 index 0000000..2a8e585 --- /dev/null +++ b/python/tests/test_reference_comparison_port.py @@ -0,0 +1,141 @@ +"""Контракт сравнения проверяется с чужим canvas без импорта приложения.""" # Фиксируем возможность повторного использования Qt-порта. +import json # Сохранённый отчёт проверяется как машиночитаемые данные. +from pathlib import Path # Временный CSV и отчёт используют явные пути. +import tempfile # Проверки не затрагивают пользовательские файлы. +import time # Монотонный срок ограничивает ожидание worker. +from types import SimpleNamespace # Источник и кривая не зависят от моделей приложения. +import unittest # Набор можно запустить вне тестового окружения потребителя. +from unittest.mock import patch # Подменяем выбор файла, сохраняя реальную запись JSON. + +try: # Используем доступную в окружении версию Qt. + from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Qt6 предоставляет базовые элементы тестового canvas. +except ImportError: # Qt5 проверяется непосредственно, без слоя совместимости приложения. + from PySide2.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Публичный порт обязан поддерживать оба binding. + +from protocan.plot import Bounds, Viewport # Общие контейнеры координат не требуют конкретного рендерера. +from set_devices.qt_ports.reference_comparison import ReferenceComparisonDialog, ReferenceComparisonPanel # Импортируем общий порт напрямую. +from set_devices.qt_ports.reference_overlay import ReferenceOverlayPanel # Прямое наложение предоставляет только параметры и данные, не создавая canvas. + + +class Canvas(QWidget): # Минимальный сторонний график реализует документированный контракт. + def __init__(self): # В тесте отсутствует зависимость от графика приложения. + super().__init__() # QSplitter принимает обычный QWidget. + self._series = [] # Панель заполняет кривые через внедрённую фабрику. + self.axis_lock_x, self.axis_lock_y = QCheckBox(self), QCheckBox(self) # Флажки поддерживают снятие фиксации осей. + self.axis_limits = None # Ручные границы сбрасываются при новом результате. + self.reset_interaction() # Начинаем с нейтрального масштаба. + self._sync_bounds() # Задаём известные границы тестовой записи. + + def reset_interaction(self): # Общая панель сбрасывает навигацию перед показом кривых. + self.viewport = Viewport() # Нормированная область начинается с полного масштаба. + + def _sync_bounds(self): # Тестовым трём отсчётам достаточно постоянной общей области. + self.visible_bounds = Bounds(0., 2., 0., 10.) # Интервал 0–2 мс и амплитуда 0–10 охватывают контрольные данные. + + +def make_series(key, name, **data): # Формат контейнера кривой определяет потребитель. + return SimpleNamespace(key=key, name=name, **data) # Принимаем полные массивы и цвет без дополнительных преобразований. + + +class ReferencePortTests(unittest.TestCase): # Проверяем интеграционный контракт независимо от GUI-потребителя. + @classmethod # Один экземпляр QApplication обслуживает весь процесс проверок. + def setUpClass(cls): # Доставка сигналов worker требует реального приложения Qt. + cls.app = QApplication.instance() or QApplication([]) # Повторно используем существующий цикл событий при совместном запуске тестов. + + def setUp(self): # Данные каждого сценария изолированы от остальных. + self.temp = tempfile.TemporaryDirectory() # Каталог удаляется после завершения всех операций. + self.root = Path(self.temp.name) # Потребитель явно задаёт начальную папку. + self.path = self.root / 'reference.csv' # Файл хранит известный линейный эталон. + self.path.write_text('time_ms,U (V)\n0,1\n1,3\n2,5\n', encoding='utf-8') # Три амплитуды позволяют проверить полный остаток и число точек. + self.sources = [(SimpleNamespace(name='U (V)', display_value=lambda value: value * 2), [(0., 1.), (1., 2.), (2., 3.)])] # После внешнего множителя измерение отличается от эталона на 1 В. + self.panels = [] # Созданные виджеты закрываются до удаления файлов. + + def tearDown(self): # Очистка обязательна даже при ошибке утверждения. + for panel in self.panels: # Завершаем все экземпляры панели и диалога. + panel.close() # Закрытие останавливает worker или запрашивает его отмену. + self.wait_idle(panel) # Ожидаем завершение до удаления QObject. + panel.deleteLater() # Дочерние элементы освобождаются очередью Qt. + self.app.processEvents() # Доставляем отложенные сигналы завершённых заданий. + self.temp.cleanup() # Фоновые операции больше не используют файлы. + + def create(self, cls=ReferenceComparisonPanel, sources=None): # Один контракт подключает встроенную панель и отдельное окно. + panel = cls(self.sources if sources is None else sources, source_path='capture.csv', plot_factory=Canvas, series_factory=make_series, path_provider=lambda name: str(self.root / name)) # Все зависимости передаются извне без импорта прикладного GUI. + self.panels.append(panel) # Регистрируем объект для гарантированного завершения. + return panel # Сценарий использует обычные методы общего порта. + + def wait_idle(self, panel): # Короткие задания не должны зависеть от скорости машины. + deadline = time.monotonic() + 5 # Пять секунд ограничивают время ожидания трёхточечного файла. + while (panel._worker is not None or getattr(panel, '_pending', False) or (hasattr(panel, '_timer') and panel._timer.isActive())) and time.monotonic() < deadline: # Для слоя эталона ждём также автоматически запланированный расчёт. + self.app.processEvents() # Доставляем result и finished без зависимости от QtTest. + time.sleep(.001) # Одна миллисекунда освобождает процессор для worker. + self.assertIsNone(panel._worker) # Дальнейшие проверки выполняются только после остановки задания. + self.assertFalse(getattr(panel, '_pending', False)) # Новый снимок параметров также должен быть обработан. + + def test_custom_canvas_transform_and_paths(self): # Проверяем пользовательский цикл на независимом canvas. + panel = self.create() # Фабрики графика и кривой принадлежат тестовому потребителю. + panel.load_reference(self.path) # Чтение CSV выполняется настоящим фоновым потоком. + self.wait_idle(panel) # Сравнение запускается после принятия эталона. + panel._calculate() # Применяем внешний множитель к полному измерению. + self.wait_idle(panel) # Ждём формирования таблицы и кривых. + self.assertIsInstance(panel.plot, Canvas) # Библиотека не навязывает собственный canvas. + self.assertEqual(1., panel.comparison.metrics['rmse']) # Ошибка 1 В подтверждает корректность преобразования источника. + self.assertEqual([2., 4., 6.], list(panel.plot._series[0].values)) # Фабрика получает все исходные амплитуды после преобразования. + panel.mode.setCurrentIndex(1) # Переходим от наложения к отклонению без нового расчёта. + self.assertEqual([1., 1., 1.], list(panel.plot._series[0].values)) # Остаток сохраняет ожидаемый знак на каждой точке. + with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getOpenFileName', return_value=('', '')) as choose: # Отмена выбора позволяет проверить только начальную папку. + panel._open_reference() # Вызываем штатный обработчик открытия эталона. + self.assertEqual(str(self.root), choose.call_args[0][2]) # Путь предоставляется потребителем библиотеки. + output = self.root / 'report.json' # Экспорт направляется в изолированный каталог теста. + with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getSaveFileName', return_value=(str(output), '')) as choose: # Подменяем только выбор имени файла. + panel._save_report() # Реальная запись проверяет UTF-8 и сериализацию показателей. + self.assertEqual(str(self.root / 'reference_comparison.json'), choose.call_args[0][2]) # Предложенное имя отчёта также проходит через provider. + report = json.loads(output.read_text(encoding='utf-8')) # Читаем результат с диска, не подменяя алгоритм экспорта. + self.assertEqual(('capture.csv', 3), (report['measured_file'], report['metrics']['count'])) # Отчёт сохраняет происхождение и полное число сравнённых точек. + + def test_dialog_and_empty_source_contract(self): # Отдельное окно сохраняет совместимость без возможности расчёта пустого источника. + dialog = self.create(ReferenceComparisonDialog, sources=[]) # Источник может временно отсутствовать у другого потребителя. + self.assertIsInstance(dialog, QDialog) # Сохраняется настоящий API QDialog, а не имитация через QWidget. + dialog.load_reference(self.path) # Одного эталона недостаточно для численного сравнения. + self.wait_idle(dialog) # После чтения проверяем окончательное состояние элементов. + self.assertFalse(dialog.calculate_button.isEnabled()) # Не допускаем обращения к индексу -1 пустого списка. + dialog.reject() # Прежний способ закрытия отдельного окна поддерживается напрямую. + + def test_overlay_loads_automatically_without_canvas(self): # Пользовательский потребитель получает кривую сразу после чтения CSV. + panel = ReferenceOverlayPanel(self.sources, source_path='capture.csv', path_provider=lambda name: str(self.root / name)) # Фабрики canvas и новой таблицы не нужны. + self.panels.append(panel) # Гарантируем остановку worker до удаления временных файлов. + events = [] # Сигнал сообщает существующему графику о готовом дополнительном слое. + panel.changed.connect(lambda: events.append(True)) # Не создаём даже тестовый canvas ради получения кривой. + panel.load_reference(self.path) # После чтения статистика и временное совмещение запускаются автоматически. + self.wait_idle(panel) # Ожидаем обе стадии фонового задания. + specs, data = panel.overlay() # Потребитель добавляет эти данные к своим исходным кривым. + self.assertFalse(hasattr(panel, 'plot')) # Слой принципиально не может подменить исходный canvas. + self.assertEqual(1, len(events)) # Один файл публикует одно готовое наложение. + self.assertEqual('Эталон: U (V)', specs[0][1]) # Легенда различает оригинал и эталон с одинаковым именем канала. + self.assertEqual([(0., 1.), (1., 3.), (2., 5.)], list(data[panel.RESULT_KEY])) # Сохраняем полную сетку и исходные амплитуды эталона. + self.assertEqual(1., panel.comparison.metrics['rmse']) # Внешнее преобразование измерения учитывается в статистике. + self.assertTrue(panel.report_button.isEnabled()) # Автоматический расчёт делает доступным актуальный отчёт. + self.assertEqual(14, panel.results.table.rowCount()) # Полные расчёты доступны переносимому потребителю вместе с данными наложения. + self.assertEqual('1', panel.results.table.item(6, 1).text()) # Восстановленная таблица содержит фактический RMSE, а не только подписи. + self.assertTrue(panel.results.export_button.isEnabled()) # Экспорт доступен непосредственно рядом с подробной таблицей. + output = self.root / 'detailed-report.json' # Подробная область сохраняет актуальный расчёт в изолированный файл. + with patch('set_devices.qt_ports.reference_overlay.QFileDialog.getSaveFileName', return_value=(str(output), '')): # Подменяем только выбор места, сохраняя обработчик экспорта. + panel.results.export_button.click() # Проверяем кнопку восстановленной подробной таблицы. + self.assertEqual(1., json.loads(output.read_text(encoding='utf-8'))['metrics']['rmse']) # JSON содержит фактическое отклонение этого результата. + panel.results.delta_curve.setChecked(True) # Добавление остатка не требует собственной фабрики графика. + specs, data = panel.overlay() # Потребитель получает эталон и отдельный слой отклонения вместе. + self.assertEqual(2, len(specs)) # Обе дополнительные кривые можно добавить к сохранённому оригиналу. + self.assertEqual([1., 1., 1.], list(data[panel.DIFFERENCE_KEY].values)) # Численные остатки совпадают с преобразованными амплитудами измерения. + panel.set_sources([(self.sources[0][0], [(10., 1.), (11., 2.), (12., 3.)])]) # Новый оригинальный кадр не требует повторного выбора файла эталона. + self.wait_idle(panel) # Совмещение времени обновляется после смены источника. + self.assertEqual([10., 11., 12.], panel.trace.times) # Эталон переходит на исходную временную шкалу нового измерения. + self.assertEqual([10., 11., 12.], panel.difference.times) # Остаток следует той же исходной шкале времени без скрытого обнуления. + panel.shift.setValue(10000.) # Сдвиг 10000 мкс выводит эталон за пределы трёхточечного измерения. + self.wait_idle(panel) # Параметр применяется без дополнительной кнопки расчёта. + self.assertIsNone(panel.comparison) # Отсутствие перекрытия исключает только статистику. + self.assertEqual([20., 21., 22.], panel.trace.times) # Кривая остаётся доступной исходному графику даже без общей части. + self.assertEqual(0, panel.results.table.rowCount()) # Старые расчёты не остаются видимыми после потери перекрытия. + self.assertFalse(panel.results.delta_curve.isEnabled() or panel.results.export_button.isEnabled()) # Без корректного результата отключаются остаток и экспорт, но не исходный график. + + +if __name__ == '__main__': # Набор можно выполнить стандартным Python без pytest. + unittest.main() # Общий порт проверяется отдельно от репозитория потребителя. diff --git a/third_party/README.md b/third_party/README.md new file mode 100644 index 0000000..6c6746c --- /dev/null +++ b/third_party/README.md @@ -0,0 +1,23 @@ +# Внешние проекты + +Этот каталог хранит исходные репозитории самостоятельных приложений, +используемых как основа для изучения и дальнейшей интеграции в инструменты. +Они подключены отдельно от переносимых ядер в `c/`, `cpp/` и `python/`. + +## KDiff3 + +- Каталог: [`kdiff3`](kdiff3). +- Официальный Git: . +- Тип подключения: вложенный Git submodule с зафиксированным коммитом. +- Исходная ревизия: `49189d0` от 26 октября 2026 года. +- Лицензии и сведения об авторах сохранены в исходных `COPYING` и `LICENSES/`. +- Назначение: исходники программы сравнения и объединения файлов и каталогов + для развития инструментов SETGUI. + +При клонировании потребителя нужно инициализировать submodule рекурсивно, +чтобы получить также вложенный KDiff3. Обновление исходников выполняется +отдельно с явным изменением зафиксированной ревизии. + +Встроенная вкладка SETGUI пока использует свои панели Qt и Python `difflib`. +Добавление этого репозитория само по себе не заменяет её движок и не запускает +внешнее приложение. diff --git a/third_party/kdiff3 b/third_party/kdiff3 new file mode 160000 index 0000000..49189d0 --- /dev/null +++ b/third_party/kdiff3 @@ -0,0 +1 @@ +Subproject commit 49189d01a49b7498c878053bdb4240d63f24f0bb diff --git a/tools/dsview/PLATES_BUSES.md b/tools/dsview/PLATES_BUSES.md new file mode 100644 index 0000000..691a0e1 --- /dev/null +++ b/tools/dsview/PLATES_BUSES.md @@ -0,0 +1,123 @@ +# Декодеры DSView для шин 96-контактного интерфейса + +Два независимых декодера: **Plates PBus** и **Plates SBus**. Показывают +адрес устройства, регистр, сырые 16-битные данные, результат проверок +и итог каждого обращения. Повторные одинаковые обращения выводятся отдельно. + +Декодеры устанавливаются в DSView и разбирают цифровой захват шин, +используемых с SETGUI. Формат взят из `ParallelBusSlave_ver_2_0.vhd` +и `SerialBusSlave_ver_2_0.vhd` из `ip_repo` репозитория PlatesSim. Зависимости +от установленного SETGUI или PlatesConf нет. Число 96 относится +к разъёму; ширина данных PBus составляет 16 бит. + +## Установка + +1. Закрыть DSView. +2. Скопировать обе папки `plates_pbus` и `plates_sbus` из `decoders` + в каталог `decoders` используемой установки DSView. Внутри каждой + папки должны непосредственно лежать `__init__.py` и `pd.py`. +3. Открыть DSView, загрузить захват или получить новый. +4. В списке декодеров выбрать **Plates PBus** либо **Plates SBus** + и назначить соответствующие каналы анализатора. При одновременном + захвате обеих шин добавить оба декодера. + +Используется sigrok API 3 и синтаксис Python 3.6+. Формат декодеров +сверён со штатными пакетами установленного DSView и +[исходниками DSView](https://github.com/DreamSourceLab/DSView/tree/master/libsigrokdecode4DSL/decoders). +Общие сведения о пользовательских декодерах — в +[инструкции производителя](https://dreamsourcelab.cn/logic-analyzer/protocol_decoder_howto/). + +## Каналы PBus + +| Сигнал декодера | Подключение | Назначение | +| --- | --- | --- | +| PBCE | `pbce` | Низкий уровень разрешает транзакцию | +| PBACK | `pback` | Подтверждения адреса, данных и инверсии | +| D0…D15 | `pbdata[0]`…`pbdata[15]` | Все 16 разрядов общей двунаправленной шины | +| PBCLK | `pbclk`, необязательно | Проверка уровня тактовой линии в фазах ответа | + +Для полного декодирования PBus требуется **18 каналов**, с PBCLK — 19. +Анализатор на 16 каналов не захватит полный набор одновременно. +Назначать D0…D15 по номерам разрядов шины, а номера контактов +96-контактного разъёма брать из схемы конкретной платы. + +Операция начинается со спада PBCE. Первый спад PBACK подтверждает адрес: +`PBDATA[15:8] = dev*16 + reg`, `PBDATA[7:0] = ~PBDATA[15:8]`. +Следующий подъём PBACK подтверждает 16-битные данные; следующий спад — +их 16-битную инверсию. При назначенном PBCLK данные должны подтверждаться +при CLK=0, инверсия — при CLK=1. Выборка слов выполняется по ACK. + +Неверная инверсия адреса, неверная инверсия ответа, нарушение фазы CLK +и незавершённое ожидание ACK выводятся отдельными предупреждениями. +Снятие CE до завершения или конец захвата сохраняют частичную операцию +с признаком `complete=False`. Начальный фрагмент захвата с уже низким CE +пропускается до следующего полного начала транзакции. + +## Каналы SBus + +| Сигнал декодера | Подключение | Направление | +| --- | --- | --- | +| SBCLK | `sbclk` | Тактовая линия мастера | +| SBDATAIN | `sbdatain` | ТМС → платы | +| SBDATAOUT | `sbdataout` | Платы → ТМС | + +Для SBus достаточно **трёх каналов**. Имена IN/OUT соответствуют +входу и выходу ведомого блока в HDL. + +Кадр содержит 29 бит старшим вперёд: направление (0 — чтение, +1 — запись), устройство (4), регистр (4), данные (16), CRC (4). +Декодер выбирает биты по нарастающим фронтам SBCLK. Заголовок и данные +записи идут по SBDATAIN; данные чтения и CRC ответа — по SBDATAOUT. +CRC учитывает все 25 бит направления, адреса и данных, а затем +четыре завершающие единицы по алгоритму HDL. Это собственный алгоритм, +его нельзя заменять произвольным штатным CRC-4. + +| Параметр | По умолчанию | Смысл | +| --- | --- | --- | +| Frame gap (us) | 3,6 мкс | Минимальное время неподвижного CLK для синхронизации нового кадра | +| Write ACK sampling delay (us) | 0,5 мкс | Задержка выборки SBDATAOUT после последнего бита CRC записи | + +Пауза учитывается при обоих уровнях CLK. Значение 3,6 мкс взято +из текущих COUNT_RESET=180 и частоты блока SBus 50 МГц. +Задержка ACK 0,5 мкс — настраиваемая точка наблюдения после проверки CRC +и до сброса по паузе. Для другого ведомого или других времён обмена +параметры нужно согласовать с захватом. Межкадровая пауза должна превышать +длительность обычного неподвижного участка CLK внутри кадра; +задержка ACK должна быть положительной и меньше межкадровой паузы. + +Низкий SBDATAOUT в точке выборки подтверждает запись. Высокий уровень +помечается `ACK HIGH (NACK / no response)`: по одному уровню нельзя +отличить отказ от отсутствия платы. CRC и ACK показываются независимо. +ACK frontend PlatesSim подтверждает CRC, но не доказывает помещение +команды в FIFO или её применение CPU1. + +Если файл закончился до выборки ACK, полная запись сохраняется с `ack=None`. +Частичный кадр отмечается числом полученных битов; следующий кадр +синхронизируется после паузы. В начале захвата SBus ожидает наблюдаемую +паузу, чтобы фрагмент уже идущего обмена не стал ложным заголовком. +При записи включать в захват паузу перед первым кадром и время после CRC. + +## Проверки и границы подтверждённого + +Из корня репозитория: + +```powershell +python -m unittest discover -s tools/dsview/tests -p test_plates_buses.py -v # Проверка декодеров на цифровых трассах без запуска DSView. +``` + +21 тест проверяет оба направления SBus, CRC, отдельную выборку ACK, +повторы, паузы обоих уровней, восстановление синхронизации, края диапазонов +PBus, обе инверсные проверки, фазы ACK, отсутствие каналов и обрывы захвата. +Среда тестов исполняет рабочие методы `decode()` и `end()` через подмену +API ожидания событий; сигналы формируются независимо от автоматов декодеров. +Дополнительно проверены синтаксис Python 3.6 и UTF-8 исходников. + +Загрузка новых пакетов в интерфейсе DSView и захват физического обмена +с SETGUI пока не проверены. Номера контактов разъёма и соответствие каналам +анализатора задаются по фактическому подключению. Модели отдельных линий +TK/PWM/ACK в эти декодеры не входят. + +Python-выход каждого декодера содержит `['FRAME', словарь]`. Общие поля: +`bus`, `operation`, `dev`, `reg`, `value`. У SBus отдельно доступны `crc`, +`crc_expected`, `crc_ok`, `ack`; у PBus — `address_word`, `address_ok`, +`inverse`, `inverse_ok`, `errors`, `complete`, `ok`. diff --git a/tools/dsview/README.md b/tools/dsview/README.md index 2417921..e096361 100644 --- a/tools/dsview/README.md +++ b/tools/dsview/README.md @@ -103,6 +103,41 @@ segmentation, timing-profile and sigrok API boundary tests. SETGUI's decoder dialogs, single/all removal with Yes/No, stacking and child lifetime. The check saves decoder dialog screenshots alongside the report. +## Application protocols from templates + +SET CAN includes the current shared parsers and dictionaries. Select +`Protocol / протокол` in the decoder settings: + +- `protocan` / `devboard`: ProtoCAN/SETCAN, GAS, Settings, boot services, + discrete and analog messages, device names and assigned GAS IDs. +- `balsam`: Balsam 167, including the actual rotating wire-word order and + separate operator-panel IDs. +- `ump-protocan`: UMP 1/2 with the explicitly selected `gas167` local map or + `assigned` global IDs. Assigned IDs do not imply firmware support. +- `deif`: DEIF XDi-192 / CANopen with STD CAN frames and physical values. +- `legacy`: explicitly select a project from the shared catalog: + Буксир, СЭДБМ, Ледокол, Бальзам, 23550, 23550.X, 23550.2, Янтарь, 23550 БСУ. +- `set-v2`: segmented SET v2, with CRC, sequence and timeout checks. +- `auto`: Balsam IDs, the SET v2 transport prefix, otherwise ProtoCAN. + Project-specific legacy formats and UMP maps always require explicit selection. + +Frames, Fields, Registers, Payload and Errors are separate annotation rows. +SET UART can apply the same non-segmented CAN profiles inside the AA55 bridge; +its Bridge settings select the profile, project and UMP map/node. + +TMS UART stacks on UART and reads `TMS_TO_TERMINAL` using the shared terminal +parser. Choose a preset layout or `custom` with discrete-byte/analog-word +counts, plus MODBUS or CCITT-FALSE CRC. For a 22220 reply, select `custom`, +70 discrete bytes and 80 analog words. RX/TX buffers are independent; +UART errors and configured gaps discard incomplete data. Only frames with +valid CRC are emitted. This decoder covers terminal telemetry layouts; +variable-length TMS bootloader/memory exchanges are not terminal telemetry. + +`protocol-files.json` is the single payload manifest for the embedded builder +and the standalone installer. Canonical Python modules and JSON maps are +copied unchanged into the private decoder runtime; no GUI imports or external +Python packages are required. Restart DSView after updating the decoders. + ## Annotation search The patch searches every enabled result column of the selected decoder and @@ -171,3 +206,22 @@ The decoder selector starts with **Пользовательские**: IGBT 1SP/ Transistor pair, SET UART, SET CAN and PM35 UART. **Стандартные** contains the remaining decoders. Search matches names and IDs in both sections and hides empty section headings. The changes are packaged in `decoder-panel.patch`. + + +## Шины 96-контактного интерфейса: Plates PBus / Plates SBus + +В общую ведомость поставки включены `plates_pbus` и `plates_sbus`. +Они устанавливаются вместе с остальными анализаторами, включаются в +будущую сборку embedded DSView и работают непосредственно с logic-каналами. +Назначение сигналов, формат обмена, параметры CRC/ACK и ограничения захвата +описаны в [PLATES_BUSES.md](PLATES_BUSES.md). + +В checkout SETGUI запуск `buildb_bat/INSTALL_DSVIEW_DECODERS.bat` без +параметров выбирает `dist/dsview`; при отсутствии полного компонента там +проверяется `src/gui_desktop/native/dsview`. Выбранный путь печатается до +копирования. Для отдельной установки передать `-DsViewPath` с её каталогом. +Для записи в защищённый Program Files требуются права администратора; +обновление декодеров не требует записи в сам `DSView.exe`. +Перед установкой закрыть целевой DSView, затем открыть его снова. +Добавление пакетов обновляет Python-декодеры без пересборки EXE; +изменение раздела «Пользовательские» применяется при следующей нативной сборке. diff --git a/tools/dsview/build.py b/tools/dsview/build.py index 9e1b36c..c2872db 100644 --- a/tools/dsview/build.py +++ b/tools/dsview/build.py @@ -15,13 +15,21 @@ import subprocess REVISION = '2e9e2c8e' +def protocol_payload(): # Одна ведомость управляет упаковкой переносимых декодеров и их зависимостей. + recipe = Path(__file__).resolve().parent # Рецепт находится в tools/dsview относительно закреплённого templates. + python = recipe.parents[1] / 'python' # Исходники прикладных протоколов берутся из того же checkout, что адаптеры. + manifest = json.loads((recipe / 'protocol-files.json').read_text(encoding='utf-8')) # JSON также читает установщик standalone DSView. + files = [(recipe / 'decoders' / name / leaf, Path(name) / leaf) for name in manifest['adapters'] for leaf in ('__init__.py', 'pd.py')] # Каждый адаптер имеет стандартную точку входа и реализацию sigrok. + files += [(python / 'logic_analyzer/decoders' / (name + '.py'), Path('common/setgui_decoders') / (name + '.py')) for name in manifest['cores']] # Ядра устанавливаются с прежним пространством имён common.setgui_decoders. + files += [(python / name, Path('common/setgui_decoders/templates') / name) for name in manifest['template_files']] # Переносимые библиотеки и карты копируются без изменения исходных файлов. + return files # Пары исходник/назначение используются и для контрольной суммы, и для копирования. + + def recipe_digest(): """Identify the patch, packaging recipe, adapters and their shared cores.""" recipe = Path(__file__).resolve().parent - core = recipe.parents[1] / 'python/logic_analyzer/decoders' - inputs = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h'] - inputs += sorted((recipe / 'decoders').rglob('*.py')) - inputs += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')] + inputs = [recipe / 'build.py', recipe / 'protocol-files.json', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h'] # Ведомость и нативные патчи входят в версию рецепта. + inputs += [source for source, _ in protocol_payload()] # Изменение парсера, адаптера или JSON-карты делает установленный компонент устаревшим. digest = hashlib.sha256() for item in inputs: digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0') @@ -46,12 +54,10 @@ def package_decoders(output): decoders = Path(output) / 'decoders' shutil.copytree(recipe / 'decoders', decoders, dirs_exist_ok=True, ignore=shutil.ignore_patterns('__pycache__')) - common = decoders / 'common/setgui_decoders' - common.mkdir(parents=True, exist_ok=True) - (common / '__init__.py').write_text('', encoding='utf-8') - core = recipe.parents[1] / 'python/logic_analyzer/decoders' - for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'): - shutil.copy2(core / (name + '.py'), common / (name + '.py')) + for source, relative in protocol_payload(): # Декодеры, ядра и карты устанавливаются по единой ведомости. + destination = decoders / relative # Все файлы остаются внутри выбранного каталога декодеров. + destination.parent.mkdir(parents=True, exist_ok=True) # Новые переносимые пакеты получают собственные подкаталоги. + shutil.copy2(source, destination) # Канонические реализации templates копируются побайтно без отдельного форка протокола. def main(): diff --git a/tools/dsview/decoder-panel.patch b/tools/dsview/decoder-panel.patch index 5947bed..6d62171 100644 --- a/tools/dsview/decoder-panel.patch +++ b/tools/dsview/decoder-panel.patch @@ -93,14 +93,14 @@ - { - srd_decoder *dec = (srd_decoder *)(info->_data_handle); - panel->AddDataItem(QString(dec->id), QString(dec->name), info); -+ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart"}; ++ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart", "tms_uart", "plates_pbus", "plates_sbus"}; // При сборке пользовательский раздел включает анализаторы обеих шин 96-контактного интерфейса. + for (bool custom : {true, false}) { + for (auto info : _decoderInfoList) { + srd_decoder *dec = (srd_decoder *)(info->_data_handle); + if (customIds.contains(QString(dec->id)) != custom) + continue; + panel->AddDataItem(QString(dec->id), QString(dec->name), info, -+ custom ? QString::fromUtf8("Пользовательские") : QString::fromUtf8("Стандартные")); ++ custom ? QStringLiteral("Пользовательские") : QStringLiteral("Стандартные")); // Unicode-заголовок выбирается по принадлежности декодера; QStringLiteral сохраняет кириллицу без преобразования через системную кодировку Windows. + } } diff --git a/tools/dsview/decoders/plates_pbus/__init__.py b/tools/dsview/decoders/plates_pbus/__init__.py new file mode 100644 index 0000000..31672d3 --- /dev/null +++ b/tools/dsview/decoders/plates_pbus/__init__.py @@ -0,0 +1,2 @@ +"""Декодер параллельной шины 96-контактного интерфейса.""" # Описание пакета доступно загрузчику декодеров DSView. +from .pd import Decoder # Экспорт класса нужен штатному загрузчику libsigrokdecode. diff --git a/tools/dsview/decoders/plates_pbus/pd.py b/tools/dsview/decoders/plates_pbus/pd.py new file mode 100644 index 0000000..0dd6b3d --- /dev/null +++ b/tools/dsview/decoders/plates_pbus/pd.py @@ -0,0 +1,140 @@ +"""PBus по ParallelBusSlave_ver_2_0.vhd; Python 3.6+, sigrok API 3.""" # Времена выборки определяются ACK аппаратного ведомого. +import sigrokdecode as srd # DSView предоставляет цифровые отсчёты и выходные аннотации. + + +# CE ограничивает транзакцию, три перехода ACK подтверждают адрес, данные и их инверсию. +class Decoder(srd.Decoder): # Протокол работает непосредственно с захватом логических уровней. + api_version = 3 # API ожидания событий поддерживается libsigrokdecode4DSL. + id = 'plates_pbus' # Имя пакета не пересекается со штатным parallel. + name = 'Plates PBus' # Название в списке протоколов DSView. + longname = 'Plates 96-pin PBus' # Название связывает декодер с интерфейсом плат. + desc = 'PBus reads with address complement, ACK and inverted data checks.' # Текущий HDL реализует внешнее чтение PBus. + license = 'gplv3+' # Допустимый идентификатор лицензии libsigrokdecode. + inputs = ['logic'] # Входные данные — цифровые каналы анализатора. + outputs = ['plates_pbus'] # Структурированные результаты доступны следующему уровню. + tags = ['Embedded/industrial'] # Категория промышленного обмена. + channels = ( # Обязательные сигналы не подменяются нулём при отсутствии захвата. + {'id': 'ce', 'name': 'PBCE', 'desc': 'Active-low transaction enable'}, # Индекс 0: CE=0 разрешает обмен согласно HDL. + {'id': 'ack', 'name': 'PBACK', 'desc': 'Address / data / inverse acknowledge'}, # Индекс 1: переходы 0,1,0 задают три фазы. + ) + tuple({'id': 'd%d' % i, 'name': 'D%d' % i, 'desc': 'PBDATA bit %d' % i} for i in range(16)) # Индексы 2…17 соответствуют физическим разрядам D0…D15. + optional_channels = ( # Для выборки достаточно ACK, поэтому тактовая линия необязательна. + {'id': 'clk', 'name': 'PBCLK', 'desc': 'Optional phase validation clock'}, # Индекс 18: данные ожидаются при CLK=0, инверсия — при CLK=1. + ) # При наличии CLK проверяется согласованность фаз с текущим HDL. + annotations = ( # Числовые типы совместимы с форматом аннотаций DSView. + ('1', 'device', 'Device'), # Индекс 0: старшая тетрада восьмибитного адреса. + ('2', 'register', 'Register'), # Индекс 1: младшая тетрада адреса. + ('3', 'data', 'Data'), # Индекс 2: прочитанное 16-битное значение. + ('4', 'check', 'Checks'), # Индекс 3: инверсная защита адреса и ответа. + ('5', 'frame', 'Transaction'), # Индекс 4: итог полного или оборванного обращения. + ('1000', 'warning', 'Warnings'), # Индекс 5: ошибки адреса, ответа, фаз и отсутствующих ACK. + ) # Индекс класса в put() отличается от числового типа в первом поле. + annotation_rows = ( # Раздельные строки сохраняют читаемость при перекрытии полей. + ('device', 'Device', (0,)), # Устройство и регистр передаются одновременно. + ('register', 'Register', (1,)), # Поэтому регистр располагается на своей строке. + ('data', 'Data', (2,)), # Данные видны рядом с проверкой инверсной копии. + ('checks', 'Checks', (3,)), # Результаты проверок не теряются за итогом кадра. + ('frames', 'Transactions', (4,)), # Итог содержит адрес, значение и состояние обмена. + ('warnings', 'Warnings', (5,)), # Ошибки выделяются штатным типом DSView. + ) # Все поля выводятся без зависимости от карт регистров PlatesConf. + + # Экземпляр декодера начинает без активной транзакции. + def __init__(self): # Вызывается загрузчиком протоколов. + self.reset() # Повторный проход использует такой же сброс. + + # Незавершённое состояние предыдущего захвата не переносится в следующий. + def reset(self): # Сбрасывает только внутренний автомат. + self.packet = None # Словарь операции появляется на спаде CE. + self.phase = None # Ожидаемая фаза: address, data, inverse или done. + self.ss = 0 # Начало транзакции задаётся в отсчётах захвата. + self.phase_start = 0 # Начало текущего ожидания нужно для границ аннотаций. + + # Аннотации и структурированные кадры регистрируются до обработки сигналов. + def start(self): # Вызывается средой DSView при запуске декодирования. + self.out_ann = self.register(srd.OUTPUT_ANN) # Текстовые поля отображаются над захватом. + self.out_python = self.register(srd.OUTPUT_PYTHON) # Пакеты сохраняют сырые значения и результаты проверок. + + # Каждая аннотация привязана к фактическим границам текущей фазы. + def annotate(self, start, end, kind, text): # kind — индекс annotations, а не числовой тип DSView. + self.put(start, max(start, end), self.out_ann, [kind, [text]]) # Одноточечные переходы также допустимы. + + # Порядок назначения каналов DSView задаёт значение разрядов без перестановки байтов. + def word(self, pins): # На входе полный набор сигналов текущего отсчёта. + return sum(pins[2 + i] << i for i in range(16)) # D0 имеет вес 1, D15 — 32768; результат беззнаковый. + + # Ошибка добавляется и в выходной пакет, и в видимую строку предупреждений. + def warning(self, text, end): # end обозначает отсчёт обнаружения ошибки. + self.packet['errors'].append(text) # Причины остаются доступны потребителю Python-выхода. + self.annotate(self.phase_start, end, 5, text) # Предупреждение относится к текущей фазе обмена. + + # Адрес находится в старшем байте; младший должен быть его инверсией согласно HDL. + def address(self, word, end): # Выборка выполняется на первом низком ACK либо при закрытии запроса без ACK. + address = word >> 8 # Ровно восемь бит делятся на два четырёхбитных адреса. + self.packet.update(dev=address >> 4, reg=address & 15, address_word=word, address_ok=((word & 255) == (address ^ 255))) # Маска 255 ограничивает инверсию одним байтом. + self.annotate(self.ss, end, 0, 'DEV 0x%X' % self.packet['dev']) # Старшая тетрада определяет устройство 0…15. + self.annotate(self.ss, end, 1, 'REG 0x%X' % self.packet['reg']) # Младшая тетрада определяет регистр 0…15. + self.annotate(self.ss, end, 3, 'ADDR 0x%04X %s' % (word, 'OK' if self.packet['address_ok'] else 'BAD')) # Видна и исходная инверсная копия адреса. + if not self.packet['address_ok']: # Несогласованность байтов не маскируется правильными данными ответа. + self.warning('Address complement mismatch', end) # На исправном ведомом такой адрес не должен получать ACK. + + # Итог формируется даже при обрыве, но неполная операция никогда не считается успешной. + def emit_frame(self, end, complete): # complete означает получение всех трёх фаз ACK. + self.packet['complete'] = complete # Полнота отделена от результата проверок содержимого. + self.packet['ok'] = complete and not self.packet['errors'] # Любая ошибка проверки остаётся явной. + value = self.packet['value'] # При отсутствии фазы данных значение остаётся None. + text = 'READ %X:%X = %s %s' % (self.packet['dev'], self.packet['reg'], '?' if value is None else '0x%04X' % value, 'OK' if self.packet['ok'] else 'BAD / incomplete') # Формат не выдаёт предположение за данные шины. + self.annotate(self.ss, end, 4, text) # Итог покрывает весь интервал обращения. + self.put(self.ss, end, self.out_python, ['FRAME', self.packet]) # Даже повторное чтение того же значения остаётся отдельной операцией. + + # Возврат CE либо конец захвата закрывает незаконченное ожидание ACK. + def close(self, end): # Завершённая операция уже выведена на ACK инверсии и не дублируется. + if self.packet is not None and self.phase != 'done': # Пустой захват или уже завершённый кадр не создают ошибок. + if self.phase == 'address': # При отсутствии первого ACK сохраняем хотя бы запрос на спаде CE. + self.address(self.packet['address_word'], end) # Это наблюдавшийся адрес, а не восстановление по ответу. + self.warning('Incomplete PBus: missing %s ACK' % self.phase, end) # Сообщение различает отсутствие адресного ответа, данных и инверсии. + self.emit_frame(end, False) # Частичные данные доступны с complete=False. + self.packet = None # После закрытия CE предыдущая операция больше не активна. + self.phase = None # Новая операция должна начаться новым спадом CE. + + # Последний незакрытый запрос виден и при обрыве самого файла захвата. + def end(self): # Метод вызывается DSView после последнего блока данных. + self.close(getattr(self, 'last_samplenum', getattr(self, 'samplenum', 0))) # Используем последнюю известную границу захвата. + + # Состояния автомата соответствуют аппаратной последовательности ACK 0 -> 1 -> 0. + def decode(self): # Ожидание событий завершается самой средой декодирования. + if not all(self.has_channel(i) for i in range(18)): # Для полного слова обязательны CE, ACK и все 16 разрядов данных. + raise ValueError('PBus requires PBCE, PBACK and all D0..D15 channels') # Отсутствующий разряд не заменяется ложным нулём. + have_clock = self.has_channel(18) # PBCLK улучшает диагностику, но не нужен для выборки по ACK. + previous = self.wait({}) # Начало посреди активного CE игнорируется до следующей полной операции. + while True: # Изменения самих данных не вызывают лишние пробуждения декодера. + pins = self.wait([{0: 'e'}, {1: 'e'}]) # Фазы ограничиваются только CE и ACK, а данные читаются в точке подтверждения. + now = self.samplenum # Все границы аннотаций задаются в отсчётах. + if pins[0]: # CE=1 завершает текущий обмен независимо от уровня ACK. + self.close(now) # Незаконченная фаза становится явной ошибкой. + else: # Только низкий CE разрешает принимать ACK ведомого. + if previous[0]: # Спад CE начинает новую операцию даже при одновременном переходе ACK. + self.ss = self.phase_start = now # Начало фазы адреса совпадает с разрешением обмена. + self.packet = {'bus': 'PBus', 'operation': 'read', 'dev': None, 'reg': None, 'address_word': self.word(pins), 'address_ok': None, 'value': None, 'inverse': None, 'inverse_ok': None, 'errors': []} # Пропущенные фазы имеют явное значение None. + self.phase = 'address' # Первым ожидается низкий адресный ACK. + falling = previous[1] == 1 and pins[1] == 0 # ACK=0 подтверждает адрес или инверсную копию в зависимости от фазы. + rising = previous[1] == 0 and pins[1] == 1 # ACK=1 подтверждает прямые данные ответа. + if self.packet is not None and self.phase == 'address' and falling: # Первый спад ACK фиксирует защищённый адрес. + self.address(self.word(pins), now) # Адрес читается после готовности приёмника, а не по произвольному фронту CLK. + self.phase, self.phase_start = 'data', now # Следующий ACK должен стать высоким при готовности данных. + elif self.packet is not None and self.phase == 'data' and rising: # Высокий ACK отмечает прямое слово ответа. + self.packet['value'] = self.word(pins) # Сохраняется всё 16-битное значение PBus. + self.annotate(self.phase_start, now, 2, 'DATA 0x%04X' % self.packet['value']) # Число показывается в шестнадцатеричном виде с ведущими нулями. + if have_clock and pins[18] != 0: # В HDL TransmitData выдаёт ACK только при низком PBCLK. + self.warning('Data ACK while PBCLK is not low', now) # Ошибка фазы помогает выявить неверное подключение либо нарушение обмена. + self.phase, self.phase_start = 'inverse', now # Далее ожидается низкий ACK инверсного слова. + elif self.packet is not None and self.phase == 'inverse' and falling: # Последний спад ACK завершает проверяемый обмен. + inverse = self.word(pins) # Инверсия выбирается отдельно от прямых данных. + valid = inverse == (self.packet['value'] ^ 65535) # Маска 65535 ограничивает инверсию ровно 16 разрядами HDL. + self.packet.update(inverse=inverse, inverse_ok=valid) # Сохраняем и сырое слово, и результат проверки. + self.annotate(self.phase_start, now, 3, 'INVERSE 0x%04X %s' % (inverse, 'OK' if valid else 'BAD')) # Проверяем содержимое, а не только наличие третьего ACK. + if not valid: # Повреждённый ответ не становится успешным из-за полного числа фаз. + self.warning('Data complement mismatch', now) # Ошибка относится к данным ответа. + if have_clock and pins[18] != 1: # В HDL TransmitCheck выдаёт ACK при высоком PBCLK. + self.warning('Inverse ACK while PBCLK is not high', now) # Проверка включается только при назначении необязательного канала. + self.emit_frame(now, True) # Итог доступен сразу после третьего подтверждения. + self.phase = 'done' # Возврат CE в единицу не должен дублировать кадр. + previous = pins # Следующий переход сравнивается с фактическими последними уровнями CE и ACK. diff --git a/tools/dsview/decoders/plates_sbus/__init__.py b/tools/dsview/decoders/plates_sbus/__init__.py new file mode 100644 index 0000000..93cc378 --- /dev/null +++ b/tools/dsview/decoders/plates_sbus/__init__.py @@ -0,0 +1,2 @@ +"""Декодер последовательной шины 96-контактного интерфейса.""" # Описание пакета доступно загрузчику декодеров DSView. +from .pd import Decoder # Экспорт класса нужен штатному загрузчику libsigrokdecode. diff --git a/tools/dsview/decoders/plates_sbus/pd.py b/tools/dsview/decoders/plates_sbus/pd.py new file mode 100644 index 0000000..af16d5e --- /dev/null +++ b/tools/dsview/decoders/plates_sbus/pd.py @@ -0,0 +1,172 @@ +"""SBus по SerialBusSlave_ver_2_0.vhd; Python 3.6+, sigrok API 3.""" # Формат берётся из HDL, без зависимости от приложения SETGUI. +import math # Округление длительностей вверх исключает преждевременное срабатывание таймеров. +import sigrokdecode as srd # Среда DSView предоставляет входные отсчёты и выходные аннотации. + + +# CRC повторяет параллельные присваивания HDL, включая четыре завершающие единицы. +def frame_crc(direction, address, value): # В расчёт входят направление, восьмибитный адрес и 16 бит данных. + word = (direction << 24) | (address << 16) | value # Поля образуют 25 бит в порядке передачи от старшего к младшему. + crc = 0 # Начальное состояние регистра CRC равно x"0" в SerialBusSlave_ver_2_0.vhd. + for bit in [((word >> shift) & 1) for shift in range(24, -1, -1)] + [1] * 4: # После 25 бит HDL подаёт четыре единицы. + c3, c2, c1, c0 = [(crc >> shift) & 1 for shift in (3, 2, 1, 0)] # Все новые разряды используют прежнее состояние CRC. + crc = ((c2 ^ c3) << 3) | ((c1 ^ c0) << 2) | (c0 << 1) | (bit ^ c1) # Это точная обратная связь HDL, а не стандартный CRC-4. + return crc # На провод передаются четыре бита результата, начиная с бита 3. + + +# Декодер разделяет кадры по паузе любого уровня CLK и выбирает линию данных по направлению. +class Decoder(srd.Decoder): # Базовый класс реализован встроенным модулем DSView. + api_version = 3 # API 3 позволяет ждать фронтов и временных интервалов без перебора всех отсчётов. + id = 'plates_sbus' # Уникальное имя не пересекается со штатными декодерами. + name = 'Plates SBus' # Короткое название отображается при выборе протокола. + longname = 'Plates 96-pin SBus' # Уточнение отличает шину от других последовательных протоколов. + desc = 'SBus: direction, device, register, data, CRC and write ACK.' # Краткое описание доступно в диалоге DSView. + license = 'gplv3+' # Это допустимый идентификатор лицензии для libsigrokdecode. + inputs = ['logic'] # Декодер получает цифровые сигналы анализатора. + outputs = ['plates_sbus'] # Python-пакеты можно использовать в следующем уровне декодирования. + tags = ['Embedded/industrial'] # Категория соответствует промышленной шине плат. + channels = ( # Направления линий указаны относительно мастера ТМС. + {'id': 'clk', 'name': 'SBCLK', 'desc': 'Serial clock'}, # CLK задаёт границы битов и паузы между кадрами. + {'id': 'tx', 'name': 'SBDATAIN', 'desc': 'TMS -> plates'}, # На этой линии находятся запрос и данные записи. + {'id': 'rx', 'name': 'SBDATAOUT', 'desc': 'Plates -> TMS'}, # На этой линии находятся данные чтения и активный низкий ACK записи. + ) # Все три линии обязательны для проверки обоих направлений. + options = ( # Времена задаются в микросекундах и пересчитываются по частоте захвата. + {'id': 'idle_us', 'desc': 'Frame gap (us)', 'default': 3.6}, # В текущем HDL COUNT_RESET=180 при 50 МГц: 180/50=3,6 мкс. + {'id': 'ack_us', 'desc': 'Write ACK sampling delay (us)', 'default': 0.5}, # Полмикросекунды после CRC оставляют запас для логики проверки до сброса по паузе. + ) # Параметры можно изменить для реального захвата другой аппаратуры. + annotations = ( # Первое поле задаёт числовой тип DSView; индекс кортежа используется в put(). + ('1', 'direction', 'Direction'), # Индекс 0: чтение или запись со стороны ТМС. + ('2', 'device', 'Device'), # Индекс 1: адрес устройства 0…15. + ('3', 'register', 'Register'), # Индекс 2: адрес регистра 0…15. + ('4', 'data', 'Data'), # Индекс 3: сырое 16-битное значение без выдуманных единиц. + ('5', 'crc', 'CRC'), # Индекс 4: принятая и рассчитанная контрольные суммы. + ('6', 'ack', 'Write ACK'), # Индекс 5: подтверждение записи, отдельное от проверки CRC. + ('7', 'frame', 'Transaction'), # Индекс 6: итог операции целиком. + ('1000', 'warning', 'Warnings'), # Индекс 7: CRC, отказ и незавершённые кадры. + ) # Числовой тип 1000 соответствует предупреждениям в DSView. + annotation_rows = ( # Параллельные строки позволяют видеть поля и итог кадра одновременно. + ('fields', 'Fields', (0, 1, 2, 3)), # Неперекрывающиеся поля запроса и данных. + ('checks', 'CRC / ACK', (4, 5)), # Независимые проверки целостности и ответа платы. + ('frames', 'Transactions', (6,)), # Сводная строка удобна для поиска обращений. + ('warnings', 'Warnings', (7,)), # Ошибки видны независимо от масштаба остальных строк. + ) # Индексы соответствуют annotations выше. + + # Новый экземпляр должен начинаться без синхронизации с границей кадра. + def __init__(self): # Загрузчик создаёт экземпляр при добавлении декодера. + self.reset() # Единый сброс используется также при повторном декодировании. + + # Сбрасываем только состояние захвата, не значения обязательных входных каналов. + def reset(self): # Вызывается средой при начале нового прохода. + self.samplerate = None # Частота захвата будет передана через metadata(). + self.bits = [] # Элементы содержат номер отсчёта и значение бита текущего кадра. + self.pending = None # Завершённая запись ждёт отдельного момента выборки ACK. + self.ack_at = None # Номер отсчёта выборки ACK существует только при pending. + + # Выходы регистрируются после сброса до обработки первых отсчётов. + def start(self): # API DSView вызывает этот метод перед decode(). + self.out_ann = self.register(srd.OUTPUT_ANN) # Текстовые поля и предупреждения рисуются над захватом. + self.out_python = self.register(srd.OUTPUT_PYTHON) # Структурированные кадры сохраняют независимые CRC и ACK. + + # Частота нужна для отделения кадров по реальному времени, а не количеству битов. + def metadata(self, key, value): # Среда передаёт метаданные захвата независимо от фронтов. + if key == srd.SRD_CONF_SAMPLERATE: # Другие метаданные не влияют на разбор шины. + self.samplerate = value # Единица измерения — отсчёты в секунду. + + # Обёртка сохраняет допустимый порядок границ даже для одноточечной аннотации. + def annotate(self, start, end, kind, text): # kind — индекс annotations, text — содержимое над сигналом. + self.put(start, max(start, end), self.out_ann, [kind, [text]]) # Одноточечные поля направления допустимы API. + + # Поля передаются старшим битом вперёд; срез не включает правую границу. + def field(self, start, end): # Параметры задают позиции битов внутри 29-битного кадра. + value = 0 # Аккумулятор формируется заново для каждого поля. + for _, bit in self.bits[start:end]: # Номер отсчёта не влияет на числовое значение. + value = (value << 1) | bit # Следующий бит занимает младший разряд после сдвига. + return value # Значение сохраняется без знакового преобразования. + + # Итог записи отделяет хороший CRC от ACK; ACK не доказывает применение команды CPU1. + def emit_frame(self, packet, end): # packet уже содержит все поля и результаты доступных проверок. + ack = packet['ack'] # Для чтения и необследованного ответа записи значение остаётся None. + suffix = '' if packet['operation'] == 'read' else ' ACK ' + ('?' if ack is None else ('OK' if ack else 'HIGH')) # Высокий уровень не различает NACK и отсутствие платы. + status = 'OK' if packet['crc_ok'] else 'BAD' # Целостность CRC показывается независимо от подтверждения. + text = '%s %X:%X = 0x%04X CRC %s%s' % (packet['operation'].upper(), packet['dev'], packet['reg'], packet['value'], status, suffix) # Адреса и данные удобны для сверки с регистрами проекта. + self.annotate(self.bits[0][0], end, 6, text) # Сводная аннотация покрывает весь кадр и ожидание ACK. + self.put(self.bits[0][0], end, self.out_python, ['FRAME', packet]) # Структурированный выход не теряет повторные одинаковые команды. + + # Бит принимается только по нарастающему фронту, когда ответ ведомого уже установлен после спада. + def receive_bit(self, pins): # pins имеет порядок CLK, запрос ТМС, ответ платы. + index = len(self.bits) # Первый бит — направление, следующие восемь — адрес. + if index >= 29: # Кадр содержит 1+8+16+4 бита; лишние такты до паузы не становятся новым запросом. + return # Синхронизация нового кадра требует паузы на CLK. + source = 1 if index < 9 or self.bits[0][1] else 2 # Заголовок и запись идут от ТМС; данные чтения и CRC — от платы. + self.bits.append((self.samplenum, pins[source])) # Сохраняем точное положение каждого принятого бита. + count = len(self.bits) # Поле выводится сразу после последнего составляющего бита. + if count == 1: # Направление 0 означает чтение, 1 означает запись в HDL. + self.annotate(self.samplenum, self.samplenum, 0, 'WRITE' if self.bits[0][1] else 'READ') # Операция указана относительно ТМС. + for size, begin, kind, label, width in ((5, 1, 1, 'DEV', 1), (9, 5, 2, 'REG', 1), (25, 9, 3, 'DATA', 4)): # Границы определены ширинами адреса и данных HDL. + if count == size: # Одно поле выводится только один раз за кадр. + self.annotate(self.bits[begin][0], self.samplenum, kind, '%s 0x%0*X' % (label, width, self.field(begin, size))) # Нулевые старшие разряды данных сохраняются. + if count != 29: # Проверка CRC возможна только при полном кадре. + return # Неполные кадры будут отмечены по паузе или концу захвата. + direction, address, value = self.field(0, 1), self.field(1, 9), self.field(9, 25) # Восьмибитный адрес делится на устройство и регистр. + actual, expected = self.field(25, 29), frame_crc(direction, address, value) # CRC рассчитывается над теми же данными, которые видны на проводе. + packet = {'bus': 'SBus', 'operation': 'write' if direction else 'read', 'dev': address >> 4, 'reg': address & 15, 'value': value, 'crc': actual, 'crc_expected': expected, 'crc_ok': actual == expected, 'ack': None} # Неизвестное подтверждение не подменяется успешным. + self.annotate(self.bits[25][0], self.samplenum, 4, 'CRC %X / %X %s' % (actual, expected, 'OK' if actual == expected else 'BAD')) # Оба значения помогают диагностировать ошибку захвата. + if actual != expected: # Ошибка видна и в отдельной строке предупреждений. + self.annotate(self.bits[25][0], self.samplenum, 7, 'CRC mismatch') # Повреждённый кадр всё равно выводится для диагностики. + if direction: # Запись подтверждается после проверки CRC ведомым. + self.pending = packet # Выход кадра откладывается до выборки ACK. + self.ack_at = self.samplenum + self.ack_samples # Задержка отсчитывается от последнего бита CRC. + else: # У чтения после CRC нет отдельного подтверждения ведомого. + self.emit_frame(packet, self.samplenum) # Полное чтение доступно без ожидания межкадровой паузы. + + # Частичный заголовок не выдаётся за достоверную операцию. + def close_partial(self, end): # Вызывается на паузе и при завершении захвата. + if self.pending is not None: # Захват может закончиться до заданного времени выборки ACK. + self.annotate(self.bits[-1][0], end, 7, 'Write ACK not sampled: capture ended') # Наблюдений недостаточно для вывода о подтверждении. + self.emit_frame(self.pending, end) # Полные данные сохраняются с ack=None. + self.pending = None # Повторный end() не должен дублировать кадр. + if 0 < len(self.bits) < 29: # Полный кадр уже выведен и не считается обрывом. + self.annotate(self.bits[0][0], end, 7, 'Incomplete SBus frame: %d/29 bits' % len(self.bits)) # Число бит помогает найти место потери синхронизации. + self.bits = [] # Следующая операция начинает собственный независимый буфер. + + # end() поддерживает диагностику обрыва даже без следующего фронта или полной паузы. + def end(self): # DSView передаёт last_samplenum после последнего блока захвата. + self.close_partial(getattr(self, 'last_samplenum', getattr(self, 'samplenum', 0))) # Резервная граница полезна в средах без last_samplenum. + + # Ожидаем фронты и ближайший таймер; тихий участок захвата не требует перебора отсчётов. + def decode(self): # Бесконечное ожидание завершается самой средой libsigrokdecode. + if not self.samplerate or not all(self.has_channel(i) for i in range(3)): # Для времени и обоих направлений нужны частота захвата и три сигнала. + raise ValueError('SBus requires samplerate, SBCLK, SBDATAIN and SBDATAOUT') # Ошибка настройки отличается от повреждённого кадра. + idle_us, ack_us = self.options['idle_us'], self.options['ack_us'] # Настройки относятся к временной шкале захвата. + if not math.isfinite(idle_us) or not math.isfinite(ack_us) or not 0 < ack_us < idle_us: # ACK должен наблюдаться раньше сброса при паузе. + raise ValueError('Require finite 0 < ACK delay < frame gap') # Неверные интервалы не должны создавать ложную диагностику. + gap = max(1, int(math.ceil(self.samplerate * idle_us / 1000000.0))) # Миллион переводит микросекунды в секунды. + self.ack_samples = max(1, int(math.ceil(self.samplerate * ack_us / 1000000.0))) # Округление вверх обеспечивает минимум один отсчёт ожидания. + if self.ack_samples >= gap: # При грубой дискретизации разные времена могут попасть в один отсчёт. + raise ValueError('Samplerate too low to separate ACK sampling and frame gap') # Такой захват не позволяет выполнить заданную временную проверку. + pins = self.wait({}) # Первый отсчёт задаёт уровень CLK без выдуманного фронта. + previous, last_edge, armed = pins[0], self.samplenum, False # Начало посреди кадра игнорируется до наблюдаемой паузы. + while True: # Каждый проход обрабатывает фронт или один ближайший таймер. + deadlines = [] if armed else [last_edge + gap] # После подтверждённой паузы достаточно дождаться следующего фронта. + if self.pending is not None: # Отдельный таймер ACK не зависит от дальнейших тактов CLK. + deadlines.append(self.ack_at) # Последний CRC может быть последним тактом в захвате. + conditions = [{0: 'e'}] # Учитываются оба фронта, поскольку сброс HDL отсчитывается от любого изменения CLK. + if deadlines: # Таймер задаётся относительно текущего положения декодера. + conditions.append({'skip': max(1, min(deadlines) - self.samplenum)}) # Минимум один отсчёт исключает ожидание в той же точке. + pins = self.wait(conditions) # Значения линий доступны и при срабатывании временного условия. + now = self.samplenum # Номер отсчёта общий для фронтов и таймеров. + if self.pending is not None and now >= self.ack_at: # ACK измеряется после CRC, а не на его последнем фронте. + self.pending['ack'] = pins[2] == 0 # В HDL низкий SBDATAOUT подтверждает запись; высокий означает отказ либо отсутствие ответа. + self.annotate(self.bits[-1][0], now, 5, 'ACK' if self.pending['ack'] else 'ACK HIGH (NACK / no response)') # Уровень ответа не скрывается даже при плохом CRC. + if not self.pending['ack']: # Отсутствие низкого ACK является отдельной проблемой обмена. + self.annotate(self.bits[-1][0], now, 7, 'No low write ACK') # Это не доказательство конкретной причины отказа. + self.emit_frame(self.pending, now) # Повторные записи выводятся отдельными кадрами. + self.pending = None # Таймер подтверждения больше не нужен. + if not armed and now - last_edge >= gap: # Пауза допустима как при высоком, так и при низком CLK. + self.close_partial(now) # Незавершённый предыдущий кадр отмечается до повторной синхронизации. + armed = True # Первый следующий нарастающий фронт начинает заголовок. + if pins[0] != previous: # Срабатывание одного таймера не должно создавать фиктивный бит. + if pins[0] and (armed or self.bits): # После паузы начинаем кадр; внутри кадра продолжаем сбор. + armed = False # До следующей длинной паузы новый заголовок запрещён. + self.receive_bit(pins) # Бит выбирается с нужной линии с учётом направления. + last_edge = now # Любой фронт перезапускает измерение неподвижного CLK. + previous = pins[0] # Следующий таймер сравнивается с последним реальным уровнем. diff --git a/tools/dsview/decoders/set_can/pd.py b/tools/dsview/decoders/set_can/pd.py index 40b84b6..bb1a2b1 100644 --- a/tools/dsview/decoders/set_can/pd.py +++ b/tools/dsview/decoders/set_can/pd.py @@ -1,71 +1,70 @@ -# Сегментация SET v2 и интерпретация CAN-данных зависят от выбранного слоя. Адрес и номер -# сегмента должны проверяться до объединения данных; classic CAN-пакет ограничен восемью -# байтами. Этот адаптер переводит события libsigrokdecode в аннотации DSView; координаты -# выборок пересчитываются с учётом частоты захвата. +import sigrokdecode as srd # API sigrok передаёт кадры базового CAN-декодера и принимает аннотации. +from common.setgui_decoders.set_can import Reassembler # Существующий сборщик проверяет сегментацию и тайм-аут SET v2. +from common.setgui_decoders.template_protocols import CAN_PROFILES, LEGACY_PROJECTS, decode_can # Профили и каталоги определяются общими библиотеками templates. -import sigrokdecode as srd -from common.setgui_decoders.set_can import Reassembler, legacy +class Decoder(srd.Decoder): # Стековый анализатор добавляет прикладные дорожки к физическому CAN. + api_version = 3 # Версия интерфейса соответствует поставляемому libsigrokdecode4DSL. + id = 'set_can' # Стабильный ID сохраняет совместимость записей и меню SETGUI. + name = 'SET CAN' # Короткое имя используется в дорожках и диалоге выбора. + longname = 'SET CAN / ProtoCAN / Balsam / UMP / DEIF / legacy' # Подпись перечисляет подключённые семейства протоколов. + desc = 'Templates protocols, application fields, registers and SET v2 reassembly.' # Описывает результат прикладного разбора. + license = 'gplv2+' # Лицензия адаптера соответствует остальным декодерам SET. + inputs = ['can'] # Источник — базовый декодер CAN, а не сырые логические уровни. + outputs = ['set'] # Тип структурированного выхода сохранён для совместимости стека. + tags = ['Embedded/industrial'] # Категория для штатного каталога DSView. + options = ( # Несовместимые карты адресов выбираются явно. + {'id': 'protocol', 'desc': 'Protocol / протокол', 'default': 'protocan', 'values': CAN_PROFILES}, # Прежнее значение по умолчанию сохраняет настройки существующих записей. + {'id': 'project', 'desc': 'Legacy CAN project / проект', 'default': LEGACY_PROJECTS[0], 'values': LEGACY_PROJECTS}, # Проект применяется в режиме legacy. + {'id': 'ump_map', 'desc': 'UMP map / карта УМП', 'default': 'gas167', 'values': ('gas167', 'assigned')}, # Локальная карта отделена от назначенных глобальных GAS ID. + {'id': 'ump_node', 'desc': 'UMP node / номер УМП', 'default': 1, 'values': (1, 2)}, # Локальные адреса 167/v1 не содержат номер УМП. + ) # Неприменимые к профилю настройки игнорируются. + annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'), ('field', 'Field'), ('register', 'Register')) # Первые три индекса сохраняют прежние дорожки. + annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)), ('fields', 'Fields', (3,)), ('registers', 'Registers', (4,))) # Поля и регистры получают самостоятельные строки. + def __init__(self): # Каждый экземпляр имеет независимое состояние разбора. + self.reset() # Незавершённые сегменты не переходят между записями. -class Decoder(srd.Decoder): - api_version = 3 - id = 'set_can' - name = 'SET CAN' - longname = 'SET v2 CAN / ProtoCAN / Balsam' - desc = 'Application fields and segmented SET packets over classic CAN.' - license = 'gplv2+' - inputs = ['can'] - outputs = ['set'] - tags = ['Embedded/industrial'] - options = ({'id': 'protocol', 'desc': 'CAN protocol (select explicitly)', - 'default': 'protocan', 'values': ('protocan', 'set-v2', 'balsam')},) - annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error')) - annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,))) + def reset(self): # Перезапуск освобождает память сборщика. + self.reassembler = Reassembler() # Машина ограничивает число параллельных пакетов и проверяет порядок сегментов. + self.samplerate = None # Частота поступает событием metadata. - def __init__(self): - self.reset() + def metadata(self, key, value): # Метаданные задают шкалу времени сборки пакетов. + if key == srd.SRD_CONF_SAMPLERATE: # Прочие метаданные не влияют на тайм-ауты. + self.samplerate = value # Частота выражена в отсчётах в секунду. - # Состояние принадлежит одному запуску декодера. Сброс не должен - # переносить незавершённый кадр или частоту из предыдущей записи. - def reset(self): - self.reassembler = Reassembler() - self.samplerate = None + def start(self): # Выходы регистрируются в штатной фазе запуска sigrok. + self.out_ann = self.register(srd.OUTPUT_ANN) # Аннотации предназначены для дорожек и поиска DSView. + self.out_python = self.register(srd.OUTPUT_PYTHON) # Словарь полей доступен последующим слоям стека. - # DSView передаёт время в номерах отсчётов. Частота нужна общему ядру - # для перевода тайм-аутов и измерений в физические единицы. - def metadata(self, key, value): - if key == srd.SRD_CONF_SAMPLERATE: - self.samplerate = value - - # Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе. - # Аннотации предназначены для дорожек, структурированные события — для стека. - def start(self): - self.out_ann = self.register(srd.OUTPUT_ANN) - self.out_python = self.register(srd.OUTPUT_PYTHON) - - def decode(self, ss, es, data): - frame_type, ident, rtr, dlc, payload = data - if frame_type != 'extended' or rtr == 'remote': - return - mode = self.options['protocol'] - if not 0 <= dlc <= 8 or len(payload) != dlc: - events = [(ss, es, None, 'Invalid classic CAN DLC')] - # Сегментированный SET v2 требует времени для тайм-аута сборки. - # Идентификатор, порядок сегментов и CRC проверяет общее ядро. - elif mode == 'set-v2': - if not self.samplerate: - raise ValueError('SET CAN requires samplerate for reassembly timeout') - events = self.reassembler.feed(ident, payload, ss, es, es * 1000.0 / self.samplerate) - else: - try: - result = legacy(ident, payload, mode) - events = [(ss, es, result, None)] if result else [] - except ValueError as exc: - events = [(ss, es, None, str(exc))] - for start, end, result, error in events: - self.put(start, end, self.out_ann, [2 if error else 0, [error or result['summary']]]) - if result: - result['can_id'] = ident - self.put(start, end, self.out_python, ['FRAME', result]) - if events and payload: - self.put(ss, es, self.out_ann, [1, [' '.join('%02X' % b for b in payload)]]) + def decode(self, ss, es, data): # Границы входного CAN-кадра выражены в отсчётах. + frame_type, ident, rtr, dlc, payload = data # Кортеж соответствует выходу штатного CAN-декодера. + mode = self.options['protocol'] # Профиль определяет формат ID и полезной нагрузки. + if frame_type not in ('extended', 'standard') or rtr == 'remote': # RTR не содержит прикладных данных. + return # Такие кадры остаются в базовых дорожках CAN. + if frame_type == 'standard' and mode != 'deif': # DEIF использует STD CANopen, остальные подключённые протоколы — EXT. + return # Предотвращает интерпретацию 11-битного ID как адреса ProtoCAN. + if not 0 <= dlc <= 8 or len(payload) != dlc: # Classic CAN ограничивает нагрузку восемью байтами и требует совпадения длины с DLC. + events = [(ss, es, None, 'Invalid classic CAN DLC')] # Повреждённый кадр показывается как ошибка. + elif mode == 'set-v2' or mode == 'auto' and ident >> 24 == 0x12: # Авто распознаёт зарезервированный префикс SET v2 без угадывания проекта. + if not self.samplerate: # Частота нужна для корректного тайм-аута сборки. + raise ValueError('SET CAN requires samplerate for reassembly timeout') # Движок получает явную ошибку конфигурации. + events = self.reassembler.feed(ident, payload, ss, es, es * 1000.0 / self.samplerate) # Перевод отсчётов в миллисекунды соответствует контракту сборщика. + else: # Несегментированные протоколы обрабатывают каждый кадр независимо. + try: # Ошибка одного кадра не должна прекращать разбор записи. + result = decode_can(ident, payload, mode, extended=frame_type == 'extended', ump_map=self.options['ump_map'], ump_node=self.options['ump_node'], project=self.options['project']) # Подключает текущий парсер templates и выбранную карту. + events = [(ss, es, result, None)] if result else [] # Чужой ID не создаёт ложных событий выбранного проекта. + except ValueError as exc: # Проверки DLC, диапазонов и форматов возвращают диагностический текст. + events = [(ss, es, None, str(exc))] # Следующий кадр продолжит обрабатываться. + for start, end, result, error in events: # Сборщик может выдать несколько событий за один кадр. + self.put(start, end, self.out_ann, [2 if error else 0, [error or result['summary']]]) # Сводка попадает на дорожку Frames либо Errors. + if result: # Подробности выдаются для распознанного сообщения. + for title, value in result.get('fields', []): # Названия и единицы определены общим парсером. + self.put(start, end, self.out_ann, [3, ['%s: %s' % (title, value)]]) # Поля доступны для просмотра и поиска. + for address, value in result.get('registers') or []: # Отсутствующая регистровая нагрузка отличается от пустого запроса. + self.put(start, end, self.out_ann, [4, ['R%04X = %04X (%d)' % (address, value, value)]]) # Адрес и слово показаны в HEX, значение также в десятичной системе. + for warning in result.get('warnings', []): # Замечания общего парсера сохраняются вместе с корректными полями. + self.put(start, end, self.out_ann, [2, [warning]]) # Предупреждения видны в строке ошибок. + result['can_id'] = ident # ID остаётся доступным последующим слоям. + self.put(start, end, self.out_python, ['FRAME', result]) # Передаёт полную модель события. + if events and payload: # Сырые байты позволяют сопоставить расшифровку с исходным CAN. + self.put(ss, es, self.out_ann, [1, [' '.join('%02X' % byte for byte in payload)]]) # Порядок байтов совпадает с захватом. diff --git a/tools/dsview/decoders/set_uart/pd.py b/tools/dsview/decoders/set_uart/pd.py index 4401c19..8ab027a 100644 --- a/tools/dsview/decoders/set_uart/pd.py +++ b/tools/dsview/decoders/set_uart/pd.py @@ -5,6 +5,7 @@ import sigrokdecode as srd from common.setgui_decoders.set_uart import StreamParser +from common.setgui_decoders.template_protocols import LEGACY_PROJECTS # Проекты CAN внутри моста выбираются из того же каталога templates. class Decoder(srd.Decoder): @@ -21,9 +22,13 @@ class Decoder(srd.Decoder): {'id': 'protocol', 'desc': 'Protocol', 'default': 'auto', 'values': ('auto', 'v2', 'v1', 'bridge')}, {'id': 'gap_ms', 'desc': 'Reset incomplete frame after gap (ms, 0 disables)', 'default': 100}, + {'id': 'can_protocol', 'desc': 'Bridge CAN protocol', 'default': 'protocan', 'values': ('protocan', 'auto', 'devboard', 'balsam', 'ump-protocan', 'deif', 'legacy')}, # Профиль применяется к прикладным кадрам, переданным транспортом AA55. + {'id': 'project', 'desc': 'Bridge legacy CAN project', 'default': LEGACY_PROJECTS[0], 'values': LEGACY_PROJECTS}, # Исторический проект выбирается явно, поскольку его ID могут пересекаться с другими форматами. + {'id': 'ump_map', 'desc': 'Bridge UMP map', 'default': 'gas167', 'values': ('gas167', 'assigned')}, # Разделяет текущую локальную карту и назначенные глобальные GAS ID. + {'id': 'ump_node', 'desc': 'Bridge UMP node', 'default': 1, 'values': (1, 2)}, # Номер УМП задаётся отдельно от локального адреса регистра. ) - annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error')) - annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,))) + annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'), ('field', 'Field')) # Индексы прежних аннотаций сохранены, подробные поля добавлены в конец. + annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)), ('fields', 'Fields', (3,))) # Поля прикладного парсера видны на отдельной дорожке. def __init__(self): self.reset() @@ -52,27 +57,37 @@ class Decoder(srd.Decoder): prefix = ('RX', 'TX')[direction] + ' ' self.put(ss, es, self.out_ann, [2 if error else 0, [prefix + (error or frame['summary'])]]) if frame: - if frame['protocol'] == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10: + if frame['protocol'] == 'ProtoCAN bridge' and not frame['flags'] & 10: # Флаги RTR и ERR исключают прикладные данные; STD допускается для профиля DEIF. # Imported only at decode time: both packages are installed together. - from common.setgui_decoders.set_can import legacy + from common.setgui_decoders.template_protocols import decode_can # Мост использует те же прикладные парсеры, что анализатор SET CAN. try: - application = legacy(frame['can_id'], frame['payload']) + application = decode_can(frame['can_id'], frame['payload'], self.options['can_protocol'], extended=bool(frame['flags'] & 1), project=self.options['project'], ump_map=self.options['ump_map'], ump_node=self.options['ump_node']) # Флаг IDE и выбранные настройки передаются общему парсеру без изменения wire-данных. frame['application'] = application - self.put(ss, es, self.out_ann, [0, [application['summary']]]) + if application: # Выбранный профиль может не распознать чужой CAN ID. + self.put(ss, es, self.out_ann, [0, [application['summary']]]) # Прикладная сводка дополняет сведения о транспортном кадре моста. + for title, value in application.get('fields', []): # Детали CAN внутри UART берутся из того же результата templates. + self.put(ss, es, self.out_ann, [3, ['%s: %s' % (title, value)]]) # Поля доступны без отдельного импорта записи в CAN-монитор. except ValueError as exc: self.put(ss, es, self.out_ann, [2, [str(exc)]]) + for title, value in frame.get('fields', []): # Стековые UART-профили могут возвращать готовые прикладные поля. + self.put(ss, es, self.out_ann, [3, [prefix + '%s: %s' % (title, value)]]) # Префикс RX/TX сохраняет направление каждого значения. frame['direction'] = direction self.put(ss, es, self.out_python, ['FRAME', frame]) if frame['payload']: self.put(ss, es, self.out_ann, [1, [prefix + ' '.join('%02X' % b for b in frame['payload'])]]) + def make_parser(self): # Фабрика позволяет использовать ту же UART-границу с другим переносимым потоковым парсером. + return StreamParser(self.options['protocol']) # Каждый экземпляр хранит байты только одного направления. + def decode(self, ss, es, data): kind, direction, payload = data if direction not in (0, 1): return # RX и TX имеют независимые парсеры: перемежающиеся байты двух направлений # нельзя объединять в один прикладной кадр. - parser = self.parsers.setdefault(direction, StreamParser(self.options['protocol'])) + if direction not in self.parsers: # В отличие от setdefault, проверка не создаёт и не отбрасывает парсер на каждом UART-событии. + self.parsers[direction] = self.make_parser() # Парсер создаётся один раз на направление и сохраняет незавершённый кадр. + parser = self.parsers[direction] # RX и TX продолжают использовать независимые буферы. if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'): self.emit(parser.flush(), direction) self.put(ss, es, self.out_ann, [2, [kind]]) diff --git a/tools/dsview/decoders/tms_uart/__init__.py b/tools/dsview/decoders/tms_uart/__init__.py new file mode 100644 index 0000000..5947668 --- /dev/null +++ b/tools/dsview/decoders/tms_uart/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder # Экспортирует стандартную точку входа анализатора libsigrokdecode. diff --git a/tools/dsview/decoders/tms_uart/pd.py b/tools/dsview/decoders/tms_uart/pd.py new file mode 100644 index 0000000..2f2309c --- /dev/null +++ b/tools/dsview/decoders/tms_uart/pd.py @@ -0,0 +1,18 @@ +from set_uart.pd import Decoder as SetUartDecoder # Общий адаптер уже разделяет RX/TX, учитывает паузы и отклоняет ошибки UART. +from common.setgui_decoders.tms_uart import LAYOUTS, TmsStreamParser, tms # Подключает существующий парсер и каталог раскладок templates. + +class Decoder(SetUartDecoder): # Использует проверенную UART-границу вместо второй реализации обработки байтов sigrok. + id = 'tms_uart' # Отдельный ID позволяет одновременно анализировать SET и исторический терминальный обмен. + name = 'TMS UART' # Короткое имя отображается в меню и дорожках. + longname = 'TMS terminal / TMS_TO_TERMINAL' # Полное имя соответствует структуре действующего протокола терминала. + desc = 'Templates TMS terminal frames, analog values, discrete bits and CRC.' # Подсказка перечисляет доступные поля расшифровки. + options = ( # Параметры раскладки повторяют возможности общего парсера терминала. + {'id': 'layout', 'desc': 'Layout / раскладка', 'default': tms.DEFAULT_PRESET, 'values': LAYOUTS}, # Готовые раскладки поступают из единого каталога templates. + {'id': 'digit_bytes', 'desc': 'Custom discrete bytes / байт дискретов', 'default': 38}, # В custom задаётся длина дискретной части в байтах. + {'id': 'analog_count', 'desc': 'Custom analog words / слов аналогов', 'default': 34}, # В custom задаётся число знаковых 16-битных аналоговых слов. + {'id': 'crc', 'desc': 'CRC variant', 'default': tms.DEFAULT_CRC, 'values': tuple(tms.CRC_VARIANTS)}, # Неподтверждённые CRC кадры общий парсер не выдаёт. + {'id': 'gap_ms', 'desc': 'Reset after gap (ms, 0 disables)', 'default': 100}, # Пауза 100 мс по умолчанию отделяет незавершённые посылки. + ) # Опции протокола SET здесь не требуются. + + def make_parser(self): # Базовый адаптер создаёт отдельный парсер для каждого направления UART. + return TmsStreamParser(self.options['layout'], self.options['digit_bytes'], self.options['analog_count'], self.options['crc']) # Общая модель проверяет диапазоны и рассчитывает длину кадра. diff --git a/tools/dsview/install_decoders.ps1 b/tools/dsview/install_decoders.ps1 index 8ea6321..79ca608 100644 --- a/tools/dsview/install_decoders.ps1 +++ b/tools/dsview/install_decoders.ps1 @@ -63,7 +63,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $decoderRoot 'common') -PathType Con # An explicit allowlist excludes tests, bytecode and unrelated modules. # The private common/setgui_decoders layout matches the embedded build. $files = @() -foreach ($name in @('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) { +$manifest = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'protocol-files.json') -Encoding UTF8 -Raw | ConvertFrom-Json # Общая ведомость совпадает с рецептом встроенного DSView. +foreach ($name in $manifest.adapters) { # Список включает все поставляемые анализаторы без отдельного перечня в установщике. foreach ($leaf in @('__init__.py', 'pd.py')) { $files += [pscustomobject]@{ Source = Join-Path $PSScriptRoot "decoders/$name/$leaf" @@ -71,12 +72,18 @@ foreach ($name in @('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_ca } } } -foreach ($name in @('__init__', 'gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) { +foreach ($name in $manifest.cores) { # Общие ядра и адаптеры устанавливаются из одной закреплённой версии templates. $files += [pscustomobject]@{ Source = Join-Path $templateRoot "python/logic_analyzer/decoders/$name.py" Relative = "common/setgui_decoders/$name.py" } } +foreach ($name in $manifest.template_files) { # Подключённым парсерам нужны исходные модули и JSON-словари протоколов. + $files += [pscustomobject]@{ # Новые зависимости проходят те же проверки, резервное копирование и откат, что существующие ядра. + Source = Join-Path $templateRoot "python/$name" # Источник всегда находится в общем templates, независимо от текущего каталога. + Relative = "common/setgui_decoders/templates/$name" # Закрытый каталог не создаёт лишних анализаторов в списке sigrok. + } # Исходные пакеты сохраняют собственные относительные импорты и не требуют внешнего Python. +} # Все зависимости включены в транзакцию до первого изменения установленного декодера. # Validate the entire payload and destination before changing any decoder. foreach ($file in $files) { if (-not (Test-Path -LiteralPath $file.Source -PathType Leaf)) { @@ -97,13 +104,31 @@ if ($CheckOnly) { Write-Output "Validated $($files.Count) files for $targetRoot. No files changed." return } -# A mapped/running Windows EXE cannot be opened for writing. Never terminate DSView. -try { - $handle = [IO.File]::Open($exe, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read) - $handle.Dispose() -} catch { - throw "Close DSView and check write permissions for '$targetRoot'. For Program Files, run PowerShell as administrator. $($_.Exception.Message)" -} +# Проверяем запущенный процесс отдельно: установка декодеров не требует права записи в DSView.exe. +foreach ($process in @(Get-Process -Name 'DSView' -ErrorAction SilentlyContinue)) { # Другие экземпляры DSView не мешают обновлению выбранного каталога. + try { # Доступ к пути процесса может быть ограничен правами владельца. + $processPath = $process.MainModule.FileName # Путь определяет принадлежность процесса конкретной установке. + } catch { # Без пути нельзя достоверно исключить работу целевого компонента. + throw 'Cannot inspect a running DSView process. Close DSView before installing decoders.' # Установщик не завершает процесс и не прерывает захват. + } # Ошибка просмотра процесса отделена от отсутствия права копировать файлы. + if ($processPath -and [string]::Equals([IO.Path]::GetFullPath($processPath), $exe, [StringComparison]::OrdinalIgnoreCase)) { # Регистр Windows-пути не влияет на совпадение. + throw "Close DSView before updating decoders in '$targetRoot'." # После закрытия требуется повторный запуск того же установщика. + } # Работа другой установки DSView допускается. +} # Проверка выполняется только при настоящем копировании, после CheckOnly. + +# Пробные файлы проверяют реальные каталоги назначения и резервных копий, не затрагивая EXE. +foreach ($directory in @($targetRoot, $decoderRoot)) { # Резервные копии создаются рядом с EXE, а новые модули — внутри decoders. + $probe = Join-Path $directory ('.set-decoder-write-' + [guid]::NewGuid().ToString('N') + '.tmp') # Уникальное имя исключает перезапись пользовательского файла. + $handle = $null # Дескриптор освобождается и при неудачном создании файла. + try { # Права записи проверяются до резервного копирования и замены первого декодера. + $handle = [IO.File]::Open($probe, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) # Пробный файл проверяет создание в выбранном каталоге. + } catch { # Защищённый Program Files требует повышения прав либо выбора доступной установки. + throw "No write access to '$directory'. Select a writable DSView directory or run PowerShell as administrator for Program Files. $($_.Exception.Message)" # Сообщение называет каталог, а не ошибочно запущенный EXE. + } finally { # Временные проверки не должны оставлять файлы после успешного или неуспешного запуска. + if ($null -ne $handle) { $handle.Dispose() } # Закрываем только дескриптор созданного нами файла. + if ([IO.File]::Exists($probe)) { [IO.File]::Delete($probe) } # Удаляется только уникальный файл текущей проверки, без обхода каталогов. + } # Исходные декодеры на этом этапе ещё не изменены. +} # Дальнейшая транзакция сохраняет существующую политику резервного копирования и отката. # Keep each previous version outside decoder discovery directories. # Retain backups after success for manual recovery, not only after errors. @@ -150,6 +175,6 @@ try { } throw $failure } -Write-Output "Installed: IGBT 1SP/1SD, SET UART, SET CAN, PM35 UART in $decoderRoot" +Write-Output "Installed: $($manifest.adapters -join ', ') in $decoderRoot" # Список формируется по общей ведомости, включая Plates PBus и Plates SBus. if (Test-Path -LiteralPath $backup) { Write-Output "Previous files retained in: $backup" } -Write-Output 'Restart DSView. Add UART/CAN first, then stack the corresponding SET decoder. No external Python is required.' +Write-Output 'Restart DSView. Plates PBus/SBus use logic channels directly; SET UART/CAN decoders stack over UART/CAN. No external Python is required.' # Подключение шинных анализаторов отличается от надстроек над UART/CAN. diff --git a/tools/dsview/protocol-files.json b/tools/dsview/protocol-files.json new file mode 100644 index 0000000..975df95 --- /dev/null +++ b/tools/dsview/protocol-files.json @@ -0,0 +1,17 @@ +{ + "adapters": ["gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart", "tms_uart", "plates_pbus", "plates_sbus"], + "cores": ["__init__", "gate_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart", "template_protocols", "tms_uart"], + "template_files": [ + "protocan/__init__.py", + "protocan/native.py", + "protocan/protocan.py", + "protocan/balsam.py", + "protocan/legacycan.py", + "protocan/gas_167.py", + "protocan/gas_167_registers.json", + "set_devices/__init__.py", + "set_devices/deif_xdi.py", + "set_devices/ump_can.py", + "set_devices/tms_terminal.py" + ] +} diff --git a/tools/dsview/setgui-host.patch b/tools/dsview/setgui-host.patch index 8470419..91e5dd8 100644 --- a/tools/dsview/setgui-host.patch +++ b/tools/dsview/setgui-host.patch @@ -597,8 +597,8 @@ index 648eb5b8..1a023707 100644 + const auto after = _protocol_lay_items.size(); + ok = phase % 2 == 0 ? after == before : after == (phase == 1 ? before - 1 : 0); + } -+ } else if (phase < 8) { -+ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart"})[phase - 4]; ++ } else if (phase < 9) { // Проверяем также новый парсер терминальных UART-посылок ТМС. ++ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart", "tms_uart"})[phase - 4]; // Индекс отсчитывается после четырёх проверок удаления анализаторов. + if (get_protocol_index_by_id(id) < 0) ok = false; + else { + QTimer::singleShot(80, this, [report, id]() { @@ -618,7 +618,7 @@ index 648eb5b8..1a023707 100644 + ok = QString(trace->decoder()->stack().back()->decoder()->id) == id; + } + } -+ } else if (phase == 8) { ++ } else if (phase == 9) { // После пяти прикладных анализаторов проверяем удаление всего набора дорожек. + answer(QMessageBox::Yes); + on_del_all_protocol(); + ok = _protocol_lay_items.empty() && _session->get_decode_signals().empty(); diff --git a/tools/dsview/tests/test_decoders.py b/tools/dsview/tests/test_decoders.py index eae67b4..abcff6f 100644 --- a/tools/dsview/tests/test_decoders.py +++ b/tools/dsview/tests/test_decoders.py @@ -58,6 +58,9 @@ try: gate = importlib.import_module('gate_driver_timing.pd') pair_decoder = importlib.import_module('transistor_pair.pd') pmcore = importlib.import_module('common.setgui_decoders.pm35_uart') + templates_core = importlib.import_module('common.setgui_decoders.template_protocols') # Новый прикладной слой импортируется из изолированного runtime. + tms_decoder = importlib.import_module('tms_uart.pd') # Проверяет контракт точки входа нового UART-анализатора. + tms_core = importlib.import_module('common.setgui_decoders.tms_uart') # Потоковый адаптер использует поставленные общие раскладки терминала. finally: sys.path.pop(0) if previous is None: @@ -225,7 +228,7 @@ class CanTests(unittest.TestCase): cancore.legacy(0x1b530010, b'\x00') def test_balsam_mask_and_endianness(self): - result = cancore.legacy(0xba0010, bytes.fromhex('a01012340000ffff'), 'balsam') + result = cancore.legacy(0xba0010, bytes.fromhex('0000ffffa0101234'), 'balsam') # Реальная прошивка передаёт value2, value3, header, value1; отмечены первое и третье слова. self.assertEqual(result['registers'], [(16, 0x1234), (18, 0xffff)]) self.assertIsNone(cancore.legacy(0xba000f, bytes(8), 'balsam')) @@ -241,6 +244,83 @@ class CanTests(unittest.TestCase): self.assertIn('status=0', cancore.legacy(0x1b5c0702, bytes.fromhex('00ff000000000000'))['summary']) +class TemplateProtocolTests(unittest.TestCase): # Сверяет прикладные события DSView с действующими парсерами templates. + def test_every_legacy_project_uses_its_wire_layout(self): # Все проекты проверяются настоящими кодировщиками обоих исторических форматов. + for project in templates_core.legacycan.PROJECTS: # Каталог включает Буксир, СЭДБМ, Ледокол, Бальзам, 23550, Янтарь и варианты формата. + with self.subTest(project=project.name): # Ошибка сообщает конкретный несовместимый профиль. + node = project.nodes[0] # Достаточно одного узла для проверки раскладки выбранного проекта. + packet = templates_core.legacycan.encode_write(project.format, node.rx_id, 16, (0x1234, 0xffff)) # Различающиеся байты выявляют ошибку порядка слов и байтов. + result = templates_core.decode_can(packet.can_id, packet.data, 'legacy', project=project.name) # Разбор выполняется тем же путём, что у адаптера sigrok. + self.assertEqual(result['registers'], [(16, 0x1234), (17, 0xffff)]) # Оба wire-формата должны восстанавливать одинаковые адреса и значения. + self.assertIn(node.name, result['summary']) # Вывод включает имя устройства из актуального каталога проекта. + + def test_balsam_real_wire_order_and_pult(self): # Регрессия неправильного положения адресного слова в прежнем адаптере. + for ident in (0xba0004, 0xba0014, 0x745009, 0x745019): # Проверяются УМП в обоих направлениях и отдельная пара ID пульта. + result = templates_core.decode_can(ident, bytes.fromhex('11223344e07f8000'), 'balsam') # Маска 111 в header=E07F отмечает три слова начиная с командного регистра 127. + self.assertEqual(result['registers'], [(127, 0x8000), (128, 0x1122), (129, 0x3344)]) # В wire сначала идут второе и третье слова, а первое находится в конце. + self.assertIsNone(templates_core.decode_can(0xba000f, bytes(8), 'balsam')) # Отсутствующий узел не выдаётся за сообщение Balsam. + + def test_ump_maps_keep_nodes_and_assigned_ids_separate(self): # Локальная карта 167/v1 и новые глобальные ID не должны смешиваться. + local = templates_core.decode_can(0x1b53007f, b'\x80\x00', 'ump-protocan', ump_node=2) # Бит 7 командного регистра означает пуск УМП в локальной карте. + self.assertIn('УМП 2', local['summary']) # Локальный адрес не теряет выбранный вручную номер устройства. + self.assertIn('cUMPstart', local['summary']) # Имена команд берутся из действующей карты, а не остаются сырым HEX. + assigned = templates_core.decode_can(0x1b530000 | 6000, b'\x01\x00', 'ump-protocan', ump_map='assigned') # Десятичный GAS ID 6000 относится к назначенной карте второго УМП. + self.assertIn('sens_error[0]', assigned['summary']) # Параметр получает имя из JSON-словаря templates. + + def test_deif_standard_can_and_physical_value(self): # DEIF должен получать STD-кадры, которые прежний SET CAN отбрасывал. + decoder = instance(can, protocol='deif') # Выбор DEIF явно разрешает стандартный CANopen ID. + raw = bytes.fromhex('010130027b000000') # Объект 0x3001:02 содержит RAW=123, соответствующий углу 12.3 градуса. + decoder.decode(10, 90, ('standard', 0x201, 'data', len(raw), raw)) # Вектор проходит реальную границу адаптера sigrok. + result = frames(decoder)[0] # Результат доступен и в Python-выходе, и в аннотациях. + self.assertIn('12.3', result['summary']) # Масштаб задаёт существующий DEIF-парсер. + self.assertTrue(any(item[2] == 0 and item[3][0] == 3 for item in decoder.emitted)) # Проверяет фактическое появление подробных полей на новой дорожке. + + def test_can_auto_routes_set_v2_and_balsam(self): # Авто ограничено проверяемыми сигнатурами и не угадывает исторические проекты. + decoder = instance(can, protocol='auto') # Профиль использует прежний сборщик SET v2 и общий парсер Balsam. + for index, payload in enumerate(CanTests().segments()): # Документированный SET PING разбивается штатным тестовым транспортом. + decoder.decode(index * 1000, index * 1000 + 900, ('extended', 0x122a0000, 'data', len(payload), payload)) # Номер отсчёта обеспечивает проверяемый тайм-аут сборки. + self.assertEqual(frames(decoder)[-1]['raw'], PING) # Авто должен вернуть исходный CRC-подтверждённый SET v2 пакет. + raw = bytes.fromhex('0000000080101234') # Заголовок отмечает первое слово по адресу 16, находящееся в конце CAN-данных. + decoder.decode(4000, 4900, ('extended', 0xba0014, 'data', 8, raw)) # ID относится к ответу первого УМП Balsam 167. + self.assertEqual(frames(decoder)[-1]['registers'], [(16, 0x1234)]) # Авто не интерпретирует Balsam как Boot Data ProtoCAN. + + def test_uart_bridge_uses_selected_can_profile(self): # Проверяет перенос общего CAN-разбора через UART-транспорт AA55. + for profile, ident, flags, payload in (('balsam', 0xba0014, 1, bytes.fromhex('0000000080101234')), ('deif', 0x201, 0, bytes.fromhex('010130027b000000'))): # Векторы охватывают EXT Balsam и STD DEIF с разным значением IDE. + decoder = instance(uart, can_protocol=profile) # Выбор профиля моста не меняет основной потоковый формат SET UART. + body = bytes([6 + len(payload), 1, flags]) + struct.pack('= origin + edge # Таймер может совпасть с фронтом в той же точке. + else: # Цифровой канал имеет уровень 0 или 1. + before, after = self.samples[sample - 1][channel], self.samples[sample][channel] # Сравниваются соседние отсчёты, а не соседние пробуждения. + valid = valid and before != after and (edge == 'e' or (edge == 'r' and after == 1) or (edge == 'f' and after == 0)) # Разрешены оба фронта или отдельное направление. + if valid: # Несколько альтернатив могут совпасть одновременно. + matched |= 1 << index # DSView представляет matched целочисленной маской. + if matched: # Возвращаем ближайшее подходящее событие, не пропуская таймеры. + self.samplenum, self.matched = sample, matched # Атрибуты обновляются до возврата уровней. + return self.samples[sample] # Декодер видит уровни именно в точке события. + raise EndOfCapture() # Отсутствие событий до конца захвата завершает поток. + + +# Импорт не должен оставлять ложный sigrokdecode в окружении остальных тестов проекта. +def load_decoders(): # Возвращает модули для доступа к классам и проверяемому CRC. + root = Path(__file__).resolve().parents[1] / 'decoders' # Оба независимых пакета лежат в соседней папке decoders. + stub = types.ModuleType('sigrokdecode') # Создаём подмену только для импорта зависимых классов. + stub.Decoder, stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = Runtime, 0, 1, 2 # Значения констант согласованы с Runtime. + original = sys.modules.get('sigrokdecode') # Ранее импортированный настоящий модуль нужно сохранить. + sys.modules['sigrokdecode'] = stub # Классы декодеров получают тестовую реализацию базового API. + sys.path.insert(0, str(root)) # Пакеты импортируются без копирования исходников. + try: # Восстановление окружения обязательно и при ошибке импорта. + return importlib.import_module('plates_sbus.pd'), importlib.import_module('plates_pbus.pd') # Проверяется и экспорт из __init__.py. + finally: # Другие тесты не должны зависеть от нашей подмены. + sys.path.remove(str(root)) # Убираем только путь, добавленный этим импортом. + if original is None: # При отсутствии настоящего модуля восстанавливаем его отсутствие. + del sys.modules['sigrokdecode'] # Ссылки внутри декодеров уже удерживают тестовую среду. + else: # В окружении с sigrokdecode возвращаем исходный объект. + sys.modules['sigrokdecode'] = original # Чужие импорты продолжают работать штатно. + + +SBUS, PBUS = load_decoders() # Все проверки исполняют рабочие файлы, а не копии алгоритмов. + + +# Трасса хранит уровни на каждом отсчёте, поэтому среда независимо обнаруживает реальные фронты. +class Trace: # Частота тестовых трасс SBus равна 100 МГц: один отсчёт — 10 нс. + def __init__(self, levels): # Начальные уровни задаются до первого сохранённого отсчёта. + self.levels, self.samples = list(levels), [] # Текущие уровни отделены от уже записанного захвата. + + def hold(self, count, updates=None): # Удерживаем сигналы count отсчётов после применения изменений. + for channel, level in (updates or {}).items(): # Незаданные сигналы сохраняют прежнее состояние. + self.levels[channel] = level # Канал индексируется в порядке объявления декодера. + self.samples.extend([tuple(self.levels)] * count) # Кортежи исключают случайное изменение истории при следующем фронте. + + +# Кадры задаются фиксированными полями и CRC, без обращения к реализации расчёта в декодере. +def serial_frame(trace, direction=1, address=0x32, value=0x1234, crc=1, ack=0, limit=29, tail=1000, tail_clock=0): # Полный формат содержит 29 нарастающих фронтов. + frame = (direction << 28) | (address << 20) | (value << 4) | crc # Временная разметка независима от внутреннего буфера декодера. + for index, shift in enumerate(range(28, 28 - limit, -1)): # limit позволяет оборвать кадр на любом поле. + bit = (frame >> shift) & 1 # Каждый бит передаётся старшим вперёд. + tx, rx = (bit, 1) if index < 9 or direction else (1, bit) # При чтении TX заполнен единицами, чтобы обнаружить выбор неверной линии. + trace.hold(14, {0: 0, 1: tx, 2: rx}) # Ведомое устанавливает ответ на спаде CLK за 140 нс до выборки. + trace.hold(14, {0: 1}) # Полупериод 140 нс совпадает с существующим HDL-тестом. + if tail: # Отсутствие хвоста моделирует конец файла непосредственно после последнего бита. + trace.hold(tail, {0: tail_clock, 2: ack if direction else trace.levels[2]}) # ACK появляется после последнего CRC, а затем CLK может оставаться неподвижным. + + +# Формирование PBus не использует состояния декодера: задаётся только последовательность физических уровней. +def parallel_frame(trace, address=0x32, value=0x1234, inverse=None, bad_address=False, phases=3, clock_ok=True, simultaneous=False): # phases ограничивает число полученных подтверждений. + word = (address << 8) | ((address ^ 255) ^ int(bad_address)) # Адрес защищён побитовой инверсией младшего байта. + data = {2 + bit: (word >> bit) & 1 for bit in range(16)} # D0…D15 записываются на отдельные физические каналы. + trace.hold(10, data) # Адрес устанавливается до разрешения CE. + trace.hold(10, {0: 0, 1: 0 if simultaneous and phases else 1, 18: 1}) # Возможен одновременный спад CE и адресного ACK в одном отсчёте. + if phases: # Адресный ACK опускается только при наличии ответа. + trace.hold(10, {1: 0}) # Первый низкий ACK подтверждает приём адреса. + if phases >= 2: # Прямые данные передаются только после адресного подтверждения. + trace.hold(10, {2 + bit: (value >> bit) & 1 for bit in range(16)}) # Данные становятся стабильны заранее. + trace.hold(10, {1: 1, 18: 0 if clock_ok else 1}) # Высокий ACK подтверждает прямые данные при низком CLK. + if phases >= 3: # Полное чтение должно получить независимое инверсное слово. + inverse = (value ^ 65535) if inverse is None else inverse # Аргумент позволяет повредить контрольную копию. + trace.hold(10, {2 + bit: (inverse >> bit) & 1 for bit in range(16)}) # Инверсия устанавливается до своего ACK. + trace.hold(10, {1: 0, 18: 1 if clock_ok else 0}) # Последний низкий ACK отмечает готовность контрольного слова. + trace.hold(10, {0: 1, 1: 1}) # CE возвращается в неактивное состояние, ведомое отпускает ACK. + + +# Настройка окружения одинакова для всех трасс и не зависит от алгоритма протокола. +def run_decoder(module, trace, options=None, connected=None, samplerate=100000000): # Временная шкала по умолчанию соответствует 10 нс на отсчёт. + decoder = module.Decoder() # Исполняется штатный конструктор и reset(). + decoder.samples, decoder.result, decoder.samplenum = trace.samples, [], 0 # Состояние тестовой среды хранится отдельно от автомата шины. + decoder.connected = set(range(len(trace.levels))) if connected is None else set(connected) # При необходимости можно отключать отдельные входы. + decoder.options = {item['id']: item['default'] for item in getattr(decoder, 'options', ())} # Среда применяет объявленные значения по умолчанию. + decoder.options.update(options or {}) # Тест может изменить интервалы выборки. + decoder.start() # Выходы должны быть зарегистрированы до первой аннотации. + if samplerate is not None and hasattr(decoder, 'metadata'): # PBus не требует частоту для выборки по ACK. + decoder.metadata(2, samplerate) # Константа 2 соответствует SRD_CONF_SAMPLERATE заглушки. + try: # Только штатный конец захвата перехватывается как успешное завершение. + decoder.decode() # Проверяется настоящий цикл wait(), а не отдельно вызванные внутренние методы. + except EndOfCapture: # Любое другое исключение делает тест ошибочным. + decoder.last_samplenum = len(trace.samples) - 1 # Последний отсчёт нужен для корректного end(). + decoder.end() # Незавершённые операции должны быть выведены при конце файла. + packets = [data[1] for _, _, output, data in decoder.result if output == 1] # Извлекаем структурированные FRAME-пакеты. + warnings = [data[1][0] for _, _, output, data in decoder.result if output == 0 and data[0] == len(decoder.annotations) - 1] # Последний класс обоих декодеров содержит предупреждения. + return packets, warnings # Проверяем одновременно данные операций и явную диагностику. + + +# Набор проверяет содержимое кадров, повторную синхронизацию, границы и настройку каналов. +class DecoderTests(unittest.TestCase): # unittest позволяет запускать тесты без GUI и библиотек DSView. + def serial(self): # Готовим заведомо достаточную паузу перед первым запросом. + trace = Trace((0, 1, 1)) # Мастер держит низкий CLK, обе линии данных отпущены в единицу. + trace.hold(1000) # Десять микросекунд превышают 3,6 мкс reset в HDL. + return trace # Дальнейшие фронты добавляются конкретным тестом. + + def parallel(self): # PBus стартует в неактивной фазе CE. + trace = Trace((1, 1) + (0,) * 16 + (1,)) # CE и ACK высокие, D0…D15 нулевые, CLK высокий. + trace.hold(10) # Первый wait() увидит исходные уровни до спада CE. + return trace # Физический запрос добавляется независимо от декодера. + + def test_write_and_delayed_ack_without_more_clocks(self): # Проверяем отдельный таймер ACK после последнего такта. + trace = self.serial() # Начальная пауза обеспечивает синхронизацию. + serial_frame(trace, tail_clock=1) # После CRC CLK остаётся высоким без новых фронтов. + packets, warnings = run_decoder(SBUS, trace) # Декодируется весь цифровой поток. + self.assertEqual((packets[0]['dev'], packets[0]['reg'], packets[0]['value']), (3, 2, 0x1234)) # Поля должны совпасть с запросом. + self.assertTrue(packets[0]['crc_ok'] and packets[0]['ack']) # Хороший CRC и низкий ACK проверяются независимо. + self.assertEqual(warnings, []) # Полный корректный кадр не содержит предупреждений. + + def test_read_uses_response_line(self): # TX после заголовка специально не содержит полезных данных чтения. + trace = self.serial() # Пауза позволяет принять первый бит направления. + serial_frame(trace, direction=0, value=0xCAFE, crc=7) # Контрольный вектор ответа отличается от всех единиц на TX. + packets, warnings = run_decoder(SBUS, trace) # Читаем по нарастающим фронтам после установки RX на спаде. + self.assertEqual((packets[0]['operation'], packets[0]['value'], packets[0]['ack']), ('read', 0xCAFE, None)) # У чтения нет ACK записи. + self.assertTrue(packets[0]['crc_ok']) # Контрольная сумма включает заголовок с TX и данные с RX. + self.assertEqual(warnings, []) # Правильное переключение линий не создаёт ошибок. + + def test_repeated_writes_and_both_idle_levels(self): # Повтор одинакового значения остаётся отдельной командой. + trace = self.serial() # Первый кадр начинается после начальной паузы. + serial_frame(trace, tail_clock=1) # Первая межкадровая пауза держит CLK высоким. + serial_frame(trace, tail_clock=0) # Вторая пауза держит CLK низким. + packets, warnings = run_decoder(SBUS, trace) # Оба уровня паузы должны разрешать синхронизацию. + self.assertEqual([packet['value'] for packet in packets], [0x1234, 0x1234]) # Никакой дедупликации по значению быть не должно. + self.assertEqual(warnings, []) # Повторная команда и высокий idle являются допустимыми. + + def test_crc_error_does_not_hide_ack(self): # ACK не заменяет проверку целостности принятого кадра. + trace = self.serial() # Создаём синхронизированный поток. + serial_frame(trace, crc=0, ack=0) # Один бит CRC повреждён, но ответ линии низкий. + packets, warnings = run_decoder(SBUS, trace) # Кадр должен остаться доступным для диагностики. + self.assertFalse(packets[0]['crc_ok']) # Расчёт CRC обнаруживает повреждение. + self.assertTrue(packets[0]['ack']) # Наблюдавшийся ACK сохраняется независимо от CRC. + self.assertIn('CRC mismatch', warnings) # Ошибка присутствует в отдельной строке. + + def test_high_ack_is_not_success(self): # Высокий RX может означать отказ или отсутствие ведомого. + trace = self.serial() # Подготавливаем запрос с правильным CRC. + serial_frame(trace, ack=1) # Плата не подтверждает запись низким уровнем. + packets, warnings = run_decoder(SBUS, trace) # Разбор не должен путать CRC и успешность ответа. + self.assertTrue(packets[0]['crc_ok']) # Данные на запросе не повреждены. + self.assertFalse(packets[0]['ack']) # Высокий ответ не считается подтверждением. + self.assertIn('No low write ACK', warnings) # Причина обмена отмечается явно без предположений о плате. + + def test_partial_frame_resynchronizes_after_gap(self): # Потерянные биты не должны сдвигать все последующие кадры. + trace = self.serial() # Создаём начало корректно синхронизированного кадра. + serial_frame(trace, limit=12) # Обрываем кадр после заголовка и части данных. + serial_frame(trace) # Следующий кадр начинается после достаточной паузы. + packets, warnings = run_decoder(SBUS, trace) # Автомат должен восстановиться независимо от остатка первого запроса. + self.assertEqual(len(packets), 1) # Частичный кадр не выдаётся за полную команду. + self.assertEqual(packets[0]['value'], 0x1234) # Следующий кадр читается без смещения. + self.assertTrue(any('12/29' in text for text in warnings)) # Обрыв первого кадра остаётся виден. + + def test_capture_starting_mid_frame_waits_for_gap(self): # В начале файла может отсутствовать заголовок. + trace = Trace((0, 1, 1)) # Предварительной паузы здесь намеренно нет. + trace.hold(1) # Захват начинается непосредственно перед неизвестным тактом. + serial_frame(trace, limit=15) # Эти биты нельзя надёжно считать первым заголовком. + serial_frame(trace) # После паузы можно декодировать полную операцию. + packets, warnings = run_decoder(SBUS, trace) # Начальный фрагмент игнорируется до синхронизации. + self.assertEqual(len(packets), 1) # Ложный первый кадр не создаётся. + self.assertEqual(warnings, []) # Непроверяемый начальный фрагмент не объявляется ошибкой протокола. + + def test_capture_ends_in_data(self): # end() должен диагностировать обрыв без следующего события. + trace = self.serial() # Первый кадр начнётся после паузы. + serial_frame(trace, limit=20, tail=0) # Захват заканчивается внутри 16-битных данных. + packets, warnings = run_decoder(SBUS, trace) # Единственный способ закрыть фрагмент — end(). + self.assertEqual(packets, []) # Частичное значение не публикуется как полное. + self.assertTrue(any('20/29' in text for text in warnings)) # Сообщение содержит фактическое количество наблюдавшихся битов. + + def test_capture_ends_before_ack_sampling(self): # Нельзя считать неизвестный ACK ни положительным, ни отрицательным. + trace = self.serial() # Кадр полностью помещается после паузы. + serial_frame(trace, tail=0) # Конец захвата наступает раньше таймера ACK. + packets, warnings = run_decoder(SBUS, trace) # end() сохраняет полный запрос. + self.assertIsNone(packets[0]['ack']) # Подтверждение осталось ненаблюдавшимся. + self.assertIn('Write ACK not sampled: capture ended', warnings) # Причина неизвестности указана явно. + + def test_serial_bad_configuration(self): # Ошибки настройки должны выявляться до разбора потока. + for options in ({'ack_us': 0}, {'ack_us': 4}, {'idle_us': float('nan')}): # Проверяем нулевой, чрезмерный и нечисловой интервалы. + with self.subTest(options=options), self.assertRaises(ValueError): # Каждый неверный набор обязан быть отвергнут. + run_decoder(SBUS, self.serial(), options=options) # Не требуется наличие команды на входе. + with self.assertRaises(ValueError): # Отсутствие RX нельзя подменять единицей либо нулём. + run_decoder(SBUS, self.serial(), connected=(0, 1)) # Чтение и ACK требуют физического ответного канала. + with self.assertRaises(ValueError): # Без частоты захвата микросекунды не переводятся в отсчёты. + run_decoder(SBUS, self.serial(), samplerate=None) # Произвольные предполагаемые частоты недопустимы. + with self.assertRaises(ValueError): # Слишком редкие отсчёты сливают ACK и межкадровую паузу. + run_decoder(SBUS, self.serial(), samplerate=100000) # При 100 кГц один отсчёт равен 10 мкс. + + def test_crc_fixed_vectors(self): # Дополнительные контрольные значения охватывают направления и крайние адреса. + for direction, address, value, expected in ((1, 0, 0, 1), (0, 0, 0, 12), (1, 255, 65535, 1), (0, 255, 65535, 12), (0, 0x32, 0xCAFE, 7), (1, 0x57, 0, 7)): # Векторы фиксированы и не вычисляются генератором трассы. + with self.subTest(direction=direction, address=address, value=value): # Ошибка укажет конкретный вектор. + self.assertEqual(SBUS.frame_crc(direction, address, value), expected) # Проверяем в том числе четыре завершающие единицы HDL. + + def test_parallel_full_words_and_optional_clock(self): # Проверяем края диапазонов и сохранение старшего разряда данных. + for address, value in ((0, 0), (255, 65535), (0x32, 0x1234), (0x8F, 0x8001)): # Адрес и данные не должны превращаться в знаковые числа. + for connected in (range(18), range(19)): # PBCLK может отсутствовать либо участвовать в проверке фаз. + with self.subTest(address=address, value=value, clock=len(connected) == 19): # Каждый вариант проверяется независимо. + trace = self.parallel() # Готовим неактивные исходные уровни. + parallel_frame(trace, address=address, value=value) # На провод подаётся полная последовательность ACK. + packets, warnings = run_decoder(PBUS, trace, connected=connected) # Выборка идёт по реальным переходам ACK. + self.assertEqual((packets[0]['dev'], packets[0]['reg'], packets[0]['value']), (address >> 4, address & 15, value)) # Разделение адреса должно сохранять все биты. + self.assertTrue(packets[0]['ok'] and packets[0]['complete']) # Успешность требует полноты и правильной защиты. + self.assertEqual(warnings, []) # Корректный обмен не должен вызывать ложные ошибки. + + def test_parallel_bad_address_with_and_without_ack(self): # Повреждение адреса видно и при молчании ведомого. + for phases in (0, 3): # Проверяем отсутствие ответа и ошибочно подтверждённый адрес. + trace = self.parallel() # Начинаем отдельный захват для каждого случая. + parallel_frame(trace, bad_address=True, phases=phases) # Инвертируем один бит защитной копии адреса. + packets, warnings = run_decoder(PBUS, trace) # Декодер сохраняет наблюдавшийся запрос. + self.assertFalse(packets[0]['address_ok'] or packets[0]['ok']) # Хорошие данные ответа не исправляют неверный запрос. + self.assertIn('Address complement mismatch', warnings) # Ошибка защиты адреса должна остаться явной. + + def test_parallel_bad_inverse(self): # Последний ACK сам по себе не доказывает целостности данных. + trace = self.parallel() # Подготавливаем обычный адрес и прямые данные. + parallel_frame(trace, inverse=0) # Контрольное слово намеренно не равно инверсии 0x1234. + packets, warnings = run_decoder(PBUS, trace) # Все фазы обмена всё же присутствуют. + self.assertTrue(packets[0]['complete']) # Полнота и правильность являются разными свойствами. + self.assertFalse(packets[0]['inverse_ok'] or packets[0]['ok']) # Повреждённая копия делает обмен ошибочным. + self.assertIn('Data complement mismatch', warnings) # Есть понятная причина ошибки. + + def test_parallel_missing_each_ack(self): # Место обрыва различается по ожидаемой фазе. + for phases, missing in ((0, 'address'), (1, 'data'), (2, 'inverse')): # Проверяем все три точки ожидания. + with self.subTest(phases=phases): # Диагностика должна назвать нужную фазу. + trace = self.parallel() # Каждый запрос имеет свой неактивный префикс. + parallel_frame(trace, phases=phases) # CE снимается до завершения операции. + packets, warnings = run_decoder(PBUS, trace) # Закрытие по CE обязано выдать частичный результат. + self.assertFalse(packets[0]['complete'] or packets[0]['ok']) # Неполная операция никогда не успешна. + self.assertIn('Incomplete PBus: missing %s ACK' % missing, warnings) # Указана конкретная отсутствующая фаза. + + def test_parallel_capture_ends_before_inverse(self): # Отсутствие возврата CE не должно терять частичный запрос. + trace = self.parallel() # Создаём начало запроса и два подтверждения. + parallel_frame(trace, phases=2) # Генератор завершает CE, но ниже этот хвост удаляется. + trace.samples = trace.samples[:-10] # Обрываем файл при ещё активном CE. + packets, warnings = run_decoder(PBUS, trace) # end() должен закрыть ожидаемую инверсию. + self.assertEqual(packets[0]['value'], 0x1234) # Наблюдавшиеся прямые данные сохраняются. + self.assertIn('Incomplete PBus: missing inverse ACK', warnings) # Отсутствие последнего подтверждения остаётся явным. + + def test_parallel_phase_checks_only_with_clock(self): # Необязательный CLK усиливает диагностику, не меняя данные. + trace = self.parallel() # Одинаковые уровни используются в двух конфигурациях каналов. + parallel_frame(trace, clock_ok=False) # ACK данных и инверсии приходят при неверных уровнях CLK. + packets, warnings = run_decoder(PBUS, trace) # Подключённый CLK должен выявить обе ошибки. + self.assertFalse(packets[0]['ok']) # Данные правильные, но протокол фаз нарушен. + self.assertEqual(len(warnings), 2) # Ошибки прямой и инверсной фазы независимы. + packets, warnings = run_decoder(PBUS, trace, connected=range(18)) # Без захвата CLK его уровень не интерпретируется. + self.assertTrue(packets[0]['ok']) # Доступные проверки CE, ACK и содержимого проходят. + self.assertEqual(warnings, []) # Нельзя жаловаться на незахваченный сигнал. + + def test_parallel_repeats_and_simultaneous_enable_ack(self): # Фронты CE и ACK могут попасть в один отсчёт анализатора. + trace = self.parallel() # До первого запроса видны исходные высокие CE и ACK. + parallel_frame(trace, simultaneous=True) # Адресный ACK совпадает со спадом CE. + parallel_frame(trace) # Повтор того же чтения обязан оставаться отдельной операцией. + packets, warnings = run_decoder(PBUS, trace) # Оба способа начала проходят один автомат. + self.assertEqual(len(packets), 2) # Ни один запрос не теряется и не дублируется. + self.assertTrue(all(packet['ok'] for packet in packets)) # Оба кадра содержат полные корректные проверки. + self.assertEqual(warnings, []) # Совпадение фронтов само по себе не ошибка. + + def test_parallel_start_in_active_transaction(self): # Начальная активная CE не доказывает наличие полного адреса. + trace = self.parallel() # Сначала строим полный физический обмен. + parallel_frame(trace) # Часть начала будет удалена из файла захвата. + trace.samples = trace.samples[45:] # Первый видимый отсчёт находится внутри активной операции. + parallel_frame(trace) # Следующий полный запрос должен декодироваться нормально. + packets, warnings = run_decoder(PBUS, trace) # Начальный фрагмент игнорируется до следующего спада CE. + self.assertEqual(len(packets), 1) # Частичное начало не порождает ложной операции. + self.assertEqual(warnings, []) # Нельзя диагностировать отсутствующий в файле адрес. + + def test_parallel_requires_every_data_bit(self): # Один незахваченный разряд делает 16-битное слово недостоверным. + with self.assertRaises(ValueError): # Декодер обязан отвергнуть неполное назначение каналов. + run_decoder(PBUS, self.parallel(), connected=range(17)) # Здесь отсутствует обязательный D15. + + def test_python36_syntax_and_line_comments(self): # Стиль проекта требует комментарий к каждой строке нового кода. + for path in Path(__file__).resolve().parents[1].glob('decoders/plates_*/*.py'): # Проверяются устанавливаемые Python-файлы обеих шин, без чужих декодеров при переносе в SETGUI. + source = path.read_text(encoding='utf-8') # Некорректная кодировка должна сразу приводить к ошибке. + ast.parse(source, filename=str(path), feature_version=(3, 6)) # DSView использует Python 3.6, поэтому более новый синтаксис запрещён. + for number, line in enumerate(source.splitlines(), 1): # Номер строки помогает исправить нарушение стиля. + if line.strip() and not line.lstrip().startswith('#'): # Пустые строки и отдельные комментарии не содержат исполняемый код. + self.assertIn('#', line, '%s:%d' % (path, number)) # Каждая строка кода сопровождается пояснением. + + +if __name__ == '__main__': # Разрешаем запуск как файла и через unittest discovery. + unittest.main() # Стандартный запуск выдаёт количество проверок и подробности ошибок. diff --git a/tools/dsview/tests/test_runtime.py b/tools/dsview/tests/test_runtime.py index 6cc908a..f5c0221 100644 --- a/tools/dsview/tests/test_runtime.py +++ b/tools/dsview/tests/test_runtime.py @@ -43,14 +43,13 @@ class RuntimeTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) recipe = root / 'tools/dsview' - core = root / 'python/logic_analyzer/decoders' - core.mkdir(parents=True) - (recipe / 'decoders/gate_driver_timing').mkdir(parents=True) - (recipe / 'native').mkdir() + recipe.mkdir(parents=True) # Временный рецепт воспроизводит относительную структуру закреплённого templates. + (recipe / 'protocol-files.json').write_bytes(RECIPE.with_name('protocol-files.json').read_bytes()) # Реальная ведомость гарантирует включение новых зависимостей и карт. files = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h', recipe / 'decoders/gate_driver_timing/pd.py'] - files += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')] + files += [root / item.relative_to(RECIPE.parents[2]) for item, _ in BUILD.protocol_payload()] # Проверка охватывает все канонические модули и JSON-карты, перечисленные в поставке. for item in files: + item.parent.mkdir(parents=True, exist_ok=True) # Для каждого переносимого пакета создаются вложенные каталоги. item.write_text('initial') with patch.object(BUILD, '__file__', str(recipe / 'build.py')): initial = BUILD.recipe_digest() @@ -60,3 +59,6 @@ class RuntimeTests(unittest.TestCase): self.assertNotEqual(initial, BUILD.recipe_digest()) item.write_text('initial') self.assertEqual(initial, BUILD.recipe_digest()) + with (recipe / 'protocol-files.json').open('a', encoding='utf-8') as output: # Пробел меняет хеш, сохраняя корректность JSON-ведомости. + output.write('\n') # Изменение самого состава поставки также должно требовать обновления runtime. + self.assertNotEqual(initial, BUILD.recipe_digest()) # Устаревшая ведомость не допускается к упаковке незаметно.