From a5e46d46275782bdd931f97a735ece69526f12c4 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 17 Oct 2026 14:36:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=88=D0=B8=D0=BD=D0=B0=D0=BC,=20CAN-=D0=B0=D0=B4=D0=B0=D0=BF?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D1=8B=20=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов. --- c/candle/CMakeLists.txt | 2 +- c/candle/candle.c | 153 +++- c/candle/candle.def | 12 + c/candle/candle.h | 7 + c/candle/candle_ctrl_req.c | 25 +- c/candle/candle_ctrl_req.h | 2 + c/set-protocol/README.md | 10 + .../setprotocol/balsam/BalsamCanProtocol.kt | 7 +- c/set-protocol/src/balsam_can.c | 27 +- c/set-protocol/src/set_plot.c | 11 +- c/set-protocol/tests/fixtures/plot-v1.json | 2 +- python/README.md | 7 + python/examples/console_examples.py | 46 ++ python/logic_analyzer/decoders/set_can.py | 15 +- .../decoders/template_protocols.py | 63 ++ python/logic_analyzer/decoders/tms_uart.py | 29 + python/protocan/balsam.py | 55 +- python/pysetterminal/README.md | 116 +++ python/pysetterminal/bus_operations.py | 113 +++ python/pysetterminal/exchange_queue.py | 144 ++++ python/pysetterminal/mainterm.py | 2 +- python/pysetterminal/panel.py | 76 +- python/pysetterminal/pbus_panel.py | 346 +++++++++ python/pysetterminal/rawprotocol.py | 89 +-- python/pysetterminal/serialbus.py | 685 +++++++++++------- python/set_devices/background_work.py | 29 + python/set_devices/balsam_can.py | 10 +- python/set_devices/can_adapter_settings.py | 147 ++++ python/set_devices/can_monitor.py | 57 +- python/set_devices/oscilloscope.py | 39 +- python/set_devices/plot_decimation.py | 81 ++- python/set_devices/plot_envelope_index.py | 174 +++++ python/set_devices/plot_processing.py | 7 +- python/set_devices/plot_ticks.py | 10 + python/set_devices/qt_ports/CONSOLE.md | 68 ++ .../set_devices/qt_ports/can_frame_builder.py | 175 +++++ python/set_devices/qt_ports/can_monitor.py | 191 +++-- python/set_devices/qt_ports/candle_adapter.py | 191 ++++- python/set_devices/qt_ports/console.py | 180 +++++ python/set_devices/qt_ports/plot_drag.py | 6 +- python/set_devices/qt_ports/plot_dual_axis.py | 33 +- .../set_devices/qt_ports/plot_navigation.py | 3 +- python/set_devices/qt_ports/plot_workers.py | 42 +- .../qt_ports/qcustomplot_renderer.py | 21 +- python/set_devices/qt_ports/slcan_adapter.py | 212 +++++- .../qt_ports/viewport_navigation.py | 10 +- python/set_devices/tms_terminal.py | 4 +- python/tests/test_byte_work_panel.py | 37 +- python/tests/test_console.py | 77 ++ python/tests/test_plot.py | 22 +- tools/dsview/PLATES_BUSES.md | 123 ++++ tools/dsview/README.md | 54 ++ tools/dsview/build.py | 26 +- tools/dsview/decoder-panel.patch | 4 +- tools/dsview/decoders/plates_pbus/__init__.py | 2 + tools/dsview/decoders/plates_pbus/pd.py | 140 ++++ tools/dsview/decoders/plates_sbus/__init__.py | 2 + tools/dsview/decoders/plates_sbus/pd.py | 172 +++++ tools/dsview/decoders/set_can/pd.py | 129 ++-- tools/dsview/decoders/set_uart/pd.py | 29 +- tools/dsview/decoders/tms_uart/__init__.py | 1 + tools/dsview/decoders/tms_uart/pd.py | 18 + tools/dsview/install_decoders.ps1 | 47 +- tools/dsview/protocol-files.json | 17 + tools/dsview/setgui-host.patch | 6 +- tools/dsview/tests/test_decoders.py | 82 ++- tools/dsview/tests/test_install_decoders.py | 39 +- tools/dsview/tests/test_plates_buses.py | 322 ++++++++ tools/dsview/tests/test_runtime.py | 12 +- 69 files changed, 4413 insertions(+), 682 deletions(-) create mode 100644 python/examples/console_examples.py create mode 100644 python/logic_analyzer/decoders/template_protocols.py create mode 100644 python/logic_analyzer/decoders/tms_uart.py create mode 100644 python/pysetterminal/bus_operations.py create mode 100644 python/pysetterminal/exchange_queue.py create mode 100644 python/pysetterminal/pbus_panel.py create mode 100644 python/set_devices/background_work.py create mode 100644 python/set_devices/can_adapter_settings.py create mode 100644 python/set_devices/plot_envelope_index.py create mode 100644 python/set_devices/plot_ticks.py create mode 100644 python/set_devices/qt_ports/CONSOLE.md create mode 100644 python/set_devices/qt_ports/can_frame_builder.py create mode 100644 python/set_devices/qt_ports/console.py create mode 100644 python/tests/test_console.py create mode 100644 tools/dsview/PLATES_BUSES.md create mode 100644 tools/dsview/decoders/plates_pbus/__init__.py create mode 100644 tools/dsview/decoders/plates_pbus/pd.py create mode 100644 tools/dsview/decoders/plates_sbus/__init__.py create mode 100644 tools/dsview/decoders/plates_sbus/pd.py create mode 100644 tools/dsview/decoders/tms_uart/__init__.py create mode 100644 tools/dsview/decoders/tms_uart/pd.py create mode 100644 tools/dsview/protocol-files.json create mode 100644 tools/dsview/tests/test_plates_buses.py 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/set-protocol/README.md b/c/set-protocol/README.md index 170cc1b..81dfaf2 100644 --- a/c/set-protocol/README.md +++ b/c/set-protocol/README.md @@ -6,6 +6,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/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/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_plot.c b/c/set-protocol/src/set_plot.c index 2bd8b0d..3516186 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -7,8 +7,7 @@ #include "set_plot.h" #include -/* Allow sample-level X navigation in multi-million-point recordings. */ -#define MIN_VIEW_WIDTH (1.0 / 10000000) +#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) { @@ -26,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] < MIN_VIEW_WIDTH || 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], MIN_VIEW_WIDTH, 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/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index 62ed262..c16c234 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -4,7 +4,7 @@ {"name":"zoom_x_anchor","op":0,"input":[0,0,1,1,2,1,0,0,0.25,0.5],"output":[0.125,0,0.5,1]}, {"name":"zoom_y_anchor","op":0,"input":[0,0,1,1,1,4,0,0,0.5,0.75],"output":[0,0.5625,1,0.25]}, {"name":"pan_clamp","op":0,"input":[0.25,0.25,0.5,0.5,1,1,20,-20,0.5,0.5],"output":[0,0.5,0.5,0.5]}, - {"name":"zoom_limit","op":0,"input":[0,0,1,1,1000,1000,0,0,0.5,0.5],"output":[0.4995,0.49609375,0.001,0.0078125]}, + {"name":"zoom_limit","op":0,"input":[0,0,1,1,1e15,1e15,0,0,0.5,0.5],"output":[0.4999999999995,0.4999999999995,1e-12,1e-12]}, {"name":"ignore_invalid_zoom","op":0,"input":[0.25,0.25,0.5,0.5,0,1,0,0,0.5,0.5],"output":[0.25,0.25,0.5,0.5]}, {"name":"horizontal_spread","op":1,"input":[100,2,8],"output":[1]}, {"name":"vertical_contract","op":1,"input":[2,-100,8],"output":[2]}, diff --git a/python/README.md b/python/README.md index 4e2b50d..30ba13f 100644 --- a/python/README.md +++ b/python/README.md @@ -24,6 +24,13 @@ Saleae SDK подключается при обращении к устройс в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`: [контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md). +Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий +компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт +одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет +альтернативный режим SETCAN и сообщает об изменениях сигналом `changed`. +`frame_values()` возвращает проверенные ID, IDE, Data и необязательный Body; +упаковка конкретного транспорта и отправка остаются у приложения. + Пути к нативным библиотекам задаёт потребитель перед импортом: `SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`. Порты не ищут DLL в каталогах SETGUI. C99-ядро собирается из `c/set-protocol`, diff --git a/python/examples/console_examples.py b/python/examples/console_examples.py new file mode 100644 index 0000000..1453266 --- /dev/null +++ b/python/examples/console_examples.py @@ -0,0 +1,46 @@ +"""Три автономных примера общей консоли templates без подключения к аппаратуре.""" # Примеры показывают служебный текст, CAN и двоичный RS485 на одном компоненте. +import argparse # Ключ --kind выбирает один пример для отдельного запуска. +import time # Время в модели кадра соответствует обычному источнику пакетов приложения. +from set_devices.can485_board import BoardFrame # Штатная модель строит RS485-пакет с настоящим CRC. +from set_devices.can_monitor import decode_protocol # CAN расшифровывается действующим декодером templates. +from set_devices.can_transport import build_frame # Транспортный кадр не собирается вручную в UI. +from set_devices.qt_ports.console import ConsoleView # Все примеры используют ту же консоль, что SETGUI. +try: # Выбираем доступную версию Qt без зависимости от приложения SETGUI. + from PySide6.QtWidgets import QApplication, QTabWidget # Вкладки позволяют сравнить три независимых журнала. +except ImportError: # Сохраняем совместимость примера с портом Qt 5. + from PySide2.QtWidgets import QApplication, QTabWidget # Интерфейс запуска одинаков для обеих версий Qt. + + +def create_examples(): # Фабрика доступна также тесту и генератору снимков интерфейса. + service = ConsoleView(limit=4000) # Первый журнал показывает служебные ответы и команды платы. + service.append_message(">>> V", direction="TX", color="#8fd18f") # Метка отправки демонстрирует независимый фильтр TX. + service.append_message("#OK driver started", direction="RX", color="#6fa8dc") # Ответ устройства показывает цвет и фильтр RX. + service.append_message("#ERR transmit failed (no ack / bus busy)", color="#ff7b7b") # Ошибка остаётся видна независимо от фильтров направления. + can = ConsoleView(limit=4000, payloads=True) # Второй пример добавляет переключение представления настоящих байтов CAN. + frame = build_frame(0x701, b"\x05", ide=False, rtr=False) # ID 0x701 и байт 05 задают heartbeat CANopen узла 1 в Operational. + decoded = decode_protocol(frame, "deif") # Подпись события берётся из штатного протокольного декодера. + can.append_message("RX CAN 0x701 · " + decoded.summary, direction="RX", payload=b"\x05") # Исходный payload позволяет сравнить режимы HEX и ASCII. + rs485 = ConsoleView(limit=4000, payloads=True) # Третий пример показывает транспортный пакет платы отдельно от прикладного CAN. + packet = BoardFrame(time.time(), False, 0x701, False, 1, b"\x05", "RS485").rs485_packet() # Пакет AA55 и CRC8 формируются кодеком templates. + rs485.append_message("RX RS485 · пакет CAN485", direction="RX", payload=packet) # Консоль отображает байты, не повторяя алгоритм разбора пакета. + for console in (can, rs485): # Двоичные примеры сразу демонстрируют одновременно байты и печатное представление. + console.display.setCurrentText("HEX + ASCII") # Пользователь может вернуться к исходной подписи или выбрать один формат. + return (("Служебная консоль", service), ("CAN", can), ("RS485", rs485)) # Каждый журнал владеет своей историей и настройками просмотра. + + +def main(): # Точка входа запускается обычным Python с установленным Qt-портом templates. + parser = argparse.ArgumentParser(description=__doc__) # Справка объясняет назначение примеров без отдельного приложения. + parser.add_argument("--kind", choices=("all", "service", "can", "rs485"), default="all") # Один пример каждого источника доступен отдельной командой. + args = parser.parse_args() # Пользователь выбирает источник до создания окна. + app = QApplication.instance() or QApplication([]) # Одно приложение Qt обслуживает все примеры. + window = QTabWidget() # Общий просмотр не связывает состояния независимых консолей. + for key, (title, console) in zip(("service", "can", "rs485"), create_examples()): # Стабильные ключи соответствуют значениям --kind. + if args.kind in ("all", key): window.addTab(console.panel, title) # Выбранные примеры добавляются с общей панелью инструментов. + window.setWindowTitle("Примеры консоли templates") # Заголовок явно отличает демонстрацию от подключения к плате. + window.resize(1100, 480) # Начальный размер в логических пикселях позволяет видеть все инструменты. + window.show() # Окно появляется только при прямом запуске примера. + return app.exec() # Цикл Qt обрабатывает поиск, паузу, экспорт и изменение масштаба. + + +if __name__ == "__main__": # Импорт фабрики в тестах не должен открывать окно или запускать цикл событий. + raise SystemExit(main()) # Код завершения Qt возвращается вызывающему процессу. diff --git a/python/logic_analyzer/decoders/set_can.py b/python/logic_analyzer/decoders/set_can.py index f0d0867..eab299e 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 0ce27ab..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,11 +80,14 @@ 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 item.address == 0x7F else "") for item in self.registers @@ -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/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 249bf28..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) @@ -285,54 +286,44 @@ class RawProtocolWidget(QWidget): 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 bd7499a..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,69 +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_splitter = QSplitter(Qt.Orientation.Horizontal) - self.macros_splitter.setObjectName("macrosSplitter") - self.macros_splitter.setChildrenCollapsible(False) - self.macros_splitter.setHandleWidth(8) - self.macros_splitter.setToolTip("Перетащите разделитель, чтобы изменить ширину панелей") - self.macros_scroll_areas = [] - self.macros_scroll_layouts = [] - for side in ("Левая", "Правая"): - area = QScrollArea() - area.setAccessibleName(f"{side} панель макросов") - area.setWidgetResizable(True) - area.setMinimumSize(120, 120) - content = QWidget() - grid = QGridLayout(content) - grid.setAlignment(Qt.AlignTop | Qt.AlignLeft) - grid.setContentsMargins(6, 6, 6, 6) - grid.setVerticalSpacing(6) - area.setWidget(content) - self.macros_splitter.addWidget(area) - self.macros_scroll_areas.append(area) - self.macros_scroll_layouts.append(grid) - self.macros_splitter.setStretchFactor(0, 1) - self.macros_splitter.setStretchFactor(1, 1) - self.macros_splitter.setSizes([1, 1]) - # Keep the existing attributes available to terminal integrations. - self.macros_scroll_area = self.macros_scroll_areas[0] - self.macros_scroll_content = self.macros_scroll_area.widget() - self.macros_scroll_layout = self.macros_scroll_layouts[0] - macros_layout.addWidget(self.macros_splitter, 1) + 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, 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) - self.setLayout(layout) # После создания интерфейса создаем кнопки для загруженных макросов @@ -468,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): """Создать новый макрос""" @@ -550,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"]) @@ -601,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_layouts[col].addWidget(container, row, 0) + self.macros_scroll_layout.addWidget(container) # FlowLayout размещает элемент без растягивания и пересчитывает перенос при изменении ширины. self.macro_buttons.append(container) @@ -616,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): """Редактировать макрос по индексу""" @@ -624,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() @@ -651,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] @@ -661,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_splitter, 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): """Загрузить макросы из файла""" 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 c0274ce..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,7 +51,7 @@ 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 item.address == 0x7F: result.fields.append(("Активные биты Commands", frame.command_description(item.value))) 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_monitor.py b/python/set_devices/can_monitor.py index 70ad7b1..6e26f32 100644 --- a/python/set_devices/can_monitor.py +++ b/python/set_devices/can_monitor.py @@ -8,17 +8,18 @@ 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"), - ("protocan", "ProtoCAN / GAS"), - ("ump_balsam", "УМП / Balsam 167 (legacy CAN)"), - ("ump1_gas167", "УМП 1 / ProtoCAN — карта GAS 167/v1"), - ("ump2_gas167", "УМП 2 / ProtoCAN — карта GAS 167/v1"), - ("ump_gas", "УМП / ProtoCAN GAS — новые ID 5000/6000")) +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) @@ -36,6 +37,13 @@ def decode_protocol(frame, protocol="auto"): 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) @@ -63,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. @@ -93,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/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 c128c39..071c9ed 100644 --- a/python/set_devices/plot_processing.py +++ b/python/set_devices/plot_processing.py @@ -10,7 +10,7 @@ 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 operator import itemgetter +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 @@ -28,8 +28,9 @@ class FrozenPoints(tuple): def between(self, left, right): if self.ordered: - start = bisect_left(self, left, key=itemgetter(0)) - stop = bisect_right(self, right, key=itemgetter(0)) + 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]) 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/CONSOLE.md b/python/set_devices/qt_ports/CONSOLE.md new file mode 100644 index 0000000..7c32bbf --- /dev/null +++ b/python/set_devices/qt_ports/CONSOLE.md @@ -0,0 +1,68 @@ +# Общая консоль Qt + +`set_devices.qt_ports.console.ConsoleView` — общий порт представления для +журналов SETGUI. Он не управляет портами, опросом и отправкой команд. + +Приложение → `ConsoleView` → Qt 6 / Qt 5. Протокольные декодеры передают +готовые подписи и необязательные исходные байты; консоль не вычисляет CRC. + +| Файл | Назначение и зависимости | +|---|---| +| `set_devices/qt_ports/console.py` | Виджет и файловый экспорт; stdlib и PySide6 либо PySide2 | +| `examples/console_examples.py` | По одному примеру служебной консоли, CAN и RS485; действующие кодеки templates | +| `tests/test_console.py` | Проверки истории, паузы, фильтров, очистки и UTF-8; pytest и Qt | + +## Возможности, собранные из журналов SETGUI + +| Возможность | Источник / поведение общего компонента | +|---|---| +| Очистка | Канальные консоли платы, TX/RX, Modbus; удаляет также скрытые строки и очередь обновления | +| Пауза | Эмуляторы CAN; экран заморожен, история продолжает принимать данные до заданного предела | +| TX/RX | Диагностика и эмуляторы; служебные ошибки без направления остаются видны | +| Автопрокрутка | Мониторы CAN и RS; её можно отключить для чтения истории | +| Цвета | Консоль платы и монитор шины; сохраняются при переразборе фильтра | +| HEX/ASCII | Диагностика RS; формат применяется к явно переданному payload | +| Сохранение | Мониторы CAN и RS; экспорт фактически видимого текста в UTF-8 | +| Поиск, копирование, перенос, масштаб | Общие инструменты всех подключённых текстовых журналов | + +## Подключение + +```python +from set_devices.qt_ports.console import ConsoleView # Подключаем общий порт представления templates. +console = ConsoleView(limit=4000, payloads=True) # Храним не более 4000 строк, включая скрытые и поступившие на паузе. +layout.addWidget(console.panel, 1) # В layout вставляется контейнер с инструментами и редактором. +console.append_message("RX ответ", direction="RX", payload=b"OK") # Передаём готовую подпись и байты без повторного разбора протокола. +console.append_message("Ошибка связи", color="#ff7b7b") # Служебная ошибка остаётся видна при выключенных TX и RX. +console.userCleared.connect(clear_frame_history) # Отдельный буфер кадров очищается тем же пользовательским действием. +``` + +Старые вызовы `appendPlainText`, `appendHtml`, `setPlainText`, `clear`, +`toPlainText` и `setMaximumBlockCount` поддерживаются. `setPlainText` заменяет +снимок истории, учитывая текущую паузу. Для внешней истории кадров подключите +`userCleared`: программный `clear()` повторно этот сигнал не испускает. + +`directions=False` скрывает встроенные флажки, если приложение уже имеет свои +фильтры TX/RX. `payloads=True` показывает форматы HEX/ASCII. События должны +поступать в GUI-потоке Qt, как при работе с обычным `QPlainTextEdit`. + +При заполнении история обновляет экран пачками с интервалом 100 мс. +Количество строк на панели относится ко всей ограниченной истории. +На паузе сохранение и копирование используют замороженный видимый экран. + +## Три запускаемых примера + +Из каталога `python`, с установленными зависимостями `.[qt]`: + +```powershell +python examples/console_examples.py --kind service +python examples/console_examples.py --kind can +python examples/console_examples.py --kind rs485 +``` + +Без `--kind` открываются все три примера. Аппаратура не требуется. +В каждом примере можно проверить очистку, паузу, поиск, TX/RX, экспорт, +копирование, автопрокрутку, перенос и масштаб; в CAN и RS485 — HEX/ASCII. + +Используется в SETGUI: CAN/RS485 и служебная консоль ESP, журналы соединения +и TX/RX, эмуляторы CAN, CAN FD, Modbus, диагностика ПМ35 и ТМС2812, +спектр и логический анализатор Altera. diff --git a/python/set_devices/qt_ports/can_frame_builder.py b/python/set_devices/qt_ports/can_frame_builder.py new file mode 100644 index 0000000..0e99116 --- /dev/null +++ b/python/set_devices/qt_ports/can_frame_builder.py @@ -0,0 +1,175 @@ +"""Общие поля конструктора CAN и SETCAN для адаптеров и обоих каналов DevBoard.""" +from PySide6.QtCore import Signal # Сигнал изменений позволяет владельцу сбрасывать готовый кадр без зависимости от транспорта. +from PySide6.QtGui import QFont # Моноширинный шрифт сохраняет читаемость HEX-идентификатора и данных. +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QSpinBox, QLineEdit, QLabel # Один набор виджетов определяет форму всех потребителей. +from protocan import protocan as pc # Сборка полей протокола выполняется существующими обёртками общего C-ядра. +from set_devices import can_transport as tr # Проверка CAN ID и разбор HEX используют действующий общий транспорт. + + +DESCRIPTION = "Обычный CAN: задайте ID, DLC и Data. Для сборки ID по полям протокола выберите SETCAN." # Одинаковое пояснение режимов используется во всех панелях. + + +class CanFrameFields(QWidget): # Компонент содержит только ввод кадра; подключение и отправка остаются у владельца. + changed = Signal() # Любое изменение ввода отменяет ранее собранный кадр в соответствующем канале. + + def __init__(self, settings_factory, parent=None): # Редактор SETTINGS передаётся владельцем как визуальный компонент без копирования его реализации. + super().__init__(parent) # Родитель Qt управляет временем жизни всех полей формы. + layout = QVBoxLayout(self) # Единый вертикальный порядок исключает различия между вкладками. + layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт панель приложения. + self.mode = QComboBox() # Переключатель сохраняет оба набора введённых значений. + self.mode.addItem("Обычный CAN — ID / DLC / Data", "can") # Исходный режим одинаков для всех трёх конструкторов. + self.mode.addItem("SETCAN — поля протокола", "setcan") # Протокольный режим всегда создаёт расширенный идентификатор. + mode_form = QFormLayout() # Первая строка остаётся видимой в обоих режимах. + mode_form.addRow("Режим", self.mode) # Подпись поясняет назначение переключателя. + layout.addLayout(mode_form) # Выбор режима предшествует зависимым полям. + self.can_panel = QWidget() # Контейнер скрывает вместе подписи и редакторы обычного CAN. + can_form = QFormLayout(self.can_panel) # Формат, идентификатор и длина выровнены общей формой. + can_form.setContentsMargins(0, 0, 0, 0) # Поля обоих режимов начинаются на одной вертикали. + self.format = QComboBox() # IDE задаётся явно независимо от численного значения идентификатора. + self.format.addItem("Стандартный — 11 бит (STD)", False) # STD соответствует диапазону CAN ID 0..0x7FF. + self.format.addItem("Расширенный — 29 бит (EXT)", True) # EXT соответствует диапазону CAN ID 0..0x1FFFFFFF. + self.can_id = QLineEdit("123") # Исходный пример идентификатора записан в HEX. + self.can_id.setToolTip("HEX, с префиксом 0x или без него. STD: 0..7FF; EXT: 0..1FFFFFFF.") # Указываем пределы CAN ID без неявного усечения. + self.dlc = QSpinBox() # Пользователь явно задаёт длину обычного CAN-кадра. + self.dlc.setRange(0, 8) # Классический CAN допускает от нуля до восьми байтов Data. + self.dlc.setToolTip("Количество байтов Data должно точно совпадать с DLC; дополнение и обрезка не выполняются.") # Описываем строгую проверку длины. + can_form.addRow("Формат ID (IDE)", self.format) # Формат отражает фактический флаг IDE. + can_form.addRow("CAN ID (HEX)", self.can_id) # Явно обозначаем систему счисления идентификатора. + can_form.addRow("DLC (0..8 байт)", self.dlc) # Длина дана в байтах полезной нагрузки. + layout.addWidget(self.can_panel) # Обычный CAN занимает то же место, что альтернативная форма SETCAN. + self.setcan_panel = QWidget() # Общий контейнер хранит поля составного 29-битного идентификатора. + form = QFormLayout(self.setcan_panel) # Подписи протокола выравниваются с остальными формами. + form.setContentsMargins(0, 0, 0, 0) # Убираем дополнительные внутренние отступы. + self.priority = QComboBox() # Приоритет соответствует старшему полю SETCAN ID. + self.priority.addItems(["0 — критический", "1 — стандартный"]) # Подписи сохраняют существующие значения приоритета протокола. + self.priority.setCurrentIndex(1) # По умолчанию используется стандартный приоритет. + self.route = QComboBox() # Направление отличает запрос мастера от сообщения устройства. + self.route.addItems(["0 — от мастера (PM)", "1 — от устройства"]) # Сохраняем совместимые значения поля ПМ. + self.dtype = QSpinBox() # Тип устройства выбирается отдельно от номера экземпляра. + self.dtype.setRange(0, 7) # Поле Device Type занимает три бита SETCAN ID. + self.dtype.setValue(1) # Сохраняем прежний исходный тип устройства. + self.did = QSpinBox() # Номер устройства задаёт адрес внутри выбранного типа. + self.did.setRange(0, 15) # Поле Device занимает четыре бита SETCAN ID. + self.did.setValue(2) # Сохраняем прежний исходный адрес устройства. + self.mtype = QComboBox() # Тип сообщения определяет подтип и назначение Body. + self.mtype.addItems([item.name for item in pc.MsgType]) # Каталог типов берётся из общего протокольного модуля. + self.mtype.setCurrentText(pc.MsgType.ANALOG.name) # Начальный протокольный пример остаётся аналоговым сообщением. + self.sub = QComboBox() # Набор подтипов меняется вместе с MsgType. + self.arg = QLineEdit("1") # Числовой аргумент поддерживает десятичный ввод и префикс 0x. + self.settings_body = settings_factory() # Используем переданный редактор байтов Z и Y без зависимости от приложения. + self.arg_panel = QWidget() # Альтернативные редакторы аргумента занимают одну строку. + arg_layout = QHBoxLayout(self.arg_panel) # Горизонтальная компоновка сохраняет левое выравнивание SETTINGS. + arg_layout.setContentsMargins(0, 0, 0, 0) # Исключаем сдвиг аргумента относительно остальных редакторов. + arg_layout.addWidget(self.arg) # Обычный аргумент виден для всех типов кроме SETTINGS. + arg_layout.addWidget(self.settings_body) # Два байта SETTINGS заменяют единое поле аргумента. + arg_layout.addStretch(1) # Свободная ширина остаётся справа от редакторов. + self.count = QSpinBox() # RegCount используется протокольными типами Modbus. + self.count.setRange(0, 15) # Поле количества занимает четыре бита Body Modbus. + self.count.setValue(1) # Исходный запрос относится к одному регистру. + self.sub_label = QLabel("Подтип") # Подпись уточняется при выборе типа сообщения. + self.arg_label = QLabel("SensorID / адрес") # Назначение аргумента зависит от MsgType. + rows = [("Priority", self.priority), ("ПМ", self.route), ("Device Type (0..7)", self.dtype), ("Device (0..15)", self.did), ("Msg Type", self.mtype), (self.sub_label, self.sub), (self.arg_label, self.arg_panel), ("RegCount (Modbus)", self.count)] # Единый порядок полей исключает расхождение форм. + for label, control in rows: # Все протокольные строки добавляются из одного визуального описания. + form.addRow(label, control) # Подпись связана с соответствующим редактором. + layout.addWidget(self.setcan_panel) # Протокольная форма показывается только в режиме SETCAN. + self.data = QLineEdit() # Полезная нагрузка общая для обоих способов ввода идентификатора. + self.data.setPlaceholderText("01 02 03 04 05 06 07 08") # Пример показывает формат ввода HEX-байтов. + data_form = QFormLayout() # Общая строка Data остаётся видимой при смене режима. + data_form.addRow("Data (HEX, до 8 байт)", self.data) # Указываем ограничение классического CAN. + layout.addLayout(data_form) # Данные завершают форму перед кнопками владельца. + for control in (self.mode, self.data): # Длинные поля получают достаточную ширину в обоих режимах. + control.setMaximumWidth(420) # Предел 420 пикселей сохраняет компактность исходного конструктора. + self.format.setMaximumWidth(340) # Название формата целиком помещается в строке шириной до 340 пикселей. + for control in (self.priority, self.route, self.mtype, self.sub, self.arg, self.can_id): # Одинаковая ширина полей ID задаётся в общем компоненте. + control.setMaximumWidth(220) # Предел 220 пикселей соответствует прежней форме SETCAN. + for control in (self.dtype, self.did, self.count, self.dlc): # Короткие числовые поля не растягиваются по ширине панели. + control.setMaximumWidth(100) # Предел 100 пикселей достаточен для всех допустимых значений. + font = QFont("Consolas", 10) # Шрифт с фиксированной шириной знаков упрощает чтение HEX. + for control in (self.can_id, self.data): # Числовой HEX и полезная нагрузка оформляются одинаково. + control.setFont(font) # Используем один шрифт для всех владельцев компонента. + self._subtype() # Настраиваем первоначальные подписи до вычисления ширины столбца. + labels = [part.labelForField(part.itemAt(row, QFormLayout.ItemRole.FieldRole).widget()) for part in (mode_form, can_form, form, data_form) for row in range(part.rowCount())] # Собираем подписи всех форм для общего выравнивания. + width = max(label.sizeHint().width() for label in labels) # Ширина столбца рассчитывается в пикселях по текущему шрифту Qt. + for label in labels: # Поля обоих режимов должны начинаться в одной позиции. + label.setMinimumWidth(width) # Самая длинная подпись задаёт единый левый столбец. + for container in (self, self.can_panel, self.setcan_panel, self.arg_panel): # Промежуточные контейнеры наследуют фон панели без тёмных полос. + container.setObjectName("canBuilderFields") # Локальный селектор ограничивает правило контейнерами формы. + container.setStyleSheet("QWidget#canBuilderFields { background: transparent; }") # Редакторы сохраняют собственный штатный фон. + self.mode.currentIndexChanged.connect(self._mode_changed) # Переключение режима одновременно меняет видимость и отменяет готовность кадра. + self.mtype.currentIndexChanged.connect(self._subtype) # Новый MsgType обновляет подтипы и редактор Body. + for control in (self.format, self.priority, self.route, self.mtype, self.sub): # Любой выбор из списка меняет будущий кадр. + control.currentIndexChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем индекс Qt, поскольку владельцу нужен только сигнал устаревания результата. + for control in (self.can_id, self.arg, self.data): # Все текстовые поля участвуют в сборке кадра. + control.textChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем текст сигнала Qt и отменяем старый кадр даже при неверном промежуточном вводе. + for control in (self.dlc, self.dtype, self.did, self.count, self.settings_body.z, self.settings_body.y): # Слежение включает оба байта SETTINGS. + control.valueChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем числовой аргумент Qt, сохраняя единый сигнал необходимости новой сборки. + self._mode_changed() # Начальная видимость одинакова у всех потребителей. + + def _mode_changed(self): # Режим определяет только видимость; введённые значения сохраняются. + raw = self.mode.currentData() == "can" # Полный CAN ID вводится только в обычном режиме. + self.can_panel.setVisible(raw) # Показываем формат, ID и DLC для обычного CAN. + self.setcan_panel.setVisible(not raw) # Альтернативно показываем составные поля SETCAN. + self.changed.emit() # Смена способа ввода отменяет ранее собранный кадр. + + def _subtype(self): # Общие подписи и список подтипов используются всеми экземплярами формы. + msg_type = pc.MsgType[self.mtype.currentText()] # Значение выбирается из существующего общего каталога. + settings = msg_type == pc.MsgType.SETTINGS # Только SETTINGS редактируется отдельными байтами Z и Y. + self.arg.setVisible(not settings) # Единый числовой аргумент скрывается для SETTINGS. + self.settings_body.setVisible(settings) # Двухбайтовый редактор появляется вместо числового аргумента. + table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)")} # Таблица определяет визуальные подписи существующих протокольных перечислений. + current = self.sub.currentText() # Сохраняем выбранный подтип, если он допустим для нового сообщения. + self.sub.clear() # Старые варианты не должны остаться в новом типе сообщения. + self.sub.setEnabled(msg_type in table) # Отдельный подтип доступен только у трёх перечисленных типов. + if msg_type in table: # Подписи и значения перечислений берутся из одного места. + enum, sub_label, arg_label = table[msg_type] # Получаем визуальное описание выбранного MsgType. + self.sub.addItems([item.name for item in enum]) # Значения берутся из общего протокольного каталога. + if current in [item.name for item in enum]: # Совместимый выбор пользователя сохраняется. + self.sub.setCurrentText(current) # Восстанавливаем допустимый подтип после заполнения списка. + else: # Типы без отдельного подтипа используют Body по собственному назначению. + sub_label = "—" # Подпись явно показывает отсутствие подтипа. + arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента. + if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Все четыре вида Modbus задают начальный адрес. + arg_label = "StrAdr (0..4095)" # Двенадцатибитный адрес показан в прежних единицах регистров. + elif msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес. + arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи. + elif msg_type == pc.MsgType.ERROR: # Код ошибки вводится в отдельный байт Body. + arg_label = "ErrorCode (0..255)" # Показываем допустимый однобайтовый диапазон. + elif settings: # SETTINGS редактируется двухбайтовым компонентом. + arg_label = "Body: Z | Y" # Подпись соответствует порядку байтов протокола. + self.sub_label.setText(sub_label) # Одинаковая подпись подтипа обновляется во всех вкладках. + self.arg_label.setText(arg_label) # Аргумент всегда подписан по выбранному MsgType. + + def body(self, msg_type, arg, count): # Общая диспетчеризация вызывает существующие обёртки C-ядра без второй реализации wire-кодека. + if msg_type == pc.MsgType.BROADCAST: # BROADCAST объединяет подтип и четырёхбитный аргумент. + return pc.merge_broadcast(pc.BroadcastType[self.sub.currentText()], arg & 0xF) # Маска 0xF сохраняет прежнее ограничение аргумента четырьмя битами. + if msg_type == pc.MsgType.DISCRETE: # Дискретное сообщение использует собственное перечисление подтипов. + return pc.merge_discrete(pc.DiscreteType[self.sub.currentText()], arg) # Поле собирается общим протокольным модулем. + if msg_type == pc.MsgType.ANALOG: # Аналоговое сообщение передаёт SensorID в Body. + return pc.merge_analog(pc.AnalogType[self.sub.currentText()], arg) # Существующий кодек задаёт расположение подтипа и SensorID. + if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # У четырёх видов Modbus одинаковый формат Body. + return pc.merge_modbus(arg, count) # Адрес и количество объединяются общей функцией протокола. + if msg_type == pc.MsgType.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника. + return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра. + return arg & 0xFFFF # Остальные типы сохраняют прежнее ограничение Body шестнадцатью битами. + + def frame_values(self): # Возвращаем проверенные параметры CAN без упаковки конкретного транспорта. + data = tr.parse_hex(self.data.text()) # Общий разбор сохраняет поддержку пробелов, запятых и префиксов 0x. + if len(data) > 8: # Классический CAN не допускает более восьми байтов данных. + raise ValueError("данные длиннее 8 байт") # Сообщаем об ошибке до передачи в транспорт. + if self.mode.currentData() == "can": # Обычный режим использует полный идентификатор и явный DLC. + identifier = int(self.can_id.text().strip(), 16) # Поле ID всегда интерпретируется как HEX. + extended = bool(self.format.currentData()) # Малый числовой ID также может иметь выбранный EXT. + tr.validate_can_id(identifier, extended) # Проверка общего транспорта запрещает усечение ID до допустимого диапазона. + if len(data) != self.dlc.value(): # Количество введённых байтов обязано точно совпадать с DLC. + raise ValueError(f"DLC = {self.dlc.value()}, а Data содержит {len(data)} байт; количество байтов должно совпадать.") # Оператор видит обе длины и может исправить ввод. + return identifier, extended, data, None # У обычного CAN нет отдельного протокольного Body. + msg_type = pc.MsgType[self.mtype.currentText()] # SETCAN использует выбранный тип сообщения. + arg = self.settings_body.value() if msg_type == pc.MsgType.SETTINGS else int(self.arg.text().strip() or "0", 0) # SETTINGS читается из Z/Y, остальные аргументы поддерживают привычные числовые префиксы. + body = self.body(msg_type, arg, self.count.value()) # Один путь сборки Body применяется ко всем владельцам формы. + identifier = pc.ProtoCanId.build(self.priority.currentIndex(), self.route.currentIndex(), self.dtype.value(), self.did.value(), msg_type, body) # Идентификатор собирается существующей обёрткой общего C-ядра. + return identifier, True, data, body # SETCAN всегда передаётся с расширенным 29-битным идентификатором. + + def bind(self, owner, prefix): # Сохраняем публичные имена полей приложения при переходе на общий компонент. + names = ("mode", "can_panel", "setcan_panel", "format", "can_id", "dlc", "priority", "route", "dtype", "did", "mtype", "sub", "arg", "settings_body", "count", "data", "sub_label", "arg_label") # Совместимые атрибуты позволяют не менять интеграцию транспорта и внешние потребители. + for name in names: # Каждое старое имя ссылается на настоящий виджет общего компонента. + setattr(owner, prefix + name, getattr(self, name)) # Префиксы build_, builder_ и rs485_builder_ разделяют три независимых состояния. diff --git a/python/set_devices/qt_ports/can_monitor.py b/python/set_devices/qt_ports/can_monitor.py index 6c13b5c..d753419 100644 --- a/python/set_devices/qt_ports/can_monitor.py +++ b/python/set_devices/qt_ports/can_monitor.py @@ -2,33 +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.setMinimumContentsLength(28) self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - self.protocol.setToolTip( - "Выберите формат CAN вручную. УМП / Balsam — legacy CAN; " - "карта 167/v1 — имена слов 0…127 и 0x5000…0x507F в ProtoCAN; " - "новые ID — назначенные диапазоны 5000…5127 и 6000…6127.") - self.protocol.setCurrentIndex(self.protocol.findData(default)) + 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("Без парса") @@ -57,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()) @@ -79,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) @@ -106,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("● Запись") @@ -159,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/console.py b/python/set_devices/qt_ports/console.py new file mode 100644 index 0000000..d223377 --- /dev/null +++ b/python/set_devices/qt_ports/console.py @@ -0,0 +1,180 @@ +"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств. +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 # Таймер объединяет обновления заполненного журнала. + from PySide6.QtGui import QFont, QTextDocument # Документ извлекает обычный текст из уже оформленных сообщений приложения. + from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Панель использует только стандартные элементы Qt. +except ImportError: # Старый порт сохраняет тот же публичный интерфейс консоли. + from PySide2.QtCore import QTimer, Signal # В Qt 5 доступны те же сигналы и таймеры. + from PySide2.QtGui import QFont, QTextDocument # Цвета и извлечение текста не зависят от версии Qt. + from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Состав инструментов одинаков в обоих портах. + + +class ConsoleView(QPlainTextEdit): # Наследование сохраняет привычные appendPlainText, appendHtml и toPlainText у потребителей. + userCleared = Signal() # Владелец отдельной истории может удалить её при явном нажатии «Очистить». + + def __init__(self, parent=None, limit=4000, directions=True, payloads=False): # Предел задан в строках, включая скрытые и поступившие на паузе. + super().__init__(parent) # Владельца редактора впоследствии принимает контейнер panel. + self.records = deque(maxlen=max(1, int(limit))) # Каждая запись хранит текст, оформление, направление и необязательные байты. + self.setReadOnly(True) # Журнал показывает полученные события и не допускает ручной подмены текста. + self.setFont(QFont("Cascadia Mono", 9)) # Моноширинный шрифт выравнивает HEX, адреса и метки времени. + super().setMaximumBlockCount(self.records.maxlen) # Документ ограничен тем же количеством строк, что история. + self.timer = QTimer(self) # Таймер уничтожается вместе с редактором и не переживает закрытие вкладки. + self.timer.setSingleShot(True) # За одну пачку входящих строк достаточно одного полного обновления. + self.timer.setInterval(100) # Интервал 100 мс ограничивает перестроения десятью в секунду при полном буфере. + self.timer.timeout.connect(self.refresh) # Перестроение всегда читает последние записи и текущие фильтры. + self.panel = QWidget(parent) # Контейнер можно вставить в layout или вкладку вместо самого редактора. + layout = QVBoxLayout(self.panel) # Две компактные строки инструментов остаются над областью текста. + layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт приложение-потребитель. + actions = QHBoxLayout() # Основные операции сгруппированы отдельно от фильтров просмотра. + self.clear_button = QPushButton("Очистить") # Очистка удаляет также скрытую историю и накопленное на паузе. + self.clear_button.clicked.connect(self._user_clear) # Сигнал владельцу позволяет очистить дополнительный буфер кадров. + actions.addWidget(self.clear_button) # Очистка постоянно доступна у левого края консоли. + self.pause = QCheckBox("Пауза") # Пауза замораживает экран, продолжая сохранять входящие сообщения. + actions.addWidget(self.pause) # Пауза относится только к этому экземпляру журнала. + self.follow = QCheckBox("Автопрокрутка") # Прокрутку можно отключить для чтения старых записей. + self.follow.setChecked(True) # По умолчанию новые события видны сразу. + actions.addWidget(self.follow) # Управление расположено рядом с паузой просмотра. + for title, callback in (("Копировать", self.copy_visible), ("Сохранить…", self.save_dialog), ("−", lambda: self.zoomOut(1)), ("+", lambda: self.zoomIn(1))): # Масштаб меняется на один шаг Qt без изменения данных. + button = QPushButton(title) # Кнопки используют тему и шрифт приложения. + button.clicked.connect(callback) # Обработчик вызывается только по явному действию пользователя. + actions.addWidget(button) # Одинаковый порядок действий используется всеми консолями. + actions.addStretch(1) # Свободное место не растягивает подписи инструментов. + layout.addLayout(actions) # Основная строка отделена от параметров отображения. + filters = QHBoxLayout() # Поиск и направления занимают вторую строку для умеренной минимальной ширины. + self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк. + self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории. + filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли. + 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(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами. + self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются. + self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки. + self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload. + filters.addWidget(self.display) # Представление можно менять без повторной передачи данных. + self.wrap = QCheckBox("Перенос") # Перенос длинных строк удобен на узких панелях. + filters.addWidget(self.wrap) # Переключатель не изменяет исходный текст при экспорте. + self.count = QLabel() # Счётчик показывает объём ограниченной истории, включая скрытые строки. + filters.addWidget(self.count) # Количество остаётся видно при включённом фильтре. + layout.addLayout(filters) # Параметры находятся непосредственно над текстом. + layout.addWidget(self, 1) # Редактор занимает оставшуюся высоту контейнера. + for control in (self.pause, self.show_tx, self.show_rx, self.follow): # Изменения параметров применяются к уже накопленным данным. + control.toggled.connect(self.refresh) # Снятие паузы показывает сохранённые во время неё события. + self.search.textChanged.connect(self.refresh) # Поиск немедленно обновляет видимую часть истории. + self.display.currentIndexChanged.connect(self.refresh) # Выбор HEX/ASCII заново форматирует сохранённый payload. + self.wrap.toggled.connect(lambda checked: self.setLineWrapMode(QPlainTextEdit.WidgetWidth if checked else QPlainTextEdit.NoWrap)) # Отключённый перенос сохраняет горизонтальную прокрутку. + self.setLineWrapMode(QPlainTextEdit.NoWrap) # Начальный режим совпадает с выключенным флажком переноса. + self.refresh() # Пустой журнал сразу получает корректный счётчик. + + def setMaximumBlockCount(self, limit): # Сохраняем совместимость с существующими настройками журналов SETGUI. + self.records = deque(self.records, maxlen=max(1, int(limit))) # Предел ограничивает и скрытую историю, а не только документ. + super().setMaximumBlockCount(self.records.maxlen) # Согласуем вместимость редактора с кольцевым буфером. + self.refresh() # Уменьшение лимита сразу удаляет вытесненные строки с экрана. + + def append_message(self, text, direction="", color=None, payload=None, _html=None, _defer=False): # Принимаем готовый текст и данные, не выполняя разбор протокола. + for line in str(text).split("\n"): # Многострочные расшифровки также подчиняются общему лимиту строк. + match = re.match(r"^(?:\[[^\]]+\]\s*|\d\d:\d\d:\d\d(?:\.\d+)?\s*)?(TX|RX|>>>)\s", line) # Направление распознаётся только у начальной метки, а не по словам внутри ошибки. + role = direction or (("TX" if match[1] == ">>>" else match[1]) if match else "") # Явная роль имеет приоритет над текстовой эвристикой. + html = _html or ('%s' % (escape(color, quote=True), escape(line)) if color else "") # Безопасное оформление сохраняет исходный текст устройства. + record = (line, html, role, bytes(payload) if payload is not None else None) # Снимок payload не зависит от последующих изменений входного bytearray. + expired = len(self.records) == self.records.maxlen # Вытеснение требует убрать старую строку даже при скрытых новых событиях. + self.records.append(record) # На паузе и под фильтрами данные продолжают сохраняться в пределах лимита. + if not self.pause.isChecked() and not _defer: # Пауза и пакетная замена замораживают документ, но не историю. + if expired or self.timer.isActive(): # Заполненный буфер обновляется пачками для устойчивого интенсивного приёма. + if not self.timer.isActive(): self.timer.start() # Следующие события не отодвигают уже запланированное обновление. + elif self._visible(record): self._append_record(record) # Обычный поток дописывает одну строку без переразбора всей истории. + self.count.setText("%d строк" % len(self.records)) # Счётчик истории обновляется даже при замороженном экране. + + def appendPlainText(self, text): # Старые потребители могут продолжать писать обычный текст. + self.append_message(text) # Ограничения, пауза и фильтры применяются через единый путь записи. + + def appendHtml(self, html): # Прежнее цветное оформление приложения остаётся доступно через API QPlainTextEdit. + document = QTextDocument() # Временный документ преобразует форматированный фрагмент в строки для поиска. + document.setHtml(html) # Разметка поступает от приложения; внешние данные оно должно экранировать. + text = document.toPlainText() # Обычный текст используется для поиска и определения направления. + self.append_message(text, _html=html if "\n" not in text else None) # Однострочное оформление сохраняется без полного обновления истории. + + def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления. + self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок. + self.records.clear() # Сохраняется только актуальный результат форматирования потребителя. + if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись. + self.refresh() # Поиск и фильтры применяются к новому снимку целиком. + + def clear(self): # Программная очистка удаляет все данные и отменяет ожидающее обновление. + self.timer.stop() # Запланированное обновление больше не требуется. + self.records.clear() # Скрытые записи и накопленное на паузе не должны вернуться. + super().clear() # Явная очистка действует сразу даже при замороженном экране. + self.count.setText("0 строк") # Счётчик согласован с удалённой историей. + + def _user_clear(self): # Только действие оператора уведомляет владельца внешней истории. + self.clear() # Сначала очищаем собственный буфер и видимый документ. + self.userCleared.emit() # Потребитель может безопасно вызвать clear повторно без рекурсивного сигнала. + + def _visible(self, record): # Единое условие применяется при добавлении и полном обновлении. + text, _html, role, _payload = record # Поиск использует исходный текст сообщения независимо от выбранного формата байтов. + return (role != "TX" or self.show_tx.isChecked()) and (role != "RX" or self.show_rx.isChecked()) and self.search.text().casefold() in text.casefold() # Служебные сообщения сохраняются при отключённых направлениях. + + 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 показывает каждый исходный байт двумя цифрами. + ascii_text = "".join(chr(value) if 32 <= value <= 126 else "." for value in payload) # Диапазон печатного ASCII задан стандартом; управляющие байты заменяются точками. + super().appendPlainText(text + " · " + (hexadecimal if mode == 1 else ascii_text if mode == 2 else hexadecimal + " | " + ascii_text)) # Исходная подпись не теряется при смене представления байтов. + 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, 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): # Пользователь самостоятельно выбирает назначение журнала. + filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым. + path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса. + if path: # Записываем только явно выбранный файл. + 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 f57dfc7..70f6f23 100644 --- a/python/set_devices/qt_ports/plot_dual_axis.py +++ b/python/set_devices/qt_ports/plot_dual_axis.py @@ -8,6 +8,13 @@ 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): @@ -32,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): @@ -115,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): @@ -139,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() @@ -177,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() @@ -189,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_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/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/viewport_navigation.py b/python/set_devices/qt_ports/viewport_navigation.py index a662265..8f60e41 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) @@ -25,6 +27,7 @@ class ViewportNavigation(QObject): def _navigation_changed(self): self._navigation_revision += 1 + self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов. if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000: self.chart._scope_measurements() self.chart._refresh_processing() @@ -43,15 +46,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/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/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_console.py b/python/tests/test_console.py new file mode 100644 index 0000000..1b50ff1 --- /dev/null +++ b/python/tests/test_console.py @@ -0,0 +1,77 @@ +"""Проверки общей консоли на настоящем Qt без физического устройства.""" # Проверяем сохранность истории, фильтры и очистку при асинхронном обновлении. +import os # Виртуальная платформа выбирается до импорта Qt. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # Тесты не открывают окна на рабочем столе. +import pytest # Фикстура даёт каждому сценарию независимую консоль. +from set_devices.qt_ports.console import ConsoleView, QApplication # Используем реальный порт templates, а не имитацию обработчиков. + + +@pytest.fixture # Создаём отдельный журнал для проверки независимости состояний. +def console(): # Ссылка на приложение удерживается до завершения сценария. + app = QApplication.instance() or QApplication([]) # Qt требует приложение перед созданием виджетов. + view = ConsoleView(limit=3, payloads=True) # Малый предел проверяет вытеснение без большого массива данных. + yield view # Сценарий управляет настоящими сигналами флажков и кнопок. + view.panel.deleteLater() # Удаляется контейнер вместе с редактором и таймером. + app.processEvents() # Отложенное удаление доставляется до следующего сценария. + + +def test_pause_keeps_bounded_history_and_clear_cannot_resurrect(console): # Очистка на паузе должна удалять также невидимые данные. + console.appendPlainText("первая") # Первая запись сразу видна на экране. + console.pause.setChecked(True) # Замораживаем только представление. + for index in range(5): console.appendPlainText("строка %d" % index) # На паузе поступает больше данных, чем помещается в истории. + assert console.toPlainText() == "первая" and len(console.records) == 3 # Экран заморожен, память ограничена. + console.pause.setChecked(False) # Возобновление показывает последние сохранённые строки. + assert console.toPlainText().splitlines() == ["строка 2", "строка 3", "строка 4"] # Вытеснённые записи больше не появляются. + console.appendPlainText("ожидает таймер") # Полный буфер ставит пакетное обновление в очередь. + assert console.timer.isActive() # Проверяем именно очистку при ожидающем обновлении. + console.pause.setChecked(True) # Очистка должна работать и при замороженном экране. + console.clear_button.click() # Пользователь удаляет весь журнал через общую панель. + console.pause.setChecked(False) # Возобновление не должно вернуть старые данные. + assert not console.records and not console.toPlainText() and not console.timer.isActive() # Документ, история и очередь согласованы. + + +def test_filters_restore_hidden_rows_and_preserve_service_errors(console): # Фильтры направлений не должны скрывать ошибки без метки TX/RX. + console.appendPlainText("[12:00:00] TX команда") # Начальная метка после времени определяет направление. + console.appendPlainText("12:00:01.123 RX ответ") # Поддерживаем существующий формат временных меток SETGUI. + console.appendPlainText("#ERR RX timeout") # Слово RX внутри ошибки не делает её принятым пакетом. + console.show_rx.setChecked(False) # Скрываем только входящие пакеты. + console.show_tx.setChecked(False) # Оба направления можно выключить одновременно. + assert console.toPlainText() == "#ERR RX timeout" # Служебная ошибка остаётся доступной оператору. + console.search.setText("ОТВЕТ") # Регистр запроса не должен влиять на поиск. + console.show_rx.setChecked(True) # Входящие данные восстанавливаются из сохранённой истории. + assert console.toPlainText() == "12:00:01.123 RX ответ" # Поиск применяется к той же записи без повторного приёма. + console.search.clear() # Снимаем текстовый фильтр. + assert len(console.records) == 3 # Фильтры не удаляют события. + + +def test_payload_and_utf8_export_use_visible_snapshot(console, tmp_path): # Сохранение и копирование соответствуют выбранному представлению. + console.append_message("RX пакет <текст>", direction="RX", payload=b"A\x00\xff") # Payload содержит печатный, управляющий и не-ASCII байты. + console.display.setCurrentText("HEX + ASCII") # Выбираем совместное представление байтов. + assert console.toPlainText() == "RX пакет <текст> · 41 00 FF | A.." # Непечатные байты заменяются точками без потери HEX. + path = tmp_path / "журнал.txt" # Проверяем кириллицу в имени и содержимом файла. + console.save_to(path) # Экспорт выполняется без диалога, сохраняя тот же код записи. + assert path.read_text(encoding="utf-8") == console.toPlainText() # UTF-8 сохраняет фактически видимый текст. + console.copy_visible() # Кнопка копирует весь показанный журнал. + assert QApplication.clipboard().text() == console.toPlainText() # Буфер обмена совпадает с экспортом. + + +def test_multiline_html_and_snapshot_obey_limit(console): # Предел относится к строкам, а не к числу вызовов записи. + console.appendPlainText("0\n1\n2\n3\n4") # Один пакет расшифровки превышает лимит документа и истории. + console.refresh() # Применяем запланированное пакетное обновление. + assert console.toPlainText() == "2\n3\n4" # Старые строки не возвращаются при смене фильтра. + console.setPlainText("снимок") # Потребитель может передать результат повторного разбора кадров целиком. + console.appendHtml('<ошибка>') # Проверяем совместимость прежнего цветного API SETGUI. + console.search.setText("ошибка") # Поиск должен работать по тексту без тегов разметки. + assert console.toPlainText() == "<ошибка>" # Спецсимволы устройства остаются обычным текстом. + console.search.clear() # Возвращаем обе записи с исходным оформлением. + assert console.toPlainText() == "снимок\n<ошибка>" # Фильтр не теряет предыдущий снимок истории. + + +def test_clear_notifies_external_owner_once(console): # Владелец может очистить свой буфер и повторно вызвать clear без рекурсии. + external = ["старый кадр"] # Имитируем отдельную историю протокольных кадров приложения. + def clear_owner(): # Обработчик очищает внешний источник повторного форматирования. + external.clear() # Старые кадры не вернутся после смены парсера. + console.clear() # Повторный программный вызов не должен снова эмитировать пользовательский сигнал. + console.userCleared.connect(clear_owner) # Подключаем тот же контракт, что консоли платы и TX/RX SETGUI. + console.appendPlainText("строка") # До очистки журнал содержит данные. + console.clear_button.click() # Нажимаем настоящую кнопку Qt. + assert not external and not console.toPlainText() # Обе истории очищены одним действием. diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 11f14f1..fa89240 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -64,10 +64,28 @@ class PlotTests(unittest.TestCase): 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=1e10) - self.assertEqual(1e-7, limited.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/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()) # Устаревшая ведомость не допускается к упаковке незаметно.