Compare commits
2 Commits
qcustomplo
...
ump-v4
| Author | SHA1 | Date | |
|---|---|---|---|
| a5e46d4627 | |||
| 95c8b8ac7d |
@@ -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})
|
||||
|
||||
@@ -34,6 +34,86 @@
|
||||
#include "candle_defs.h"
|
||||
#include "candle_ctrl_req.h"
|
||||
#include "ch_9.h"
|
||||
#include <cfgmgr32.h> /* Сопоставляет интерфейсы 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-контроллера. */
|
||||
} /* Завершение управления встроенным окончанием шины. */
|
||||
|
||||
@@ -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 — включён. */
|
||||
|
||||
|
||||
@@ -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 и других оболочек.
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
#include "set_plot.h"
|
||||
#include <math.h>
|
||||
|
||||
/* 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);
|
||||
|
||||
@@ -18,7 +18,8 @@ unsigned ump_validate(unsigned fn, uint16_t a, uint16_t n, int can)
|
||||
return can ? ((a == UMP_FIRMWARE_INFO && n == UMP_FIRMWARE_INFO_WORDS) ? 0 : 2)
|
||||
: ((n && n <= UMP_FIRMWARE_INFO + UMP_FIRMWARE_INFO_WORDS - a) ? 0 : 2);
|
||||
if (can) return ((a == UMP_BASE && n == 16) ||
|
||||
(a == UMP_DATA && (n == 40 || n == 80 || n == 120 || n == 42 || n == 84)) ||
|
||||
/* Разрешает целые 12-словные записи v4 до 120 слов; размеры v2/v3 сохраняются для прежних устройств. */
|
||||
(a == UMP_DATA && (n == 40 || n == 80 || n == 42 || n == 84 || (n && n <= 120 && !(n % 12U)))) ||
|
||||
(a == UMP_LIVE && n == 124)) ? 0 : 2;
|
||||
if (!n || n > 125) return 2;
|
||||
return ((a >= UMP_BASE && a < UMP_BASE+16 && n <= UMP_BASE+16-a) ||
|
||||
|
||||
2
c/set-protocol/tests/fixtures/plot-v1.json
vendored
2
c/set-protocol/tests/fixtures/plot-v1.json
vendored
@@ -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]},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
46
python/examples/console_examples.py
Normal file
46
python/examples/console_examples.py
Normal file
@@ -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 возвращается вызывающему процессу.
|
||||
@@ -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,
|
||||
|
||||
63
python/logic_analyzer/decoders/template_protocols.py
Normal file
63
python/logic_analyzer/decoders/template_protocols.py
Normal file
@@ -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-формата обоими существующими уровнями.
|
||||
29
python/logic_analyzer/decoders/tms_uart.py
Normal file
29
python/logic_analyzer/decoders/tms_uart.py
Normal file
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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`: перемещаются влево, вправо, вверх, вниз и в общую группу
|
||||
вкладок со штатной подсветкой области вставки. Разделители меняют размеры.
|
||||
Перенос сохраняет экземпляры панелей, соединение и выполняемые задания.
|
||||
Раскладка сохраняется между запусками; «Сбросить раскладку» возвращает
|
||||
стандартное расположение без пересоздания содержимого.
|
||||
|
||||
113
python/pysetterminal/bus_operations.py
Normal file
113
python/pysetterminal/bus_operations.py
Normal file
@@ -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} # Заполненная таблица подтверждается данными после паузы, пустая остаётся остановленной.
|
||||
144
python/pysetterminal/exchange_queue.py
Normal file
144
python/pysetterminal/exchange_queue.py
Normal file
@@ -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 # Возвращаем единый планировщик ручного обмена и периодических заданий.
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
346
python/pysetterminal/pbus_panel.py
Normal file
346
python/pysetterminal/pbus_panel.py
Normal file
@@ -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 # Новое подключение должно подтвердить весь снимок таблицы заново.
|
||||
@@ -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):
|
||||
"""Чтение сырых данных"""
|
||||
|
||||
File diff suppressed because one or more lines are too long
29
python/set_devices/background_work.py
Normal file
29
python/set_devices/background_work.py
Normal file
@@ -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() # Последняя проверка не допускает публикации результата, отменённого в конце короткого блока.
|
||||
@@ -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)))
|
||||
|
||||
147
python/set_devices/can_adapter_settings.py
Normal file
147
python/set_devices/can_adapter_settings.py
Normal file
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
174
python/set_devices/plot_envelope_index.py
Normal file
174
python/set_devices/plot_envelope_index.py
Normal file
@@ -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 # Запоминаем конец текущего пути для следующей выбранной точки.
|
||||
@@ -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])
|
||||
|
||||
10
python/set_devices/plot_ticks.py
Normal file
10
python/set_devices/plot_ticks.py
Normal file
@@ -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, а разность порядков сохраняет отдельную подпись для каждого деления.
|
||||
68
python/set_devices/qt_ports/CONSOLE.md
Normal file
68
python/set_devices/qt_ports/CONSOLE.md
Normal file
@@ -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.
|
||||
175
python/set_devices/qt_ports/can_frame_builder.py
Normal file
175
python/set_devices/qt_ports/can_frame_builder.py
Normal file
@@ -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_ разделяют три независимых состояния.
|
||||
@@ -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) # Готовая пачка отображается за одно обновление виджета.
|
||||
|
||||
File diff suppressed because one or more lines are too long
180
python/set_devices/qt_ports/console.py
Normal file
180
python/set_devices/qt_ports/console.py
Normal file
@@ -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 ('<span style="color:%s;white-space:pre">%s</span>' % (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)) # Причина ошибки остаётся доступна оператору.
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 — начало/конец.')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) # Текст можно скопировать для анализа состояния устройства.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -22,6 +22,12 @@ FAST_CAPABILITY = 0x1220
|
||||
FAST_DATA = 0x2000
|
||||
WORDS = 40
|
||||
CAPACITY = 64000 // (2 * WORDS)
|
||||
# Компактная запись v4 имеет 12 слов; старые 40/42 слова остаются поддержанными для чтения архивов.
|
||||
COMPACT_WORDS = 12
|
||||
# Ёмкость 760 точек сохраняет прежнее число записей постинтервала и поведение кольцевых банков.
|
||||
COMPACT_CAPACITY = 760
|
||||
# Ключи соответствуют индексам оставшихся полей v2/v3, значения — индексам этих же каналов в компактном v4.
|
||||
COMPACT_INDEX = {2: 2, 3: 3, 4: 4, 5: 5, 9: 6, 24: 7, 25: 8, 40: 9, 41: 10}
|
||||
FIELDS = (
|
||||
('off_trigger', 'Событие отключения', 2, 8),
|
||||
('post_truncated', 'Усечение постинтервала', 2, 9),
|
||||
@@ -33,68 +39,37 @@ FIELDS = (
|
||||
('start_out', 'Выход START_DPTB', 5, 0),
|
||||
('stop_out', 'Выход STOP_DPTB', 5, 1),
|
||||
('res_out', 'Выход RES_OUT_2', 5, 2),
|
||||
('impulse', 'Остаток импульса, мс', 6, None),
|
||||
('wake', 'Инициализация ЦАП, тики ЦАП', 7, None),
|
||||
('dac', 'Код ЦАП', 8, None),
|
||||
('current', 'Расчётное задание тока, мА', 9, None),
|
||||
('sensor0', 'Измерение 0', 10, None),
|
||||
('sensor1', 'Измерение 1', 11, None),
|
||||
('sensor2', 'Измерение 2', 12, None),
|
||||
('sensor3', 'Измерение 3', 13, None),
|
||||
('error0', 'Ошибки 0 (маска)', 14, None),
|
||||
('error1', 'Ошибки 1 (маска)', 15, None),
|
||||
('error2', 'Ошибки 2 (маска)', 16, None),
|
||||
('error3', 'Ошибки 3 (маска)', 17, None),
|
||||
('can_header', 'CAN адрес/маска', 18, None),
|
||||
('can0', 'CAN слово 0', 19, None),
|
||||
('can1', 'CAN слово 1', 20, None),
|
||||
('can2', 'CAN слово 2', 21, None),
|
||||
('can_count', 'Количество CAN RX (mod 65536)', 22, None),
|
||||
('can_timeout', 'Таймауты CAN TX (mod 65536)', 23, None),
|
||||
('commands', 'Команды (маска)', 3, None),
|
||||
('go', 'Начальная уставка, мА', 24, None),
|
||||
('stop', 'Конечная уставка, мА', 25, None),
|
||||
('dac_min', 'Код калибровки 4 мА', 26, None),
|
||||
('dac_max', 'Код калибровки 20 мА', 27, None),
|
||||
('dac_cal', 'Калибровочный код ЦАП', 28, None),
|
||||
('dac04', 'DAC_04 (регистр)', 29, None),
|
||||
('dac20', 'DAC_20 (регистр)', 30, None),
|
||||
('ramp', 'Счётчик разгона', 31, None),
|
||||
('duration', 'Длительность разгона, тики ЦАП', 32, None),
|
||||
('effective_go', 'Начальная уставка после ограничений, мА', 33, None),
|
||||
('effective_stop', 'Конечная уставка после ограничений, мА', 34, None),
|
||||
('position', 'Положение разгона (kod)', 35, None),
|
||||
('adc0', 'АЦП канал 0', 36, None),
|
||||
('adc1', 'АЦП канал 1', 37, None),
|
||||
('adc2', 'АЦП канал 2', 38, None),
|
||||
('adc3', 'АЦП канал 3', 39, None),
|
||||
('zpt1', 'ЗПТ1: U1 + U2 (ПМ67, рег. 306)', 40, None),
|
||||
('zpt2', 'ЗПТ2: U3 + U4 (ПМ67, рег. 9)', 41, None),
|
||||
)
|
||||
|
||||
|
||||
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes:
|
||||
if not 0 <= controller <= 255 or not 0 <= value <= 65535:
|
||||
raise ValueError('Недопустимый адрес контроллера или значение')
|
||||
if record_words not in (40, 42):
|
||||
raise ValueError('Неверный размер записи логгера')
|
||||
capacity = {40: 800, 42: 760}[record_words]
|
||||
if function == 3:
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
|
||||
or (address == FAST_CAPABILITY and value == 4)
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
|
||||
and (address - FAST_DATA) % record_words == 0
|
||||
and 1 <= value <= 120 and value % record_words == 0
|
||||
and address + value <= FAST_DATA + capacity * record_words))
|
||||
else:
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION + 3 and 1 <= value <= 60000) or
|
||||
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
(address == EMU_CONTROL + 2 and value in (0, 1)))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос логгера УМП')
|
||||
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big'))
|
||||
def request(controller: int, function: int, address: int, value: int, *, record_words: int = WORDS) -> bytes: # Проверяет запрос с учётом ширины записи, прочитанной из статуса платы.
|
||||
if not 0 <= controller <= 255 or not 0 <= value <= 65535: # Ограничивает адрес одним октетом, значение — 16-битным словом.
|
||||
raise ValueError('Недопустимый адрес контроллера или значение') # Запрещает усечение входных параметров при упаковке.
|
||||
if record_words not in (COMPACT_WORDS, 40, 42): # Допускает только известные компактный и прежние форматы записи.
|
||||
raise ValueError('Неверный размер записи логгера') # Не позволяет строить адреса по неизвестной ширине.
|
||||
capacity = {COMPACT_WORDS: COMPACT_CAPACITY, 40: 800, 42: 760}[record_words] # Выбирает реальную границу банка по формату.
|
||||
if function == 3: # Разрешает только существующие диагностические блоки чтения.
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) # Читает полный блок защиты либо дескриптор журнала.
|
||||
or (address == DATA and 1 <= value <= 120 and (value % COMPACT_WORDS == 0 or value in (40, 80, 42, 84))) # Принимает целые записи v4 и исторические блоки v2/v3.
|
||||
or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4) # Сохраняет фиксированные размеры онлайн-блока и управления эмуляцией.
|
||||
or (address == FAST_CAPABILITY and value == 4) # Проверяет возможность ускоренного архивного чтения.
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words # Ограничивает адрес началом и концом реального банка.
|
||||
and (address - FAST_DATA) % record_words == 0 # Требует начала блока на границе записи.
|
||||
and 1 <= value <= 120 and value % record_words == 0 # Передаёт до 120 слов, содержащих целое число записей.
|
||||
and address + value <= FAST_DATA + capacity * record_words)) # Исключает пересечение конца банка.
|
||||
else: # Запись разрешена только прежним управляющим адресам.
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) # Проверяет маску эмуляции защиты.
|
||||
or (address == PROTECTION + 3 and 1 <= value <= 60000) # Проверяет тайм-аут свежести CAN в миллисекундах.
|
||||
or address in (BASE, BASE + 1, BASE + 2, BASE + 3) # Оставляет команды управления журналом без запуска УМП.
|
||||
or (address == EMU_CONTROL + 2 and value in (0, 1))) # Сохраняет двоичное включение эмулятора.
|
||||
if not valid: # Запрещает запросы вне согласованных адресов и размеров.
|
||||
raise ValueError('Недопустимый запрос логгера УМП') # Возвращает прежнее понятное сообщение вызывающему коду.
|
||||
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big')) # Передаёт 16-битный адрес и добавляет общий Modbus CRC.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -114,27 +89,27 @@ class Status:
|
||||
@classmethod
|
||||
def decode(cls, words):
|
||||
if (len(words) != 16 or words[0] != 0x554D or
|
||||
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760))):
|
||||
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3')
|
||||
(words[1], words[4], words[5]) not in ((2, 40, 800), (3, 42, 760), (4, COMPACT_WORDS, COMPACT_CAPACITY))): # Проверяет согласованные версию, ширину и ёмкость.
|
||||
raise ValueError('Прошивка не поддерживает логгер УМП v2/v3/v4') # Перечисляет известные форматы без разрешения неизвестной прошивки.
|
||||
if words[9] != 6:
|
||||
raise ValueError('Логгер УМП доступен только на плате нагрузки dsk_LOAD')
|
||||
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > 1000 or words[15] not in (0, 1, 3):
|
||||
post_limit = max(1000, min(65535, words[5] // 10 * words[6])) if words[1] >= 3 else 1000 # Постинтервал v3/v4 занимает 10% точек; при 760 точках и 20 мс его оценка достигает 1520 мс.
|
||||
if words[2] not in (0, 1, 2, 3) or words[3] > words[5] or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > words[5] or words[14] > post_limit or words[15] not in (0, 1, 3):
|
||||
raise ValueError('Повреждён статус логгера')
|
||||
return cls(words[2], words[3], words[6], words[10], words[11], *words[12:16], words[4], words[5])
|
||||
|
||||
|
||||
def sample(words, frequency=1000):
|
||||
if len(words) not in (40, 42) or frequency <= 0:
|
||||
if len(words) not in (COMPACT_WORDS, 40, 42) or frequency <= 0: # Допускает только известную ширину записи и положительную частоту времени.
|
||||
raise ValueError('Неверная запись логгера')
|
||||
result = {'time_ms': (words[0] | words[1] << 16) * 1000 / frequency, 'event': words[2]}
|
||||
for key, _label, index, bit in FIELDS:
|
||||
value = words[index] if index < len(words) else float('nan')
|
||||
physical_index = COMPACT_INDEX[index] if len(words) == COMPACT_WORDS else index # Выбирает индекс оставшегося поля по фактической версии записи.
|
||||
value = words[physical_index] if physical_index < len(words) else float('nan') # ЗПТ в старых 40-словных архивах остаётся неизвестным.
|
||||
if bit is not None:
|
||||
value = (value >> bit) & 1
|
||||
elif 10 <= index <= 13 and value >= 32768:
|
||||
value -= 65536
|
||||
# Физические единицы приводим здесь, одинаково для CSV и графика.
|
||||
if key in ('current', 'effective_go', 'effective_stop'):
|
||||
if key == 'current': # Сохранившееся задание имеет масштаб 0,1 мА; уставки и напряжения передаются целыми единицами.
|
||||
value /= 10.0
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
@@ -27,7 +27,7 @@ def build_request(mode, token, function, address, value):
|
||||
if not 1 <= token <= 255:
|
||||
raise ValueError('Неверный номер CAN-запроса')
|
||||
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and value in (40, 80, 120, 42, 84)) or
|
||||
(address == DATA and (value in (40, 80, 42, 84) or 0 < value <= 120 and value % 12 == 0)) or # Читает целые компактные записи и прежние архивы.
|
||||
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
|
||||
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
|
||||
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user