diff --git a/c/candle/CMakeLists.txt b/c/candle/CMakeLists.txt
new file mode 100644
index 0000000..e856010
--- /dev/null
+++ b/c/candle/CMakeLists.txt
@@ -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})
diff --git a/c/candle/LICENSE b/c/candle/LICENSE
new file mode 100644
index 0000000..cf8eac1
--- /dev/null
+++ b/c/candle/LICENSE
@@ -0,0 +1,66 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
+0. Additional Definitions.
+
+As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License.
+
+“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
+
+An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
+
+A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”.
+
+The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
+
+The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
+1. Exception to Section 3 of the GNU GPL.
+
+You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
+2. Conveying Modified Versions.
+
+If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
+
+ a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
+ b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
+
+3. Object Code Incorporating Material from Library Header Files.
+
+The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
+ b) Accompany the object code with a copy of the GNU GPL and this license document.
+
+4. Combined Works.
+
+You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
+
+ a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
+ c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
+ d) Do one of the following:
+ 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
+ 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
+ e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
+
+5. Combined Libraries.
+
+You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
+ b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
+
+6. Revised Versions of the GNU Lesser General Public License.
+
+The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
+
+If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
+
diff --git a/c/candle/README.md b/c/candle/README.md
new file mode 100644
index 0000000..34624af
--- /dev/null
+++ b/c/candle/README.md
@@ -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`.
diff --git a/c/candle/candle.c b/c/candle/candle.c
new file mode 100644
index 0000000..5437d97
--- /dev/null
+++ b/c/candle/candle.c
@@ -0,0 +1,1151 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+
+ 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 .
+
+*/
+
+#include "candle.h"
+
+#include
+#include
+#include
+
+#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; iwinUSBHandle, 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; irxurbs[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; ilast_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;
+}
diff --git a/c/candle/candle.def b/c/candle/candle.def
new file mode 100644
index 0000000..02d71f9
--- /dev/null
+++ b/c/candle/candle.def
@@ -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
diff --git a/c/candle/candle.h b/c/candle/candle.h
new file mode 100644
index 0000000..4db70c4
--- /dev/null
+++ b/c/candle/candle.h
@@ -0,0 +1,287 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+ 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 .
+
+*/
+
+#pragma once
+#include
+#include
+
+#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
diff --git a/c/candle/candle_ctrl_req.c b/c/candle/candle_ctrl_req.c
new file mode 100644
index 0000000..8f3a8d2
--- /dev/null
+++ b/c/candle/candle_ctrl_req.c
@@ -0,0 +1,234 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+
+ 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 .
+
+*/
+
+#include "candle_ctrl_req.h"
+#include "ch_9.h"
+
+#include
+
+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;
+}
diff --git a/c/candle/candle_ctrl_req.h b/c/candle/candle_ctrl_req.h
new file mode 100644
index 0000000..b3208c6
--- /dev/null
+++ b/c/candle/candle_ctrl_req.h
@@ -0,0 +1,49 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+ 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 .
+
+*/
+
+#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);
+
diff --git a/c/candle/candle_defs.h b/c/candle/candle_defs.h
new file mode 100644
index 0000000..2ba93d5
--- /dev/null
+++ b/c/candle/candle_defs.h
@@ -0,0 +1,101 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+ 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 .
+
+*/
+
+#pragma once
+
+#include
+
+#ifndef _WIN32_WINNT
+#define _WIN32_WINNT 0x0600
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#undef __CRT__NO_INLINE
+#include
+#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;
+
diff --git a/c/candle/ch_9.h b/c/candle/ch_9.h
new file mode 100644
index 0000000..ba1994b
--- /dev/null
+++ b/c/candle/ch_9.h
@@ -0,0 +1,37 @@
+/*
+
+ Copyright (c) 2016 Hubert Denkmair
+
+ 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 .
+
+*/
+
+#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
diff --git a/python/README.md b/python/README.md
new file mode 100644
index 0000000..9c52d3f
--- /dev/null
+++ b/python/README.md
@@ -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`.
diff --git a/python/altera_logic/native.py b/python/altera_logic/native.py
index 7419e27..e638a4a 100644
--- a/python/altera_logic/native.py
+++ b/python/altera_logic/native.py
@@ -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:
diff --git a/python/altera_logic/qt_port.py b/python/altera_logic/qt_port.py
index c5fdce9..737d9e9 100644
--- a/python/altera_logic/qt_port.py
+++ b/python/altera_logic/qt_port.py
@@ -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
diff --git a/python/altera_logic/stream_port.py b/python/altera_logic/stream_port.py
index e7e01a4..30d7691 100644
--- a/python/altera_logic/stream_port.py
+++ b/python/altera_logic/stream_port.py
@@ -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
diff --git a/python/logic_analyzer/__init__.py b/python/logic_analyzer/__init__.py
new file mode 100644
index 0000000..fc6c605
--- /dev/null
+++ b/python/logic_analyzer/__init__.py
@@ -0,0 +1 @@
+"""Reusable device libraries from setcorp/templates."""
diff --git a/python/logic_analyzer/analysis.py b/python/logic_analyzer/analysis.py
new file mode 100644
index 0000000..cec59ee
--- /dev/null
+++ b/python/logic_analyzer/analysis.py
@@ -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)
diff --git a/python/logic_analyzer/decoders/__init__.py b/python/logic_analyzer/decoders/__init__.py
new file mode 100644
index 0000000..fc6c605
--- /dev/null
+++ b/python/logic_analyzer/decoders/__init__.py
@@ -0,0 +1 @@
+"""Reusable device libraries from setcorp/templates."""
diff --git a/python/logic_analyzer/decoders/gate_timing.py b/python/logic_analyzer/decoders/gate_timing.py
new file mode 100644
index 0000000..e8685c8
--- /dev/null
+++ b/python/logic_analyzer/decoders/gate_timing.py
@@ -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
diff --git a/python/logic_analyzer/decoders/pm35_uart.py b/python/logic_analyzer/decoders/pm35_uart.py
new file mode 100644
index 0000000..0d90397
--- /dev/null
+++ b/python/logic_analyzer/decoders/pm35_uart.py
@@ -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
diff --git a/python/logic_analyzer/decoders/set_can.py b/python/logic_analyzer/decoders/set_can.py
new file mode 100644
index 0000000..e396ab4
--- /dev/null
+++ b/python/logic_analyzer/decoders/set_can.py
@@ -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
diff --git a/python/logic_analyzer/decoders/set_uart.py b/python/logic_analyzer/decoders/set_uart.py
new file mode 100644
index 0000000..f871a41
--- /dev/null
+++ b/python/logic_analyzer/decoders/set_uart.py
@@ -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')]
diff --git a/python/logic_analyzer/files.py b/python/logic_analyzer/files.py
new file mode 100644
index 0000000..0db09b0
--- /dev/null
+++ b/python/logic_analyzer/files.py
@@ -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'':
+ 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(' 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)
diff --git a/python/logic_analyzer/saleae.py b/python/logic_analyzer/saleae.py
new file mode 100644
index 0000000..51c2fb6
--- /dev/null
+++ b/python/logic_analyzer/saleae.py
@@ -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'' 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()
diff --git a/python/pyproject.toml b/python/pyproject.toml
new file mode 100644
index 0000000..2255f3b
--- /dev/null
+++ b/python/pyproject.toml
@@ -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*"]
diff --git a/python/set_devices/__init__.py b/python/set_devices/__init__.py
new file mode 100644
index 0000000..fc6c605
--- /dev/null
+++ b/python/set_devices/__init__.py
@@ -0,0 +1 @@
+"""Reusable device libraries from setcorp/templates."""
diff --git a/python/set_devices/balsam_can.py b/python/set_devices/balsam_can.py
new file mode 100644
index 0000000..deaa4df
--- /dev/null
+++ b/python/set_devices/balsam_can.py
@@ -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
diff --git a/python/set_devices/bus_demo.py b/python/set_devices/bus_demo.py
new file mode 100644
index 0000000..1a49d1d
--- /dev/null
+++ b/python/set_devices/bus_demo.py
@@ -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()
diff --git a/python/set_devices/can485_board.py b/python/set_devices/can485_board.py
new file mode 100644
index 0000000..08aa866
--- /dev/null
+++ b/python/set_devices/can485_board.py
@@ -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)(? 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 `` прошивки."""
+ 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
diff --git a/python/set_devices/can_bridge.py b/python/set_devices/can_bridge.py
new file mode 100644
index 0000000..5fbd009
--- /dev/null
+++ b/python/set_devices/can_bridge.py
@@ -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
diff --git a/python/set_devices/can_log_trends.py b/python/set_devices/can_log_trends.py
new file mode 100644
index 0000000..9e0b5eb
--- /dev/null
+++ b/python/set_devices/can_log_trends.py
@@ -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
diff --git a/python/set_devices/can_ping.py b/python/set_devices/can_ping.py
new file mode 100644
index 0000000..677d363
--- /dev/null
+++ b/python/set_devices/can_ping.py
@@ -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"))
diff --git a/python/set_devices/can_transport.py b/python/set_devices/can_transport.py
new file mode 100644
index 0000000..086b472
--- /dev/null
+++ b/python/set_devices/can_transport.py
@@ -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)
diff --git a/python/set_devices/ds18b20.py b/python/set_devices/ds18b20.py
new file mode 100644
index 0000000..45a3e5a
--- /dev/null
+++ b/python/set_devices/ds18b20.py
@@ -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-шине")
diff --git a/python/set_devices/eeprom.py b/python/set_devices/eeprom.py
new file mode 100644
index 0000000..de33a89
--- /dev/null
+++ b/python/set_devices/eeprom.py
@@ -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"
diff --git a/python/set_devices/firmware.py b/python/set_devices/firmware.py
new file mode 100644
index 0000000..0384014
--- /dev/null
+++ b/python/set_devices/firmware.py
@@ -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))
diff --git a/python/set_devices/gas_catalog.py b/python/set_devices/gas_catalog.py
new file mode 100644
index 0000000..510c242
--- /dev/null
+++ b/python/set_devices/gas_catalog.py
@@ -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
diff --git a/python/set_devices/models.py b/python/set_devices/models.py
new file mode 100644
index 0000000..12d00dc
--- /dev/null
+++ b/python/set_devices/models.py
@@ -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)
diff --git a/python/set_devices/panel.py b/python/set_devices/panel.py
new file mode 100644
index 0000000..7e1c1a1
--- /dev/null
+++ b/python/set_devices/panel.py
@@ -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)))
diff --git a/python/set_devices/protocol.py b/python/set_devices/protocol.py
new file mode 100644
index 0000000..de68959
--- /dev/null
+++ b/python/set_devices/protocol.py
@@ -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
diff --git a/python/set_devices/protocol_capture.py b/python/set_devices/protocol_capture.py
new file mode 100644
index 0000000..d1783f9
--- /dev/null
+++ b/python/set_devices/protocol_capture.py
@@ -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)
diff --git a/python/set_devices/protocol_router.py b/python/set_devices/protocol_router.py
new file mode 100644
index 0000000..9411d47
--- /dev/null
+++ b/python/set_devices/protocol_router.py
@@ -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 []
diff --git a/python/set_devices/qt_ports/__init__.py b/python/set_devices/qt_ports/__init__.py
new file mode 100644
index 0000000..fc6c605
--- /dev/null
+++ b/python/set_devices/qt_ports/__init__.py
@@ -0,0 +1 @@
+"""Reusable device libraries from setcorp/templates."""
diff --git a/python/set_devices/qt_ports/candle_adapter.py b/python/set_devices/qt_ports/candle_adapter.py
new file mode 100644
index 0000000..5a8e105
--- /dev/null
+++ b/python/set_devices/qt_ports/candle_adapter.py
@@ -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()
diff --git a/python/set_devices/qt_ports/mock_port.py b/python/set_devices/qt_ports/mock_port.py
new file mode 100644
index 0000000..e6221a9
--- /dev/null
+++ b/python/set_devices/qt_ports/mock_port.py
@@ -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))
diff --git a/python/set_devices/qt_ports/qt_compat.py b/python/set_devices/qt_ports/qt_compat.py
new file mode 100644
index 0000000..952ba94
--- /dev/null
+++ b/python/set_devices/qt_ports/qt_compat.py
@@ -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
diff --git a/python/set_devices/qt_ports/serial_port.py b/python/set_devices/qt_ports/serial_port.py
new file mode 100644
index 0000000..cc1a291
--- /dev/null
+++ b/python/set_devices/qt_ports/serial_port.py
@@ -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())
diff --git a/python/set_devices/qt_ports/slcan_adapter.py b/python/set_devices/qt_ports/slcan_adapter.py
new file mode 100644
index 0000000..1968398
--- /dev/null
+++ b/python/set_devices/qt_ports/slcan_adapter.py
@@ -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()
diff --git a/python/set_devices/qt_ports/stm32_bootloader.py b/python/set_devices/qt_ports/stm32_bootloader.py
new file mode 100644
index 0000000..8f69312
--- /dev/null
+++ b/python/set_devices/qt_ports/stm32_bootloader.py
@@ -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)
diff --git a/python/set_devices/qt_ports/stm_settings.py b/python/set_devices/qt_ports/stm_settings.py
new file mode 100644
index 0000000..f64b3f9
--- /dev/null
+++ b/python/set_devices/qt_ports/stm_settings.py
@@ -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("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)
diff --git a/python/set_devices/qt_ports/tms_bootloader.py b/python/set_devices/qt_ports/tms_bootloader.py
new file mode 100644
index 0000000..f1451c7
--- /dev/null
+++ b/python/set_devices/qt_ports/tms_bootloader.py
@@ -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)
diff --git a/python/set_devices/qt_ports/ump_can_client.py b/python/set_devices/qt_ports/ump_can_client.py
new file mode 100644
index 0000000..98420a5
--- /dev/null
+++ b/python/set_devices/qt_ports/ump_can_client.py
@@ -0,0 +1,69 @@
+"""Последовательный клиент CAN-логгера поверх уже подключённого Dima-адаптера."""
+import secrets
+from .qt_compat import QObject, QTimer, Signal
+from set_devices.ump_logger_can import Response, build_request
+
+
+class UmpCanClient(QObject):
+ completed = Signal(str, object)
+ failed = Signal(str)
+
+ def __init__(self, send, parent=None):
+ super().__init__(parent)
+ self._send = send
+ self._token = secrets.randbelow(255) + 1
+ self._pending = None
+ self._operation = ''
+ self._timer = QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self._timeout)
+
+ @property
+ def busy(self):
+ return self._pending is not None
+
+ def cancel(self):
+ # Поздний ответ после переключения транспорта не должен продолжить
+ # старую цепочку и отправить команду в новое подключение.
+ self._timer.stop()
+ self._pending = None
+ self._operation = ''
+
+ def request(self, operation, mode, function, address, value, timeout_ms=2000):
+ if self.busy:
+ self.failed.emit('CAN-логгер занят предыдущим запросом')
+ return
+ self._token = self._token % 255 + 1
+ try:
+ can_id, data = build_request(mode, self._token, function, address, value)
+ self._pending = Response(mode, self._token, function, address, value)
+ self._operation = operation
+ self._timer.start(timeout_ms)
+ self._send(can_id, data)
+ except (ValueError, RuntimeError, OSError) as error:
+ self.cancel()
+ self.failed.emit(str(error))
+
+ def receive(self, can_id, data):
+ if self._pending is None:
+ return
+ try:
+ words = self._pending.feed(can_id, data)
+ except ValueError as error:
+ self.cancel()
+ self.failed.emit(str(error))
+ return
+ if words is None:
+ return
+ operation = self._operation
+ self.cancel()
+ self.completed.emit(operation, words)
+
+ def _timeout(self):
+ writing = self._pending is not None and self._pending.function == 6
+ self.cancel()
+ text = ('Таймаут CAN-логгера: проверьте адаптер, скорость, номер платы '
+ 'и прошивку с поддержкой CAN-логгера.')
+ if writing:
+ text += ' Подтверждение записи потеряно; команда могла выполниться.'
+ self.failed.emit(text)
diff --git a/python/set_devices/shared_protocol.py b/python/set_devices/shared_protocol.py
new file mode 100644
index 0000000..5083e91
--- /dev/null
+++ b/python/set_devices/shared_protocol.py
@@ -0,0 +1,18 @@
+"""Optional native SETProtocol backend, configured by SETPROTOCOL_LIBRARY."""
+
+from __future__ import annotations
+
+def _load():
+ try:
+ from protocan.native import NativeProtocol
+ return NativeProtocol()
+ except (ImportError, OSError, RuntimeError):
+ return None
+
+
+SHARED_PROTOCOL = _load()
+
+
+def get_shared_protocol():
+ """Return SETProtocol, or None for source/test fallback builds."""
+ return SHARED_PROTOCOL
diff --git a/python/set_devices/spectrum_demo.py b/python/set_devices/spectrum_demo.py
new file mode 100644
index 0000000..47878fa
--- /dev/null
+++ b/python/set_devices/spectrum_demo.py
@@ -0,0 +1,119 @@
+"""@file spectrum_demo.py
+@brief Демонстрационный источник спектра для проверки вкладки без прибора.
+
+Повторяет то, что передаёт прошивка: основной тон, несколько гармоник
+и шумовая подложка. Нужен, чтобы вкладку можно было открыть и убедиться
+в правильности отрисовки, не имея под рукой ни платы, ни датчика.
+
+Источник намеренно живёт рядом с разбором потока, а не внутри вкладки:
+он выдаёт те же самые строки, что и прибор, и проходит через тот же
+разбор. Иначе демонстрация проверяла бы отрисовку, но не формат.
+"""
+
+from __future__ import annotations
+
+import math
+import random
+
+from set_devices.spectrum_stream import (
+ Harmonic,
+ HarmonicsFrame,
+ build_harmonics_line,
+ build_spectrum_line,
+)
+
+#: Число элементов спектра в демонстрационном кадре.
+DEMO_BINS = 128
+
+#: Частота выборки демонстрационного источника, Гц.
+DEMO_SAMPLE_RATE = 20000
+
+#: Размер преобразования, которому соответствует спектр.
+DEMO_FFT_SIZE = 512
+
+#: Верхняя граница шкалы уровней, дБ относительно милливольта.
+DEMO_TOP_DBMV = 60.0
+
+#: Размах шкалы уровней, дБ.
+DEMO_RANGE_DB = 60.0
+
+
+class SpectrumDemoSource:
+ """@brief Выдаёт строки спектра и гармоник, как это делал бы прибор."""
+
+ def __init__(self, seed: int = 1) -> None:
+ self._seq = 0
+ self._random = random.Random(seed)
+ self._phase = 0.0
+
+ def next_lines(self) -> str:
+ """@brief Формирует очередную пару строк.
+
+ @return Строка спектра и строка гармоник, склеенные подряд.
+
+ Основной тон медленно плывёт по частоте, чтобы на экране было
+ видно движение и стало заметно, если отрисовка где-то подвисает.
+ """
+ self._seq += 1
+ self._phase += 0.05
+
+ fundamental_hz = 1000.0 + 200.0 * math.sin(self._phase)
+ fundamental_mv = 400.0
+ bin_width = (DEMO_SAMPLE_RATE / 2.0) / DEMO_BINS
+
+ # Уровни: шумовая подложка около 3 мВ плюс узкие пики на основном
+ # тоне и его гармониках.
+ amplitudes = [self._random.uniform(1.5, 4.0) for _ in range(DEMO_BINS)]
+
+ harmonics: list[Harmonic] = []
+ for order in range(1, 5):
+ frequency = fundamental_hz * order
+ if frequency >= DEMO_SAMPLE_RATE / 2.0:
+ break
+
+ amplitude = fundamental_mv / (order * order)
+ index = int(round(frequency / bin_width))
+ if 0 <= index < DEMO_BINS:
+ amplitudes[index] = amplitude
+ # Соседние элементы приподняты: у реального окна энергия
+ # тона всегда занимает несколько элементов, и совсем узкий
+ # пик выглядел бы неправдоподобно.
+ for neighbour in (index - 1, index + 1):
+ if 0 <= neighbour < DEMO_BINS:
+ amplitudes[neighbour] = max(
+ amplitudes[neighbour], amplitude * 0.45
+ )
+
+ if order >= 2:
+ harmonics.append(
+ Harmonic(
+ index=order,
+ frequency_hz=frequency,
+ amplitude_mv=amplitude,
+ relative_db=20.0 * math.log10(amplitude / fundamental_mv),
+ )
+ )
+
+ levels = [20.0 * math.log10(max(value, 1e-3)) for value in amplitudes]
+
+ spectrum = build_spectrum_line(
+ seq=self._seq,
+ sample_rate_hz=DEMO_SAMPLE_RATE,
+ fft_size=DEMO_FFT_SIZE,
+ levels_db=levels,
+ top_dbmv=DEMO_TOP_DBMV,
+ range_db=DEMO_RANGE_DB,
+ )
+
+ total = math.sqrt(sum(h.amplitude_mv**2 for h in harmonics))
+ frame = HarmonicsFrame(
+ seq=self._seq,
+ fundamental_hz=fundamental_hz,
+ fundamental_mv=fundamental_mv,
+ thd_percent=100.0 * total / fundamental_mv,
+ snr_db=42.0,
+ rms_mv=fundamental_mv / math.sqrt(2.0),
+ harmonics=tuple(harmonics),
+ )
+
+ return spectrum + build_harmonics_line(frame)
diff --git a/python/set_devices/spectrum_stream.py b/python/set_devices/spectrum_stream.py
new file mode 100644
index 0000000..e6578d0
--- /dev/null
+++ b/python/set_devices/spectrum_stream.py
@@ -0,0 +1,420 @@
+"""@file spectrum_stream.py
+@brief Разбор потока спектра и гармоник от прибора на STM32.
+
+Прибор снимает сигнал вибродатчика или микрофона, считает быстрое
+преобразование Фурье прямо на месте и передаёт готовый результат: сам спектр
+и параметры найденных гармоник. Задача этого модуля — превратить поток байтов
+в структуры, с которыми работает вкладка.
+
+Модуль не зависит от Qt и от последовательного порта: на вход подаются байты,
+на выход идут кадры. Так его можно проверить тестами без прибора и без окна.
+
+Формат строки повторяет привычный по навигационным приборам вид: доллар,
+поля через запятую, звёздочка и контрольная сумма::
+
+ $SPEC,,,,,,,*
+ $HARM,,,,,,,[,,,]...*
+
+Текстовый формат выбран намеренно, хотя двоичный вышел бы компактнее.
+Прибор отлаживается через обычный терминал, и возможность увидеть поток
+глазами дороже экономии канала. Спектр при этом ужат до одного байта
+на элемент, поэтому кадр укладывается примерно в 290 байт и на скорости
+115200 бод спокойно идёт пять раз в секунду.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+#: Начальный символ строки.
+LINE_START = "$"
+
+#: Разделитель контрольной суммы.
+CHECKSUM_MARK = "*"
+
+#: Тип строки со спектром.
+TAG_SPECTRUM = "SPEC"
+
+#: Тип строки с гармониками.
+TAG_HARMONICS = "HARM"
+
+#: Наибольшее число гармоник в кадре; совпадает с пределом прошивки.
+MAX_HARMONICS = 8
+
+#: Наибольшая длина строки. Защита от мусора в канале: без неё обрыв связи
+#: посреди передачи привёл бы к бесконечному накоплению в буфере.
+MAX_LINE_LENGTH = 4096
+
+
+def checksum(payload: str) -> int:
+ """@brief Считает контрольную сумму строки.
+
+ @param payload Содержимое между ``$`` и ``*``.
+ @return Побайтовое исключающее ИЛИ, 0..255.
+
+ Сумма именно такая простая не от небрежности: канал короткий и
+ проводной, а задача суммы здесь — отсеять строку, склеенную из двух
+ обрывков, а не защитить от искажения одного бита.
+ """
+ value = 0
+ for char in payload.encode("ascii", errors="replace"):
+ value ^= char
+ return value & 0xFF
+
+
+@dataclass(frozen=True)
+class Harmonic:
+ """@brief Одна найденная гармоника."""
+
+ index: int
+ """Номер гармоники: 2 — вторая, 3 — третья и так далее."""
+
+ frequency_hz: float
+ """Измеренная частота."""
+
+ amplitude_mv: float
+ """Амплитуда."""
+
+ relative_db: float
+ """Уровень относительно основного тона, децибелы. Всегда отрицателен
+ у исправного тракта: гармоника слабее основного тона."""
+
+
+@dataclass(frozen=True)
+class HarmonicsFrame:
+ """@brief Параметры основного тона и его гармоник."""
+
+ seq: int
+ fundamental_hz: float
+ fundamental_mv: float
+ thd_percent: float
+ """Коэффициент гармонических искажений."""
+ snr_db: float
+ rms_mv: float
+ harmonics: tuple[Harmonic, ...] = ()
+
+
+@dataclass(frozen=True)
+class SpectrumFrame:
+ """@brief Спектр целиком."""
+
+ seq: int
+ sample_rate_hz: int
+ fft_size: int
+ top_dbmv: float
+ """Верхняя граница шкалы уровней, децибелы относительно милливольта."""
+ range_db: float
+ """Полный размах шкалы уровней."""
+ levels_db: tuple[float, ...] = field(default=())
+ """Уровни элементов спектра в децибелах относительно милливольта."""
+
+ @property
+ def bin_width_hz(self) -> float:
+ """@brief Шаг по частоте между элементами переданного спектра.
+
+ Учитывает, что прибор мог проредить спектр перед передачей: делится
+ не на размер преобразования, а на фактическое число элементов.
+ """
+ if not self.levels_db:
+ return 0.0
+ top_frequency = self.sample_rate_hz / 2.0
+ return top_frequency / len(self.levels_db)
+
+ def frequency_of(self, index: int) -> float:
+ """@brief Частота, соответствующая элементу спектра.
+
+ @param index Номер элемента.
+ @return Частота в герцах.
+ """
+ return index * self.bin_width_hz
+
+
+class SpectrumStreamError(Exception):
+ """@brief Строка не разобрана. Несёт причину для журнала."""
+
+
+def _split_line(line: str) -> tuple[str, list[str]]:
+ """@brief Проверяет обрамление и контрольную сумму, возвращает поля.
+
+ @param line Одна строка без перевода строки.
+ @return Кортеж «тип строки, список полей».
+ @throws SpectrumStreamError При нарушении формата или суммы.
+ """
+ if not line.startswith(LINE_START):
+ raise SpectrumStreamError("строка не начинается с '$'")
+
+ star = line.rfind(CHECKSUM_MARK)
+ if star < 0:
+ raise SpectrumStreamError("нет контрольной суммы")
+
+ payload = line[1:star]
+ tail = line[star + 1 :].strip()
+
+ if len(tail) != 2:
+ raise SpectrumStreamError("контрольная сумма не из двух знаков")
+
+ try:
+ expected = int(tail, 16)
+ except ValueError as exc:
+ raise SpectrumStreamError("контрольная сумма не шестнадцатеричная") from exc
+
+ actual = checksum(payload)
+ if actual != expected:
+ raise SpectrumStreamError(
+ f"контрольная сумма не сошлась: получено {expected:02X}, "
+ f"посчитано {actual:02X}"
+ )
+
+ fields = payload.split(",")
+ if not fields:
+ raise SpectrumStreamError("пустая строка")
+
+ return fields[0], fields[1:]
+
+
+def _decode_levels(hex_data: str, top_dbmv: float, range_db: float) -> tuple[float, ...]:
+ """@brief Разворачивает упакованный спектр в уровни.
+
+ @param hex_data Элементы спектра, по два знака на элемент.
+ @param top_dbmv Верхняя граница шкалы.
+ @param range_db Размах шкалы.
+ @return Уровни в децибелах.
+
+ Прибор передаёт каждый элемент одним байтом: ноль означает нижнюю
+ границу шкалы, 255 — верхнюю. Такое сжатие теряет доли децибела,
+ что для картины спектра несущественно, зато уменьшает кадр вчетверо
+ относительно передачи чисел текстом.
+ """
+ if len(hex_data) % 2 != 0:
+ raise SpectrumStreamError("нечётное число знаков в данных спектра")
+
+ bottom = top_dbmv - range_db
+ levels: list[float] = []
+
+ for pos in range(0, len(hex_data), 2):
+ chunk = hex_data[pos : pos + 2]
+ try:
+ raw = int(chunk, 16)
+ except ValueError as exc:
+ raise SpectrumStreamError(f"неверный знак в данных: '{chunk}'") from exc
+ levels.append(bottom + (raw / 255.0) * range_db)
+
+ return tuple(levels)
+
+
+def parse_line(line: str) -> SpectrumFrame | HarmonicsFrame:
+ """@brief Разбирает одну строку.
+
+ @param line Строка без перевода строки.
+ @return Кадр спектра или кадр гармоник.
+ @throws SpectrumStreamError При любой ошибке разбора.
+ """
+ tag, fields = _split_line(line)
+
+ if tag == TAG_SPECTRUM:
+ if len(fields) != 7:
+ raise SpectrumStreamError(
+ f"в строке спектра {len(fields)} полей вместо 7"
+ )
+ try:
+ seq = int(fields[0])
+ sample_rate = int(fields[1])
+ fft_size = int(fields[2])
+ bins = int(fields[3])
+ top = float(fields[4])
+ span = float(fields[5])
+ except ValueError as exc:
+ raise SpectrumStreamError("нечисловое поле в строке спектра") from exc
+
+ if span <= 0.0:
+ raise SpectrumStreamError("размах шкалы должен быть положительным")
+
+ levels = _decode_levels(fields[6], top, span)
+
+ # Заявленное число элементов обязано совпасть с переданным: иначе
+ # шкала частот сдвинется, и оператор увидит тон не на своей частоте.
+ if len(levels) != bins:
+ raise SpectrumStreamError(
+ f"заявлено {bins} элементов, передано {len(levels)}"
+ )
+
+ return SpectrumFrame(
+ seq=seq,
+ sample_rate_hz=sample_rate,
+ fft_size=fft_size,
+ top_dbmv=top,
+ range_db=span,
+ levels_db=levels,
+ )
+
+ if tag == TAG_HARMONICS:
+ if len(fields) < 6:
+ raise SpectrumStreamError("слишком короткая строка гармоник")
+ try:
+ seq = int(fields[0])
+ fundamental_hz = float(fields[1])
+ fundamental_mv = float(fields[2])
+ thd = float(fields[3])
+ snr = float(fields[4])
+ rms = float(fields[5])
+ count = int(fields[6]) if len(fields) > 6 else 0
+ except ValueError as exc:
+ raise SpectrumStreamError("нечисловое поле в строке гармоник") from exc
+
+ if count < 0 or count > MAX_HARMONICS:
+ raise SpectrumStreamError(f"недопустимое число гармоник: {count}")
+
+ expected_fields = 7 + count * 3
+ if len(fields) != expected_fields:
+ raise SpectrumStreamError(
+ f"для {count} гармоник нужно {expected_fields} полей, "
+ f"получено {len(fields)}"
+ )
+
+ harmonics: list[Harmonic] = []
+ for item in range(count):
+ base = 7 + item * 3
+ try:
+ harmonics.append(
+ Harmonic(
+ index=item + 2,
+ frequency_hz=float(fields[base]),
+ amplitude_mv=float(fields[base + 1]),
+ relative_db=float(fields[base + 2]),
+ )
+ )
+ except ValueError as exc:
+ raise SpectrumStreamError(
+ f"нечисловое поле у гармоники {item + 2}"
+ ) from exc
+
+ return HarmonicsFrame(
+ seq=seq,
+ fundamental_hz=fundamental_hz,
+ fundamental_mv=fundamental_mv,
+ thd_percent=thd,
+ snr_db=snr,
+ rms_mv=rms,
+ harmonics=tuple(harmonics),
+ )
+
+ raise SpectrumStreamError(f"неизвестный тип строки: '{tag}'")
+
+
+class SpectrumStream:
+ """@brief Накопитель байтов, отдающий разобранные кадры.
+
+ Данные из порта приходят кусками произвольного размера: строка может
+ прийти по частям, а в одном куске может оказаться несколько строк.
+ Накопитель собирает их обратно.
+ """
+
+ def __init__(self) -> None:
+ self._buffer = ""
+ self.parsed = 0
+ """Число успешно разобранных строк."""
+ self.errors = 0
+ """Число отброшенных строк."""
+ self.last_error: str = ""
+ """Причина последней ошибки — для журнала вкладки."""
+
+ def reset(self) -> None:
+ """@brief Сбрасывает накопитель и счётчики."""
+ self._buffer = ""
+ self.parsed = 0
+ self.errors = 0
+ self.last_error = ""
+
+ def feed(self, chunk: str | bytes) -> list[SpectrumFrame | HarmonicsFrame]:
+ """@brief Добавляет очередной кусок потока.
+
+ @param chunk Данные из порта.
+ @return Список кадров, разобранных из накопленного.
+
+ Ошибочные строки отбрасываются, но не прерывают разбор: одиночная
+ помеха в канале не должна ронять поток целиком.
+ """
+ if isinstance(chunk, (bytes, bytearray)):
+ chunk = chunk.decode("ascii", errors="replace")
+
+ self._buffer += chunk
+
+ # Защита от мусора без переводов строки: иначе буфер рос бы
+ # неограниченно и приложение съело бы всю память.
+ if len(self._buffer) > MAX_LINE_LENGTH:
+ cut = self._buffer.rfind("\n")
+ self._buffer = self._buffer[cut + 1 :] if cut >= 0 else ""
+ self.errors += 1
+ self.last_error = "строка длиннее допустимой, буфер очищен"
+
+ frames: list[SpectrumFrame | HarmonicsFrame] = []
+
+ while "\n" in self._buffer:
+ line, _, self._buffer = self._buffer.partition("\n")
+ line = line.strip("\r\n \t")
+ if not line:
+ continue
+ try:
+ frames.append(parse_line(line))
+ self.parsed += 1
+ except SpectrumStreamError as exc:
+ self.errors += 1
+ self.last_error = str(exc)
+
+ return frames
+
+
+def build_spectrum_line(
+ seq: int,
+ sample_rate_hz: int,
+ fft_size: int,
+ levels_db: list[float] | tuple[float, ...],
+ top_dbmv: float,
+ range_db: float,
+) -> str:
+ """@brief Собирает строку спектра.
+
+ @return Строка с переводом строки в конце.
+
+ Нужна для тестов и для демонстрационного источника, но главное — она
+ задаёт формат в одном месте с разбором. Если формат изменят только
+ в прошивке, круговой тест это заметит.
+ """
+ bottom = top_dbmv - range_db
+ parts: list[str] = []
+
+ for level in levels_db:
+ ratio = (level - bottom) / range_db if range_db > 0.0 else 0.0
+ raw = int(round(max(0.0, min(1.0, ratio)) * 255.0))
+ parts.append(f"{raw:02X}")
+
+ payload = (
+ f"{TAG_SPECTRUM},{seq},{sample_rate_hz},{fft_size},"
+ f"{len(parts)},{top_dbmv:.1f},{range_db:.1f},{''.join(parts)}"
+ )
+ return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
+
+
+def build_harmonics_line(frame: HarmonicsFrame) -> str:
+ """@brief Собирает строку гармоник.
+
+ @param frame Данные для передачи.
+ @return Строка с переводом строки в конце.
+ """
+ parts = [
+ TAG_HARMONICS,
+ str(frame.seq),
+ f"{frame.fundamental_hz:.1f}",
+ f"{frame.fundamental_mv:.1f}",
+ f"{frame.thd_percent:.2f}",
+ f"{frame.snr_db:.1f}",
+ f"{frame.rms_mv:.1f}",
+ str(len(frame.harmonics)),
+ ]
+ for harmonic in frame.harmonics:
+ parts.append(f"{harmonic.frequency_hz:.1f}")
+ parts.append(f"{harmonic.amplitude_mv:.1f}")
+ parts.append(f"{harmonic.relative_db:.1f}")
+
+ payload = ",".join(parts)
+ return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
diff --git a/python/set_devices/tms_flash.py b/python/set_devices/tms_flash.py
new file mode 100644
index 0000000..433ed75
--- /dev/null
+++ b/python/set_devices/tms_flash.py
@@ -0,0 +1,115 @@
+"""Legacy BALZAM/TMS parallel-memory dump protocol.
+
+The TMS320F2812 addresses external XINTF memory in 16-bit words, while
+``CMD_UPLOAD`` transfers a byte count. This module keeps those units explicit
+so a complete dump cannot silently become half or twice the requested size.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from protocan import tms2812 as shared_tms2812
+
+CMD_UPLOAD = 52
+DEFAULT_CONTROLLER_ADDRESS = 5
+PARALLEL_NVM_START_WORD = 0x100000
+PARALLEL_NVM_WORDS = 0x80000
+PARALLEL_NVM_BYTES = PARALLEL_NVM_WORDS * 2
+MT29F1G08_WORDS = 0x4000000
+MT29F1G08_BYTES = MT29F1G08_WORDS * 2
+DEFAULT_CHUNK_BYTES = 4096
+NAND_CHUNK_BYTES = 2048
+UPLOAD_TRAILER_BYTES = 6 # CRC16 + four legacy padding bytes
+
+
+class TmsFlashError(ValueError):
+ """Malformed command, reply, or dump range."""
+
+
+def build_upload_request(address: int, word_address: int, byte_count: int) -> bytes:
+ """Build the 12-byte legacy ``CMD_UPLOAD`` request."""
+ if not 0 <= address <= 0xFF:
+ raise TmsFlashError("адрес контроллера вне диапазона 0..255")
+ if not 0 <= word_address <= 0xFFFFFFFF:
+ raise TmsFlashError("адрес слова вне диапазона uint32")
+ if not 1 <= byte_count <= 0xFFFFFFFF:
+ raise TmsFlashError("размер блока вне диапазона 1..0xFFFFFFFF")
+ try:
+ return shared_tms2812.build_upload_request(
+ address, word_address, byte_count
+ )
+ except ValueError as error:
+ raise TmsFlashError(str(error)) from error
+
+
+def decode_upload_reply(raw: bytes, address: int, byte_count: int) -> bytes:
+ """Validate one complete upload reply and return its memory bytes."""
+ expected = 2 + byte_count + UPLOAD_TRAILER_BYTES
+ if len(raw) != expected:
+ raise TmsFlashError(
+ f"ответ CMD_UPLOAD: ожидалось {expected} байт, получено {len(raw)}"
+ )
+ try:
+ return shared_tms2812.decode_upload_reply(raw, address, byte_count)
+ except ValueError as error:
+ raise TmsFlashError(str(error)) from error
+
+
+@dataclass(frozen=True)
+class DumpChunk:
+ """One request in a word-addressed, byte-transferred dump."""
+
+ word_address: int
+ byte_count: int
+
+
+class FlashDumpPlan:
+ """Deterministic chunk plan used by the GUI and host tests."""
+
+ def __init__(self, start_word: int, word_count: int, chunk_bytes: int) -> None:
+ if start_word < 0 or start_word > 0xFFFFFFFF:
+ raise TmsFlashError("начальный адрес слова вне диапазона uint32")
+ if word_count <= 0:
+ raise TmsFlashError("число слов должно быть положительным")
+ if start_word + word_count > 0x100000000:
+ raise TmsFlashError("конец диапазона выходит за uint32")
+ if chunk_bytes < 2 or chunk_bytes > 0xFFFF or chunk_bytes % 2:
+ raise TmsFlashError("блок должен быть чётным числом байт 2..65534")
+ self.start_word = start_word
+ self.word_count = word_count
+ self.chunk_bytes = chunk_bytes
+ self.total_bytes = word_count * 2
+
+ def chunks(self) -> list[DumpChunk]:
+ result = []
+ offset = 0
+ while offset < self.total_bytes:
+ size = min(self.chunk_bytes, self.total_bytes - offset)
+ result.append(DumpChunk(self.start_word + offset // 2, size))
+ offset += size
+ return result
+
+
+class UploadReplyBuffer:
+ """Collect arbitrary serial fragments for exactly one upload reply."""
+
+ def __init__(self, address: int, byte_count: int) -> None:
+ self.address = address
+ self.byte_count = byte_count
+ self.expected_size = 2 + byte_count + UPLOAD_TRAILER_BYTES
+ self._buffer = bytearray()
+
+ @property
+ def received_size(self) -> int:
+ return len(self._buffer)
+
+ def feed(self, data: bytes) -> tuple[bytes | None, bytes]:
+ """Return ``(payload, trailing_data)`` when the reply is complete."""
+ self._buffer.extend(data)
+ if len(self._buffer) < self.expected_size:
+ return None, b""
+ raw = bytes(self._buffer[: self.expected_size])
+ trailing = bytes(self._buffer[self.expected_size :])
+ self._buffer.clear()
+ return decode_upload_reply(raw, self.address, self.byte_count), trailing
diff --git a/python/set_devices/tms_terminal.py b/python/set_devices/tms_terminal.py
new file mode 100644
index 0000000..4dbd304
--- /dev/null
+++ b/python/set_devices/tms_terminal.py
@@ -0,0 +1,358 @@
+# -*- coding: utf-8 -*-
+"""Посылка ПЧ -> терминал прошивки ТМС (``TMS_TO_TERMINAL``).
+
+Это третий формат в проекте: не протокол SETGUI (``A5 5A``) из ``protocol.py``
+и не транспорт моста (``AA 55``) из ``can_transport.py``, а ответ контроллера
+преобразователя частоты на TMS320F281x, который он шлёт по RS485 в терминал.
+
+Раскладка структуры (tempFold/message2.h, tempFold/RS_Functions.h)::
+
+ head Address, Number 2 байта
+ digit_data ANS_DIGIT_DATA_TO_TERMINAL N байт
+ analog_data TMS_ANALOG_DATA, каждое lo, hi 2*M байт
+ crc_lo, crc_hi 2 байта
+ add_byte 1 байт
+
+CRC считается по всей посылке кроме последних трёх байт (``sizeof - 3``),
+начальное значение 0xFFFF, кладётся младшим байтом вперёд.
+
+Числа N и M у разных сборок прошивки разные: в ``RS_Functions.h`` это 28 и 30,
+в ``message2.h`` того же прибора — уже 38 и 34 (в исходнике видно ``//add``
+перед byte29). Поэтому раскладка здесь — параметр, а не константа.
+
+Байтов синхронизации в формате нет — прошивка полагается на паузу в линии.
+Поэтому разборщик ищет границу посылки перебором смещений по CRC, а не по
+сигнатуре; заодно это гасит лишний байт, который ``RS_Send`` шлёт сверх
+структуры (``sizeof(TMS_TO_TERMINAL)+1``).
+"""
+
+from __future__ import annotations
+
+import random
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, Optional
+
+#: Head (2) + CRC (2) + add_byte (1): всё, что не данные.
+FRAME_OVERHEAD = 5
+#: CRC не покрывает crc_lo, crc_hi и add_byte.
+CRC_TAIL = 3
+
+#: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает.
+MIN_DIGIT_BYTES = 1
+MAX_DIGIT_BYTES = 64
+MIN_ANALOG_COUNT = 1
+MAX_ANALOG_COUNT = 64
+
+#: Номера команд, enum в конце RS_Functions.h.
+COMMAND_NAMES: Dict[int, str] = {
+ 51: "CMD_LOAD",
+ 52: "CMD_UPLOAD",
+ 53: "CMD_RUN",
+ 54: "CMD_XFLASH",
+ 55: "CMD_TFLASH",
+ 56: "CMD_PEEK",
+ 57: "CMD_POKE",
+ 58: "CMD_INITLOAD",
+ 59: "CMD_INIT",
+ 60: "CMD_EXTEND",
+ 61: "CMD_VECTOR",
+ 62: "CMD_IMPULSE",
+ 65: "CMD_STD",
+ 66: "CMD_TEST_ALL",
+ 67: "CMD_STD_ANS",
+}
+
+#: Назначение аналоговых величин по комментариям в message2.c (ReceiveCommand).
+ANALOG_NAMES = [
+ "FREQ_OUT (Гц*100)",
+ "FREQ_PWM",
+ "K_MOD_INV_1",
+ "K_MOD_INV_2",
+ "SENS_ROT0",
+ "SENS_ROT1",
+ "U_ZPT_1+",
+ "U_ZPT_1-",
+ "I_OUT_INV1",
+ "I_OUT_INV2",
+ "F_ROT",
+ "DIR_ROT",
+ "U_ZPT_2+",
+ "U_ZPT_2-",
+ "SENS_ROT_2",
+ "SENS_ROT_3",
+ "УКСС8 рег. 30",
+ "УКСС8 рег. 31",
+ "TORQ",
+ "log13",
+ "log17",
+ "УКСС3 рег. 40",
+ "УКСС3 рег. 42",
+ "УКСС2 рег. 40 (BTR)",
+ "УКСС2 рег. 42 (BTR)",
+ "TEMP_GED (Uin УМП1)",
+ "TEMP_BEARING (Uin УМП2)",
+ "POWER",
+ "Uenter_1",
+ "Uenter_2",
+ "УМП1 задание 4-20",
+ "УМП1 обратная связь 4-20",
+ "УМП2 задание 4-20",
+ "УМП2 обратная связь 4-20",
+]
+
+
+def analog_name(index: int) -> str:
+ """Имя аналоговой величины по индексу с нуля; за таблицей — просто номер."""
+ if index < len(ANALOG_NAMES):
+ return ANALOG_NAMES[index]
+ return "аналог %d" % (index + 1)
+
+
+def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
+ """CRC-16/MODBUS: poly 0xA001 (рефлексия 0x8005), init 0xFFFF."""
+ for byte in data:
+ crc ^= byte
+ for _ in range(8):
+ crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
+ return crc & 0xFFFF
+
+
+def crc16_ccitt_false(data: bytes, crc: int = 0xFFFF) -> int:
+ """CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, без рефлексии."""
+ 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
+
+
+#: Самого ``crc16.h`` прошивки под рукой нет, поэтому вариант выбирает оператор.
+CRC_VARIANTS: Dict[str, Callable[[bytes], int]] = {
+ "MODBUS": crc16_modbus,
+ "CCITT-FALSE": crc16_ccitt_false,
+}
+DEFAULT_CRC = "MODBUS"
+
+
+@dataclass(frozen=True)
+class Layout:
+ """@brief Раскладка посылки: сколько байт дискретных величин и аналогов."""
+
+ digit_bytes: int
+ analog_count: int
+ crc_name: str = DEFAULT_CRC
+
+ def __post_init__(self) -> None:
+ if not MIN_DIGIT_BYTES <= self.digit_bytes <= MAX_DIGIT_BYTES:
+ raise ValueError("байт дискретных величин вне диапазона %d..%d"
+ % (MIN_DIGIT_BYTES, MAX_DIGIT_BYTES))
+ if not MIN_ANALOG_COUNT <= self.analog_count <= MAX_ANALOG_COUNT:
+ raise ValueError("аналоговых величин вне диапазона %d..%d"
+ % (MIN_ANALOG_COUNT, MAX_ANALOG_COUNT))
+ if self.crc_name not in CRC_VARIANTS:
+ raise ValueError("неизвестный вариант CRC: %s" % self.crc_name)
+
+ @property
+ def frame_size(self) -> int:
+ """Полный размер ``TMS_TO_TERMINAL`` в байтах."""
+ return FRAME_OVERHEAD + self.digit_bytes + 2 * self.analog_count
+
+ @property
+ def analog_offset(self) -> int:
+ return 2 + self.digit_bytes
+
+ def crc(self, data: bytes) -> int:
+ return CRC_VARIANTS[self.crc_name](data)
+
+
+#: Готовые раскладки из исходников: оператору не нужно считать байты руками.
+PRESETS: Dict[str, Layout] = {
+ "BALZAM_7 (28/34)": Layout(28, 34),
+ "message2.h (38/34)": Layout(38, 34),
+ "RS_Functions.h (28/30)": Layout(28, 30),
+ "TEST_ALL (24/24)": Layout(24, 24),
+}
+DEFAULT_PRESET = "message2.h (38/34)"
+
+
+def build_std_request(address: int, analogs: List[int], digits: bytes) -> bytes:
+ """BALZAM_7 CMD_TO_TMS without add_byte (RS485.c: size_cmd15 - 1).
+
+ CMD_STD applies all four setpoints and six control bytes, even when the
+ caller only wants a telemetry reply. Never substitute an empty poll.
+ """
+ if not 0 <= address <= 255:
+ raise ValueError("Адрес должен быть 0…255")
+ if len(analogs) != 4 or len(digits) != 6:
+ raise ValueError("CMD_STD: требуется 4 аналоговых значения и 6 байт дискретов")
+ if any(not -32768 <= value <= 32767 for value in analogs):
+ raise ValueError("Аналоговые значения должны быть -32768…32767")
+ body = bytes((address, 65))
+ body += b"".join(value.to_bytes(2, "little", signed=True) for value in analogs)
+ body += digits
+ return body + crc16_modbus(body).to_bytes(2, "little")
+
+
+def default_layout() -> Layout:
+ """Раскладка по умолчанию — самая свежая из исходников."""
+ return PRESETS[DEFAULT_PRESET]
+
+
+@dataclass
+class TmsRecord:
+ """@brief Одна разобранная посылка ПЧ -> терминал."""
+
+ address: int
+ number: int
+ digits: bytes
+ analogs: List[int] # знаковые int16
+ crc_frame: int
+ crc_calc: int
+ add_byte: int
+ raw: bytes
+ layout: Layout = field(default_factory=default_layout)
+
+ @property
+ def crc_ok(self) -> bool:
+ return self.crc_frame == self.crc_calc
+
+ @property
+ def command(self) -> str:
+ """Номер команды словом; неизвестные остаются числом."""
+ name = COMMAND_NAMES.get(self.number)
+ return "%s (%d)" % (name, self.number) if name else str(self.number)
+
+ def bits(self, byte_index: int) -> List[int]:
+ """Биты одного байта дискретных величин от bit0 к bit7."""
+ value = self.digits[byte_index]
+ return [(value >> bit) & 1 for bit in range(8)]
+
+ def set_bits(self) -> List[str]:
+ """Имена взведённых битов вида ``byte05.bit3`` — для сводки в журнале."""
+ return ["byte%02d.bit%d" % (index + 1, bit)
+ for index, value in enumerate(self.digits)
+ for bit in range(8) if value >> bit & 1]
+
+ @property
+ def summary(self) -> str:
+ head = "адрес %d, %s" % (self.address, self.command)
+ if not self.crc_ok:
+ return "%s, CRC не сошлась" % head
+ return "%s, битов взведено: %d" % (head, len(self.set_bits()))
+
+
+def decode(raw: bytes, layout: Optional[Layout] = None) -> TmsRecord:
+ """@brief Раскладывает готовые байты посылки по полям.
+
+ @param raw Ровно ``layout.frame_size`` байт.
+ @param layout Раскладка; по умолчанию из ``message2.h``.
+ @throws ValueError если длина не совпадает с раскладкой.
+ """
+ layout = layout or default_layout()
+ if len(raw) != layout.frame_size:
+ raise ValueError("ожидалось %d байт, получено %d"
+ % (layout.frame_size, len(raw)))
+ analogs = []
+ for index in range(layout.analog_count):
+ offset = layout.analog_offset + 2 * index
+ analogs.append(int.from_bytes(raw[offset:offset + 2], "little", signed=True))
+ return TmsRecord(
+ address=raw[0],
+ number=raw[1],
+ digits=bytes(raw[2:layout.analog_offset]),
+ analogs=analogs,
+ crc_frame=int.from_bytes(raw[-3:-1], "little"),
+ crc_calc=layout.crc(raw[:-CRC_TAIL]),
+ add_byte=raw[-1],
+ raw=bytes(raw),
+ layout=layout,
+ )
+
+
+def build(address: int, number: int, digits: bytes, analogs: List[int],
+ layout: Optional[Layout] = None, add_byte: int = 0) -> bytes:
+ """@brief Собирает посылку — нужна демо-потоку и тестам разбора."""
+ layout = layout or default_layout()
+ if len(digits) != layout.digit_bytes:
+ raise ValueError("ожидалось %d байт дискретных величин" % layout.digit_bytes)
+ if len(analogs) != layout.analog_count:
+ raise ValueError("ожидалось %d аналоговых величин" % layout.analog_count)
+ body = bytearray([address & 0xFF, number & 0xFF])
+ body += bytes(digits)
+ for value in analogs:
+ body += int(value).to_bytes(2, "little", signed=True)
+ body += layout.crc(bytes(body)).to_bytes(2, "little")
+ body.append(add_byte & 0xFF)
+ return bytes(body)
+
+
+class TmsParser:
+ """@brief Потоковый разборщик посылок ТМС.
+
+ Сигнатуры у формата нет, поэтому границу ищем перебором смещений: посылка
+ считается найденной там, где сходится CRC. Каждый пропущенный при поиске
+ байт идёт в ``resync_bytes``.
+
+ Раскладка меняется на лету (``set_layout``) — оператор подбирает число
+ полей прямо во время приёма.
+ """
+
+ def __init__(self, layout: Optional[Layout] = None) -> None:
+ self._buf = bytearray()
+ self.layout = layout or default_layout()
+ self.stats = {"frames": 0, "resync_bytes": 0}
+
+ def set_layout(self, layout: Layout) -> None:
+ """Меняет раскладку и сбрасывает недособранный хвост буфера."""
+ self.layout = layout
+ self._buf.clear()
+
+ def reset(self) -> None:
+ self._buf.clear()
+ self.stats.update(frames=0, resync_bytes=0)
+
+ def feed(self, chunk: bytes) -> List[TmsRecord]:
+ """@brief Принимает кусок потока и отдаёт все распознанные посылки."""
+ self._buf += chunk
+ size = self.layout.frame_size
+ records: List[TmsRecord] = []
+ while len(self._buf) >= size:
+ candidate = bytes(self._buf[:size])
+ if self.layout.crc(candidate[:-CRC_TAIL]) == int.from_bytes(
+ candidate[-3:-1], "little"):
+ records.append(decode(candidate, self.layout))
+ self.stats["frames"] += 1
+ del self._buf[:size]
+ continue
+ del self._buf[:1]
+ self.stats["resync_bytes"] += 1
+ return records
+
+
+class TmsDemoSource:
+ """@brief Источник демонстрационных посылок ТМС: разбор без железа.
+
+ Повторяет то, что кладёт в линию ``RS_Send``, включая лишний байт сверх
+ структуры, — на нём и проверяется ресинхронизация разборщика.
+
+ @param layout Раскладка выдаваемых посылок.
+ @param rng Источник случайности; передаётся в тестах для повторяемости.
+ """
+
+ def __init__(self, layout: Optional[Layout] = None,
+ rng: Optional[random.Random] = None) -> None:
+ self.layout = layout or default_layout()
+ self._rng = rng or random.Random()
+ self._tick = 0
+
+ def next_frame(self) -> bytes:
+ """@brief Очередная посылка плюс хвостовой байт от ``RS_Send``."""
+ self._tick += 1
+ digits = bytes(self._rng.getrandbits(8)
+ for _ in range(self.layout.digit_bytes))
+ analogs = [self._rng.randint(-5000, 5000)
+ for _ in range(self.layout.analog_count)]
+ analogs[0] = 5000 + self._tick % 100 # FREQ_OUT ползёт заметно
+ frame = build(1, 65, digits, analogs, self.layout,
+ add_byte=self._tick & 0xFF)
+ return frame + bytes([0x00])
diff --git a/python/set_devices/ump_logger.py b/python/set_devices/ump_logger.py
new file mode 100644
index 0000000..2ae5f45
--- /dev/null
+++ b/python/set_devices/ump_logger.py
@@ -0,0 +1,132 @@
+"""Протокол кольцевого логгера УМП v2.
+
+Технологические регистры 0..127 не изменены. Расширение 0x1000 сначала
+проверяется по сигнатуре и версии: старая прошивка игнорировала старший
+байт адреса, поэтому диагностическое чтение безопасно попадёт в 0..15,
+но управляющие записи до успешной проверки запрещены в панели.
+CRC и проверка ответов остаются в общей библиотеке протокола ПМ35.
+"""
+from dataclasses import dataclass
+from protocan import periph28335 as protocol
+
+BASE = 0x1000
+DATA = 0x1010
+LIVE = 0x1100
+EMU_CONTROL = 0x1200
+FAST_CAPABILITY = 0x1220
+FAST_DATA = 0x2000
+WORDS = 40
+CAPACITY = 64000 // (2 * WORDS)
+FIELDS = (
+ ('off_trigger', 'Событие отключения', 2, 8),
+ ('post_truncated', 'Усечение постинтервала', 2, 9),
+ ('start', 'Команда пуск УМП', 3, 7),
+ ('reset', 'Команда сброс УМП', 3, 6),
+ ('omega', 'Вход OMEGA', 4, 0),
+ ('start_in', 'Вход Start_1', 4, 1),
+ ('res_in', 'Вход Res_in', 4, 2),
+ ('start_out', 'Выход START_DPTB', 5, 0),
+ ('stop_out', 'Выход STOP_DPTB', 5, 1),
+ ('res_out', 'Выход RES_OUT_2', 5, 2),
+ ('impulse', 'Остаток импульса, мс', 6, None),
+ ('wake', 'Инициализация ЦАП, тики ЦАП', 7, None),
+ ('dac', 'Код ЦАП', 8, None),
+ ('current', 'Расчётное задание тока, мА', 9, None),
+ ('sensor0', 'Измерение 0', 10, None),
+ ('sensor1', 'Измерение 1', 11, None),
+ ('sensor2', 'Измерение 2', 12, None),
+ ('sensor3', 'Измерение 3', 13, None),
+ ('error0', 'Ошибки 0 (маска)', 14, None),
+ ('error1', 'Ошибки 1 (маска)', 15, None),
+ ('error2', 'Ошибки 2 (маска)', 16, None),
+ ('error3', 'Ошибки 3 (маска)', 17, None),
+ ('can_header', 'CAN адрес/маска', 18, None),
+ ('can0', 'CAN слово 0', 19, None),
+ ('can1', 'CAN слово 1', 20, None),
+ ('can2', 'CAN слово 2', 21, None),
+ ('can_count', 'Количество CAN RX (mod 65536)', 22, None),
+ ('can_timeout', 'Таймауты CAN TX (mod 65536)', 23, None),
+ ('commands', 'Команды (маска)', 3, None),
+ ('go', 'Начальная уставка, мА', 24, None),
+ ('stop', 'Конечная уставка, мА', 25, None),
+ ('dac_min', 'Код калибровки 4 мА', 26, None),
+ ('dac_max', 'Код калибровки 20 мА', 27, None),
+ ('dac_cal', 'Калибровочный код ЦАП', 28, None),
+ ('dac04', 'DAC_04 (регистр)', 29, None),
+ ('dac20', 'DAC_20 (регистр)', 30, None),
+ ('ramp', 'Счётчик разгона', 31, None),
+ ('duration', 'Длительность разгона, тики ЦАП', 32, None),
+ ('effective_go', 'Начальная уставка после ограничений, мА', 33, None),
+ ('effective_stop', 'Конечная уставка после ограничений, мА', 34, None),
+ ('position', 'Положение разгона (kod)', 35, None),
+ ('adc0', 'АЦП канал 0', 36, None),
+ ('adc1', 'АЦП канал 1', 37, None),
+ ('adc2', 'АЦП канал 2', 38, None),
+ ('adc3', 'АЦП канал 3', 39, None),
+)
+
+
+def request(controller: int, function: int, address: int, value: int) -> bytes:
+ if not 0 <= controller <= 255 or not 0 <= value <= 65535:
+ raise ValueError('Недопустимый адрес контроллера или значение')
+ if function == 3:
+ valid = ((address == BASE and value == 16) or
+ (address == DATA and 1 <= value <= 120 and value % WORDS == 0) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
+ or (address == FAST_CAPABILITY and value == 4)
+ or (FAST_DATA <= address < FAST_DATA + CAPACITY * WORDS
+ and (address - FAST_DATA) % WORDS == 0
+ and 1 <= value <= 120 and value % WORDS == 0
+ and address + value <= FAST_DATA + CAPACITY * WORDS))
+ else:
+ valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
+ (address == EMU_CONTROL + 2 and value in (0, 1)))
+ if not valid:
+ raise ValueError('Недопустимый запрос логгера УМП')
+ return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big'))
+
+
+@dataclass(frozen=True)
+class Status:
+ state: int
+ count: int
+ period: int
+ mode: int
+ frequency: int
+ generation: int
+ archive_count: int
+ post_ms: int
+ live_state: int
+
+ @classmethod
+ def decode(cls, words):
+ if (len(words) != 16 or words[0:2] != (0x554D, 2) or
+ words[4] != WORDS or words[5] != CAPACITY):
+ raise ValueError('Прошивка не поддерживает логгер УМП v2')
+ if words[9] != 6:
+ raise ValueError('Логгер УМП доступен только на плате нагрузки dsk_LOAD')
+ if words[2] not in (0, 1, 2, 3) or words[3] > CAPACITY or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > CAPACITY or words[14] > 1000 or words[15] not in (0, 1, 3):
+ raise ValueError('Повреждён статус логгера')
+ return cls(words[2], words[3], words[6], words[10], words[11], *words[12:16])
+
+
+def sample(words, frequency=1000):
+ if len(words) != WORDS or frequency <= 0:
+ raise ValueError('Неверная запись логгера')
+ result = {'time_ms': (words[0] | words[1] << 16) * 1000 / frequency, 'event': words[2]}
+ for key, _label, index, bit in FIELDS:
+ value = words[index]
+ if bit is not None:
+ value = (value >> bit) & 1
+ elif 10 <= index <= 13 and value >= 32768:
+ value -= 65536
+ # Физические единицы приводим здесь, одинаково для CSV и графика.
+ if key in ('current', 'effective_go', 'effective_stop'):
+ value /= 10.0
+ result[key] = value
+ return result
+
+
+def emulator_enabled(words):
+ if len(words) != 4 or tuple(words[:2]) != (0x454D, 1) or words[2] not in (0, 1):
+ raise ValueError('Плата не поддерживает управление эмуляцией STM')
+ return bool(words[2])
diff --git a/python/set_devices/ump_logger_can.py b/python/set_devices/ump_logger_can.py
new file mode 100644
index 0000000..d714ed4
--- /dev/null
+++ b/python/set_devices/ump_logger_can.py
@@ -0,0 +1,73 @@
+"""CAN-транспорт расширения регистратора periph_28335.
+
+Служебные запросы идут на штатный RX ID платы 0xBA0000 + Mode - 1.
+Ответы имеют отдельный ID 0xBA0020 + Mode - 1. Технологическая телеметрия
+не принимается за ответ логгера. Данные кадра следуют порядку MDL, MDH
+существующей прошивки; каждое слово передаётся старшим байтом вперёд.
+"""
+from dataclasses import dataclass, field
+import struct
+from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL
+
+MARKER = 0x1F00
+MAX_WORDS = 124
+
+
+def request_ids(mode):
+ if not 1 <= mode <= 16:
+ raise ValueError('Номер CAN-платы должен быть 1…16 (Mode прошивки)')
+ return 0xBA0000 + mode - 1, 0xBA0020 + mode - 1
+
+
+def build_request(mode, token, function, address, value):
+ if not 1 <= token <= 255:
+ raise ValueError('Неверный номер CAN-запроса')
+ valid = ((function == 3 and ((address == BASE and value == 16) or
+ (address == DATA and value in (40, 80, 120)) or
+ (address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
+ (function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
+ (address == EMU_CONTROL+2 and value in (0, 1)))))
+ if not valid:
+ raise ValueError('Недопустимый запрос CAN-логгера')
+ can_id, _ = request_ids(mode)
+ return can_id, struct.pack('>4H', address, value, MARKER, token << 8 | function)
+
+
+@dataclass
+class Response:
+ """Собирает один снимок. Дубликаты допустимы, противоречащие — ошибка.
+
+ Номер фрагмента позволяет восстановить порядок при задержках адаптера.
+ До получения всех фрагментов снимок не передаётся в график/архив.
+ """
+ mode: int
+ token: int
+ function: int
+ address: int
+ value: int
+ fragments: dict = field(default_factory=dict)
+
+ def feed(self, can_id, data):
+ if can_id != request_ids(self.mode)[1] or len(data) != 8:
+ return None
+ first, second, header, tag = struct.unpack('>4H', bytes(data))
+ if not MARKER <= header < MARKER + 62 or tag >> 8 != self.token:
+ return None
+ if tag & 255:
+ raise ValueError(f'Плата отклонила CAN-запрос логгера: ошибка {tag & 255}')
+ count = self.value if self.function == 3 else 2
+ index = header - MARKER
+ if index >= count // 2:
+ raise ValueError('Лишний фрагмент CAN-ответа логгера')
+ words = (first, second)
+ if index in self.fragments and self.fragments[index] != words:
+ raise ValueError('Противоречащие фрагменты CAN-ответа')
+ self.fragments[index] = words
+ if len(self.fragments) != count // 2:
+ return None
+ result = tuple(word for n in range(count // 2) for word in self.fragments[n])
+ if self.function == 6:
+ if result != (self.address, self.value):
+ raise ValueError('CAN-плата подтвердила другую запись')
+ return ()
+ return result
diff --git a/python/set_devices/ump_logger_demo.py b/python/set_devices/ump_logger_demo.py
new file mode 100644
index 0000000..2006312
--- /dev/null
+++ b/python/set_devices/ump_logger_demo.py
@@ -0,0 +1,57 @@
+"""Синтетические записи УМП для проверки графиков без подключения платы."""
+import math
+
+from set_devices import ump_logger as codec
+
+
+def sample(time_ms, period_ms=20):
+ """Повторяемый цикл: пуск, разгон 6–16 мА, отключение и спад тока."""
+ phase = time_ms % 6000
+ started = 1000 <= phase < 5000
+ reset = 5000 <= phase < 5300
+ # Как в прошивке: фронт команды включает выход на 1000 мс.
+ start_out = 1000 <= phase < 2000
+ stop_out = 5000 <= phase < 6000
+ ramp = max(0, min(2500, phase - 1000))
+ if started:
+ current = 6 + 10 * ramp / 2500 + 0.2 * math.sin(phase / 90)
+ elif phase >= 5000:
+ current = 4 + 12 * max(0, 1 - (phase - 5000) / 300)
+ else:
+ current = 4
+
+ words = [0] * codec.WORDS
+ words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535
+ words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0)
+ words[3] = (int(started) << 7) | (int(reset) << 6)
+ words[4] = int(1200 <= phase < 5100) | (int(started) << 1) | (int(reset) << 2)
+ words[5] = int(start_out) | (int(stop_out) << 1) | (int(started) << 2)
+ words[6] = (2000 - phase) if start_out else ((6000 - phase) if stop_out else 0)
+ words[7] = max(0, 250 - phase)
+ words[8] = round(500 + (current - 4) * 3000 / 16)
+ words[9] = round(current * 10)
+ for channel in range(4):
+ measured = round(120 * math.sin(phase / (180 + channel * 70) + channel))
+ words[10 + channel] = measured & 65535
+ words[14 + channel] = (1 << channel) if 5100 <= phase < 5400 else 0
+ words[36 + channel] = 2048 + measured * 4
+ words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0]
+ words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500]
+ words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)]
+ return codec.sample(words)
+
+
+def capture(period_ms):
+ """Пример архива: до 399 точек предыстории, отключение и 1 с после него."""
+ before = list(range(5000, -1, -period_ms))[1:400][::-1]
+ after = list(range(5000 + period_ms, 6000, period_ms)) + [6000]
+ available = codec.CAPACITY - len(before) - 1
+ truncated = len(after) > available
+ if truncated:
+ after = after[:available - 1] + after[-1:]
+ records = [sample(t, period_ms) for t in before + [5000] + after]
+ # Последний снимок относится к концу постинтервала, а не новому циклу.
+ records[-1]['event'] = 5 | (0x200 if truncated else 0)
+ records[-1]['post_truncated'] = int(truncated)
+ records[-1]['off_trigger'] = 0
+ return records
diff --git a/python/tests/test_logic_analyzer_analysis.py b/python/tests/test_logic_analyzer_analysis.py
new file mode 100644
index 0000000..584239c
--- /dev/null
+++ b/python/tests/test_logic_analyzer_analysis.py
@@ -0,0 +1,186 @@
+from array import array
+from collections import Counter
+from pathlib import Path
+import struct
+import unittest
+
+from logic_analyzer.files import DigitalChannel, DigitalCapture, read_capture, ImportCancelled
+from logic_analyzer.analysis import analyze_capture, can_crc, pulse_measurements, interval_edges
+from logic_analyzer.decoders.set_uart import crc16
+
+
+PING = bytes.fromhex('A5 5A 02 08 01 00 00 00 2A 00 34 12 00 00 33 EC 33 04')
+
+
+def bits_of(value, count):
+ return [(value >> bit) & 1 for bit in range(count - 1, -1, -1)]
+
+
+def capture_bits(bits, rate, invert=False, origin=0):
+ edges = array('d')
+ for i in range(1, len(bits)):
+ if bits[i] != bits[i - 1]:
+ edges.append(origin + i / rate)
+ channel = DigitalChannel('bus', bits[0] ^ invert, edges)
+ return DigitalCapture('test', 'test', [channel], origin, origin + len(bits) / rate)
+
+
+def uart_capture(data, baud=115200, parity='none', stops=1, invert=False, bad_parity=False, bad_stop=False):
+ bits = [1] * 5
+ for byte in data:
+ bits += [0] + [(byte >> i) & 1 for i in range(8)]
+ if parity != 'none':
+ bits += [((sum(bits[-8:]) + (parity == 'odd')) & 1) ^ bad_parity]
+ bits += [0 if bad_stop else 1] * stops
+ bits += [1] * 5
+ return capture_bits(bits, baud, invert)
+
+
+def can_packet(ident, data, extended=True, remote=False, corrupt_crc=False, ack=True):
+ if extended:
+ bits = [0] + bits_of(ident >> 18, 11) + [1, 1] + bits_of(ident & 0x3ffff, 18) + [int(remote), 0, 0]
+ else:
+ bits = [0] + bits_of(ident, 11) + [int(remote), 0, 0]
+ bits += bits_of(len(data), 4)
+ if not remote:
+ for byte in data:
+ bits += bits_of(byte, 8)
+ bits += bits_of(can_crc(bits) ^ int(corrupt_crc), 15)
+ stuffed, last, run = [], None, 0
+ for value in bits:
+ stuffed.append(value)
+ run = run + 1 if value == last else 1
+ last = value
+ if run == 5:
+ stuffed.append(1 - value)
+ last, run = 1 - value, 1
+ return stuffed + [1, 0 if ack else 1, 1] + [1] * 7
+
+
+class LogicAnalysisTests(unittest.TestCase):
+ def test_uart_byte_values_inverted_and_parity(self):
+ for invert in (False, True):
+ for parity in ('none', 'even', 'odd'):
+ cap = uart_capture(b'\x00\xff\xa5\x5a', parity=parity, stops=2, invert=invert)
+ result = analyze_capture(cap, dict(mode='UART', baudrate=115200, parity=parity, stops=2, inverted=invert))
+ self.assertEqual([e.text for e in result.events], ['UART 0x00', 'UART 0xFF', 'UART 0xA5', 'UART 0x5A'])
+
+ def test_uart_errors_are_not_accepted_as_set_frames(self):
+ cap = uart_capture(PING, parity='even', bad_parity=True)
+ result = analyze_capture(cap, dict(mode='SET UART', parity='even'))
+ self.assertTrue(result.events)
+ self.assertTrue(all(e.kind == 'error' for e in result.events))
+ result = analyze_capture(uart_capture(b'\xa5', bad_stop=True), dict(mode='UART'))
+ self.assertTrue(any('стопового' in e.text for e in result.events))
+
+ def test_set_ping_bridge_crc_and_resynchronization(self):
+ damaged = bytearray(PING)
+ damaged[-1] ^= 1
+ data = b'noise' + damaged + PING + bytes.fromhex('AA 55 08 01 01 67 45 23 01 AA BB FE 14')
+ events = analyze_capture(uart_capture(data), dict(mode='SET UART')).events
+ self.assertTrue(any('CRC' in e.text and e.kind == 'error' for e in events))
+ self.assertEqual(sum('SET v2 PING' in e.text for e in events), 1)
+ self.assertTrue(any('ProtoCAN seq=' in e.text for e in events))
+ self.assertTrue(any('ProtoCAN GAS' in e.text for e in events))
+
+ def test_pm35_request_and_special_response(self):
+ raw = bytes.fromhex('100300000002')
+ raw += struct.pack('>= 7
+ while value >= 64:
+ parts.append(128 | (value & 127))
+ value >>= 7
+ return bytes([64 | value] + list(reversed(parts)))
+
+
+def sal_binary(version=1, initial=1):
+ head = b'' + struct.pack('', version, 0,
+ initial, start, end, len(edges)) +
+ struct.pack('<%dd' % len(edges), *edges))
+
+
+class SaleaeCoreTests(unittest.TestCase):
+ def test_constant_channel_tail_and_sparse_channel_numbers(self):
+ with tempfile.TemporaryDirectory() as directory:
+ binary(Path(directory) / 'digital_2.bin', [.2, .3])
+ binary(Path(directory) / 'digital_7.bin', initial=1)
+ capture = saleae.read_binary(directory, (2, 7))
+ self.assertEqual([c.name for c in capture.channels], ['D2', 'D7'])
+ self.assertEqual(capture.end, 1.)
+ self.assertEqual(capture.channels[0].level_at(.25), 1)
+ self.assertEqual(capture.channels[0].level_at(.9), 0)
+ self.assertEqual(capture.channels[1].level_at(.9), 1)
+
+ def test_reject_bad_binary_and_cancel(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / 'digital_0.bin'
+ for kwargs in ({'version': 1}, {'edges': [.4, .3]}, {'edges': [float('nan')]},
+ {'end': -1}, {'initial': 2}):
+ binary(path, **kwargs)
+ with self.assertRaises(ValueError):
+ saleae.read_binary(directory, (0,))
+ binary(path, [.2])
+ with self.assertRaises(ImportCancelled):
+ saleae.read_binary(directory, (0,), lambda: True)
+
+ def test_settings_validation(self):
+ good = saleae.SaleaeSettings('id')
+ good.validate()
+ for kwargs in ({'channels': ()}, {'channels': (1, 1)}, {'channels': (16,)},
+ {'sample_rate': 0}, {'duration': float('nan')}, {'port': 0},
+ {'buffer_mb': 1}, {'threshold': 5}, {'device_id': ''}):
+ with self.assertRaises(ValueError):
+ replace(good, **kwargs).validate()
+
+ def fake_api(self):
+ api = MagicMock()
+ manager = api.Manager.connect.return_value.__enter__.return_value
+ capture = manager.start_capture.return_value
+
+ def export(directory, **kwargs):
+ for channel in kwargs['digital_channels']:
+ binary(Path(directory) / ('digital_%d.bin' % channel), [.25])
+ capture.export_raw_data_binary.side_effect = export
+ return api, manager, capture
+
+ def test_stop_exports_and_closes_exactly_once(self):
+ api, manager, capture = self.fake_api()
+ settings = saleae.SaleaeSettings('abc', channels=(3, 5), threshold=1.8)
+ with patch.object(saleae, 'automation_api', return_value=api):
+ result = saleae.acquire(settings, lambda: True, lambda: False)
+ self.assertEqual(result.sample_rate, 24000000)
+ self.assertIn('abc', result.source)
+ self.assertEqual([c.name for c in result.channels], ['D3', 'D5'])
+ capture.stop.assert_called_once()
+ capture.wait.assert_not_called()
+ capture.close.assert_called_once()
+ api.LogicDeviceConfiguration.assert_called_once_with(
+ enabled_digital_channels=[3, 5], digital_sample_rate=24000000, digital_threshold_volts=1.8)
+ self.assertFalse(Path(capture.export_raw_data_binary.call_args[0][0]).exists())
+
+ def test_cancel_stops_without_export_and_failure_closes(self):
+ api, manager, capture = self.fake_api()
+ with patch.object(saleae, 'automation_api', return_value=api):
+ calls = iter((False, True, True))
+ with self.assertRaises(ImportCancelled):
+ saleae.acquire(saleae.SaleaeSettings('id'), lambda: False, lambda: next(calls))
+ capture.stop.assert_called_once()
+ capture.close.assert_called_once()
+ capture.export_raw_data_binary.assert_not_called()
+ capture.reset_mock()
+ capture.stop.side_effect = RuntimeError('USB disconnected')
+ with patch.object(saleae, 'automation_api', return_value=api):
+ with self.assertRaisesRegex(RuntimeError, 'USB'):
+ saleae.acquire(saleae.SaleaeSettings('id'), lambda: True, lambda: False)
+ capture.stop.assert_called_once()
+ capture.close.assert_called_once()
diff --git a/python/tests/test_shared_library_boundary.py b/python/tests/test_shared_library_boundary.py
new file mode 100644
index 0000000..cb3f4bb
--- /dev/null
+++ b/python/tests/test_shared_library_boundary.py
@@ -0,0 +1,38 @@
+"""Shared libraries must import without the desktop application or Qt."""
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+class LibraryBoundaryTests(unittest.TestCase):
+ def test_cores_import_with_gui_and_qt_blocked(self):
+ root = Path(__file__).resolve().parents[1]
+ code = '''
+import importlib, importlib.abc, pkgutil, sys
+class BlockGui(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.split('.')[0] in ('gui_desktop', 'PySide6', 'PySide2'):
+ raise ImportError('Application dependency in shared core: ' + fullname)
+sys.meta_path.insert(0, BlockGui())
+for name in ('logic_analyzer', 'set_devices'):
+ package = importlib.import_module(name)
+ for module in pkgutil.walk_packages(package.__path__, name + '.'):
+ if '.qt_ports' not in module.name:
+ importlib.import_module(module.name)
+assert 'gui_desktop' not in sys.modules
+'''
+ env = dict(os.environ, PYTHONPATH=str(root))
+ with tempfile.TemporaryDirectory() as directory:
+ result = subprocess.run([sys.executable, '-c', code], cwd=directory, env=env,
+ capture_output=True, text=True, timeout=30)
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_no_reverse_imports_in_sources(self):
+ root = Path(__file__).resolve().parents[1]
+ for name in ('logic_analyzer', 'set_devices', 'altera_logic'):
+ for path in (root / name).rglob('*.py'):
+ text = path.read_text(encoding='utf-8')
+ self.assertNotIn('gui_desktop', text, str(path))