Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

9
c/candle/CMakeLists.txt Normal file
View 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

File diff suppressed because one or more lines are too long

16
c/candle/README.md Normal file
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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`.

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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)

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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

View 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

View 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

View 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')]

View 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)

View 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
View 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*"]

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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

View 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()

View 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

View 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

View 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

View 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"))

View 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)

View 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-шине")

View 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"

View 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))

View 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

View 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
View 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)))

View 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

View 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)

View 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 []

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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()

View 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))

View 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

View 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())

View 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()

View 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)

View 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)

View 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