Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
9
c/candle/CMakeLists.txt
Normal file
9
c/candle/CMakeLists.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
project(candle C)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "Candle transport requires Windows WinUSB")
|
||||
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_include_directories(candle PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
66
c/candle/LICENSE
Normal file
66
c/candle/LICENSE
Normal file
File diff suppressed because one or more lines are too long
16
c/candle/README.md
Normal file
16
c/candle/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Candle / gs_usb — порт WinUSB
|
||||
|
||||
Общий C-порт для адаптеров candleLight/gs_usb. Перенесён из
|
||||
SETGUI `src/gui_desktop/native/candle_src`; исходные файлы и LGPLv3
|
||||
`LICENSE` сохранены без изменений.
|
||||
|
||||
Сборка из Developer Command Prompt:
|
||||
|
||||
```bat
|
||||
cmake -S c/candle -B build/candle -A x64
|
||||
cmake --build build/candle --config Release
|
||||
```
|
||||
|
||||
Для 32-битной библиотеки используйте `-A Win32` и отдельный каталог сборки.
|
||||
Python Qt-порт находится в `python/set_devices/qt_ports/candle_adapter.py`;
|
||||
путь к библиотеке задаётся через `CANDLE_LIBRARY`.
|
||||
1151
c/candle/candle.c
Normal file
1151
c/candle/candle.c
Normal file
@@ -0,0 +1,1151 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#include "candle.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "candle_defs.h"
|
||||
#include "candle_ctrl_req.h"
|
||||
#include "ch_9.h"
|
||||
|
||||
static bool candle_dev_interal_open(candle_handle hdev);
|
||||
|
||||
candle_log_fn_t candle_log_fn = NULL;
|
||||
bool candle_log_verbose = false;
|
||||
|
||||
static void candle_logf(const wchar_t *fmt, ...)
|
||||
{
|
||||
if (candle_log_fn == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t buf[512];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
HRESULT hr = StringCchVPrintfW(buf, 512, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
if (SUCCEEDED(hr)) {
|
||||
candle_log_fn(buf);
|
||||
}
|
||||
}
|
||||
|
||||
static void candle_logf_verbose(const wchar_t *fmt, ...)
|
||||
{
|
||||
if (candle_log_fn == NULL || !candle_log_verbose) {
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t buf[512];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
HRESULT hr = StringCchVPrintfW(buf, 512, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
if (SUCCEEDED(hr)) {
|
||||
candle_log_fn(buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool candle_read_di(HDEVINFO hdi, SP_DEVICE_INTERFACE_DATA interfaceData, candle_device_t *dev)
|
||||
{
|
||||
/* get required length first (this call always fails with an error) */
|
||||
ULONG requiredLength=0;
|
||||
SetupDiGetDeviceInterfaceDetail(hdi, &interfaceData, NULL, 0, &requiredLength, NULL);
|
||||
if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) {
|
||||
dev->last_error = CANDLE_ERR_SETUPDI_IF_DETAILS;
|
||||
return false;
|
||||
}
|
||||
|
||||
PSP_DEVICE_INTERFACE_DETAIL_DATA detail_data =
|
||||
(PSP_DEVICE_INTERFACE_DETAIL_DATA) LocalAlloc(LMEM_FIXED, requiredLength);
|
||||
|
||||
if (detail_data != NULL) {
|
||||
detail_data->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA);
|
||||
} else {
|
||||
dev->last_error = CANDLE_ERR_MALLOC;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool retval = true;
|
||||
ULONG length = requiredLength;
|
||||
if (!SetupDiGetDeviceInterfaceDetail(hdi, &interfaceData, detail_data, length, &requiredLength, NULL) ) {
|
||||
dev->last_error = CANDLE_ERR_SETUPDI_IF_DETAILS2;
|
||||
retval = false;
|
||||
} else if (FAILED(StringCchCopy(dev->path, sizeof(dev->path), detail_data->DevicePath))) {
|
||||
dev->last_error = CANDLE_ERR_PATH_LEN;
|
||||
retval = false;
|
||||
}
|
||||
|
||||
LocalFree(detail_data);
|
||||
|
||||
if (!retval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* try to open to read device infos and see if it is avail */
|
||||
if (candle_dev_interal_open(dev)) {
|
||||
dev->state = CANDLE_DEVSTATE_AVAIL;
|
||||
candle_dev_close(dev);
|
||||
} else {
|
||||
dev->state = CANDLE_DEVSTATE_INUSE;
|
||||
}
|
||||
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Return true when path already appears in l->dev[0..count-1]. */
|
||||
static bool candle_path_exists(const candle_list_t *l, unsigned count, const wchar_t *path)
|
||||
{
|
||||
for (unsigned i = 0; i < count; i++) {
|
||||
if (wcscmp(l->dev[i].path, path) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Scan one GUID and append found devices to l->dev[] starting at offset.
|
||||
* Returns the number of devices appended, or -1 on a hard error (l->last_error set). */
|
||||
static int candle_scan_guid(candle_list_t *l, const wchar_t *guid_str, unsigned offset)
|
||||
{
|
||||
GUID guid;
|
||||
if (CLSIDFromString(guid_str, &guid) != NOERROR) {
|
||||
l->last_error = CANDLE_ERR_CLSID;
|
||||
return -1;
|
||||
}
|
||||
|
||||
HDEVINFO hdi = SetupDiGetClassDevs(&guid, NULL, NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
|
||||
if (hdi == INVALID_HANDLE_VALUE) {
|
||||
/* No devices with this GUID present — not a hard error. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int found = 0;
|
||||
for (unsigned i = 0; (offset + i) < CANDLE_MAX_DEVICES; i++) {
|
||||
SP_DEVICE_INTERFACE_DATA interfaceData;
|
||||
interfaceData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA);
|
||||
|
||||
if (!SetupDiEnumDeviceInterfaces(hdi, NULL, &guid, i, &interfaceData)) {
|
||||
if (GetLastError() != ERROR_NO_MORE_ITEMS) {
|
||||
l->last_error = CANDLE_ERR_SETUPDI_IF_ENUM;
|
||||
found = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!candle_read_di(hdi, interfaceData, &l->dev[offset + i])) {
|
||||
l->last_error = l->dev[offset + i].last_error;
|
||||
found = -1;
|
||||
break;
|
||||
}
|
||||
found++;
|
||||
}
|
||||
|
||||
SetupDiDestroyDeviceInfoList(hdi);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Scan for WinUSB devices matching vid:pid whose device interface GUID was not
|
||||
* covered by the GUID list above. For each matching USB device instance the
|
||||
* function reads DeviceInterfaceGUIDs (or DeviceInterfaceGUID) from the Windows
|
||||
* registry, re-uses candle_scan_guid() for each GUID found there, and appends
|
||||
* only those devices that are not already present in l->dev[0..existing-1].
|
||||
* Returns the number of new devices added. */
|
||||
static int candle_scan_vidpid(candle_list_t *l, uint16_t vid, uint16_t pid, unsigned existing)
|
||||
{
|
||||
wchar_t hwid_prefix[32];
|
||||
StringCchPrintfW(hwid_prefix, 32, L"USB\\VID_%04X&PID_%04X", vid, pid);
|
||||
|
||||
/* Enumerate USB device instances (not interfaces) so we can read hardware IDs. */
|
||||
HDEVINFO hdi = SetupDiGetClassDevs(NULL, L"USB", NULL,
|
||||
DIGCF_ALLCLASSES | DIGCF_PRESENT);
|
||||
if (hdi == INVALID_HANDLE_VALUE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
SP_DEVINFO_DATA devInfo;
|
||||
devInfo.cbSize = sizeof(SP_DEVINFO_DATA);
|
||||
|
||||
for (DWORD i = 0;
|
||||
SetupDiEnumDeviceInfo(hdi, i, &devInfo) && existing + added < CANDLE_MAX_DEVICES;
|
||||
i++)
|
||||
{
|
||||
|
||||
/* Hardware IDs are a REG_MULTI_SZ — check each string for our VID/PID prefix. */
|
||||
wchar_t hwids[512];
|
||||
memset(hwids, 0, sizeof(hwids));
|
||||
if (!SetupDiGetDeviceRegistryPropertyW(hdi, &devInfo, SPDRP_HARDWAREID,
|
||||
NULL, (PBYTE)hwids, sizeof(hwids) - sizeof(wchar_t), NULL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool matches = false;
|
||||
const wchar_t *p;
|
||||
for (p = hwids; *p; p += wcslen(p) + 1) {
|
||||
if (_wcsnicmp(p, hwid_prefix, wcslen(hwid_prefix)) == 0) {
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Open the device's software registry key (Device Parameters) and read
|
||||
* the WinUSB device interface GUID(s) stored by the driver INF. */
|
||||
HKEY hKey = SetupDiOpenDevRegKey(hdi, &devInfo, DICS_FLAG_GLOBAL, 0,
|
||||
DIREG_DEV, KEY_READ);
|
||||
if (hKey == INVALID_HANDLE_VALUE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
wchar_t guid_buf[256];
|
||||
memset(guid_buf, 0, sizeof(guid_buf));
|
||||
DWORD buf_len = sizeof(guid_buf) - sizeof(wchar_t);
|
||||
|
||||
/* Prefer DeviceInterfaceGUIDs (REG_MULTI_SZ, modern INFs); fall back to
|
||||
* DeviceInterfaceGUID (REG_SZ, older/zadig-generated INFs). */
|
||||
LONG reg_rc = RegQueryValueExW(hKey, L"DeviceInterfaceGUIDs", NULL, NULL,
|
||||
(LPBYTE)guid_buf, &buf_len);
|
||||
if (reg_rc != ERROR_SUCCESS) {
|
||||
buf_len = sizeof(guid_buf) - sizeof(wchar_t);
|
||||
RegQueryValueExW(hKey, L"DeviceInterfaceGUID", NULL, NULL,
|
||||
(LPBYTE)guid_buf, &buf_len);
|
||||
}
|
||||
RegCloseKey(hKey);
|
||||
|
||||
if (!guid_buf[0]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Iterate GUID strings. Both REG_SZ and REG_MULTI_SZ are covered by the
|
||||
* same NUL-terminated-string walk (REG_SZ just has one entry). */
|
||||
const wchar_t *g;
|
||||
for (g = guid_buf;
|
||||
*g && existing + added < CANDLE_MAX_DEVICES;
|
||||
g += wcslen(g) + 1)
|
||||
{
|
||||
unsigned base = existing + added;
|
||||
int n = candle_scan_guid(l, g, base);
|
||||
if (n <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Remove any entries whose path was already found by the GUID scan. */
|
||||
for (int ni = 0; ni < n; ) {
|
||||
if (candle_path_exists(l, base, l->dev[base + ni].path)) {
|
||||
memmove(&l->dev[base + ni], &l->dev[base + ni + 1],
|
||||
(unsigned)(n - ni - 1) * sizeof(candle_device_t));
|
||||
n--;
|
||||
} else {
|
||||
ni++;
|
||||
}
|
||||
}
|
||||
added += n;
|
||||
}
|
||||
}
|
||||
|
||||
SetupDiDestroyDeviceInfoList(hdi);
|
||||
return added;
|
||||
}
|
||||
|
||||
bool __stdcall candle_list_scan(candle_list_handle *list)
|
||||
{
|
||||
if (list == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
candle_list_t *l = (candle_list_t *)calloc(1, sizeof(candle_list_t));
|
||||
*list = l;
|
||||
if (l == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* GUIDs for gs_usb-compatible devices on Windows.
|
||||
* candleLight / CANable / most gs_usb devices: */
|
||||
static const wchar_t *GUIDS[] = {
|
||||
L"{c15b4308-04d3-11e6-b3ea-6057189e6443}" /* candleLight / CANable / gs_usb standard */
|
||||
};
|
||||
static const unsigned NUM_GUIDS = sizeof(GUIDS) / sizeof(GUIDS[0]);
|
||||
|
||||
unsigned total = 0;
|
||||
for (unsigned g = 0; g < NUM_GUIDS; g++) {
|
||||
int n = candle_scan_guid(l, GUIDS[g], total);
|
||||
if (n < 0) {
|
||||
return false;
|
||||
}
|
||||
total += (unsigned)n;
|
||||
}
|
||||
|
||||
/* VID/PID scan for devices whose device interface GUID is not in the list
|
||||
* above (e.g. CANnectivity which uses its own registered interface GUID). */
|
||||
static const struct { uint16_t vid; uint16_t pid; } VIDPIDS[] = {
|
||||
{ 0x1209, 0xCA01 }, /* CANnectivity (electronut-labs) */
|
||||
};
|
||||
static const unsigned NUM_VIDPIDS = sizeof(VIDPIDS) / sizeof(VIDPIDS[0]);
|
||||
|
||||
for (unsigned v = 0; v < NUM_VIDPIDS && total < CANDLE_MAX_DEVICES; v++) {
|
||||
int n = candle_scan_vidpid(l, VIDPIDS[v].vid, VIDPIDS[v].pid, total);
|
||||
if (n > 0)
|
||||
total += (unsigned)n;
|
||||
}
|
||||
|
||||
l->num_devices = (uint8_t)total;
|
||||
l->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_list_free(candle_list_handle list)
|
||||
{
|
||||
free(list);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_list_length(candle_list_handle list, uint8_t *len)
|
||||
{
|
||||
candle_list_t *l = (candle_list_t *)list;
|
||||
*len = l->num_devices;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_dev_get(candle_list_handle list, uint8_t dev_num, candle_handle *hdev)
|
||||
{
|
||||
candle_list_t *l = (candle_list_t *)list;
|
||||
if (l==NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dev_num >= CANDLE_MAX_DEVICES) {
|
||||
l->last_error = CANDLE_ERR_DEV_OUT_OF_RANGE;
|
||||
return false;
|
||||
}
|
||||
|
||||
candle_device_t *dev = calloc(1, sizeof(candle_device_t));
|
||||
*hdev = dev;
|
||||
if (dev==NULL) {
|
||||
l->last_error = CANDLE_ERR_MALLOC;
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(dev, &l->dev[dev_num], sizeof(candle_device_t));
|
||||
l->last_error = CANDLE_ERR_OK;
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool __stdcall DLL candle_dev_get_state(candle_handle hdev, candle_devstate_t *state)
|
||||
{
|
||||
if (hdev==NULL) {
|
||||
return false;
|
||||
} else {
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
*state = dev->state;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
wchar_t * __stdcall DLL candle_dev_get_path(candle_handle hdev)
|
||||
{
|
||||
if (hdev==NULL) {
|
||||
return NULL;
|
||||
} else {
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return dev->path;
|
||||
}
|
||||
}
|
||||
|
||||
static bool candle_dev_interal_open(candle_handle hdev)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
memset(dev->rxevents, 0, sizeof(dev->rxevents));
|
||||
memset(dev->rxurbs, 0, sizeof(dev->rxurbs));
|
||||
|
||||
dev->deviceHandle = CreateFile(
|
||||
dev->path,
|
||||
GENERIC_WRITE | GENERIC_READ,
|
||||
FILE_SHARE_WRITE | FILE_SHARE_READ,
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
|
||||
NULL
|
||||
);
|
||||
|
||||
if (dev->deviceHandle == INVALID_HANDLE_VALUE) {
|
||||
dev->last_error = CANDLE_ERR_CREATE_FILE;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!WinUsb_Initialize(dev->deviceHandle, &dev->winUSBHandle)) {
|
||||
dev->last_error = CANDLE_ERR_WINUSB_INITIALIZE;
|
||||
goto close_handle;
|
||||
}
|
||||
|
||||
USB_INTERFACE_DESCRIPTOR ifaceDescriptor;
|
||||
if (!WinUsb_QueryInterfaceSettings(dev->winUSBHandle, 0, &ifaceDescriptor)) {
|
||||
dev->last_error = CANDLE_ERR_QUERY_INTERFACE;
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
dev->interfaceNumber = ifaceDescriptor.bInterfaceNumber;
|
||||
bool has_in = false, has_out = false;
|
||||
|
||||
candle_logf(L"open path=%ls interface=%u endpoints=%u",
|
||||
dev->path,
|
||||
dev->interfaceNumber,
|
||||
ifaceDescriptor.bNumEndpoints);
|
||||
|
||||
for (uint8_t i=0; i<ifaceDescriptor.bNumEndpoints; i++) {
|
||||
|
||||
WINUSB_PIPE_INFORMATION pipeInfo;
|
||||
if (!WinUsb_QueryPipe(dev->winUSBHandle, 0, i, &pipeInfo)) {
|
||||
dev->last_error = CANDLE_ERR_QUERY_PIPE;
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
if (pipeInfo.PipeType == UsbdPipeTypeBulk && USB_ENDPOINT_DIRECTION_IN(pipeInfo.PipeId)) {
|
||||
if (!has_in) {
|
||||
dev->bulkInPipe = pipeInfo.PipeId;
|
||||
has_in = true;
|
||||
candle_logf(L"selected bulk IN pipe=0x%02x maxPacket=%u interval=%u",
|
||||
pipeInfo.PipeId,
|
||||
pipeInfo.MaximumPacketSize,
|
||||
pipeInfo.Interval);
|
||||
}
|
||||
} else if (pipeInfo.PipeType == UsbdPipeTypeBulk && USB_ENDPOINT_DIRECTION_OUT(pipeInfo.PipeId)) {
|
||||
if (!has_out) {
|
||||
dev->bulkOutPipe = pipeInfo.PipeId;
|
||||
has_out = true;
|
||||
candle_logf(L"selected bulk OUT pipe=0x%02x maxPacket=%u interval=%u",
|
||||
pipeInfo.PipeId,
|
||||
pipeInfo.MaximumPacketSize,
|
||||
pipeInfo.Interval);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!has_in || !has_out) {
|
||||
dev->last_error = CANDLE_ERR_PARSE_IF_DESCR;
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
char use_raw_io = 1;
|
||||
if (!WinUsb_SetPipePolicy(dev->winUSBHandle, dev->bulkInPipe, RAW_IO, sizeof(use_raw_io), &use_raw_io)) {
|
||||
dev->last_error = CANDLE_ERR_SET_PIPE_RAW_IO;
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
if (!candle_ctrl_set_host_format(dev)) {
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
if (!candle_ctrl_get_config(dev, &dev->dconf)) {
|
||||
goto winusb_free;
|
||||
}
|
||||
candle_logf(L"device config channels=%u sw=0x%08x hw=0x%08x",
|
||||
dev->dconf.icount + 1,
|
||||
dev->dconf.sw_version,
|
||||
dev->dconf.hw_version);
|
||||
|
||||
if (!candle_ctrl_get_capability(dev, 0, &dev->bt_const)) {
|
||||
dev->last_error = CANDLE_ERR_GET_BITTIMING_CONST;
|
||||
goto winusb_free;
|
||||
}
|
||||
candle_logf(L"cap ch0 feature=0x%08x fclk=%u tseg1=%u..%u tseg2=%u..%u sjw=%u brp=%u..%u inc=%u",
|
||||
dev->bt_const.feature,
|
||||
dev->bt_const.fclk_can,
|
||||
dev->bt_const.tseg1_min,
|
||||
dev->bt_const.tseg1_max,
|
||||
dev->bt_const.tseg2_min,
|
||||
dev->bt_const.tseg2_max,
|
||||
dev->bt_const.sjw_max,
|
||||
dev->bt_const.brp_min,
|
||||
dev->bt_const.brp_max,
|
||||
dev->bt_const.brp_inc);
|
||||
|
||||
/* Query capabilities for each channel on multi-channel devices */
|
||||
uint8_t num_channels = dev->dconf.icount + 1;
|
||||
if (num_channels > 8) num_channels = 8;
|
||||
for (uint8_t ch = 0; ch < num_channels; ch++) {
|
||||
if (!candle_ctrl_get_capability(dev, ch, &dev->ch_caps[ch])) {
|
||||
/* Fall back to channel 0 capabilities for this channel */
|
||||
memcpy(&dev->ch_caps[ch], &dev->bt_const, sizeof(candle_capability_t));
|
||||
candle_logf(L"cap ch%u failed, falling back to ch0", ch);
|
||||
} else {
|
||||
candle_logf(L"cap ch%u feature=0x%08x fclk=%u",
|
||||
ch,
|
||||
dev->ch_caps[ch].feature,
|
||||
dev->ch_caps[ch].fclk_can);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pre-allocate a manual-reset event for timed overlapped writes. Reusing
|
||||
* one event per device (writes are serialised by writeMutex) avoids
|
||||
* per-frame CreateEvent overhead at high CAN frame rates. */
|
||||
dev->txEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
|
||||
if (!dev->txEvent) {
|
||||
dev->last_error = CANDLE_ERR_MALLOC;
|
||||
goto winusb_free;
|
||||
}
|
||||
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
|
||||
winusb_free:
|
||||
WinUsb_Free(dev->winUSBHandle);
|
||||
dev->winUSBHandle = NULL;
|
||||
|
||||
close_handle:
|
||||
CloseHandle(dev->deviceHandle);
|
||||
dev->deviceHandle = NULL;
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
static bool candle_prepare_read(candle_device_t *dev, unsigned urb_num)
|
||||
{
|
||||
if (dev->rxurbs[urb_num].pending) {
|
||||
dev->last_error = CANDLE_ERR_PREPARE_READ;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dev->rxurbs[urb_num].ovl.hEvent == NULL) {
|
||||
dev->last_error = CANDLE_ERR_PREPARE_READ;
|
||||
return false;
|
||||
}
|
||||
|
||||
ResetEvent(dev->rxurbs[urb_num].ovl.hEvent);
|
||||
|
||||
BOOL rc = WinUsb_ReadPipe(
|
||||
dev->winUSBHandle,
|
||||
dev->bulkInPipe,
|
||||
dev->rxurbs[urb_num].buf,
|
||||
sizeof(dev->rxurbs[urb_num].buf),
|
||||
NULL,
|
||||
&dev->rxurbs[urb_num].ovl
|
||||
);
|
||||
|
||||
if (rc) {
|
||||
/* Synchronous completion: data is already in buf and the event is
|
||||
* signaled. WaitForMultipleObjects will return immediately on the
|
||||
* next call and GetOverlappedResult will succeed, so this is fine. */
|
||||
dev->rxurbs[urb_num].pending = true;
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_IO_PENDING) {
|
||||
dev->rxurbs[urb_num].pending = true;
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
candle_logf(L"prepare read urb=%u failed winerr=%lu", urb_num, err);
|
||||
dev->last_error = CANDLE_ERR_PREPARE_READ;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool candle_close_rxurbs(candle_device_t *dev)
|
||||
{
|
||||
if (dev->winUSBHandle != NULL) {
|
||||
WinUsb_AbortPipe(dev->winUSBHandle, dev->bulkInPipe);
|
||||
}
|
||||
|
||||
for (unsigned i=0; i<CANDLE_URB_COUNT; i++) {
|
||||
if (dev->rxurbs[i].pending) {
|
||||
CancelIoEx(dev->deviceHandle, &dev->rxurbs[i].ovl);
|
||||
|
||||
DWORD bytes_transfered;
|
||||
WinUsb_GetOverlappedResult(dev->winUSBHandle,
|
||||
&dev->rxurbs[i].ovl,
|
||||
&bytes_transfered,
|
||||
TRUE);
|
||||
dev->rxurbs[i].pending = false;
|
||||
}
|
||||
|
||||
if (dev->rxevents[i] != NULL) {
|
||||
CloseHandle(dev->rxevents[i]);
|
||||
dev->rxevents[i] = NULL;
|
||||
memset(&dev->rxurbs[i].ovl, 0, sizeof(dev->rxurbs[i].ovl));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void candle_release_open_handles(candle_device_t *dev)
|
||||
{
|
||||
candle_close_rxurbs(dev);
|
||||
|
||||
if (dev->txEvent) {
|
||||
CloseHandle(dev->txEvent);
|
||||
dev->txEvent = NULL;
|
||||
}
|
||||
|
||||
if (dev->winUSBHandle) {
|
||||
WinUsb_Free(dev->winUSBHandle);
|
||||
dev->winUSBHandle = NULL;
|
||||
}
|
||||
|
||||
if (dev->deviceHandle && dev->deviceHandle != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(dev->deviceHandle);
|
||||
dev->deviceHandle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool __stdcall DLL candle_dev_open(candle_handle hdev)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
if (candle_dev_interal_open(dev)) {
|
||||
for (unsigned i=0; i<CANDLE_URB_COUNT; i++) {
|
||||
HANDLE ev = CreateEvent(NULL, true, false, NULL);
|
||||
if (ev == NULL) {
|
||||
dev->last_error = CANDLE_ERR_MALLOC;
|
||||
candle_err_t last_error = dev->last_error;
|
||||
candle_release_open_handles(dev);
|
||||
dev->last_error = last_error;
|
||||
return false;
|
||||
}
|
||||
dev->rxevents[i] = ev;
|
||||
dev->rxurbs[i].ovl.hEvent = ev;
|
||||
if (!candle_prepare_read(dev, i)) {
|
||||
candle_err_t last_error = dev->last_error;
|
||||
candle_release_open_handles(dev);
|
||||
dev->last_error = last_error;
|
||||
return false; // keep last_error from prepare_read call
|
||||
}
|
||||
}
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
} else {
|
||||
return false; // keep last_error from open_device call
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_dev_get_timestamp_us(candle_handle hdev, uint32_t *timestamp_us)
|
||||
{
|
||||
return candle_ctrl_get_timestamp(hdev, timestamp_us);
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_dev_close(candle_handle hdev)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
candle_release_open_handles(dev);
|
||||
|
||||
dev->last_error = CANDLE_ERR_OK;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_dev_free(candle_handle hdev)
|
||||
{
|
||||
free(hdev);
|
||||
return true;
|
||||
}
|
||||
|
||||
candle_err_t __stdcall DLL candle_dev_last_error(candle_handle hdev)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return dev->last_error;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_count(candle_handle hdev, uint8_t *num_channels)
|
||||
{
|
||||
// TODO check if info was already read from device; try to do so; throw error...
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
*num_channels = dev->dconf.icount+1;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_get_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
uint8_t num_channels = dev->dconf.icount + 1;
|
||||
if (ch < num_channels && ch < 8) {
|
||||
memcpy(cap, &dev->ch_caps[ch], sizeof(candle_capability_t));
|
||||
} else {
|
||||
memcpy(cap, &dev->bt_const, sizeof(candle_capability_t));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_get_state(candle_handle hdev, uint8_t ch, candle_can_state_t *state)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
candle_device_state_t ds;
|
||||
if (!candle_ctrl_get_state(dev, ch, &ds)) {
|
||||
return false;
|
||||
}
|
||||
*state = (candle_can_state_t)ds.state;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_bus_off_recover(candle_handle hdev, uint8_t ch)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return candle_ctrl_bus_off_recover(dev, ch);
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_set_timing(candle_handle hdev, uint8_t ch, candle_bittiming_t *data)
|
||||
{
|
||||
// TODO ensure device is open, check channel count..
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return candle_ctrl_set_bittiming(dev, ch, data);
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_set_bitrate(candle_handle hdev, uint8_t ch, uint32_t bitrate)
|
||||
{
|
||||
// TODO ensure device is open, check channel count..
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
if (dev->bt_const.fclk_can != 48000000) {
|
||||
/* this function only works for the candleLight base clock of 48MHz */
|
||||
dev->last_error = CANDLE_ERR_BITRATE_FCLK;
|
||||
return false;
|
||||
}
|
||||
|
||||
candle_bittiming_t t;
|
||||
t.prop_seg = 1;
|
||||
t.sjw = 1;
|
||||
t.phase_seg1 = 13 - t.prop_seg;
|
||||
t.phase_seg2 = 2;
|
||||
|
||||
switch (bitrate) {
|
||||
case 10000:
|
||||
t.brp = 300;
|
||||
break;
|
||||
|
||||
case 20000:
|
||||
t.brp = 150;
|
||||
break;
|
||||
|
||||
case 50000:
|
||||
t.brp = 60;
|
||||
break;
|
||||
|
||||
case 83333:
|
||||
t.brp = 36;
|
||||
break;
|
||||
|
||||
case 100000:
|
||||
t.brp = 30;
|
||||
break;
|
||||
|
||||
case 125000:
|
||||
t.brp = 24;
|
||||
break;
|
||||
|
||||
case 250000:
|
||||
t.brp = 12;
|
||||
break;
|
||||
|
||||
case 500000:
|
||||
t.brp = 6;
|
||||
break;
|
||||
|
||||
case 800000:
|
||||
t.brp = 4;
|
||||
t.phase_seg1 = 12 - t.prop_seg;
|
||||
t.phase_seg2 = 2;
|
||||
break;
|
||||
|
||||
case 1000000:
|
||||
t.brp = 3;
|
||||
break;
|
||||
|
||||
default:
|
||||
dev->last_error = CANDLE_ERR_BITRATE_UNSUPPORTED;
|
||||
return false;
|
||||
}
|
||||
|
||||
return candle_ctrl_set_bittiming(dev, ch, &t);
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_start(candle_handle hdev, uint8_t ch, uint32_t flags)
|
||||
{
|
||||
// TODO ensure device is open, check channel count..
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
candle_capability_t *cap = (ch < 8) ? &dev->ch_caps[ch] : &dev->bt_const;
|
||||
|
||||
if (cap->feature & CANDLE_FEATURE_HW_TIMESTAMP) {
|
||||
flags |= CANDLE_MODE_HW_TIMESTAMP;
|
||||
} else {
|
||||
candle_logf(L"channel %u has no HW timestamp capability; starting without timestamp flag", ch);
|
||||
}
|
||||
|
||||
bool rc = candle_ctrl_set_device_mode(dev, ch, CANDLE_DEVMODE_START, flags);
|
||||
candle_logf(L"channel %u start flags=0x%08x result=%u err=%u",
|
||||
ch,
|
||||
flags,
|
||||
rc ? 1 : 0,
|
||||
dev->last_error);
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_channel_stop(candle_handle hdev, uint8_t ch)
|
||||
{
|
||||
// TODO ensure device is open, check channel count..
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return candle_ctrl_set_device_mode(dev, ch, CANDLE_DEVMODE_RESET, 0);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
static bool candle_write_pipe_timed(candle_device_t *dev, uint8_t *buf, DWORD len)
|
||||
{
|
||||
OVERLAPPED ovl;
|
||||
memset(&ovl, 0, sizeof(ovl));
|
||||
ovl.hEvent = dev->txEvent;
|
||||
ResetEvent(dev->txEvent);
|
||||
|
||||
BOOL rc = WinUsb_WritePipe(dev->winUSBHandle, dev->bulkOutPipe,
|
||||
buf, len, NULL, &ovl);
|
||||
if (rc) {
|
||||
return true; /* completed synchronously */
|
||||
}
|
||||
if (GetLastError() != ERROR_IO_PENDING) {
|
||||
return false; /* hard error */
|
||||
}
|
||||
|
||||
if (WaitForSingleObject(dev->txEvent, 150) != WAIT_OBJECT_0) {
|
||||
/* Timed out: cancel the transfer and restore the pipe to a clean state. */
|
||||
WinUsb_AbortPipe(dev->winUSBHandle, dev->bulkOutPipe);
|
||||
DWORD dummy = 0;
|
||||
WinUsb_GetOverlappedResult(dev->winUSBHandle, &ovl, &dummy, TRUE);
|
||||
WinUsb_ResetPipe(dev->winUSBHandle, dev->bulkOutPipe);
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD transferred = 0;
|
||||
return WinUsb_GetOverlappedResult(dev->winUSBHandle, &ovl, &transferred, FALSE) != FALSE;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_frame_send(candle_handle hdev, uint8_t ch, candle_frame_t *frame)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
frame->echo_id = 0;
|
||||
frame->channel = ch;
|
||||
bool rc = candle_write_pipe_timed(dev, (uint8_t*)frame, sizeof(*frame));
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SEND_FRAME;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_frame_read(candle_handle hdev, candle_frame_t *frame, uint32_t timeout_ms)
|
||||
{
|
||||
// TODO ensure device is open..
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
DWORD wait_result = WaitForMultipleObjects(CANDLE_URB_COUNT, dev->rxevents, false, timeout_ms);
|
||||
if (wait_result == WAIT_TIMEOUT) {
|
||||
dev->last_error = CANDLE_ERR_READ_TIMEOUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( (wait_result < WAIT_OBJECT_0) || (wait_result >= WAIT_OBJECT_0 + CANDLE_URB_COUNT) ) {
|
||||
dev->last_error = CANDLE_ERR_READ_WAIT;
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD urb_num = wait_result - WAIT_OBJECT_0;
|
||||
DWORD bytes_transfered;
|
||||
|
||||
if (!WinUsb_GetOverlappedResult(dev->winUSBHandle, &dev->rxurbs[urb_num].ovl, &bytes_transfered, false)) {
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_IO_INCOMPLETE) {
|
||||
ResetEvent(dev->rxurbs[urb_num].ovl.hEvent);
|
||||
} else {
|
||||
dev->rxurbs[urb_num].pending = false;
|
||||
candle_prepare_read(dev, urb_num);
|
||||
}
|
||||
candle_logf(L"classic read result failed urb=%u winerr=%lu", urb_num, err);
|
||||
dev->last_error = CANDLE_ERR_READ_RESULT;
|
||||
return false;
|
||||
}
|
||||
dev->rxurbs[urb_num].pending = false;
|
||||
|
||||
if (bytes_transfered < sizeof(*frame)-4) {
|
||||
candle_prepare_read(dev, urb_num);
|
||||
candle_logf(L"classic read too small urb=%u bytes=%lu min=%u",
|
||||
urb_num,
|
||||
bytes_transfered,
|
||||
(unsigned)(sizeof(*frame) - 4));
|
||||
dev->last_error = CANDLE_ERR_READ_SIZE;
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(frame, 0, sizeof(*frame));
|
||||
DWORD copy_len = (bytes_transfered < sizeof(*frame)) ? bytes_transfered : sizeof(*frame);
|
||||
memcpy(frame, dev->rxurbs[urb_num].buf, copy_len);
|
||||
candle_logf_verbose(L"classic read urb=%u bytes=%lu echo=0x%08x can_id=0x%08x dlc=%u ch=%u flags=0x%02x ts=%u",
|
||||
urb_num,
|
||||
bytes_transfered,
|
||||
frame->echo_id,
|
||||
frame->can_id,
|
||||
frame->can_dlc,
|
||||
frame->channel,
|
||||
frame->flags,
|
||||
frame->timestamp_us);
|
||||
|
||||
return candle_prepare_read(dev, urb_num);
|
||||
}
|
||||
|
||||
candle_frametype_t __stdcall DLL candle_frame_type(candle_frame_t *frame)
|
||||
{
|
||||
if (frame->echo_id != 0xFFFFFFFF) {
|
||||
return CANDLE_FRAMETYPE_ECHO;
|
||||
};
|
||||
|
||||
if (frame->can_id & CANDLE_ID_ERR) {
|
||||
return CANDLE_FRAMETYPE_ERROR;
|
||||
}
|
||||
|
||||
return CANDLE_FRAMETYPE_RECEIVE;
|
||||
}
|
||||
|
||||
uint32_t __stdcall DLL candle_frame_id(candle_frame_t *frame)
|
||||
{
|
||||
return frame->can_id & 0x1FFFFFFF;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_frame_is_extended_id(candle_frame_t *frame)
|
||||
{
|
||||
return (frame->can_id & CANDLE_ID_EXTENDED) != 0;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_frame_is_rtr(candle_frame_t *frame)
|
||||
{
|
||||
return (frame->can_id & CANDLE_ID_RTR) != 0;
|
||||
}
|
||||
|
||||
uint8_t __stdcall DLL candle_frame_dlc(candle_frame_t *frame)
|
||||
{
|
||||
return frame->can_dlc;
|
||||
}
|
||||
|
||||
uint8_t * __stdcall DLL candle_frame_data(candle_frame_t *frame)
|
||||
{
|
||||
return frame->data;
|
||||
}
|
||||
|
||||
uint32_t __stdcall DLL candle_frame_timestamp_us(candle_frame_t *frame)
|
||||
{
|
||||
return frame->timestamp_us;
|
||||
}
|
||||
|
||||
/* ---- CAN FD extensions ---- */
|
||||
|
||||
bool __stdcall DLL candle_channel_set_data_timing(candle_handle hdev, uint8_t ch, candle_bittiming_t *data)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
return candle_ctrl_set_data_bittiming(dev, ch, data);
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_send(candle_handle hdev, uint8_t ch, candle_fd_frame_t *frame)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
frame->echo_id = 0;
|
||||
frame->channel = ch;
|
||||
bool rc = candle_write_pipe_timed(dev, (uint8_t*)frame, sizeof(*frame));
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SEND_FRAME;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *frame, uint32_t timeout_ms)
|
||||
{
|
||||
candle_device_t *dev = (candle_device_t*)hdev;
|
||||
|
||||
DWORD wait_result = WaitForMultipleObjects(CANDLE_URB_COUNT, dev->rxevents, false, timeout_ms);
|
||||
if (wait_result == WAIT_TIMEOUT) {
|
||||
dev->last_error = CANDLE_ERR_READ_TIMEOUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( (wait_result < WAIT_OBJECT_0) || (wait_result >= WAIT_OBJECT_0 + CANDLE_URB_COUNT) ) {
|
||||
dev->last_error = CANDLE_ERR_READ_WAIT;
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD urb_num = wait_result - WAIT_OBJECT_0;
|
||||
DWORD bytes_transfered;
|
||||
|
||||
if (!WinUsb_GetOverlappedResult(dev->winUSBHandle, &dev->rxurbs[urb_num].ovl, &bytes_transfered, false)) {
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_IO_INCOMPLETE) {
|
||||
ResetEvent(dev->rxurbs[urb_num].ovl.hEvent);
|
||||
} else {
|
||||
dev->rxurbs[urb_num].pending = false;
|
||||
candle_prepare_read(dev, urb_num);
|
||||
}
|
||||
candle_logf(L"fd read result failed urb=%u winerr=%lu", urb_num, err);
|
||||
dev->last_error = CANDLE_ERR_READ_RESULT;
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
dev->last_error = CANDLE_ERR_READ_SIZE;
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(frame, 0, sizeof(*frame));
|
||||
|
||||
/*
|
||||
* Detect frame type from the flags byte (offset 10 in both structs).
|
||||
* Classic CAN frames: header(12) + data(8) + timestamp(4) = 24 bytes total.
|
||||
*
|
||||
* FD frames come in two wire formats:
|
||||
* - Legacy fixed (candleLight/CANable 1.x): always 80 bytes — header(12) +
|
||||
* data[64] + timestamp(4). The timestamp is ALWAYS at offset 76, regardless
|
||||
* of the actual DLC. Identified by bytes_transferred == sizeof(candle_fd_frame_t).
|
||||
* - Variable-length (CANnectivity/Zephyr): header(12) + actual_data(DLC) +
|
||||
* timestamp(4). Identified by bytes_transferred < sizeof(candle_fd_frame_t).
|
||||
*/
|
||||
bool is_fd_frame = (dev->rxurbs[urb_num].buf[10] & CANDLE_FRAME_FLAG_FD) != 0;
|
||||
|
||||
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];
|
||||
const DWORD data_len = candle_dlc_to_len(raw_dlc);
|
||||
const DWORD min_size = 12 + data_len; /* header + data, without timestamp */
|
||||
|
||||
if (bytes_transfered < min_size) {
|
||||
candle_prepare_read(dev, urb_num);
|
||||
candle_logf(L"fd read FD frame too small urb=%u bytes=%lu min=%lu flags=0x%02x dlc=%u",
|
||||
urb_num,
|
||||
bytes_transfered,
|
||||
min_size,
|
||||
dev->rxurbs[urb_num].buf[10],
|
||||
raw_dlc);
|
||||
dev->last_error = CANDLE_ERR_READ_SIZE;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Copy the fixed 12-byte header (echo_id … reserved). */
|
||||
memcpy(frame, dev->rxurbs[urb_num].buf, 12);
|
||||
/* Copy data at offset 12 into the struct's data field. */
|
||||
memcpy(frame->data, dev->rxurbs[urb_num].buf + 12, data_len);
|
||||
|
||||
/* Timestamp location depends on the wire format (see comment above). */
|
||||
const DWORD fixed_ts_offset = (DWORD)(sizeof(candle_fd_frame_t) - sizeof(uint32_t)); /* = 76 */
|
||||
const DWORD ts_offset = (bytes_transfered >= (DWORD)sizeof(candle_fd_frame_t))
|
||||
? fixed_ts_offset
|
||||
: min_size;
|
||||
if (bytes_transfered >= ts_offset + (DWORD)sizeof(uint32_t)) {
|
||||
memcpy(&frame->timestamp_us, dev->rxurbs[urb_num].buf + ts_offset, sizeof(uint32_t));
|
||||
}
|
||||
/* else: timestamp stays zero from memset above */
|
||||
} else {
|
||||
/* 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);
|
||||
memcpy(&classic, dev->rxurbs[urb_num].buf, copy_len);
|
||||
|
||||
frame->echo_id = classic.echo_id;
|
||||
frame->can_id = classic.can_id;
|
||||
frame->can_dlc = classic.can_dlc;
|
||||
frame->channel = classic.channel;
|
||||
frame->flags = classic.flags;
|
||||
frame->reserved = classic.reserved;
|
||||
memcpy(frame->data, classic.data, 8);
|
||||
frame->timestamp_us = (bytes_transfered >= sizeof(classic)) ? classic.timestamp_us : 0;
|
||||
}
|
||||
candle_logf_verbose(L"fd read urb=%u bytes=%lu is_fd=%u echo=0x%08x can_id=0x%08x dlc=%u ch=%u flags=0x%02x ts=%u",
|
||||
urb_num,
|
||||
bytes_transfered,
|
||||
is_fd_frame ? 1 : 0,
|
||||
frame->echo_id,
|
||||
frame->can_id,
|
||||
frame->can_dlc,
|
||||
frame->channel,
|
||||
frame->flags,
|
||||
frame->timestamp_us);
|
||||
|
||||
return candle_prepare_read(dev, urb_num);
|
||||
}
|
||||
|
||||
candle_frametype_t __stdcall DLL candle_fd_frame_type(candle_fd_frame_t *frame)
|
||||
{
|
||||
if (frame->echo_id != 0xFFFFFFFF) {
|
||||
return CANDLE_FRAMETYPE_ECHO;
|
||||
}
|
||||
if (frame->can_id & CANDLE_ID_ERR) {
|
||||
return CANDLE_FRAMETYPE_ERROR;
|
||||
}
|
||||
return CANDLE_FRAMETYPE_RECEIVE;
|
||||
}
|
||||
|
||||
uint32_t __stdcall DLL candle_fd_frame_id(candle_fd_frame_t *frame)
|
||||
{
|
||||
return frame->can_id & 0x1FFFFFFF;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_is_extended_id(candle_fd_frame_t *frame)
|
||||
{
|
||||
return (frame->can_id & CANDLE_ID_EXTENDED) != 0;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_is_rtr(candle_fd_frame_t *frame)
|
||||
{
|
||||
return (frame->can_id & CANDLE_ID_RTR) != 0;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_is_fd(candle_fd_frame_t *frame)
|
||||
{
|
||||
return (frame->flags & CANDLE_FRAME_FLAG_FD) != 0;
|
||||
}
|
||||
|
||||
bool __stdcall DLL candle_fd_frame_is_brs(candle_fd_frame_t *frame)
|
||||
{
|
||||
return (frame->flags & CANDLE_FRAME_FLAG_BRS) != 0;
|
||||
}
|
||||
|
||||
uint8_t __stdcall DLL candle_fd_frame_dlc(candle_fd_frame_t *frame)
|
||||
{
|
||||
return frame->can_dlc;
|
||||
}
|
||||
|
||||
uint8_t * __stdcall DLL candle_fd_frame_data(candle_fd_frame_t *frame)
|
||||
{
|
||||
return frame->data;
|
||||
}
|
||||
|
||||
uint32_t __stdcall DLL candle_fd_frame_timestamp_us(candle_fd_frame_t *frame)
|
||||
{
|
||||
return frame->timestamp_us;
|
||||
}
|
||||
29
c/candle/candle.def
Normal file
29
c/candle/candle.def
Normal file
@@ -0,0 +1,29 @@
|
||||
EXPORTS
|
||||
candle_list_scan
|
||||
candle_list_free
|
||||
candle_list_length
|
||||
candle_dev_get
|
||||
candle_dev_get_state
|
||||
candle_dev_get_path
|
||||
candle_dev_open
|
||||
candle_dev_get_timestamp_us
|
||||
candle_dev_close
|
||||
candle_dev_free
|
||||
candle_channel_count
|
||||
candle_channel_get_capabilities
|
||||
candle_channel_get_state
|
||||
candle_channel_bus_off_recover
|
||||
candle_channel_set_timing
|
||||
candle_channel_set_bitrate
|
||||
candle_channel_start
|
||||
candle_channel_stop
|
||||
candle_frame_send
|
||||
candle_frame_read
|
||||
candle_frame_type
|
||||
candle_frame_id
|
||||
candle_frame_is_extended_id
|
||||
candle_frame_is_rtr
|
||||
candle_frame_dlc
|
||||
candle_frame_data
|
||||
candle_frame_timestamp_us
|
||||
candle_dev_last_error
|
||||
287
c/candle/candle.h
Normal file
287
c/candle/candle.h
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
Copyright (c) 2026 Schildkroet
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef void* candle_list_handle;
|
||||
typedef void* candle_handle;
|
||||
|
||||
typedef enum {
|
||||
CANDLE_DEVSTATE_AVAIL,
|
||||
CANDLE_DEVSTATE_INUSE
|
||||
} candle_devstate_t;
|
||||
|
||||
typedef enum {
|
||||
CANDLE_FRAMETYPE_UNKNOWN,
|
||||
CANDLE_FRAMETYPE_RECEIVE,
|
||||
CANDLE_FRAMETYPE_ECHO,
|
||||
CANDLE_FRAMETYPE_ERROR,
|
||||
CANDLE_FRAMETYPE_TIMESTAMP_OVFL
|
||||
} candle_frametype_t;
|
||||
|
||||
enum {
|
||||
CANDLE_ID_EXTENDED = 0x80000000,
|
||||
CANDLE_ID_RTR = 0x40000000,
|
||||
CANDLE_ID_ERR = 0x20000000
|
||||
};
|
||||
|
||||
/* Feature flags reported in candle_capability_t.feature */
|
||||
enum {
|
||||
/** CAN channel supports listen-only mode, in which it is not allowed to send dominant bits. */
|
||||
CANDLE_FEATURE_LISTEN_ONLY = 0x0001,
|
||||
/** CAN channel supports loopback mode, in which it receives own frames. */
|
||||
CANDLE_FEATURE_LOOP_BACK = 0x0002,
|
||||
/** CAN channel supports triple sampling mode */
|
||||
CANDLE_FEATURE_TRIPLE_SAMPLE = 0x0004,
|
||||
/** CAN channel supports not retransmitting in case of lost arbitration or missing ACK. */
|
||||
CANDLE_FEATURE_ONE_SHOT = 0x0008,
|
||||
/** CAN channel supports hardware timestamping of CAN frames. */
|
||||
CANDLE_FEATURE_HW_TIMESTAMP = 0x0010,
|
||||
/** CAN channel supports visual identification. */
|
||||
CANDLE_FEATURE_IDENTIFY = 0x0020,
|
||||
/** CAN channel supports user IDs (unsupported). */
|
||||
CANDLE_FEATURE_USER_ID = 0x0040,
|
||||
/** CAN channel supports padding of host frames (unsupported). */
|
||||
CANDLE_FEATURE_PAD_PKTS_TO_MAX = 0x0080,
|
||||
/** CAN channel supports transmitting/receiving CAN FD frames. */
|
||||
CANDLE_FEATURE_FD = 0x0100,
|
||||
/** CAN channel support LPC546xx specific quirks (Unused) */
|
||||
CANDLE_FEATURE_REQ_USB_QUIRK_LPC546XX = 0x0200,
|
||||
/** CAN channel supports extended bit timing limits. */
|
||||
CANDLE_FEATURE_BT_CONST_EXT = 0x0400,
|
||||
/** CAN channel supports configurable bus termination. */
|
||||
CANDLE_FEATURE_TERMINATION = 0x0800,
|
||||
/** CAN channel supports bus error reporting (Unsupported, always enabled) */
|
||||
CANDLE_FEATURE_BERR_REPORTING = 0x1000,
|
||||
/** CAN channel supports reporting of bus state. */
|
||||
CANDLE_FEATURE_GET_STATE = 0x2000,
|
||||
/** Host-controlled recovery after the controller enters bus-off. */
|
||||
CANDLE_FEATURE_BUS_OFF_RECOVERY = 0x40000,
|
||||
};
|
||||
|
||||
/* Flags in the flags byte of received/transmitted frames */
|
||||
enum {
|
||||
CANDLE_FRAME_FLAG_OVERFLOW = 0x01,
|
||||
CANDLE_FRAME_FLAG_FD = 0x02,
|
||||
CANDLE_FRAME_FLAG_BRS = 0x04,
|
||||
CANDLE_FRAME_FLAG_ESI = 0x08,
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
CANDLE_STATE_ERROR_ACTIVE = 0,
|
||||
CANDLE_STATE_ERROR_WARNING = 1,
|
||||
CANDLE_STATE_ERROR_PASSIVE = 2,
|
||||
CANDLE_STATE_BUS_OFF = 3,
|
||||
CANDLE_STATE_STOPPED = 4,
|
||||
CANDLE_STATE_SLEEPING = 5,
|
||||
} candle_can_state_t;
|
||||
|
||||
typedef enum {
|
||||
CANDLE_MODE_NORMAL = 0x0000,
|
||||
CANDLE_MODE_LISTEN_ONLY = 0x0001,
|
||||
CANDLE_MODE_LOOP_BACK = 0x0002,
|
||||
CANDLE_MODE_TRIPLE_SAMPLE = 0x0004,
|
||||
CANDLE_MODE_ONE_SHOT = 0x0008,
|
||||
CANDLE_MODE_HW_TIMESTAMP = 0x0010,
|
||||
CANDLE_MODE_PAD_PKTS_TO_MAX = 0x0080,
|
||||
CANDLE_MODE_FD = 0x0100,
|
||||
} candle_mode_t;
|
||||
|
||||
typedef enum {
|
||||
CANDLE_ERR_OK = 0,
|
||||
CANDLE_ERR_CREATE_FILE = 1,
|
||||
CANDLE_ERR_WINUSB_INITIALIZE = 2,
|
||||
CANDLE_ERR_QUERY_INTERFACE = 3,
|
||||
CANDLE_ERR_QUERY_PIPE = 4,
|
||||
CANDLE_ERR_PARSE_IF_DESCR = 5,
|
||||
CANDLE_ERR_SET_HOST_FORMAT = 6,
|
||||
CANDLE_ERR_GET_DEVICE_INFO = 7,
|
||||
CANDLE_ERR_GET_BITTIMING_CONST = 8,
|
||||
CANDLE_ERR_PREPARE_READ = 9,
|
||||
CANDLE_ERR_SET_DEVICE_MODE = 10,
|
||||
CANDLE_ERR_SET_BITTIMING = 11,
|
||||
CANDLE_ERR_BITRATE_FCLK = 12,
|
||||
CANDLE_ERR_BITRATE_UNSUPPORTED = 13,
|
||||
CANDLE_ERR_SEND_FRAME = 14,
|
||||
CANDLE_ERR_READ_TIMEOUT = 15,
|
||||
CANDLE_ERR_READ_WAIT = 16,
|
||||
CANDLE_ERR_READ_RESULT = 17,
|
||||
CANDLE_ERR_READ_SIZE = 18,
|
||||
CANDLE_ERR_SETUPDI_IF_DETAILS = 19,
|
||||
CANDLE_ERR_SETUPDI_IF_DETAILS2 = 20,
|
||||
CANDLE_ERR_MALLOC = 21,
|
||||
CANDLE_ERR_PATH_LEN = 22,
|
||||
CANDLE_ERR_CLSID = 23,
|
||||
CANDLE_ERR_GET_DEVICES = 24,
|
||||
CANDLE_ERR_SETUPDI_IF_ENUM = 25,
|
||||
CANDLE_ERR_SET_TIMESTAMP_MODE = 26,
|
||||
CANDLE_ERR_DEV_OUT_OF_RANGE = 27,
|
||||
CANDLE_ERR_GET_TIMESTAMP = 28,
|
||||
CANDLE_ERR_SET_PIPE_RAW_IO = 29
|
||||
} candle_err_t;
|
||||
|
||||
#pragma pack(push,1)
|
||||
|
||||
typedef struct {
|
||||
uint32_t echo_id;
|
||||
uint32_t can_id;
|
||||
uint8_t can_dlc;
|
||||
uint8_t channel;
|
||||
uint8_t flags;
|
||||
uint8_t reserved;
|
||||
uint8_t data[8];
|
||||
uint32_t timestamp_us;
|
||||
} candle_frame_t;
|
||||
|
||||
/* CAN FD frame: same header as candle_frame_t but with 64-byte data payload */
|
||||
typedef struct {
|
||||
uint32_t echo_id;
|
||||
uint32_t can_id;
|
||||
uint8_t can_dlc;
|
||||
uint8_t channel;
|
||||
uint8_t flags;
|
||||
uint8_t reserved;
|
||||
uint8_t data[64];
|
||||
uint32_t timestamp_us;
|
||||
} candle_fd_frame_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t feature;
|
||||
uint32_t fclk_can;
|
||||
uint32_t tseg1_min;
|
||||
uint32_t tseg1_max;
|
||||
uint32_t tseg2_min;
|
||||
uint32_t tseg2_max;
|
||||
uint32_t sjw_max;
|
||||
uint32_t brp_min;
|
||||
uint32_t brp_max;
|
||||
uint32_t brp_inc;
|
||||
} candle_capability_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t prop_seg;
|
||||
uint32_t phase_seg1;
|
||||
uint32_t phase_seg2;
|
||||
uint32_t sjw;
|
||||
uint32_t brp;
|
||||
} candle_bittiming_t;
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
/*
|
||||
* CAN FD DLC encoding:
|
||||
* DLC 0-8 → 0-8 bytes (same as classic CAN)
|
||||
* DLC 9 → 12 bytes
|
||||
* DLC 10 → 16 bytes
|
||||
* DLC 11 → 20 bytes
|
||||
* DLC 12 → 24 bytes
|
||||
* DLC 13 → 32 bytes
|
||||
* DLC 14 → 48 bytes
|
||||
* DLC 15 → 64 bytes
|
||||
*/
|
||||
static inline uint8_t candle_dlc_to_len(uint8_t dlc)
|
||||
{
|
||||
static const uint8_t tbl[16] = { 0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64 };
|
||||
return (dlc <= 15u) ? tbl[dlc] : 0u;
|
||||
}
|
||||
|
||||
static inline uint8_t candle_len_to_dlc(uint8_t len)
|
||||
{
|
||||
if (len <= 8u) return len;
|
||||
if (len <= 12u) return 9u;
|
||||
if (len <= 16u) return 10u;
|
||||
if (len <= 20u) return 11u;
|
||||
if (len <= 24u) return 12u;
|
||||
if (len <= 32u) return 13u;
|
||||
if (len <= 48u) return 14u;
|
||||
return 15u;
|
||||
}
|
||||
|
||||
#define DLL
|
||||
|
||||
/* Optional log callback — set once at startup to receive diagnostic messages.
|
||||
* If NULL (the default) no logging is performed. */
|
||||
typedef void (*candle_log_fn_t)(const wchar_t *msg);
|
||||
extern candle_log_fn_t candle_log_fn;
|
||||
|
||||
/* Set to true to enable per-frame and per-control-transfer trace logs.
|
||||
* Off by default; only error and setup messages are logged. */
|
||||
extern bool candle_log_verbose;
|
||||
|
||||
bool __stdcall DLL candle_list_scan(candle_list_handle *list);
|
||||
bool __stdcall DLL candle_list_free(candle_list_handle list);
|
||||
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_open(candle_handle hdev);
|
||||
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);
|
||||
|
||||
bool __stdcall DLL candle_channel_count(candle_handle hdev, uint8_t *num_channels);
|
||||
bool __stdcall DLL candle_channel_get_capabilities(candle_handle hdev, uint8_t ch, candle_capability_t *cap);
|
||||
bool __stdcall DLL candle_channel_get_state(candle_handle hdev, uint8_t ch, candle_can_state_t *state);
|
||||
bool __stdcall DLL candle_channel_bus_off_recover(candle_handle hdev, uint8_t ch);
|
||||
bool __stdcall DLL candle_channel_set_timing(candle_handle hdev, uint8_t ch, candle_bittiming_t *data);
|
||||
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_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);
|
||||
|
||||
candle_frametype_t __stdcall DLL candle_frame_type(candle_frame_t *frame);
|
||||
uint32_t __stdcall DLL candle_frame_id(candle_frame_t *frame);
|
||||
bool __stdcall DLL candle_frame_is_extended_id(candle_frame_t *frame);
|
||||
bool __stdcall DLL candle_frame_is_rtr(candle_frame_t *frame);
|
||||
uint8_t __stdcall DLL candle_frame_dlc(candle_frame_t *frame);
|
||||
uint8_t * __stdcall DLL candle_frame_data(candle_frame_t *frame);
|
||||
uint32_t __stdcall DLL candle_frame_timestamp_us(candle_frame_t *frame);
|
||||
|
||||
/* CAN FD extensions */
|
||||
bool __stdcall DLL candle_channel_set_data_timing(candle_handle hdev, uint8_t ch, candle_bittiming_t *data);
|
||||
bool __stdcall DLL candle_fd_frame_send(candle_handle hdev, uint8_t ch, candle_fd_frame_t *frame);
|
||||
bool __stdcall DLL candle_fd_frame_read(candle_handle hdev, candle_fd_frame_t *frame, uint32_t timeout_ms);
|
||||
|
||||
candle_frametype_t __stdcall DLL candle_fd_frame_type(candle_fd_frame_t *frame);
|
||||
uint32_t __stdcall DLL candle_fd_frame_id(candle_fd_frame_t *frame);
|
||||
bool __stdcall DLL candle_fd_frame_is_extended_id(candle_fd_frame_t *frame);
|
||||
bool __stdcall DLL candle_fd_frame_is_rtr(candle_fd_frame_t *frame);
|
||||
bool __stdcall DLL candle_fd_frame_is_fd(candle_fd_frame_t *frame);
|
||||
bool __stdcall DLL candle_fd_frame_is_brs(candle_fd_frame_t *frame);
|
||||
uint8_t __stdcall DLL candle_fd_frame_dlc(candle_fd_frame_t *frame);
|
||||
uint8_t * __stdcall DLL candle_fd_frame_data(candle_fd_frame_t *frame);
|
||||
uint32_t __stdcall DLL candle_fd_frame_timestamp_us(candle_fd_frame_t *frame);
|
||||
|
||||
candle_err_t __stdcall DLL candle_dev_last_error(candle_handle hdev);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
234
c/candle/candle_ctrl_req.c
Normal file
234
c/candle/candle_ctrl_req.c
Normal file
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#include "candle_ctrl_req.h"
|
||||
#include "ch_9.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
|
||||
enum {
|
||||
CANDLE_BREQ_HOST_FORMAT = 0,
|
||||
CANDLE_BREQ_BITTIMING = 1,
|
||||
CANDLE_BREQ_MODE = 2,
|
||||
CANDLE_BREQ_BERR = 3,
|
||||
CANDLE_BREQ_BT_CONST = 4,
|
||||
CANDLE_BREQ_DEVICE_CONFIG = 5,
|
||||
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_BUS_OFF_RECOVERY = 32,
|
||||
};
|
||||
|
||||
static void candle_ctrl_logf(const wchar_t *fmt, ...)
|
||||
{
|
||||
if (candle_log_fn == NULL || !candle_log_verbose) {
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t buf[512];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
HRESULT hr = StringCchVPrintfW(buf, 512, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
if (SUCCEEDED(hr)) {
|
||||
candle_log_fn(buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool usb_control_msg(WINUSB_INTERFACE_HANDLE hnd, uint8_t request, uint8_t requesttype, uint16_t value, uint16_t index, void *data, uint16_t size)
|
||||
{
|
||||
WINUSB_SETUP_PACKET packet;
|
||||
memset(&packet, 0, sizeof(packet));
|
||||
|
||||
packet.Request = request;
|
||||
packet.RequestType = requesttype;
|
||||
packet.Value = value;
|
||||
packet.Index = index;
|
||||
packet.Length = size;
|
||||
|
||||
unsigned long bytes_sent = 0;
|
||||
BOOL rc = WinUsb_ControlTransfer(hnd, packet, (uint8_t*)data, size, &bytes_sent, 0);
|
||||
candle_ctrl_logf(L"ctrl req=0x%02x type=0x%02x value=%u index=%u size=%u rc=%u transferred=%lu winerr=%lu",
|
||||
request,
|
||||
requesttype,
|
||||
value,
|
||||
index,
|
||||
size,
|
||||
rc ? 1 : 0,
|
||||
bytes_sent,
|
||||
rc ? 0 : GetLastError());
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_set_host_format(candle_device_t *dev)
|
||||
{
|
||||
candle_host_config_t hconf;
|
||||
hconf.byte_order = 0x0000beef;
|
||||
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_HOST_FORMAT,
|
||||
USB_DIR_OUT|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
1,
|
||||
dev->interfaceNumber,
|
||||
&hconf,
|
||||
sizeof(hconf)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_HOST_FORMAT;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_set_device_mode(candle_device_t *dev, uint8_t channel, uint32_t mode, uint32_t flags)
|
||||
{
|
||||
candle_device_mode_t dm;
|
||||
dm.mode = mode;
|
||||
dm.flags = flags;
|
||||
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_MODE,
|
||||
USB_DIR_OUT|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
dev->interfaceNumber,
|
||||
&dm,
|
||||
sizeof(dm)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE;
|
||||
return rc;
|
||||
}
|
||||
|
||||
|
||||
bool candle_ctrl_get_config(candle_device_t *dev, candle_device_config_t *dconf)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_DEVICE_CONFIG,
|
||||
USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
1,
|
||||
dev->interfaceNumber,
|
||||
dconf,
|
||||
sizeof(*dconf)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_DEVICE_INFO;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_get_timestamp(candle_device_t *dev, uint32_t *current_timestamp)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_TIMESTAMP_GET,
|
||||
USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
1,
|
||||
dev->interfaceNumber,
|
||||
current_timestamp,
|
||||
sizeof(*current_timestamp)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_TIMESTAMP;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_get_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *data)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_BT_CONST,
|
||||
USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
0,
|
||||
data,
|
||||
sizeof(*data)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_BITTIMING_CONST;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_set_bittiming(candle_device_t *dev, uint8_t channel, candle_bittiming_t *data)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_BITTIMING,
|
||||
USB_DIR_OUT|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
0,
|
||||
data,
|
||||
sizeof(*data)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_BITTIMING;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_set_data_bittiming(candle_device_t *dev, uint8_t channel, candle_bittiming_t *data)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_DATA_BITTIMING,
|
||||
USB_DIR_OUT|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
0,
|
||||
data,
|
||||
sizeof(*data)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_BITTIMING;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_get_state(candle_device_t *dev, uint8_t channel, candle_device_state_t *data)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_GET_STATE,
|
||||
USB_DIR_IN|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
dev->interfaceNumber,
|
||||
data,
|
||||
sizeof(*data)
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_GET_DEVICE_INFO;
|
||||
return rc;
|
||||
}
|
||||
|
||||
bool candle_ctrl_bus_off_recover(candle_device_t *dev, uint8_t channel)
|
||||
{
|
||||
bool rc = usb_control_msg(
|
||||
dev->winUSBHandle,
|
||||
CANDLE_BREQ_BUS_OFF_RECOVERY,
|
||||
USB_DIR_OUT|USB_TYPE_VENDOR|USB_RECIP_INTERFACE,
|
||||
channel,
|
||||
dev->interfaceNumber,
|
||||
NULL,
|
||||
0
|
||||
);
|
||||
|
||||
dev->last_error = rc ? CANDLE_ERR_OK : CANDLE_ERR_SET_DEVICE_MODE;
|
||||
return rc;
|
||||
}
|
||||
49
c/candle/candle_ctrl_req.h
Normal file
49
c/candle/candle_ctrl_req.h
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
Copyright (c) 2026 Schildkroet
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "candle_defs.h"
|
||||
|
||||
enum {
|
||||
CANDLE_DEVMODE_RESET = 0,
|
||||
CANDLE_DEVMODE_START = 1
|
||||
};
|
||||
|
||||
#pragma pack(push, 1)
|
||||
typedef struct {
|
||||
uint32_t state;
|
||||
uint32_t rxerr;
|
||||
uint32_t txerr;
|
||||
} candle_device_state_t;
|
||||
#pragma pack(pop)
|
||||
|
||||
bool candle_ctrl_set_host_format(candle_device_t *dev);
|
||||
bool candle_ctrl_set_device_mode(candle_device_t *dev, uint8_t channel, uint32_t mode, uint32_t flags);
|
||||
bool candle_ctrl_get_config(candle_device_t *dev, candle_device_config_t *dconf);
|
||||
bool candle_ctrl_get_capability(candle_device_t *dev, uint8_t channel, candle_capability_t *data);
|
||||
bool candle_ctrl_set_bittiming(candle_device_t *dev, uint8_t channel, candle_bittiming_t *data);
|
||||
bool candle_ctrl_set_data_bittiming(candle_device_t *dev, uint8_t channel, candle_bittiming_t *data);
|
||||
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);
|
||||
|
||||
101
c/candle/candle_defs.h
Normal file
101
c/candle/candle_defs.h
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
Copyright (c) 2026 Schildkroet
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifndef _WIN32_WINNT
|
||||
#define _WIN32_WINNT 0x0600
|
||||
#endif
|
||||
|
||||
#include <windows.h>
|
||||
#include <winbase.h>
|
||||
#include <winusb.h>
|
||||
#include <setupapi.h>
|
||||
#include <devguid.h>
|
||||
#include <regstr.h>
|
||||
|
||||
#undef __CRT__NO_INLINE
|
||||
#include <strsafe.h>
|
||||
#define __CRT__NO_INLINE
|
||||
|
||||
#include "candle.h"
|
||||
|
||||
#define CANDLE_MAX_DEVICES 32
|
||||
#define CANDLE_URB_COUNT 30
|
||||
|
||||
#pragma pack(push,1)
|
||||
|
||||
typedef struct {
|
||||
uint32_t byte_order;
|
||||
} candle_host_config_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t reserved1;
|
||||
uint8_t reserved2;
|
||||
uint8_t reserved3;
|
||||
uint8_t icount;
|
||||
uint32_t sw_version;
|
||||
uint32_t hw_version;
|
||||
} candle_device_config_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t mode;
|
||||
uint32_t flags;
|
||||
} candle_device_mode_t;
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
|
||||
typedef struct {
|
||||
OVERLAPPED ovl;
|
||||
bool pending;
|
||||
uint8_t buf[512];
|
||||
} canlde_rx_urb;
|
||||
|
||||
typedef struct {
|
||||
wchar_t path[256];
|
||||
candle_devstate_t state;
|
||||
candle_err_t last_error;
|
||||
|
||||
HANDLE deviceHandle;
|
||||
WINUSB_INTERFACE_HANDLE winUSBHandle;
|
||||
UCHAR interfaceNumber;
|
||||
UCHAR bulkInPipe;
|
||||
UCHAR bulkOutPipe;
|
||||
HANDLE txEvent; /* pre-allocated event for timed overlapped writes */
|
||||
|
||||
candle_device_config_t dconf;
|
||||
candle_capability_t bt_const;
|
||||
/* Per-channel capabilities: index 0..dconf.icount, maximum 8 channels */
|
||||
candle_capability_t ch_caps[8];
|
||||
canlde_rx_urb rxurbs[CANDLE_URB_COUNT];
|
||||
HANDLE rxevents[CANDLE_URB_COUNT];
|
||||
} candle_device_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t num_devices;
|
||||
candle_err_t last_error;
|
||||
candle_device_t dev[CANDLE_MAX_DEVICES];
|
||||
} candle_list_t;
|
||||
|
||||
37
c/candle/ch_9.h
Normal file
37
c/candle/ch_9.h
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
|
||||
Copyright (c) 2016 Hubert Denkmair <hubert@denkmair.de>
|
||||
|
||||
This file is part of the candle windows API.
|
||||
|
||||
This library is free software: you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#define USB_DIR_OUT 0 /* to device */
|
||||
#define USB_DIR_IN 0x80 /* to host */
|
||||
|
||||
#define USB_TYPE_MASK (0x03 << 5)
|
||||
#define USB_TYPE_STANDARD (0x00 << 5)
|
||||
#define USB_TYPE_CLASS (0x01 << 5)
|
||||
#define USB_TYPE_VENDOR (0x02 << 5)
|
||||
#define USB_TYPE_RESERVED (0x03 << 5)
|
||||
|
||||
#define USB_RECIP_MASK 0x1f
|
||||
#define USB_RECIP_DEVICE 0x00
|
||||
#define USB_RECIP_INTERFACE 0x01
|
||||
#define USB_RECIP_ENDPOINT 0x02
|
||||
#define USB_RECIP_OTHER 0x03
|
||||
47
python/README.md
Normal file
47
python/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Общие Python-библиотеки SET
|
||||
|
||||
Исходники для всех приложений редактируются в репозитории `templates`.
|
||||
Потребитель подключает конкретный коммит как Git submodule либо устанавливает
|
||||
пакет из `templates/python`: `pip install -e ./python`.
|
||||
|
||||
| Пакет | Назначение |
|
||||
|---|---|
|
||||
| `logic_analyzer.files` | CSV/SAL/DSL, цифровые каналы и массивы фронтов |
|
||||
| `logic_analyzer.analysis` | UART/CAN, измерения, интервалы и события |
|
||||
| `logic_analyzer.decoders` | SET UART/CAN, PM35, 1SP0635/1SD536F2 |
|
||||
| `logic_analyzer.saleae` | Настройки, обнаружение и захват Saleae Logic 2 |
|
||||
| `altera_logic` | C FFI Altera, SETCAN stream, модели и Qt-порты |
|
||||
| `set_devices` | Кодеки, каталоги, прошивки, EEPROM/DS18B20, TMS, UMP, CAN485, спектр, демо-модели |
|
||||
| `set_devices.qt_ports` | UART/mock, SLCAN, Candle/WinUSB, STM/TMS boot, STM settings, UMP CAN |
|
||||
| `protocan`, `setprotocol` | Существующие обёртки общего C99-ядра и SET v2 |
|
||||
|
||||
Импорт `logic_analyzer` / `set_devices` не загружает Qt и не зависит от GUI.
|
||||
Qt подключается только при импорте конкретного `qt_ports` (PySide6 или PySide2).
|
||||
Saleae SDK подключается при обращении к устройству: `pip install -e './python[saleae]'`.
|
||||
Qt-виджеты, пользовательские настройки и управление сессией находятся в приложении.
|
||||
|
||||
Пути к нативным библиотекам задаёт потребитель перед импортом:
|
||||
`SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`.
|
||||
Порты не ищут DLL в каталогах SETGUI. C99-ядро собирается из `c/set-protocol`,
|
||||
Candle/WinUSB — из `c/candle`. Библиотеки не выполняют поиск соседних репозиториев.
|
||||
|
||||
Пример без GUI:
|
||||
|
||||
```python
|
||||
from logic_analyzer.files import read_capture
|
||||
from logic_analyzer.analysis import analyze_capture
|
||||
|
||||
capture = read_capture('digital.csv')
|
||||
result = analyze_capture(capture, dict(mode='UART', channel=0, baudrate=115200))
|
||||
for event in result.events:
|
||||
print(event.start, event.text)
|
||||
```
|
||||
|
||||
Тесты логанализаторов без Qt и Saleae:
|
||||
`python -m unittest discover -s python/tests -p 'test_logic_analyzer_*.py'`.
|
||||
Добавьте `templates/python` в `PYTHONPATH` либо установите пакет.
|
||||
`test_shared_library_boundary.py` проверяет отсутствие зависимости от приложения.
|
||||
|
||||
Декодеры перенесены из SETGUI; исходное происхождение:
|
||||
`DSLogic_Logic_2/dslogic_script/dsview_decoders/{gate_driver_timing,set_uart,set_can,pm35_uart}`.
|
||||
После переноса единственное место сопровождения этих ядер — `templates/python/logic_analyzer`.
|
||||
@@ -41,9 +41,9 @@ class NativeAnalyzer:
|
||||
|
||||
def __init__(self, library=None):
|
||||
if library is None:
|
||||
explicit = os.environ.get("ALTERA_LOGIC_LIBRARY")
|
||||
explicit = os.environ.get("ALTERA_LOGIC_LIBRARY") or os.environ.get("SETPROTOCOL_LIBRARY")
|
||||
name = "setprotocol.dll" if sys.platform == "win32" else "libsetprotocol.so"
|
||||
bundled = Path(getattr(sys, "_MEIPASS", "")) / "gui_desktop/native" / name
|
||||
bundled = Path(getattr(sys, "_MEIPASS", "")) / "native" / name
|
||||
local = Path(__file__).resolve().parent / "native" / name
|
||||
path = Path(explicit) if explicit else (bundled if bundled.is_file() else local)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Qt serial/lifecycle port. Protocol decisions are exclusively in C99."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
|
||||
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
from set_devices.qt_ports.qt_compat import QObject, QTimer, QElapsedTimer, Signal
|
||||
from set_devices.qt_ports.qt_compat import QSerialPort, QSerialPortInfo
|
||||
from .native import NativeAnalyzer
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ UART uses the same SETCAN frames inside the shared AA55/CRC16 transport.
|
||||
Reception and rendering clocks are separate; no packet-rate repainting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PySide6.QtCore import QObject, QTimer, QElapsedTimer, Signal
|
||||
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
from set_devices.qt_ports.qt_compat import QObject, QTimer, QElapsedTimer, Signal
|
||||
from set_devices.qt_ports.qt_compat import QSerialPort, QSerialPortInfo
|
||||
from .stream import NativeStream
|
||||
|
||||
|
||||
|
||||
1
python/logic_analyzer/__init__.py
Normal file
1
python/logic_analyzer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
358
python/logic_analyzer/analysis.py
Normal file
358
python/logic_analyzer/analysis.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Offline analysis of timestamped edges; no resampling or hardware access."""
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left, bisect_right
|
||||
from dataclasses import dataclass
|
||||
import heapq
|
||||
import math
|
||||
|
||||
from .files import ImportCancelled
|
||||
from .decoders.gate_timing import TimingChecker
|
||||
from .decoders.set_uart import StreamParser
|
||||
from .decoders.pm35_uart import PM35Parser
|
||||
from .decoders.set_can import legacy, Reassembler
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisEvent:
|
||||
start: float
|
||||
end: float
|
||||
kind: str
|
||||
text: str
|
||||
details: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
events: list
|
||||
truncated: bool
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
def pulse_measurements(channel, time, start, end):
|
||||
"""Measure a complete adjacent high/low cycle, without scanning the record."""
|
||||
edges = channel.edges
|
||||
index = bisect_right(edges, time)
|
||||
result = {'level': channel.initial ^ (index & 1)}
|
||||
if 0 < index < len(edges):
|
||||
left, right = edges[index - 1], edges[index]
|
||||
if start <= left < right <= end:
|
||||
result['width'] = right - left
|
||||
result['high' if result['level'] else 'low'] = right - left
|
||||
# Prefer the next complete interval, otherwise the preceding one.
|
||||
other = None
|
||||
if index + 1 < len(edges) and edges[index + 1] <= end:
|
||||
other = edges[index + 1] - right
|
||||
elif index >= 2 and edges[index - 2] >= start:
|
||||
other = left - edges[index - 2]
|
||||
if other is not None:
|
||||
result['low' if result['level'] else 'high'] = other
|
||||
result['period'] = result['high'] + result['low']
|
||||
result['frequency'] = 1 / result['period']
|
||||
result['duty'] = 100 * result['high'] / result['period']
|
||||
return result
|
||||
|
||||
|
||||
def interval_edges(channel, a, b):
|
||||
"""Count rising/falling edges in (min(a,b), max(a,b)]."""
|
||||
left, right = sorted((a, b))
|
||||
lo, hi = bisect_right(channel.edges, left), bisect_right(channel.edges, right)
|
||||
count = hi - lo
|
||||
first_rising = channel.initial ^ (lo & 1) == 0
|
||||
rising = count // 2 + int(bool(count & 1) and first_rising)
|
||||
return rising, count - rising
|
||||
|
||||
|
||||
def _cancel(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=False, cancel=lambda: False):
|
||||
"""Yield (start, end, byte, error), sampling 8-bit UART at bit centres."""
|
||||
bit = 1.0 / baud
|
||||
bits = 9 + (parity != 'none') + stops
|
||||
edges = channel.edges
|
||||
index = bisect_left(edges, start)
|
||||
while index < len(edges) and edges[index] < end:
|
||||
_cancel(cancel)
|
||||
time = edges[index]
|
||||
level = channel.initial ^ ((index + 1) & 1) ^ inverted
|
||||
if level:
|
||||
index += 1
|
||||
continue
|
||||
finish = time + bits * bit
|
||||
if finish > end + bit * 1e-6:
|
||||
yield time, end, None, 'Неполный UART-байт в конце диапазона'
|
||||
break
|
||||
read = lambda pos: channel.level_at(time + pos * bit) ^ inverted
|
||||
if read(0.5):
|
||||
index += 1 # A glitch shorter than half a start bit.
|
||||
continue
|
||||
values = [read(1.5 + i) for i in range(8)]
|
||||
value = sum(v << i for i, v in enumerate(values))
|
||||
error = None
|
||||
if parity != 'none' and read(9.5) != ((sum(values) + (parity == 'odd')) & 1):
|
||||
error = 'Ошибка чётности UART'
|
||||
stop_start = 9 + (parity != 'none')
|
||||
if any(read(stop_start + i + 0.5) != 1 for i in range(stops)):
|
||||
error = 'Ошибка стопового бита UART / BREAK'
|
||||
yield time, finish, value, error
|
||||
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
|
||||
|
||||
|
||||
def _number(bits):
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | bit
|
||||
return value
|
||||
|
||||
|
||||
def can_crc(bits):
|
||||
crc = 0
|
||||
for bit in bits:
|
||||
feedback = ((crc >> 14) & 1) ^ bit
|
||||
crc = (crc << 1) & 0x7fff
|
||||
if feedback:
|
||||
crc ^= 0x4599
|
||||
return crc
|
||||
|
||||
|
||||
class _CanBits:
|
||||
def __init__(self, channel, time, end, bitrate, inverted, sample_point):
|
||||
self.channel, self.time, self.end = channel, time, end
|
||||
self.bit, self.inverted = 1 / bitrate, inverted
|
||||
self.sample_point = sample_point
|
||||
self.position, self.previous, self.run = 0, None, 0
|
||||
self.bits = []
|
||||
self.last_sample = time
|
||||
|
||||
def raw(self):
|
||||
# Resynchronise on recessive->dominant edges near the bit boundary.
|
||||
boundary = self.time + self.position * self.bit
|
||||
if self.position:
|
||||
lo = bisect_right(self.channel.edges, max(self.last_sample, boundary - self.bit * .2))
|
||||
hi = bisect_right(self.channel.edges, boundary + self.bit * .2, lo)
|
||||
for index in range(lo, hi):
|
||||
level = self.channel.initial ^ ((index + 1) & 1) ^ self.inverted
|
||||
if level == 0:
|
||||
self.time += self.channel.edges[index] - boundary
|
||||
boundary = self.channel.edges[index]
|
||||
break
|
||||
sample = boundary + self.sample_point * self.bit
|
||||
if sample >= self.end:
|
||||
raise EOFError('Неполный CAN-кадр в конце диапазона')
|
||||
self.last_sample = sample
|
||||
self.position += 1
|
||||
return self.channel.level_at(sample) ^ self.inverted
|
||||
|
||||
def stuffed(self):
|
||||
if self.run == 5:
|
||||
value = self.raw()
|
||||
if value == self.previous:
|
||||
raise ValueError('CAN: ошибка bit stuffing / error frame')
|
||||
self.previous, self.run = value, 1
|
||||
value = self.raw()
|
||||
self.run = self.run + 1 if value == self.previous else 1
|
||||
self.previous = value
|
||||
self.bits.append(value)
|
||||
return value
|
||||
|
||||
def take(self, count):
|
||||
return _number([self.stuffed() for _ in range(count)])
|
||||
|
||||
|
||||
def can_frames(channel, start, end, bitrate, inverted=False, sample_point=.7, cancel=lambda: False):
|
||||
"""Classic CAN, strict stuffing/CRC/form validation before application decode."""
|
||||
edges = channel.edges
|
||||
index = bisect_left(edges, start)
|
||||
bit = 1 / bitrate
|
||||
while index < len(edges) and edges[index] < end:
|
||||
_cancel(cancel)
|
||||
time = edges[index]
|
||||
level = channel.initial ^ ((index + 1) & 1) ^ inverted
|
||||
previous = edges[index - 1] if index else start
|
||||
# SOF follows at least three recessive intermission bits.
|
||||
if level or time - previous < bit * 2.9:
|
||||
index += 1
|
||||
continue
|
||||
reader = _CanBits(channel, time, end, bitrate, inverted, sample_point)
|
||||
frame, error = None, None
|
||||
try:
|
||||
if reader.take(1):
|
||||
raise ValueError('CAN: неверный SOF')
|
||||
ident = reader.take(11)
|
||||
rtr, extended = reader.take(1), reader.take(1)
|
||||
if extended:
|
||||
if rtr != 1:
|
||||
raise ValueError('CAN: неверный SRR')
|
||||
ident = (ident << 18) | reader.take(18)
|
||||
rtr = reader.take(1)
|
||||
if reader.take(2):
|
||||
raise ValueError('CAN FD / reserved bits не поддерживаются')
|
||||
elif reader.take(1):
|
||||
raise ValueError('CAN FD / reserved bit не поддерживается')
|
||||
dlc = reader.take(4)
|
||||
if dlc > 8:
|
||||
raise ValueError('CAN: поддерживается classic DLC 0…8')
|
||||
data = bytes(reader.take(8) for _ in range(0 if rtr else dlc))
|
||||
expected = can_crc(reader.bits)
|
||||
received = reader.take(15)
|
||||
if reader.run == 5:
|
||||
if reader.raw() == reader.previous:
|
||||
raise ValueError('CAN: неверный последний stuff bit')
|
||||
if reader.raw() != 1:
|
||||
raise ValueError('CAN: неверный CRC delimiter')
|
||||
ack = reader.raw() == 0
|
||||
if reader.raw() != 1 or any(reader.raw() != 1 for _ in range(7)):
|
||||
raise ValueError('CAN: неверный ACK delimiter / EOF')
|
||||
if received != expected:
|
||||
raise ValueError('CAN CRC15: получено %04X, ожидается %04X' % (received, expected))
|
||||
frame = dict(ident=ident, extended=bool(extended), remote=bool(rtr),
|
||||
data=data, dlc=dlc, ack=ack)
|
||||
except (ValueError, EOFError) as exc:
|
||||
error = str(exc)
|
||||
finish = min(end, reader.time + reader.position * bit)
|
||||
yield time, finish, frame, error
|
||||
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
|
||||
|
||||
|
||||
def analyze_capture(capture, options, start=None, end=None, progress=lambda n: None, cancel=lambda: False):
|
||||
start = capture.start if start is None else max(capture.start, start)
|
||||
end = capture.end if end is None else min(capture.end, end)
|
||||
if end <= start:
|
||||
raise ValueError('Выберите непустой интервал анализа.')
|
||||
mode = options['mode']
|
||||
channel = capture.channels[options.get('channel', 0)]
|
||||
events = []
|
||||
last_progress = -1
|
||||
limit = options.get('max_events', 50000)
|
||||
if limit <= 0:
|
||||
raise ValueError('Лимит результатов должен быть положительным.')
|
||||
|
||||
def emit(a, b, kind, text, details=''):
|
||||
if len(events) >= limit:
|
||||
raise OverflowError()
|
||||
events.append(AnalysisEvent(a, b, kind, text, details))
|
||||
|
||||
def report(time):
|
||||
nonlocal last_progress
|
||||
_cancel(cancel)
|
||||
value = min(99, int((time - start) / (end - start) * 100))
|
||||
if value != last_progress:
|
||||
progress(value)
|
||||
last_progress = value
|
||||
|
||||
def parsed(items):
|
||||
for a, b, frame, error in items:
|
||||
emit(a, b, 'error' if error else 'frame', error or frame['summary'],
|
||||
'' if not frame else bytes(frame.get('raw', frame.get('payload', b''))).hex(' '))
|
||||
if frame and frame.get('protocol') == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
|
||||
try:
|
||||
application = legacy(frame['can_id'], frame['payload'])
|
||||
emit(a, b, 'frame', application['summary'])
|
||||
except ValueError as exc:
|
||||
emit(a, b, 'error', str(exc))
|
||||
|
||||
truncated = False
|
||||
try:
|
||||
if mode in ('1SP0635', '1SD536F2'):
|
||||
other = options.get('status_channel', 1)
|
||||
if other == options.get('channel', 0):
|
||||
raise ValueError('Vin и Vstat должны быть разными каналами.')
|
||||
status = capture.channels[other]
|
||||
checker = TimingChecker(1e9, mode, options.get('tolerance_ns', 100),
|
||||
not options.get('vin_low', False), not options.get('status_low', False))
|
||||
# The pure checker works in integer ticks. Offset before rounding
|
||||
# to preserve ns precision for CSV timestamps far from zero.
|
||||
tick = lambda t: round((t - start) * 1e9)
|
||||
|
||||
def timing(items):
|
||||
for item in items:
|
||||
text = item['text']
|
||||
if item['kind'] == 'fault':
|
||||
text += ' · аварийная обратная связь; превышение тока не подтверждено'
|
||||
emit(start + item['start'] / 1e9, start + item['end'] / 1e9, item['kind'], text)
|
||||
|
||||
def transitions(ch, which):
|
||||
first = bisect_right(ch.edges, start)
|
||||
for i in range(first, bisect_right(ch.edges, end)):
|
||||
yield ch.edges[i], which, ch.initial ^ ((i + 1) & 1)
|
||||
|
||||
if bool(status.level_at(start)) == checker.vstat_active_high:
|
||||
emit(start, start, 'orphan', 'Vstat активен в начале диапазона: начало импульса не записано')
|
||||
for time, which, level in heapq.merge(transitions(channel, 0), transitions(status, 1)):
|
||||
report(time)
|
||||
timing(checker.expire(tick(time)))
|
||||
timing(checker.on_control_edge(tick(time), level) if which == 0 else checker.on_status_edge(tick(time), level))
|
||||
timing(checker.expire(tick(end)))
|
||||
if checker.status_start is not None:
|
||||
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
|
||||
for pending in checker.pending:
|
||||
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin: диапазон закончился до тайм-аута ACK')
|
||||
elif mode in ('UART', 'SET UART', 'PM35 UART'):
|
||||
baud = options.get('baudrate', 115200)
|
||||
parity, stops = options.get('parity', 'none'), options.get('stops', 1)
|
||||
if not math.isfinite(baud) or baud <= 0 or parity not in ('none', 'even', 'odd') or stops not in (1, 2):
|
||||
raise ValueError('Некорректные настройки UART.')
|
||||
parser = (StreamParser(options.get('protocol', 'auto')) if mode == 'SET UART' else
|
||||
PM35Parser(options.get('role', 'response')) if mode == 'PM35 UART' else None)
|
||||
gap = (3.5 * (9 + (parity != 'none') + stops) / baud if mode == 'PM35 UART'
|
||||
else options.get('gap_ms', 100) / 1000)
|
||||
last = None
|
||||
for a, b, value, error in uart_frames(channel, start, end, baud, parity, stops, options.get('inverted', False), cancel):
|
||||
report(a)
|
||||
if parser and last is not None and gap > 0 and a - last >= gap:
|
||||
parsed(parser.flush())
|
||||
last = b
|
||||
if error:
|
||||
if parser:
|
||||
parsed(parser.flush())
|
||||
emit(a, b, 'error', error)
|
||||
elif parser:
|
||||
parsed(parser.feed(value, a, b))
|
||||
else:
|
||||
emit(a, b, 'byte', 'UART 0x%02X' % value, chr(value) if 32 <= value < 127 else '')
|
||||
if parser:
|
||||
parsed(parser.flush())
|
||||
elif mode in ('CAN', 'SET CAN'):
|
||||
bitrate = options.get('baudrate', 1000000)
|
||||
sample_point = options.get('sample_point', 70) / 100
|
||||
if not math.isfinite(bitrate) or bitrate <= 0 or not .1 <= sample_point <= .95:
|
||||
raise ValueError('Некорректный битрейт / точка выборки CAN.')
|
||||
reassembler = Reassembler()
|
||||
for a, b, frame, error in can_frames(channel, start, end, bitrate, options.get('inverted', False), sample_point, cancel):
|
||||
report(a)
|
||||
if error:
|
||||
emit(a, b, 'error', error)
|
||||
# Never bridge an invalid/missing physical frame.
|
||||
for pending in reassembler.pending.values():
|
||||
emit(pending['start'], b, 'incomplete', 'SET CAN: сборка прервана ошибкой шины')
|
||||
reassembler.pending.clear()
|
||||
continue
|
||||
ident, data = frame['ident'], frame['data']
|
||||
emit(a, b, 'can', 'CAN %s ID=%08X DLC=%d %s %s' % (
|
||||
'EXT' if frame['extended'] else 'STD', ident, frame['dlc'],
|
||||
'RTR' if frame['remote'] else 'DATA', 'ACK' if frame['ack'] else 'NACK'), data.hex(' '))
|
||||
if mode == 'SET CAN' and frame['extended'] and not frame['remote']:
|
||||
protocol = options.get('protocol', 'protocan')
|
||||
if protocol == 'set-v2':
|
||||
parsed(reassembler.feed(ident, data, a, b, b * 1000))
|
||||
else:
|
||||
try:
|
||||
result = legacy(ident, data, protocol)
|
||||
if result:
|
||||
emit(a, b, 'frame', result['summary'], data.hex(' '))
|
||||
except ValueError as exc:
|
||||
emit(a, b, 'error', str(exc))
|
||||
for pending in reassembler.pending.values():
|
||||
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
|
||||
else:
|
||||
raise ValueError('Неизвестный анализатор: ' + mode)
|
||||
except OverflowError:
|
||||
truncated = True
|
||||
_cancel(cancel)
|
||||
progress(100)
|
||||
events.sort(key=lambda event: (event.start, event.end))
|
||||
return AnalysisResult(events, truncated, start, end)
|
||||
1
python/logic_analyzer/decoders/__init__.py
Normal file
1
python/logic_analyzer/decoders/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
181
python/logic_analyzer/decoders/gate_timing.py
Normal file
181
python/logic_analyzer/decoders/gate_timing.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Pure timing checker used by the DSView decoder and unit tests.
|
||||
|
||||
All time values in profiles are nanoseconds. The ACK delay in the vendor
|
||||
data sheets is a typical value only, so the delay window is intentionally an
|
||||
engineering warning threshold. ACK pulse width has specified limits and is
|
||||
therefore checked as PASS/FAIL.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
PROFILES = {
|
||||
'1SP0635': {
|
||||
'ack_delay_typ_ns': 250.0,
|
||||
'ack_width_min_ns': 400.0,
|
||||
'ack_width_typ_ns': 700.0,
|
||||
'ack_width_max_ns': 1050.0,
|
||||
'fault_threshold_ns': 1500.0,
|
||||
},
|
||||
'1SD536F2': {
|
||||
'ack_delay_typ_ns': 380.0,
|
||||
'ack_width_min_ns': 600.0,
|
||||
'ack_width_typ_ns': 900.0,
|
||||
'ack_width_max_ns': 1800.0,
|
||||
# The application manual calls >1.5 us a fault, while individual
|
||||
# data sheets allow ACK pulses up to 1.8 us. A correlated pulse in
|
||||
# the specified ACK range wins; otherwise the longer limit is used.
|
||||
'fault_threshold_ns': 1800.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_ns(value_ns):
|
||||
if value_ns >= 1000000.0:
|
||||
return '%.3f ms' % (value_ns / 1000000.0)
|
||||
if value_ns >= 1000.0:
|
||||
return '%.3f us' % (value_ns / 1000.0)
|
||||
return '%.1f ns' % value_ns
|
||||
|
||||
|
||||
class TimingChecker(object):
|
||||
"""Match Vin edges to Vstat pulses and return annotation dictionaries."""
|
||||
|
||||
def __init__(self, samplerate, profile='1SP0635', delay_tolerance_ns=100.0,
|
||||
vin_active_high=True, vstat_active_high=True,
|
||||
custom=None):
|
||||
if not samplerate:
|
||||
raise ValueError('samplerate is required')
|
||||
if profile == 'custom':
|
||||
if not custom:
|
||||
raise ValueError('custom profile values are required')
|
||||
self.spec = dict(custom)
|
||||
else:
|
||||
self.spec = dict(PROFILES[profile])
|
||||
self.profile = profile
|
||||
self.samplerate = float(samplerate)
|
||||
self.delay_tolerance_ns = float(delay_tolerance_ns)
|
||||
self.vin_active_high = bool(vin_active_high)
|
||||
self.vstat_active_high = bool(vstat_active_high)
|
||||
self.pending = deque()
|
||||
self.status_start = None
|
||||
self.status_control = None
|
||||
|
||||
# Long enough to avoid calling a merely late ACK "missing", while
|
||||
# still producing a useful annotation during a capture.
|
||||
self.ack_timeout_ns = max(
|
||||
self.spec['ack_delay_typ_ns'] + 3.0 * self.delay_tolerance_ns,
|
||||
self.spec['ack_delay_typ_ns'] + self.spec['ack_width_max_ns'])
|
||||
|
||||
def samples_to_ns(self, samples):
|
||||
return float(samples) * 1000000000.0 / self.samplerate
|
||||
|
||||
def ns_to_samples(self, value_ns):
|
||||
return max(1, int(round(float(value_ns) * self.samplerate / 1000000000.0)))
|
||||
|
||||
def next_deadline(self):
|
||||
if not self.pending:
|
||||
return None
|
||||
return self.pending[0]['sample'] + self.ns_to_samples(self.ack_timeout_ns)
|
||||
|
||||
def on_control_edge(self, sample, level):
|
||||
state_on = bool(level) == self.vin_active_high
|
||||
item = {
|
||||
'sample': int(sample),
|
||||
'level': int(level),
|
||||
'edge': 'ON' if state_on else 'OFF',
|
||||
}
|
||||
self.pending.append(item)
|
||||
return [{
|
||||
'kind': 'control', 'start': int(sample), 'end': int(sample),
|
||||
'text': 'Vin %s' % item['edge'], 'short': item['edge'],
|
||||
}]
|
||||
|
||||
def expire(self, sample):
|
||||
events = []
|
||||
timeout_samples = self.ns_to_samples(self.ack_timeout_ns)
|
||||
while self.pending and int(sample) >= self.pending[0]['sample'] + timeout_samples:
|
||||
item = self.pending.popleft()
|
||||
end = item['sample'] + timeout_samples
|
||||
events.append({
|
||||
'kind': 'missing', 'start': item['sample'], 'end': end,
|
||||
'text': 'FAIL: no Vstat ACK after Vin %s (timeout %s)' %
|
||||
(item['edge'], format_ns(self.ack_timeout_ns)),
|
||||
'short': 'NO ACK',
|
||||
})
|
||||
return events
|
||||
|
||||
def on_status_edge(self, sample, level):
|
||||
sample = int(sample)
|
||||
is_active = bool(level) == self.vstat_active_high
|
||||
events = self.expire(sample)
|
||||
|
||||
if is_active:
|
||||
# Ignore a second active edge caused by an inconsistent trace.
|
||||
if self.status_start is not None:
|
||||
return events
|
||||
self.status_start = sample
|
||||
self.status_control = self.pending.popleft() if self.pending else None
|
||||
if self.status_control is not None:
|
||||
delay_ns = self.samples_to_ns(sample - self.status_control['sample'])
|
||||
typ_ns = self.spec['ack_delay_typ_ns']
|
||||
delta_ns = delay_ns - typ_ns
|
||||
in_window = abs(delta_ns) <= self.delay_tolerance_ns
|
||||
events.append({
|
||||
'kind': 'delay_ok' if in_window else 'delay_warn',
|
||||
'start': self.status_control['sample'], 'end': sample,
|
||||
'delay_ns': delay_ns,
|
||||
'text': '%s: ACK delay %s (typ %s, delta %+0.1f ns)' %
|
||||
('PASS' if in_window else 'WARN', format_ns(delay_ns),
|
||||
format_ns(typ_ns), delta_ns),
|
||||
'short': '%s %s' % ('OK' if in_window else 'WARN',
|
||||
format_ns(delay_ns)),
|
||||
})
|
||||
return events
|
||||
|
||||
if self.status_start is None:
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': sample, 'end': sample,
|
||||
'text': 'Unexpected inactive Vstat edge', 'short': 'Vstat?',
|
||||
})
|
||||
return events
|
||||
|
||||
start = self.status_start
|
||||
control = self.status_control
|
||||
width_ns = self.samples_to_ns(sample - start)
|
||||
self.status_start = None
|
||||
self.status_control = None
|
||||
lo = self.spec['ack_width_min_ns']
|
||||
hi = self.spec['ack_width_max_ns']
|
||||
|
||||
if control is not None and lo <= width_ns <= hi:
|
||||
events.append({
|
||||
'kind': 'width_ok', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'PASS: ACK width %s (limit %s...%s)' %
|
||||
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
|
||||
'short': 'ACK %s' % format_ns(width_ns),
|
||||
})
|
||||
elif width_ns > self.spec['fault_threshold_ns']:
|
||||
events.append({
|
||||
'kind': 'fault', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'FAULT: Vstat active for %s' % format_ns(width_ns),
|
||||
'short': 'FAULT %s' % format_ns(width_ns),
|
||||
})
|
||||
elif control is None:
|
||||
events.append({
|
||||
'kind': 'orphan', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'Unexpected Vstat pulse %s (no Vin edge)' % format_ns(width_ns),
|
||||
'short': 'ORPHAN %s' % format_ns(width_ns),
|
||||
})
|
||||
else:
|
||||
events.append({
|
||||
'kind': 'width_fail', 'start': start, 'end': sample,
|
||||
'width_ns': width_ns,
|
||||
'text': 'FAIL: ACK width %s outside %s...%s' %
|
||||
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
|
||||
'short': 'BAD ACK %s' % format_ns(width_ns),
|
||||
})
|
||||
return events
|
||||
95
python/logic_analyzer/decoders/pm35_uart.py
Normal file
95
python/logic_analyzer/decoders/pm35_uart.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""PM35 MODBUS 03/06, including the legacy 128-word/zero-byte-count reply."""
|
||||
from .set_uart import crc16, number
|
||||
|
||||
|
||||
def parse_pm35(raw, role):
|
||||
raw = bytes(raw)
|
||||
if len(raw) < 5:
|
||||
raise ValueError('Incomplete PM35 frame')
|
||||
if crc16(raw[:-2], True) != number(raw[-2:]):
|
||||
raise ValueError('PM35 CRC mismatch')
|
||||
address, function = raw[:2]
|
||||
result = dict(protocol='PM35', controller=address, function=function, raw=raw)
|
||||
if function in (0x83, 0x86):
|
||||
if len(raw) != 5 or role == 'request':
|
||||
raise ValueError('Invalid exception response')
|
||||
result['summary'] = 'PM35 node=%d exception fn=%02X code=%d' % (address, function, raw[2])
|
||||
elif function == 6 or function == 3 and role == 'request':
|
||||
if len(raw) != 8:
|
||||
raise ValueError('PM35 request/06 must contain 8 bytes')
|
||||
register, value = number(raw[2:4], 'big'), number(raw[4:6], 'big')
|
||||
if register >= 128 or function == 3 and not 1 <= value <= 128 - register:
|
||||
raise ValueError('PM35 register range outside 0..127')
|
||||
result.update(register=register, value=value)
|
||||
result['summary'] = 'PM35 node=%d %s R%d %s=%d' % (address,
|
||||
'READ' if function == 3 else 'WRITE/echo', register,
|
||||
'count' if function == 3 else 'value', value)
|
||||
if function == 6 and register == 127:
|
||||
result['summary'] += ' command_bits=%04X' % value
|
||||
elif function == 3:
|
||||
count = raw[2] or 256
|
||||
if count % 2 or len(raw) != count + 5:
|
||||
raise ValueError('Invalid PM35 response byte count')
|
||||
result['values'] = [number(raw[i:i+2], 'big') for i in range(3, len(raw)-2, 2)]
|
||||
result['summary'] = 'PM35 node=%d READ response %d words: %s' % (address,
|
||||
len(result['values']), ' '.join('%04X' % v for v in result['values']))
|
||||
else:
|
||||
raise ValueError('Unsupported PM35 function')
|
||||
return result
|
||||
|
||||
|
||||
class PM35Parser:
|
||||
def __init__(self, role):
|
||||
self.role = role
|
||||
self.buffer = []
|
||||
|
||||
def flush(self):
|
||||
if not self.buffer:
|
||||
return []
|
||||
ss, es = self.buffer[0][1], self.buffer[-1][2]
|
||||
self.buffer = []
|
||||
return [(ss, es, None, 'Incomplete PM35 frame')]
|
||||
|
||||
def feed(self, value, ss, es):
|
||||
self.buffer.append((value, ss, es))
|
||||
events = []
|
||||
while len(self.buffer) >= 3:
|
||||
raw = bytes(item[0] for item in self.buffer)
|
||||
fn = raw[1]
|
||||
if fn not in (3, 6, 0x83, 0x86):
|
||||
del self.buffer[0]
|
||||
continue
|
||||
role = self.role
|
||||
total = 5 if fn & 128 else 8
|
||||
if role == 'auto' and fn == 3:
|
||||
candidates = [(8, 'request'), ((raw[2] or 256) + 5, 'response')]
|
||||
found = None
|
||||
for length, candidate_role in candidates:
|
||||
if len(raw) >= length:
|
||||
try:
|
||||
found = (length, candidate_role, parse_pm35(raw[:length], candidate_role))
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if found:
|
||||
length, candidate_role, result = found
|
||||
events.append((self.buffer[0][1], self.buffer[length-1][2], result, None))
|
||||
del self.buffer[:length]
|
||||
continue
|
||||
if len(raw) < max(length for length, _ in candidates):
|
||||
break
|
||||
events.append((self.buffer[0][1], self.buffer[-1][2], None, 'Invalid PM35 frame/CRC'))
|
||||
del self.buffer[0]
|
||||
continue
|
||||
if fn == 3 and role == 'response':
|
||||
total = (raw[2] or 256) + 5
|
||||
if len(raw) < total:
|
||||
break
|
||||
result, error = None, None
|
||||
try:
|
||||
result = parse_pm35(raw[:total], role)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
events.append((self.buffer[0][1], self.buffer[total-1][2], result, error))
|
||||
del self.buffer[:1 if error else total]
|
||||
return events
|
||||
124
python/logic_analyzer/decoders/set_can.py
Normal file
124
python/logic_analyzer/decoders/set_can.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ProtoCAN, Balsam and SET v2 classic CAN binding."""
|
||||
from .set_uart import number, parse_frame
|
||||
|
||||
TYPES = {0: 'BROADCAST', 1: 'DISCRETE', 2: 'ANALOG', 3: 'GAS',
|
||||
4: 'MODBUS_COIL', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING',
|
||||
7: 'MODBUS_INPUT', 8: 'ERROR', 9: 'BOOT_CONTROL', 10: 'BOOT_DATA_A',
|
||||
11: 'BOOT_DATA_B', 12: 'BOOT_STATUS', 13: 'BOOT_DISCOVERY', 15: 'PULSE'}
|
||||
BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIFY '
|
||||
'COMMIT CONFIRM REBOOT ABORT QUERY_PROGRESS').split(), 1))
|
||||
|
||||
|
||||
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)))
|
||||
msg, body = (ident >> 16) & 15, ident & 0xffff
|
||||
result = dict(protocol='ProtoCAN', priority=(ident >> 28) & 1,
|
||||
route=(ident >> 27) & 1, device_type=(ident >> 24) & 7,
|
||||
device_id=(ident >> 20) & 15, message_type=msg, body=body)
|
||||
text = 'ProtoCAN %s dev=%d:%d route=%d priority=%d body=%04X' % (
|
||||
TYPES.get(msg, 'TYPE_%X' % msg), result['device_type'], result['device_id'],
|
||||
result['route'], result['priority'], body)
|
||||
if msg == 3:
|
||||
if len(data) % 2 or body + len(data)//2 > 65536:
|
||||
raise ValueError('Invalid GAS register payload/range')
|
||||
result['registers'] = [(body + i//2, number(data[i:i+2])) for i in range(0, len(data), 2)]
|
||||
text += ' ' + ('READ' if not data else ' '.join('R%04X=%04X' % pair for pair in result['registers']))
|
||||
elif 4 <= msg <= 7:
|
||||
text += ' address=%03X count=%d' % (body >> 4, body & 15)
|
||||
elif msg == 8:
|
||||
text += ' info=%02X code=%02X' % (body >> 8, body & 255)
|
||||
elif msg in (9, 12):
|
||||
command = body & 255
|
||||
text += ' session=%d command=%s' % (body >> 8, BOOT.get(command, '%02X' % command))
|
||||
if msg == 12:
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT_STATUS requires DLC=8')
|
||||
text += ' status=%d slot=%d next_block=%d CRC32=%08X' % (data[0], data[1], number(data[2:4]), number(data[4:8]))
|
||||
elif command in (3, 4):
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT metadata requires DLC=8')
|
||||
if command == 3:
|
||||
text += ' image_size=%d CRC32=%08X' % (number(data[:4]), number(data[4:]))
|
||||
else:
|
||||
text += ' product=%d hw=%d..%d version=%08X' % (number(data[:2]), data[2], data[3], number(data[4:]))
|
||||
elif command in BOOT and data:
|
||||
raise ValueError('BOOT control requires DLC=0')
|
||||
elif msg in (10, 11):
|
||||
if len(data) != 8:
|
||||
raise ValueError('BOOT_DATA requires DLC=8')
|
||||
text += ' block=%d offset=0x%X' % (body, body * 8)
|
||||
elif msg in (0, 1, 2):
|
||||
shift = 4 if msg == 0 else 12
|
||||
text += ' type=%X value=%X' % (body >> shift, body & ((1 << shift) - 1))
|
||||
result['summary'] = text
|
||||
return result
|
||||
|
||||
|
||||
class Reassembler:
|
||||
def __init__(self):
|
||||
self.pending = {}
|
||||
|
||||
def feed(self, ident, data, ss, es, now_ms):
|
||||
events = []
|
||||
for key, state in list(self.pending.items()):
|
||||
if now_ms - state['time'] >= 500:
|
||||
events.append((state['start'], es, None, 'SET CAN timeout ID=%08X' % key))
|
||||
del self.pending[key]
|
||||
if ident >> 24 != 0x12:
|
||||
return events
|
||||
try:
|
||||
if not 1 <= len(data) <= 8:
|
||||
raise ValueError('Invalid SET CAN DLC')
|
||||
pci = data[0] & 0xf0
|
||||
if pci == 0x30:
|
||||
if len(data) != 3 or data[0] & 15 > 2 or data[2] > 127:
|
||||
raise ValueError('Invalid FLOW_CONTROL')
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='FLOW_CONTROL status=%d block=%d st_min=%d ms' % (data[0] & 15, data[1], data[2])), None))
|
||||
elif pci == 0x10:
|
||||
if data[0] != 0x10 or len(data) != 8 or not 18 <= number(data[1:3]) <= 530:
|
||||
raise ValueError('Invalid FIRST segment')
|
||||
old = self.pending.pop(ident, None)
|
||||
if old:
|
||||
events.append((old['start'], es, None, 'FIRST replaced incomplete packet'))
|
||||
if len(self.pending) >= 64:
|
||||
raise ValueError('Too many concurrent SET CAN packets (64)')
|
||||
self.pending[ident] = dict(start=ss, time=now_ms, total=number(data[1:3]),
|
||||
data=bytearray(data[3:]), sequence=1)
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='FIRST length=%d' % number(data[1:3])), None))
|
||||
elif pci == 0x20:
|
||||
state = self.pending.get(ident)
|
||||
if not state or len(data) < 2 or data[0] & 15 != state['sequence']:
|
||||
raise ValueError('Unexpected CONSECUTIVE sequence')
|
||||
state['data'].extend(data[1:])
|
||||
state['time'], state['sequence'] = now_ms, (state['sequence'] + 1) & 15
|
||||
if len(state['data']) > state['total']:
|
||||
raise ValueError('SET CAN packet exceeds advertised length')
|
||||
if len(state['data']) == state['total']:
|
||||
packet = parse_frame(state['data'])
|
||||
if packet['protocol'] != 'SET v2' or packet['source'] != (ident >> 8) & 255 or packet['destination'] != (ident >> 16) & 255 or bool(packet['flags'] & 32) != bool(ident & 128):
|
||||
raise ValueError('CAN ID and SET header disagree')
|
||||
events.append((state['start'], es, packet, None))
|
||||
del self.pending[ident]
|
||||
else:
|
||||
events.append((ss, es, dict(protocol='SET CAN', summary='CONSECUTIVE SN=%d' % (data[0] & 15)), None))
|
||||
else:
|
||||
raise ValueError('Unknown SET CAN PCI')
|
||||
except ValueError as exc:
|
||||
state = self.pending.pop(ident, None)
|
||||
events.append((state['start'] if state else ss, es, None, str(exc)))
|
||||
return events
|
||||
133
python/logic_analyzer/decoders/set_uart.py
Normal file
133
python/logic_analyzer/decoders/set_uart.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Portable wire readers based on templates/c/set-protocol (no native DLL)."""
|
||||
import binascii
|
||||
|
||||
|
||||
V2 = {1: 'PING', 2: 'DEVICE_INFO', 3: 'CAPABILITIES', 8: 'DIAGNOSTICS',
|
||||
9: 'READ', 10: 'WRITE', 16: 'LOG_READ', 17: 'CATALOG',
|
||||
18: 'SUBSCRIBE', 19: 'PUBLISH', 20: 'UNSUBSCRIBE'}
|
||||
V2.update(dict((256 + i, 'FW_' + name) for i, name in enumerate(
|
||||
('BEGIN', 'DATA', 'END', 'ABORT', 'STATUS', 'ACTIVATE'))))
|
||||
V1 = dict(enumerate(('PING DEVICE_INFO GET_OBJECT SET_OBJECT GET_OBJECT_LIST '
|
||||
'GET_OBJECT_INFO COMMAND_STATUS DIAGNOSTICS READ_REGISTERS WRITE_REGISTERS '
|
||||
'FIRMWARE_BEGIN FIRMWARE_DATA FIRMWARE_END FIRMWARE_ABORT FIRMWARE_STATUS '
|
||||
'READ_LOGS GAS_CATALOG GAS_WATCH_SET GAS_WATCH_DATA').split(), 1))
|
||||
V1.update({128: 'NACK', 129: 'ACK', 130: 'ERROR'})
|
||||
V1.update(dict(enumerate(('SENSOR_SCAN SENSOR_LIST SENSOR_READ SENSOR_DATA '
|
||||
'SET_USER_BYTES SET_RESOLUTION SET_POLL_PERIOD SEND_ID_CAN UI_KEY UI_READ '
|
||||
'UI_STATE EEPROM_SCAN EEPROM_INFO EEPROM_READ EEPROM_LIST').split(), 32)))
|
||||
|
||||
|
||||
def number(data, order='little'):
|
||||
return int.from_bytes(bytes(data), order)
|
||||
|
||||
|
||||
def crc16(data, modbus=False):
|
||||
crc = 0xffff
|
||||
for byte in data:
|
||||
crc ^= byte if modbus else byte << 8
|
||||
for _ in range(8):
|
||||
if modbus:
|
||||
crc = (crc >> 1) ^ (0xa001 if crc & 1 else 0)
|
||||
else:
|
||||
crc = ((crc << 1) ^ (0x1021 if crc & 0x8000 else 0)) & 0xffff
|
||||
return crc
|
||||
|
||||
|
||||
def parse_frame(raw):
|
||||
raw = bytes(raw)
|
||||
bridge = raw[:2] == b'\xaa\x55'
|
||||
if bridge:
|
||||
if len(raw) < 11 or not 6 <= raw[2] <= 14 or len(raw) != raw[2] + 5:
|
||||
raise ValueError('Invalid ProtoCAN length')
|
||||
crc, received = crc16(raw[2:-2]), number(raw[-2:])
|
||||
ident, flags = number(raw[5:9]), raw[4]
|
||||
if ident > (0x1fffffff if flags & 1 else 0x7ff) or flags & 0xf0:
|
||||
raise ValueError('Invalid ProtoCAN ID/flags')
|
||||
result = dict(protocol='ProtoCAN bridge', sequence=raw[3], flags=flags,
|
||||
can_id=ident, payload=raw[9:-2])
|
||||
result['summary'] = 'ProtoCAN seq=%d ID=%08X flags=%02X' % (raw[3], ident, flags)
|
||||
else:
|
||||
if len(raw) < 12 or raw[:2] != b'\xa5\x5a' or raw[2] not in (1, 2):
|
||||
raise ValueError('Invalid SET signature/version')
|
||||
version = raw[2]
|
||||
header = 14 if version == 2 else 8
|
||||
if len(raw) < header + 4:
|
||||
raise ValueError('Incomplete SET header')
|
||||
size = number(raw[12:14]) if version == 2 else number(raw[6:8], 'big')
|
||||
if size > 512 or len(raw) != header + size + 4:
|
||||
raise ValueError('Invalid SET payload length')
|
||||
crc, received = binascii.crc32(raw[2:-4]) & 0xffffffff, number(raw[-4:])
|
||||
msg = number(raw[4:6]) if version == 2 else raw[3]
|
||||
seq = number(raw[10:12]) if version == 2 else number(raw[4:6], 'big')
|
||||
result = dict(protocol='SET v2' if version == 2 else 'SETGUI v1',
|
||||
message_type=msg, sequence=seq, payload=raw[header:-4])
|
||||
name = (V2 if version == 2 else V1).get(msg, 'TYPE_%04X' % msg)
|
||||
result['summary'] = '%s %s seq=%d len=%d' % (result['protocol'], name, seq, size)
|
||||
if version == 2:
|
||||
result.update(source=number(raw[6:8]), destination=number(raw[8:10]), flags=raw[3])
|
||||
result['summary'] += ' %04X->%04X flags=%02X' % (result['source'], result['destination'], raw[3])
|
||||
if raw[3] & 0xc0:
|
||||
raise ValueError('Reserved SET flags')
|
||||
if raw[3] & 1:
|
||||
if size < 2:
|
||||
raise ValueError('SET response missing status')
|
||||
result['status'] = number(result['payload'][:2])
|
||||
result['summary'] += ' status=%d' % result['status']
|
||||
if crc != received:
|
||||
raise ValueError('CRC mismatch: received %X expected %X' % (received, crc))
|
||||
result['raw'] = raw
|
||||
return result
|
||||
|
||||
|
||||
class StreamParser:
|
||||
"""Bounded byte stream; keep sample positions during resynchronization."""
|
||||
def __init__(self, mode='auto'):
|
||||
self.mode = mode
|
||||
self.buffer = []
|
||||
|
||||
def feed(self, value, start, end):
|
||||
self.buffer.append((value, start, end))
|
||||
events = []
|
||||
while len(self.buffer) >= 3:
|
||||
raw = bytes(item[0] for item in self.buffer)
|
||||
bridge = raw[:2] == b'\xaa\x55' and self.mode in ('auto', 'bridge')
|
||||
modern = raw[:2] == b'\xa5\x5a' and self.mode != 'bridge'
|
||||
if not bridge and not modern:
|
||||
del self.buffer[0]
|
||||
continue
|
||||
error = None
|
||||
if bridge:
|
||||
total = raw[2] + 5
|
||||
if not 6 <= raw[2] <= 14:
|
||||
error = 'Invalid ProtoCAN length'
|
||||
else:
|
||||
if raw[2] not in (1, 2) or self.mode in ('v1', 'v2') and raw[2] != int(self.mode[1]):
|
||||
error = 'Unexpected SET version'
|
||||
header = 14 if raw[2] == 2 else 8
|
||||
if not error and len(raw) < header:
|
||||
break
|
||||
size = number(raw[12:14]) if raw[2] == 2 else number(raw[6:8], 'big')
|
||||
total = header + size + 4
|
||||
if size > 512:
|
||||
error = 'Payload exceeds 512 bytes'
|
||||
if not error and len(raw) < total:
|
||||
break
|
||||
last = min(len(raw), total) - 1
|
||||
result = None
|
||||
if not error:
|
||||
try:
|
||||
result = parse_frame(raw[:total])
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
events.append((self.buffer[0][1], self.buffer[last][2], result, error))
|
||||
del self.buffer[:1 if error else total]
|
||||
return events
|
||||
|
||||
def flush(self):
|
||||
if not self.buffer:
|
||||
return []
|
||||
pending = self.buffer
|
||||
self.buffer = []
|
||||
if pending[0][0] not in (0xa5, 0xaa):
|
||||
return []
|
||||
return [(pending[0][1], pending[-1][2], None, 'Incomplete frame')]
|
||||
299
python/logic_analyzer/files.py
Normal file
299
python/logic_analyzer/files.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Digital capture readers. Store edges, never expand captures to sample arrays.
|
||||
|
||||
DSView layout: DreamSourceLab/DSView libsigrok4DSL/session_driver.c.
|
||||
SAL v1 layout is validated against example/digitOSC; v3 RLE layout reference:
|
||||
https://github.com/nemanjan00/sigrok2sal/blob/master/SAL_FORMAT.md
|
||||
Unknown binary versions fail explicitly instead of guessing their layout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from bisect import bisect_right
|
||||
import configparser
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
|
||||
class ImportCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigitalChannel:
|
||||
name: str
|
||||
initial: int
|
||||
edges: array
|
||||
|
||||
def level_at(self, time):
|
||||
return self.initial ^ (bisect_right(self.edges, time) & 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigitalCapture:
|
||||
source: str
|
||||
format: str
|
||||
channels: list
|
||||
start: float
|
||||
end: float
|
||||
sample_rate: float = 0
|
||||
|
||||
|
||||
def _check(cancel):
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
|
||||
|
||||
def read_capture(path, progress=lambda value: None, cancel=lambda: False):
|
||||
path = Path(path)
|
||||
try:
|
||||
if path.suffix.lower() == '.csv':
|
||||
result = _csv(path, progress, cancel)
|
||||
elif path.suffix.lower() in ('.sal', '.dsl'):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
result = (_sal if path.suffix.lower() == '.sal' else _dsl)(
|
||||
path, archive, progress, cancel)
|
||||
else:
|
||||
raise ValueError('Выберите цифровую запись CSV, SAL или DSL.')
|
||||
except (KeyError, struct.error, zipfile.BadZipFile, EOFError,
|
||||
configparser.Error, UnicodeError) as exc:
|
||||
raise ValueError('Повреждённый или неподдерживаемый файл: %s' % exc) from exc
|
||||
_check(cancel)
|
||||
if not result.channels or not math.isfinite(result.start) or not math.isfinite(result.end):
|
||||
raise ValueError('Файл не содержит цифровой записи.')
|
||||
progress(100)
|
||||
return result
|
||||
|
||||
|
||||
def _csv(path, progress, cancel):
|
||||
size = max(1, path.stat().st_size)
|
||||
consumed = 0
|
||||
channels = None
|
||||
previous = []
|
||||
start = end = None
|
||||
dsview = False
|
||||
with path.open(encoding='utf-8-sig', newline='') as stream:
|
||||
for number, line in enumerate(stream, 1):
|
||||
consumed += len(line)
|
||||
if number % 4096 == 0:
|
||||
_check(cancel)
|
||||
progress(min(99, int(consumed * 100 / size)))
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.lstrip().startswith((';', '#')):
|
||||
dsview |= 'DSView' in line
|
||||
continue
|
||||
if channels is None:
|
||||
delimiter = max((',', ';', '\t'), key=line.count)
|
||||
names = [v.strip() for v in next(csv.reader([line], delimiter=delimiter))]
|
||||
match = re.fullmatch(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', names[0], re.I)
|
||||
if not match or not 2 <= len(names) <= 65 or any(not n for n in names):
|
||||
raise ValueError('Ожидается CSV: Time [s], затем цифровые каналы (0/1).')
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError('Повторяющиеся имена каналов CSV.')
|
||||
factor = {'s': 1, 'ms': 1e-3, 'us': 1e-6, 'µs': 1e-6, 'ns': 1e-9}[(match[1] or 's').lower()]
|
||||
channels = [DigitalChannel(name, 0, array('d')) for name in names[1:]]
|
||||
continue
|
||||
row = next(csv.reader([line], delimiter=delimiter))
|
||||
try:
|
||||
time = float(row[0].strip().replace(',', '.')) * factor
|
||||
values = [v.strip() for v in row[1:]]
|
||||
if len(values) != len(channels) or any(v not in ('0', '1') for v in values):
|
||||
raise ValueError('цифровые уровни должны быть 0 или 1')
|
||||
if not math.isfinite(time) or (end is not None and time <= end):
|
||||
raise ValueError('время должно строго возрастать и быть конечным')
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise ValueError('CSV, строка %d: %s' % (number, exc)) from exc
|
||||
if start is None:
|
||||
start = time
|
||||
for channel, value in zip(channels, values):
|
||||
channel.initial = int(value)
|
||||
else:
|
||||
for i, channel in enumerate(channels):
|
||||
if values[i] != previous[i]:
|
||||
channel.edges.append(time)
|
||||
previous = values
|
||||
end = time
|
||||
if start is None:
|
||||
raise ValueError('CSV не содержит данных.')
|
||||
return DigitalCapture(str(path), 'DSLogic CSV' if dsview else 'Logic 2 CSV', channels, start, end)
|
||||
|
||||
|
||||
class _Binary:
|
||||
def __init__(self, data):
|
||||
self.data, self.pos = data, 0
|
||||
|
||||
def take(self, size):
|
||||
if size < 0 or self.pos + size > len(self.data):
|
||||
raise ValueError('Обрезанный двоичный блок.')
|
||||
value = self.data[self.pos:self.pos + size]
|
||||
self.pos += size
|
||||
return value
|
||||
|
||||
def unpack(self, fmt):
|
||||
return struct.unpack('<' + fmt, self.take(struct.calcsize('<' + fmt)))
|
||||
|
||||
|
||||
def _runs(data):
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
value = data[pos]
|
||||
pos += 1
|
||||
if value >= 128:
|
||||
raise ValueError('Некорректный RLE SAL.')
|
||||
if value & 64:
|
||||
value &= 63
|
||||
for _ in range(9):
|
||||
if pos == len(data):
|
||||
raise ValueError('Обрезанный RLE SAL.')
|
||||
byte = data[pos]
|
||||
pos += 1
|
||||
value = (value << 7) | (byte & 127)
|
||||
if not byte & 128:
|
||||
break
|
||||
else:
|
||||
raise ValueError('Слишком длинный RLE SAL.')
|
||||
yield value + 1
|
||||
|
||||
|
||||
def _sal_channel(data, name, cancel):
|
||||
reader = _Binary(data)
|
||||
if reader.take(8) != b'<SALEAE>':
|
||||
raise ValueError('Неверная сигнатура SAL.')
|
||||
version, kind = reader.unpack('II')
|
||||
if version not in (1, 3, 4) or kind != 100:
|
||||
raise ValueError('Не поддерживается цифровой SAL версии %d, тип %d.' % (version, kind))
|
||||
schema, rate, unix_ms, fractional_ms = reader.unpack('BdQd')
|
||||
if schema != 1 or not math.isfinite(rate) or rate <= 0:
|
||||
raise ValueError('Некорректный заголовок канала SAL.')
|
||||
for _ in range(2):
|
||||
optional, = reader.unpack('B')
|
||||
if optional not in (0, 1):
|
||||
raise ValueError('Неизвестный заголовок SAL.')
|
||||
if optional:
|
||||
reader.take(8)
|
||||
count, = reader.unpack('Q')
|
||||
if not 0 < count <= len(data) // 26:
|
||||
raise ValueError('Неверное число блоков SAL.')
|
||||
channel = DigitalChannel(name, 0, array('d'))
|
||||
previous_end = None
|
||||
level = 0
|
||||
for chunk in range(count):
|
||||
_check(cancel)
|
||||
if version == 1:
|
||||
begin, end, samples, chunk_rate, scale, length = reader.unpack('6Q')
|
||||
if samples != end - begin or chunk_rate != rate or scale != 1:
|
||||
raise ValueError('Неподдерживаемая шкала блока SAL.')
|
||||
encoded = reader.take(length)
|
||||
entries, = reader.unpack('Q')
|
||||
if not entries:
|
||||
raise ValueError('В SAL отсутствует индекс начального уровня.')
|
||||
index = reader.take(entries * 20)
|
||||
offset, byte_offset, initial = struct.unpack_from('<QQI', index)
|
||||
if offset or byte_offset:
|
||||
raise ValueError('Некорректный индекс SAL.')
|
||||
else:
|
||||
begin, end, initial, flags, length = reader.unpack('QQBBQ')
|
||||
if flags:
|
||||
raise ValueError('Неизвестные флаги SAL.')
|
||||
encoded = reader.take(length)
|
||||
if initial not in (0, 1) or end <= begin or (previous_end is not None and begin != previous_end):
|
||||
raise ValueError('Некорректные границы или уровень блока SAL.')
|
||||
if chunk == 0:
|
||||
first = begin
|
||||
channel.initial = initial
|
||||
elif initial != level:
|
||||
channel.edges.append(begin / rate)
|
||||
level = initial
|
||||
position = begin
|
||||
for run_index, run in enumerate(_runs(encoded)):
|
||||
if run_index % 8192 == 0:
|
||||
_check(cancel)
|
||||
position += run
|
||||
if position > end:
|
||||
raise ValueError('RLE выходит за границы блока SAL.')
|
||||
if position < end:
|
||||
channel.edges.append(position / rate)
|
||||
level ^= 1
|
||||
if position != end:
|
||||
raise ValueError('Неполный RLE блока SAL.')
|
||||
previous_end = end
|
||||
if reader.pos != len(data):
|
||||
raise ValueError('Неизвестные дополнительные данные SAL.')
|
||||
return channel, first / rate, previous_end / rate, rate
|
||||
|
||||
|
||||
def _sal(path, archive, progress, cancel):
|
||||
meta = json.loads(archive.read('meta.json'))
|
||||
names = {}
|
||||
for row in meta.get('data', {}).get('rowsSettings', []):
|
||||
channel = row.get('channel', {})
|
||||
if channel.get('type') == 'Digital':
|
||||
names[channel.get('deviceChannel')] = row.get('name')
|
||||
files = sorted((int(m[1]), name) for name in archive.namelist()
|
||||
for m in [re.fullmatch(r'digital-(\d+)\.bin', name)] if m)
|
||||
if not files:
|
||||
raise ValueError('В SAL нет цифровых каналов.')
|
||||
channels, bounds = [], []
|
||||
for i, (index, filename) in enumerate(files):
|
||||
_check(cancel)
|
||||
channel, start, end, rate = _sal_channel(archive.read(filename), names.get(index) or 'D%d' % index, cancel)
|
||||
channels.append(channel)
|
||||
bounds.append((start, end, rate))
|
||||
progress(int((i + 1) * 100 / len(files)))
|
||||
if any(bound != bounds[0] for bound in bounds):
|
||||
raise ValueError('SAL с разными временными границами каналов не поддерживается.')
|
||||
return DigitalCapture(str(path), 'Logic 2 SAL', channels, *bounds[0])
|
||||
|
||||
|
||||
def _dsl(path, archive, progress, cancel):
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read_string(archive.read('header').decode('utf-8-sig'))
|
||||
version = config.getint('version', 'version')
|
||||
header = config['header']
|
||||
if version not in (2, 3) or header.getint('device mode', 0) != 0:
|
||||
raise ValueError('Поддерживаются цифровые сессии DSView DSL версий 2 и 3.')
|
||||
match = re.fullmatch(r'\s*([\d.]+)\s*([kKmMgG]?)(?:Hz)?\s*', header['samplerate'])
|
||||
if not match:
|
||||
raise ValueError('Неверная частота DSL.')
|
||||
rate = float(match[1]) * {'': 1, 'k': 1e3, 'm': 1e6, 'g': 1e9}[match[2].lower()]
|
||||
samples = header.getint('total samples')
|
||||
blocks = header.getint('total blocks')
|
||||
if not math.isfinite(rate) or rate <= 0 or samples <= 0 or blocks <= 0:
|
||||
raise ValueError('Некорректный размер или частота DSL.')
|
||||
probes = sorted((int(m[1]), name) for key, name in header.items()
|
||||
for m in [re.fullmatch(r'probe(\d+)', key)] if m)
|
||||
channels = []
|
||||
for i, (index, name) in enumerate(probes):
|
||||
channel = DigitalChannel(name, 0, array('d'))
|
||||
position, level = 0, 0
|
||||
for block in range(blocks):
|
||||
_check(cancel)
|
||||
# DSView v3 stores enabled channels in sequential directories.
|
||||
directory = i if version == 3 else index
|
||||
data = archive.read('L-%d/%d' % (directory, block))
|
||||
for offset, byte in enumerate(data):
|
||||
if offset % 65536 == 0:
|
||||
_check(cancel)
|
||||
if position >= samples:
|
||||
break
|
||||
if position == 0:
|
||||
channel.initial = level = byte & 1
|
||||
changes = byte ^ ((byte << 1) & 255 | level)
|
||||
while changes:
|
||||
bit = (changes & -changes).bit_length() - 1
|
||||
if position + bit < samples:
|
||||
channel.edges.append((position + bit) / rate)
|
||||
changes &= changes - 1
|
||||
level = byte >> 7
|
||||
position += 8
|
||||
if position < samples:
|
||||
raise ValueError('Обрезанные данные DSL, канал %s.' % name)
|
||||
channels.append(channel)
|
||||
progress(int((i + 1) * 100 / max(1, len(probes))))
|
||||
return DigitalCapture(str(path), 'DSLogic DSL', channels, 0, samples / rate, rate)
|
||||
155
python/logic_analyzer/saleae.py
Normal file
155
python/logic_analyzer/saleae.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Local Logic 2 acquisition through the optional Saleae Automation API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from logic_analyzer.files import DigitalCapture, DigitalChannel, ImportCancelled
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SaleaeSettings:
|
||||
device_id: str
|
||||
channels: tuple = (0, 1)
|
||||
sample_rate: int = 24000000
|
||||
duration: float = 1.0 # Wall-clock stop timer; zero means manual stop.
|
||||
threshold: float = None
|
||||
buffer_mb: int = 256
|
||||
port: int = 10430
|
||||
|
||||
def validate(self):
|
||||
if not self.device_id:
|
||||
raise ValueError('Выберите устройство Saleae.')
|
||||
if (not self.channels or len(set(self.channels)) != len(self.channels)
|
||||
or any(type(c) is not int or not 0 <= c <= 15 for c in self.channels)):
|
||||
raise ValueError('Выберите цифровые каналы D0…D15 без повторений.')
|
||||
if not 1 <= self.sample_rate <= 500000000:
|
||||
raise ValueError('Частота должна быть от 1 до 500000000 выборок/с.')
|
||||
if not math.isfinite(self.duration) or not 0 <= self.duration <= 3600:
|
||||
raise ValueError('Таймер должен быть от 0 до 3600 с.')
|
||||
if self.threshold not in (None, 1.2, 1.8, 3.3):
|
||||
raise ValueError('Неподдерживаемый порог цифрового входа.')
|
||||
if not 16 <= self.buffer_mb <= 4096 or not 1 <= self.port <= 65535:
|
||||
raise ValueError('Некорректный буфер или порт Automation API.')
|
||||
|
||||
|
||||
def automation_api():
|
||||
try:
|
||||
from saleae import automation
|
||||
except ImportError as exc:
|
||||
raise RuntimeError('Не установлен logic2-automation. Для запуска из Python: '
|
||||
'pip install logic2-automation; для EXE нужна сборка с этим пакетом.') from exc
|
||||
return automation
|
||||
|
||||
|
||||
def connect(api, port):
|
||||
# Bound every RPC, including stop/export/close, when Logic 2 stops responding.
|
||||
config = json.dumps({'methodConfig': [{'name': [{}], 'timeout': '30s'}]})
|
||||
try:
|
||||
return api.Manager.connect(port=port, connect_timeout_seconds=3,
|
||||
grpc_channel_arguments=[('grpc.service_config', config)])
|
||||
except Exception as exc:
|
||||
raise RuntimeError('Нет связи с Logic 2 на localhost:%d. Запустите Logic 2 и '
|
||||
'включите Preferences → Enable Automation Server. %s' % (port, exc)) from exc
|
||||
|
||||
|
||||
def list_devices(port=10430, simulation=False):
|
||||
api = automation_api()
|
||||
with connect(api, port) as manager:
|
||||
return manager.get_devices(include_simulation_devices=simulation)
|
||||
|
||||
|
||||
def read_binary(directory, channels, cancel=lambda: False):
|
||||
"""Read documented Saleae digital binary v0, preserving constant tails."""
|
||||
result, bounds = [], None
|
||||
header = struct.Struct('<8siiIddQ')
|
||||
for channel in channels:
|
||||
path = Path(directory) / ('digital_%d.bin' % channel)
|
||||
with path.open('rb') as stream:
|
||||
raw = stream.read(header.size)
|
||||
if len(raw) != header.size:
|
||||
raise ValueError('Обрезанный заголовок Saleae: ' + path.name)
|
||||
magic, version, kind, initial, start, end, count = header.unpack(raw)
|
||||
if magic != b'<SALEAE>' or version != 0 or kind != 0:
|
||||
raise ValueError('Неподдерживаемый бинарный экспорт Saleae (нужен digital v0).')
|
||||
if (initial not in (0, 1) or not math.isfinite(start) or not math.isfinite(end)
|
||||
or end < start or path.stat().st_size != header.size + count * 8):
|
||||
raise ValueError('Повреждённый бинарный экспорт Saleae.')
|
||||
if bounds is not None and bounds != (start, end):
|
||||
raise ValueError('Границы каналов Saleae не совпадают.')
|
||||
bounds = start, end
|
||||
edges = array('d')
|
||||
previous = start
|
||||
while count:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
size = min(count, 65536)
|
||||
block = array('d')
|
||||
block.frombytes(stream.read(size * 8))
|
||||
if sys.byteorder != 'little':
|
||||
block.byteswap()
|
||||
for edge in block:
|
||||
if not math.isfinite(edge) or edge < previous or edge > end:
|
||||
raise ValueError('Некорректное время фронта Saleae.')
|
||||
previous = edge
|
||||
edges.extend(block)
|
||||
count -= size
|
||||
result.append(DigitalChannel('D%d' % channel, initial, edges))
|
||||
if not result:
|
||||
raise ValueError('Нет цифровых каналов Saleae.')
|
||||
return DigitalCapture('', 'Saleae ONLINE', result, *bounds)
|
||||
|
||||
|
||||
def acquire(settings, stop, cancel, status=lambda text: None):
|
||||
"""Stop exactly once; cancel discards data, stop imports the captured window."""
|
||||
settings.validate()
|
||||
api = automation_api()
|
||||
status('Подключение к Logic 2…')
|
||||
with connect(api, settings.port) as manager:
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
device = api.LogicDeviceConfiguration(
|
||||
enabled_digital_channels=list(settings.channels),
|
||||
digital_sample_rate=settings.sample_rate,
|
||||
digital_threshold_volts=settings.threshold)
|
||||
configuration = api.CaptureConfiguration(buffer_size_megabytes=settings.buffer_mb,
|
||||
capture_mode=api.ManualCaptureMode())
|
||||
capture = manager.start_capture(device_id=settings.device_id,
|
||||
device_configuration=device,
|
||||
capture_configuration=configuration)
|
||||
stopped = False
|
||||
try:
|
||||
status('Захват Saleae… Нажмите «Стоп и показать» для завершения.')
|
||||
deadline = time.monotonic() + settings.duration if settings.duration else None
|
||||
while not stop() and not cancel():
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(.02)
|
||||
stopped = True # Never retry stop(), even if it raises.
|
||||
capture.stop()
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
status('Получение цифровых каналов…')
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-saleae-') as directory:
|
||||
capture.export_raw_data_binary(directory, digital_channels=list(settings.channels),
|
||||
analog_channels=[])
|
||||
result = read_binary(directory, settings.channels, cancel)
|
||||
if cancel():
|
||||
raise ImportCancelled()
|
||||
result.source = 'Saleae %s · %g MS/s' % (settings.device_id, settings.sample_rate / 1e6)
|
||||
result.sample_rate = settings.sample_rate
|
||||
return result
|
||||
finally:
|
||||
if not stopped:
|
||||
with suppress(Exception):
|
||||
capture.stop()
|
||||
with suppress(Exception):
|
||||
capture.close()
|
||||
17
python/pyproject.toml
Normal file
17
python/pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "setcorp-templates"
|
||||
version = "0.1.0"
|
||||
description = "Reusable SET device protocols, transports and logic analyzers"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[project.optional-dependencies]
|
||||
qt = ["PySide6>=6.7,<7; python_version >= '3.9'", "PySide2>=5.15,<6; python_version < '3.9'"]
|
||||
saleae = ["logic2-automation>=1.0.7,<2"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*"]
|
||||
1
python/set_devices/__init__.py
Normal file
1
python/set_devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
51
python/set_devices/balsam_can.py
Normal file
51
python/set_devices/balsam_can.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""GUI-neutral adapter for the shared templates Balsam 167 CAN decoder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from protocan import balsam
|
||||
|
||||
from protocan import protocan as pc
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
|
||||
def decode(raw_id: int, data: bytes, *, ide: bool = True,
|
||||
rtr: bool = False) -> pc.Decoded | None:
|
||||
if not ide or rtr or not balsam.is_balsam_id(raw_id):
|
||||
return None
|
||||
try:
|
||||
frame = balsam.decode(raw_id, data, native=get_shared_protocol())
|
||||
except ValueError as error:
|
||||
result = pc.Decoded(pc.ProtoCanId.parse(raw_id), len(data), bytes(data),
|
||||
ide=ide, rtr=rtr, protocol="BALZAM 167")
|
||||
result.summary = "BALZAM: неверный кадр"
|
||||
result.fields = [("Протокол", "BALZAM 167"), ("Ошибка", str(error))]
|
||||
result.warnings.append(str(error))
|
||||
return result
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
pid = pc.ProtoCanId(
|
||||
raw=frame.can_id, body=frame.start_address, msg_type=0,
|
||||
device=frame.device, device_type=0,
|
||||
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 регистры")
|
||||
result.summary = frame.summary
|
||||
result.fields = [
|
||||
("Протокол", "BALZAM 167 legacy eCAN"),
|
||||
("Узел", "%d — %s" % (frame.device, frame.device_name)),
|
||||
("Направление", "от устройства" if frame.from_device else "к устройству"),
|
||||
("Начальный адрес", "0x%04X" % frame.start_address),
|
||||
("Маска трёх слов", "0b%s" % format(frame.present_mask, "03b")),
|
||||
]
|
||||
result.registers = [(item.address, item.value) for item in frame.registers]
|
||||
for item in frame.registers:
|
||||
result.fields.append((
|
||||
"0x%04X %s" % (item.address, item.name or "регистр"),
|
||||
"0x%04X (%d)" % (item.value, item.signed_value),
|
||||
))
|
||||
if frame.present_mask == 0:
|
||||
result.warnings.append("В адресном слове не отмечен ни один регистр")
|
||||
return result
|
||||
79
python/set_devices/bus_demo.py
Normal file
79
python/set_devices/bus_demo.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""@file bus_demo.py
|
||||
@brief Генератор трафика полевой шины: разбор проверяется без железа.
|
||||
|
||||
Повторяет то, что мост выдаёт в RS485: готовые транспортные кадры со
|
||||
всеми типами сообщений ProtoCAN. Модуль не импортирует Qt — планировщик
|
||||
живёт во вкладке, а сам генератор проверяется host-тестами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
|
||||
#: Типы сообщений, которые генератор выдаёт по кругу случайно.
|
||||
KINDS = ("pulse", "analog", "modbus", "gas", "status", "error", "discrete")
|
||||
|
||||
|
||||
class BusDemoSource:
|
||||
"""@brief Источник демонстрационных кадров шины.
|
||||
|
||||
@param rng Источник случайности; передаётся в тестах для повторяемости.
|
||||
"""
|
||||
|
||||
def __init__(self, rng: random.Random | None = None) -> None:
|
||||
self._rng = rng or random.Random()
|
||||
self._seq = 0
|
||||
|
||||
def next_frame(self) -> bytes:
|
||||
"""@brief Собирает очередной кадр так же, как его отдал бы мост.
|
||||
|
||||
@return Готовые байты транспортного кадра, включая SOF и CRC.
|
||||
"""
|
||||
self._seq = (self._seq + 1) & 0xFF
|
||||
kind = self._rng.choice(KINDS)
|
||||
dev_type, dev_id = 1, 2
|
||||
if kind == "pulse":
|
||||
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.PULSE, 0)
|
||||
data = bytes([self._seq])
|
||||
elif kind == "analog":
|
||||
sid = self._rng.randint(1, 40)
|
||||
atype = self._rng.choice([pc.AnalogType.U, pc.AnalogType.I, pc.AnalogType.T])
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.ANALOG,
|
||||
pc.merge_analog(atype, sid))
|
||||
tag = {pc.AnalogType.U: b"US", pc.AnalogType.I: b"IS",
|
||||
pc.AnalogType.T: b"TS"}[atype]
|
||||
data = tag + ("%04d" % sid).encode()
|
||||
elif kind == "modbus":
|
||||
adr, cnt = self._rng.randint(0, 0x200), self._rng.randint(1, 4)
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.MODBUS_HOLDING,
|
||||
pc.merge_modbus(adr, cnt))
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(cnt))
|
||||
elif kind == "gas":
|
||||
adr = self._rng.randint(0, 0x400)
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id,
|
||||
pc.MsgType.GENERAL_ADDRESS_SPACE, adr)
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(self._rng.randint(1, 4)))
|
||||
elif kind == "status":
|
||||
now = datetime.now()
|
||||
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.BROADCAST,
|
||||
pc.merge_broadcast(pc.BroadcastType.STATUS, 0))
|
||||
data = bytes([now.hour, now.minute, now.second, now.year % 100,
|
||||
now.month, now.day, now.isoweekday() % 7])
|
||||
elif kind == "error":
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.ERROR,
|
||||
pc.merge_error(self._rng.randint(0, 5), 0xFF))
|
||||
data = b""
|
||||
else:
|
||||
dtype = self._rng.choice(list(pc.DiscreteType)[:7])
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.DISCRETE,
|
||||
pc.merge_discrete(dtype, self._rng.randint(0, 20)))
|
||||
data = bytes(self._rng.randint(0, 255)
|
||||
for _ in range(self._rng.randint(1, 8)))
|
||||
return tr.Frame(seq=self._seq, flags=tr.FLAG_IDE, can_id=cid,
|
||||
data=data).encode()
|
||||
623
python/set_devices/can485_board.py
Normal file
623
python/set_devices/can485_board.py
Normal file
@@ -0,0 +1,623 @@
|
||||
"""@file can485_board.py
|
||||
@brief Плата WeAct CAN485 DevBoard V1 (ESP32): её текстовый вывод и пакеты RS485.
|
||||
|
||||
Прошивка ``CAN_Parser`` печатает в USB-UART разобранные кадры CAN и RS485
|
||||
строками, а CAN-кадры также выкладывает в RS485 пакетами по 17 байт. Здесь
|
||||
разбор обоих форматов, сборка команд управления платой и генератор демо-трафика.
|
||||
|
||||
Перенесено из отдельного tkinter-приложения ``Tools/CanMonitorGUI``
|
||||
репозитория платы. Модуль не импортирует Qt: разбор переносим и проверяется
|
||||
host-тестами.
|
||||
|
||||
Строка кадра::
|
||||
|
||||
[ 12.345] EXT 0x18FEE000 DLC=8 DATA=01 02 03 04 05 06 07 08 |........|
|
||||
|
||||
Пакет RS485::
|
||||
|
||||
AA 55 | FLAGS | ID[4] big-endian | DLC | DATA[8] | CRC8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import chain
|
||||
from typing import Iterable
|
||||
|
||||
#: Распространённые скорости UART для подключения платы и RS485-приёмника.
|
||||
UART_BAUDS = (
|
||||
"1200", "2400", "4800", "9600", "14400", "19200", "38400", "57600",
|
||||
"115200", "128000", "230400", "256000", "460800", "500000", "512000",
|
||||
"576000", "921600", "1000000", "1152000", "1500000", "2000000",
|
||||
"3000000", "4000000",
|
||||
)
|
||||
#: Скорости USB-UART платы и скорость по умолчанию для ESP32.
|
||||
USB_BAUDS = UART_BAUDS[8:]
|
||||
DEFAULT_USB_BAUD = "921600"
|
||||
#: Скорости шины CAN, кбит/с. Список закрытый: прошивка проверяет значение.
|
||||
CAN_BITRATES = ["25", "50", "100", "125", "250", "500", "800", "1000"]
|
||||
#: Режимы контроллера: имя и код команды ``M``.
|
||||
MODES = (("normal", 0), ("listen-only", 1), ("no-ack", 2))
|
||||
#: Маршруты прошивки 1.6: бит 0 — CAN→RS485, бит 1 — RS485→CAN.
|
||||
ROUTES = (("Выкл", 0), ("CAN→RS485", 1), ("RS485→CAN", 2), ("Оба", 3))
|
||||
#: Скорости встроенного UART RS485 платы и значение по умолчанию.
|
||||
RS485_BAUDS = UART_BAUDS
|
||||
DEFAULT_RS485_BAUD = "512000"
|
||||
|
||||
#: Синхробайты и длина пакета RS485.
|
||||
RS485_SYNC = b"\xAA\x55"
|
||||
RS485_LEN = 17
|
||||
|
||||
#: Строка кадра: время, тип идентификатора, сам идентификатор и хвост.
|
||||
FRAME_RE = re.compile(
|
||||
r"^\[\s*(\d+)\.(\d{3})\]\s+(?:(RS485)\s+)?"
|
||||
r"(EXT|STD)\s+0x([0-9A-Fa-f]+)\s+(.*)$"
|
||||
)
|
||||
RTR_RE = re.compile(r"^RTR\s+DLC=(\d)")
|
||||
DATA_RE = re.compile(r"^DLC=(\d)\s*(?:DATA=([0-9A-Fa-f ]*))?")
|
||||
HEX_BYTE_RE = re.compile(r"(?i)(?<![0-9a-f])[0-9a-f]{2}(?![0-9a-f])")
|
||||
|
||||
|
||||
def crc8(data: bytes) -> int:
|
||||
"""@brief CRC-8, полином 0x07, начальное значение 0 — как в прошивке."""
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
|
||||
return crc
|
||||
|
||||
|
||||
def to_ascii(data: bytes) -> str:
|
||||
"""Печатные байты как есть, остальные точкой."""
|
||||
return "".join(chr(byte) if 0x20 <= byte < 0x7F else "." for byte in data)
|
||||
|
||||
|
||||
def j1939(can_id: int) -> str:
|
||||
"""@brief Короткий разбор 29-битного идентификатора по J1939.
|
||||
|
||||
@param can_id Идентификатор кадра.
|
||||
@return Приоритет, PGN и адрес источника одной строкой.
|
||||
"""
|
||||
priority = (can_id >> 26) & 0x07
|
||||
pdu_format = (can_id >> 16) & 0xFF
|
||||
source = can_id & 0xFF
|
||||
pgn = (can_id >> 8) & 0x3FFFF
|
||||
if pdu_format < 240:
|
||||
# PDU1: младший байт PGN — адрес получателя, а не часть номера группы.
|
||||
pgn &= 0x3FF00
|
||||
return "P%d PGN %05X SA %02X" % (priority, pgn, source)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardFrame:
|
||||
"""@brief Один кадр шины, пришедший от платы."""
|
||||
|
||||
timestamp: float
|
||||
extended: bool
|
||||
identifier: int
|
||||
rtr: bool
|
||||
dlc: int
|
||||
data: bytes
|
||||
source: str = "CAN"
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[bool, int]:
|
||||
"""Ключ агрегирования в таблице «по ID»."""
|
||||
return (self.extended, self.identifier)
|
||||
|
||||
def id_str(self) -> str:
|
||||
return ("0x%08X" if self.extended else "0x%03X") % self.identifier
|
||||
|
||||
def data_str(self) -> str:
|
||||
return "RTR" if self.rtr else " ".join("%02X" % byte for byte in self.data)
|
||||
|
||||
def ascii_str(self) -> str:
|
||||
return "" if self.rtr else to_ascii(self.data)
|
||||
|
||||
def rs485_packet(self) -> bytes:
|
||||
"""@brief Пакет RS485, каким его выложила бы плата.
|
||||
|
||||
Нужен и для показа сырых байт, и для демо-потока: генератор отдаёт
|
||||
те же байты, что придут с железа, поэтому проверяется весь разбор.
|
||||
"""
|
||||
flags = (1 if self.extended else 0) | (2 if self.rtr else 0)
|
||||
body = (bytes([flags])
|
||||
+ (self.identifier & 0x1FFFFFFF).to_bytes(4, "big")
|
||||
+ bytes([self.dlc])
|
||||
+ self.data.ljust(8, b"\x00")[:8])
|
||||
return RS485_SYNC + body + bytes([crc8(body)])
|
||||
|
||||
|
||||
def parse_frame_line(line: str, timestamp: float) -> BoardFrame | None:
|
||||
"""@brief Разбирает строку кадра из USB-вывода прошивки.
|
||||
|
||||
@param line Строка без завершающего перевода строки.
|
||||
@param timestamp Время приёма на стороне ПК.
|
||||
@return Кадр либо ``None``, если строка не про кадр.
|
||||
"""
|
||||
match = FRAME_RE.match(line)
|
||||
if not match:
|
||||
return None
|
||||
source = "RS485" if match.group(3) else "CAN"
|
||||
extended = match.group(4) == "EXT"
|
||||
identifier = int(match.group(5), 16)
|
||||
rest = match.group(6)
|
||||
|
||||
remote = RTR_RE.match(rest)
|
||||
if remote:
|
||||
return BoardFrame(timestamp, extended, identifier, True,
|
||||
int(remote.group(1)), b"", source=source)
|
||||
|
||||
payload = DATA_RE.match(rest)
|
||||
if not payload:
|
||||
return None
|
||||
dlc = int(payload.group(1))
|
||||
# Хвост строки может нести ASCII-колонку в вертикальных чертах.
|
||||
hex_part = (payload.group(2) or "").split("|")[0]
|
||||
try:
|
||||
data = bytes(int(item, 16) for item in hex_part.split())
|
||||
except ValueError:
|
||||
return None
|
||||
return BoardFrame(timestamp, extended, identifier, False, dlc, data[:8],
|
||||
source=source)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdLogImport:
|
||||
"""Результат безопасного импорта журнала, снятого с TF/SD-карты."""
|
||||
|
||||
frames: list[BoardFrame]
|
||||
rows_seen: int
|
||||
skipped_rows: int
|
||||
truncated: bool
|
||||
format_name: str
|
||||
|
||||
|
||||
def _header_key(value: str) -> str:
|
||||
"""Нормализует русские и английские заголовки CSV для сопоставления."""
|
||||
return re.sub(r"[^a-zа-я0-9]+", "", value.strip().lower().lstrip("\ufeff"))
|
||||
|
||||
|
||||
def _column_index(header: list[str], *aliases: str) -> int | None:
|
||||
keys = [_header_key(value) for value in header]
|
||||
wanted = {_header_key(alias) for alias in aliases}
|
||||
return next((index for index, key in enumerate(keys) if key in wanted), None)
|
||||
|
||||
|
||||
def _parse_log_timestamp(value: str, reference: float, milliseconds: bool) -> float:
|
||||
"""Читает epoch, millis() либо часы из CSV без требования даты в файле."""
|
||||
text = value.strip()
|
||||
clock = re.fullmatch(r"(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d{1,6}))?", text)
|
||||
if clock:
|
||||
fraction = float("0." + (clock.group(4) or "0"))
|
||||
local = time.localtime(reference)
|
||||
midnight = time.mktime((local.tm_year, local.tm_mon, local.tm_mday,
|
||||
0, 0, 0, local.tm_wday, local.tm_yday,
|
||||
local.tm_isdst))
|
||||
return midnight + int(clock.group(1)) * 3600 + int(clock.group(2)) * 60 \
|
||||
+ int(clock.group(3)) + fraction
|
||||
numeric = float(text.replace(",", "."))
|
||||
if numeric >= 100_000_000_000:
|
||||
return numeric / 1000.0
|
||||
if numeric >= 1_000_000_000:
|
||||
return numeric
|
||||
return numeric / 1000.0 if milliseconds else numeric
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "ext", "e", "29"}
|
||||
|
||||
|
||||
def _parse_csv_frame(
|
||||
row: list[str], header: list[str] | None, reference: float,
|
||||
) -> tuple[BoardFrame, bool]:
|
||||
"""Разбирает одну строку SD CSV; второй результат отмечает относительное время."""
|
||||
if header is None:
|
||||
if len(row) < 5:
|
||||
raise ValueError("слишком мало колонок")
|
||||
time_index, type_index, id_index, dlc_index, data_index = 0, 1, 2, 3, 4
|
||||
rtr_index = None
|
||||
time_key = "ms"
|
||||
else:
|
||||
time_index = _column_index(header, "Время", "time", "timestamp", "ms",
|
||||
"millis", "time_ms", "timestamp_ms",
|
||||
"uptime_ms", "elapsed_ms", "t_ms")
|
||||
type_index = _column_index(header, "Тип", "type", "format", "fmt", "ide", "ext")
|
||||
id_index = _column_index(header, "ID", "identifier", "can_id", "canid",
|
||||
"ID (29 бит)")
|
||||
dlc_index = _column_index(header, "DLC", "len", "length")
|
||||
data_index = _column_index(header, "Данные (hex)", "data", "payload",
|
||||
"data_hex", "bytes", "DATA (8 байт)")
|
||||
rtr_index = _column_index(header, "RTR", "remote")
|
||||
if None in (time_index, id_index, dlc_index, data_index):
|
||||
raise ValueError("нет обязательных колонок")
|
||||
time_key = _header_key(header[time_index])
|
||||
|
||||
required = [time_index, id_index, dlc_index, data_index]
|
||||
if any(index is None or index >= len(row) for index in required):
|
||||
raise ValueError("короткая строка")
|
||||
type_text = row[type_index].strip() if type_index is not None and type_index < len(row) else ""
|
||||
id_text = row[id_index].strip().lower().replace("0x", "")
|
||||
identifier = int(id_text, 16)
|
||||
type_upper = type_text.upper()
|
||||
if type_upper in {"EXT", "E", "29", "TRUE", "YES", "1"}:
|
||||
extended = True
|
||||
elif type_upper in {"STD", "S", "11", "FALSE", "NO", "0"}:
|
||||
extended = False
|
||||
else:
|
||||
extended = identifier > 0x7FF
|
||||
if not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF):
|
||||
raise ValueError("ID вне диапазона")
|
||||
|
||||
dlc_text = row[dlc_index].strip()
|
||||
dlc = int(dlc_text, 16 if dlc_text.lower().startswith("0x") else 10)
|
||||
if not 0 <= dlc <= 8:
|
||||
raise ValueError("DLC вне диапазона")
|
||||
rtr = (type_text.upper() == "RTR" or row[data_index].strip().upper() == "RTR"
|
||||
or (rtr_index is not None and rtr_index < len(row) and _parse_bool(row[rtr_index])))
|
||||
|
||||
data = b""
|
||||
if not rtr:
|
||||
# У SD-прошивки байты могут идти одной колонкой или отдельными полями.
|
||||
joined = (row[data_index] if header is not None
|
||||
else " ".join(row[data_index:]))
|
||||
compact = re.sub(r"(?i)0x|[^0-9a-f]", "", joined)
|
||||
if compact and len(compact) % 2 == 0:
|
||||
data = bytes.fromhex(compact)
|
||||
else:
|
||||
data = bytes(int(value, 16) for value in HEX_BYTE_RE.findall(joined))
|
||||
if len(data) < dlc:
|
||||
raise ValueError("данных меньше DLC")
|
||||
data = data[:dlc]
|
||||
|
||||
milliseconds = time_key in {
|
||||
"ms", "millis", "timems", "timestampms", "uptimems", "elapsedms",
|
||||
"tms",
|
||||
}
|
||||
stamp = _parse_log_timestamp(row[time_index], reference, milliseconds)
|
||||
relative = stamp < 1_000_000_000 and not ":" in row[time_index]
|
||||
return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source="SD"), relative
|
||||
|
||||
|
||||
def parse_sd_log(
|
||||
lines: Iterable[str], reference_timestamp: float | None = None,
|
||||
limit: int = 5000,
|
||||
) -> SdLogImport:
|
||||
"""Разбирает SD-журнал CAN485 без загрузки неограниченного файла в память.
|
||||
|
||||
Поддерживаются текстовые строки ``CAN_Parser``, его компактный CSV
|
||||
``ms,type,id,dlc,data`` и CSV, сохранённый самим SETGUI. В память попадают
|
||||
только последние ``limit`` корректных кадров.
|
||||
"""
|
||||
if limit <= 0:
|
||||
raise ValueError("limit должен быть положительным")
|
||||
reference = time.time() if reference_timestamp is None else reference_timestamp
|
||||
source_lines = iter(lines)
|
||||
first = ""
|
||||
found_data = False
|
||||
for candidate in source_lines:
|
||||
first = candidate.strip("\r\n\ufeff")
|
||||
if first.strip() and not first.lstrip().startswith("#"):
|
||||
found_data = True
|
||||
break
|
||||
if not found_data:
|
||||
return SdLogImport([], 0, 0, False, "empty")
|
||||
|
||||
rows_seen = 0
|
||||
skipped = 0
|
||||
total_frames = 0
|
||||
relative_flags: deque[bool] = deque(maxlen=limit)
|
||||
frames: deque[BoardFrame] = deque(maxlen=limit)
|
||||
|
||||
def add(frame: BoardFrame | None, relative: bool = False) -> None:
|
||||
nonlocal skipped, total_frames
|
||||
if frame is None:
|
||||
skipped += 1
|
||||
return
|
||||
frames.append(frame)
|
||||
relative_flags.append(relative)
|
||||
total_frames += 1
|
||||
|
||||
if first.lstrip().startswith("["):
|
||||
format_name = "CAN_Parser text"
|
||||
for line in chain((first,), source_lines):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
rows_seen += 1
|
||||
match = FRAME_RE.match(stripped)
|
||||
relative = False
|
||||
stamp = reference
|
||||
if match:
|
||||
stamp = int(match.group(1)) + int(match.group(2)) / 1000.0
|
||||
relative = True
|
||||
add(parse_frame_line(stripped, stamp), relative)
|
||||
else:
|
||||
delimiter = ";" if first.count(";") >= first.count(",") else ","
|
||||
reader = csv.reader(chain((first,), source_lines), delimiter=delimiter)
|
||||
first_row = next(reader, [])
|
||||
id_column = _column_index(first_row, "ID", "identifier", "can_id", "canid",
|
||||
"ID (29 бит)")
|
||||
header = first_row if id_column is not None else None
|
||||
format_name = "SETGUI CSV" if header and _column_index(
|
||||
header, "Данные (hex)") is not None else "CAN_Parser CSV"
|
||||
data_rows = reader if header is not None else chain((first_row,), reader)
|
||||
for row in data_rows:
|
||||
if not row or not any(value.strip() for value in row):
|
||||
continue
|
||||
rows_seen += 1
|
||||
try:
|
||||
frame, relative = _parse_csv_frame(row, header, reference)
|
||||
except (ValueError, TypeError, IndexError):
|
||||
add(None)
|
||||
else:
|
||||
add(frame, relative)
|
||||
|
||||
imported = list(frames)
|
||||
flags = list(relative_flags)
|
||||
relative_stamps = [frame.timestamp for frame, relative in zip(imported, flags) if relative]
|
||||
if relative_stamps:
|
||||
# millis()/время от старта не имеет календаря: конец журнала привязываем
|
||||
# ко времени изменения файла на карте, сохраняя интервалы между кадрами.
|
||||
shift = reference - max(relative_stamps)
|
||||
imported = [BoardFrame(frame.timestamp + shift if relative else frame.timestamp,
|
||||
frame.extended, frame.identifier, frame.rtr,
|
||||
frame.dlc, frame.data, frame.source)
|
||||
for frame, relative in zip(imported, flags)]
|
||||
return SdLogImport(imported, rows_seen, skipped, total_frames > limit, format_name)
|
||||
|
||||
|
||||
def parse_status(line: str) -> dict[str, str]:
|
||||
"""@brief Разбирает поля ``#STAT`` или ``#OK`` в словарь «ключ — значение»."""
|
||||
values: dict[str, str] = {}
|
||||
for item in line.split()[1:]:
|
||||
if "=" in item:
|
||||
key, value = item.split("=", 1)
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
class LineAssembler:
|
||||
"""@brief Собирает строки из кусков потока USB-UART.
|
||||
|
||||
Куски приходят как попало — QSerialPort отдаёт то, что успело прийти,
|
||||
поэтому остаток без перевода строки хранится до следующего вызова.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[str]:
|
||||
"""@brief Добавляет кусок и возвращает завершённые строки."""
|
||||
self._buffer += chunk
|
||||
lines: list[str] = []
|
||||
while True:
|
||||
end = self._buffer.find(b"\n")
|
||||
if end < 0:
|
||||
break
|
||||
raw = bytes(self._buffer[:end])
|
||||
del self._buffer[:end + 1]
|
||||
lines.append(raw.decode("utf-8", "replace").rstrip("\r"))
|
||||
return lines
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buffer.clear()
|
||||
|
||||
|
||||
class Rs485PacketParser:
|
||||
"""@brief Выделяет из потока RS485 пакеты по 17 байт и проверяет CRC."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
#: Счётчики: принято пакетов и отброшено по несходимости CRC.
|
||||
self.stats = {"packets": 0, "crc_errors": 0}
|
||||
|
||||
def feed(self, chunk: bytes, timestamp: float | None = None) -> list[BoardFrame]:
|
||||
"""@brief Добавляет кусок потока и возвращает разобранные кадры."""
|
||||
self._buffer += chunk
|
||||
now = time.time() if timestamp is None else timestamp
|
||||
frames: list[BoardFrame] = []
|
||||
while len(self._buffer) >= RS485_LEN:
|
||||
start = self._buffer.find(RS485_SYNC)
|
||||
if start < 0:
|
||||
# Синхробайт может быть разорван между кусками: последний байт
|
||||
# оставляем, вдруг это первая половина AA 55.
|
||||
del self._buffer[:-1]
|
||||
break
|
||||
if start:
|
||||
del self._buffer[:start]
|
||||
continue
|
||||
packet = bytes(self._buffer[:RS485_LEN])
|
||||
if crc8(packet[2:16]) != packet[16]:
|
||||
self.stats["crc_errors"] += 1
|
||||
del self._buffer[:2]
|
||||
continue
|
||||
del self._buffer[:RS485_LEN]
|
||||
self.stats["packets"] += 1
|
||||
flags = packet[2]
|
||||
identifier = int.from_bytes(packet[3:7], "big") & 0x1FFFFFFF
|
||||
dlc = min(packet[7], 8)
|
||||
frames.append(BoardFrame(now, bool(flags & 1), identifier,
|
||||
bool(flags & 2), dlc, packet[8:8 + dlc],
|
||||
source="RS485"))
|
||||
return frames
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buffer.clear()
|
||||
self.stats.update(packets=0, crc_errors=0)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Команды прошивки
|
||||
# ==========================================================================
|
||||
|
||||
def command_bitrate(kbps: str | int) -> str:
|
||||
"""Скорость шины CAN, кбит/с."""
|
||||
return "S%s" % kbps
|
||||
|
||||
|
||||
def command_mode(mode: int) -> str:
|
||||
"""Режим контроллера: 0 normal, 1 listen-only, 2 no-ack."""
|
||||
return "M%d" % mode
|
||||
|
||||
|
||||
def command_rs485(enabled: bool) -> str:
|
||||
"""Только CAN→RS485; в прошивке 1.6 обратный маршрут не меняется."""
|
||||
return "R1" if enabled else "R0"
|
||||
|
||||
|
||||
def command_route(route: int) -> str:
|
||||
"""Маршрут прошивки 1.6: 0 выкл, 1 CAN→RS485, 2 RS485→CAN, 3 оба."""
|
||||
if route not in (0, 1, 2, 3):
|
||||
raise ValueError("маршрут должен быть 0..3")
|
||||
return "Q%d" % route
|
||||
|
||||
|
||||
def command_rs485_baud(baud: str | int) -> str:
|
||||
"""Скорость физического интерфейса RS485 на плате."""
|
||||
value = int(baud)
|
||||
if not 1200 <= value <= 3_000_000:
|
||||
raise ValueError("скорость RS485 должна быть 1200..3000000 бод")
|
||||
return "B%d" % value
|
||||
|
||||
|
||||
def command_rs485_transmit(packet: bytes) -> str:
|
||||
"""Передача готового 17-байтового пакета в RS485 через USB платы."""
|
||||
if len(packet) != RS485_LEN or not packet.startswith(RS485_SYNC):
|
||||
raise ValueError("ожидается 17-байтовый пакет RS485 AA 55")
|
||||
if crc8(packet[2:16]) != packet[16]:
|
||||
raise ValueError("неверный CRC8 пакета RS485")
|
||||
return "X," + packet.hex().upper()
|
||||
|
||||
|
||||
def command_sd_logging(enabled: bool) -> str:
|
||||
"""Запуск или остановка записи CAN-кадров на TF-карту."""
|
||||
return "L1" if enabled else "L0"
|
||||
|
||||
|
||||
def command_sd_list() -> str:
|
||||
"""Запрос списка файлов TF-карты через USB-UART платы."""
|
||||
return "D"
|
||||
|
||||
|
||||
def normalize_sd_log_name(name: str) -> str:
|
||||
"""Разрешает загрузку только корневых CSV-журналов ``can_*``."""
|
||||
value = name.strip()
|
||||
if value.startswith("/"):
|
||||
value = value[1:]
|
||||
if not re.fullmatch(r"(?i)can_[a-z0-9_-]+\.csv", value):
|
||||
raise ValueError("допустим только корневой журнал can_*.csv")
|
||||
return "/" + value
|
||||
|
||||
|
||||
def command_sd_download(name: str, offset: int = 0) -> str:
|
||||
"""Запрос содержимого выбранного файла карты через текстовый протокол."""
|
||||
if not 0 <= offset <= 0xFFFFFFFF:
|
||||
raise ValueError("смещение SD-журнала вне uint32")
|
||||
command = "G," + normalize_sd_log_name(name)
|
||||
return command if offset == 0 else command + "," + str(offset)
|
||||
|
||||
|
||||
def parse_sd_file_entry(line: str) -> tuple[str, int] | None:
|
||||
"""Разбирает строку списка ``#SD file <name> <size>`` прошивки."""
|
||||
match = re.fullmatch(r"#SD file\s+(\S+)\s+(\d+)\s*", line)
|
||||
if match is None:
|
||||
return None
|
||||
return normalize_sd_log_name(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def command_filter(identifier: str = "", mask: str = "") -> str:
|
||||
"""@brief Аппаратный фильтр по идентификатору и маске (hex).
|
||||
|
||||
Без аргументов фильтр снимается: прошивка так и понимает голую ``F``.
|
||||
"""
|
||||
identifier = identifier.strip()
|
||||
mask = mask.strip()
|
||||
if not identifier and not mask:
|
||||
return "F"
|
||||
return "F%s,%s" % (identifier or "0", mask or "0")
|
||||
|
||||
|
||||
def command_transmit(extended: bool, identifier: str, dlc: int, data: str) -> str:
|
||||
"""@brief Передача кадра в шину.
|
||||
|
||||
@param extended 29-битный идентификатор вместо 11-битного.
|
||||
@param identifier Идентификатор в hex, с ``0x`` или без.
|
||||
@param dlc Число байт данных, 0..8.
|
||||
@param data Байты данных в hex; пробелы игнорируются.
|
||||
"""
|
||||
clean_id = identifier.strip().replace("0x", "").replace("0X", "") or "0"
|
||||
clean_data = "".join(data.split())
|
||||
return "T,%s,%s,%d,%s" % ("E" if extended else "S", clean_id, dlc, clean_data)
|
||||
|
||||
|
||||
#: Команда обнуления счётчиков прошивки.
|
||||
COMMAND_CLEAR_COUNTERS = "Z"
|
||||
#: Команда «представься»: версия, распиновка и текущее состояние.
|
||||
COMMAND_VERSION = "V"
|
||||
#: Запрос текущего маршрута (прошивка 1.6 и новее).
|
||||
COMMAND_ROUTE = "Q"
|
||||
#: Команда вывода списка команд.
|
||||
COMMAND_HELP = "?"
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Демонстрационный поток
|
||||
# ==========================================================================
|
||||
|
||||
@dataclass
|
||||
class DemoTraffic:
|
||||
"""Порция демо-трафика: строки USB и пакеты RS485 за один такт."""
|
||||
|
||||
lines: list[str] = field(default_factory=list)
|
||||
packets: list[bytes] = field(default_factory=list)
|
||||
|
||||
|
||||
class BoardDemoSource:
|
||||
"""@brief Генератор трафика платы: интерфейс проверяется без железа.
|
||||
|
||||
Строки собираются ровно в формате прошивки, пакеты RS485 — с настоящим
|
||||
CRC, поэтому демо-режим проходит через тот же разбор, что и железо.
|
||||
"""
|
||||
|
||||
#: Идентификаторы, по которым генератор идёт по кругу.
|
||||
IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF))
|
||||
|
||||
def __init__(self, rng: random.Random | None = None) -> None:
|
||||
self._rng = rng or random.Random()
|
||||
self._count = 0
|
||||
self._start = time.time()
|
||||
|
||||
def next_traffic(self) -> DemoTraffic:
|
||||
"""@brief Очередной такт: кадр в обоих видах, раз в 20 тактов — ``#STAT``."""
|
||||
extended, identifier = self.IDS[self._count % len(self.IDS)]
|
||||
dlc = 8 if extended else 3
|
||||
data = bytes(self._rng.randrange(256) for _ in range(dlc))
|
||||
elapsed_ms = int((time.time() - self._start) * 1000)
|
||||
id_text = ("0x%08X" if extended else "0x%03X ") % identifier
|
||||
line = "[%4d.%03d] %s %s DLC=%d DATA=%s" % (
|
||||
elapsed_ms // 1000, elapsed_ms % 1000, "EXT" if extended else "STD",
|
||||
id_text, dlc, " ".join("%02X" % byte for byte in data))
|
||||
|
||||
frame = BoardFrame(time.time(), extended, identifier, False, dlc, data,
|
||||
source="RS485")
|
||||
traffic = DemoTraffic(lines=[line], packets=[frame.rs485_packet()])
|
||||
|
||||
self._count += 1
|
||||
if self._count % 20 == 0:
|
||||
traffic.lines.append(
|
||||
"#STAT state=RUNNING bitrate=500 mode=normal rx=%d shown=%d tx=0 "
|
||||
"buserr=0 miss=0 txerr=0 rxerr=0 dropusb=0 droprs=0 rs485=on "
|
||||
"route=3 rsrx=%d rscrc=0 rstx=%d dropcan=0 "
|
||||
"baud=512000 vin=12180 up=%d sd=rec sdfile=/can_demo.csv "
|
||||
"sdkb=1 sdlines=%d dropsd=0"
|
||||
% (self._count, self._count, self._count, self._count,
|
||||
int(time.time() - self._start),
|
||||
self._count))
|
||||
return traffic
|
||||
102
python/set_devices/can_bridge.py
Normal file
102
python/set_devices/can_bridge.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Служебный уровень моста CAN <-> RS485: его кадры и его регистры.
|
||||
|
||||
Мост пересылает кадры шины прозрачно, но два вида трафика принадлежат
|
||||
ему самому: служебный кадр с ``FLAGS.ERR`` (состояние CAN и счётчики
|
||||
потерь) и блок регистров ``0xFF00`` в общем адресном пространстве.
|
||||
Разбор обоих собран здесь, чтобы вкладка шины не знала подробностей.
|
||||
|
||||
Модуль не импортирует Qt и проверяется host-тестами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
from . import balsam_can
|
||||
|
||||
#: Расшифровка поля LEC регистра CAN_ESR.
|
||||
CAN_ESR_LEC = {
|
||||
0: "нет ошибок",
|
||||
1: "stuff error",
|
||||
2: "form error",
|
||||
3: "acknowledgment error",
|
||||
4: "bit recessive error",
|
||||
5: "bit dominant error",
|
||||
6: "CRC error",
|
||||
7: "флаг сброшен ПО",
|
||||
}
|
||||
|
||||
#: Имена диагностических регистров моста, см. CAN_to_RS485/fw/Inc/bridge.h.
|
||||
GAS_NAMES = {
|
||||
0xFF00: "версия транспорта",
|
||||
0xFF01: "время работы, с",
|
||||
0xFF02: "CAN_ESR, младшие 16 бит",
|
||||
0xFF03: "CAN TEC — ошибки передачи",
|
||||
0xFF04: "CAN REC — ошибки приёма",
|
||||
0xFF05: "принято из CAN (младшие)",
|
||||
0xFF06: "принято из CAN (старшие)",
|
||||
0xFF07: "передано в CAN (младшие)",
|
||||
0xFF08: "передано в CAN (старшие)",
|
||||
0xFF09: "потеряно кадров CAN",
|
||||
0xFF0A: "кадров принято с RS485",
|
||||
0xFF0B: "ошибок CRC на RS485",
|
||||
0xFF0C: "кадров не отдано в RS485",
|
||||
0xFF0D: "кадров принято с UART GUI",
|
||||
0xFF0E: "битрейт CAN, кбит/с",
|
||||
0xFF0F: "скорость UART / 100",
|
||||
}
|
||||
|
||||
#: Сколько регистров GAS помещается в один кадр шины (8 байт данных).
|
||||
GAS_REGS_PER_FRAME = 4
|
||||
|
||||
|
||||
def decode_frame(frame: tr.Frame) -> pc.Decoded:
|
||||
"""@brief Разбирает кадр: служебные кадры моста — отдельно от ProtoCAN.
|
||||
|
||||
@param frame Кадр, снятый с полевого канала моста.
|
||||
@return Разбор прикладного уровня либо расшифровка служебного кадра.
|
||||
"""
|
||||
if not frame.is_error:
|
||||
decoded_balsam = balsam_can.decode(
|
||||
frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
if decoded_balsam is not None:
|
||||
return decoded_balsam
|
||||
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
|
||||
dec = pc.Decoded(id=pc.ProtoCanId.parse(frame.can_id), dlc=len(frame.data),
|
||||
data=frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
d = frame.data
|
||||
if len(d) >= 7:
|
||||
esr, tec, rec = d[0], d[1], d[2]
|
||||
can_ovr = d[3] | (d[4] << 8)
|
||||
rs_err = d[5] | (d[6] << 8)
|
||||
lec = (esr >> 4) & 0x7
|
||||
dec.summary = ("МОСТ: TEC=%d REC=%d, %s%s%s, потери CAN=%d, ошибки RS485=%d"
|
||||
% (tec, rec, CAN_ESR_LEC.get(lec, "?"),
|
||||
", BUS-OFF" if esr & 0x4 else "",
|
||||
", passive" if esr & 0x2 else "",
|
||||
can_ovr, rs_err))
|
||||
dec.fields = [
|
||||
("Источник", "служебный кадр моста (FLAGS.ERR)"),
|
||||
("CAN_ESR[7:0]", "0x%02X" % esr),
|
||||
(" Последняя ошибка", CAN_ESR_LEC.get(lec, "?")),
|
||||
(" EWGF / EPVF / BOFF", "%d / %d / %d" % (esr & 1, (esr >> 1) & 1,
|
||||
(esr >> 2) & 1)),
|
||||
("Счётчик ошибок передачи (TEC)", str(tec)),
|
||||
("Счётчик ошибок приёма (REC)", str(rec)),
|
||||
("Потеряно кадров CAN", str(can_ovr)),
|
||||
("Ошибок/потерь RS485", str(rs_err)),
|
||||
]
|
||||
if esr & 0x4:
|
||||
dec.warnings.append("Шина CAN в состоянии BUS-OFF")
|
||||
elif esr & 0x2:
|
||||
dec.warnings.append("Шина CAN в состоянии error passive")
|
||||
if can_ovr:
|
||||
dec.warnings.append("Мост потерял %d кадр(ов) CAN из-за переполнения" % can_ovr)
|
||||
if rs_err:
|
||||
dec.warnings.append("На стороне RS485 %d ошибок/потерянных байт" % rs_err)
|
||||
else:
|
||||
dec.summary = "МОСТ: служебный кадр, %d байт" % len(d)
|
||||
dec.fields = [("Источник", "служебный кадр моста (FLAGS.ERR)"),
|
||||
("Данные", tr.hex_str(d))]
|
||||
return dec
|
||||
61
python/set_devices/can_log_trends.py
Normal file
61
python/set_devices/can_log_trends.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Convert ESP CAN485 logs into bounded, independently timestamped curves."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from protocan.legacycan import PROJECTS, LegacyCanFormat, decode
|
||||
|
||||
from .can485_board import BoardFrame, SdLogImport, parse_sd_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanLogChannel:
|
||||
name: str
|
||||
address: int
|
||||
points: deque
|
||||
|
||||
|
||||
def _values(frame: BoardFrame):
|
||||
if frame.rtr:
|
||||
return
|
||||
prefix = f"{'EXT' if frame.extended else 'STD'} {frame.identifier:08X}"
|
||||
if frame.extended:
|
||||
for project in PROJECTS:
|
||||
if project.format is not LegacyCanFormat.ROTATING_THREE_WORDS:
|
||||
continue
|
||||
node = project.node_for(frame.identifier)
|
||||
if node is None:
|
||||
continue
|
||||
packet = decode(project.format, node, frame.identifier, frame.data)
|
||||
if packet is not None:
|
||||
for address, value in packet.present_values:
|
||||
# Keep both CAN directions and nodes in separate curves.
|
||||
yield (f"{prefix} · {node.name} · R{address:04X}", address, value)
|
||||
return
|
||||
# Unknown protocols stay explicitly raw; never guess a register layout.
|
||||
for offset in range(0, len(frame.data), 2):
|
||||
part = frame.data[offset:offset + 2]
|
||||
label = f"BE16 [{offset}:{offset + 1}]" if len(part) == 2 else f"байт [{offset}]"
|
||||
yield (f"{prefix} · {label}", offset, int.from_bytes(part, "big"))
|
||||
|
||||
|
||||
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
|
||||
max_total_points: int = 1_000_000,
|
||||
frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]:
|
||||
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
|
||||
channels = {}
|
||||
for frame in imported.frames:
|
||||
for name, address, _value in _values(frame):
|
||||
channels.setdefault(name, address)
|
||||
if len(channels) > 4096:
|
||||
raise ValueError("CAN CSV: слишком много каналов (максимум 4096)")
|
||||
if not channels:
|
||||
raise ValueError("CAN CSV: нет данных для графиков")
|
||||
capacity = max(1, min(capacity, max_total_points // len(channels)))
|
||||
result = {name: CanLogChannel(name, address, deque(maxlen=capacity))
|
||||
for name, address in channels.items()}
|
||||
for frame in imported.frames:
|
||||
for name, _address, value in _values(frame):
|
||||
result[name].points.append((frame.timestamp * 1000, value))
|
||||
return list(result.values()), imported
|
||||
79
python/set_devices/can_ping.py
Normal file
79
python/set_devices/can_ping.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Addressed SETProtocol v2 PING exchange over segmented classic CAN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
|
||||
from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment
|
||||
from setprotocol.core import (
|
||||
Frame as SetFrame,
|
||||
FrameFlag,
|
||||
MessageType,
|
||||
SetProtocolError,
|
||||
Status,
|
||||
build_frame,
|
||||
decode_datagram,
|
||||
decode_response,
|
||||
)
|
||||
|
||||
from . import can_transport as tr
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanPingReply:
|
||||
node_id: int
|
||||
uptime_ms: int
|
||||
protocol_version: int = 2
|
||||
|
||||
|
||||
class CanPingExchange:
|
||||
def __init__(self, node_id: int, sequence: int = 1, channel: int = 1) -> None:
|
||||
if not 0 <= node_id <= 0xFF:
|
||||
raise ValueError("SETP node ID вне диапазона 0..255")
|
||||
self.node_id = node_id
|
||||
self.sequence = sequence & 0xFFFF or 1
|
||||
self.channel = channel
|
||||
self._reassembler = CanReassembler()
|
||||
|
||||
def request(self) -> list[tr.Frame]:
|
||||
packet = build_frame(
|
||||
SetFrame(
|
||||
MessageType.PING,
|
||||
self.sequence,
|
||||
flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY,
|
||||
source=0,
|
||||
destination=self.node_id,
|
||||
)
|
||||
)
|
||||
return [
|
||||
tr.build_frame(item.can_id, item.data, to_can=True)
|
||||
for item in segment(packet, CanAddress(self.node_id, 0, 1, self.channel))
|
||||
]
|
||||
|
||||
def feed(self, frame: tr.Frame) -> CanPingReply | None:
|
||||
if frame.to_can or not frame.ide:
|
||||
return None
|
||||
try:
|
||||
address = CanAddress.unpack(frame.can_id)
|
||||
if address.source != self.node_id or address.destination != 0:
|
||||
return None
|
||||
packet = self._reassembler.feed(
|
||||
CanFrame(frame.can_id, frame.data), int(perf_counter() * 1000)
|
||||
)
|
||||
if packet is None:
|
||||
return None
|
||||
response = decode_datagram(packet)
|
||||
status, body = decode_response(response)
|
||||
except SetProtocolError:
|
||||
return None
|
||||
if (
|
||||
response.message_type != MessageType.PING
|
||||
or response.sequence != self.sequence
|
||||
or response.source != self.node_id
|
||||
or response.destination != 0
|
||||
or status != Status.OK
|
||||
or len(body) != 4
|
||||
):
|
||||
return None
|
||||
return CanPingReply(self.node_id, int.from_bytes(body, "little"))
|
||||
257
python/set_devices/can_transport.py
Normal file
257
python/set_devices/can_transport.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Транспортный кадр моста CAN <-> RS485.
|
||||
|
||||
Это не протокол SETGUI (``A5 5A``) из ``protocol.py``, а кадр полевого
|
||||
канала моста: им приходят кадры самой шины CAN.
|
||||
|
||||
Формат описан в CAN_to_RS485/docs/PROTOCOL.md и реализован в прошивке
|
||||
моста (`templates/c/set-protocol/src/pcan_frame.c`):
|
||||
|
||||
AA 55 | LEN | SEQ | FLAGS | ID0..ID3 | DATA[0..8] | CRC_L CRC_H
|
||||
|
||||
LEN = 6 + DLC (длина участка SEQ..DATA)
|
||||
CRC = CRC-16/CCITT-FALSE по байтам LEN..DATA включительно, little-endian
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
_SHARED_PROTOCOL = get_shared_protocol()
|
||||
|
||||
SOF0 = 0xAA
|
||||
SOF1 = 0x55
|
||||
|
||||
FLAG_IDE = 0x01
|
||||
FLAG_RTR = 0x02
|
||||
FLAG_DIR = 0x04 # 0: CAN -> RS485, 1: RS485 -> CAN
|
||||
FLAG_ERR = 0x08
|
||||
|
||||
STANDARD_ID_MAX = 0x7FF
|
||||
EXTENDED_ID_MAX = 0x1FFFFFFF
|
||||
|
||||
MIN_LEN = 6
|
||||
MAX_LEN = 14
|
||||
FRAME_OVERHEAD = 5 # SOF(2) + LEN(1) + CRC(2)
|
||||
|
||||
|
||||
def crc16_ccitt(data: bytes, crc: int = 0xFFFF) -> int:
|
||||
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, без рефлексии."""
|
||||
if _SHARED_PROTOCOL is not None and crc == 0xFFFF:
|
||||
return _SHARED_PROTOCOL.crc16(data)
|
||||
for byte in data:
|
||||
crc ^= byte << 8
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""Один транспортный кадр."""
|
||||
|
||||
seq: int
|
||||
flags: int
|
||||
can_id: int
|
||||
data: bytes
|
||||
|
||||
@property
|
||||
def ide(self) -> bool:
|
||||
return bool(self.flags & FLAG_IDE)
|
||||
|
||||
@property
|
||||
def rtr(self) -> bool:
|
||||
return bool(self.flags & FLAG_RTR)
|
||||
|
||||
@property
|
||||
def to_can(self) -> bool:
|
||||
"""True — кадр адресован в CAN (передан хостом)."""
|
||||
return bool(self.flags & FLAG_DIR)
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return bool(self.flags & FLAG_ERR)
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
if self.is_error:
|
||||
return "ERR"
|
||||
return "TX" if self.to_can else "RX"
|
||||
|
||||
def encode(self) -> bytes:
|
||||
validate_can_id(self.can_id, self.ide)
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.encode(
|
||||
self.seq & 0xFF, self.flags & 0xFF, self.can_id, self.data)
|
||||
payload = bytearray()
|
||||
payload.append(MIN_LEN + len(self.data))
|
||||
payload.append(self.seq & 0xFF)
|
||||
payload.append(self.flags & 0xFF)
|
||||
payload += self.can_id.to_bytes(4, "little")
|
||||
payload += self.data
|
||||
crc = crc16_ccitt(bytes(payload))
|
||||
return bytes([SOF0, SOF1]) + bytes(payload) + crc.to_bytes(2, "little")
|
||||
|
||||
|
||||
def build_frame(can_id: int, data: bytes, seq: int = 0, ide: bool = True,
|
||||
rtr: bool = False, to_can: bool = True) -> Frame:
|
||||
"""Собирает кадр для передачи в мост."""
|
||||
validate_can_id(can_id, ide)
|
||||
if len(data) > 8:
|
||||
raise ValueError("DLC не может превышать 8 байт")
|
||||
flags = 0
|
||||
if ide:
|
||||
flags |= FLAG_IDE
|
||||
if rtr:
|
||||
flags |= FLAG_RTR
|
||||
if to_can:
|
||||
flags |= FLAG_DIR
|
||||
return Frame(seq=seq & 0xFF, flags=flags, can_id=can_id, data=bytes(data))
|
||||
|
||||
|
||||
def validate_can_id(can_id: int, ide: bool = True) -> None:
|
||||
"""Проверяет диапазон стандартного или расширенного CAN ID.
|
||||
|
||||
ProtoCAN из SETCAN использует ``ide=True`` и полный 29-битный диапазон.
|
||||
Явная проверка не даёт ошибочному ID незаметно обрезаться при упаковке.
|
||||
"""
|
||||
if not isinstance(can_id, int):
|
||||
raise TypeError("CAN ID должен быть целым числом")
|
||||
maximum = EXTENDED_ID_MAX if ide else STANDARD_ID_MAX
|
||||
if not 0 <= can_id <= maximum:
|
||||
kind = "расширенный (29 бит)" if ide else "стандартный (11 бит)"
|
||||
raise ValueError(
|
||||
"%s CAN ID должен быть в диапазоне 0x0..0x%X" % (kind, maximum)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseError:
|
||||
"""Отброшенный участок потока."""
|
||||
|
||||
reason: str
|
||||
raw: bytes
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""Потоковый разборщик: накапливает байты и отдаёт готовые кадры.
|
||||
|
||||
Ресинхронизация — сдвигом на один байт от неудачной сигнатуры,
|
||||
поэтому мусор в линии стоит не больше одного пропущенного кадра.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
self._native = (_SHARED_PROTOCOL.parser()
|
||||
if _SHARED_PROTOCOL is not None else None)
|
||||
self.stats = {"frames": 0, "crc_errors": 0, "resync_bytes": 0, "seq_lost": 0}
|
||||
self._last_seq: Optional[int] = None
|
||||
self.errors: List[ParseError] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buf.clear()
|
||||
self._last_seq = None
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
self._native = _SHARED_PROTOCOL.parser()
|
||||
self.stats = {"frames": 0, "crc_errors": 0, "resync_bytes": 0, "seq_lost": 0}
|
||||
|
||||
def feed(self, chunk: bytes) -> List[Frame]:
|
||||
"""Добавляет байты в буфер и возвращает все разобранные кадры."""
|
||||
if self._native is not None:
|
||||
frames = [Frame(item.sequence, item.flags, item.can_id, item.data)
|
||||
for item in self._native.feed(chunk)]
|
||||
for frame in frames:
|
||||
if self._last_seq is not None:
|
||||
self.stats["seq_lost"] += (frame.seq - self._last_seq - 1) & 0xFF
|
||||
self._last_seq = frame.seq
|
||||
native_stats = self._native.stats()
|
||||
self.stats["frames"] = native_stats["frames"]
|
||||
self.stats["crc_errors"] = native_stats["crc_errors"]
|
||||
self.stats["resync_bytes"] = (
|
||||
native_stats["stray_bytes"] + native_stats["bad_len"])
|
||||
return frames
|
||||
self._buf += chunk
|
||||
return list(self._drain())
|
||||
|
||||
def _drain(self) -> Iterator[Frame]:
|
||||
buf = self._buf
|
||||
while True:
|
||||
# 1. Ищем сигнатуру
|
||||
start = -1
|
||||
for i in range(len(buf) - 1):
|
||||
if buf[i] == SOF0 and buf[i + 1] == SOF1:
|
||||
start = i
|
||||
break
|
||||
if start < 0:
|
||||
# Сигнатуры нет: оставляем последний байт (вдруг это 0xAA)
|
||||
drop = max(0, len(buf) - 1)
|
||||
if drop:
|
||||
self.stats["resync_bytes"] += drop
|
||||
del buf[:drop]
|
||||
return
|
||||
if start:
|
||||
self.stats["resync_bytes"] += start
|
||||
del buf[:start]
|
||||
|
||||
# 2. Ждём LEN
|
||||
if len(buf) < 3:
|
||||
return
|
||||
length = buf[2]
|
||||
if not (MIN_LEN <= length <= MAX_LEN):
|
||||
self.errors.append(ParseError("LEN=%d вне 6..14" % length, bytes(buf[:3])))
|
||||
self.stats["resync_bytes"] += 1
|
||||
del buf[:1]
|
||||
continue
|
||||
|
||||
total = 2 + 1 + length + 2
|
||||
if len(buf) < total:
|
||||
return
|
||||
|
||||
payload = bytes(buf[2:2 + 1 + length])
|
||||
got = buf[2 + 1 + length] | (buf[3 + 1 + length] << 8)
|
||||
want = crc16_ccitt(payload)
|
||||
if got != want:
|
||||
self.stats["crc_errors"] += 1
|
||||
self.errors.append(ParseError(
|
||||
"CRC 0x%04X, ожидалось 0x%04X" % (got, want), bytes(buf[:total])))
|
||||
self.stats["resync_bytes"] += 1
|
||||
del buf[:1]
|
||||
continue
|
||||
|
||||
frame = Frame(
|
||||
seq=payload[1],
|
||||
flags=payload[2],
|
||||
can_id=int.from_bytes(payload[3:7], "little") & 0x1FFFFFFF,
|
||||
data=payload[7:],
|
||||
)
|
||||
del buf[:total]
|
||||
self.stats["frames"] += 1
|
||||
if self._last_seq is not None:
|
||||
gap = (frame.seq - self._last_seq - 1) & 0xFF
|
||||
if gap:
|
||||
self.stats["seq_lost"] += gap
|
||||
self._last_seq = frame.seq
|
||||
yield frame
|
||||
|
||||
|
||||
def parse_hex(text: str) -> bytes:
|
||||
"""Разбирает строку вида 'AA 55 08' / 'aa5508' / '0xAA,0x55' в байты."""
|
||||
cleaned = (text.replace("0x", " ").replace("0X", " ")
|
||||
.replace(",", " ").replace(";", " ")
|
||||
.replace("\r", " ").replace("\n", " ").replace("\t", " "))
|
||||
tokens = cleaned.split()
|
||||
if not tokens:
|
||||
return b""
|
||||
if all(len(t) <= 2 for t in tokens):
|
||||
return bytes(int(t, 16) for t in tokens)
|
||||
joined = "".join(tokens)
|
||||
if len(joined) % 2:
|
||||
raise ValueError("нечётное число hex-символов")
|
||||
return bytes.fromhex(joined)
|
||||
|
||||
|
||||
def hex_str(data: bytes) -> str:
|
||||
return " ".join("%02X" % b for b in data)
|
||||
448
python/set_devices/ds18b20.py
Normal file
448
python/set_devices/ds18b20.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""@file ds18b20.py
|
||||
@brief Модель и кодеки датчиков DS18B20 на шине 1-Wire STM32F103C8T6.
|
||||
|
||||
Содержит определения структур данных для работы с температурными датчиками,
|
||||
кодеки для кодирования/декодирования информации о датчиках, и вспомогательные
|
||||
функции для проверки целостности и форматирования данных.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import IntFlag
|
||||
|
||||
from set_devices.protocol import ProtocolError, crc8_maxim
|
||||
|
||||
ROM_SIZE = 8
|
||||
SENSOR_RECORD_SIZE = 14
|
||||
FAMILY_DS18B20 = 0x28
|
||||
MAX_SENSORS = 8
|
||||
MIN_RESOLUTION_BITS = 9
|
||||
MAX_RESOLUTION_BITS = 12
|
||||
RAW_STEP_C = 1.0 / 16.0
|
||||
|
||||
#: Время преобразования в миллисекундах для 9..12 бит.
|
||||
CONVERSION_TIME_MS = {9: 94, 10: 188, 11: 375, 12: 750}
|
||||
|
||||
|
||||
class SensorStatus(IntFlag):
|
||||
"""@brief Биты байта статуса записи ``SENSOR_DATA``.
|
||||
|
||||
Флаги независимы: датчик может ответить presence-импульсом и всё равно
|
||||
вернуть scratchpad с неверным CRC8.
|
||||
"""
|
||||
|
||||
VALID = 0x01
|
||||
CRC_ERROR = 0x02
|
||||
NO_PRESENCE = 0x04
|
||||
PARASITE_POWER = 0x08
|
||||
|
||||
|
||||
def format_rom(rom: bytes) -> str:
|
||||
"""@brief Приводит 64-битный ROM к виду ``28-FF-64-1E-0C-3D-2A-91``.
|
||||
|
||||
@param rom Восемь байт в порядке передачи по шине.
|
||||
@return Строка верхнего регистра для журнала и таблицы.
|
||||
"""
|
||||
return "-".join(f"{byte:02X}" for byte in rom)
|
||||
|
||||
|
||||
def parse_rom(text: str) -> bytes:
|
||||
"""@brief Разбирает ROM из строки с любыми разделителями.
|
||||
|
||||
@param text Текст вида ``28-FF-64-...`` либо непрерывный HEX.
|
||||
@return Ровно восемь байт ROM.
|
||||
@raises ProtocolError При недопустимых символах или длине.
|
||||
"""
|
||||
cleaned = "".join(char for char in text if char.isalnum())
|
||||
try:
|
||||
rom = bytes.fromhex(cleaned)
|
||||
except ValueError as error:
|
||||
raise ProtocolError(f"ROM содержит не HEX-символы: {text}") from error
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
return rom
|
||||
|
||||
|
||||
def resolution_from_config(config: int) -> int:
|
||||
"""@brief Извлекает разрешение из байта конфигурации scratchpad.
|
||||
|
||||
@param config Байт 4 scratchpad, значащие биты 6:5.
|
||||
@return Разрешение в битах от 9 до 12.
|
||||
"""
|
||||
return MIN_RESOLUTION_BITS + ((config >> 5) & 0x03)
|
||||
|
||||
|
||||
def config_from_resolution(bits: int) -> int:
|
||||
"""@brief Собирает байт конфигурации из требуемого разрешения.
|
||||
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@return Байт конфигурации с обязательными единицами резервных бит.
|
||||
@raises ProtocolError При разрешении вне диапазона датчика.
|
||||
"""
|
||||
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
|
||||
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
|
||||
return 0x1F | ((bits - MIN_RESOLUTION_BITS) << 5)
|
||||
|
||||
|
||||
def raw_to_celsius(raw: int, bits: int) -> float:
|
||||
"""@brief Переводит код scratchpad в °C с учётом неопределённых бит.
|
||||
|
||||
При разрешении ниже 12 бит младшие биты кода не определены датчиком и
|
||||
маскируются до перевода в градусы.
|
||||
|
||||
@param raw Знаковый 16-битный код из байтов 0 и 1 scratchpad.
|
||||
@param bits Текущее разрешение датчика 9..12 бит.
|
||||
@return Температура в градусах Цельсия с шагом 1/16 °C.
|
||||
"""
|
||||
limited = max(MIN_RESOLUTION_BITS, min(bits, MAX_RESOLUTION_BITS))
|
||||
undefined = MAX_RESOLUTION_BITS - limited
|
||||
mask = -1 << undefined
|
||||
return (raw & mask) * RAW_STEP_C
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensorReading:
|
||||
"""@brief Одна запись ``SENSOR_DATA`` без привязки к Qt.
|
||||
|
||||
@param rom 64-битный уникальный идентификатор датчика.
|
||||
@param raw Знаковый код температуры из scratchpad.
|
||||
@param user_byte1 Пользовательский байт 1 (регистр TH).
|
||||
@param user_byte2 Пользовательский байт 2 (регистр TL).
|
||||
@param config Байт конфигурации с разрешением преобразования.
|
||||
@param status Битовая маска ``SensorStatus``.
|
||||
"""
|
||||
|
||||
rom: bytes
|
||||
raw: int
|
||||
user_byte1: int
|
||||
user_byte2: int
|
||||
config: int
|
||||
status: int
|
||||
|
||||
@property
|
||||
def rom_hex(self) -> str:
|
||||
"""Идентификатор датчика в виде разделённого HEX."""
|
||||
return format_rom(self.rom)
|
||||
|
||||
@property
|
||||
def family(self) -> int:
|
||||
"""Код семейства 1-Wire; у DS18B20 это ``0x28``."""
|
||||
return self.rom[0]
|
||||
|
||||
@property
|
||||
def serial(self) -> str:
|
||||
"""48-битный серийный номер без кода семейства и CRC8."""
|
||||
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
|
||||
|
||||
@property
|
||||
def rom_crc_valid(self) -> bool:
|
||||
"""Проверяет CRC8 самого ROM, а не принятых данных."""
|
||||
return crc8_maxim(self.rom[:7]) == self.rom[7]
|
||||
|
||||
@property
|
||||
def resolution(self) -> int:
|
||||
"""Текущее разрешение датчика в битах."""
|
||||
return resolution_from_config(self.config)
|
||||
|
||||
@property
|
||||
def conversion_time_ms(self) -> int:
|
||||
"""Время преобразования, соответствующее разрешению."""
|
||||
return CONVERSION_TIME_MS[self.resolution]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""Признак того, что температуру можно показывать оператору."""
|
||||
return bool(self.status & SensorStatus.VALID)
|
||||
|
||||
@property
|
||||
def temperature(self) -> float | None:
|
||||
"""Температура в °C либо ``None`` для недостоверного измерения."""
|
||||
if not self.is_valid:
|
||||
return None
|
||||
return raw_to_celsius(self.raw, self.resolution)
|
||||
|
||||
@property
|
||||
def user_byte1_signed(self) -> int:
|
||||
"""Пользовательский байт 1 как знаковое значение TH в °C."""
|
||||
return self.user_byte1 - 256 if self.user_byte1 > 127 else self.user_byte1
|
||||
|
||||
@property
|
||||
def user_byte2_signed(self) -> int:
|
||||
"""Пользовательский байт 2 как знаковое значение TL в °C."""
|
||||
return self.user_byte2 - 256 if self.user_byte2 > 127 else self.user_byte2
|
||||
|
||||
def status_text(self) -> str:
|
||||
"""@brief Собирает читаемое описание битов статуса.
|
||||
|
||||
@return Перечисление активных признаков через запятую.
|
||||
"""
|
||||
if self.status & SensorStatus.NO_PRESENCE:
|
||||
parts = ["нет ответа"]
|
||||
elif self.status & SensorStatus.CRC_ERROR:
|
||||
parts = ["ошибка CRC8"]
|
||||
elif self.is_valid:
|
||||
parts = ["норма"]
|
||||
else:
|
||||
parts = ["нет данных"]
|
||||
if self.status & SensorStatus.PARASITE_POWER:
|
||||
parts.append("паразитное питание")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def decode_sensor_list(payload: bytes) -> list[bytes]:
|
||||
"""@brief Разбирает ответ ``SENSOR_LIST`` в список ROM.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Список 8-байтовых идентификаторов в порядке обхода шины.
|
||||
@raises ProtocolError При неверной длине или несогласованном счётчике.
|
||||
"""
|
||||
if not payload:
|
||||
raise ProtocolError("SENSOR_LIST не содержит счётчик датчиков")
|
||||
count = payload[0]
|
||||
if len(payload) != 1 + count * ROM_SIZE:
|
||||
raise ProtocolError("SENSOR_LIST содержит неверное число ROM")
|
||||
return [
|
||||
payload[1 + index * ROM_SIZE : 1 + (index + 1) * ROM_SIZE]
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def decode_sensor_data(payload: bytes) -> list[SensorReading]:
|
||||
"""@brief Разбирает ответ или push-кадр ``SENSOR_DATA``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Список измерений в том же порядке, что и ``SENSOR_LIST``.
|
||||
@raises ProtocolError При неверной длине записи или счётчике.
|
||||
"""
|
||||
if not payload:
|
||||
raise ProtocolError("SENSOR_DATA не содержит счётчик датчиков")
|
||||
count = payload[0]
|
||||
if len(payload) != 1 + count * SENSOR_RECORD_SIZE:
|
||||
raise ProtocolError("SENSOR_DATA содержит повреждённые записи")
|
||||
readings: list[SensorReading] = []
|
||||
for index in range(count):
|
||||
base = 1 + index * SENSOR_RECORD_SIZE
|
||||
record = payload[base : base + SENSOR_RECORD_SIZE]
|
||||
readings.append(
|
||||
SensorReading(
|
||||
rom=record[0:ROM_SIZE],
|
||||
raw=int.from_bytes(record[8:10], "little", signed=True),
|
||||
user_byte1=record[10],
|
||||
user_byte2=record[11],
|
||||
config=record[12],
|
||||
status=record[13],
|
||||
)
|
||||
)
|
||||
return readings
|
||||
|
||||
|
||||
def encode_sensor_reading(reading: SensorReading) -> bytes:
|
||||
"""@brief Кодирует запись обратно в wire-формат.
|
||||
|
||||
Общая реализация формата записи для тестов и mock-источника исключает
|
||||
расхождение кодера и декодера.
|
||||
|
||||
@param reading Проверенное измерение.
|
||||
@return Ровно ``SENSOR_RECORD_SIZE`` байт.
|
||||
"""
|
||||
return (
|
||||
reading.rom
|
||||
+ int(reading.raw).to_bytes(2, "little", signed=True)
|
||||
+ bytes(
|
||||
(
|
||||
reading.user_byte1 & 0xFF,
|
||||
reading.user_byte2 & 0xFF,
|
||||
reading.config & 0xFF,
|
||||
reading.status & 0xFF,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def encode_sensor_data(readings: list[SensorReading]) -> bytes:
|
||||
"""@brief Собирает payload ``SENSOR_DATA`` из списка измерений.
|
||||
|
||||
@param readings Не более ``MAX_SENSORS`` записей.
|
||||
@return Счётчик и последовательность записей.
|
||||
@raises ProtocolError Если записей больше, чем помещается в кадр.
|
||||
"""
|
||||
if len(readings) > MAX_SENSORS:
|
||||
raise ProtocolError(f"На шину рассчитано не более {MAX_SENSORS} датчиков")
|
||||
return bytes((len(readings),)) + b"".join(
|
||||
encode_sensor_reading(reading) for reading in readings
|
||||
)
|
||||
|
||||
|
||||
def encode_user_bytes(
|
||||
rom: bytes, user_byte1: int, user_byte2: int, save_to_eeprom: bool = False
|
||||
) -> bytes:
|
||||
"""@brief Формирует payload ``SET_USER_BYTES``.
|
||||
|
||||
@param rom Идентификатор целевого датчика.
|
||||
@param user_byte1 Значение TH в диапазоне -128..255.
|
||||
@param user_byte2 Значение TL в диапазоне -128..255.
|
||||
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
|
||||
@return Одиннадцать байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или значениях байтов.
|
||||
"""
|
||||
_validate_rom(rom)
|
||||
return rom + bytes(
|
||||
(
|
||||
_validate_byte(user_byte1, "user byte 1"),
|
||||
_validate_byte(user_byte2, "user byte 2"),
|
||||
0x01 if save_to_eeprom else 0x00,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def encode_resolution(rom: bytes, bits: int, save_to_eeprom: bool = False) -> bytes:
|
||||
"""@brief Формирует payload ``SET_RESOLUTION``.
|
||||
|
||||
@param rom Идентификатор целевого датчика.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
|
||||
@return Десять байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или разрешении.
|
||||
"""
|
||||
_validate_rom(rom)
|
||||
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
|
||||
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
|
||||
return rom + bytes((bits, 0x01 if save_to_eeprom else 0x00))
|
||||
|
||||
|
||||
def encode_poll_period(period_ms: int) -> bytes:
|
||||
"""@brief Формирует payload ``SET_POLL_PERIOD``.
|
||||
|
||||
@param period_ms Период автоматической выдачи, 0 отключает push.
|
||||
@return Два байта в little-endian.
|
||||
@raises ProtocolError При выходе периода за 16 бит.
|
||||
"""
|
||||
if not 0 <= period_ms <= 0xFFFF:
|
||||
raise ProtocolError("Период опроса задаётся в диапазоне 0..65535 мс")
|
||||
return period_ms.to_bytes(2, "little")
|
||||
|
||||
|
||||
def decode_bus_info(payload: bytes) -> dict[str, int | str]:
|
||||
"""@brief Извлекает хвост ``DEVICE_INFO`` с полями шины 1-Wire.
|
||||
|
||||
Первые 16 байт совпадают с общим форматом транспорта, поэтому разбор
|
||||
хвоста не мешает существующему декодеру устройства.
|
||||
|
||||
@param payload Полезная нагрузка ``DEVICE_INFO`` не короче 24 байт.
|
||||
@return Словарь с числом датчиков, разрешением и периодом выдачи.
|
||||
@raises ProtocolError При слишком короткой полезной нагрузке.
|
||||
"""
|
||||
if len(payload) < 24:
|
||||
raise ProtocolError("DEVICE_INFO не содержит блок 1-Wire")
|
||||
info: dict[str, int | str] = {
|
||||
"sensor_count": payload[16],
|
||||
"max_sensors": payload[17],
|
||||
"default_resolution": payload[18],
|
||||
"bus_flags": payload[19],
|
||||
"poll_period_ms": int.from_bytes(payload[20:22], "little"),
|
||||
}
|
||||
name = payload[24:].split(b"\x00")[0].decode("ascii", "replace").strip()
|
||||
if name:
|
||||
info["product"] = name
|
||||
return info
|
||||
|
||||
|
||||
def _validate_rom(rom: bytes) -> None:
|
||||
"""Проверяет длину ROM до формирования запроса."""
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
|
||||
|
||||
def _validate_byte(value: int, title: str) -> int:
|
||||
"""Приводит знаковое или беззнаковое значение к одному байту."""
|
||||
if not -128 <= value <= 255:
|
||||
raise ProtocolError(f"{title} вне диапазона -128..255")
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
class MockSensorBus:
|
||||
"""@brief Детерминированный источник трёх датчиков для mock-режима.
|
||||
|
||||
Класс не зависит от Qt: планировщик передаёт время, а объект отвечает за
|
||||
модель шины, пользовательские байты и разрешение каждого датчика.
|
||||
"""
|
||||
|
||||
ROMS = (
|
||||
bytes((0x28, 0xFF, 0x64, 0x1E, 0x0C, 0x3D, 0x2A, 0x00)),
|
||||
bytes((0x28, 0x1A, 0x77, 0x91, 0x05, 0x00, 0x00, 0x00)),
|
||||
bytes((0x28, 0x0B, 0xC2, 0x54, 0x0A, 0x11, 0x03, 0x00)),
|
||||
)
|
||||
BASE_TEMPERATURES = (24.5, 36.6, -12.0)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sensors = [
|
||||
SensorReading(
|
||||
rom=self._with_crc(rom),
|
||||
raw=int(round(base / RAW_STEP_C)),
|
||||
user_byte1=0x4B,
|
||||
user_byte2=0xC9,
|
||||
config=config_from_resolution(12),
|
||||
status=int(SensorStatus.VALID),
|
||||
)
|
||||
for rom, base in zip(self.ROMS, self.BASE_TEMPERATURES)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _with_crc(rom: bytes) -> bytes:
|
||||
"""Дописывает корректный CRC8, чтобы ROM проходил проверку GUI."""
|
||||
return rom[:7] + bytes((crc8_maxim(rom[:7]),))
|
||||
|
||||
def roms(self) -> list[bytes]:
|
||||
"""Возвращает список ROM в порядке обхода шины."""
|
||||
return [sensor.rom for sensor in self._sensors]
|
||||
|
||||
def snapshot(self, timestamp_s: float) -> list[SensorReading]:
|
||||
"""@brief Считает очередной набор измерений без побочных эффектов.
|
||||
|
||||
@param timestamp_s Монотонное время в секундах от старта mock-порта.
|
||||
@return Список измерений с плавным дрейфом температуры.
|
||||
"""
|
||||
readings: list[SensorReading] = []
|
||||
for index, sensor in enumerate(self._sensors):
|
||||
drift = math.sin(timestamp_s / (4.0 + index) + index) * (0.5 + index * 0.25)
|
||||
base = self.BASE_TEMPERATURES[index] + drift
|
||||
step = 1 << (MAX_RESOLUTION_BITS - sensor.resolution)
|
||||
raw = int(round(base / RAW_STEP_C / step)) * step
|
||||
readings.append(replace(sensor, raw=raw))
|
||||
return readings
|
||||
|
||||
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
|
||||
"""@brief Применяет пользовательские байты к mock-датчику.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param user_byte1 Новое значение TH.
|
||||
@param user_byte2 Новое значение TL.
|
||||
@raises KeyError Если ROM отсутствует на mock-шине.
|
||||
"""
|
||||
index = self._index_of(rom)
|
||||
self._sensors[index] = replace(
|
||||
self._sensors[index],
|
||||
user_byte1=_validate_byte(user_byte1, "user byte 1"),
|
||||
user_byte2=_validate_byte(user_byte2, "user byte 2"),
|
||||
)
|
||||
|
||||
def set_resolution(self, rom: bytes, bits: int) -> None:
|
||||
"""@brief Меняет разрешение mock-датчика.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@raises KeyError Если ROM отсутствует на mock-шине.
|
||||
"""
|
||||
index = self._index_of(rom)
|
||||
self._sensors[index] = replace(
|
||||
self._sensors[index], config=config_from_resolution(bits)
|
||||
)
|
||||
|
||||
def _index_of(self, rom: bytes) -> int:
|
||||
"""Ищет датчик по ROM и сообщает об отсутствии через KeyError."""
|
||||
for index, sensor in enumerate(self._sensors):
|
||||
if sensor.rom == rom:
|
||||
return index
|
||||
raise KeyError(f"Датчик {format_rom(rom)} отсутствует на mock-шине")
|
||||
403
python/set_devices/eeprom.py
Normal file
403
python/set_devices/eeprom.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Каталог «датчик — позиция» во внешней EEPROM прибора DS18B20."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from set_devices.ds18b20 import ROM_SIZE, format_rom
|
||||
from set_devices.protocol import ProtocolError
|
||||
|
||||
#: Заголовки колонок экспорта каталога — общие для CSV и Markdown.
|
||||
EXPORT_COLUMNS = (
|
||||
"№", "ID (ROM)", "Серийный", "Позиция", "Позиция (hex)",
|
||||
"Сборка", "Сборка (hex)",
|
||||
)
|
||||
|
||||
#: Длина полезной нагрузки ответа ``EEPROM_INFO``.
|
||||
MEMORY_INFO_SIZE = 20
|
||||
|
||||
#: Длина одной записи каталога в кадре ``EEPROM_LIST``.
|
||||
CATALOG_RECORD_SIZE = 12
|
||||
|
||||
#: Заголовок кадра ``EEPROM_LIST`` перед записями.
|
||||
CATALOG_HEADER_SIZE = 5
|
||||
|
||||
#: Признак «позиция не назначена» в записи каталога.
|
||||
NO_POSITION = 0xFFFF
|
||||
|
||||
#: Признак «номер сборки не назначен» в записи каталога.
|
||||
NO_SERIAL = 0xFFFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryInfo:
|
||||
"""@brief Состояние внешней памяти и каталога позиций.
|
||||
|
||||
@param present Кристалл ответил на свой адрес при последнем опросе.
|
||||
@param persistent Каталог прочитан из памяти и переживёт отключение.
|
||||
@param count Число заполненных записей каталога.
|
||||
@param capacity Вместимость каталога в записях.
|
||||
@param i2c_address Адрес кристалла на шине I2C.
|
||||
@param page_size Размер страницы записи, байт.
|
||||
@param size Объём кристалла, байт.
|
||||
@param base_address Адрес блока каталога в памяти.
|
||||
@param bus_errors Счётчик отказов шины с момента запуска прибора.
|
||||
@param blob_size Длина блока каталога в памяти, байт.
|
||||
"""
|
||||
|
||||
present: bool
|
||||
persistent: bool
|
||||
count: int
|
||||
capacity: int
|
||||
i2c_address: int
|
||||
page_size: int
|
||||
size: int
|
||||
base_address: int
|
||||
bus_errors: int
|
||||
blob_size: int
|
||||
|
||||
@property
|
||||
def size_text(self) -> str:
|
||||
"""Объём кристалла в килобайтах для строки состояния."""
|
||||
return f"{self.size // 1024} КБ" if self.size >= 1024 else f"{self.size} Б"
|
||||
|
||||
def summary(self) -> str:
|
||||
"""@brief Собирает описание памяти одной строкой.
|
||||
|
||||
@return Текст для панели вкладки DS18B20.
|
||||
"""
|
||||
if not self.present:
|
||||
return "Память не найдена: проверьте плату EEPROM на выводах PB6 и PB7"
|
||||
storage = "энергонезависимо" if self.persistent else "только в ОЗУ прибора"
|
||||
return (
|
||||
f"FT24C256 по адресу 0x{self.i2c_address:02X}, {self.size_text}, "
|
||||
f"страница {self.page_size} Б; каталог по адресу 0x{self.base_address:04X}, "
|
||||
f"занято {self.count} из {self.capacity} записей, {storage}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
"""@brief Одна запись каталога: датчик, позиция и номер сборки.
|
||||
|
||||
Номер сборки — учётное поле: несколько датчиков одной партии (например,
|
||||
10 штук на одной плате) несут один и тот же номер. По шине CAN он не
|
||||
передаётся, узлу-приёмнику важна только позиция.
|
||||
|
||||
@param rom Идентификатор датчика, 8 байт.
|
||||
@param position Позиция в таблице узла-приёмника, два байта.
|
||||
@param assembly_serial Серийный номер сборки (партии), два байта.
|
||||
"""
|
||||
|
||||
rom: bytes
|
||||
position: int
|
||||
assembly_serial: int = NO_SERIAL
|
||||
|
||||
@property
|
||||
def rom_hex(self) -> str:
|
||||
"""Идентификатор в виде разделённого HEX."""
|
||||
return format_rom(self.rom)
|
||||
|
||||
@property
|
||||
def serial(self) -> str:
|
||||
"""48-битный серийный номер датчика без кода семейства и CRC8."""
|
||||
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
|
||||
|
||||
@property
|
||||
def assigned(self) -> bool:
|
||||
"""Признак того, что позиция действительно назначена."""
|
||||
return self.position != NO_POSITION
|
||||
|
||||
@property
|
||||
def position_text(self) -> str:
|
||||
"""Позиция в десятичном и шестнадцатеричном виде."""
|
||||
if not self.assigned:
|
||||
return "не назначена"
|
||||
return f"{self.position} (0x{self.position:04X})"
|
||||
|
||||
@property
|
||||
def assembly_serial_assigned(self) -> bool:
|
||||
"""Признак того, что номер сборки действительно назначен."""
|
||||
return self.assembly_serial != NO_SERIAL
|
||||
|
||||
@property
|
||||
def assembly_serial_text(self) -> str:
|
||||
"""Номер сборки в десятичном и шестнадцатеричном виде."""
|
||||
if not self.assembly_serial_assigned:
|
||||
return "не назначен"
|
||||
return f"{self.assembly_serial} (0x{self.assembly_serial:04X})"
|
||||
|
||||
|
||||
def position_conflicts(
|
||||
entries: list[CatalogEntry],
|
||||
) -> dict[tuple[int, int], list[CatalogEntry]]:
|
||||
"""Возвращает пары «сборка, позиция» с несколькими различными ROM.
|
||||
|
||||
Неназначенные записи ``0xFFFF`` не участвуют в проверке. Результат
|
||||
сохраняет порядок строк каталога, чтобы GUI мог однозначно подсветить и
|
||||
показать оператору конфликтующие датчики.
|
||||
"""
|
||||
by_position: dict[tuple[int, int], list[CatalogEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.assigned and entry.assembly_serial_assigned:
|
||||
key = (entry.assembly_serial, entry.position)
|
||||
by_position.setdefault(key, []).append(entry)
|
||||
return {
|
||||
key: owners
|
||||
for key, owners in by_position.items()
|
||||
if len({entry.rom for entry in owners}) > 1
|
||||
}
|
||||
|
||||
|
||||
def decode_memory_info(payload: bytes) -> MemoryInfo:
|
||||
"""@brief Разбирает ответ ``EEPROM_INFO``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Состояние памяти и каталога.
|
||||
@raises ProtocolError При слишком короткой полезной нагрузке.
|
||||
"""
|
||||
if len(payload) < MEMORY_INFO_SIZE:
|
||||
raise ProtocolError("EEPROM_INFO короче 20 байт")
|
||||
return MemoryInfo(
|
||||
present=payload[0] != 0,
|
||||
persistent=payload[1] != 0,
|
||||
count=payload[2],
|
||||
capacity=payload[3],
|
||||
i2c_address=payload[4],
|
||||
page_size=int.from_bytes(payload[6:8], "little"),
|
||||
size=int.from_bytes(payload[8:12], "little"),
|
||||
base_address=int.from_bytes(payload[12:16], "little"),
|
||||
bus_errors=int.from_bytes(payload[16:18], "little"),
|
||||
blob_size=int.from_bytes(payload[18:20], "little"),
|
||||
)
|
||||
|
||||
|
||||
def decode_catalog(payload: bytes) -> tuple[int, int, list[CatalogEntry]]:
|
||||
"""@brief Разбирает ответ ``EEPROM_LIST``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Смещение окна, полное число записей и сами записи.
|
||||
@raises ProtocolError При неверной длине кадра или счётчике записей.
|
||||
"""
|
||||
if len(payload) < CATALOG_HEADER_SIZE:
|
||||
raise ProtocolError("EEPROM_LIST короче заголовка")
|
||||
offset = int.from_bytes(payload[0:2], "little")
|
||||
total = int.from_bytes(payload[2:4], "little")
|
||||
count = payload[4]
|
||||
if len(payload) != CATALOG_HEADER_SIZE + count * CATALOG_RECORD_SIZE:
|
||||
raise ProtocolError("EEPROM_LIST содержит повреждённые записи")
|
||||
entries: list[CatalogEntry] = []
|
||||
for index in range(count):
|
||||
base = CATALOG_HEADER_SIZE + index * CATALOG_RECORD_SIZE
|
||||
position_offset = base + ROM_SIZE
|
||||
serial_offset = position_offset + 2
|
||||
entries.append(
|
||||
CatalogEntry(
|
||||
rom=payload[base : base + ROM_SIZE],
|
||||
position=int.from_bytes(payload[position_offset:position_offset + 2],
|
||||
"little"),
|
||||
assembly_serial=int.from_bytes(payload[serial_offset:serial_offset + 2],
|
||||
"little"),
|
||||
)
|
||||
)
|
||||
return offset, total, entries
|
||||
|
||||
|
||||
def encode_catalog_request(offset: int = 0, count: int = 0) -> bytes:
|
||||
"""@brief Формирует запрос ``EEPROM_READ``.
|
||||
|
||||
Пустой запрос читает каталог целиком с начала, поэтому значения по
|
||||
умолчанию дают самый частый случай — «показать всё».
|
||||
|
||||
@param offset Номер первой запрашиваемой записи.
|
||||
@param count Число записей; 0 означает «сколько есть».
|
||||
@return Пустая строка байтов либо три байта окна.
|
||||
@raises ProtocolError При выходе аргументов за диапазон.
|
||||
"""
|
||||
if offset == 0 and count == 0:
|
||||
return b""
|
||||
if not 0 <= offset <= 0xFFFF:
|
||||
raise ProtocolError("Смещение каталога задаётся в диапазоне 0..65535")
|
||||
if not 0 <= count <= 0xFF:
|
||||
raise ProtocolError("Число записей задаётся в диапазоне 0..255")
|
||||
return offset.to_bytes(2, "little") + bytes((count,))
|
||||
|
||||
|
||||
def encode_catalog(entries: list[CatalogEntry], offset: int = 0,
|
||||
total: int | None = None) -> bytes:
|
||||
"""@brief Кодирует записи обратно в кадр ``EEPROM_LIST``.
|
||||
|
||||
Общая реализация формата для тестов и mock-прибора исключает расхождение
|
||||
кодера и декодера.
|
||||
|
||||
@param entries Записи каталога.
|
||||
@param offset Смещение окна.
|
||||
@param total Полное число записей; по умолчанию равно длине окна.
|
||||
@return Полезная нагрузка кадра.
|
||||
@raises ProtocolError Если записей больше, чем помещается в кадр.
|
||||
"""
|
||||
if len(entries) > 0xFF:
|
||||
raise ProtocolError("В кадр EEPROM_LIST помещается не более 255 записей")
|
||||
payload = (
|
||||
offset.to_bytes(2, "little")
|
||||
+ (len(entries) if total is None else total).to_bytes(2, "little")
|
||||
+ bytes((len(entries),))
|
||||
)
|
||||
for entry in entries:
|
||||
if len(entry.rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
payload += (
|
||||
entry.rom
|
||||
+ (entry.position & 0xFFFF).to_bytes(2, "little")
|
||||
+ (entry.assembly_serial & 0xFFFF).to_bytes(2, "little")
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: Код команды преамбулы CAN «записать датчика в позицию» (см. lib/can на МК).
|
||||
CAN_CMD_WRITE_POSITION = 0xA1
|
||||
|
||||
#: Код команды преамбулы CAN «очистить позицию».
|
||||
CAN_CMD_CLEAR_POSITION = 0xA2
|
||||
|
||||
|
||||
def encode_assign_position(rom: bytes, position: int, assembly_serial: int = 0,
|
||||
command: int = CAN_CMD_WRITE_POSITION) -> bytes:
|
||||
"""@brief Формирует payload команды ``SEND_ID_CAN``.
|
||||
|
||||
Прибор сначала сохраняет пару «датчик — позиция» вместе с номером сборки
|
||||
в каталоге EEPROM — независимо от того, есть ли кто-то на шине CAN — и
|
||||
только потом передаёт идентификатор в CAN двумя кадрами, поэтому один
|
||||
запрос одновременно обновляет каталог прибора и назначает позицию
|
||||
узлу-приёмнику. Номер сборки в кадры CAN не попадает — это учётное поле
|
||||
каталога.
|
||||
|
||||
@param rom Идентификатор датчика, полученный при поиске.
|
||||
@param position Позиция в таблице узла-приёмника, 0..65535.
|
||||
@param assembly_serial Серийный номер сборки (партии), 0..65535.
|
||||
@param command Код команды преамбулы: запись или очистка позиции.
|
||||
@return Тринадцать байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или значении полей.
|
||||
"""
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
if not 0 <= position <= 0xFFFF:
|
||||
raise ProtocolError("Позиция задаётся в диапазоне 0..65535")
|
||||
if not 0 <= assembly_serial <= 0xFFFF:
|
||||
raise ProtocolError("Номер сборки задаётся в диапазоне 0..65535")
|
||||
return (
|
||||
rom
|
||||
+ position.to_bytes(2, "little")
|
||||
+ assembly_serial.to_bytes(2, "little")
|
||||
+ bytes((command,))
|
||||
)
|
||||
|
||||
|
||||
def encode_memory_info(info: MemoryInfo) -> bytes:
|
||||
"""@brief Кодирует состояние памяти в кадр ``EEPROM_INFO``.
|
||||
|
||||
@param info Состояние памяти и каталога.
|
||||
@return Полезная нагрузка кадра длиной ``MEMORY_INFO_SIZE``.
|
||||
"""
|
||||
return (
|
||||
bytes((int(info.present), int(info.persistent), info.count, info.capacity,
|
||||
info.i2c_address, 0))
|
||||
+ info.page_size.to_bytes(2, "little")
|
||||
+ info.size.to_bytes(4, "little")
|
||||
+ info.base_address.to_bytes(4, "little")
|
||||
+ info.bus_errors.to_bytes(2, "little")
|
||||
+ info.blob_size.to_bytes(2, "little")
|
||||
)
|
||||
|
||||
|
||||
def _export_row(index: int, entry: CatalogEntry) -> tuple[str, ...]:
|
||||
"""@brief Собирает одну строку экспорта из записи каталога.
|
||||
|
||||
Общая для CSV и Markdown реализация исключает расхождение колонок между
|
||||
форматами: обе таблицы читаются одинаково.
|
||||
|
||||
@param index Порядковый номер строки, с единицы.
|
||||
@param entry Запись каталога.
|
||||
@return Кортеж строковых значений в порядке EXPORT_COLUMNS.
|
||||
"""
|
||||
position = "" if not entry.assigned else str(entry.position)
|
||||
position_hex = "" if not entry.assigned else f"0x{entry.position:04X}"
|
||||
serial = "" if not entry.assembly_serial_assigned else str(entry.assembly_serial)
|
||||
serial_hex = (
|
||||
"" if not entry.assembly_serial_assigned else f"0x{entry.assembly_serial:04X}"
|
||||
)
|
||||
return (str(index), entry.rom_hex, entry.serial, position, position_hex,
|
||||
serial, serial_hex)
|
||||
|
||||
|
||||
def export_catalog_csv(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое CSV-файла каталога для Excel.
|
||||
|
||||
Разделитель — точка с запятой: в русской локали Excel открывает такой
|
||||
CSV с готовой разбивкой по колонкам без диалога импорта, в отличие от
|
||||
запятой. Пустое значение позиции или номера сборки означает, что запись
|
||||
не назначена.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла с завершающим переводом строки.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n")
|
||||
writer.writerow(EXPORT_COLUMNS)
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
writer.writerow(_export_row(index, entry))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_catalog_json(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое JSON-файла каталога.
|
||||
|
||||
Годится для повторной загрузки в другой программе: значения остаются
|
||||
числами (а не отформатированным текстом), отсутствие позиции или номера
|
||||
сборки — явный ``null``, а не признак-заглушка вроде 0xFFFF.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла в кодировке UTF-8 с отступом в 2 пробела.
|
||||
"""
|
||||
payload = {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"count": len(entries),
|
||||
"entries": [
|
||||
{
|
||||
"index": index,
|
||||
"rom": entry.rom_hex,
|
||||
"serial": entry.serial,
|
||||
"position": entry.position if entry.assigned else None,
|
||||
"assembly_serial": (
|
||||
entry.assembly_serial if entry.assembly_serial_assigned else None
|
||||
),
|
||||
}
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def export_catalog_markdown(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое Markdown-файла каталога для отчёта.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла: заголовок, время выгрузки и таблица GFM.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
lines = [
|
||||
"# Каталог EEPROM DS18B20",
|
||||
"",
|
||||
f"Выгружено: {timestamp}. Записей: {len(entries)}.",
|
||||
"",
|
||||
"| " + " | ".join(EXPORT_COLUMNS) + " |",
|
||||
"|" + "|".join("---" for _ in EXPORT_COLUMNS) + "|",
|
||||
]
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
row = _export_row(index, entry)
|
||||
lines.append("| " + " | ".join(value or "—" for value in row) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
125
python/set_devices/firmware.py
Normal file
125
python/set_devices/firmware.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""@file firmware.py
|
||||
@brief Проверка образов и переносимая машина блочной передачи прошивки.
|
||||
|
||||
Реализует загрузку и валидацию образов прошивки из различных форматов файлов,
|
||||
проверку целостности данных, и логику передачи образов на устройство.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from set_devices.protocol import crc32_ieee
|
||||
|
||||
|
||||
class FirmwareImageError(ValueError):
|
||||
"""@brief Файл нельзя безопасно представить как непрерывный бинарный образ."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareImage:
|
||||
"""@brief Загруженный образ прошивки с метаданными.
|
||||
|
||||
@param path Путь к исходному файлу образа
|
||||
@param data Бинарные данные образа
|
||||
@param base_address Адрес начала загрузки (для Intel HEX формата)
|
||||
@param version Номер версии прошивки
|
||||
"""
|
||||
path: Path
|
||||
data: bytes
|
||||
base_address: int = 0
|
||||
version: int = 0
|
||||
|
||||
@property
|
||||
def crc32(self) -> int:
|
||||
return crc32_ieee(self.data)
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path, maximum_size: int = 2 * 1024 * 1024) -> "FirmwareImage":
|
||||
file_path = Path(path)
|
||||
raw = file_path.read_bytes()
|
||||
if file_path.suffix.lower() == ".hex":
|
||||
data, base = _parse_intel_hex(raw.decode("ascii"))
|
||||
elif file_path.suffix.lower() == ".bin":
|
||||
data, base = raw, 0
|
||||
else:
|
||||
raise FirmwareImageError("Поддерживаются только .bin и Intel HEX")
|
||||
if not data:
|
||||
raise FirmwareImageError("Образ пуст")
|
||||
if len(data) > maximum_size:
|
||||
raise FirmwareImageError(f"Размер образа превышает {maximum_size} байт")
|
||||
return cls(file_path, data, base)
|
||||
|
||||
|
||||
def _parse_intel_hex(text: str) -> tuple[bytes, int]:
|
||||
memory: dict[int, int] = {}
|
||||
upper = 0
|
||||
eof = False
|
||||
for line_number, source in enumerate(text.splitlines(), 1):
|
||||
line = source.strip()
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith(":"):
|
||||
raise FirmwareImageError(f"Intel HEX: строка {line_number} без ':'")
|
||||
try:
|
||||
record = bytes.fromhex(line[1:])
|
||||
except ValueError as error:
|
||||
raise FirmwareImageError(f"Intel HEX: неверный HEX в строке {line_number}") from error
|
||||
if len(record) < 5 or len(record) != record[0] + 5 or sum(record) & 0xFF:
|
||||
raise FirmwareImageError(f"Intel HEX: неверная длина/CRC строки {line_number}")
|
||||
count = record[0]
|
||||
address = int.from_bytes(record[1:3], "big")
|
||||
kind = record[3]
|
||||
payload = record[4 : 4 + count]
|
||||
if kind == 0x00:
|
||||
absolute = upper + address
|
||||
for index, value in enumerate(payload):
|
||||
key = absolute + index
|
||||
if key in memory and memory[key] != value:
|
||||
raise FirmwareImageError("Intel HEX содержит конфликтующие диапазоны")
|
||||
memory[key] = value
|
||||
elif kind == 0x01:
|
||||
eof = True
|
||||
break
|
||||
elif kind == 0x04 and count == 2:
|
||||
upper = int.from_bytes(payload, "big") << 16
|
||||
elif kind == 0x02 and count == 2:
|
||||
upper = int.from_bytes(payload, "big") << 4
|
||||
elif kind not in (0x03, 0x05):
|
||||
raise FirmwareImageError(f"Intel HEX: тип записи {kind:#x} не поддержан")
|
||||
if not eof or not memory:
|
||||
raise FirmwareImageError("Intel HEX не содержит данных или EOF")
|
||||
start, end = min(memory), max(memory) + 1
|
||||
image = bytearray(b"\xFF" * (end - start))
|
||||
for address, value in memory.items():
|
||||
image[address - start] = value
|
||||
return bytes(image), start
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareTransfer:
|
||||
image: FirmwareImage
|
||||
block_size: int = 480
|
||||
offset: int = 0
|
||||
|
||||
def next_block(self) -> tuple[int, bytes] | None:
|
||||
if self.offset >= len(self.image.data):
|
||||
return None
|
||||
offset = self.offset
|
||||
return offset, self.image.data[offset : offset + self.block_size]
|
||||
|
||||
def acknowledge(self, next_offset: int) -> None:
|
||||
current = self.next_block()
|
||||
if current is None or next_offset != current[0] + len(current[1]):
|
||||
raise FirmwareImageError("МК подтвердил неожиданный offset")
|
||||
self.offset = next_offset
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
return int(self.offset * 100 / len(self.image.data))
|
||||
317
python/set_devices/gas_catalog.py
Normal file
317
python/set_devices/gas_catalog.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Каталог общего адресного пространства и поток выбранных значений.
|
||||
|
||||
Прибор объявляет, какие регистры у него есть и как они называются, GUI
|
||||
выбирает подмножество и получает его пакетами. Схема повторяет реестр
|
||||
регистров ST Motor Control Workbench.
|
||||
|
||||
Модуль не импортирует Qt: кодеки переносимы и проверяются host-тестами.
|
||||
Двоичный контракт описан в ``templates/c/set-protocol/docs/legacy/GUI_CATALOG.md`` и
|
||||
продублирован на C в ``gui/gui_catalog.c``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from .protocol import MAX_PAYLOAD_SIZE, ProtocolError, decode_u16, decode_u32, encode_u16
|
||||
|
||||
#: Длина одной записи каталога на линии.
|
||||
ENTRY_SIZE = 32
|
||||
#: Длина заголовка ответа GAS_CATALOG.
|
||||
CATALOG_HEADER_SIZE = 6
|
||||
#: Длина поля имени в записи. 24 байта - это 12 кириллических
|
||||
#: символов в UTF-8; на 16 байтах не помещалось даже "Температура".
|
||||
NAME_SIZE = 24
|
||||
#: Максимум адресов в одной подписке.
|
||||
WATCH_MAX = 64
|
||||
|
||||
#: Сколько записей каталога помещается в один кадр.
|
||||
ENTRIES_PER_FRAME = (MAX_PAYLOAD_SIZE - CATALOG_HEADER_SIZE) // ENTRY_SIZE
|
||||
|
||||
#: Сколько значений помещается в один кадр потока.
|
||||
VALUES_PER_FRAME = (MAX_PAYLOAD_SIZE - 6) // 2
|
||||
|
||||
|
||||
class ObjectType(IntEnum):
|
||||
"""Формат значения регистра."""
|
||||
|
||||
U16 = 0
|
||||
I16 = 1
|
||||
U32 = 2
|
||||
I32 = 3
|
||||
BITS = 4
|
||||
|
||||
@property
|
||||
def registers(self) -> int:
|
||||
"""Сколько подряд идущих адресов занимает значение."""
|
||||
return 2 if self in (ObjectType.U32, ObjectType.I32) else 1
|
||||
|
||||
@property
|
||||
def signed(self) -> bool:
|
||||
return self in (ObjectType.I16, ObjectType.I32)
|
||||
|
||||
|
||||
#: Доступ и признаки записи каталога.
|
||||
FLAG_READABLE = 0x01
|
||||
FLAG_WRITABLE = 0x02
|
||||
FLAG_DEFAULT_WATCH = 0x04
|
||||
|
||||
#: Единицы измерения; коды входят в wire-контракт и не перенумеровываются.
|
||||
UNITS: dict[int, str] = {
|
||||
0: "",
|
||||
1: "В",
|
||||
2: "А",
|
||||
3: "°C",
|
||||
4: "%",
|
||||
5: "Гц",
|
||||
6: "мс",
|
||||
7: "с",
|
||||
8: "кбит/с",
|
||||
9: "шт.",
|
||||
10: "об/мин",
|
||||
11: "Вт",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectEntry:
|
||||
"""@brief Одна запись каталога общего адресного пространства.
|
||||
|
||||
@param address Адрес первого регистра значения.
|
||||
@param type Формат значения.
|
||||
@param flags Биты доступа ``FLAG_*``.
|
||||
@param scale_pow10 Показатель степени: физическое = raw * 10^scale.
|
||||
@param unit Код единицы измерения из ``UNITS``.
|
||||
@param name Имя для оператора, до 24 байт в UTF-8.
|
||||
"""
|
||||
|
||||
address: int
|
||||
type: ObjectType
|
||||
flags: int
|
||||
scale_pow10: int
|
||||
unit: int
|
||||
name: str
|
||||
|
||||
@property
|
||||
def readable(self) -> bool:
|
||||
return bool(self.flags & FLAG_READABLE)
|
||||
|
||||
@property
|
||||
def writable(self) -> bool:
|
||||
return bool(self.flags & FLAG_WRITABLE)
|
||||
|
||||
@property
|
||||
def default_watch(self) -> bool:
|
||||
return bool(self.flags & FLAG_DEFAULT_WATCH)
|
||||
|
||||
@property
|
||||
def unit_text(self) -> str:
|
||||
return UNITS.get(self.unit, "?")
|
||||
|
||||
@property
|
||||
def registers(self) -> int:
|
||||
return self.type.registers
|
||||
|
||||
def scale(self, raw: int) -> float:
|
||||
"""@brief Переводит сырое значение в физическую величину.
|
||||
|
||||
@param raw Слово или пара слов, уже собранные в целое.
|
||||
@return Значение с учётом знака и множителя ``10^scale_pow10``.
|
||||
"""
|
||||
width = 32 if self.registers == 2 else 16
|
||||
if self.type.signed and raw >= (1 << (width - 1)):
|
||||
raw -= 1 << width
|
||||
return raw * (10.0 ** self.scale_pow10)
|
||||
|
||||
def format(self, raw: int) -> str:
|
||||
"""@brief Готовая подпись значения для таблицы GUI."""
|
||||
if self.type is ObjectType.BITS:
|
||||
width = 4 * self.registers
|
||||
return f"0x{raw:0{width}X}"
|
||||
value = self.scale(raw)
|
||||
decimals = max(0, -self.scale_pow10)
|
||||
text = f"{value:.{decimals}f}"
|
||||
return f"{text} {self.unit_text}".strip()
|
||||
|
||||
|
||||
def encode_entry(entry: ObjectEntry) -> bytes:
|
||||
"""@brief Кодирует запись каталога в 32 байта.
|
||||
|
||||
@raises ProtocolError Если имя не влезает в 24 байта UTF-8.
|
||||
"""
|
||||
name = entry.name.encode("utf-8")
|
||||
if len(name) > NAME_SIZE:
|
||||
raise ProtocolError(f"имя '{entry.name}' длиннее {NAME_SIZE} байт в UTF-8")
|
||||
if not 0 <= entry.address <= 0xFFFF:
|
||||
raise ProtocolError("адрес вне диапазона 0..0xFFFF")
|
||||
if not -128 <= entry.scale_pow10 <= 127:
|
||||
raise ProtocolError("scale_pow10 вне диапазона int8")
|
||||
return (
|
||||
encode_u16(entry.address)
|
||||
+ bytes((int(entry.type) & 0xFF, entry.flags & 0xFF,
|
||||
entry.scale_pow10 & 0xFF, entry.unit & 0xFF))
|
||||
+ b"\x00\x00"
|
||||
+ name.ljust(NAME_SIZE, b"\x00")
|
||||
)
|
||||
|
||||
|
||||
def decode_entry(data: bytes, offset: int = 0) -> ObjectEntry:
|
||||
"""@brief Разбирает 32 байта записи каталога.
|
||||
|
||||
Неизвестный код типа не роняет разбор: запись остаётся видимой
|
||||
оператору как ``U16``, иначе новая прошивка сделала бы старый GUI
|
||||
полностью слепым.
|
||||
"""
|
||||
if offset + ENTRY_SIZE > len(data):
|
||||
raise ProtocolError("payload короче записи каталога")
|
||||
chunk = data[offset:offset + ENTRY_SIZE]
|
||||
raw_type = chunk[2]
|
||||
try:
|
||||
object_type = ObjectType(raw_type)
|
||||
except ValueError:
|
||||
object_type = ObjectType.U16
|
||||
scale = chunk[4] - 256 if chunk[4] >= 128 else chunk[4]
|
||||
name = chunk[8:8 + NAME_SIZE].split(b"\x00", 1)[0].decode("utf-8", "replace")
|
||||
return ObjectEntry(
|
||||
address=decode_u16(chunk),
|
||||
type=object_type,
|
||||
flags=chunk[3],
|
||||
scale_pow10=scale,
|
||||
unit=chunk[5],
|
||||
name=name,
|
||||
)
|
||||
|
||||
|
||||
def build_catalog_request(start_index: int = 0, max_count: int = 0) -> bytes:
|
||||
"""@brief Payload запроса каталога.
|
||||
|
||||
@param start_index Порядковый номер записи, а не адрес.
|
||||
@param max_count Сколько записей вернуть; 0 — сколько влезет в кадр.
|
||||
"""
|
||||
return encode_u16(start_index) + encode_u16(max_count)
|
||||
|
||||
|
||||
def build_catalog_response(total: int, start_index: int,
|
||||
entries: list[ObjectEntry]) -> bytes:
|
||||
"""@brief Payload ответа каталога (нужен mock-режиму и тестам)."""
|
||||
if len(entries) > ENTRIES_PER_FRAME:
|
||||
raise ProtocolError(f"в кадр входит не более {ENTRIES_PER_FRAME} записей")
|
||||
return (encode_u16(total) + encode_u16(start_index) + encode_u16(len(entries))
|
||||
+ b"".join(encode_entry(item) for item in entries))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogChunk:
|
||||
"""@brief Часть каталога из одного кадра.
|
||||
|
||||
@param total Полный размер каталога прибора.
|
||||
@param start_index Индекс первой записи в этом куске.
|
||||
@param entries Записи в порядке прибора.
|
||||
"""
|
||||
|
||||
total: int
|
||||
start_index: int
|
||||
entries: tuple[ObjectEntry, ...]
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Дошёл ли каталог до конца именно этим куском."""
|
||||
return self.start_index + len(self.entries) >= self.total
|
||||
|
||||
|
||||
def decode_catalog(payload: bytes) -> CatalogChunk:
|
||||
"""@brief Разбирает кадр ``GAS_CATALOG`` от прибора."""
|
||||
if len(payload) < CATALOG_HEADER_SIZE:
|
||||
raise ProtocolError("GAS_CATALOG короче 6 байт")
|
||||
total = decode_u16(payload)
|
||||
start_index = decode_u16(payload, 2)
|
||||
count = decode_u16(payload, 4)
|
||||
if len(payload) != CATALOG_HEADER_SIZE + count * ENTRY_SIZE:
|
||||
raise ProtocolError("GAS_CATALOG содержит неверное число записей")
|
||||
if start_index + count > total:
|
||||
raise ProtocolError("GAS_CATALOG выходит за объявленный размер каталога")
|
||||
entries = tuple(
|
||||
decode_entry(payload, CATALOG_HEADER_SIZE + index * ENTRY_SIZE)
|
||||
for index in range(count)
|
||||
)
|
||||
return CatalogChunk(total=total, start_index=start_index, entries=entries)
|
||||
|
||||
|
||||
def build_watch_set(period_ms: int, addresses: list[int]) -> bytes:
|
||||
"""@brief Payload подписки на поток значений.
|
||||
|
||||
@param period_ms Период потока; 0 останавливает поток.
|
||||
@param addresses Адреса в нужном порядке, не больше ``WATCH_MAX``.
|
||||
"""
|
||||
if not 0 <= period_ms <= 0xFFFF:
|
||||
raise ProtocolError("период вне диапазона 0..65535 мс")
|
||||
if len(addresses) > WATCH_MAX:
|
||||
raise ProtocolError(f"в подписке не больше {WATCH_MAX} адресов")
|
||||
return (encode_u16(period_ms) + encode_u16(len(addresses))
|
||||
+ b"".join(encode_u16(item) for item in addresses))
|
||||
|
||||
|
||||
def decode_watch_set(payload: bytes) -> tuple[int, list[int]]:
|
||||
"""@brief Разбирает подписку; на стороне прибора и в mock-режиме."""
|
||||
if len(payload) < 4:
|
||||
raise ProtocolError("GAS_WATCH_SET короче 4 байт")
|
||||
period_ms = decode_u16(payload)
|
||||
count = decode_u16(payload, 2)
|
||||
if len(payload) != 4 + count * 2:
|
||||
raise ProtocolError("GAS_WATCH_SET содержит неверное число адресов")
|
||||
return period_ms, [decode_u16(payload, 4 + index * 2) for index in range(count)]
|
||||
|
||||
|
||||
def decode_watch_ack(payload: bytes) -> tuple[int, int]:
|
||||
"""@brief Разбирает эхо прибора: принятый период и число адресов."""
|
||||
if len(payload) < 4:
|
||||
raise ProtocolError("эхо GAS_WATCH_SET короче 4 байт")
|
||||
return decode_u16(payload), decode_u16(payload, 2)
|
||||
|
||||
|
||||
def build_watch_data(timestamp_ms: int, values: list[int]) -> bytes:
|
||||
"""@brief Payload пакета значений (нужен mock-режиму и тестам)."""
|
||||
if len(values) > VALUES_PER_FRAME:
|
||||
raise ProtocolError(f"в кадр входит не более {VALUES_PER_FRAME} значений")
|
||||
return (int(timestamp_ms & 0xFFFFFFFF).to_bytes(4, "little")
|
||||
+ encode_u16(len(values))
|
||||
+ b"".join(encode_u16(item & 0xFFFF) for item in values))
|
||||
|
||||
|
||||
def decode_watch_data(payload: bytes) -> tuple[int, list[int]]:
|
||||
"""@brief Разбирает пакет потока.
|
||||
|
||||
@return Время прибора в миллисекундах и значения в порядке подписки.
|
||||
"""
|
||||
if len(payload) < 6:
|
||||
raise ProtocolError("GAS_WATCH_DATA короче 6 байт")
|
||||
timestamp_ms = decode_u32(payload)
|
||||
count = decode_u16(payload, 4)
|
||||
if len(payload) != 6 + count * 2:
|
||||
raise ProtocolError("GAS_WATCH_DATA содержит неверное число значений")
|
||||
return timestamp_ms, [decode_u16(payload, 6 + index * 2) for index in range(count)]
|
||||
|
||||
|
||||
def assemble(entries: list[ObjectEntry], values: dict[int, int]) -> dict[int, int]:
|
||||
"""@brief Собирает многословные значения из потока сырых слов.
|
||||
|
||||
@param entries Записи каталога, описывающие ширину каждого значения.
|
||||
@param values Сырые слова по адресам, как пришли в потоке.
|
||||
@return Значение по адресу первого слова; ширина учтена.
|
||||
"""
|
||||
result: dict[int, int] = {}
|
||||
for entry in entries:
|
||||
low = values.get(entry.address)
|
||||
if low is None:
|
||||
continue
|
||||
if entry.registers == 1:
|
||||
result[entry.address] = low
|
||||
continue
|
||||
high = values.get(entry.address + 1)
|
||||
if high is None:
|
||||
# Старшее слово не подписано - показывать половину числа хуже,
|
||||
# чем не показывать ничего.
|
||||
continue
|
||||
result[entry.address] = low | (high << 16)
|
||||
return result
|
||||
162
python/set_devices/models.py
Normal file
162
python/set_devices/models.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Модель нейтральных сигналов для шаблона без привязки к ТЗ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import sin
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BitDefinition:
|
||||
"""@brief Описание битового индикатора или управляющей дискреты.
|
||||
|
||||
@param key Стабильный ключ обмена, не зависящий от подписи в UI.
|
||||
@param title Отображаемое имя сигнала.
|
||||
@param writable Разрешено ли пользователю изменять значение.
|
||||
@param alarm Активное состояние отображается аварийным цветом.
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
writable: bool = False
|
||||
alarm: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NumericDefinition:
|
||||
"""@brief Метаданные цифрового поля или измерительного индикатора.
|
||||
|
||||
Границы и точность принадлежат catalog, поэтому UI не содержит сведений
|
||||
о конкретной карте объектов контроллера.
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
unit: str = ""
|
||||
minimum: float = 0.0
|
||||
maximum: float = 100.0
|
||||
decimals: int = 1
|
||||
writable: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignalCatalog:
|
||||
"""@brief Полный неизменяемый набор отображаемых сигналов.
|
||||
|
||||
Catalog является переносимой границей между данными устройства и Qt UI.
|
||||
Для реального изделия заменяются определения, а не код вкладок.
|
||||
"""
|
||||
|
||||
controller_fields: tuple[NumericDefinition, ...]
|
||||
indicators: tuple[NumericDefinition, ...]
|
||||
status_bits: tuple[BitDefinition, ...]
|
||||
input_discretes: tuple[BitDefinition, ...]
|
||||
output_discretes: tuple[BitDefinition, ...]
|
||||
|
||||
@classmethod
|
||||
def template(cls) -> "SignalCatalog":
|
||||
"""@brief Создаёт согласованный catalog с нейтральными именами.
|
||||
|
||||
@return Шаблон для обсуждения компоновки без зависимости от финального ТЗ.
|
||||
"""
|
||||
return cls(
|
||||
controller_fields=tuple(
|
||||
NumericDefinition(
|
||||
key=f"controller_{index:02d}",
|
||||
title=f"Параметр {index:02d}",
|
||||
minimum=-9999,
|
||||
maximum=9999,
|
||||
decimals=0,
|
||||
writable=True,
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
indicators=tuple(
|
||||
NumericDefinition(
|
||||
key=f"indicator_{index:02d}",
|
||||
title=f"Индикатор {index:02d}",
|
||||
unit=("В" if index <= 4 else "%"),
|
||||
maximum=(400.0 if index <= 4 else 100.0),
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
status_bits=tuple(
|
||||
BitDefinition(
|
||||
key=f"status_{index:02d}",
|
||||
title=f"Статус {index:02d}",
|
||||
alarm=index in (7, 8),
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
input_discretes=tuple(
|
||||
BitDefinition(
|
||||
key=f"input_{index:02d}",
|
||||
title=f"Вход {index:02d}",
|
||||
)
|
||||
for index in range(1, 17)
|
||||
),
|
||||
output_discretes=tuple(
|
||||
BitDefinition(
|
||||
key=f"output_{index:02d}",
|
||||
title=f"Выход {index:02d}",
|
||||
writable=True,
|
||||
)
|
||||
for index in range(1, 17)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalSnapshot:
|
||||
"""@brief Атомарный снимок значений от одного момента времени.
|
||||
|
||||
Словари заменяются целиком при публикации, чтобы вкладки не видели смесь
|
||||
данных от двух циклов опроса.
|
||||
"""
|
||||
|
||||
timestamp_s: float = 0.0
|
||||
numeric: dict[str, float] = field(default_factory=dict)
|
||||
bits: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MockSignalSource:
|
||||
"""@brief Детерминированный генератор данных без контроллера.
|
||||
|
||||
Не зависит от Qt и используется как переносимое ядро в host-тестах.
|
||||
"""
|
||||
|
||||
def __init__(self, catalog: SignalCatalog) -> None:
|
||||
self._catalog = catalog
|
||||
self._outputs = {item.key: False for item in catalog.output_discretes}
|
||||
|
||||
def set_output(self, key: str, value: bool) -> None:
|
||||
"""@brief Сохраняет состояние существующего template-выхода.
|
||||
|
||||
@param key Ключ из ``SignalCatalog.output_discretes``.
|
||||
@param value Новое логическое состояние.
|
||||
@raises KeyError Если ключ отсутствует в разрешённом наборе.
|
||||
"""
|
||||
if key not in self._outputs:
|
||||
raise KeyError(f"Неизвестная управляющая дискрета: {key}")
|
||||
self._outputs[key] = bool(value)
|
||||
|
||||
def snapshot(self, timestamp_s: float) -> SignalSnapshot:
|
||||
"""@brief Генерирует согласованный снимок для заданного времени.
|
||||
|
||||
@param timestamp_s Монотонное время от запуска mock в секундах.
|
||||
@return Новый независимый ``SignalSnapshot``.
|
||||
"""
|
||||
numeric: dict[str, float] = {}
|
||||
bits: dict[str, bool] = {}
|
||||
for index, item in enumerate(self._catalog.controller_fields):
|
||||
numeric[item.key] = float((index + 1) * 10)
|
||||
for index, item in enumerate(self._catalog.indicators):
|
||||
center = item.maximum * 0.55
|
||||
amplitude = item.maximum * 0.18
|
||||
numeric[item.key] = center + amplitude * sin(timestamp_s * 0.7 + index)
|
||||
for index, item in enumerate(self._catalog.status_bits):
|
||||
bits[item.key] = int(timestamp_s + index) % (index + 2) == 0
|
||||
for index, item in enumerate(self._catalog.input_discretes):
|
||||
bits[item.key] = int(timestamp_s * 2 + index) % 5 < 2
|
||||
bits.update(self._outputs)
|
||||
return SignalSnapshot(timestamp_s=timestamp_s, numeric=numeric, bits=bits)
|
||||
320
python/set_devices/panel.py
Normal file
320
python/set_devices/panel.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""@file panel.py
|
||||
@brief Зеркало экрана прибора и кнопки его панели.
|
||||
|
||||
Модуль описывает кадры ``UI_KEY``, ``UI_READ`` и ``UI_STATE``: они позволяют
|
||||
управлять меню прибора из GUI параллельно физическим кнопкам и показывать в
|
||||
окне то же, что видно на дисплее. Разбор здесь не зависит от Qt, поэтому
|
||||
тесты обходятся без графической подсистемы.
|
||||
|
||||
Формат снимка описан в ``PROTOCOL.md`` прошивки STM32F103C8T6; зеркало на
|
||||
стороне МК — ``app_send_ui_state`` в ``src/main.c``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from set_devices.protocol import ProtocolError
|
||||
|
||||
#: Длина заголовка ``UI_STATE`` до первой строки.
|
||||
STATE_HEADER_SIZE = 6
|
||||
|
||||
#: Предельная длина строки снимка; прошивка обрезает по этой границе.
|
||||
TEXT_MAX = 31
|
||||
|
||||
#: Бит 0 первого байта ``UI_STATE``: панель найдена и работает.
|
||||
FLAG_ACTIVE = 0x01
|
||||
|
||||
|
||||
class PanelKey(IntEnum):
|
||||
"""@brief Кнопки панели прибора.
|
||||
|
||||
Значения совпадают с ``Menu_Key`` прошивки и являются частью протокола.
|
||||
"""
|
||||
|
||||
UP = 0
|
||||
DOWN = 1
|
||||
LEFT = 2
|
||||
RIGHT = 3
|
||||
ENTER = 4
|
||||
BACK = 5
|
||||
|
||||
|
||||
#: Подписи кнопок для интерфейса в порядке их размещения на пульте.
|
||||
KEY_TITLES: dict[PanelKey, str] = {
|
||||
PanelKey.UP: "▲",
|
||||
PanelKey.DOWN: "▼",
|
||||
PanelKey.LEFT: "◀",
|
||||
PanelKey.RIGHT: "▶",
|
||||
PanelKey.ENTER: "OK",
|
||||
PanelKey.BACK: "Назад",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelRow:
|
||||
"""@brief Одна видимая строка списка на экране прибора."""
|
||||
|
||||
label: str
|
||||
value: str
|
||||
selected: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelScreen:
|
||||
"""@brief Снимок видимой части экрана прибора.
|
||||
|
||||
Повторяет изображение построчно: прошивка отдаёт содержимое из кэша
|
||||
последней отрисовки, поэтому снимок совпадает с панелью.
|
||||
"""
|
||||
|
||||
active: bool
|
||||
title: str
|
||||
status: str
|
||||
rows: tuple[PanelRow, ...]
|
||||
cursor: int
|
||||
first: int
|
||||
total: int
|
||||
depth: int
|
||||
|
||||
@property
|
||||
def cursor_row(self) -> int | None:
|
||||
"""@brief Номер выделенной строки в пределах снимка либо ``None``."""
|
||||
for index, row in enumerate(self.rows):
|
||||
if row.selected:
|
||||
return index
|
||||
return None
|
||||
|
||||
@property
|
||||
def scrollable(self) -> bool:
|
||||
"""@brief Сообщает, что список не поместился в окно экрана целиком."""
|
||||
return self.total > len(self.rows)
|
||||
|
||||
|
||||
def encode_key(key: PanelKey, hold: bool = False) -> bytes:
|
||||
"""@brief Формирует payload ``UI_KEY``.
|
||||
|
||||
@param key Нажимаемая кнопка.
|
||||
@param hold Длинное нажатие; для «назад» означает возврат в корень меню.
|
||||
@return Два байта полезной нагрузки.
|
||||
"""
|
||||
if not isinstance(key, PanelKey):
|
||||
key = PanelKey(key)
|
||||
return bytes((int(key), 0x01 if hold else 0x00))
|
||||
|
||||
|
||||
def encode_read(subscribe: bool | None = None) -> bytes:
|
||||
"""@brief Формирует payload ``UI_READ``.
|
||||
|
||||
@param subscribe ``True`` — подписаться на инициативную выдачу снимков,
|
||||
``False`` — отписаться, ``None`` — только запросить снимок, не меняя
|
||||
подписку.
|
||||
@return Ноль или один байт полезной нагрузки.
|
||||
"""
|
||||
if subscribe is None:
|
||||
return b""
|
||||
return bytes((0x01 if subscribe else 0x00,))
|
||||
|
||||
|
||||
def _take_text(payload: bytes, offset: int) -> tuple[str, int]:
|
||||
"""Читает строку «длина и байты» и возвращает её вместе с новой позицией."""
|
||||
if offset >= len(payload):
|
||||
raise ProtocolError("UI_STATE: строка выходит за границу payload")
|
||||
length = payload[offset]
|
||||
offset += 1
|
||||
if length > TEXT_MAX:
|
||||
raise ProtocolError(f"UI_STATE: строка длиннее {TEXT_MAX} байт: {length}")
|
||||
end = offset + length
|
||||
if end > len(payload):
|
||||
raise ProtocolError("UI_STATE: строка обрывается на границе payload")
|
||||
# Прошивка печатает только ASCII, но битый байт не должен ронять разбор.
|
||||
return payload[offset:end].decode("ascii", errors="replace"), end
|
||||
|
||||
|
||||
def decode_state(payload: bytes) -> PanelScreen:
|
||||
"""@brief Разбирает payload ``UI_STATE`` в снимок экрана.
|
||||
|
||||
@param payload Полезная нагрузка кадра.
|
||||
@return Снимок экрана прибора.
|
||||
@throws ProtocolError Если длина или структура снимка нарушена.
|
||||
"""
|
||||
if len(payload) < STATE_HEADER_SIZE:
|
||||
raise ProtocolError(
|
||||
f"UI_STATE: ожидалось не менее {STATE_HEADER_SIZE} байт, получено {len(payload)}"
|
||||
)
|
||||
flags = payload[0]
|
||||
depth = payload[1]
|
||||
total = payload[2]
|
||||
cursor = payload[3]
|
||||
first = payload[4]
|
||||
row_count = payload[5]
|
||||
|
||||
offset = STATE_HEADER_SIZE
|
||||
title, offset = _take_text(payload, offset)
|
||||
status, offset = _take_text(payload, offset)
|
||||
|
||||
rows: list[PanelRow] = []
|
||||
for _ in range(row_count):
|
||||
if offset >= len(payload):
|
||||
raise ProtocolError("UI_STATE: строк меньше, чем объявлено в заголовке")
|
||||
selected = payload[offset] != 0
|
||||
offset += 1
|
||||
label, offset = _take_text(payload, offset)
|
||||
value, offset = _take_text(payload, offset)
|
||||
rows.append(PanelRow(label=label, value=value, selected=selected))
|
||||
|
||||
return PanelScreen(
|
||||
active=(flags & FLAG_ACTIVE) != 0,
|
||||
title=title,
|
||||
status=status,
|
||||
rows=tuple(rows),
|
||||
cursor=cursor,
|
||||
first=first,
|
||||
total=total,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
|
||||
def encode_state(screen: PanelScreen) -> bytes:
|
||||
"""@brief Собирает payload ``UI_STATE`` из снимка.
|
||||
|
||||
Нужен mock-режиму и тестам: прошивка формирует кадр сама.
|
||||
|
||||
@param screen Снимок экрана.
|
||||
@return Полезная нагрузка кадра.
|
||||
"""
|
||||
|
||||
def text(value: str) -> bytes:
|
||||
raw = value.encode("ascii", errors="replace")[:TEXT_MAX]
|
||||
return bytes((len(raw),)) + raw
|
||||
|
||||
payload = bytearray(
|
||||
(
|
||||
FLAG_ACTIVE if screen.active else 0x00,
|
||||
screen.depth,
|
||||
screen.total,
|
||||
screen.cursor,
|
||||
screen.first,
|
||||
len(screen.rows),
|
||||
)
|
||||
)
|
||||
payload += text(screen.title)
|
||||
payload += text(screen.status)
|
||||
for row in screen.rows:
|
||||
payload.append(0x01 if row.selected else 0x00)
|
||||
payload += text(row.label)
|
||||
payload += text(row.value)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
#: Пункты корневого экрана прошивки; порядок совпадает с ``ui_root_label``.
|
||||
ROOT_ITEMS = ("SENSORS", "RESCAN", "CAN", "SETTINGS", "ABOUT")
|
||||
|
||||
#: Сколько строк помещается на панели 135x240 при оформлении по умолчанию.
|
||||
ROWS_VISIBLE = 5
|
||||
|
||||
#: Подсказка по кнопкам, которую прошивка ставит в строку состояния.
|
||||
HINT = "ENT-OPEN BACK-UP HOLD"
|
||||
|
||||
|
||||
class MockPanel:
|
||||
"""@brief Модель меню прибора для работы GUI без оборудования.
|
||||
|
||||
Повторяет структуру экранов прошивки настолько, чтобы пультом можно было
|
||||
пользоваться в mock-режиме: корневой экран и список датчиков с прокруткой.
|
||||
Значения берутся у поставщика, поэтому модель не знает про 1-Wire.
|
||||
"""
|
||||
|
||||
def __init__(self, sensors: Callable[[], Sequence[tuple[str, str]]] | None = None) -> None:
|
||||
"""@param sensors Поставщик строк экрана датчиков: название и значение."""
|
||||
self._sensors = sensors
|
||||
self._depth = 1
|
||||
self._cursor = [0, 0]
|
||||
self._first = [0, 0]
|
||||
self._status = HINT
|
||||
|
||||
def press(self, key: int, hold: bool = False) -> None:
|
||||
"""@brief Применяет нажатие кнопки к модели меню.
|
||||
|
||||
@param key Код кнопки из PanelKey.
|
||||
@param hold Длинное нажатие; для «назад» возвращает в корень.
|
||||
"""
|
||||
key = PanelKey(key)
|
||||
level = self._depth - 1
|
||||
total = self._total()
|
||||
|
||||
if key is PanelKey.BACK:
|
||||
if hold or self._depth > 1:
|
||||
self._depth = 1
|
||||
self._status = HINT
|
||||
return
|
||||
if key is PanelKey.ENTER:
|
||||
if self._depth == 1 and self._cursor[0] == 0:
|
||||
self._depth = 2
|
||||
self._cursor[1] = 0
|
||||
self._first[1] = 0
|
||||
elif self._depth == 1 and self._cursor[0] == 1:
|
||||
self._status = f"{len(self._rows())} SENSORS FOUND"
|
||||
return
|
||||
if total == 0:
|
||||
return
|
||||
if key is PanelKey.UP:
|
||||
self._cursor[level] = (self._cursor[level] - 1) % total
|
||||
elif key is PanelKey.DOWN:
|
||||
self._cursor[level] = (self._cursor[level] + 1) % total
|
||||
else:
|
||||
# Влево и вправо правят значения, которых у mock-экранов нет.
|
||||
return
|
||||
self._scroll(level, total)
|
||||
|
||||
def screen(self) -> PanelScreen:
|
||||
"""@brief Возвращает снимок текущего экрана модели."""
|
||||
level = self._depth - 1
|
||||
total = self._total()
|
||||
first = self._first[level]
|
||||
items = self._items()[first:first + ROWS_VISIBLE]
|
||||
rows = tuple(
|
||||
PanelRow(label=label, value=value,
|
||||
selected=(first + index) == self._cursor[level])
|
||||
for index, (label, value) in enumerate(items)
|
||||
)
|
||||
marks = ""
|
||||
if total > ROWS_VISIBLE:
|
||||
marks = ("^" if first != 0 else "") + ("v" if first + ROWS_VISIBLE < total else "")
|
||||
return PanelScreen(
|
||||
active=True,
|
||||
title="DS18B20" if self._depth == 1 else "SENSORS",
|
||||
status=self._status + marks,
|
||||
rows=rows,
|
||||
cursor=self._cursor[level],
|
||||
first=first,
|
||||
total=total,
|
||||
depth=self._depth,
|
||||
)
|
||||
|
||||
def _rows(self) -> Sequence[tuple[str, str]]:
|
||||
"""Строки экрана датчиков от поставщика; пустой список без него."""
|
||||
return () if self._sensors is None else self._sensors()
|
||||
|
||||
def _items(self) -> Sequence[tuple[str, str]]:
|
||||
"""Пункты открытого экрана вместе со значениями."""
|
||||
if self._depth == 1:
|
||||
values = (str(len(self._rows())), "", "OFF", "", "")
|
||||
return tuple(zip(ROOT_ITEMS, values))
|
||||
return self._rows()
|
||||
|
||||
def _total(self) -> int:
|
||||
"""Число пунктов открытого экрана."""
|
||||
return len(self._items())
|
||||
|
||||
def _scroll(self, level: int, total: int) -> None:
|
||||
"""Держит курсор внутри окна прокрутки после перемещения."""
|
||||
cursor = self._cursor[level]
|
||||
first = self._first[level]
|
||||
if cursor < first:
|
||||
first = cursor
|
||||
elif cursor >= first + ROWS_VISIBLE:
|
||||
first = cursor - ROWS_VISIBLE + 1
|
||||
self._first[level] = max(0, min(first, max(0, total - ROWS_VISIBLE)))
|
||||
325
python/set_devices/protocol.py
Normal file
325
python/set_devices/protocol.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Совместимое с ``lib/gui_transport`` кадрирование GUI protocol v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
_SHARED_PROTOCOL = get_shared_protocol()
|
||||
|
||||
SOF = b"\xA5\x5A"
|
||||
PROTOCOL_VERSION = 0x01
|
||||
MAX_PAYLOAD_SIZE = 512
|
||||
HEADER_SIZE = 8
|
||||
CRC_SIZE = 4
|
||||
|
||||
|
||||
class ProtocolError(ValueError):
|
||||
"""@brief Ошибка нарушения контракта транспортного кадра.
|
||||
|
||||
Возникает до передачи при неверных границах и при явной проверке кадра.
|
||||
"""
|
||||
|
||||
|
||||
class MessageType(IntEnum):
|
||||
"""@brief Стабильные типы сообщений из ``gui_transport_protocol.h``.
|
||||
|
||||
Числовые значения являются частью wire-протокола и не перенумеровываются.
|
||||
"""
|
||||
|
||||
PING = 0x01
|
||||
DEVICE_INFO = 0x02
|
||||
GET_OBJECT = 0x03
|
||||
SET_OBJECT = 0x04
|
||||
GET_OBJECT_LIST = 0x05
|
||||
GET_OBJECT_INFO = 0x06
|
||||
COMMAND_STATUS = 0x07
|
||||
DIAGNOSTICS = 0x08
|
||||
READ_REGISTERS = 0x09
|
||||
WRITE_REGISTERS = 0x0A
|
||||
FIRMWARE_BEGIN = 0x0B
|
||||
FIRMWARE_DATA = 0x0C
|
||||
FIRMWARE_END = 0x0D
|
||||
FIRMWARE_ABORT = 0x0E
|
||||
FIRMWARE_STATUS = 0x0F
|
||||
READ_LOGS = 0x10
|
||||
# Каталог общего адресного пространства и поток выбранных значений,
|
||||
# см. templates/c/set-protocol/docs/legacy/GUI_CATALOG.md.
|
||||
GAS_CATALOG = 0x11
|
||||
GAS_WATCH_SET = 0x12
|
||||
GAS_WATCH_DATA = 0x13
|
||||
SENSOR_SCAN = 0x20
|
||||
SENSOR_LIST = 0x21
|
||||
SENSOR_READ = 0x22
|
||||
SENSOR_DATA = 0x23
|
||||
SET_USER_BYTES = 0x24
|
||||
SET_RESOLUTION = 0x25
|
||||
SET_POLL_PERIOD = 0x26
|
||||
SEND_ID_CAN = 0x27
|
||||
UI_KEY = 0x28
|
||||
UI_READ = 0x29
|
||||
UI_STATE = 0x2A
|
||||
EEPROM_SCAN = 0x2B
|
||||
EEPROM_INFO = 0x2C
|
||||
EEPROM_READ = 0x2D
|
||||
EEPROM_LIST = 0x2E
|
||||
NACK = 0x80
|
||||
ACK = 0x81
|
||||
ERROR = 0x82
|
||||
|
||||
|
||||
class ObjectResult(IntEnum):
|
||||
"""Коды результата, общие с ``GUITransport_ObjectResult``."""
|
||||
|
||||
OK = 0
|
||||
INVALID_ARGUMENT = 1
|
||||
INVALID_LENGTH = 2
|
||||
NOT_FOUND = 3
|
||||
ACCESS_DENIED = 4
|
||||
BUSY = 5
|
||||
NO_PROVIDER = 6
|
||||
INTERNAL = 7
|
||||
# Расширение диапазона 0x10+ занято прошивкой STM32F103C8T6 (1-Wire).
|
||||
BUS_ERROR = 0x10
|
||||
UNSUPPORTED = 0x11
|
||||
NO_DISPLAY = 0x12
|
||||
NOT_APPLIED = 0x13
|
||||
NO_MEMORY = 0x14
|
||||
#: Кадр CAN не удалось поставить в почтовый ящик; к 1-Wire не относится.
|
||||
CAN_ERROR = 0x15
|
||||
#: Позиция этой сборки уже принадлежит другому физическому датчику.
|
||||
POSITION_CONFLICT = 0x16
|
||||
|
||||
|
||||
#: Причины отказа прибора на языке оператора.
|
||||
RESULT_TEXT: dict[int, str] = {
|
||||
ObjectResult.OK: "успех",
|
||||
ObjectResult.INVALID_ARGUMENT: "недопустимый аргумент",
|
||||
ObjectResult.INVALID_LENGTH: "неверная длина payload",
|
||||
ObjectResult.NOT_FOUND: "объект или датчик не найден",
|
||||
ObjectResult.ACCESS_DENIED: "доступ запрещён",
|
||||
ObjectResult.BUSY: "прибор занят",
|
||||
ObjectResult.NO_PROVIDER: "обработчик не назначен",
|
||||
ObjectResult.INTERNAL: "внутренняя ошибка прошивки",
|
||||
ObjectResult.BUS_ERROR: "ошибка шины 1-Wire: датчик не подтвердил запись",
|
||||
ObjectResult.UNSUPPORTED: "команда не поддерживается",
|
||||
ObjectResult.NO_DISPLAY: "панель недоступна",
|
||||
ObjectResult.NOT_APPLIED: "датчик ответил, но оставил прежнее значение",
|
||||
ObjectResult.NO_MEMORY: "внешняя память недоступна: отсутствует, переполнена или не приняла запись",
|
||||
ObjectResult.CAN_ERROR: "кадр не удалось поставить в почтовый ящик CAN",
|
||||
ObjectResult.POSITION_CONFLICT: (
|
||||
"позиция этой сборки уже назначена другому датчику; "
|
||||
"выберите запись для удаления"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def describe_result(code: int) -> str:
|
||||
"""@brief Переводит код результата в текст для журнала GUI.
|
||||
|
||||
@param code Значение из payload кадров NACK и ERROR.
|
||||
@return Русское описание либо запись с неизвестным числовым кодом.
|
||||
"""
|
||||
return RESULT_TEXT.get(code, f"неизвестный код {code}")
|
||||
|
||||
|
||||
def crc8_maxim(data: bytes) -> int:
|
||||
"""@brief Вычисляет CRC8 Dallas/Maxim для ROM и scratchpad DS18B20.
|
||||
|
||||
@param data Байты без поля контрольной суммы.
|
||||
@return Значение CRC8 с обратным полиномом ``0x8C``.
|
||||
"""
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0x8C if crc & 0x01 else crc >> 1
|
||||
return crc
|
||||
|
||||
|
||||
def encode_u16(value: int) -> bytes:
|
||||
if not 0 <= value <= 0xFFFF:
|
||||
raise ProtocolError("u16 вне диапазона")
|
||||
return value.to_bytes(2, "little")
|
||||
|
||||
|
||||
def encode_u32(value: int) -> bytes:
|
||||
if not 0 <= value <= 0xFFFFFFFF:
|
||||
raise ProtocolError("u32 вне диапазона")
|
||||
return value.to_bytes(4, "little")
|
||||
|
||||
|
||||
def decode_u16(data: bytes, offset: int = 0) -> int:
|
||||
if offset < 0 or offset + 2 > len(data):
|
||||
raise ProtocolError("payload не содержит u16")
|
||||
return int.from_bytes(data[offset : offset + 2], "little")
|
||||
|
||||
|
||||
def decode_u32(data: bytes, offset: int = 0) -> int:
|
||||
if offset < 0 or offset + 4 > len(data):
|
||||
raise ProtocolError("payload не содержит u32")
|
||||
return int.from_bytes(data[offset : offset + 4], "little")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
"""@brief Проверенный кадр без служебных полей SOF и CRC.
|
||||
|
||||
@param message_type Тип сообщения из общего C/Python-контракта.
|
||||
@param sequence Номер запроса в диапазоне 0..65535.
|
||||
@param payload Полезная нагрузка не более ``MAX_PAYLOAD_SIZE`` байт.
|
||||
"""
|
||||
|
||||
message_type: MessageType
|
||||
sequence: int
|
||||
payload: bytes = b""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""@brief Проверяет границы до кодирования или передачи кадра.
|
||||
|
||||
@raises ProtocolError При неверном sequence или слишком длинном payload.
|
||||
"""
|
||||
if not 0 <= self.sequence <= 0xFFFF:
|
||||
raise ProtocolError("sequence должен быть в диапазоне 0..65535")
|
||||
if len(self.payload) > MAX_PAYLOAD_SIZE:
|
||||
raise ProtocolError("payload превышает 512 байт")
|
||||
|
||||
|
||||
def crc32_ieee(data: bytes) -> int:
|
||||
"""@brief Вычисляет IEEE CRC32 как ``GUITransport_Protocol_Crc32``.
|
||||
|
||||
@param data Защищаемые байты от version до конца payload.
|
||||
@return Беззнаковое 32-битное значение контрольной суммы.
|
||||
"""
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.gui_crc32(data)
|
||||
return binascii.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def build_frame(frame: Frame) -> bytes:
|
||||
"""@brief Кодирует полный wire-кадр транспортного протокола.
|
||||
|
||||
@param frame Предварительно проверенная модель кадра.
|
||||
@return SOF, big-endian header, payload и little-endian CRC32.
|
||||
"""
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.gui_encode(
|
||||
int(frame.message_type), frame.sequence, frame.payload)
|
||||
payload_size = len(frame.payload)
|
||||
protected = bytes(
|
||||
(
|
||||
PROTOCOL_VERSION,
|
||||
int(frame.message_type),
|
||||
(frame.sequence >> 8) & 0xFF,
|
||||
frame.sequence & 0xFF,
|
||||
(payload_size >> 8) & 0xFF,
|
||||
payload_size & 0xFF,
|
||||
)
|
||||
) + frame.payload
|
||||
return SOF + protected + crc32_ieee(protected).to_bytes(4, "little")
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""@brief Потоковый parser с восстановлением синхронизации.
|
||||
|
||||
Экземпляр владеет входным буфером и счётчиками ошибок. Он не использует
|
||||
глобальное состояние и подходит для независимых последовательных каналов.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._native = (_SHARED_PROTOCOL.gui_parser()
|
||||
if _SHARED_PROTOCOL is not None else None)
|
||||
self._native_offsets = (0, 0, 0)
|
||||
self.crc_errors = 0
|
||||
self.version_errors = 0
|
||||
self.length_errors = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""@brief Удаляет незавершённые входные данные.
|
||||
|
||||
Счётчики диагностики сохраняются для анализа качества соединения.
|
||||
"""
|
||||
self._buffer.clear()
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
self._native_offsets = (
|
||||
self.crc_errors, self.version_errors, self.length_errors)
|
||||
self._native = _SHARED_PROTOCOL.gui_parser()
|
||||
|
||||
def feed(self, data: bytes) -> list[Frame]:
|
||||
"""@brief Добавляет произвольный фрагмент последовательного потока.
|
||||
|
||||
@param data Новые байты; границы фрагмента не обязаны совпадать с кадром.
|
||||
@return Ноль или несколько полностью проверенных кадров.
|
||||
"""
|
||||
if not data:
|
||||
return []
|
||||
if self._native is not None:
|
||||
frames: list[Frame] = []
|
||||
for item in self._native.feed(data):
|
||||
try:
|
||||
message_type = MessageType(item.message_type)
|
||||
except ValueError:
|
||||
continue
|
||||
frames.append(Frame(message_type, item.sequence, item.payload))
|
||||
native_stats = self._native.stats()
|
||||
self.crc_errors = self._native_offsets[0] + native_stats["crc_errors"]
|
||||
self.version_errors = (
|
||||
self._native_offsets[1] + native_stats["version_errors"])
|
||||
self.length_errors = (
|
||||
self._native_offsets[2] + native_stats["length_errors"])
|
||||
return frames
|
||||
self._buffer.extend(data)
|
||||
frames: list[Frame] = []
|
||||
|
||||
while True:
|
||||
sof_index = self._buffer.find(SOF)
|
||||
if sof_index < 0:
|
||||
# Последний A5 может быть началом SOF следующего фрагмента.
|
||||
self._buffer[:] = self._buffer[-1:] if self._buffer[-1:] == SOF[:1] else b""
|
||||
break
|
||||
if sof_index:
|
||||
del self._buffer[:sof_index]
|
||||
if len(self._buffer) < HEADER_SIZE:
|
||||
break
|
||||
if self._buffer[2] != PROTOCOL_VERSION:
|
||||
self.version_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
payload_size = (self._buffer[6] << 8) | self._buffer[7]
|
||||
if payload_size > MAX_PAYLOAD_SIZE:
|
||||
self.length_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
total_size = HEADER_SIZE + payload_size + CRC_SIZE
|
||||
if len(self._buffer) < total_size:
|
||||
break
|
||||
|
||||
packet = bytes(self._buffer[:total_size])
|
||||
protected = packet[2:-CRC_SIZE]
|
||||
received_crc = int.from_bytes(packet[-CRC_SIZE:], "little")
|
||||
if crc32_ieee(protected) != received_crc:
|
||||
self.crc_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
try:
|
||||
message_type = MessageType(packet[3])
|
||||
except ValueError:
|
||||
# Неизвестный тип остаётся протокольной ошибкой GUI.
|
||||
del self._buffer[:total_size]
|
||||
continue
|
||||
frames.append(
|
||||
Frame(
|
||||
message_type=message_type,
|
||||
sequence=(packet[4] << 8) | packet[5],
|
||||
payload=packet[8 : 8 + payload_size],
|
||||
)
|
||||
)
|
||||
del self._buffer[:total_size]
|
||||
|
||||
return frames
|
||||
253
python/set_devices/protocol_capture.py
Normal file
253
python/set_devices/protocol_capture.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Bounded host traffic recording and synthetic DSView v3 logic captures.
|
||||
|
||||
No hardware timing is inferred: timestamps are host observations and overlapping
|
||||
packets on one wire are serialized. Original events remain in the archive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from time import monotonic_ns
|
||||
import zipfile
|
||||
|
||||
|
||||
def uart_event(source: str, direction: str, data: bytes, rate: int) -> dict:
|
||||
return dict(source=source, kind="uart", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns())
|
||||
|
||||
|
||||
def can_event(source: str, direction: str, identifier: int, data: bytes,
|
||||
rate: int, extended: bool = True, remote: bool = False) -> dict:
|
||||
return dict(source=source, kind="can", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns(), identifier=identifier,
|
||||
extended=extended, remote=remote)
|
||||
|
||||
|
||||
def validate_event(event: dict) -> None:
|
||||
if event['kind'] not in ('uart', 'can') or event['direction'] not in ('RX', 'TX'):
|
||||
raise ValueError('Unknown traffic type/direction')
|
||||
if not isinstance(event['rate'], int) or not 1 <= event['rate'] <= 10_000_000:
|
||||
raise ValueError('Invalid line rate')
|
||||
if not isinstance(event['time_ns'], int) or event['time_ns'] < 0:
|
||||
raise ValueError('Invalid event timestamp')
|
||||
if not event['source'] or any(c in event['source'] for c in '\r\n=[]'):
|
||||
raise ValueError('Invalid source name')
|
||||
if event['kind'] == 'can':
|
||||
if len(event['data']) > 8 or not 0 <= event['identifier'] <= (0x1fffffff if event['extended'] else 0x7ff):
|
||||
raise ValueError('Only classic CAN IDs and DLC 0..8 are supported')
|
||||
|
||||
|
||||
class TrafficRecording:
|
||||
MAX_EVENTS = 100_000
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[dict] = []
|
||||
self.byte_count = 0
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
validate_event(event)
|
||||
data = bytes(event['data'])
|
||||
if event['kind'] == 'uart' and not data:
|
||||
return
|
||||
if len(self.events) >= self.MAX_EVENTS or self.byte_count + len(data) > self.MAX_BYTES:
|
||||
raise ValueError('Достигнут лимит записи: 100 000 событий / 8 МиБ данных')
|
||||
self.events.append(dict(event, data=data))
|
||||
self.byte_count += len(data)
|
||||
|
||||
|
||||
def bits(value: int, count: int):
|
||||
return [(value >> i) & 1 for i in range(count-1, -1, -1)]
|
||||
|
||||
|
||||
def uart_bits(data: bytes):
|
||||
for byte in data:
|
||||
yield 0
|
||||
for i in range(8):
|
||||
yield (byte >> i) & 1
|
||||
yield 1
|
||||
|
||||
|
||||
def can_bits(event: dict):
|
||||
"""CAN 2.0 data/remote frame, CRC15, stuffing, synthetic successful ACK."""
|
||||
ident, remote, payload = event['identifier'], event['remote'], event['data']
|
||||
if event['extended']:
|
||||
raw = [0] + bits(ident >> 18, 11) + [1, 1] + bits(ident & 0x3ffff, 18) + [int(remote), 0, 0]
|
||||
else:
|
||||
raw = [0] + bits(ident, 11) + [int(remote), 0, 0]
|
||||
raw += bits(len(payload), 4)
|
||||
if not remote:
|
||||
for byte in payload:
|
||||
raw += bits(byte, 8)
|
||||
crc = 0
|
||||
for bit in raw:
|
||||
feedback = ((crc >> 14) & 1) ^ bit
|
||||
crc = (crc << 1) & 0x7fff
|
||||
if feedback:
|
||||
crc ^= 0x4599
|
||||
raw += bits(crc, 15)
|
||||
last, run = None, 0
|
||||
for bit in raw:
|
||||
yield bit
|
||||
run = run + 1 if bit == last else 1
|
||||
last = bit
|
||||
if run == 5:
|
||||
last = 1 - bit
|
||||
yield last
|
||||
run = 1
|
||||
# CRC delimiter, reconstructed ACK, ACK delimiter, EOF, intermission.
|
||||
yield from [1, 0, 1] + [1] * 10
|
||||
|
||||
|
||||
def channel_name(event):
|
||||
# A reconnect at another line rate must remain independently decodable.
|
||||
return event['source'] + ('_' + event['direction'] if event['kind'] == 'uart' else '_CAN') + '_' + str(event['rate'])
|
||||
|
||||
|
||||
class _PackedWriter:
|
||||
"""LSB-first samples streamed in bounded chunks, including long idle runs."""
|
||||
def __init__(self, stream, cancelled):
|
||||
self.stream, self.cancelled = stream, cancelled
|
||||
self.partial = self.used = 0
|
||||
self.buffer = bytearray()
|
||||
|
||||
def _flush(self):
|
||||
if self.cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
self.stream.write(self.buffer)
|
||||
self.buffer.clear()
|
||||
|
||||
def run(self, level: int, count: int):
|
||||
if count < 0:
|
||||
raise ValueError('Non-monotonic waveform')
|
||||
if self.used:
|
||||
take = min(count, 8 - self.used)
|
||||
if level:
|
||||
self.partial |= ((1 << take)-1) << self.used
|
||||
self.used += take
|
||||
count -= take
|
||||
if self.used == 8:
|
||||
self.buffer.append(self.partial)
|
||||
self.partial = self.used = 0
|
||||
full, tail = divmod(count, 8)
|
||||
while full:
|
||||
take = min(full, 65536)
|
||||
self.buffer.extend(bytes([255 if level else 0]) * take)
|
||||
full -= take
|
||||
if len(self.buffer) >= 65536:
|
||||
self._flush()
|
||||
if tail:
|
||||
self.partial = (1 << tail)-1 if level else 0
|
||||
self.used = tail
|
||||
|
||||
def finish(self):
|
||||
if self.used:
|
||||
raise ValueError('Unaligned sample count')
|
||||
self._flush()
|
||||
|
||||
|
||||
def export_dsl(path, events, samplerate=20_000_000, *, overwrite=False,
|
||||
cancelled=lambda: False, progress=lambda value: None):
|
||||
"""Atomic export. Existing destination survives failed/cancelled exports."""
|
||||
path = Path(path)
|
||||
if not events:
|
||||
raise ValueError('Нет записанного трафика')
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(path)
|
||||
if not isinstance(samplerate, int) or not 1 <= samplerate <= 100_000_000:
|
||||
raise ValueError('Частота дискретизации должна быть от 1 до 100 МГц')
|
||||
groups = {}
|
||||
origin = min(event['time_ns'] for event in events)
|
||||
shifted = 0
|
||||
for event in events:
|
||||
validate_event(event)
|
||||
if samplerate < event['rate'] * 8:
|
||||
raise ValueError('Частота дискретизации должна быть не меньше 8× скорости линии')
|
||||
groups.setdefault(channel_name(event), []).append(event)
|
||||
if len(groups) > 32:
|
||||
raise ValueError('DSView: максимум 32 цифровых канала')
|
||||
schedule, maximum = {}, 0
|
||||
for name, channel_events in groups.items():
|
||||
cursor = 16
|
||||
scheduled = []
|
||||
for event in sorted(channel_events, key=lambda item: item['time_ns']):
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
data_bits = list(can_bits(event)) if event['kind'] == 'can' else None
|
||||
bit_count = len(data_bits) if data_bits is not None else len(event['data']) * 10
|
||||
wanted = 16 + (event['time_ns'] - origin) * samplerate // 1_000_000_000
|
||||
start = max(cursor, wanted)
|
||||
shifted += int(start > wanted)
|
||||
count = (bit_count * samplerate + event['rate'] - 1) // event['rate']
|
||||
scheduled.append((start, event, data_bits, bit_count))
|
||||
cursor = start + count
|
||||
maximum = max(maximum, cursor + 16)
|
||||
schedule[name] = scheduled
|
||||
padded = (maximum + 63) // 64 * 64
|
||||
if padded > 2_000_000_000:
|
||||
raise ValueError('Запись превышает 2 млрд отсчётов: сократите длительность или частоту')
|
||||
names = list(groups)
|
||||
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
|
||||
'device mode = 0\ncapturefile = data\n'
|
||||
f'total samples = {padded}\ntotal probes = {len(names)}\ntotal blocks = 1\n'
|
||||
f'samplerate = {samplerate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||||
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
|
||||
session = {'Device': 'virtual-session', 'DeviceMode': 0, 'Version': 3,
|
||||
'Title': 'DSView v1.3.2', 'Sample count': str(padded),
|
||||
'Sample rate': str(samplerate), 'Max Height': '1X', 'decoder': [],
|
||||
'channel': [{'colour': 'default', 'enabled': True, 'index': i,
|
||||
'name': name, 'strigger': 0, 'type': 10000, 'view_index': i}
|
||||
for i, name in enumerate(names)]}
|
||||
fd, temporary = tempfile.mkstemp(prefix='.setgui-dsl-', suffix='.tmp', dir=path.parent)
|
||||
os.close(fd)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('header', header)
|
||||
archive.writestr('session', json.dumps(session, ensure_ascii=False))
|
||||
archive.writestr('decoders', '[]')
|
||||
metadata = dict(schema='setgui.protocol-capture.v1', synthetic=True,
|
||||
timing='host observation; overlapping events serialized per wire',
|
||||
uart='8N1, non-inverted', can_ack='synthetic dominant ACK',
|
||||
shifted_events=shifted, samplerate=samplerate,
|
||||
origin_monotonic_ns=origin,
|
||||
events=[dict(event, data=event['data'].hex()) for event in events])
|
||||
archive.writestr('protocol-log.json', json.dumps(metadata, ensure_ascii=False))
|
||||
done, last_progress = 0, -1
|
||||
for index, name in enumerate(names):
|
||||
with archive.open(f'L-{index}/0', 'w', force_zip64=True) as output:
|
||||
writer = _PackedWriter(output, cancelled)
|
||||
cursor = 0
|
||||
for start, event, data_bits, bit_count in schedule[name]:
|
||||
writer.run(1, start - cursor)
|
||||
bitstream = data_bits if data_bits is not None else uart_bits(event['data'])
|
||||
previous = 0
|
||||
for i, level in enumerate(bitstream, 1):
|
||||
boundary = (i * samplerate + event['rate'] - 1) // event['rate']
|
||||
writer.run(level, boundary - previous)
|
||||
previous = boundary
|
||||
cursor = start + previous
|
||||
done += 1
|
||||
value = done * 95 // len(events)
|
||||
if value != last_progress:
|
||||
progress(value)
|
||||
last_progress = value
|
||||
writer.run(1, padded - cursor)
|
||||
writer.finish()
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
with zipfile.ZipFile(temporary) as archive:
|
||||
if archive.testzip() is not None:
|
||||
raise ValueError('Ошибка проверки архива DSL')
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
if overwrite:
|
||||
os.replace(temporary, path)
|
||||
else:
|
||||
# Atomic no-clobber publication on the same filesystem.
|
||||
os.link(temporary, path)
|
||||
progress(100)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return dict(samples=padded, samplerate=samplerate, channels=names, shifted_events=shifted)
|
||||
71
python/set_devices/protocol_router.py
Normal file
71
python/set_devices/protocol_router.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Выбор wire-протокола для одного последовательного канала SETGUI.
|
||||
|
||||
Во время подключения оба потоковых parser-а получают одинаковые байты. Первый
|
||||
полностью проверенный кадр фиксирует режим канала до следующего ``reset``.
|
||||
Версия находится сразу после общего SOF A5 5A, поэтому протоколы не могут
|
||||
ошибочно принять кадр друг друга.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from set_devices import protocol as gui_v1
|
||||
from setprotocol import core as setp_v2
|
||||
|
||||
|
||||
class ProtocolMode(Enum):
|
||||
"""Wire-протокол, выбранный для текущего физического соединения."""
|
||||
|
||||
AUTO = "auto"
|
||||
SETPROTOCOL_V2 = "setprotocol_v2"
|
||||
GUI_V1 = "gui_v1"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return {
|
||||
ProtocolMode.AUTO: "Определение протокола…",
|
||||
ProtocolMode.SETPROTOCOL_V2: "SETProtocol v2",
|
||||
ProtocolMode.GUI_V1: "GUI protocol v1 (совместимость)",
|
||||
}[self]
|
||||
|
||||
|
||||
class ProtocolRouter:
|
||||
"""Потоковый маршрутизатор SETProtocol v2 и устаревшего GUI v1."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._v2 = setp_v2.FrameParser()
|
||||
self._v1 = gui_v1.FrameParser()
|
||||
self.mode = ProtocolMode.AUTO
|
||||
|
||||
def reset(self, mode: ProtocolMode = ProtocolMode.AUTO) -> None:
|
||||
self._v2.reset()
|
||||
self._v1.reset()
|
||||
self.mode = mode
|
||||
|
||||
def select(self, mode: ProtocolMode) -> None:
|
||||
"""Фиксирует режим и отбрасывает остатки предыдущего разбора."""
|
||||
if mode is ProtocolMode.AUTO:
|
||||
self.reset()
|
||||
return
|
||||
self.reset(mode)
|
||||
|
||||
def feed(self, data: bytes) -> list[gui_v1.Frame | setp_v2.Frame]:
|
||||
if self.mode is ProtocolMode.SETPROTOCOL_V2:
|
||||
return list(self._v2.feed(data))
|
||||
if self.mode is ProtocolMode.GUI_V1:
|
||||
return list(self._v1.feed(data))
|
||||
|
||||
# В AUTO parser-ы независимы. Неверная версия учитывается только их
|
||||
# внутренней диагностикой и не влияет на второй parser.
|
||||
v2_frames = self._v2.feed(data)
|
||||
v1_frames = self._v1.feed(data)
|
||||
if v2_frames:
|
||||
self.mode = ProtocolMode.SETPROTOCOL_V2
|
||||
self._v1.reset()
|
||||
return list(v2_frames)
|
||||
if v1_frames:
|
||||
self.mode = ProtocolMode.GUI_V1
|
||||
self._v2.reset()
|
||||
return list(v1_frames)
|
||||
return []
|
||||
1
python/set_devices/qt_ports/__init__.py
Normal file
1
python/set_devices/qt_ports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
258
python/set_devices/qt_ports/candle_adapter.py
Normal file
258
python/set_devices/qt_ports/candle_adapter.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""WinUSB transport for candleLight/gs_usb CAN adapters.
|
||||
|
||||
The native library is the same Candle API used by CANgaroo. This module keeps
|
||||
all ctypes details out of the UI and exposes a small Qt-friendly connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes as ct
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import re
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
|
||||
|
||||
_EXTENDED_ID = 0x80000000
|
||||
_FRAME_RECEIVE = 1
|
||||
_MODE_LISTEN_ONLY = 0x0001
|
||||
|
||||
|
||||
class CandleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandleChannel:
|
||||
path: str
|
||||
channel: int
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
lower = self.path.lower()
|
||||
product = "CANnectivity" if "vid_1209&pid_ca01" in lower else "candle"
|
||||
return f"{product} — канал {self.channel}"
|
||||
|
||||
@property
|
||||
def adapter_kind(self) -> str:
|
||||
return "candle"
|
||||
|
||||
|
||||
class _Frame(ct.Structure):
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("echo_id", ct.c_uint32), ("can_id", ct.c_uint32),
|
||||
("can_dlc", ct.c_uint8), ("channel", ct.c_uint8),
|
||||
("flags", ct.c_uint8), ("reserved", ct.c_uint8),
|
||||
("data", ct.c_uint8 * 8), ("timestamp_us", ct.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def _library_path() -> Path:
|
||||
explicit = os.environ.get('CANDLE_LIBRARY')
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
bundled = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1]))
|
||||
candidates = (
|
||||
Path(__file__).resolve().parents[1] / "native" / "candle.dll",
|
||||
bundled / "native" / "candle.dll",
|
||||
bundled / "candle.dll",
|
||||
)
|
||||
return next((path for path in candidates if path.exists()), candidates[0])
|
||||
|
||||
|
||||
class _Api:
|
||||
def __init__(self) -> None:
|
||||
path = _library_path()
|
||||
if sys.platform != "win32" or not path.exists():
|
||||
raise CandleError(f"Candle API недоступен: {path}")
|
||||
self.dll = ct.WinDLL(str(path))
|
||||
handle = ct.c_void_p
|
||||
self._fn("candle_list_scan", [ct.POINTER(handle)])
|
||||
self._fn("candle_list_free", [handle])
|
||||
self._fn("candle_list_length", [handle, ct.POINTER(ct.c_uint8)])
|
||||
self._fn("candle_dev_get", [handle, ct.c_uint8, ct.POINTER(handle)])
|
||||
self._fn("candle_dev_get_path", [handle], ct.c_wchar_p)
|
||||
self._fn("candle_dev_open", [handle])
|
||||
self._fn("candle_dev_close", [handle])
|
||||
self._fn("candle_dev_free", [handle])
|
||||
self._fn("candle_dev_last_error", [handle], ct.c_int)
|
||||
self._fn("candle_channel_count", [handle, ct.POINTER(ct.c_uint8)])
|
||||
self._fn("candle_channel_set_bitrate", [handle, ct.c_uint8, ct.c_uint32])
|
||||
self._fn("candle_channel_start", [handle, ct.c_uint8, ct.c_uint32])
|
||||
self._fn("candle_channel_stop", [handle, ct.c_uint8])
|
||||
self._fn("candle_frame_send", [handle, ct.c_uint8, ct.POINTER(_Frame)])
|
||||
self._fn("candle_frame_read", [handle, ct.POINTER(_Frame), ct.c_uint32])
|
||||
self._fn("candle_frame_type", [ct.POINTER(_Frame)], ct.c_int)
|
||||
self._fn("candle_frame_id", [ct.POINTER(_Frame)], ct.c_uint32)
|
||||
|
||||
def _fn(self, name: str, args: list, result=ct.c_bool) -> None:
|
||||
function = getattr(self.dll, name)
|
||||
function.argtypes = args
|
||||
function.restype = result
|
||||
|
||||
def scan(self) -> list[CandleChannel]:
|
||||
result: list[CandleChannel] = []
|
||||
physical_devices: set[str] = set()
|
||||
listing = ct.c_void_p()
|
||||
if not self.dll.candle_list_scan(ct.byref(listing)):
|
||||
raise CandleError("Не удалось выполнить поиск candle-адаптеров")
|
||||
try:
|
||||
count = ct.c_uint8()
|
||||
if not self.dll.candle_list_length(listing, ct.byref(count)):
|
||||
raise CandleError("Candle API не вернул список устройств")
|
||||
for index in range(count.value):
|
||||
device = ct.c_void_p()
|
||||
if not self.dll.candle_dev_get(listing, index, ct.byref(device)):
|
||||
continue
|
||||
try:
|
||||
if not self.dll.candle_dev_open(device):
|
||||
continue
|
||||
channels = ct.c_uint8()
|
||||
if self.dll.candle_channel_count(device, ct.byref(channels)):
|
||||
path = self.dll.candle_dev_get_path(device) or ""
|
||||
# Composite gs_usb devices expose MI_00, MI_02, ... as
|
||||
# separate Windows paths although each path reports all
|
||||
# CAN channels. CANgaroo folds them into one device.
|
||||
key = re.sub(r"&mi_[0-9a-f]+", "", path.casefold())
|
||||
key = re.sub(r"&[0-9a-f]{4}(?=#\{)", "", key)
|
||||
if key not in physical_devices:
|
||||
physical_devices.add(key)
|
||||
result.extend(CandleChannel(path, channel)
|
||||
for channel in range(channels.value))
|
||||
self.dll.candle_dev_close(device)
|
||||
finally:
|
||||
self.dll.candle_dev_free(device)
|
||||
finally:
|
||||
self.dll.candle_list_free(listing)
|
||||
return result
|
||||
|
||||
def acquire(self, wanted_path: str) -> ct.c_void_p:
|
||||
listing = ct.c_void_p()
|
||||
if not self.dll.candle_list_scan(ct.byref(listing)):
|
||||
raise CandleError("Не удалось обновить список candle-адаптеров")
|
||||
try:
|
||||
count = ct.c_uint8()
|
||||
self.dll.candle_list_length(listing, ct.byref(count))
|
||||
for index in range(count.value):
|
||||
device = ct.c_void_p()
|
||||
if not self.dll.candle_dev_get(listing, index, ct.byref(device)):
|
||||
continue
|
||||
path = self.dll.candle_dev_get_path(device) or ""
|
||||
if path.casefold() == wanted_path.casefold():
|
||||
return device
|
||||
self.dll.candle_dev_free(device)
|
||||
finally:
|
||||
self.dll.candle_list_free(listing)
|
||||
raise CandleError("Выбранный candle-адаптер больше не подключён")
|
||||
|
||||
|
||||
class CandleAdapter(QObject):
|
||||
"""One opened classic-CAN channel with a background RX loop."""
|
||||
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._api: _Api | None = None
|
||||
self._device: ct.c_void_p | None = None
|
||||
self._channel = 0
|
||||
self._stop = threading.Event()
|
||||
self._reader: threading.Thread | None = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._listen_only = False
|
||||
|
||||
def _get_api(self) -> _Api:
|
||||
if self._api is None:
|
||||
self._api = _Api()
|
||||
return self._api
|
||||
|
||||
def scan(self) -> list[CandleChannel]:
|
||||
return self._get_api().scan()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._device is not None
|
||||
|
||||
def connect_channel(self, channel: CandleChannel, bitrate: int,
|
||||
listen_only: bool = False) -> None:
|
||||
self.disconnect_channel()
|
||||
api = self._get_api()
|
||||
device = api.acquire(channel.path)
|
||||
try:
|
||||
if not api.dll.candle_dev_open(device):
|
||||
raise CandleError("Не удалось открыть candle-адаптер")
|
||||
if not api.dll.candle_channel_set_bitrate(device, channel.channel, bitrate):
|
||||
raise CandleError(f"Адаптер не поддерживает {bitrate} бит/с")
|
||||
mode = _MODE_LISTEN_ONLY if listen_only else 0
|
||||
if not api.dll.candle_channel_start(device, channel.channel, mode):
|
||||
raise CandleError("Не удалось запустить CAN-канал")
|
||||
except Exception:
|
||||
api.dll.candle_dev_close(device)
|
||||
api.dll.candle_dev_free(device)
|
||||
raise
|
||||
self._device = device
|
||||
self._channel = channel.channel
|
||||
self._listen_only = listen_only
|
||||
self._stop.clear()
|
||||
self._reader = threading.Thread(target=self._read_loop,
|
||||
name="candle-rx", daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def disconnect_channel(self) -> None:
|
||||
device, self._device = self._device, None
|
||||
if device is None:
|
||||
return
|
||||
self._stop.set()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=0.3)
|
||||
api = self._get_api()
|
||||
api.dll.candle_channel_stop(device, self._channel)
|
||||
api.dll.candle_dev_close(device)
|
||||
api.dll.candle_dev_free(device)
|
||||
self._reader = None
|
||||
self._listen_only = False
|
||||
|
||||
def send(self, can_id: int, data: bytes) -> None:
|
||||
device = self._device
|
||||
if device is None:
|
||||
raise CandleError("Candle-адаптер не подключён")
|
||||
if self._listen_only:
|
||||
raise CandleError("Candle-адаптер открыт в режиме только приёма")
|
||||
if len(data) > 8:
|
||||
raise CandleError("Classic CAN поддерживает не более 8 байт")
|
||||
frame = _Frame()
|
||||
frame.can_id = (can_id & 0x1FFFFFFF) | _EXTENDED_ID
|
||||
frame.can_dlc = len(data)
|
||||
frame.channel = self._channel
|
||||
frame.data[:len(data)] = data
|
||||
with self._send_lock:
|
||||
if not self._get_api().dll.candle_frame_send(
|
||||
device, self._channel, ct.byref(frame)):
|
||||
raise CandleError("Candle API не передал CAN-кадр")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
device = self._device
|
||||
if device is None:
|
||||
return
|
||||
api = self._get_api()
|
||||
while not self._stop.is_set() and self._device is device:
|
||||
frame = _Frame()
|
||||
if not api.dll.candle_frame_read(device, ct.byref(frame), 50):
|
||||
continue
|
||||
if (api.dll.candle_frame_type(ct.byref(frame)) == _FRAME_RECEIVE
|
||||
and frame.channel == self._channel):
|
||||
size = min(frame.can_dlc, 8)
|
||||
self.capture_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
|
||||
bytes(frame.data[:size]), bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
|
||||
self.frame_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
|
||||
bytes(frame.data[:size]))
|
||||
|
||||
def close(self) -> None:
|
||||
self.disconnect_channel()
|
||||
187
python/set_devices/qt_ports/mock_port.py
Normal file
187
python/set_devices/qt_ports/mock_port.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""@file mock_port.py
|
||||
@brief Qt-таймер, имитирующий подключённый контроллер без COM-порта.
|
||||
|
||||
Реализует mock-устройство для тестирования и разработки без физического
|
||||
оборудования. Использует детерминированные значения для надёжного тестирования.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .qt_compat import QElapsedTimer, QObject, QTimer, Signal
|
||||
|
||||
from set_devices.ds18b20 import MockSensorBus
|
||||
from set_devices.eeprom import CatalogEntry, MemoryInfo
|
||||
from set_devices.models import MockSignalSource, SignalCatalog
|
||||
from set_devices.panel import MockPanel
|
||||
|
||||
|
||||
class MockDevicePort(QObject):
|
||||
"""@brief Асинхронный mock-порт с интерфейсом реального порта.
|
||||
|
||||
Qt-таймер является платформенным планировщиком, а генерация значений
|
||||
делегируется переносимому ``MockSignalSource``.
|
||||
"""
|
||||
|
||||
connected_changed = Signal(bool, str)
|
||||
snapshot_received = Signal(object)
|
||||
sensors_received = Signal(object)
|
||||
memory_info_received = Signal(object)
|
||||
catalog_received = Signal(object)
|
||||
sensor_list_received = Signal(object)
|
||||
panel_received = Signal(object)
|
||||
tx_logged = Signal(str)
|
||||
rx_logged = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, catalog: SignalCatalog, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._source = MockSignalSource(catalog)
|
||||
self._bus = MockSensorBus()
|
||||
# Каталог mock-прибора: два датчика уже разложены по позициям одной
|
||||
# сборки (assembly_serial=7), третий остаётся без записи.
|
||||
self._catalog = [
|
||||
CatalogEntry(rom=self._bus.roms()[0], position=1, assembly_serial=7),
|
||||
CatalogEntry(rom=self._bus.roms()[2], position=258, assembly_serial=7),
|
||||
]
|
||||
self._panel = MockPanel(self._panel_rows)
|
||||
self._elapsed = QElapsedTimer()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._publish_snapshot)
|
||||
|
||||
def open(self) -> None:
|
||||
"""@brief Запускает генерацию четырёх снимков в секунду.
|
||||
|
||||
Повторный вызов безопасен и не создаёт второй таймер.
|
||||
"""
|
||||
if self._timer.isActive():
|
||||
return
|
||||
self._elapsed.start()
|
||||
self._timer.start()
|
||||
self.connected_changed.emit(True, "MOCK")
|
||||
self.rx_logged.emit("Mock-контроллер подключён")
|
||||
self._publish_snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Останавливает генератор и публикует отключение.
|
||||
|
||||
Повторное закрытие не создаёт ложное событие изменения состояния.
|
||||
"""
|
||||
was_active = self._timer.isActive()
|
||||
self._timer.stop()
|
||||
if was_active:
|
||||
self.connected_changed.emit(False, "MOCK")
|
||||
|
||||
def scan_sensors(self) -> None:
|
||||
"""@brief Публикует список ROM mock-шины 1-Wire.
|
||||
|
||||
Поиск выполняется мгновенно: модель шины известна заранее.
|
||||
"""
|
||||
self.tx_logged.emit("SENSOR_SCAN mock")
|
||||
self.sensor_list_received.emit(self._bus.roms())
|
||||
self.publish_sensors()
|
||||
|
||||
def publish_sensors(self) -> None:
|
||||
"""@brief Публикует очередной набор измерений DS18B20."""
|
||||
self.sensors_received.emit(self._bus.snapshot(self._timestamp_s()))
|
||||
|
||||
def scan_memory(self) -> None:
|
||||
"""@brief Публикует состояние mock-памяти и каталога позиций."""
|
||||
self.tx_logged.emit("EEPROM_SCAN mock")
|
||||
self.memory_info_received.emit(
|
||||
MemoryInfo(
|
||||
present=True,
|
||||
persistent=True,
|
||||
count=len(self._catalog),
|
||||
capacity=32,
|
||||
i2c_address=0x50,
|
||||
page_size=64,
|
||||
size=32768,
|
||||
base_address=1024,
|
||||
bus_errors=0,
|
||||
blob_size=332,
|
||||
)
|
||||
)
|
||||
|
||||
def read_catalog(self) -> None:
|
||||
"""@brief Публикует записи каталога mock-прибора."""
|
||||
self.tx_logged.emit("EEPROM_READ mock")
|
||||
self.catalog_received.emit(list(self._catalog))
|
||||
|
||||
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
|
||||
"""@brief Применяет пользовательские байты к mock-датчику.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param user_byte1 Новое значение TH.
|
||||
@param user_byte2 Новое значение TL.
|
||||
"""
|
||||
try:
|
||||
self._bus.set_user_bytes(rom, user_byte1, user_byte2)
|
||||
except (KeyError, ValueError) as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"SET_USER_BYTES mock {user_byte1:#04x} {user_byte2:#04x}")
|
||||
self.publish_sensors()
|
||||
|
||||
def set_resolution(self, rom: bytes, bits: int) -> None:
|
||||
"""@brief Меняет разрешение mock-датчика.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
"""
|
||||
try:
|
||||
self._bus.set_resolution(rom, bits)
|
||||
except (KeyError, ValueError) as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"SET_RESOLUTION mock {bits} бит")
|
||||
self.publish_sensors()
|
||||
|
||||
def read_panel(self) -> None:
|
||||
"""@brief Публикует снимок экрана mock-панели."""
|
||||
self.panel_received.emit(self._panel.screen())
|
||||
|
||||
def press_panel_key(self, key: int, hold: bool = False) -> None:
|
||||
"""@brief Применяет нажатие кнопки к mock-панели.
|
||||
|
||||
@param key Код кнопки из PanelKey.
|
||||
@param hold Длинное нажатие.
|
||||
"""
|
||||
self._panel.press(key, hold)
|
||||
self.tx_logged.emit(f"UI_KEY mock {int(key)}{' hold' if hold else ''}")
|
||||
self.read_panel()
|
||||
|
||||
def _panel_rows(self) -> list[tuple[str, str]]:
|
||||
"""Строки экрана датчиков mock-панели: номер и температура."""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for index, reading in enumerate(self._bus.snapshot(self._timestamp_s())):
|
||||
temperature = reading.temperature
|
||||
rows.append((
|
||||
f"{index + 1} {reading.serial}",
|
||||
"---" if temperature is None else f"{temperature:+.1f}",
|
||||
))
|
||||
return rows
|
||||
|
||||
def set_output(self, key: str, value: bool) -> None:
|
||||
"""@brief Применяет управляющую дискрету к mock-модели.
|
||||
|
||||
@param key Ключ разрешённого выхода.
|
||||
@param value Новое состояние; ошибка неизвестного ключа идёт в signal.
|
||||
"""
|
||||
try:
|
||||
self._source.set_output(key, value)
|
||||
except KeyError as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"{key} = {int(value)}")
|
||||
self._publish_snapshot()
|
||||
|
||||
def _timestamp_s(self) -> float:
|
||||
"""Возвращает монотонное время mock-порта в секундах."""
|
||||
return self._elapsed.elapsed() / 1000.0
|
||||
|
||||
def _publish_snapshot(self) -> None:
|
||||
"""@brief Создаёт и публикует атомарный снимок вне UI-виджетов."""
|
||||
timestamp_s = self._timestamp_s()
|
||||
self.snapshot_received.emit(self._source.snapshot(timestamp_s))
|
||||
self.sensors_received.emit(self._bus.snapshot(timestamp_s))
|
||||
7
python/set_devices/qt_ports/qt_compat.py
Normal file
7
python/set_devices/qt_ports/qt_compat.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Qt transport dependencies; importing set_devices itself never loads Qt."""
|
||||
try:
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QElapsedTimer
|
||||
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, Signal, QTimer, QElapsedTimer
|
||||
from PySide2.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
212
python/set_devices/qt_ports/serial_port.py
Normal file
212
python/set_devices/qt_ports/serial_port.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""@file serial_port.py
|
||||
@brief Неблокирующий QSerialPort и потоковый parser GUI transport.
|
||||
|
||||
Реализует асинхронный интерфейс к последовательному порту с полностью
|
||||
неблокирующей обработкой данных и автоматическим разбором кадров протокола.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from set_devices.protocol_capture import uart_event
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
from set_devices.protocol import Frame, MessageType, build_frame
|
||||
from set_devices.protocol_router import ProtocolMode, ProtocolRouter
|
||||
from setprotocol.core import (
|
||||
Frame as SetFrame,
|
||||
FrameFlag as SetFrameFlag,
|
||||
MessageType as SetMessageType,
|
||||
build_frame as build_set_frame,
|
||||
)
|
||||
|
||||
|
||||
class SerialDevicePort(QObject):
|
||||
"""@brief Реальный COM-порт без блокирующих ожиданий в GUI thread.
|
||||
|
||||
Управляет связью с устройством через последовательный порт, обеспечивая
|
||||
асинхронный обмен данными и передачу кадров по протоколу GUI.
|
||||
"""
|
||||
|
||||
connected_changed = Signal(bool, str)
|
||||
frame_received = Signal(object)
|
||||
tx_logged = Signal(str)
|
||||
capture_event = Signal(object)
|
||||
rx_logged = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
protocol_changed = Signal(object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._router = ProtocolRouter()
|
||||
self._sequence = 0
|
||||
self._serial.readyRead.connect(self._read_available)
|
||||
self._serial.errorOccurred.connect(self._handle_error)
|
||||
|
||||
@staticmethod
|
||||
def available_ports() -> list[str]:
|
||||
"""Возвращает стабильный отсортированный список имён COM-портов."""
|
||||
return [name for name, _description in SerialDevicePort.available_port_infos()]
|
||||
|
||||
@staticmethod
|
||||
def available_port_infos() -> list[tuple[str, str]]:
|
||||
"""@brief Перечисляет COM-порты вместе с описанием драйвера.
|
||||
|
||||
Порядок естественный: COM3 идёт перед COM10, поэтому список не
|
||||
перестраивается при подключении новых преобразователей.
|
||||
|
||||
@return Пары «имя порта, описание устройства».
|
||||
"""
|
||||
infos = [
|
||||
(info.portName(), info.description() or info.manufacturer())
|
||||
for info in QSerialPortInfo.availablePorts()
|
||||
]
|
||||
return sorted(infos, key=lambda item: SerialDevicePort._sort_key(item[0]))
|
||||
|
||||
@staticmethod
|
||||
def _sort_key(port_name: str) -> tuple[str, int, str]:
|
||||
"""Разделяет имя порта на буквенный префикс и числовой индекс."""
|
||||
digits = "".join(char for char in port_name if char.isdigit())
|
||||
prefix = port_name[: len(port_name) - len(digits)] if digits else port_name
|
||||
return (prefix.upper(), int(digits) if digits else 0, port_name)
|
||||
|
||||
def open(self, port_name: str, baud_rate: int) -> bool:
|
||||
"""Открывает 8N1 без flow control; результат сообщает синхронную ошибку."""
|
||||
if not port_name:
|
||||
self.error_occurred.emit("COM-порт не выбран")
|
||||
return False
|
||||
self.close()
|
||||
self._serial.setPortName(port_name)
|
||||
self._serial.setBaudRate(baud_rate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.set_protocol_mode(ProtocolMode.AUTO)
|
||||
self.connected_changed.emit(True, port_name)
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Признак открытого физического COM-порта."""
|
||||
return self._serial.isOpen()
|
||||
|
||||
@property
|
||||
def port_name(self) -> str:
|
||||
"""Имя текущего COM-порта, например ``COM11``."""
|
||||
return self._serial.portName()
|
||||
|
||||
@property
|
||||
def baud_rate(self) -> int:
|
||||
"""Фактически установленная скорость последовательного порта."""
|
||||
return self._serial.baudRate()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрывает порт и не оставляет активных Qt-операций чтения."""
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
name = self._serial.portName()
|
||||
self._serial.close()
|
||||
self.connected_changed.emit(False, name)
|
||||
|
||||
def send(self, frame: Frame) -> bool:
|
||||
"""Ставит целый кадр в буфер Qt без ожидания физической передачи."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
packet = build_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def send_set(self, frame: SetFrame) -> bool:
|
||||
"""Передаёт SETProtocol v2 кадр, в том числе пробный кадр AUTO."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
if self.protocol_mode is ProtocolMode.GUI_V1:
|
||||
self.error_occurred.emit("Канал уже работает в режиме GUI protocol v1")
|
||||
return False
|
||||
packet = build_set_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def next_sequence(self) -> int:
|
||||
"""Выделяет очередной sequence для адаптера транзакций."""
|
||||
self._sequence = (self._sequence + 1) & 0xFFFF
|
||||
return self._sequence
|
||||
|
||||
def send_message(self, message_type: MessageType, payload: bytes = b"") -> int | None:
|
||||
"""Передаёт запрос и возвращает его sequence либо ``None``."""
|
||||
sequence = self.next_sequence()
|
||||
return sequence if self.send(Frame(message_type, sequence, payload)) else None
|
||||
|
||||
def send_set_message(
|
||||
self,
|
||||
message_type: int | SetMessageType,
|
||||
payload: bytes = b"",
|
||||
*,
|
||||
flags: SetFrameFlag = SetFrameFlag.ACK_REQUIRED,
|
||||
destination: int = 0,
|
||||
) -> int | None:
|
||||
"""Передаёт запрос SETProtocol v2 и возвращает sequence."""
|
||||
sequence = self.next_sequence()
|
||||
frame = SetFrame(
|
||||
message_type,
|
||||
sequence,
|
||||
payload,
|
||||
flags=flags,
|
||||
destination=destination,
|
||||
)
|
||||
return sequence if self.send_set(frame) else None
|
||||
|
||||
@property
|
||||
def protocol_mode(self) -> ProtocolMode:
|
||||
return self._router.mode
|
||||
|
||||
def set_protocol_mode(self, mode: ProtocolMode) -> None:
|
||||
"""Сбрасывает parser-ы и выбирает режим текущего соединения."""
|
||||
changed = self._router.mode is not mode
|
||||
self._router.select(mode)
|
||||
if changed or mode is ProtocolMode.AUTO:
|
||||
self.protocol_changed.emit(mode)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Отправляет совместимый PING с новым 16-битным sequence."""
|
||||
return self.send_message(MessageType.PING) is not None
|
||||
|
||||
def _read_available(self) -> None:
|
||||
"""Выгружает доступный фрагмент и передаёт parser-у без блокировки."""
|
||||
data = bytes(self._serial.readAll())
|
||||
if not data:
|
||||
return
|
||||
self.rx_logged.emit(data.hex(" ").upper())
|
||||
self.capture_event.emit(uart_event('SET', 'RX', data, self.baud_rate))
|
||||
previous_mode = self._router.mode
|
||||
frames = self._router.feed(data)
|
||||
if self._router.mode is not previous_mode:
|
||||
self.protocol_changed.emit(self._router.mode)
|
||||
for frame in frames:
|
||||
self.frame_received.emit(frame)
|
||||
|
||||
def _handle_error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
"""Публикует только реальные ошибки, игнорируя NoError."""
|
||||
if error == QSerialPort.SerialPortError.NoError:
|
||||
return
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Serial Line CAN (Lawicel/SLCAN) transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
|
||||
class SlcanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlcanChannel:
|
||||
path: str
|
||||
channel: int = 0
|
||||
description: str = ""
|
||||
manufacturer: str = ""
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
details = self.description or self.manufacturer or "COM-порт"
|
||||
return f"{self.path} — {details}"
|
||||
|
||||
@property
|
||||
def adapter_kind(self) -> str:
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
|
||||
"""Encode one classic-CAN frame in Lawicel ASCII format."""
|
||||
if len(data) > 8:
|
||||
raise SlcanError("Classic CAN поддерживает не более 8 байт")
|
||||
if not 0 <= can_id <= 0x1FFFFFFF:
|
||||
raise SlcanError("CAN ID вне диапазона")
|
||||
if extended is None:
|
||||
extended = can_id > 0x7FF
|
||||
if not extended:
|
||||
if can_id > 0x7FF:
|
||||
raise SlcanError("Стандартный CAN ID вне диапазона")
|
||||
return f"t{can_id:03X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
|
||||
return f"T{can_id:08X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
|
||||
|
||||
|
||||
def decode_frame(line: bytes) -> tuple[int, bytes] | None:
|
||||
"""Decode a received SLCAN data frame; ignore ACK/status/RTR records."""
|
||||
if not line or line[:1] not in (b"t", b"T"):
|
||||
return None
|
||||
extended = line[:1] == b"T"
|
||||
id_size = 8 if extended else 3
|
||||
try:
|
||||
can_id = int(line[1:1 + id_size], 16)
|
||||
size = int(line[1 + id_size:2 + id_size], 16)
|
||||
start = 2 + id_size
|
||||
payload = bytes.fromhex(line[start:start + size * 2].decode("ascii"))
|
||||
except (ValueError, UnicodeError):
|
||||
return None
|
||||
maximum = 0x1FFFFFFF if extended else 0x7FF
|
||||
if can_id > maximum or size > 8 or len(payload) != size:
|
||||
return None
|
||||
return can_id, payload
|
||||
|
||||
|
||||
class SlcanAdapter(QObject):
|
||||
"""One SLCAN COM port using the usual 115200 8N1 host connection."""
|
||||
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._buffer = bytearray()
|
||||
self._listen_only = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._serial.isOpen()
|
||||
|
||||
def scan(self) -> list[SlcanChannel]:
|
||||
# Windows не предоставляет надёжного признака протокола SLCAN.
|
||||
# Показываем COM-кандидаты только внутри явно выбранного режима SLCAN.
|
||||
return [SlcanChannel(info.portName(), description=info.description(),
|
||||
manufacturer=info.manufacturer())
|
||||
for info in QSerialPortInfo.availablePorts()]
|
||||
|
||||
def connect_channel(self, channel: SlcanChannel, bitrate: int,
|
||||
host_baudrate: int = 115200,
|
||||
listen_only: bool = False) -> None:
|
||||
self.disconnect_channel()
|
||||
command = _BITRATE_COMMANDS.get(bitrate)
|
||||
if command is None:
|
||||
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
|
||||
self._serial.setPortName(channel.path)
|
||||
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
|
||||
230400, 460800, 921600):
|
||||
raise SlcanError(f"Недопустимая скорость COM: {host_baudrate}")
|
||||
self._serial.setBaudRate(host_baudrate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
|
||||
self._buffer.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}")
|
||||
|
||||
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
|
||||
|
||||
def send(self, can_id: int, data: bytes) -> None:
|
||||
if not self.connected:
|
||||
raise SlcanError("SLCAN-адаптер не подключён")
|
||||
if self._listen_only:
|
||||
raise SlcanError("SLCAN-адаптер открыт в режиме только приёма")
|
||||
# The adapter contract is used by ProtoCAN, whose IDs are always EXT,
|
||||
# including the numerically small values that would also fit in 11 bits.
|
||||
packet = encode_frame(can_id, data, extended=True)
|
||||
if self._serial.write(packet) != len(packet):
|
||||
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)
|
||||
|
||||
def _error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
if error in (QSerialPort.SerialPortError.NoError,
|
||||
QSerialPort.SerialPortError.NotOpenError):
|
||||
return
|
||||
if self._serial.isOpen():
|
||||
message = self._serial.errorString()
|
||||
self._serial.close()
|
||||
self._listen_only = False
|
||||
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
|
||||
|
||||
def close(self) -> None:
|
||||
self.disconnect_channel()
|
||||
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal file
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Non-blocking client for the STM32 ROM UART bootloader described by AN3155."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Callable
|
||||
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
|
||||
from set_devices.firmware import FirmwareImage
|
||||
from protocan.stm32_boot import (
|
||||
ACK, NACK, SYNC, address, command, erase_pages_payload, write_payload,
|
||||
)
|
||||
|
||||
|
||||
class Stm32Bootloader(QObject):
|
||||
"""Programs STM32 system Flash over the factory UART bootloader (8E1)."""
|
||||
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read_available)
|
||||
self._serial.errorOccurred.connect(self._serial_error)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(lambda: self._fail("Нет ответа STM32 bootloader"))
|
||||
self._rx = bytearray()
|
||||
self._steps: deque[tuple[bytes, int, str, Callable[[], None]]] = deque()
|
||||
self._waiting: tuple[int, str, Callable[[], None]] | None = None
|
||||
self._image: FirmwareImage | None = None
|
||||
self._offset = 0
|
||||
self._cancelled = False
|
||||
|
||||
def start(self, image: FirmwareImage, port_name: str, baud_rate: int) -> None:
|
||||
if self._serial.isOpen() or self._waiting is not None:
|
||||
self.finished.emit(False, "Прошивка STM32 уже выполняется")
|
||||
return
|
||||
if not port_name or baud_rate <= 0:
|
||||
self.finished.emit(False, "Неверные настройки UART")
|
||||
return
|
||||
if not 0x08000000 <= image.base_address <= 0x080FFFFF:
|
||||
self.finished.emit(False, "Для STM32 укажите адрес Flash, например 0x08000000")
|
||||
return
|
||||
if image.base_address + len(image.data) > 0x08100000:
|
||||
self.finished.emit(False, "Образ выходит за допустимый диапазон Flash STM32")
|
||||
return
|
||||
self._serial.setPortName(port_name)
|
||||
self._serial.setBaudRate(baud_rate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.EvenParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self.finished.emit(False, f"Не удалось открыть {port_name}: {self._serial.errorString()}")
|
||||
return
|
||||
self._image = image
|
||||
self._offset = 0
|
||||
self._cancelled = False
|
||||
self._rx.clear()
|
||||
self._steps.clear()
|
||||
self.progress.emit(0, "Синхронизация с STM32 (BOOT0=1)")
|
||||
self._queue(SYNC, 1500, "синхронизации", self._erase)
|
||||
self._next()
|
||||
|
||||
def cancel(self) -> None:
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
self._cancelled = True
|
||||
self._finish(False, "Операция отменена")
|
||||
|
||||
def _queue(self, packet: bytes, timeout_ms: int, stage: str,
|
||||
callback: Callable[[], None]) -> None:
|
||||
self._steps.append((packet, timeout_ms, stage, callback))
|
||||
|
||||
def _next(self) -> None:
|
||||
if self._waiting is not None or not self._steps or self._cancelled:
|
||||
return
|
||||
packet, timeout_ms, stage, callback = self._steps.popleft()
|
||||
if self._serial.write(packet) != len(packet):
|
||||
self._fail(f"Ошибка передачи на этапе {stage}")
|
||||
return
|
||||
self._waiting = (timeout_ms, stage, callback)
|
||||
self._timer.start(timeout_ms)
|
||||
|
||||
def _erase(self) -> None:
|
||||
self.progress.emit(1, "Стирание Flash")
|
||||
self._queue(command(0x43), 1500, "команды Erase", self._erase_payload)
|
||||
self._next()
|
||||
|
||||
def _erase_payload(self) -> None:
|
||||
assert self._image is not None
|
||||
first = (self._image.base_address - 0x08000000) // 1024
|
||||
last = (self._image.base_address + len(self._image.data) - 1 - 0x08000000) // 1024
|
||||
pages = list(range(first, last + 1))
|
||||
self._queue(erase_pages_payload(pages), 20000, "стирания страниц Flash",
|
||||
self._write_next)
|
||||
self._next()
|
||||
|
||||
def _write_next(self) -> None:
|
||||
image = self._image
|
||||
if image is None:
|
||||
return
|
||||
if self._offset >= len(image.data):
|
||||
self.progress.emit(100, "Запуск приложения")
|
||||
self._queue(command(0x21), 1500, "команды Go", self._go_address)
|
||||
self._next()
|
||||
return
|
||||
chunk = image.data[self._offset:self._offset + 256]
|
||||
# STM32 Flash is programmed by words; erased bytes safely pad the tail.
|
||||
if len(chunk) & 3:
|
||||
chunk += b"\xff" * (4 - (len(chunk) & 3))
|
||||
self._queue(command(0x31), 1500, "команды Write Memory", self._write_address)
|
||||
self._next()
|
||||
|
||||
def _write_address(self) -> None:
|
||||
assert self._image is not None
|
||||
self._queue(address(self._image.base_address + self._offset), 1500,
|
||||
"адреса блока", self._write_data)
|
||||
self._next()
|
||||
|
||||
def _write_data(self) -> None:
|
||||
assert self._image is not None
|
||||
chunk = self._image.data[self._offset:self._offset + 256]
|
||||
actual = len(chunk)
|
||||
if len(chunk) & 3:
|
||||
chunk += b"\xff" * (4 - (len(chunk) & 3))
|
||||
|
||||
def accepted() -> None:
|
||||
self._offset += actual
|
||||
percent = int(self._offset * 100 / len(self._image.data))
|
||||
self.progress.emit(percent, f"Записано {self._offset} байт")
|
||||
self._write_next()
|
||||
|
||||
self._queue(write_payload(chunk), 2500, "записи блока", accepted)
|
||||
self._next()
|
||||
|
||||
def _go_address(self) -> None:
|
||||
assert self._image is not None
|
||||
self._queue(address(self._image.base_address), 1500, "адреса запуска",
|
||||
lambda: self._finish(True, "Прошивка STM32 завершена, приложение запущено"))
|
||||
self._next()
|
||||
|
||||
def _read_available(self) -> None:
|
||||
self._rx.extend(bytes(self._serial.readAll()))
|
||||
while self._waiting is not None and self._rx:
|
||||
response = self._rx.pop(0)
|
||||
timeout_ms, stage, callback = self._waiting
|
||||
if response not in (ACK, NACK):
|
||||
continue
|
||||
self._timer.stop()
|
||||
self._waiting = None
|
||||
if response == NACK:
|
||||
self._fail(f"STM32 отклонил операцию на этапе {stage}")
|
||||
return
|
||||
callback()
|
||||
|
||||
def _serial_error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
if error not in (QSerialPort.SerialPortError.NoError,
|
||||
QSerialPort.SerialPortError.TimeoutError):
|
||||
self._fail(self._serial.errorString())
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self._finish(False, message)
|
||||
|
||||
def _finish(self, success: bool, message: str) -> None:
|
||||
self._timer.stop()
|
||||
self._waiting = None
|
||||
self._steps.clear()
|
||||
self._image = None
|
||||
if self._serial.isOpen():
|
||||
self._serial.close()
|
||||
self.progress.emit(100 if success else 0, message)
|
||||
self.finished.emit(success, message)
|
||||
170
python/set_devices/qt_ports/stm_settings.py
Normal file
170
python/set_devices/qt_ports/stm_settings.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Transactional STM configuration extension (FC03/FC06, 0x1210)."""
|
||||
import struct
|
||||
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
from set_devices.tms_terminal import crc16_modbus
|
||||
|
||||
BAUDRATES = (9600, 19200, 38400, 57600, 115200)
|
||||
|
||||
|
||||
def request(unit, function, register, value):
|
||||
body = struct.pack(">BBHH", unit, function, register, value)
|
||||
return body + struct.pack("<H", crc16_modbus(body))
|
||||
|
||||
|
||||
def decode_config(raw):
|
||||
if len(raw) != 25:
|
||||
raise ValueError("Нужна прошивка STM с выбором режима эмуляции")
|
||||
words = struct.unpack(">10H", raw[3:-2])
|
||||
if words[:2] != (0x5343, 2):
|
||||
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
|
||||
address, tms, rate = words[2:5]
|
||||
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > 1 or words[9] > 1:
|
||||
raise ValueError("STM вернула неверные параметры связи")
|
||||
return words
|
||||
|
||||
|
||||
class StmSettingsClient(QObject):
|
||||
finished = Signal(bool, str, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.serial = QSerialPort(self)
|
||||
self.serial.readyRead.connect(self._receive)
|
||||
self.serial.errorOccurred.connect(self._error)
|
||||
self.timeout = QTimer(self)
|
||||
self.timeout.setSingleShot(True)
|
||||
self.timeout.timeout.connect(lambda: self._finish(False, "Тайм-аут настройки STM"))
|
||||
self.delay = QTimer(self)
|
||||
self.delay.setSingleShot(True)
|
||||
self.delay.timeout.connect(self._next)
|
||||
self.active = False
|
||||
self.committed = False
|
||||
self.rx = bytearray()
|
||||
|
||||
def start(self, port, unit, baud, timeout, desired=None):
|
||||
if self.active:
|
||||
return
|
||||
if desired is not None:
|
||||
addr, tms, rate, mode = desired
|
||||
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1):
|
||||
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
|
||||
return
|
||||
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
|
||||
self.desired = desired
|
||||
self.active, self.committed = True, False
|
||||
self.stage = "probe"
|
||||
self.serial.setPortName(port)
|
||||
self.serial.setBaudRate(baud)
|
||||
self.serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self.serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self._finish(False, self.serial.errorString())
|
||||
return
|
||||
self._send(3, 0x1210, 10)
|
||||
|
||||
def _send(self, function, register, value):
|
||||
self.rx.clear()
|
||||
self.packet = request(self.unit, function, register, value)
|
||||
self.timeout.start(self.timeout_ms)
|
||||
if self.stage == "commit":
|
||||
# If ACK is lost, the board may nevertheless switch settings.
|
||||
self.committed = True
|
||||
if self.serial.write(self.packet) != len(self.packet):
|
||||
self._finish(False, "Ошибка передачи настройки STM")
|
||||
|
||||
def _receive(self):
|
||||
chunk = bytes(self.serial.readAll())
|
||||
if not self.active or not self.timeout.isActive():
|
||||
return
|
||||
self.rx.extend(chunk)
|
||||
function = self.packet[1]
|
||||
while len(self.rx) >= 2:
|
||||
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80):
|
||||
del self.rx[0]
|
||||
continue
|
||||
size = 5 if self.rx[1] & 0x80 else (25 if function == 3 else 8)
|
||||
if len(self.rx) < size:
|
||||
return
|
||||
raw = bytes(self.rx[:size])
|
||||
del self.rx[:size]
|
||||
if crc16_modbus(raw):
|
||||
self._finish(False, "Неверная CRC ответа STM")
|
||||
return
|
||||
if raw[1] & 0x80:
|
||||
self._finish(False, "STM отклонила настройку, код %d. Для старой прошивки требуется обновление." % raw[2])
|
||||
return
|
||||
if (function == 6 and raw != self.packet) or (function == 3 and raw[2] != 20):
|
||||
self._finish(False, "Неверный ответ настройки STM")
|
||||
return
|
||||
self.timeout.stop()
|
||||
try:
|
||||
self._accept(raw)
|
||||
except ValueError as error:
|
||||
self._finish(False, str(error))
|
||||
return
|
||||
|
||||
def _accept(self, raw):
|
||||
if self.stage == "probe":
|
||||
words = decode_config(raw)
|
||||
if self.desired is None:
|
||||
self._finish(True, "Настройки STM прочитаны", (words[2], words[3], BAUDRATES[words[4]], words[8]))
|
||||
return
|
||||
addr, tms, baud, mode = self.desired
|
||||
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
|
||||
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
|
||||
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
|
||||
self.stage = "stage"
|
||||
elif self.stage == "stage" and self.packet[1] == 3:
|
||||
words = decode_config(raw)
|
||||
if (*words[5:8], words[9]) != self.staged:
|
||||
raise ValueError("STM не подтвердила подготовленные настройки")
|
||||
self.stage = "commit"
|
||||
self.steps = [(6, 0x1218, 0xA55A)]
|
||||
elif self.stage == "commit":
|
||||
self.stage = "switch"
|
||||
self.delay.start(700)
|
||||
return
|
||||
elif self.stage == "verify":
|
||||
words = decode_config(raw)
|
||||
if (*words[2:5], words[8]) != self.staged:
|
||||
raise ValueError("Новые настройки STM не подтвердились")
|
||||
self._finish(True, "Настройки применены в STM и проверены. Действуют до перезагрузки платы.", self.desired)
|
||||
return
|
||||
self.delay.start(20)
|
||||
|
||||
def _next(self):
|
||||
if not self.active:
|
||||
return
|
||||
if self.stage == "switch":
|
||||
self.unit, _tms, baud, _mode = self.desired
|
||||
if not self.serial.setBaudRate(baud):
|
||||
self._finish(False, "Не удалось переключить скорость COM")
|
||||
return
|
||||
self.stage = "verify"
|
||||
self._send(3, 0x1210, 10)
|
||||
else:
|
||||
self._send(*self.steps.pop(0))
|
||||
|
||||
def _error(self, error):
|
||||
if self.active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
|
||||
self._finish(False, self.serial.errorString())
|
||||
|
||||
def cancel(self):
|
||||
if self.active:
|
||||
self._finish(False, "Настройка прервана")
|
||||
|
||||
def _finish(self, ok, message, values=None):
|
||||
if not self.active:
|
||||
return
|
||||
self.active = False
|
||||
self.timeout.stop()
|
||||
self.delay.stop()
|
||||
if self.serial.isOpen():
|
||||
self.serial.close()
|
||||
if not ok and self.committed:
|
||||
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812). Повторите чтение по ним или перезагрузите плату." % self.desired
|
||||
self.finished.emit(ok, message, values)
|
||||
137
python/set_devices/qt_ports/tms_bootloader.py
Normal file
137
python/set_devices/qt_ports/tms_bootloader.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Non-blocking port of Gui_Android's INITLOAD / LOAD / TFLASH workflow."""
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
|
||||
from protocan import tms_firmware as wire
|
||||
|
||||
|
||||
class TmsBootloader(QObject):
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._timeout = QTimer(self)
|
||||
self._timeout.setSingleShot(True)
|
||||
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
|
||||
self._settle = QTimer(self)
|
||||
self._settle.setSingleShot(True)
|
||||
self._settle.setInterval(100)
|
||||
self._settle.timeout.connect(self._accept)
|
||||
self._delay = QTimer(self)
|
||||
self._delay.setSingleShot(True)
|
||||
self._delay.setInterval(100)
|
||||
self._delay.timeout.connect(self._advance)
|
||||
self._active = False
|
||||
self._waiting = False
|
||||
self._rx = bytearray()
|
||||
self._stage = ""
|
||||
|
||||
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
target.validate(len(image.data))
|
||||
if not port or baud <= 0:
|
||||
raise ValueError("Неверные настройки COM-порта")
|
||||
except ValueError as error:
|
||||
self.finished.emit(False, str(error))
|
||||
return
|
||||
self._image, self._target = image, target
|
||||
self._active = True
|
||||
self._serial.setPortName(port)
|
||||
self._serial.setBaudRate(baud)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self._finish(False, self._serial.errorString())
|
||||
return
|
||||
self._response = None
|
||||
self._steps = wire.programming_steps(image.data, target)
|
||||
self._advance()
|
||||
|
||||
def _advance(self):
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
request, self._size, timeout, self._stage, percent = self._steps.send(self._response)
|
||||
except StopIteration:
|
||||
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
|
||||
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
|
||||
else "Прошивка завершена, записанные данные проверены")
|
||||
return
|
||||
except (ValueError, RuntimeError) as error:
|
||||
self._finish(False, str(error))
|
||||
return
|
||||
self._command = request[1]
|
||||
self._rx.clear()
|
||||
self._waiting = True
|
||||
self.progress.emit(percent, self._stage)
|
||||
# LOAD is one continuous write, without pauses inside its packet.
|
||||
if self._serial.write(request) != len(request):
|
||||
self._finish(False, "Ошибка передачи " + self._stage)
|
||||
return
|
||||
if self._active:
|
||||
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
|
||||
|
||||
def _read(self):
|
||||
chunk = bytes(self._serial.readAll())
|
||||
if not self._waiting:
|
||||
return
|
||||
self._rx.extend(chunk)
|
||||
header = bytes((self._target.controller, self._command))
|
||||
start = self._rx.find(header)
|
||||
if start < 0:
|
||||
self._rx[:] = self._rx[-1:]
|
||||
return
|
||||
del self._rx[:start]
|
||||
# Some devices send an ACK before an UPLOAD data response.
|
||||
if self._size > 6:
|
||||
for ack_size in (6, 4):
|
||||
if (self._rx[ack_size:ack_size + 2] == header
|
||||
and wire.normalize_reply(bytes(self._rx[:ack_size]), *header, 6)):
|
||||
del self._rx[:ack_size]
|
||||
break
|
||||
if len(self._rx) >= self._size:
|
||||
self._accept()
|
||||
else:
|
||||
self._settle.start()
|
||||
|
||||
def _accept(self):
|
||||
if not self._waiting:
|
||||
return
|
||||
reply = wire.normalize_reply(bytes(self._rx[:self._size]), self._target.controller, self._command, self._size)
|
||||
if reply is None:
|
||||
if len(self._rx) >= self._size:
|
||||
self._finish(False, "Неверный CRC ответа " + self._stage)
|
||||
return
|
||||
self._timeout.stop()
|
||||
self._settle.stop()
|
||||
self._waiting = False
|
||||
self._response = reply
|
||||
self._delay.start()
|
||||
|
||||
def cancel(self):
|
||||
if self._active:
|
||||
self._finish(False, "Операция TMS отменена; запись Flash могла быть начата")
|
||||
|
||||
def _error(self, error):
|
||||
if self._active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
|
||||
self._finish(False, self._serial.errorString())
|
||||
|
||||
def _finish(self, success, message):
|
||||
if not self._active:
|
||||
return
|
||||
self._active = self._waiting = False
|
||||
for timer in (self._timeout, self._settle, self._delay):
|
||||
timer.stop()
|
||||
if self._serial.isOpen():
|
||||
self._serial.close()
|
||||
if success:
|
||||
self.progress.emit(100, message)
|
||||
self.finished.emit(success, message)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user