Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fe22439c | ||
|
|
26f9d62896 | ||
|
|
4d101302be | ||
|
|
07929c880a | ||
|
|
4f7f6cdb87 | ||
|
|
1fc88684d5 | ||
|
|
fa5ea166a9 | ||
|
|
062f064c85 |
@@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.16) # Эта версия поддерживает используемые target_compile_features и CTest без платформенных расширений.
|
||||
project(can485_monitor C) # Переносимое ядро собирается независимо от Linux-порта и приложения SETGUI.
|
||||
add_library(can485_monitor STATIC src/can485_monitor.c) # Все платформы связываются с одним обработчиком команд и wire-форматов.
|
||||
target_include_directories(can485_monitor PUBLIC include) # Потребитель получает только публичный C-контракт библиотеки.
|
||||
target_compile_features(can485_monitor PUBLIC c_std_99) # Ядро не требует C11 и платформенных расширений языка.
|
||||
include(CTest) # Стандартный BUILD_TESTING управляет автономной проверкой библиотеки.
|
||||
if(BUILD_TESTING) # Host-проверка не требует Linux, Qt и физической CAN-шины.
|
||||
add_executable(test_can485_monitor tests/test_can485_monitor.c) # Использует независимые wire-векторы и фикстуру аппаратных отказов.
|
||||
target_link_libraries(test_can485_monitor PRIVATE can485_monitor) # Проверяет настоящую библиотеку, не тестовую копию алгоритма.
|
||||
add_test(NAME can485_monitor_contract COMMAND test_can485_monitor) # Регистрирует единый поведенческий контракт для всех аппаратных портов.
|
||||
endif() # Завершает необязательную сборку тестов.
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux") # Linux-заголовки не попадают в сборки ESP32, Windows и других MCU.
|
||||
add_executable(zynq-can485d ports/zynq-linux/main.c) # SocketCAN, USB и Ethernet реализуются только аппаратным портом.
|
||||
target_link_libraries(zynq-can485d PRIVATE can485_monitor) # Linux использует ровно то же C99-ядро, что host-проверка.
|
||||
endif() # Завершает платформенную часть библиотеки.
|
||||
@@ -0,0 +1,75 @@
|
||||
# CAN485 monitor
|
||||
|
||||
Общее C99-ядро команд CAN485 DevBoard/SETGUI и Linux-порт для PS CAN Zynq.
|
||||
Протоколы CAN (SETCAN, ProtoCAN, УМП, Balsam, DEIF и остальные) разбираются
|
||||
существующими библиотеками `templates/python`, без копий в проекте платы.
|
||||
|
||||
Слои: SETGUI BoardTab → `qt_ports/can485_port.py` → Ethernet TCP или USB tty →
|
||||
`ports/zynq-linux/main.c` → `can485_monitor.c` → SocketCAN / RS485 / каталог SD.
|
||||
В ядре нет Linux, Qt, HAL, выделения памяти и глобального контекста.
|
||||
|
||||
| Файл | Назначение и зависимости |
|
||||
|---|---|
|
||||
| `include/can485_monitor.h` | Контракт C99, `stdint.h`, `stddef.h` |
|
||||
| `src/can485_monitor.c` | Проверка команд, фильтр, маршруты, AA55/CRC8, строки CAN |
|
||||
| `ports/zynq-linux/main.c` | SocketCAN, rtnetlink, TCP, USB tty, UART RS485, SD, POSIX/Linux |
|
||||
| `tests/test_can485_monitor.c` | Эталонный пакет, ошибки/границы, маршрутизация, отказы оборудования |
|
||||
|
||||
Порт предоставляет четыре callback: `emit(context,line)`,
|
||||
`configure(context,kbps,mode,running)`, `send(context,frame,source)` и
|
||||
`control(context,command)`. `source=0` означает CAN, `source=1` — RS485.
|
||||
`configure` и `send` возвращают 1 только после принятия операции драйвером.
|
||||
Аппаратный ACK этим результатом не подтверждается. Контекст и буферы принадлежат приложению.
|
||||
|
||||
```c
|
||||
#include "can485_monitor.h" /* Подключает переносимое ядро библиотеки. */
|
||||
void example(cm_port port) { /* Аппаратные callback передаются вызывающей платформой. */
|
||||
cm_monitor state; /* Контекст не разделяется с другим экземпляром моста. */
|
||||
cm_init(&state, port); /* CAN остановлен, оба направления маршрута выключены. */
|
||||
cm_command(&state, "V"); /* Версия и статус не меняют оборудование. */
|
||||
cm_command(&state, "S125"); /* Запрашивает CAN 125 кбит/с через аппаратный callback. */
|
||||
cm_command(&state, "M1"); /* Listen-only запрещает ручной TX и RS485→CAN. */
|
||||
cm_frame frame = {0x123, 2, 0, {0xAB, 0xCD}}; /* STD ID и два исходных байта. */
|
||||
cm_receive(&state, &frame, 0, 12345); /* Приём CAN в момент 12,345 с монотонного времени. */
|
||||
cm_status(&state, 12345, "sd=idle"); /* Статус содержит только известные платформе поля. */
|
||||
} /* Реальная плата передаёт только фактически принятые кадры в cm_receive. */
|
||||
```
|
||||
|
||||
Поддержаны команды `V ? H S M O C F Q R B T X Z L D G I` и Linux-расширение
|
||||
`Ncan0` / `Ncan1`. Команды строго проверяются целиком. RS485-пакет:
|
||||
`AA 55 FLAGS ID[4]be DLC DATA[8] CRC8(FLAGS..DATA, poly=0x07, init=0)`.
|
||||
CAN STD/EXT/RTR сохраняются. DATA TX через `T` повторяет контракт существующего ESP GUI.
|
||||
|
||||
Linux-порт запускается с CAN остановленным, маршрутами и записью SD выключенными.
|
||||
`S`/`M`/`O` запускают CAN; `C` останавливает интерфейс. Настройка через rtnetlink
|
||||
требует root или `CAP_NET_ADMIN`; RAW CAN — соответствующих прав Linux.
|
||||
M2 требует реальной поддержки `CAN_CTRLMODE_PRESUME_ACK`; ошибка драйвера
|
||||
не маскируется под успешный no-ack. Фильтр программный и не отключает аппаратный ACK.
|
||||
|
||||
TCP-порт по умолчанию **37579**, один управляющий клиент. Подключённый USB
|
||||
может получать поток, но команды второго транспорта возвращают `#ERR busy`.
|
||||
Второй TCP-клиент не вытесняет первого. При обрыве владельца маршруты выключаются,
|
||||
его SD-download отменяется. Медленный клиент отключается при заполнении очереди 64 КиБ.
|
||||
Служба не включает периодический CAN TX: этот таймер принадлежит SETGUI и
|
||||
останавливается при отключении. Смена `N` закрывает только собственный CAN socket.
|
||||
|
||||
`--usb /dev/ttyGS0` использует USB OTG CDC ACM, если образ Linux предоставляет
|
||||
gadget serial. Также допустим выделенный UART через USB-UART, свободный от
|
||||
console/getty. Разъём USB HOST и USB-JTAG сами по себе CDC не создают.
|
||||
USB и RS485 должны использовать разные tty. RTS/CTS выключены. RS485 требует
|
||||
физического трансивера с аппаратным управлением направлением либо предварительной
|
||||
настройки драйвера UART; Linux-порт не переключает произвольные GPIO платы.
|
||||
|
||||
SD — заранее выбранный каталог `--logs`, например `/mnt/sd/can-logs`.
|
||||
Журналы `can_*.csv` содержат совместимые текстовые строки кадров; импорт SETGUI
|
||||
распознаёт этот формат автоматически. Выдача файла асинхронная, блоками 128 байт,
|
||||
с проверяемыми offset/size/CRC32. Путь ограничен одним каталогом, `..` и symlink
|
||||
запрещены. Запись SD выключается перед чтением. `vin`, TEC и REC не выдумываются,
|
||||
если платформа не получила реальные значения.
|
||||
|
||||
Используется: Zynq RK7020 (`demo/can485`) и CAN-панель SETGUI (`pc/zynq_can_panel.py`).
|
||||
Для единого физического источника задавайте одинаковый **TEMPLATES_ROOT** при
|
||||
запуске SETGUI и сборке ARM (`build.ps1 -Templates <тот же каталог>`).
|
||||
После изменения Python перезапустите приложение; изменение C требует пересборки
|
||||
службы, изменение встроенных библиотек EXE — пересборки EXE. Подмодули разных
|
||||
checkout обновляются явно: Git не обновляет уже собранную прошивку автоматически.
|
||||
@@ -0,0 +1,24 @@
|
||||
#ifndef CAN485_MONITOR_H /* Защищает публичный контракт монитора от повторного включения. */
|
||||
#define CAN485_MONITOR_H /* Одинаковое ядро обслуживает команды DevBoard на разных платформах. */
|
||||
#include <stdint.h> /* Фиксирует размеры CAN ID, счётчиков и байтов независимо от процессора. */
|
||||
#include <stddef.h> /* Размеры выходных строк выражены штатным типом size_t. */
|
||||
typedef struct { uint32_t id; uint8_t dlc, flags, data[8]; } cm_frame; /* flags: бит 0 EXT, бит 1 RTR; Classic CAN содержит до восьми байтов. */
|
||||
typedef struct { /* Платформа предоставляет ввод-вывод, не копируя разбор команд. */
|
||||
void (*emit)(void *, const char *); /* Принимает одну законченную строку с LF для Ethernet, USB и журнала. */
|
||||
int (*configure)(void *, unsigned, unsigned, int); /* Применяет битрейт в кбит/с, режим M0/M1/M2 и состояние драйвера; ноль означает ошибку. */
|
||||
int (*send)(void *, const cm_frame *, int); /* Передаёт кадр в CAN при source=0 либо в RS485 при source=1; успех означает принятие локальным драйвером. */
|
||||
int (*control)(void *, const char *); /* Обслуживает аппаратную скорость B и файловые L/D/G/I; ответы формирует сам порт. */
|
||||
void *context; /* Контекст принадлежит вызывающему коду и позволяет иметь несколько независимых мостов. */
|
||||
} cm_port; /* В ядре нет HAL, Linux, Qt, динамической памяти и глобального состояния. */
|
||||
typedef struct { /* Состояние протокола отделено от сетевых клиентов и файловых дескрипторов. */
|
||||
cm_port port; unsigned bitrate, mode, route, baud, running; /* Скорости заданы в кбит/с для CAN и бод для RS485; route использует два младших бита направлений. */
|
||||
uint32_t filter_id, filter_mask, rx, tx, rsrx, rstx, buserr, dropcan, droprs, rscrc; /* Счётчики относятся к фактическим операциям порта; фильтр сравнивает только 29 бит ID. */
|
||||
uint64_t now; /* Последнее подтверждённое монотонное время позволяет ответу V сохранять реальный uptime между периодическими статусами. */
|
||||
} cm_monitor; /* Всё изменяемое состояние хранится в экземпляре вызывающего приложения. */
|
||||
void cm_init(cm_monitor *, cm_port); /* Инициализирует остановленный CAN 125 кбит/с, normal, маршруты выключены и RS485 512000 бод. */
|
||||
void cm_command(cm_monitor *, const char *); /* Проверяет полную команду без CR/LF и применяет её только после успешной валидации. */
|
||||
void cm_receive(cm_monitor *, const cm_frame *, int, uint64_t); /* Принимает кадр CAN=0 либо RS485=1 и монотонное время в миллисекундах. */
|
||||
void cm_status(cm_monitor *, uint64_t, const char *); /* Печатает совместимый #STAT; дополнительная строка содержит подтверждённые поля SD/Linux. */
|
||||
int cm_packet_decode(const uint8_t *, cm_frame *); /* Проверяет все 17 байт AA55/CRC8, формат ID и DLC до маршрутизации RS485. */
|
||||
void cm_packet_encode(const cm_frame *, uint8_t *); /* Формирует RS485 AA55, FLAGS, ID big-endian, DLC, восемь байтов и CRC8/0x07. */
|
||||
#endif /* Завершает независимый публичный контракт CAN485 monitor. */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,6 +39,22 @@ class IdentityTests(unittest.TestCase):
|
||||
self.assertEqual(self.prepare([clean,dirty])["build_id"], "abcdef0+")
|
||||
self.assertEqual(self.prepare([dirty,clean])["build_id"], "abcdef0+")
|
||||
|
||||
def test_project_and_repository_survive_sealing(self): # Проверяет сведения, нужные Публикатору после переноса сборки на другой компьютер.
|
||||
for project_name in (None, "Бальзам — ПМ35"): # Поддерживает прежние конфигурации и явно заданное русское название.
|
||||
settings = {"product": "PM35", "target": "TMS320F28335", "version": [1, 2, 3]} # Код изделия сохраняет техническое назначение независимо от названия проекта.
|
||||
if project_name is not None: # Отсутствующее поле должно использовать совместимое значение по умолчанию.
|
||||
settings["project"] = project_name # Название из конфигурации переносится в паспорт без сокращений.
|
||||
self.config.write_text(json.dumps(settings), encoding="utf-8") # Конфигурация сохраняется в той же кодировке, что и проект пользователя.
|
||||
clean = {"commit": "a" * 40, "dirty": False} # Источник и templates остаются известными чистыми состояниями.
|
||||
metadata = self.prepare([dict(clean), dict(clean)]) # Генерирует настоящий precompile JSON и заголовок без запуска компилятора.
|
||||
self.assertEqual(metadata["project"], project_name or "PM35") # Старый JSON получает код изделия, новый — заданное название.
|
||||
self.assertEqual(metadata["source"]["repository"], str(self.root.resolve())) # Абсолютный путь сохраняется в состоянии исходного проекта.
|
||||
image = self.root / "controller.bin" # Отдельный образ позволяет проверить последующий postbuild manifest.
|
||||
image.write_bytes(b"firmware fixture") # Небольшой файл достаточен для проверки сохранения полей и SHA-256.
|
||||
sealed = identity.seal(image, self.root / "firmware_identity.json") # Штатный postbuild дополняет сохранённое происхождение без чтения нового HEAD.
|
||||
self.assertEqual(sealed["project"], metadata["project"]) # Название доходит до того manifest, который импортирует GUI.
|
||||
self.assertEqual(sealed["source"], metadata["source"]) # Путь, полный коммит и признак dirty переживают запечатывание образа.
|
||||
|
||||
def test_missing_git_is_explicit(self):
|
||||
unknown = {"commit":None,"dirty":None}
|
||||
self.assertEqual(self.prepare([unknown,unknown])["build_id"], "NOGIT000")
|
||||
|
||||
@@ -85,6 +85,7 @@ def prepare(repository: Path, templates: Path, config: Path, output: Path) -> di
|
||||
except FileNotFoundError: # Отсутствие файла после успешного rename является штатным результатом.
|
||||
pass # Другие ошибки файловой системы продолжают останавливать подготовку.
|
||||
source, dependency = git_state(repository), git_state(templates)
|
||||
source = dict(source, repository=str(repository.resolve())) # Сохраняет абсолютный локальный путь исходного проекта для Публикатора без адресов remote и учётных данных.
|
||||
# A clean application built with a modified templates checkout is dirty too.
|
||||
known = source["commit"] is not None and dependency["commit"] is not None
|
||||
dirty = bool(source["dirty"] or dependency["dirty"])
|
||||
@@ -96,7 +97,7 @@ def prepare(repository: Path, templates: Path, config: Path, output: Path) -> di
|
||||
stamp = stamp.replace(microsecond=0)
|
||||
if not 2000 <= stamp.year <= 2199:
|
||||
raise ValueError("build year outside firmware-info v1 range")
|
||||
metadata = {"schema": 1, "product": settings["product"], "target": settings["target"],
|
||||
metadata = {"schema": 1, "project": settings.get("project") or settings["product"], "product": settings["product"], "target": settings["target"], # Необязательное название проекта переносится из конфигурации; старые проекты используют свой код изделия.
|
||||
"version": version, "build_id": build_id, "built_at_utc": stamp.isoformat(),
|
||||
"source": source, "templates": dependency,
|
||||
"identity_contract": 1, "identity_register_count": 12}
|
||||
|
||||
@@ -10,7 +10,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
# SET protocol v2: управление, телеметрия, CAN segmentation и firmware flow.
|
||||
set(SETPROTOCOL_V2_SOURCES
|
||||
src/c2000_sci.c # Собирает общий автомат SCI Boot и TI Flash Kernel A для F28335 во всех портах.
|
||||
src/c2000_sci.c # Общий автомат SCI Boot F28335 входит во все host и MCU цели.
|
||||
src/firmware_image.c
|
||||
src/altera_logic.c
|
||||
src/altera_stream.c
|
||||
@@ -29,6 +29,7 @@ set(SETPROTOCOL_V2_SOURCES
|
||||
|
||||
# Совместимые ProtoCAN/SETGUI v1 форматы переходного периода.
|
||||
set(SETPROTOCOL_LEGACY_SOURCES
|
||||
src/uksvep_can.c # Общий профиль STM32 UKSVEP: маршруты, payload и карта дискретов.
|
||||
src/balsam_can.c
|
||||
src/set_crc.c
|
||||
src/periph28335.c
|
||||
@@ -47,6 +48,7 @@ set(SETPROTOCOL_LEGACY_SOURCES
|
||||
)
|
||||
|
||||
set(SETPROTOCOL_SOURCES
|
||||
src/set_modbus_gateway.c # Единый кодек USB RTU, Ethernet TCP и таблицы маршрутов используется платой и Qt-панелью.
|
||||
src/deif_xdi.c # Общее C99-ядро DEIF для всех платформ.
|
||||
src/set_invertor_gas.c # Подключает две проекции одной карты STM Invertor: GAS 0x3 и масочный GAS SLOT 0x4.
|
||||
src/set_deif_gauge.c
|
||||
@@ -100,10 +102,13 @@ endif()
|
||||
# JNI/CMSIS-порты подключаются целевым проектом и в host CMake не входят.
|
||||
option(SETP_BUILD_TESTS "Build host tests" ON)
|
||||
if(SETP_BUILD_TESTS)
|
||||
add_executable(test_modbus_gateway tests/test_modbus_gateway.c) # Эталонные ADU защищают маршрутизацию, CRC и настройки от расхождений между портами.
|
||||
target_link_libraries(test_modbus_gateway PRIVATE setprotocol_static) # Проверяет тот же исходник, который выполняется на Zynq.
|
||||
add_test(NAME modbus_gateway COMMAND test_modbus_gateway) # Делает проверку шлюза частью стандартного CTest.
|
||||
enable_testing()
|
||||
add_executable(test_c2000_sci tests/test_c2000_sci.c) # Проверяет эталонные обмены ROM/TI Kernel A и отказы до аппаратного старта.
|
||||
target_link_libraries(test_c2000_sci PRIVATE setprotocol_static) # Host-проверка использует ту же реализацию, что ctypes и будущие порты.
|
||||
add_test(NAME c2000_sci COMMAND test_c2000_sci) # Делает контракт штатного загрузчика F28335 частью обычного CTest.
|
||||
add_executable(test_c2000_sci tests/test_c2000_sci.c) # Эталонные байты ROM и TI Kernel A проверяются без устройства.
|
||||
target_link_libraries(test_c2000_sci PRIVATE setprotocol_static) # Проверяет тот же C99-модуль, что использует ctypes.
|
||||
add_test(NAME c2000_sci COMMAND test_c2000_sci) # Регистрирует контракт в обычном CTest.
|
||||
add_executable(test_deif_xdi tests/test_deif_xdi.c) # Прямые проверки C-контракта DEIF.
|
||||
target_link_libraries(test_deif_xdi PRIVATE setprotocol_static) # Используется единственное общее ядро.
|
||||
add_test(NAME deif_xdi COMMAND test_deif_xdi "${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/deif-xdi-v1.tsv") # Векторы общие с JNI и Python.
|
||||
|
||||
@@ -131,3 +131,10 @@ FFI-клиенты подключают `setprotocol_abi.h`. Имена функ
|
||||
## GAS: регистры 0x3 и слоты 0x4
|
||||
|
||||
[Адресация GAS](docs/GAS_SLOT.md): `0x3` — отдельные 16-битные регистры `0…65535`, запись 1…4 слов. `0x4` — слот с Word0-маской и тремя значениями; PERIPH сохраняет 200 слов в 67 слотах `0xMM00…0xMM42`. Формат определяется MsgType, без настройки профиля.
|
||||
|
||||
## УКСВЭП STM32F103RC
|
||||
|
||||
Общий профиль `uksvep_can.h/.c` предоставляет карту дискретов и параметров,
|
||||
маршруты проекта «Ледокол», C-кодирование регистрового CAN и расчёт состояний
|
||||
для эмулятора SETGUI. Контракт ABI, единицы и проверки:
|
||||
[UKSVEP_CAN.md](docs/UKSVEP_CAN.md).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# UKSVEP STM32F103RC: регистровый CAN
|
||||
|
||||
Профиль относится к `UKSVEP_ICE_22220.6`: `Core/Src/can.c`,
|
||||
`Core/Inc/package.h`, `Core/Src/message.c`, `Core/Src/lampa.c` и `main.h`.
|
||||
Он подключён в SETGUI как отдельный эмулятор проекта «Ледокол».
|
||||
|
||||
Classic CAN, extended 29-битный ID, DLC=8, 250000 бит/с.
|
||||
DeviceID 0…15 задают J0…J3; `Mode=DeviceID+1`.
|
||||
Данные ВЭП: `0x001CE000 | DeviceID`; команды ВЭП:
|
||||
`0x001CE020 | DeviceID`. Это регистровое приложение, а не ProtoCAN Boot Type5.
|
||||
Broadcast из старого приложения не выдаётся этим клиентом за адресный ответ.
|
||||
|
||||
Payload общий с историческим `balsam_can`: word2, word3,
|
||||
адрес/маска, word1, по два байта big endian. В заголовке биты15…13 задают
|
||||
присутствие слов, биты12…0 — начальный адрес. `uksvep_can` ограничивает
|
||||
банк R0…R127; отсутствующие слова не должны обнулять предыдущее состояние.
|
||||
Маршрутизация других Balsam-плат остаётся в их собственном профиле.
|
||||
|
||||
| Регистры | Назначение |
|
||||
|---|---|
|
||||
| 0 / 1 / 2 | Входы / неисправности / аварии |
|
||||
| 8 / 9 | Маски неисправностей / аварий |
|
||||
| 23 | Перемычки; младший полубайт соответствует DeviceID |
|
||||
| 48 | Sleep_time, секунды |
|
||||
| 100 | Brightness, 0…10 тактов ШИМ |
|
||||
| 101 / 102 | Cancount, быстрый / медленный период по 10 мс |
|
||||
| 125 / 126 / 127 | Версия протокола / Mode / команды |
|
||||
|
||||
Назначения входов, GPIO, команд и значения по умолчанию доступны через
|
||||
`uksvep_field_*`, `uksvep_input_name`, `uksvep_command_name`.
|
||||
`uksvep_evaluate_inputs` моделирует ReadEnteres: R0.7 никогда не является
|
||||
неисправностью/аварией; R0.8 всегда является неисправностью и становится
|
||||
аварией по Error_mask.8; R0.9 всегда является неисправностью и аварией.
|
||||
Вход R0.9/PB12 читается без инверсии, остальные назначенные
|
||||
сигналы собираются в соответствии с физической картой STM32-проекта.
|
||||
|
||||
## Слои
|
||||
|
||||
`SETGUI → protocan/uksvep.py (ctypes) → uksvep_can.c → общий payload balsam_can.c`.
|
||||
Ни GPIO, ни Qt, ни часы ОС общему C-профилю не нужны; динамическая память
|
||||
не используется. Вычисления и wire не дублируются в Python.
|
||||
|
||||
Все выходные буферы принадлежат caller и имеют явную capacity:
|
||||
|
||||
- `uksvep_can_encode`: минимум8 байтов, возвращает8 либо0;
|
||||
- `uksvep_can_decode`: минимум8 слов uint16 — направление, количество,
|
||||
затем максимум3 пары адрес/значение; возвращает число пар,0 для чужого ID,
|
||||
−1 для неверных аргументов/своего повреждённого кадра;
|
||||
- `uksvep_field_info`: минимум4 слова uint16 — адрес, максимум, default, flags;
|
||||
- `uksvep_evaluate_inputs`: минимум2 слова uint16 — неисправности и аварии.
|
||||
|
||||
Флаги полей: PARAM=1, COMMAND=2, STATE=4. GUI использует их для выбора
|
||||
допустимых редакторов роли. Имена возвращаются постоянными строками UTF-8;
|
||||
владение памятью клиенту не передаётся. Для применения RX клиент дополнительно
|
||||
отбрасывает STD/RTR и собственное направление/эхо. Физические уровни
|
||||
LED0…LED3 и PVT по текущему регистровому CAN не передаются; интерфейс не
|
||||
должен представлять расчётные лампы как измерение этих GPIO.
|
||||
|
||||
## Проверки
|
||||
|
||||
`test_uksvep_can` фиксирует независимые wire-эталоны, все16 адресов,
|
||||
малый выходной буфер, границы R127, sparse101, неверный DLC, чужие профили,
|
||||
особые входы и регрессию прежнего decoder Бальзам. Python-тесты используют
|
||||
ту же DLL, GUI-тесты подменяют только аппаратный драйвер.
|
||||
@@ -43,6 +43,9 @@ typedef struct {
|
||||
uint16_t values[BALSAM_CAN_REGISTER_COUNT];
|
||||
} balsam_can_frame_t;
|
||||
|
||||
size_t balsam_can_encode_payload(uint16_t address, const uint16_t *values, size_t count, uint8_t *output, size_t capacity); /* Общий payload word2/word3/адрес/word1 для исторических регистровых CAN-профилей. */
|
||||
int balsam_can_decode_payload(const uint8_t *data, size_t size, balsam_can_frame_t *output); /* Разбирает только три слова и маску, оставляя маршрутизацию конкретному профилю. */
|
||||
|
||||
/** Return non-zero for the command/data/terminal IDs used by CAN_Bal_2812. */
|
||||
int balsam_can_is_id(uint32_t can_id);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef SET_MODBUS_GATEWAY_H /* Защищает единый контракт шлюза от повторного включения. */
|
||||
#define SET_MODBUS_GATEWAY_H /* Кодек используется платой, Qt-портом и независимыми тестами. */
|
||||
#include "pcan_abi.h" /* Предоставляет экспорт C ABI, size_t и целочисленные типы фиксированной ширины. */
|
||||
#ifdef __cplusplus /* Позволяет подключать ядро из C++ без изменения имён экспортируемых функций. */
|
||||
extern "C" { /* Все точки входа сохраняют C ABI для ctypes и прошивок. */
|
||||
#endif /* Завершает условие C++-линковки. */
|
||||
#define SMG_ADU 260u /* Максимум TCP: MBAP 7 байт и PDU 253 байта по спецификации Modbus. */
|
||||
#define SMG_ROUTES 20u /* Двадцать записей по 10 байт помещаются вместе с настройками в один PDU. */
|
||||
#define SMG_CONTROL_UNIT 248u /* Зарезервированный адрес управления исключает конфликт с RTU slave 1…247. */
|
||||
typedef struct { uint16_t transaction, length; uint8_t unit, pdu[253]; } smg_request; /* Поля хранятся в native-порядке; структура никогда не передаётся по проводу. */
|
||||
typedef struct { uint8_t source, target, transport, interface_id; uint32_t ipv4; uint16_t port; } smg_route; /* transport: 0 RS485, 1 TCP; interface_id: 0 маршрут Linux, 1 eth0, 2 eth1. */
|
||||
typedef struct { uint32_t baud; uint16_t timeout_ms; uint8_t parity, stop_bits, default_serial, count; smg_route routes[SMG_ROUTES]; } smg_config; /* parity: 0 N, 1 E, 2 O; адреса IPv4 записаны как числовое значение big-endian. */
|
||||
PCAN_ABI_API int smg_tcp_size(const uint8_t *, size_t); /* Возвращает полный размер ADU, 0 до получения MBAP либо -1 при неверном заголовке. */
|
||||
PCAN_ABI_API int smg_rtu_size(const uint8_t *, size_t); /* Вычисляет размер запроса поддерживаемой функции без зависимости от границ USB-пакетов. */
|
||||
PCAN_ABI_API int smg_rtu_response_size(const uint8_t *, size_t); /* Вычисляет размер ответа, исключения либо конфигурации без повторения смещений в Qt-порте. */
|
||||
PCAN_ABI_API int smg_decode_tcp(const uint8_t *, size_t, smg_request *); /* Декодирует ровно один TCP-запрос без семантического применения команд. */
|
||||
PCAN_ABI_API int smg_decode_rtu(const uint8_t *, size_t, smg_request *); /* Проверяет CRC до извлечения Unit ID и PDU; CRC реализована в set_crc.c. */
|
||||
PCAN_ABI_API unsigned smg_check(const smg_request *); /* Проверяет FC01/02/03/04/05/06/15/16, количества и весь диапазон до записи на внешнюю шину. */
|
||||
PCAN_ABI_API size_t smg_tcp(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Формирует TCP ADU с исходным Transaction ID и явно выбранным Unit ID. */
|
||||
PCAN_ABI_API size_t smg_rtu(uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Добавляет общий CRC16 к PDU; младший байт CRC передаётся первым. */
|
||||
PCAN_ABI_API size_t smg_accept_tcp(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Проверяет корреляцию, длину и echo ответа до передачи PDU клиенту. */
|
||||
PCAN_ABI_API size_t smg_accept_rtu(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Проверяет CRC, slave, функцию и форму ответа RTU, включая исключения. */
|
||||
PCAN_ABI_API size_t smg_exception(const smg_request *, unsigned, uint8_t *); /* Формирует двухбайтный PDU исключения; 0A означает отсутствие пути, 0B отсутствие ответа. */
|
||||
PCAN_ABI_API void smg_config_default(smg_config *); /* Создаёт остановленную маршрутизацию с 115200 8N1 и тайм-аутом 1000 мс. */
|
||||
PCAN_ABI_API int smg_config_valid(const smg_config *); /* Проверяет уникальность входных адресов, транспорт и допустимые значения всех маршрутов. */
|
||||
PCAN_ABI_API size_t smg_config_pack(unsigned, const smg_config *, uint8_t *, size_t); /* Частная FC65: операция 1 читает, 2 применяет, 3 возвращает полную конфигурацию. */
|
||||
PCAN_ABI_API int smg_config_unpack(const uint8_t *, size_t, unsigned *, smg_config *); /* Разбирает ту же FC65 на ARM и ПК; произвольные shell-команды отсутствуют. */
|
||||
#ifdef __cplusplus /* Закрывает C ABI при включении из C++. */
|
||||
} /* Завершает внешнюю C-линковку. */
|
||||
#endif /* Завершает условие C++-линковки. */
|
||||
#endif /* Завершает публичный контракт шлюза. */
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef UKSVEP_CAN_H /* Защищает общий профиль ВЭП от повторного объявления. */
|
||||
#define UKSVEP_CAN_H /* Карта соответствует STM32-проекту UKSVEP_ICE_22220.6, а не ВЭП Бальзам. */
|
||||
#include "pcan_abi.h" /* Даёт экспортируемый C ABI для Python и других клиентов templates. */
|
||||
#ifdef __cplusplus /* Позволяет использовать тот же профиль из C++ и JNI. */
|
||||
extern "C" { /* Сохраняет стабильные имена экспортов без C++ mangling. */
|
||||
#endif /* Завершает выбор языка связывания. */
|
||||
#define UKSVEP_REGISTER_COUNT 128U /* message.h выделяет Modbus_LEN=0x80 слов uint16. */
|
||||
#define UKSVEP_FIELD_PARAM 1U /* Поле допускает запись параметра в реальный ВЭП. */
|
||||
#define UKSVEP_FIELD_COMMAND 2U /* Поле R127 содержит битовые команды приложения. */
|
||||
#define UKSVEP_FIELD_STATE 4U /* Поле состояния доступно для редактирования только в роли эмулируемого ВЭП. */
|
||||
PCAN_ABI_API uint32_t uksvep_can_id(unsigned device, unsigned from_device); /* Адрес 0…15: данные 0x1CE000+ID, команды 0x1CE020+ID; UINT32_MAX означает отказ. */
|
||||
PCAN_ABI_API size_t uksvep_can_encode(unsigned device, unsigned from_device, unsigned address, const uint16_t *values, size_t count, uint8_t *output, size_t capacity); /* Строит DLC=8 для одного–трёх слов; capacity задаётся в байтах, требуется минимум 8. */
|
||||
PCAN_ABI_API int uksvep_can_decode(unsigned device, uint32_t id, const uint8_t *data, size_t size, uint16_t *output, size_t capacity); /* output: направление, число, три пары адрес/значение; capacity в словах uint16, минимум 8; 0=чужой ID, -1=ошибка. */
|
||||
PCAN_ABI_API unsigned uksvep_field_count(void); /* Число подтверждённых package.h полей, без выдуманных регистров. */
|
||||
PCAN_ABI_API int uksvep_field_info(unsigned index, unsigned device, uint16_t *output, size_t capacity); /* output[4]: адрес, максимум, начальное значение, флаги роли; capacity задаётся в словах uint16. */
|
||||
PCAN_ABI_API const char *uksvep_field_name(unsigned index); /* Возвращает постоянное имя UTF-8 без передачи владения памятью. */
|
||||
PCAN_ABI_API const char *uksvep_input_name(unsigned bit); /* Возвращает назначение и физический GPIO каждого из четырнадцати входов. */
|
||||
PCAN_ABI_API const char *uksvep_command_name(unsigned bit); /* Описывает только команды package.h; пустая строка означает резерв. */
|
||||
PCAN_ABI_API void uksvep_evaluate_inputs(uint16_t inputs, uint16_t alarm_mask, uint16_t error_mask, uint16_t *output, size_t capacity); /* Вычисляет R1/R2 по ReadEnteres; capacity задаётся в словах uint16, требуется минимум 2. */
|
||||
#ifdef __cplusplus /* Закрывает внешнее C-связывание для C++. */
|
||||
} /* Все клиенты вызывают одну реализацию протокола. */
|
||||
#endif /* Завершает совместимость с C++. */
|
||||
#endif /* Завершает публичный профиль UKSVEP. */
|
||||
@@ -48,11 +48,44 @@ int balsam_can_is_id(uint32_t can_id)
|
||||
&& relative < BALSAM_CAN_DATA_OFFSET + BALSAM_CAN_NODE_COUNT);
|
||||
}
|
||||
|
||||
size_t balsam_can_encode_payload(uint16_t address, const uint16_t *values, size_t count, uint8_t *output, size_t capacity) /* Общий регистровый payload не зависит от выбранной платы. */
|
||||
{ /* Формат совпадает у Бальзам и UKSVEP, но адреса CAN определяют разные профили. */
|
||||
size_t index; /* Перебирает только присутствующие полезные слова. */
|
||||
uint16_t header; /* Хранит трёхбитную маску и 13-битный адрес регистра. */
|
||||
if (capacity < 8U || address > 0x1FFFU || values == NULL || output == NULL || count == 0U || count > 3U) { /* Проверяет геометрию payload до изменения выходных байтов. */
|
||||
return 0U; /* Отказ не создаёт частично готового кадра. */
|
||||
} /* Конкретный профиль дополнительно ограничивает размер своего банка. */
|
||||
memset(output, 0, 8U); /* Отсутствующие слова получают нулевое заполнение без бита присутствия. */
|
||||
header = (uint16_t)(address | (((7U << (3U - count)) & 7U) << 13U)); /* Одно/два/три слова дают маски 100/110/111 в битах 15…13. */
|
||||
output[4] = (uint8_t)(header >> 8U); /* Старший байт адреса/маски передаётся первым. */
|
||||
output[5] = (uint8_t)header; /* Младший байт завершает big-endian заголовок. */
|
||||
for (index = 0U; index < count; ++index) { /* eCAN передаёт word2 и word3 перед word1. */
|
||||
size_t offset = index == 0U ? 6U : (index - 1U) * 2U; /* Первое слово находится в байтах 6/7, следующие — в 0/1 и 2/3. */
|
||||
output[offset] = (uint8_t)(values[index] >> 8U); /* Записывает старшие восемь бит без зависимости от endian хоста. */
|
||||
output[offset + 1U] = (uint8_t)values[index]; /* Записывает младшие восемь бит того же слова. */
|
||||
} /* Порядок слов реализован в одном C-модуле для всех потребителей. */
|
||||
return 8U; /* Исторический регистровый кадр всегда имеет DLC=8. */
|
||||
} /* Завершает общий кодировщик трёх регистров. */
|
||||
|
||||
int balsam_can_decode_payload(const uint8_t *data, size_t size, balsam_can_frame_t *output) /* Отделяет общий payload от профиля CAN ID. */
|
||||
{ /* Маршрут и номер устройства заполняет вызывающий профиль. */
|
||||
uint16_t header; /* Получает маску присутствия и начальный адрес слова. */
|
||||
if (data == NULL || output == NULL || size != BALSAM_CAN_DLC) { /* Не читает неполные либо отсутствующие восемь байтов. */
|
||||
return 0; /* Отказ не изменяет ранее принятые значения клиента. */
|
||||
} /* Сохраняет исходный порядок декодирования Balsam без второго codec. */
|
||||
header = get_be16(&data[4]); /* Адрес находится в третьем 16-битном слове на проводе. */
|
||||
output->present_mask = (uint8_t)((header >> 13U) & 0x07U); /* Три старших бита разрешают отдельные регистры. */
|
||||
output->start_address = (uint16_t)(header & 0x1FFFU); /* Остальные 13 бит задают начальный адрес. */
|
||||
output->values[0] = get_be16(&data[6]); /* Первое логическое слово передаётся последним. */
|
||||
output->values[1] = get_be16(&data[0]); /* Второе логическое слово передаётся первым. */
|
||||
output->values[2] = get_be16(&data[2]); /* Третье логическое слово передаётся вторым. */
|
||||
return 1; /* Все три значения разобраны; применять можно только присутствующие. */
|
||||
} /* Завершает общий разбор payload без назначения платы. */
|
||||
|
||||
int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
|
||||
balsam_can_frame_t *output)
|
||||
{
|
||||
uint32_t relative;
|
||||
uint16_t header;
|
||||
if ((data == NULL) || (output == NULL)) return -1;
|
||||
can_id &= 0x1FFFFFFFUL;
|
||||
if (!balsam_can_is_id(can_id)) return 0;
|
||||
@@ -67,14 +100,7 @@ int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size,
|
||||
output->direction = can_id == 0x00745019UL
|
||||
? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE;
|
||||
}
|
||||
/* eCAN sends MDL before MDH: value2, value3, address/mask, value1. */
|
||||
header = get_be16(&data[4]);
|
||||
output->present_mask = (uint8_t)((header >> 13) & 0x07U);
|
||||
output->start_address = (uint16_t)(header & 0x1FFFU);
|
||||
output->values[0] = get_be16(&data[6]);
|
||||
output->values[1] = get_be16(&data[0]);
|
||||
output->values[2] = get_be16(&data[2]);
|
||||
return 1;
|
||||
return balsam_can_decode_payload(data, size, output); /* Переиспользует тот же payload decoder, сохраняя прежние проверки и маршрутизацию Бальзам. */
|
||||
}
|
||||
|
||||
const char *balsam_can_device_name(uint8_t device)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "set_modbus_gateway.h" /* Общий контракт исключает вторую реализацию форматов в Qt и Linux. */
|
||||
#include "set_crc.h" /* Использует уже существующую реализацию Modbus CRC16 из templates. */
|
||||
#include <string.h> /* Копирует только заранее проверенные ограниченные буферы. */
|
||||
static unsigned get16(const uint8_t *p) { return ((unsigned)p[0] << 8) | p[1]; } /* Пары байтов Modbus всегда передаются старшим байтом вперёд. */
|
||||
static void put16(uint8_t *p, unsigned v) { p[0] = (uint8_t)(v >> 8); p[1] = (uint8_t)v; } /* Сериализация не зависит от endian ARM или ПК. */
|
||||
int smg_tcp_size(const uint8_t *p, size_t n) { /* TCP может доставить заголовок несколькими чтениями. */
|
||||
unsigned length; if (!p) { return -1; } if (n < 6) { return 0; } length = get16(p + 4); /* Шесть байтов достаточно для Protocol ID и длины оставшегося ADU. */
|
||||
return get16(p + 2) || length < 2 || length > 254 ? -1 : (int)(length + 6); /* Protocol ID равен нулю; длина включает Unit ID и PDU до 253 байт. */
|
||||
} /* Возвращённый размер позволяет отделять склеенные TCP-запросы без потери хвоста. */
|
||||
int smg_rtu_size(const uint8_t *p, size_t n) { /* Длина определяется запросом, а не границами USB transfer. */
|
||||
if (!p) { return -1; } if (n < 2) { return 0; } /* До байта функции нельзя выбрать формат запроса. */
|
||||
if (p[1] >= 1 && p[1] <= 6) { return 8; } /* FC01…06 содержат адрес, количество либо значение и CRC. */
|
||||
if (p[1] == 15 || p[1] == 16) { return n < 7 ? 0 : p[6] > 247 ? -1 : 9 + p[6]; } /* Byte count ограничен предельным RTU ADU 256 байт. */
|
||||
if (p[0] == SMG_CONTROL_UNIT && p[1] == 65) { return n < 3 ? 0 : p[2] == 1 ? 5 : n < 14 ? 0 : p[13] > SMG_ROUTES ? -1 : 16 + 10 * p[13]; } /* FC65: после операции и заголовка следует count маршрутов и два байта CRC. */
|
||||
return -1; /* Неизвестные RTU-функции требуют границы тишины и проверяются вызывающим портом отдельно. */
|
||||
} /* Функция не читает ни одного байта за полученным префиксом. */
|
||||
int smg_rtu_response_size(const uint8_t *p, size_t n) { /* Ответ RTU имеет другой формат длины, чем запрос чтения. */
|
||||
if (!p) { return -1; } if (n < 2) { return 0; } if (p[1] & 128) { return 5; } /* Все стандартные исключения содержат slave, функцию, код и CRC. */
|
||||
if (p[1] >= 1 && p[1] <= 4) { return n < 3 ? 0 : p[2] > 250 ? -1 : 5 + p[2]; } /* Ответ чтения содержит byte count и не более 250 байт данных. */
|
||||
if (p[1] == 5 || p[1] == 6 || p[1] == 15 || p[1] == 16) { return 8; } /* Запись подтверждается фиксированным echo адреса и значения либо количества. */
|
||||
if (p[0] == SMG_CONTROL_UNIT && p[1] == 65) { return n < 14 ? 0 : p[2] != 3 || p[13] > SMG_ROUTES ? -1 : 16 + 10 * p[13]; } /* Ответ конфигурации содержит операцию 3 и целое число маршрутов. */
|
||||
return -1; /* Неизвестный ответ не подтверждает незавершённую транзакцию. */
|
||||
} /* Функция нужна потоковому порту USB и не зависит от размера USB-пакета. */
|
||||
int smg_decode_tcp(const uint8_t *p, size_t n, smg_request *r) { /* Не принимает неполные ADU и два сообщения как одно. */
|
||||
int expected = smg_tcp_size(p, n); if (!r || expected < 8 || (size_t)expected != n) { return 0; } /* Пустой PDU не является запросом Modbus. */
|
||||
memset(r, 0, sizeof(*r)); r->transaction = (uint16_t)get16(p); r->unit = p[6]; r->length = (uint16_t)(n - 7); memcpy(r->pdu, p + 7, r->length); return 1; /* Корреляция клиента отделена от адреса конечного устройства. */
|
||||
} /* Семантика функции проверяется до выбора внешнего маршрута. */
|
||||
int smg_decode_rtu(const uint8_t *p, size_t n, smg_request *r) { /* Повреждённые RTU кадры не получают ответа и не меняют состояние. */
|
||||
if (!p || !r || n < 4 || n > 256 || set_crc16_modbus(p, n)) { return 0; } /* Нулевой остаток CRC проверяет также два переданных байта контрольной суммы. */
|
||||
memset(r, 0, sizeof(*r)); r->unit = p[0]; r->length = (uint16_t)(n - 3); memcpy(r->pdu, p + 1, r->length); return 1; /* RTU не содержит Transaction ID; порт назначает его для исходящего TCP. */
|
||||
} /* Исходные байты принадлежат вызывающему и могут быть повторно использованы после возврата. */
|
||||
unsigned smg_check(const smg_request *r) { /* Ошибочный запрос отклоняется до любого внешнего I/O. */
|
||||
unsigned fn, address, quantity, bytes, maximum; if (!r || !r->length || r->length > 253) { return 3; } fn = r->pdu[0]; /* Максимальный PDU одинаков для TCP и RTU. */
|
||||
if (!r->unit) { return 3; } if (fn == 65 && r->unit == SMG_CONTROL_UNIT) { return 0; } /* Широковещательные записи запрещены; адрес 248 обслуживает только настройки шлюза. */
|
||||
if (!(fn >= 1 && fn <= 6) && fn != 15 && fn != 16) { return 1; } if (r->length < 5) { return 3; } /* Поддерживаются те же базовые функции, что в штатной Modbus-панели SETGUI. */
|
||||
address = get16(r->pdu + 1); quantity = get16(r->pdu + 3); if (fn <= 6 && r->length != 5) { return 3; } /* Дополнительный хвост не должен скрывать второй запрос. */
|
||||
if (fn == 5) { return quantity == 0 || quantity == 0xff00 ? 0 : 3; } if (fn == 6) { return 0; } /* FC05 допускает только стандартные значения OFF и ON; FC06 передаёт uint16. */
|
||||
maximum = fn <= 2 ? 2000 : fn <= 4 ? 125 : fn == 15 ? 1968 : 123; /* Ограничения количеств взяты из описаний FC01/02/03/04/15/16 Modbus V1.1b3. */
|
||||
if (!quantity || quantity > maximum) { return 3; } if (address + quantity > 65536u) { return 2; } /* Вычисление в unsigned предотвращает 16-битное переполнение диапазона адресов. */
|
||||
if (fn == 15 || fn == 16) { bytes = fn == 15 ? (quantity + 7) / 8 : quantity * 2; if (r->length != 6 + bytes || r->pdu[5] != bytes) { return 3; } } /* Проверяет весь блок записи до передачи на RS485 либо Ethernet. */
|
||||
return 0; /* Проверка не применяет значения регистров и не меняет запрос. */
|
||||
} /* Исключения возвращаются в стандартном PDU, а ошибки CRC отбрасываются раньше. */
|
||||
size_t smg_tcp(const smg_request *r, uint8_t unit, const uint8_t *pdu, size_t n, uint8_t *out, size_t cap) { /* Один builder используется для запросов и ответов TCP. */
|
||||
if (!r || !pdu || !out || !n || n > 253 || cap < n + 7) { return 0; } put16(out, r->transaction); put16(out + 2, 0); put16(out + 4, (unsigned)n + 1); out[6] = unit; memcpy(out + 7, pdu, n); return n + 7; /* Возвращает исходную корреляцию независимо от изменения slave на маршруте. */
|
||||
} /* Protocol ID всегда равен нулю, длина не включает первые шесть байтов MBAP. */
|
||||
size_t smg_rtu(uint8_t unit, const uint8_t *pdu, size_t n, uint8_t *out, size_t cap) { /* CRC вычисляется только общим кодом templates. */
|
||||
uint16_t crc; if (!pdu || !out || !n || n > 253 || cap < n + 3) { return 0; } out[0] = unit; memcpy(out + 1, pdu, n); crc = set_crc16_modbus(out, n + 1); out[n + 1] = (uint8_t)crc; out[n + 2] = (uint8_t)(crc >> 8); return n + 3; /* В отличие от регистров, CRC16 передаётся младшим байтом вперёд. */
|
||||
} /* Unit ID проверяется при выборе маршрута, чтобы адрес управления 248 также мог иметь RTU-ответ. */
|
||||
static size_t accept_pdu(const smg_request *r, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* Не позволяет выдать ответ другого запроса за текущую транзакцию. */
|
||||
unsigned fn, count, bytes; if (!r || !p || !out || !n || n > cap || smg_check(r)) { return 0; } fn = r->pdu[0]; /* Запрос должен быть предварительно допустимым. */
|
||||
if (p[0] == (fn | 128u)) { if (n != 2 || !p[1]) { return 0; } } /* Исключение содержит ровно функцию с битом 7 и ненулевой код. */
|
||||
else if (fn == 65 && r->unit == SMG_CONTROL_UNIT) { smg_config c; unsigned op; if (!smg_config_unpack(p, n, &op, &c) || op != 3) { return 0; } } /* Настройки также проходят проверку общей версией кодека до публикации в GUI. */
|
||||
else { if (p[0] != fn) { return 0; } count = get16(r->pdu + 3); /* Только ответ исходной функции может завершить ожидание. */
|
||||
if (fn <= 4) { bytes = fn <= 2 ? (count + 7) / 8 : count * 2; if (n != bytes + 2 || p[1] != bytes) { return 0; } } /* Byte count проверяется по количеству запроса, а не только по длине принятого пакета. */
|
||||
else if (n != 5 || memcmp(p, r->pdu, 5)) { return 0; } /* FC05/06/15/16 обязаны подтвердить тот же адрес и значение либо количество. */
|
||||
} memcpy(out, p, n); return n; /* Только полностью проверенный PDU передаётся входному клиенту. */
|
||||
} /* На неверном ответе выходной буфер остаётся без изменений. */
|
||||
size_t smg_accept_tcp(const smg_request *r, uint8_t unit, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* TCP backend сохраняет единственную исходящую транзакцию. */
|
||||
smg_request reply; if (!r || !smg_decode_tcp(p, n, &reply) || reply.transaction != r->transaction || reply.unit != unit) { return 0; } return accept_pdu(r, reply.pdu, reply.length, out, cap); /* Проверяет MBAP до проверки формы данных. */
|
||||
} /* Чужой Transaction ID не подтверждает запись и не вызывает повтор записи. */
|
||||
size_t smg_accept_rtu(const smg_request *r, uint8_t unit, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* RTU backend проверяет целый кадр после межкадровой тишины. */
|
||||
smg_request reply; if (!r || !smg_decode_rtu(p, n, &reply) || reply.unit != unit) { return 0; } return accept_pdu(r, reply.pdu, reply.length, out, cap); /* Неверная CRC либо адрес не завершают запрос успешным ответом. */
|
||||
} /* Исключение целевого устройства передаётся без подмены кодом ошибки шлюза. */
|
||||
size_t smg_exception(const smg_request *r, unsigned code, uint8_t *out) { /* Служба задаёт причину, а формат исключения принадлежит ядру. */
|
||||
if (!r || !r->length || !out || !code || code > 255) { return 0; } out[0] = r->pdu[0] | 128u; out[1] = (uint8_t)code; return 2; /* Два байта PDU одинаково оборачиваются в TCP и RTU. */
|
||||
} /* Никакие ошибки внешней шины не превращаются в успешное подтверждение. */
|
||||
void smg_config_default(smg_config *c) { memset(c, 0, sizeof(*c)); c->baud = 115200; c->timeout_ms = 1000; c->stop_bits = 1; } /* По умолчанию нет маршрутов и нет неявных записей на RS485. */
|
||||
int smg_config_valid(const smg_config *c) { /* Целиком проверяет таблицу перед публикацией новой конфигурации. */
|
||||
unsigned i, j; if (!c || c->count > SMG_ROUTES || c->timeout_ms < 100 || c->timeout_ms > 60000 || c->parity > 2 || c->stop_bits < 1 || c->stop_bits > 2 || c->default_serial > 1) { return 0; } /* Тайм-аут выражен в мс и совпадает с допустимым диапазоном SETGUI. */
|
||||
if (c->baud != 1200 && c->baud != 2400 && c->baud != 4800 && c->baud != 9600 && c->baud != 19200 && c->baud != 38400 && c->baud != 57600 && c->baud != 115200 && c->baud != 230400 && c->baud != 460800 && c->baud != 921600) { return 0; } /* Только скорости, имеющие переносимые Linux termios constants. */
|
||||
for (i = 0; i < c->count; ++i) { const smg_route *r = c->routes + i; /* Unit ID источника однозначно выбирает одну внешнюю транзакцию. */
|
||||
if (!r->source || r->source >= SMG_CONTROL_UNIT || !r->target || r->transport > 1 || r->interface_id > 2) { return 0; } /* Адрес управления не может быть переопределён внешним устройством. */
|
||||
if (!r->transport && (r->target > 247 || r->interface_id || r->ipv4 || r->port)) { return 0; } /* RS485 не принимает сетевые параметры либо широковещательный адрес. */
|
||||
if (r->transport && (!r->port || !r->ipv4 || (r->ipv4 >> 24) == 0 || (r->ipv4 >> 24) >= 224)) { return 0; } /* Ethernet-маршрут требует unicast IPv4 и ненулевой TCP-порт; loopback допустим для независимых проверок порта. */
|
||||
for (j = 0; j < i; ++j) if (c->routes[j].source == r->source) { return 0; } /* Повторяющиеся адреса отклоняются вместо неопределённого выбора назначения. */
|
||||
} return 1; /* Физическое наличие интерфейса дополнительно проверяет порт ОС. */
|
||||
} /* Ядро не обращается к сети, файловой системе и настройкам UART. */
|
||||
size_t smg_config_pack(unsigned op, const smg_config *c, uint8_t *p, size_t cap) { /* Один формат обслуживает запись настроек, чтение и файл состояния. */
|
||||
unsigned i; size_t n; if (!p || op < 1 || op > 3 || cap < 2) { return 0; } if (op == 1) { p[0] = 65; p[1] = 1; return 2; } /* Запрос чтения не передаёт фиктивную конфигурацию. */
|
||||
if (!smg_config_valid(c)) { return 0; } n = 13 + 10 * c->count; if (cap < n) { return 0; } memset(p, 0, n); p[0] = 65; p[1] = (uint8_t)op; p[2] = 1; put16(p + 3, c->timeout_ms); put16(p + 5, c->baud >> 16); put16(p + 7, c->baud); p[9] = c->parity; p[10] = c->stop_bits; p[11] = c->default_serial; p[12] = c->count; /* Версия 1 фиксирует смещения и единицы измерения без сериализации native-структур. */
|
||||
for (i = 0; i < c->count; ++i) { const smg_route *r = c->routes + i; uint8_t *at = p + 13 + 10 * i; at[0] = r->source; at[1] = r->target; at[2] = r->transport; at[3] = r->interface_id; put16(at + 4, r->ipv4 >> 16); put16(at + 6, r->ipv4); put16(at + 8, r->port); } return n; /* Каждая запись имеет десять явных байтов независимо от padding C ABI. */
|
||||
} /* При ошибке ёмкости частичная таблица не формируется. */
|
||||
int smg_config_unpack(const uint8_t *p, size_t n, unsigned *op, smg_config *c) { /* Разбор выполняется во временную структуру до изменения рабочего состояния. */
|
||||
smg_config temporary; unsigned i; if (!p || !op || !c || n < 2 || p[0] != 65 || p[1] < 1 || p[1] > 3) { return 0; } if (p[1] == 1) { if (n != 2) { return 0; } *op = 1; return 1; } /* Чтение не изменяет переданный объект конфигурации. */
|
||||
if (n < 13 || p[2] != 1 || p[12] > SMG_ROUTES || n != 13u + 10u * p[12]) { return 0; } memset(&temporary, 0, sizeof(temporary)); temporary.timeout_ms = (uint16_t)get16(p + 3); temporary.baud = ((uint32_t)get16(p + 5) << 16) | get16(p + 7); temporary.parity = p[9]; temporary.stop_bits = p[10]; temporary.default_serial = p[11]; temporary.count = p[12]; /* Проверяет версию и длину до первого обращения к записи таблицы. */
|
||||
for (i = 0; i < temporary.count; ++i) { smg_route *r = temporary.routes + i; const uint8_t *at = p + 13 + 10 * i; r->source = at[0]; r->target = at[1]; r->transport = at[2]; r->interface_id = at[3]; r->ipv4 = ((uint32_t)get16(at + 4) << 16) | get16(at + 6); r->port = (uint16_t)get16(at + 8); } /* Распаковывает все адреса, сохраняя одинаковый numeric IPv4 на ПК и ARM. */
|
||||
if (!smg_config_valid(&temporary)) { return 0; } *c = temporary; *op = p[1]; return 1; /* Транзакционная публикация исключает частичную замену маршрутов при ошибке. */
|
||||
} /* Протокол управления не содержит имён файлов, команд shell и произвольных путей ОС. */
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "uksvep_can.h" /* Объявляет общий формат, метаданные и ABI профиля UKSVEP. */
|
||||
#include "balsam_can.h" /* Повторно использует исторический порядок трёх слов без второго кодека. */
|
||||
#include <string.h> /* Обнуляет кадр до заполнения присутствующих слов. */
|
||||
|
||||
typedef struct { /* Хранит подтверждённые параметры регистра из package.h/message.c. */
|
||||
uint16_t address; /* Индекс слова в modbus[128], без смещения Modbus 40001. */
|
||||
uint16_t maximum; /* Верхняя граница ввода в исходных единицах прошивки. */
|
||||
uint16_t initial; /* Значение для предпросмотра, не считается принятым от платы. */
|
||||
uint16_t flags; /* Отделяет параметры, команды и эмулируемое состояние. */
|
||||
const char *name; /* Постоянная подпись UTF-8 для всех потребителей профиля. */
|
||||
} field_t; /* Структура остаётся внутренней: через ABI передаются скаляры. */
|
||||
|
||||
static const field_t fields[] = { /* Источник адресов — Core/Inc/package.h текущего STM32-проекта. */
|
||||
{0U, 65535U, 0x247FU, UKSVEP_FIELD_STATE, "Дискретные входы"}, /* R0: нейтраль включает исправные питания, 380 В, ЛСУ и СКК; аварийные биты 8/9 выключены. */
|
||||
{1U, 65535U, 0U, UKSVEP_FIELD_STATE, "Неисправности"}, /* R1 вычисляется из входов и Alarm_mask, а не является прямым аппаратным входом. */
|
||||
{2U, 65535U, 0U, UKSVEP_FIELD_STATE, "Аварии"}, /* R2 вычисляется из входов и Error_mask. */
|
||||
{8U, 65535U, 0x277FU, UKSVEP_FIELD_PARAM, "Маска неисправностей"}, /* Default_params: 0x3FFF без битов 7, 11 и 12. */
|
||||
{9U, 65535U, 0x0200U, UKSVEP_FIELD_PARAM, "Маска аварий"}, /* По умолчанию выбран бит 9, авария сети 24 В. */
|
||||
{23U, 65535U, 0U, UKSVEP_FIELD_STATE, "Перемычки J0…J3 / TEST"}, /* Jumpers=R0x17, младший байт задаёт физические перемычки. */
|
||||
{48U, 65535U, 60U, UKSVEP_FIELD_PARAM, "Время до отключения, с"}, /* Millisecond умножает Sleep_time на 1000 мс. */
|
||||
{100U, 10U, 9U, UKSVEP_FIELD_PARAM, "Яркость лампы, 0…10"}, /* ШИМ использует десять отсчётов, штатное значение Brightness=9. */
|
||||
{101U, 65535U, 20U, UKSVEP_FIELD_PARAM, "Быстрый CAN, ×10 мс"}, /* Cancount[0]=20 соответствует 200 мс между пакетами. */
|
||||
{102U, 65535U, 200U, UKSVEP_FIELD_PARAM, "Медленный CAN, ×10 мс"}, /* Cancount[1]=200 соответствует 2000 мс между пакетами. */
|
||||
{125U, 65535U, 1U, UKSVEP_FIELD_STATE, "Версия прикладного протокола"}, /* PROTOKOL=1 относится к регистровому приложению, не к ProtoCAN Boot. */
|
||||
{126U, 65535U, 1U, UKSVEP_FIELD_STATE, "Mode = DeviceID + 1"}, /* При запросе метаданных значение уточняется по выбранному адресу. */
|
||||
{127U, 65535U, 0U, UKSVEP_FIELD_COMMAND, "Команды"} /* cTestLamp/cDefParam/cSaveParam/cLoadParam/cExtLamp/cExtLite/cReset. */
|
||||
}; /* Все адреса и значения по умолчанию определены в одном общем модуле. */
|
||||
|
||||
static const char *const inputs[] = { /* Порядок битов повторяет ReadEnteres в Core/Src/lampa.c. */
|
||||
"ИП1 · контроллер · PC13 · активный 0", /* Бит 0 читает инверсию IN_06. */
|
||||
"ИП2 · периферия · PC15 · активный 0", /* Бит 1 читает инверсию IN_05. */
|
||||
"ИП3 · замки / освещение · PA0 · активный 0", /* Бит 2 читает инверсию IN_04. */
|
||||
"ИП4 · датчики + · PA1 · активный 0", /* Бит 3 читает инверсию IN_03. */
|
||||
"ИП5 · датчики − · PA2 · активный 0", /* Бит 4 читает инверсию IN_02. */
|
||||
"ИП6 · драйверы · PA3 · активный 0", /* Бит 5 читает инверсию IN_01. */
|
||||
"Три фазы 380 В · PB15 · активный 0", /* Бит 6 читает инверсию IN_07. */
|
||||
"Заряд накопителя · PB14 · активный 0", /* Бит 7 не становится неисправностью либо аварией. */
|
||||
"Разряд накопителя · PB13 · активный 0", /* Бит 8 всегда становится неисправностью при единице. */
|
||||
"Авария 24 В · PB12 · активный 1", /* Бит 9 единственный читается без инверсии IN_10. */
|
||||
"Питание ЛСУ · PB1 · активный 0", /* Бит 10 читает инверсию IN_11. */
|
||||
"Резерв 11 · PB0 · активный 0", /* Бит 11 читает инверсию IN_12. */
|
||||
"Резерв 12 · PA5 · активный 0", /* Бит 12 читает инверсию IN_13. */
|
||||
"Питание СКК · PA4 · активный 0" /* Бит 13 читает инверсию IN_14. */
|
||||
}; /* Логическая единица здесь означает установленный бит R0, а не высокий GPIO. */
|
||||
|
||||
static const char *const commands[16] = { /* Имена битов берутся из package.h, без команд чужого задатчика. */
|
||||
"Тест ламп", "Параметры по умолчанию", "Сохранить параметры", "Загрузить параметры", /* Биты 0…3 действительно обслуживаются main. */
|
||||
"", "", "ExtLamp · флаг", "ExtLite · флаг", /* Биты 6/7 объявлены, но текущая логика лампы не использует их как отдельные команды. */
|
||||
"", "", "", "", "", "", "", "Перезагрузка" /* Бит 15 запрещает продление IWDG и вызывает аппаратный reset. */
|
||||
}; /* Пустые имена не создают ложных кнопок для резервных битов. */
|
||||
|
||||
uint32_t uksvep_can_id(unsigned device, unsigned from_device) /* Повторяет Setup_CAN_addr без связи с адресом boot Type=5. */
|
||||
{ /* Весь DeviceID ограничен четырьмя перемычками платы. */
|
||||
if (device > 15U || from_device > 1U) { /* Не обрезает ошибочный номер до адреса другого устройства. */
|
||||
return UINT32_MAX; /* Явный недопустимый ID исключает скрытую передачу. */
|
||||
} /* Направление 1 означает данные от эмулируемого или реального ВЭП. */
|
||||
return 0x001CE000UL | device | (from_device ? 0U : 0x20U); /* Командное смещение 0x20 отличается от 0x10 в Бальзам. */
|
||||
} /* Завершает единственное вычисление маршрутов UKSVEP. */
|
||||
|
||||
size_t uksvep_can_encode(unsigned device, unsigned from_device, unsigned address, const uint16_t *values, size_t count, uint8_t *output, size_t capacity) /* Проверяет геометрию до общего кодека. */
|
||||
{ /* Один кадр вмещает до трёх последовательных слов uint16. */
|
||||
if (capacity < 8U || uksvep_can_id(device, from_device) == UINT32_MAX || address >= UKSVEP_REGISTER_COUNT || count == 0U || count > 3U || count > UKSVEP_REGISTER_COUNT - address) { /* Исключает переполнение банка R0…R127. */
|
||||
return 0U; /* Не выдаёт частично собранный пакет ошибочного диапазона. */
|
||||
} /* Параметры относятся к существующей карте платы. */
|
||||
return balsam_can_encode_payload((uint16_t)address, values, count, output, capacity); /* Общий кодек задаёт порядок word2, word3, адрес/маска, word1. */
|
||||
} /* Не содержит второго endian/масочного алгоритма. */
|
||||
|
||||
int uksvep_can_decode(unsigned device, uint32_t id, const uint8_t *data, size_t size, uint16_t *output, size_t capacity) /* Разбирает только выбранное устройство и обе его роли. */
|
||||
{ /* ABI возвращает плоские скаляры без зависимости от выравнивания C-структур. */
|
||||
balsam_can_frame_t frame = {0}; /* Общий payload decoder заполняет маску, адрес и три слова. */
|
||||
unsigned index; /* Перебирает только реально присутствующие биты маски. */
|
||||
unsigned count = 0U; /* Считает адресные пары, которые разрешено применить к RX-снимку. */
|
||||
if (capacity < 8U || device > 15U || output == NULL || id > 0x1FFFFFFFUL) { /* Отвергает неверные аргументы до сравнения маршрутов. */
|
||||
return -1; /* Ошибка не должна частично изменить снимок клиента. */
|
||||
} /* Проверка чужого ID выполняется до DLC и указателя данных. */
|
||||
if (id != uksvep_can_id(device, 0U) && id != uksvep_can_id(device, 1U)) { /* Broadcast и чужой ВЭП не выдаются за адресный ответ. */
|
||||
return 0; /* Позволяет общему CAN-монитору передать кадр другим потребителям. */
|
||||
} /* Дальше кадр гарантированно адресует выбранный профиль. */
|
||||
if (data == NULL || size != 8U || !balsam_can_decode_payload(data, size, &frame) || frame.present_mask == 0U) { /* Требует полный classic CAN data payload с полезными словами. */
|
||||
return -1; /* Повреждённый собственный кадр учитывается как ошибка RX. */
|
||||
} /* Проверяет все адреса до публикации результата. */
|
||||
for (index = 0U; index < 3U; ++index) { /* В кадре максимум три последовательных адреса. */
|
||||
if ((frame.present_mask & (4U >> index)) != 0U && frame.start_address + index >= UKSVEP_REGISTER_COUNT) { /* Отсутствующее слово не создаёт ложной записи R128/R129. */
|
||||
return -1; /* При выходе присутствующего слова за карту отвергается весь пакет. */
|
||||
} /* Проверка проходит до заполнения плоского output. */
|
||||
} /* Геометрия всех значащих слов проверена. */
|
||||
output[0] = id == uksvep_can_id(device, 1U); /* Сохраняет реальное направление, чтобы собственный TX не стал ответом платы. */
|
||||
for (index = 0U; index < 3U; ++index) { /* Сохраняет только слова с установленными битами присутствия. */
|
||||
if ((frame.present_mask & (4U >> index)) != 0U) { /* Неприсутствующие нули не должны затирать предыдущие RX-значения. */
|
||||
output[2U + count * 2U] = (uint16_t)(frame.start_address + index); /* Возвращает точный индекс регистра. */
|
||||
output[3U + count * 2U] = frame.values[index]; /* Возвращает полезное uint16 без потери старшего бита. */
|
||||
++count; /* Продвигает плоскую таблицу только после полезной пары. */
|
||||
} /* Завершает применение одного бита маски. */
|
||||
} /* Все полезные слова готовы к атомарному обновлению модели. */
|
||||
output[1] = (uint16_t)count; /* Число пар делает ABI независимым от завершающих неиспользуемых элементов. */
|
||||
return (int)count; /* Положительный результат отличает собственный валидный кадр от чужого. */
|
||||
} /* Завершает общий декодер профиля без GPIO и Qt. */
|
||||
|
||||
unsigned uksvep_field_count(void) /* Предоставляет размер карты из единственного источника. */
|
||||
{ /* Клиент не хранит вторую таблицу адресов. */
|
||||
return (unsigned)(sizeof(fields) / sizeof(fields[0])); /* Размер определяется фактическим C-массивом. */
|
||||
} /* Завершает запрос количества полей. */
|
||||
|
||||
int uksvep_field_info(unsigned index, unsigned device, uint16_t *output, size_t capacity) /* Возвращает метаданные в явном массиве четырёх uint16. */
|
||||
{ /* Неверный адрес устройства не должен давать правдоподобные параметры другой платы. */
|
||||
if (capacity < 4U || index >= uksvep_field_count() || device > 15U || output == NULL) { /* Проверяет границы всех входных аргументов. */
|
||||
return 0; /* При отказе выходной буфер не изменяется. */
|
||||
} /* Индекс описывает существующее именованное поле. */
|
||||
output[0] = fields[index].address; /* Индекс слова modbus без дополнительного смещения. */
|
||||
output[1] = fields[index].maximum; /* Ограничение ввода остаётся общим для GUI и будущих портов. */
|
||||
output[2] = fields[index].address == 126U ? (uint16_t)(device + 1U) : fields[index].initial; /* Mode зависит от четырёх перемычек выбранного адреса. */
|
||||
if (fields[index].address == 23U) { /* R23 содержит активные логические перемычки J0…J3 в младшем полубайте. */
|
||||
output[2] = (uint16_t)device; /* Начальный эмулируемый адрес соответствует выбранному DeviceID без TEST. */
|
||||
} /* Профиль не создаёт противоречия между исходящим ID и состоянием перемычек. */
|
||||
output[3] = fields[index].flags; /* Роль определяет доступность редактирования реальной платы. */
|
||||
return 1; /* Все четыре скаляра успешно заполнены. */
|
||||
} /* Завершает запрос метаданных без передачи C padding через ABI. */
|
||||
|
||||
const char *uksvep_field_name(unsigned index) /* Возвращает UTF-8 имя именованного регистра. */
|
||||
{ /* Неверный индекс не должен приводить к чтению произвольного указателя. */
|
||||
return index < uksvep_field_count() ? fields[index].name : ""; /* Пустая строка явно означает отсутствие поля. */
|
||||
} /* Память строки остаётся во владении библиотеки. */
|
||||
|
||||
const char *uksvep_input_name(unsigned bit) /* Возвращает назначение физического дискретного входа. */
|
||||
{ /* В прошивке используются ровно четырнадцать разрядов R0. */
|
||||
return bit < 14U ? inputs[bit] : ""; /* Резервные 14/15 не получают выдуманных GPIO. */
|
||||
} /* Завершает запрос общей карты входов. */
|
||||
|
||||
const char *uksvep_command_name(unsigned bit) /* Возвращает назначение управляющего бита R127. */
|
||||
{ /* Сохраняет пустые позиции резервных команд package.h. */
|
||||
return bit < 16U ? commands[bit] : ""; /* Проверяет индекс до доступа к таблице. */
|
||||
} /* Завершает запрос именованных команд. */
|
||||
|
||||
void uksvep_evaluate_inputs(uint16_t value, uint16_t alarm_mask, uint16_t error_mask, uint16_t *output, size_t capacity) /* Модель ReadEnteres для исходящих данных эмулируемого ВЭП. */
|
||||
{ /* Расчёт используется только по явной кнопке и не считается ответом реальной платы. */
|
||||
if (output == NULL || capacity < 2U) { /* Позволяет безопасно отвергнуть неверный буфер клиента. */
|
||||
return; /* Не обращается к отсутствующей памяти. */
|
||||
} /* Все действия ниже повторяют особые случаи битов 7/8/9 из lampa.c. */
|
||||
output[0] = (uint16_t)((uint16_t)~value & alarm_mask & (uint16_t)~0x0380U); /* Обычные неисправности — отсутствие включённых в маску входов; 7/8/9 обрабатываются отдельно. */
|
||||
output[0] |= (uint16_t)(value & 0x0300U); /* Разряд и авария 24 В всегда являются неисправностями. */
|
||||
output[1] = (uint16_t)((uint16_t)~value & error_mask & (uint16_t)~0x0380U); /* Обычные аварии также определяются инверсией входов и маской. */
|
||||
output[1] |= (uint16_t)((value & error_mask & 0x0100U) | (value & 0x0200U)); /* Разряд зависит от Error_mask.8, авария 24 В всегда активна при R0.9=1. */
|
||||
} /* Заряд накопителя R0.7 никогда не создаёт неисправность либо аварию. */
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "set_modbus_gateway.h" /* Проверяет публичный C-контракт той же сборки, что используется GUI и Zynq. */
|
||||
#include <assert.h> /* Независимые ожидаемые байты и границы должны прерывать выпуск при расхождении. */
|
||||
#include <stdio.h> /* Единственный PASS печатается после всех протокольных сценариев. */
|
||||
#include <string.h> /* Сравнивает эталонные wire-байты и неизменность конфигурации при отказе. */
|
||||
int main(void) { /* Тест не открывает USB, Ethernet или реальные регистры устройств. */
|
||||
const uint8_t golden_rtu[] = {1,3,0,0,0,10,0xc5,0xcd}, golden_tcp[] = {0x12,0x34,0,0,0,6,1,3,0,0,0,10}; smg_request r, parsed; uint8_t wire[SMG_ADU], pdu[253]; smg_config c, restored, before; unsigned op; size_t n; /* CRC C5 CD задана независимым распространённым вектором FC03. */
|
||||
assert(smg_decode_rtu(golden_rtu, sizeof(golden_rtu), &r)); r.transaction = 0x1234; assert(!smg_check(&r)); n = smg_tcp(&r, 1, r.pdu, r.length, wire, sizeof(wire)); assert(n == sizeof(golden_tcp) && !memcmp(wire, golden_tcp, n)); /* Перевод RTU → TCP сохраняет PDU и назначенный Transaction ID. */
|
||||
assert(smg_decode_tcp(golden_tcp, sizeof(golden_tcp), &parsed)); n = smg_rtu(parsed.unit, parsed.pdu, parsed.length, wire, sizeof(wire)); assert(n == sizeof(golden_rtu) && !memcmp(wire, golden_rtu, n)); /* Обратный перевод формирует строго исходный CRC и порядок байтов. */
|
||||
for (size_t i = 0; i < 6; ++i) assert(!smg_tcp_size(golden_tcp, i)); for (size_t i = 6; i <= sizeof(golden_tcp); ++i) assert(smg_tcp_size(golden_tcp, i) == 12); assert(!smg_decode_tcp(golden_tcp, 11, &parsed)); /* Разделённый MBAP не трактуется как повреждённый или уже завершённый пакет. */
|
||||
memcpy(wire, golden_tcp, 12); wire[2] = 1; assert(smg_tcp_size(wire, 12) == -1); wire[2] = 0; wire[4] = 1; wire[5] = 255; assert(smg_tcp_size(wire, 12) == -1); /* Некорректный Protocol ID и чрезмерная длина отклоняются до копирования. */
|
||||
memcpy(wire, golden_rtu, 8); wire[7] ^= 1; assert(!smg_decode_rtu(wire, 8, &parsed)); assert(!smg_tcp(&r, 1, r.pdu, r.length, wire, 11)); /* Ошибка CRC и недостаточная ёмкость не порождают валидный запрос. */
|
||||
r.pdu[3] = 0; r.pdu[4] = 0; assert(smg_check(&r) == 3); r.pdu[4] = 126; assert(smg_check(&r) == 3); r.pdu[4] = 2; r.pdu[1] = 255; r.pdu[2] = 255; assert(smg_check(&r) == 2); /* Количество и адресный wraparound проверяются до внешней операции. */
|
||||
r.pdu[0] = 5; r.pdu[3] = 1; assert(smg_check(&r) == 3); r.pdu[3] = 255; r.pdu[4] = 0; assert(!smg_check(&r)); /* FC05 принимает стандартные FF00 и 0000, а не произвольную маску. */
|
||||
r.pdu[0] = 16; r.pdu[1] = r.pdu[2] = r.pdu[3] = 0; r.pdu[4] = 2; r.pdu[5] = 4; r.length = 10; assert(!smg_check(&r)); r.pdu[5] = 3; assert(smg_check(&r) == 3); /* Весь блок FC16 валидируется до передачи, включая byte count. */
|
||||
r.pdu[5] = 4; n = smg_exception(&r, 11, pdu); assert(n == 2 && pdu[0] == 0x90 && pdu[1] == 11); n = smg_rtu(7, pdu, n, wire, sizeof(wire)); assert(smg_accept_rtu(&r, 7, wire, n, pdu, sizeof(pdu)) == 2); assert(!smg_accept_rtu(&r, 8, wire, n, pdu, sizeof(pdu))); /* Исключение устройства передаётся только при совпадении конечного slave. */
|
||||
const uint8_t echo[] = {16,0,0,0,2}; n = smg_tcp(&r, 7, echo, sizeof(echo), wire, sizeof(wire)); assert(smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu)) == 5); wire[0] ^= 1; assert(!smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu))); wire[0] ^= 1; wire[n - 1] = 3; assert(!smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu))); /* Чужой Transaction ID и подтверждение другого количества не принимаются. */
|
||||
smg_config_default(&c); assert(smg_config_valid(&c) && !c.default_serial && !c.count); c.count = 2; c.routes[0] = (smg_route){5,1,0,0,0,0}; c.routes[1] = (smg_route){6,1,1,2,0xc0a8010a,502}; n = smg_config_pack(2, &c, pdu, sizeof(pdu)); assert(n == 33 && smg_config_unpack(pdu, n, &op, &restored) && op == 2 && !memcmp(&c, &restored, sizeof(c))); /* Таблица одновременно содержит RS485 и Ethernet RJ2 с remap двух одинаковых Slave ID. */
|
||||
before = restored; pdu[23] = 5; assert(!smg_config_unpack(pdu, n, &op, &restored) && !memcmp(&before, &restored, sizeof(before))); /* Дубликат источника отвергается транзакционно, не затирая действующую таблицу. */
|
||||
c.count = SMG_ROUTES; for (unsigned i = 0; i < c.count; ++i) c.routes[i] = (smg_route){(uint8_t)(i + 1),1,0,0,0,0}; n = smg_config_pack(3, &c, pdu, sizeof(pdu)); assert(n == 213 && smg_config_unpack(pdu, n, &op, &restored)); n = smg_rtu(SMG_CONTROL_UNIT, pdu, n, wire, sizeof(wire)); assert(n == 216 && smg_rtu_response_size(wire, n) == 216 && smg_rtu_size(wire, n) == 216); /* Максимальная таблица помещается в один стандартный RTU ADU. */
|
||||
r.unit = SMG_CONTROL_UNIT; r.length = (uint16_t)smg_config_pack(1, NULL, r.pdu, sizeof(r.pdu)); assert(smg_accept_rtu(&r, SMG_CONTROL_UNIT, wire, n, pdu, sizeof(pdu)) == 213); /* Ответ FC65 проходит тот же CRC и потоковый USB-контракт, что обычные данные. */
|
||||
puts("PASS modbus gateway: golden TCP/RTU, bounds, CRC, correlation, configuration"); return 0; /* PASS относится только к завершённым проверкам общего ядра. */
|
||||
} /* Аппаратные тайминги и работа конкретного PHY проверяются отдельными сценариями. */
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "uksvep_can.h" /* Проверяет C99-профиль, который вызывают все GUI через ABI. */
|
||||
#include "balsam_can.h" /* Сохраняет регрессию существующей маршрутизации Бальзам после выделения общего payload. */
|
||||
#include <assert.h> /* Неверный проводной вектор должен завершать тест с отказом. */
|
||||
#include <string.h> /* Сравнивает байты с независимым эталоном, а не только с обратным decoder. */
|
||||
|
||||
int main(void) /* Фиксирует реальную карту UKSVEP из can.c/lampa.c/package.h. */
|
||||
{ /* Проверки не требуют Qt, аппаратного CAN или микроконтроллера. */
|
||||
const uint16_t words[] = {0x1234U, 0xABCDU, 0x8001U}; /* Различные слова выявляют перестановку и потерю старшего бита. */
|
||||
const uint8_t golden[] = {0xABU, 0xCDU, 0x80U, 0x01U, 0xE0U, 0x08U, 0x12U, 0x34U}; /* R8…R10: word2, word3, маска111/адрес8, word1. */
|
||||
const uint8_t sparse[] = {0xAAU, 0x55U, 0x33U, 0x44U, 0xA0U, 0x7DU, 0x11U, 0x22U}; /* Маска101 разрешает R125 и R127, оставляя R126 неизменным. */
|
||||
uint8_t payload[8]; /* Выходной буфер реального кодировщика C. */
|
||||
uint16_t decoded[8]; /* Плоский ABI буфер направления и пар регистров. */
|
||||
uint16_t info[4]; /* Адрес, максимум, начальное значение и роль регистра. */
|
||||
uint16_t state[2]; /* Расчётные неисправности и аварии эмулируемого ВЭП. */
|
||||
balsam_can_frame_t previous = {0}; /* Независимо проверяет существующий публичный decoder Бальзам. */
|
||||
unsigned device; /* Перебирает все положения четырёх перемычек. */
|
||||
unsigned index; /* Перебирает общую карту подтверждённых полей. */
|
||||
|
||||
for (device = 0U; device < 16U; ++device) { /* Адреса не должны наследовать Mode=9 и базу BA0000 другого ВЭП. */
|
||||
assert(uksvep_can_id(device, 0U) == 0x001CE020U + device); /* Командный ID берётся из Setup_CAN_addr текущего STM32-проекта. */
|
||||
assert(uksvep_can_id(device, 1U) == 0x001CE000U + device); /* Исходящий ID ВЭП не содержит командного смещения 0x20. */
|
||||
} /* Проверяет обе роли каждого физического адреса. */
|
||||
assert(uksvep_can_id(16U, 0U) == UINT32_MAX && uksvep_can_id(0U, 2U) == UINT32_MAX); /* Ошибочная цель не обрезается до соседнего устройства. */
|
||||
assert(uksvep_can_encode(5U, 0U, 8U, words, 3U, payload, 7U) == 0U); /* Малый выходной буфер не допускает записи восьми байтов за его границы. */
|
||||
assert(uksvep_can_encode(5U, 0U, 8U, words, 3U, payload, sizeof(payload)) == 8U); /* Строит стандартный трёхсловный командный кадр. */
|
||||
assert(memcmp(payload, golden, sizeof(golden)) == 0); /* Эталон проверяет endian, адрес и маску независимо от decoder. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE025U, golden, 8U, decoded, 8U) == 3); /* Распознаёт собственное командное направление. */
|
||||
assert(decoded[0] == 0U && decoded[2] == 8U && decoded[3] == 0x1234U && decoded[6] == 10U && decoded[7] == 0x8001U); /* Возвращает исходные пары без потери разрядов. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE005U, sparse, 8U, decoded, 8U) == 2); /* Частичный RX не требует всех трёх последовательных регистров. */
|
||||
assert(decoded[0] == 1U && decoded[2] == 125U && decoded[3] == 0x1122U && decoded[4] == 127U && decoded[5] == 0x3344U); /* Отсутствующий R126 не появляется среди обновлений. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE004U, golden, 8U, decoded, 8U) == 0); /* Данные другого адреса игнорируются. */
|
||||
assert(uksvep_can_decode(5U, 0x00BA0018U, golden, 8U, decoded, 8U) == 0); /* Старый ВЭП проекта Бальзам не подменяет STM32 УКСВЭП. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE005U, golden, 7U, decoded, 8U) == -1); /* Повреждённый DLC собственного узла отвергается. */
|
||||
assert(uksvep_can_encode(5U, 0U, 127U, words, 2U, payload, sizeof(payload)) == 0U); /* Запись второго слова не может пересечь R127. */
|
||||
assert(uksvep_can_encode(5U, 0U, 127U, words, 1U, payload, sizeof(payload)) == 8U && payload[4] == 0x80U && payload[5] == 0x7FU); /* Одно последнее слово использует маску100 и нулевые соседи. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE005U, payload, 8U, decoded, 8U) == 1); /* Последний валидный регистр можно принять отдельно. */
|
||||
payload[4] = 0xE0U; /* Искусственно делает присутствующими запрещённые R128/R129. */
|
||||
assert(uksvep_can_decode(5U, 0x001CE005U, payload, 8U, decoded, 8U) == -1); /* Такой пакет отвергается целиком, без частичного применения R127. */
|
||||
assert(balsam_can_decode(0x00BA0017U, golden, 8U, &previous) == 1 && previous.device == 8U && previous.values[1] == 0xABCDU); /* Общий payload сохраняет прежний маршрут и слова задатчика. */
|
||||
for (index = 0U; index < uksvep_field_count(); ++index) { /* Каждое именованное поле должно принадлежать реальной карте. */
|
||||
assert(uksvep_field_info(index, 7U, info, 4U) && info[0] < 128U && info[2] <= info[1]); /* Начальное значение не превышает UI-границу поля. */
|
||||
assert(strlen(uksvep_field_name(index)) != 0U); /* Нет безымянных полей, которые выглядели бы пропущенной картой. */
|
||||
if (info[0] == 126U) { /* Физический адрес отличается от Mode на единицу. */
|
||||
assert(info[2] == 8U); /* DeviceID7 соответствует Mode8 независимо от названия устройства. */
|
||||
} /* Остальные defaults одинаковы для всех адресов. */
|
||||
} /* Размер карты задаёт C, а не отдельный список Python. */
|
||||
assert(!uksvep_field_info(999U, 0U, info, 4U) && strlen(uksvep_input_name(14U)) == 0U); /* За границами нет фиктивных регистров и GPIO. */
|
||||
assert(strstr(uksvep_input_name(9U), "PB12") != NULL && strlen(uksvep_command_name(4U)) == 0U); /* Особый прямой вход и резервная команда сохраняют реальное назначение. */
|
||||
uksvep_evaluate_inputs(0x247FU, 0x277FU, 0x0200U, state, 2U); /* Моделирует исправные штатные входы и маски. */
|
||||
assert(state[0] == 0U && state[1] == 0U); /* Исправное состояние не должно вызывать ложную аварию. */
|
||||
uksvep_evaluate_inputs(0x247EU, 0x277FU, 0x0200U, state, 2U); /* Убирает только исправность ИП1. */
|
||||
assert(state[0] == 1U && state[1] == 0U); /* Маска по умолчанию делает потерю ИП1 неисправностью, а не аварией. */
|
||||
uksvep_evaluate_inputs(0x27FFU, 0U, 0U, state, 2U); /* Включает заряд, разряд и аварию 24 В при нулевых масках. */
|
||||
assert(state[0] == 0x0300U && state[1] == 0x0200U); /* Заряд не влияет, разряд всегда неисправность, 24 В всегда авария. */
|
||||
uksvep_evaluate_inputs(0x25FFU, 0U, 0x0100U, state, 2U); /* Разрешает аварийный разряд отдельным битом маски. */
|
||||
assert(state[1] == 0x0100U); /* Особый бит 8 проверяется по своей полярности без инверсии. */
|
||||
return 0; /* Все эталоны, границы и регрессия старого профиля выполнены. */
|
||||
} /* Завершает независимую host-проверку C-профиля. */
|
||||
@@ -24,7 +24,8 @@ INCLUDE = ROOT / "include"
|
||||
JNI_INCLUDES: list[Path] = []
|
||||
SOURCES = [
|
||||
ROOT / "src" / name for name in (
|
||||
"c2000_sci.c", # Прямые MSVC/CC-сборки экспортируют общий автомат штатного SCI-загрузчика F28335.
|
||||
"c2000_sci.c", # Прямая MSVC/CC-сборка включает общий автомат SCI F28335.
|
||||
"set_modbus_gateway.c", # Экспортирует общий USB RTU/TCP кодек также при прямой MSVC/CC сборке без CMake.
|
||||
"deif_xdi.c", # Общий DEIF входит в host-библиотеку.
|
||||
"set_invertor_gas.c", # Собирает общий адаптер двух GAS для STM Invertor при прямом запуске host-builder.
|
||||
"set_deif_gauge.c", "set_igbt_realtime.c",
|
||||
@@ -33,6 +34,7 @@ SOURCES = [
|
||||
"altera_stream.c",
|
||||
"set_gas_watch.c", # Добавляет общий GAS_WATCH2 в прямые MSVC/CC сборки, чтобы новые ctypes/JNI символы были доступны.
|
||||
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", "set_signal.c", "set_wavegen.c",
|
||||
"uksvep_can.c", # Включает общий профиль ВЭП также в прямую MSVC/CC сборку DLL.
|
||||
"balsam_can.c", "set_crc.c", "periph28335.c", "tms2812.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
|
||||
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
|
||||
"pcan_gas.c", "pcan_modbus_server.c",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Прошивка STM32 через J-Link
|
||||
|
||||
В SETGUI откройте **Сервис → Прошивка** и выберите **STM32 / J-Link JTAG или SWD**.
|
||||
Установленный SEGGER J-Link Commander обнаруживается в Keil, каталоге SEGGER или PATH.
|
||||
При необходимости укажите `JLink.exe` кнопкой «Обзор». Одноимённый инструмент Java JDK программатором не является.
|
||||
|
||||
Выберите микроконтроллер из базы J-Link, JTAG либо SWD, частоту в кГц и фактический объём Flash в КиБ.
|
||||
Начальная частота — 1000 кГц. При нескольких подключённых J-Link укажите серийный номер нужного программатора.
|
||||
COM-порт и доступ к BOOT0 для этого режима не требуются.
|
||||
|
||||
Выберите HEX или BIN и нажмите «Начать прошивку». HEX сохраняет собственные абсолютные адреса и разрывы;
|
||||
для BIN укажите адрес в поле «Адрес приложения». При изменении файла после выбора требуется выбрать его заново.
|
||||
Если есть manifest выпуска, его target должен соответствовать выбранному MCU.
|
||||
|
||||
Каждая операция создаёт отдельный каталог в выбранной папке резервных копий:
|
||||
|
||||
- `before.bin` — вся Flash до записи;
|
||||
- `image.hex` — проверенный разреженный снимок записываемых данных;
|
||||
- `after.bin` — вся Flash после записи;
|
||||
- `program.jlink`, `program.log`, `program-dll.log` — команды и результаты программирования;
|
||||
- `run.jlink`, `run.log`, `run-dll.log` — отдельный этап сброса и запуска.
|
||||
|
||||
Программирование выполняется через `loadfile` без команды полного стирания.
|
||||
После встроенной проверки J-Link выполняется независимое побайтное сравнение readback с планом.
|
||||
Только после успешного сравнения отдельный процесс делает reset и `g`.
|
||||
Нулевой код выхода без полных снимков Flash не считается успешной записью.
|
||||
Отмена останавливает собственный процесс Commander; при прерванной записи нужна повторная прошивка.
|
||||
|
||||
## UKSVEP STM32F103RC
|
||||
|
||||
Для текущей платы оставьте включённым профиль UKSVEP, устройство `STM32F103RC`, объём 256 КиБ.
|
||||
Приложение начинается с `0x08008000`. Страница параметров `0x0801F800..0x0801FFFF` запрещена для записи,
|
||||
её сохранность проверяется сравнением `before.bin` и `after.bin`.
|
||||
|
||||
Для первой установки выберите разреженный `uksvep_install.hex`, содержащий boot и приложение.
|
||||
Для обновления через JTAG можно выбрать обычный HEX приложения или BIN с адресом `0x08008000`.
|
||||
Общий план добавит manifest CRC32 по адресу `0x0803F800`, который проверяет постоянный загрузчик.
|
||||
Готовый manifest установочного HEX сверяется с приложением. Перед запуском снимаются старые одноразовые
|
||||
маркеры boot в BKP DR9/DR10 без стирания резервного домена.
|
||||
|
||||
Для других плат снимите флажок UKSVEP и задайте их микроконтроллер и объём Flash.
|
||||
Защита страницы и формирование manifest UKSVEP относятся только к этому явно выбранному профилю.
|
||||
|
||||
## Расположение общей реализации
|
||||
|
||||
- `python/set_devices/stm32_jtag.py` — неизменяемые настройки, проверка образа, разреженный HEX, сценарии и readback;
|
||||
- `python/set_devices/qt_ports/stm32_jtag.py` — асинхронный порт QProcess, журналы, тайм-аут, отмена и reset/run;
|
||||
- `python/set_devices/qt_ports/stm32_jtag_panel.py` — встраиваемая форма Qt 5/6 с сохранением настроек;
|
||||
- `python/tests/test_stm32_jtag.py` — проверки плана, защиты параметров и ошибок без физического MCU.
|
||||
|
||||
Модули не импортируют `gui_desktop`. SETGUI подключает панель к существующим кнопкам, прогрессу и терминалу.
|
||||
Код загрузчика `stm32-jtag`, транспорт `stm32`, соответствует индексу 8 формы;
|
||||
профиль `flashing.profiles.stm32-jtag` может задавать `base_address`.
|
||||
Параметры конкретного J-Link сохраняются локально и не публикуются вместе с прошивкой.
|
||||
SEGGER Commander должен быть установлен отдельно; пакет SETGUI не скачивает и не включает сторонний executable.
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Расчёт токов асинхронного двигателя по состояниям ключей инвертора.""" # Модель не зависит от GUI, sigrok и сторонних пакетов Python.
|
||||
import cmath # Комплексные числа представляют неподвижные электрические оси α и β.
|
||||
import math # Проверки параметров и преобразования СИ используют стандартную библиотеку.
|
||||
|
||||
|
||||
def finite(value, name, minimum=None, strict=False): # Единая проверка исключает NaN, бесконечности и нефизические параметры.
|
||||
number = float(value) # Параметры sigrok и непосредственных вызовов приводятся к одному числовому типу.
|
||||
if not math.isfinite(number) or minimum is not None and (number <= minimum if strict else number < minimum): # Строгая граница применяется к сопротивлениям и индуктивностям.
|
||||
raise ValueError('{} has an invalid value: {}'.format(name, value)) # Сообщение указывает конкретную настройку пользователя.
|
||||
return number # Возвращается только проверенное значение в единицах вызывающей стороны.
|
||||
|
||||
|
||||
def clarke(values): # Амплитудно-инвариантное преобразование сохраняет амплитуду симметричных фаз.
|
||||
return complex((2 * values[0] - values[1] - values[2]) / 3, (values[1] - values[2]) / math.sqrt(3)) # Оси αβ соответствуют порядку фаз A, B, C.
|
||||
|
||||
|
||||
def phases(vector): # Обратное преобразование восстанавливает три фазы трёхпроводной нагрузки.
|
||||
return (vector.real, -vector.real / 2 + math.sqrt(3) * vector.imag / 2, -vector.real / 2 - math.sqrt(3) * vector.imag / 2) # Сумма фаз равна нулю по первому закону Кирхгофа.
|
||||
|
||||
|
||||
class InductionMotor: # Линейная модель короткозамкнутого двигателя использует приведённые к статору параметры ротора.
|
||||
def __init__(self, rs=1.0, rr=0.8, lls_mh=5.0, llr_mh=5.0, lm_mh=150.0, pole_pairs=2, speed_rpm=1440.0, initial_flux=0.0, flux_angle=0.0, ia=0.0, ib=0.0): # Значения по умолчанию являются примером, а не паспортом конкретного двигателя.
|
||||
self.rs = finite(rs, 'Stator R (ohm)', 0, True) # Положительное сопротивление одной обмотки статора задаётся в омах.
|
||||
self.rr = finite(rr, 'Rotor R referred to stator (ohm)', 0, True) # Сопротивление ротора должно быть приведено к той же обмотке статора.
|
||||
lls = finite(lls_mh, 'Stator leakage L (mH)', 0, True) / 1000 # Миллигенри переводятся в генри перед вычислением уравнений.
|
||||
llr = finite(llr_mh, 'Rotor leakage L (mH)', 0, True) / 1000 # Параметр ротора также приводится к статору и переводится в генри.
|
||||
lm = finite(lm_mh, 'Magnetizing L (mH)', 0, True) / 1000 # Взаимная индуктивность задаёт магнитную связь без насыщения.
|
||||
pairs = finite(pole_pairs, 'Pole pairs', 1) # Число пар полюсов переводит механическую скорость в электрическую.
|
||||
if pairs != int(pairs): # Дробное число пар полюсов не описывает физический двигатель.
|
||||
raise ValueError('Pole pairs must be an integer.') # Ошибка выдаётся до расчёта токов.
|
||||
omega = finite(speed_rpm, 'Mechanical speed (rpm)') * pairs * math.tau / 60 # Один оборот равен 2π радиан, одна минута содержит 60 секунд.
|
||||
lr = llr + lm # Полная индуктивность ротора включает рассеяние и намагничивание.
|
||||
self.lsigma = lls + lm * llr / lr # Устойчивая форма Ls−Lm²/Lr не теряет точность при малом рассеянии.
|
||||
self.k = lm / lr # Коэффициент связи переводит потокосцепление ротора в уравнение статора.
|
||||
self.a = -(self.rs + self.rr * self.k * self.k) / self.lsigma # Первый диагональный коэффициент учитывает потери статора и приведённого ротора.
|
||||
self.b = self.k * complex(self.rr / lr, -omega) / self.lsigma # Влияние потока ротора включает его вращение относительно неподвижных осей.
|
||||
self.c = self.rr * self.k # Ток статора возбуждает поток ротора через взаимную индуктивность.
|
||||
self.d = complex(-self.rr / lr, omega) # Поток ротора затухает с постоянной Lr/Rr и вращается с электрической скоростью ротора.
|
||||
self.det = self.a * self.d - self.b * self.c # Определитель нужен для точного вынужденного решения при постоянном напряжении.
|
||||
self.mid = (self.a + self.d) / 2 # Половина следа матрицы задаёт среднее собственных значений.
|
||||
self.root = cmath.sqrt(((self.a - self.d) / 2) ** 2 + self.b * self.c) # Разность собственных значений позволяет вычислить матричную экспоненту без numpy.
|
||||
initial = (finite(ia, 'Initial Ia (A)'), finite(ib, 'Initial Ib (A)')) # Начальные линейные токи задаются в амперах.
|
||||
self.current = clarke((initial[0], initial[1], -sum(initial))) # Третья фаза определяется условием трёхпроводного подключения.
|
||||
self.flux = finite(initial_flux, 'Initial rotor flux linkage (Wb)', 0) * cmath.exp(1j * math.radians(finite(flux_angle, 'Initial rotor flux angle (deg)'))) # Модуль и угол задают неизвестное при начале записи начальное потокосцепление.
|
||||
self.cache = {} # Повторяющиеся интервалы переключений используют готовую матрицу перехода.
|
||||
|
||||
def advance(self, seconds, voltage): # Между фронтами ключей напряжение неизменно, поэтому используется точное линейное решение.
|
||||
dt = finite(seconds, 'Time interval (s)', 0) # Отрицательное время и неконечные интервалы запрещены.
|
||||
if not dt: # Повторные события одного отсчёта меняют ключи без изменения физического состояния.
|
||||
return phases(self.current) # Начальный ток сохраняется без искусственного шага интегрирования.
|
||||
matrix = self.cache.get(dt) # На равномерной сетке большинство длительностей повторяется.
|
||||
if matrix is None: # Новая длительность требует вычисления exp(A·dt) ровно один раз.
|
||||
if abs(self.root * dt) < 1e-8: # При близких собственных значениях деление на их разность теряет точность.
|
||||
factor = cmath.exp(self.mid * dt) # Общая экспонента остаётся ограниченной для пассивного двигателя.
|
||||
ratio = factor * dt * (1 + (self.root * dt) ** 2 / 6) # Разложение sinh(z)/z сохраняет точность около нуля.
|
||||
center = factor * (1 + (self.root * dt) ** 2 / 2) # Разложение cosh(z) согласовано с тем же пределом.
|
||||
else: # Раздельные экспоненты избегают переполнения cosh при сильно различающихся постоянных времени.
|
||||
first, second = cmath.exp((self.mid + self.root) * dt), cmath.exp((self.mid - self.root) * dt) # Обе моды учитывают затухание и вращение потока ротора.
|
||||
ratio, center = (first - second) / (2 * self.root), (first + second) / 2 # Эти коэффициенты задают экспоненту матрицы размерности два.
|
||||
matrix = (center + ratio * (self.a - self.mid), ratio * self.b, ratio * self.c, center + ratio * (self.d - self.mid)) # Порядок элементов соответствует строкам тока статора и потока ротора.
|
||||
if len(self.cache) >= 1024: # Ограничение в 1024 различных длительности исключает рост памяти на нерегулярном ШИМ.
|
||||
self.cache.clear() # Сброс кэша влияет только на производительность, а не на состояние двигателя.
|
||||
self.cache[dt] = matrix # Сохраняется только матрица перехода, независимая от текущего напряжения.
|
||||
drive = clarke(voltage) / self.lsigma # Фазные напряжения переводятся в неподвижный вектор скорости изменения тока.
|
||||
equilibrium_i, equilibrium_flux = -self.d * drive / self.det, self.c * drive / self.det # Частное решение −A⁻¹B·u учитывает постоянное напряжение на всём интервале.
|
||||
delta_i, delta_flux = self.current - equilibrium_i, self.flux - equilibrium_flux # Начальное состояние переносится относительно частного решения.
|
||||
self.current = equilibrium_i + matrix[0] * delta_i + matrix[1] * delta_flux # Ток обновляется без явного Эйлера и зависимости устойчивости от частоты отображения.
|
||||
self.flux = equilibrium_flux + matrix[2] * delta_i + matrix[3] * delta_flux # Поток использует прежний ток, сохранённый до обновления состояния.
|
||||
if not all(math.isfinite(value) for value in (self.current.real, self.current.imag, self.flux.real, self.flux.imag)): # Чрезмерные параметры не должны порождать молчаливые NaN в графиках.
|
||||
raise ValueError('Motor state overflow: check motor parameters and capture timing.') # Сообщение связывает ошибку с физическими параметрами и временем записи.
|
||||
return phases(self.current) # Возвращаются мгновенные токи трёх эквивалентных обмоток в амперах.
|
||||
|
||||
|
||||
class PwmMotor: # Связь логических входов, силовой схемы и общего двигателя не содержит зависимости от DSView.
|
||||
def __init__(self, samplerate, topology='2-level', dc_voltage=560.0, midpoint_percent=50.0, connection='star', active_high=True, **motor): # Полное напряжение звена задаётся между положительной и отрицательной шинами.
|
||||
self.rate = finite(samplerate, 'Samplerate (Hz)', 0, True) # Частота захвата переводит номера отсчётов в секунды.
|
||||
if topology not in ('2-level', '3-level P/N', '3-level NPC') or connection not in ('star', 'delta'): # Поддерживаются явные схемы без автоматического угадывания кодировки.
|
||||
raise ValueError('Unsupported inverter topology or winding connection.') # Неизвестная схема не подменяется другой электрической моделью.
|
||||
dc = finite(dc_voltage, 'Total DC link (V)', 0, True) # Значение в вольтах относится ко всему звену, а не к его половине.
|
||||
split = finite(midpoint_percent, 'Upper DC link (%)', 0, True) / 100 # Доля верхней половины позволяет моделировать дисбаланс конденсаторов.
|
||||
if split >= 1: # Обе половины звена должны иметь строго положительное напряжение.
|
||||
raise ValueError('Upper DC link fraction must be between 0 and 100 percent.') # Граничные значения не описывают трёхуровневое звено.
|
||||
self.upper, self.lower = dc * split, -dc * (1 - split) # Потенциалы полюсов отсчитываются от средней точки конденсаторов.
|
||||
self.topology, self.connection, self.active_high = topology, connection, bool(active_high) # Параметры сохраняются для интерпретации каждой новой комбинации ключей.
|
||||
ia, ib = motor.get('ia', 0.0), motor.get('ib', 0.0) # Начальные токи пользователь задаёт в линейных проводах независимо от схемы обмоток.
|
||||
if connection == 'delta': # В симметричном треугольнике линейный ток равен разности токов соседних обмоток.
|
||||
motor.update(ia=(2 * ia + ib) / 3, ib=(ib - ia) / 3) # Обратное преобразование предполагает отсутствие циркулирующего тока нулевой последовательности.
|
||||
self.motor = InductionMotor(**motor) # Параметры Rr и Lr всегда приведены к одной фактической обмотке статора.
|
||||
self.sample, self.pins, self.poles = None, None, (0.0, 0.0, 0.0) # До первого отсчёта не создаётся вымышленный участок записи.
|
||||
self.warnings = () # Диагностика сообщает о комбинациях, не задающих однозначное выходное напряжение.
|
||||
self.interval_warnings = () # Завершённый интервал сохраняет собственную диагностику до приёма следующего состояния затворов.
|
||||
|
||||
def currents(self): # Публичные результаты относятся к проводам A, B, C, подключённым к инвертору.
|
||||
a, b, c = phases(self.motor.current) # Эквивалентные токи обмоток сохраняют условие отсутствия нулевой последовательности.
|
||||
return (a - c, b - a, c - b) if self.connection == 'delta' else (a, b, c) # Для треугольника выводятся линейные токи, а не токи внутри обмоток.
|
||||
|
||||
def set_gates(self, pins): # Нумерация: Q1/P фаз A/B/C, Q4/N фаз A/B/C, затем Q2/Q3 каждой фазы.
|
||||
if len(pins) != 12 or any(value not in (0, 1, None) for value in pins): # Отсутствующий логический канал обозначается None до нормализации полярности.
|
||||
raise ValueError('Expected twelve gate inputs containing 0, 1 or None.') # Служебные значения sigrok не принимаются за реальный логический уровень.
|
||||
bits = tuple(None if value is None else bool(value) == self.active_high for value in pins) # Инверсия применяется только к реально назначенным входам.
|
||||
required = range(3) if self.topology == '2-level' else range(6) if self.topology == '3-level P/N' else range(12) # Три, шесть или двенадцать каналов нужны выбранной схеме.
|
||||
if any(bits[index] is None for index in required): # Неполная трёхфазная схема не позволяет определить линейные напряжения двигателя.
|
||||
raise ValueError('Assign all gate channels required by the selected topology.') # Ошибка возникает до продолжения расчёта по отсутствующим сигналам.
|
||||
if self.topology == '2-level' and any(value is not None for value in bits[3:6]) and any(value is None for value in bits[3:6]): # Нижние ключи двухуровневой схемы назначаются одновременно для всех фаз.
|
||||
raise ValueError('Assign either all three lower gates or none.') # Частично измеренные нижние ключи не смешиваются с идеальными дополнениями.
|
||||
poles, warnings = [], [] # Каждая фаза сначала получает собственный потенциал и список неоднозначностей.
|
||||
currents = self.currents() # Направление линейного тока задаёт приближённую проводимость обратных диодов во время dead time.
|
||||
for phase in range(3): # Все входы одного отсчёта обрабатываются одновременно, без ложных промежуточных состояний.
|
||||
high, low = bits[phase], bits[phase + 3] # В P/N это команды положительного и отрицательного уровней; в NPC это внешние Q1 и Q4.
|
||||
state = (high, low) # Стандартная таблица P/N использует только два внешних логических входа.
|
||||
table = {(True, False): self.upper, (False, True): self.lower, (False, False): 0.0} # Состояние 00 задаёт среднюю точку только в явной кодировке P/N.
|
||||
if self.topology == '2-level': # Один вход на фазу означает идеальную комплементарную пару без измеренного dead time.
|
||||
if low is None: # Нижняя команда восстанавливается только когда пользователь не назначил соответствующие входы.
|
||||
low = not high # Низкий уровень верхней команды соответствует отрицательной шине.
|
||||
state = (high, low) # Реальные нижние команды сохраняют интервалы одновременного закрытия ключей.
|
||||
table = {(True, False): self.upper, (False, True): self.lower} # Одновременное включение двух ключей не имеет допустимого фазного напряжения.
|
||||
if self.topology == '3-level NPC': # NPC обрабатывает четыре отдельных затворных сигнала каждого плеча.
|
||||
state = (high, bits[6 + 2 * phase], bits[7 + 2 * phase], low) # Физический порядок ключей идёт сверху вниз от положительной шины к отрицательной.
|
||||
table = {(True, True, False, False): self.upper, (False, True, True, False): 0.0, (False, False, True, True): self.lower} # Допустимые состояния Q1…Q4 равны 1100, 0110 и 0011.
|
||||
if state in table: # Однозначная команда непосредственно определяет полюсное напряжение.
|
||||
pole = table[state] # Токи интегрируются по реальному напряжению, а не по сглаженному заполнению ШИМ.
|
||||
elif not any(state): # Полностью закрытое плечо проводит через обратные диоды при ненулевом токе.
|
||||
pole = self.lower if currents[phase] > 1e-9 else self.upper if currents[phase] < -1e-9 else 0.0 # Порог 1 нА подавляет выбор шины по ошибке округления около нулевого тока.
|
||||
warnings.append('Phase {}: diode/dead-time approximation'.format('ABC'[phase])) # Неизвестные падения на диодах и смена проводимости явно отмечаются как приближение.
|
||||
else: # Недопустимые перекрытия и неполные состояния NPC не интерпретируются как достоверное напряжение.
|
||||
pole = self.poles[phase] # Последний определённый потенциал используется только как явно помеченное продолжение оценки.
|
||||
warnings.append('Phase {}: invalid gates {}; current estimate unreliable'.format('ABC'[phase], ''.join(str(int(value)) for value in state))) # Диагностика содержит фазу и фактическую комбинацию ключей.
|
||||
poles.append(pole) # Все три полюса сохраняются до вычисления напряжений обмоток.
|
||||
self.pins, self.poles, self.warnings = bits, tuple(poles), tuple(warnings) # Состояние относится к интервалу после текущего одновременного переключения.
|
||||
|
||||
def update(self, sample, pins=None): # Сначала завершается предыдущий электрический интервал и только затем меняются ключи.
|
||||
stamp = int(sample) # Номера отсчётов сохраняются целыми, чтобы исключить накопление ошибки временной сетки.
|
||||
if stamp != sample or stamp < 0 or self.sample is not None and stamp < self.sample: # Отсчёты должны поступать в хронологическом порядке.
|
||||
raise ValueError('Sample numbers must be nonnegative, integral and monotonic.') # Ошибка входной последовательности не маскируется нулевой длительностью.
|
||||
previous, start = self.currents(), self.sample # Левый конец сегмента берётся до интегрирования предыдущего напряжения.
|
||||
self.interval_warnings = self.warnings # Выходной пакет должен описывать ключи рассчитанного интервала, а не уже принятую новую команду.
|
||||
if start is not None and stamp > start: # Первый отсчёт определяет только начальное состояние записи.
|
||||
a, b, c = self.poles # Полюсные напряжения неизменны с прошлого обработанного фронта.
|
||||
voltage = (a - b, b - c, c - a) if self.connection == 'delta' else (a, b, c) # Преобразование Кларка само вычитает плавающий потенциал нейтрали звезды.
|
||||
self.motor.advance((stamp - start) / self.rate, voltage) # Точное решение учитывает длительность каждого фактического состояния ключей.
|
||||
self.sample = stamp # Обновление временной отметки предшествует приёму новых команд того же отсчёта.
|
||||
if pins is not None: # Периодические точки графика могут приходить без нового фронта.
|
||||
self.set_gates(pins) # Новые уровни действуют только справа от своей временной отметки.
|
||||
elif self.pins is not None: # Переоценка диодов на сетке ограничивает ошибку около смены знака тока.
|
||||
self.set_gates(tuple(None if value is None else int(value == self.active_high) for value in self.pins)) # Логические уровни переводятся обратно во входную полярность без двойной инверсии.
|
||||
return None if start is None or stamp == start else (start, stamp, previous, self.currents()) # Сегмент содержит физические амперы на обоих концах интервала.
|
||||
@@ -19,6 +19,11 @@ class PwmChannel:
|
||||
self.window_index = 1
|
||||
self.high = 0
|
||||
self.previous = None
|
||||
self.carrier_start = self.carrier_period = None # Два действительных активных фронта нужны для измерения несущей; начальный уровень не считается фронтом.
|
||||
self.carrier_count = self.carrier_samples = 0 # Счётчик полных периодов и их суммарная длина в отсчётах позволяют усреднять несущую без хранения записи.
|
||||
self.field_previous = self.field_crossing = self.field_pending = None # Интерполированное пересечение уровня 50% отделено от подтверждённого полного периода поля.
|
||||
self.field_armed = False # До отрицательной полуволны начальный фрагмент огибающей не может открыть измерение поля.
|
||||
self.field_carrier = None # Снимок счётчиков несущей привязывает среднюю частоту к последнему завершённому периоду поля.
|
||||
|
||||
def next_boundary(self):
|
||||
if self.window is None or self.origin is None:
|
||||
@@ -34,9 +39,33 @@ class PwmChannel:
|
||||
alpha = -math.expm1(-(midpoint - previous[0]) / self.tau)
|
||||
value = previous[1] + alpha * (value - previous[1])
|
||||
self.previous = (midpoint, value)
|
||||
carrier = self.rate / self.carrier_period if self.carrier_period and end - self.carrier_start <= 2 * self.carrier_period else None # После двух периодов без фронтов прежняя несущая больше не считается текущим измерением.
|
||||
field = self._field(midpoint, value) # Частота поля определяется по той же нормированной огибающей, которая выводится на график.
|
||||
return dict(start=start, end=end, duty=duty, value=value,
|
||||
frequency=self.rate / (end - start), midpoint=midpoint,
|
||||
previous=previous)
|
||||
previous=previous, carrier_frequency=carrier, field=field) # Частота окна остаётся совместимой со старым контрактом, измеренная несущая и полный период поля передаются отдельно.
|
||||
|
||||
def _field(self, sample, value): # Потоковый детектор измеряет электрическую частоту между одноимёнными пересечениями уровня заполнения 50%.
|
||||
previous = self.field_previous # Предыдущая точка нужна для линейной интерполяции времени пересечения между серединами периодов ШИМ.
|
||||
self.field_previous = (sample, value) # Храним только последнюю точку, поэтому память не зависит от длительности захвата.
|
||||
if value <= -0.02: # Гистерезис 0,02 нормированной шкалы соответствует одному процентному пункту заполнения ниже 50%.
|
||||
self.field_armed = True # Отрицательная полуволна разрешает одно следующее положительное пересечение.
|
||||
if self.field_armed and previous is not None and previous[1] <= 0 < value: # Учитываем только восходящий переход, чтобы не удвоить электрическую частоту.
|
||||
self.field_pending = previous[0] - previous[1] * (sample - previous[0]) / (value - previous[1]) # Интерполяция устраняет квантование периода поля шагом несущей.
|
||||
if value <= 0: # Возврат через нуль до положительного порога означает неподтверждённое пересечение.
|
||||
self.field_pending = None # Колебания внутри полосы гистерезиса не создают отдельные периоды.
|
||||
if not self.field_armed or self.field_pending is None or value < 0.02: # Положительный порог также отстоит от 50% на один процентный пункт заполнения.
|
||||
return None # Постоянное заполнение и неполные полуволны не получают выдуманную частоту поля.
|
||||
crossing = self.field_pending # Подтверждение использует время нулевого пересечения, а не более позднее достижение порога.
|
||||
last = self.field_crossing # Два подтверждённых восходящих пересечения ограничивают один полный электрический период.
|
||||
counters = (self.carrier_count, self.carrier_samples) # Снимок относится только к действительно наблюдавшимся активным фронтам.
|
||||
old_counters = self.field_carrier # Разность накопленных счётчиков даёт среднюю несущую за текущий период поля.
|
||||
self.field_crossing, self.field_carrier = crossing, counters # Следующий период начинается с текущего подтверждённого пересечения.
|
||||
self.field_armed, self.field_pending = False, None # Новое измерение разрешается только после следующей отрицательной полуволны.
|
||||
if last is None: # Первое пересечение задаёт начало, но ещё не содержит полного периода.
|
||||
return None # Начальный неполный фрагмент записи исключён из результата.
|
||||
count, elapsed = counters[0] - old_counters[0], counters[1] - old_counters[1] # Длительность измеренных периодов несущей выражена в отсчётах, а не миллисекундах.
|
||||
return dict(start=round(last), end=round(crossing), frequency=self.rate / (crossing - last), carrier_frequency=self.rate * count / elapsed if elapsed else None) # Полный период поля получает частоту в Гц и среднюю измеренную несущую, если за интервал были фронты.
|
||||
|
||||
def update(self, sample, pin):
|
||||
level = bool(pin) == self.active_high
|
||||
@@ -48,6 +77,8 @@ class PwmChannel:
|
||||
if sample < self.last:
|
||||
raise ValueError('Samples must be monotonic.')
|
||||
events = []
|
||||
if level != self.level and level and self.window is None: # В автоматическом режиме новый активный фронт одновременно завершает текущий период ШИМ.
|
||||
self._carrier_edge(sample) # Обновляем измерение до выдачи завершённого периода, чтобы частота относилась именно к нему.
|
||||
if self.window is not None:
|
||||
boundary = self.next_boundary()
|
||||
while boundary <= sample:
|
||||
@@ -58,6 +89,8 @@ class PwmChannel:
|
||||
self.window_index += 1
|
||||
boundary = self.next_boundary()
|
||||
self.high += (sample - self.last) * self.level
|
||||
if level != self.level and level: # В режиме окон частота несущей всё равно измеряется по фронтам, независимо от заданного размера окна.
|
||||
self._carrier_edge(sample) # Фронт на границе окна относится к следующему окну и не меняет уже выданное измерение.
|
||||
elif level != self.level:
|
||||
if level:
|
||||
if self.start is not None and self.fall is not None:
|
||||
@@ -67,3 +100,121 @@ class PwmChannel:
|
||||
self.fall = sample
|
||||
self.last, self.level = sample, level
|
||||
return events
|
||||
|
||||
def _carrier_edge(self, sample): # Оба режима восстановления используют единый счётчик фактических периодов несущей.
|
||||
if self.carrier_start is not None and sample > self.carrier_start: # Первый фронт только задаёт начало; нулевой период не допускается.
|
||||
self.carrier_period = sample - self.carrier_start # Интервал активный фронт — активный фронт задаёт полный период ШИМ в отсчётах.
|
||||
self.carrier_count += 1 # Счётчик нужен для среднего числа полных периодов за электрический цикл.
|
||||
self.carrier_samples += self.carrier_period # Суммируем длительности, чтобы переменная несущая усреднялась как число периодов за время.
|
||||
self.carrier_start = sample # Следующее измерение начинается с текущего реального фронта.
|
||||
|
||||
|
||||
class NpcChannel(PwmChannel): # Один вход NPC объединяет четыре затвора S1…S4, расположенные сверху вниз в фазной стойке.
|
||||
STATES = {(1, 1, 0, 0): 1, (0, 1, 1, 0): 0, (0, 0, 1, 1): -1} # Допустимые состояния классического NPC дают +Udc/2, нейтраль и −Udc/2 соответственно.
|
||||
|
||||
def __init__(self, samplerate, active_high=True, carrier_hz=0, smoothing_ms=0): # Настройки времени и полярности совпадают с двухуровневым режимом.
|
||||
super().__init__(samplerate, active_high, carrier_hz, smoothing_ms) # Общий расчёт частоты поля и фильтра применяется к среднему знаковому уровню NPC.
|
||||
self.integral = self.known = 0 # Интеграл уровня выражен в отсчётах; known хранит длительность достоверных состояний.
|
||||
self.last_valid = self.pulse_sign = None # Пропуск dead-time не должен ошибочно создавать новый импульс той же полярности.
|
||||
|
||||
def state(self, gates): # Преобразование использует все четыре затвора, а не только два внешних ключа.
|
||||
return self.STATES.get(tuple(int(bool(pin) == self.active_high) for pin in gates)) # Неизвестное состояние, включая все выключенные ключи, возвращает None вместо выдуманного нуля напряжения.
|
||||
|
||||
def _accumulate(self, sample): # Между цифровыми событиями уровень стойки считается постоянным только при допустимой комбинации затворов.
|
||||
if self.level is not None: # Во время dead-time напряжение зависит от тока и диодов и не определяется одними управляющими сигналами.
|
||||
self.integral += (sample - self.last) * self.level # Знаковая площадь сохраняет отличие положительных, нулевых и отрицательных участков.
|
||||
self.known += sample - self.last # В среднем используются лишь участки, однозначно восстановленные по затворам.
|
||||
self.last = sample # Новый интервал начинается после полностью учтённой части времени.
|
||||
|
||||
def _npc_emit(self, start, end): # Среднее по допустимым состояниям является оценкой огибающей, а не измерением напряжения в dead-time.
|
||||
coverage = self.known / (end - start) # Доля известного времени позволяет отделить короткие коммутационные паузы от повреждённой записи.
|
||||
if coverage < 0.95: # При более чем 5% неизвестного времени в периоде оценка считается недостаточно определённой и пропускается.
|
||||
self.previous = self.field_previous = self.field_crossing = self.field_pending = self.field_carrier = None # Разрыв не должен соединяться графиком или измерением поля с предыдущим достоверным участком.
|
||||
self.field_armed = False # Новое поле должно заново подтвердить отрицательную полуволну.
|
||||
return [] # Неизвестный интервал не превращается в фиктивный уровень нуля.
|
||||
high = (self.integral / self.known + 1) * (end - start) / 2 # Общий _emit ожидает эквивалентную активную длительность; преобразование сохраняет средний уровень −1…+1.
|
||||
event = self._emit(start, end, high) # Общая обработка формирует середину периода, частоты и фильтрованное значение.
|
||||
event['valid_fraction'] = coverage # Точность определения уровня видна в числовой аннотации и экспорте.
|
||||
return [event] # Формат списка совместим с обновлением двухуровневого канала.
|
||||
|
||||
def update(self, sample, gates): # Одно событие получает атомарный снимок четырёх цифровых входов.
|
||||
level = self.state(gates) # Только три штатные комбинации имеют определённый нормированный уровень стойки.
|
||||
if self.last is None: # Начальный уровень не является обнаруженным фронтом несущей.
|
||||
self.last, self.level, self.last_valid = sample, level, level # Состояние начинается с текущего снимка без выдуманного предыдущего импульса.
|
||||
if self.window is not None: # В режиме заданной несущей интегрирование начинается с области декодирования.
|
||||
self.origin = self.start = sample # Границы окон совпадают с прежним контрактом PwmChannel.
|
||||
return [] # Начальная точка ещё не содержит полного периода.
|
||||
if sample < self.last: # Равные номера допустимы для одновременных фронтов разных каналов.
|
||||
raise ValueError('Samples must be monotonic.') # Отрицательные интервалы нельзя интегрировать или выдавать в DSView.
|
||||
entry = level in (-1, 1) and level != self.last_valid # Начало ненулевого импульса распознаётся с учётом последнего достоверного состояния до dead-time.
|
||||
events = [] # Результаты содержат лишь полные периоды или окна.
|
||||
if self.window is not None: # Фиксированные окна сохраняют 0/100% и работают при отсутствии переключений.
|
||||
boundary = self.next_boundary() # Граница рассчитывается от начала, поэтому дробный период не накапливает ошибку округления.
|
||||
while boundary <= sample: # Одно цифровое событие может завершить несколько окон постоянного уровня.
|
||||
self._accumulate(boundary) # Учёт предыдущего состояния заканчивается точно на границе окна.
|
||||
events.extend(self._npc_emit(self.start, boundary)) # Неизвестные участки проверяются до формирования результата.
|
||||
self.start = boundary # Следующее окно начинается без перекрытия с предыдущим.
|
||||
self.integral = self.known = 0 # Площадь и известное время относятся только к новому окну.
|
||||
self.window_index += 1 # Номер следующей границы задаёт её положение в общей шкале отсчётов.
|
||||
boundary = self.next_boundary() # Повторное вычисление сохраняет точность дробной частоты.
|
||||
self._accumulate(sample) # Оставшаяся часть предыдущего уровня учитывается до изменения затворов.
|
||||
if entry: # Частота несущей измеряется по началам последовательных ненулевых импульсов одинакового знака.
|
||||
if self.pulse_sign == level: # Переход положительной полуволны в отрицательную не является коротким периодом несущей.
|
||||
self._carrier_edge(sample) # Измеренный период не подменяется частотой заданного окна.
|
||||
if self.window is None and self.start is not None: # Автоматический режим завершает период на одноимённом фронте следующего импульса.
|
||||
events.extend(self._npc_emit(self.start, sample)) # Огибающая получается из интеграла всех трёх уровней за полный период.
|
||||
else: # Первое появление другой полярности заново синхронизирует несущую этой полуволны.
|
||||
self.carrier_start, self.carrier_period = sample, None # Интервал через смену знака не принимается за измеренную частоту.
|
||||
self.pulse_sign = level # Следующий ненулевой импульс сравнивается с текущей полярностью.
|
||||
if self.window is None: # В автоматическом режиме начало и интеграл обновляются только на опорных фронтах.
|
||||
self.start = sample # Начальный неполный фрагмент полуволны исключается.
|
||||
self.integral = self.known = 0 # Новый период не наследует площадь предыдущего.
|
||||
self.level = level # Новое состояние действует только после текущего цифрового события.
|
||||
if level is not None: # Неизвестная коммутационная пауза не стирает информацию о последнем допустимом уровне.
|
||||
self.last_valid = level # Это исключает ложные импульсы при кратком выключении и возврате к прежней комбинации.
|
||||
return events # Адаптер получает тот же поток результатов, что для обычного ШИМ.
|
||||
|
||||
|
||||
class PhaseAnalysis: # Согласование времён A/B/C отделено от конкретного числа затворов в фазе.
|
||||
def __init__(self, samplerate): # Частота нужна для контроля устаревших оценок фазового периода.
|
||||
self.rate = samplerate # Все временные координаты остаются в отсчётах захвата.
|
||||
self.points, self.fields = {}, {} # Для интерполяции хранятся две последние точки каждой огибающей и последний полный период поля.
|
||||
self.last_vector = None # Одинаковый общий момент времени не должен выдавать повторный угол.
|
||||
|
||||
def add(self, channel, event): # Каждый восстановленный период обновляет только свою фазу.
|
||||
points = self.points.setdefault(channel, []) # Отсутствующая фаза не получает нулевой или выдуманный сигнал.
|
||||
if event.get('previous', True) is None: # Ядро явно разрывает огибающую после недостаточно определённого окна NPC.
|
||||
points.clear() # Интерполяция не должна соединять новую точку с данными до неизвестного участка.
|
||||
self.fields.pop(channel, None) # Межфазные углы также должны заново дождаться полного достоверного периода.
|
||||
points.append((event['midpoint'], event['value'])) # Угол вычисляется по восстановленным средним уровням, а не по мгновенным коммутациям.
|
||||
del points[:-2] # Ограниченная история сохраняет постоянный расход памяти на длинных записях.
|
||||
if event['field'] is not None: # Межфазные углы требуют наблюдавшихся полных электрических периодов.
|
||||
field = event['field'] # Результат содержит последнее восходящее пересечение и частоту данной фазы.
|
||||
self.fields[channel] = (field['end'], self.rate / field['frequency']) # Период хранится в отсчётах для вычисления сдвига в градусах.
|
||||
if any(len(self.points.get(ch, [])) < 2 for ch in range(3)): # Пространственный вектор нельзя восстановить из неподключённых или ещё не заполненных A/B/C.
|
||||
return None # Начальный участок записи не получает ложного θ.
|
||||
sample = min(self.points[ch][-1][0] for ch in range(3)) # Общая точка не выходит за наблюдавшееся время ни одной фазы.
|
||||
if sample <= max(self.points[ch][0][0] for ch in range(3)) or self.last_vector is not None and sample <= self.last_vector: # Требуется общий интервал и новое время, без экстраполяции остановившейся фазы.
|
||||
return None # Несогласованные точки не смешиваются в один вектор.
|
||||
values = [] # Три значения будут относиться к одному и тому же моменту времени.
|
||||
for ch in range(3): # Порядок A/B/C определяет положительное направление вращения.
|
||||
left, right = self.points[ch] # Две соседние точки ограничивают достоверный интервал данной фазы.
|
||||
values.append(left[1] + (right[1] - left[1]) * (sample - left[0]) / (right[0] - left[0])) # Линейная интерполяция устраняет сдвиг, вызванный разными моментами фронтов PWM A/B/C.
|
||||
alpha = (2 * values[0] - values[1] - values[2]) / 3 # Полное преобразование Кларка удаляет синфазную составляющую, включая добавку SVPWM.
|
||||
beta = (values[1] - values[2]) / math.sqrt(3) # Ортогональная ось соответствует последовательности A, B, C.
|
||||
self.last_vector = sample # Данный момент времени считается обработанным даже при почти нулевом векторе.
|
||||
if math.hypot(alpha, beta) < 0.02: # Ниже 2% нормированного уровня направление вектора не считается определённым.
|
||||
return dict(sample=sample, theta=None) # Разрыв явно запрещает соединять графиком углы через нулевую амплитуду.
|
||||
return dict(sample=sample, theta=math.degrees(math.atan2(beta, alpha)) % 360) # θ выражен в электрических градусах 0…360 относительно положительной оси A.
|
||||
|
||||
def differences(self, sample): # Положительный результат A→B означает запаздывание фазы B относительно A.
|
||||
angles = {} # Отсутствующая пара не получает выдуманный сдвиг 120 градусов.
|
||||
for first, second in ((0, 1), (1, 2), (2, 0)): # Измеряются все три запрошенные пары независимо.
|
||||
if first not in self.fields or second not in self.fields: # Каждой фазе пары нужен хотя бы один полный измеренный период.
|
||||
continue # Две подключённые фазы всё ещё могут дать одну доступную пару.
|
||||
a, b = self.fields[first], self.fields[second] # Последние одноимённые пересечения задают фазовые часы двух огибающих.
|
||||
if any(sample < item[0] or sample - item[0] > 2 * item[1] for item in (a, b)): # После двух периодов без нового измерения прежний сдвиг становится устаревшим.
|
||||
continue # Остановившийся или пропавший вход не сохраняет старое числовое значение.
|
||||
angle = 360 * ((sample - a[0]) / a[1] - (sample - b[0]) / b[1]) # Разность фазовых часов выражает запаздывание второй огибающей в градусах.
|
||||
angles['ABC'[first] + '→' + 'ABC'[second]] = (angle + 180) % 360 - 180 # Знаковый диапазон −180…180 различает запаздывание и опережение, включая обратную последовательность фаз.
|
||||
return angles # Результаты рассчитываются из записи и не предполагают заранее правильную трёхфазную систему.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Потоковый CIC-декодер однобитного сигма-дельта модулятора без зависимостей от GUI.""" # Ядро одинаково работает в DSView и автономных анализаторах.
|
||||
|
||||
|
||||
class SigmaDeltaFilter: # Каскад интеграторов и гребенчатых звеньев реализует фильтры sinc первого, второго и третьего порядка.
|
||||
def __init__(self, decimation=256, order=3, invert=False): # Децимация задаётся числом тактов на результат; 256 и sinc3 являются начальными настройками анализатора.
|
||||
if isinstance(decimation, bool) or not isinstance(decimation, int) or not 1 <= decimation <= 65536: # Ограничение 65536 тактов удерживает максимальное усиление sinc3 в пределах 49 бит.
|
||||
raise ValueError('Decimation must be an integer from 1 to 65536.') # Дробные и неположительные окна не определяют допустимую децимацию.
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order not in (1, 2, 3): # Число интеграторов должно совпадать с числом гребенчатых звеньев.
|
||||
raise ValueError('Filter order must be 1, 2 or 3.') # Неизвестный порядок нельзя незаметно заменять sinc3.
|
||||
self.decimation = decimation # Каждый результат появляется после этого числа принятых битов.
|
||||
self.order = order # Порядок также определяет длительность начального установления фильтра.
|
||||
self.invert = bool(invert) # Инверсия применяется к битам до фильтрации и меняет знак восстановленного сигнала.
|
||||
self.gain = decimation ** order # Усиление CIC при постоянном входе равно R в степени порядка при дифференциальной задержке один.
|
||||
self.mask = (1 << self.gain.bit_length()) - 1 # Модуль степени двойки строго больше усиления сохраняет точный результат при переполнениях интеграторов.
|
||||
self.integrators = [0] * order # Нулевое начальное состояние не предполагает неизвестную предысторию захвата.
|
||||
self.delays = [0] * order # Каждый гребенчатый каскад хранит свой предыдущий вход на пониженной частоте.
|
||||
self.phase = 0 # Счётчик внутри окна исключает накопление дробной ошибки децимации.
|
||||
self.blocks = 0 # Первые order минус один результатов относятся к установлению и не выдаются пользователю.
|
||||
|
||||
def feed(self, bit): # Принимается ровно один уровень DATA на выбранном фронте CLK.
|
||||
if bit not in (0, 1): # Служебные значения неподключённых каналов sigrok не являются цифровыми уровнями.
|
||||
raise ValueError('Sigma-delta data must be a logic level 0 or 1.') # Ошибка назначения входов не должна превращаться в правдоподобную амплитуду.
|
||||
value = int(bit) ^ self.invert # Униполярное представление позволяет нормировать результат без знакового переполнения.
|
||||
for stage in range(self.order): # Последовательно обновляем интеграторы на полной частоте модулятора.
|
||||
self.integrators[stage] = (self.integrators[stage] + value) & self.mask # Модульная арифметика ограничивает память независимо от длительности записи.
|
||||
value = self.integrators[stage] # Следующий интегратор получает уже обновлённый выход предыдущего.
|
||||
self.phase += 1 # Считаем только фактически захваченные тактовые фронты.
|
||||
if self.phase < self.decimation: # Между границами децимации выходной отсчёт ещё не готов.
|
||||
return None # Неполные окна не дополняются вымышленными битами.
|
||||
self.phase = 0 # Новый блок начинается со следующего реального бита.
|
||||
for stage in range(self.order): # Гребенчатые звенья выполняются только на выходной частоте.
|
||||
previous = value # Задержка сохраняет вход текущего звена, а не его разность.
|
||||
value = (value - self.delays[stage]) & self.mask # Разности устраняют накопление интеграторов, включая их модульные переполнения.
|
||||
self.delays[stage] = previous # Это состояние используется через R входных тактов.
|
||||
self.blocks = min(self.order, self.blocks + 1) # Счётчик установления насыщается и не растёт вместе с записью.
|
||||
if self.blocks < self.order: # Для sincN пропускаем первые N минус один выходных блоков с неизвестной предысторией.
|
||||
return None # Частично установившийся фильтр не выдаёт ложное смещение постоянного сигнала.
|
||||
code = 2 * value - self.gain # Биполярный код принимает диапазон от минус R**N до плюс R**N без привязки к конкретному АЦП.
|
||||
return {'code': code, 'gain': self.gain, 'value': code / self.gain, 'density': value / self.gain} # Нормированная амплитуда и фильтрованная плотность единиц сохраняют точный масштаб CIC.
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Тонкий Python-порт общей CAN-карты UKSVEP на STM32F103RC.""" # Форматы, GPIO и расчёт неисправностей принадлежат C99 templates.
|
||||
from __future__ import annotations # Аннотации остаются совместимыми с исходниками Windows 7.
|
||||
import ctypes as ct # Передаёт скаляры и буферы единственной нативной реализации.
|
||||
from dataclasses import dataclass # Представляет готовые результаты C без протокольных вычислений.
|
||||
from .native import get_native_protocol, NativeProtocolUnavailable # Использует штатный поиск и жизненный цикл общей DLL.
|
||||
|
||||
@dataclass(frozen=True) # Метаданные одинаковы для всех представлений GUI.
|
||||
class Field: # Значения читаются из package.h-совместимой таблицы C.
|
||||
address: int # Нулевой индекс регистра modbus[128].
|
||||
maximum: int # Верхняя граница ввода в исходных единицах.
|
||||
initial: int # Значение предпросмотра, которое ещё не принято от платы.
|
||||
flags: int # 1 — параметр, 2 — команды, 4 — эмулируемое состояние.
|
||||
name: str # Общая подпись UTF-8 из C-профиля.
|
||||
|
||||
@dataclass(frozen=True) # Пакет отделён от физического CAN-драйвера.
|
||||
class Frame: # Готовые байты нельзя изменять через поля виджета после отправки.
|
||||
can_id: int # Полный 29-битный ID без аппаратного бита IDE.
|
||||
data: bytes # Восемь байтов payload, построенных C.
|
||||
|
||||
@dataclass(frozen=True) # Снимок содержит только реально присутствующие слова.
|
||||
class Packet: # Источник пакета отделяет ответ ВЭП от команды управляющего узла.
|
||||
from_device: bool # Истина означает ID данных ВЭП, а не собственное эхо команд.
|
||||
values: tuple[tuple[int, int], ...] # Пары адрес/значение, разрешённые маской присутствия C.
|
||||
|
||||
class UksvepCan: # Каждая панель использует общее нативное ядро без собственного codec.
|
||||
def __init__(self): # Привязывает только дополнительные экспорты профиля UKSVEP.
|
||||
self.lib = get_native_protocol().lib # Загрузка DLL и общий ABI уже проверены штатным портом.
|
||||
signatures = { # Типы описывают ABI, а не байтовый порядок wire-протокола.
|
||||
"uksvep_can_id": ([ct.c_uint, ct.c_uint], ct.c_uint32), # Возвращает маршрут для физического адреса и роли.
|
||||
"uksvep_can_encode": ([ct.c_uint, ct.c_uint, ct.c_uint, ct.POINTER(ct.c_uint16), ct.c_size_t, ct.POINTER(ct.c_uint8), ct.c_size_t], ct.c_size_t), # Кодирует до трёх слов в буфер caller.
|
||||
"uksvep_can_decode": ([ct.c_uint, ct.c_uint32, ct.POINTER(ct.c_uint8), ct.c_size_t, ct.POINTER(ct.c_uint16), ct.c_size_t], ct.c_int), # Возвращает плоский массив пар из C.
|
||||
"uksvep_field_count": ([], ct.c_uint), # Позволяет получить все поля без таблицы дублей Python.
|
||||
"uksvep_field_info": ([ct.c_uint, ct.c_uint, ct.POINTER(ct.c_uint16), ct.c_size_t], ct.c_int), # Возвращает четыре скаляра метаданных.
|
||||
"uksvep_field_name": ([ct.c_uint], ct.c_char_p), # C владеет постоянной строкой UTF-8.
|
||||
"uksvep_input_name": ([ct.c_uint], ct.c_char_p), # Назначения GPIO остаются общими с профилем.
|
||||
"uksvep_command_name": ([ct.c_uint], ct.c_char_p), # Пустое имя обозначает резервный бит R127.
|
||||
"uksvep_evaluate_inputs": ([ct.c_uint16, ct.c_uint16, ct.c_uint16, ct.POINTER(ct.c_uint16), ct.c_size_t], None), # Рассчитывает R1/R2 для эмуляции по алгоритму C.
|
||||
} # Символы добавлены без изменения существующего ABI версии 1.
|
||||
try: # Старая DLL должна давать понятную ошибку вместо тихого Python fallback.
|
||||
for name, (arguments, result) in signatures.items(): # Каждый экспорт получает точную сигнатуру до первого вызова.
|
||||
function = getattr(self.lib, name) # Отсутствие профиля в старой DLL обнаруживается здесь.
|
||||
function.argtypes = arguments # ctypes не угадывает размер указателей и целых.
|
||||
function.restype = result # Сохраняет 32-битные ID и указатели строк на 64-битном хосте.
|
||||
except AttributeError as error: # Формат wire не реализуется заново в аварийной ветви.
|
||||
raise NativeProtocolUnavailable("В общей DLL нет профиля UKSVEP. Пересоберите setprotocol.dll из templates.") from error # Поясняет требуемое действие пользователю.
|
||||
|
||||
@staticmethod # Валидация Python-аргумента предотвращает обрезание ctypes до C-типа.
|
||||
def _device(value): # Адрес хранится целым числом из четырёх перемычек.
|
||||
if not isinstance(value, int) or not 0 <= value <= 15: # Нельзя молча адресовать соседний узел по младшим битам.
|
||||
raise ValueError("DeviceID ВЭП должен быть 0…15") # Ошибка возникает до передачи CAN.
|
||||
return value # Валидный скаляр далее проверяется также C-профилем.
|
||||
|
||||
def can_id(self, device, from_device=False): # Доверяет вычисление маршрута общему C-профилю.
|
||||
return int(self.lib.uksvep_can_id(self._device(device), bool(from_device))) # Python не повторяет базу и смещение идентификатора.
|
||||
|
||||
def fields(self, device=0): # Получает подтверждённые поля выбранного ВЭП.
|
||||
device = self._device(device) # Проверяет адрес до создания начальных значений Mode.
|
||||
result = [] # Список содержит только готовые метаданные ABI.
|
||||
for index in range(self.lib.uksvep_field_count()): # Размер карты также принадлежит C.
|
||||
raw = (ct.c_uint16 * 4)() # Caller владеет явно ограниченным выходным буфером.
|
||||
if not self.lib.uksvep_field_info(index, device, raw, len(raw)): # Проверяет результат вызова вместо чтения частично готового массива.
|
||||
raise ValueError("Не удалось получить карту UKSVEP") # Ошибка не заменяется выдуманными defaults.
|
||||
name = self.lib.uksvep_field_name(index).decode("utf-8") # Декодирует только строковое представление общей подписи.
|
||||
result.append(Field(*map(int, raw), name)) # Не меняет единицы и адреса, возвращённые C.
|
||||
return tuple(result) # Карта не изменяется виджетами во время работы.
|
||||
|
||||
def names(self, kind): # Читает общие назначения входов либо команд.
|
||||
function = self.lib.uksvep_input_name if kind == "inputs" else self.lib.uksvep_command_name # Выбор относится к категории отображения, а не к codec.
|
||||
return tuple(function(bit).decode("utf-8") for bit in range(16)) # Резервные позиции остаются пустыми строками.
|
||||
|
||||
def frames(self, device, address, values, from_device=False): # Делит длинный снимок на штатные вызовы трёхсловного C-кодировщика.
|
||||
device = self._device(device) # Неверный адрес не может быть обрезан ctypes.
|
||||
values = tuple(values) # Фиксирует входной снимок до первой физической записи.
|
||||
if not values or not isinstance(address, int) or not 0 <= address < 128 or len(values) > 128 - address: # Проверяет Python-диапазон до преобразования unsigned.
|
||||
raise ValueError("Выберите непустой диапазон R0…R127") # Ошибка обнаруживается до формирования первого пакета.
|
||||
if any(not isinstance(value, int) or not 0 <= value <= 65535 for value in values): # Не обрезает отрицательные или переполненные слова.
|
||||
raise ValueError("Значения ВЭП должны быть uint16: 0…65535") # Поле пользователя не должно превращаться в другое число на проводе.
|
||||
result = [] # Все байты создаются до передачи, чтобы не отправить половину неверного снимка.
|
||||
for offset in range(0, len(values), 3): # Ограничение трёх слов задано существующим историческим форматом.
|
||||
part = values[offset:offset + 3] # UI-порт только разбивает контейнер, не строит маску/endian.
|
||||
source = (ct.c_uint16 * len(part))(*part) # Преобразует значения в явно типизированный вход C.
|
||||
target = (ct.c_uint8 * 8)() # Payload classic CAN ограничен восемью байтами.
|
||||
size = self.lib.uksvep_can_encode(device, bool(from_device), address + offset, source, len(part), target, len(target)) # Все поля wire заполняет C99.
|
||||
if size != 8: # Валидный UKSVEP кадр обязан иметь полный DLC.
|
||||
raise ValueError("C-профиль отклонил регистры UKSVEP") # Ошибка не скрывается частичной передачей.
|
||||
result.append(Frame(self.can_id(device, from_device), bytes(target))) # Возвращает готовый транспортно-независимый кадр.
|
||||
return tuple(result) # Физический writer принадлежит существующему BusTab.
|
||||
|
||||
def decode(self, device, can_id, data): # Вызывает общий decoder с ограниченными буферами.
|
||||
device = self._device(device) # Проверяет адрес до входа в ctypes.
|
||||
if not isinstance(can_id, int) or not 0 <= can_id <= 0x1FFFFFFF: # Исключает знаковое переполнение и биты вне extended ID.
|
||||
return None # Такое событие не принадлежит допустимому CAN-профилю.
|
||||
data = bytes(data) # Фиксирует входной payload на время вызова C.
|
||||
source = (ct.c_uint8 * len(data)).from_buffer_copy(data) # Caller владеет памятью до завершения синхронного разбора.
|
||||
target = (ct.c_uint16 * 8)() # Восемь скаляров вмещают направление и три адресные пары.
|
||||
count = self.lib.uksvep_can_decode(device, can_id, source, len(data), target, len(target)) # C выбирает ID, DLC, маску и границы карты.
|
||||
if count < 0: # Повреждённый пакет выбранного ВЭП не считается чужим.
|
||||
raise ValueError("Повреждённый CAN-кадр UKSVEP: DLC, маска или адрес") # UI может увеличить счётчик отказов RX.
|
||||
if count == 0: # Пакет другого адреса не меняет снимок.
|
||||
return None # Общая шина продолжает обрабатывать его независимо.
|
||||
pairs = tuple((int(target[2 + index * 2]), int(target[3 + index * 2])) for index in range(count)) # Переводит плоский ABI в готовые пары без повторного endian-разбора.
|
||||
return Packet(bool(target[0]), pairs) # Направление не позволяет выдать эхо TX за ответ платы.
|
||||
|
||||
def evaluate(self, inputs, alarm_mask, error_mask): # Предоставляет явный расчёт состояния эмулируемого ВЭП.
|
||||
values = (inputs, alarm_mask, error_mask) # Передаёт только исходные слова C-алгоритму ReadEnteres.
|
||||
if any(not isinstance(value, int) or not 0 <= value <= 65535 for value in values): # Защищает параметры от неявного uint16 обрезания.
|
||||
raise ValueError("Входы и маски должны быть uint16") # Неверный ввод не превращается в правдоподобную аварию.
|
||||
result = (ct.c_uint16 * 2)() # Буфер содержит R1 и R2, вычисленные C.
|
||||
self.lib.uksvep_evaluate_inputs(*values, result, len(result)) # Python не повторяет особые случаи заряд/разряд/24 В.
|
||||
return tuple(map(int, result)) # Результат используется только в исходящем эмулируемом снимке.
|
||||
@@ -468,6 +468,11 @@ class Rs485PacketParser:
|
||||
# Команды прошивки
|
||||
# ==========================================================================
|
||||
|
||||
def command_can_interface(name: str) -> str: # Расширение общего контракта выбирает PS CAN Linux, сохраняя protocol wire в templates.
|
||||
if name not in ('can0', 'can1'): raise ValueError('CAN-интерфейс должен быть can0 или can1') # Закрытый набор не допускает произвольных имён и внедрения дополнительных команд.
|
||||
return 'N' + name # Выбор интерфейса не включает драйвер и не передаёт кадры автоматически.
|
||||
|
||||
|
||||
def command_bitrate(kbps: str | int) -> str:
|
||||
"""Скорость шины CAN, кбит/с."""
|
||||
return "S%s" % kbps
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,3 +44,10 @@ CANDLE_G474_BOOT = { # Профиль воспроизводит CAN_BOOTLOADER
|
||||
"device_type": 7, "device": 15, "product": 0, "hardware": 0, # Идентичность ProtoCAN BOOT: Type 7, Device 15, Product 0 и аппаратная ревизия 0.
|
||||
"can_bitrate": 500_000, "uart_baud": 512_000, # Classic CAN работает на 500 кбит/с, USART1 PA9/PA10 — на 512000 бод, 8N1.
|
||||
} # Адреса и скорости относятся только к CANdle G474VET6 и не подменяют профиль USB BOOT G474RB.
|
||||
|
||||
UKSVEP_F103RC_BOOT = { # Профиль отдельного boot-проекта UKSVEP_ICE_22220.6 согласован с ports/stm32f103rc-uksvep/boot_profile.h.
|
||||
"app_base": 0x08008000, "app_size": 94 * 1024, # Начальные 32 КиБ заняты BOOT; приложение заканчивается перед существующей EEPROM-страницей 0x0801F800.
|
||||
"stack_base": 0x20000000, "stack_end": 0x2000C000, # DS5792 определяет для STM32F103RC 48 КиБ SRAM и восьмибайтовое выравнивание начального MSP.
|
||||
"device_type": 5, "product": 0x5606, "hardware": 1, # DeviceID задают активные нулём перемычки PA6/PA7/PC4/PC5; Product отличает ВЭП от других STM32.
|
||||
"can_bitrate": 250_000, "uart_baud": 115_200, # Загрузчик UKSVEP использует CAN PA11/PA12 и USART1 PA9 TX/PA10 RX на 115200 бод.
|
||||
} # Single-slot обновление сохраняет параметры; для запуска требуется постоянный manifest и CRC образа.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
|
||||
"""Единый неблокирующий порт CAN485 для USB CDC/UART и Ethernet TCP.""" # Протокол кадров остаётся в can485_board и переносимом C-ядре templates.
|
||||
from .qt_compat import QObject, Signal, QTimer, QSerialPort, QElapsedTimer # Qt elapsed timer ограничивает также короткое подтверждение восстановления UART-консоли.
|
||||
try: # Выбирает тот же Qt binding, который использует остальное приложение.
|
||||
from PySide6.QtNetwork import QTcpSocket, QAbstractSocket # TCP передаёт тот же поток строк, что физический USB-порт.
|
||||
except ImportError: # Старые системы используют доступный PySide2.
|
||||
from PySide2.QtNetwork import QTcpSocket, QAbstractSocket # Публичный контракт адаптера не зависит от версии Qt.
|
||||
|
||||
|
||||
class Can485Port(QObject): # Совместимая с BoardTab оболочка исключает копирование протокольных и экранных обработчиков.
|
||||
readyRead = Signal() # Уведомляет монитор о произвольном фрагменте, включая неполную строку.
|
||||
errorOccurred = Signal(object) # Использует существующий обработчик ошибок последовательного порта панели.
|
||||
connected = Signal() # TCP-подключение подтверждается асинхронно после фактического установления соединения.
|
||||
|
||||
def __init__(self, parent=None): # Создаёт закрытые endpoints без аппаратного ввода-вывода при импорте или открытии вкладки.
|
||||
super().__init__(parent) # QObject связывает жизненный цикл сокетов с владельцем панели.
|
||||
self.serial = QSerialPort(self); self.tcp = QTcpSocket(self); self.kind = 'USB'; self._closing = False; self._error = ''; self._endpoint = ''; self._console = False; self._console_ready = False; self._console_rx = bytearray(); self.console_restored=True # Состояние восстановления консоли проверяется аппаратным USB-тестом после закрытия.
|
||||
self.serial.readyRead.connect(self._serial_ready); self.tcp.readyRead.connect(self.readyRead) # USB-консоль ожидает подтверждение службы, затем передаёт bytes штатному parser строк.
|
||||
self.serial.errorOccurred.connect(self._serial_error); self.tcp.errorOccurred.connect(self._tcp_error); self.tcp.disconnected.connect(self._disconnected) # Потеря соединения останавливает циклическую отправку через обработчик панели.
|
||||
self.tcp.connected.connect(self._connected) # Начальная настройка выполняется только после подтверждённого TCP handshake.
|
||||
self.timeout = QTimer(self); self.timeout.setSingleShot(True); self.timeout.timeout.connect(self._timed_out) # Конечный срок TCP не блокирует GUI даже при отключённой плате.
|
||||
self._shared=None # Единая сборка позволяет CAN и Modbus одновременно использовать один физический FTDI.
|
||||
|
||||
def _connected(self): # Завершает фазу ожидания и разрешает запрос версии.
|
||||
self.timeout.stop(); self.connected.emit() # Запоздалый timeout больше не может закрыть действующую сессию.
|
||||
|
||||
def _tcp_error(self, error): # Преобразует ошибку TCP в общий контракт порта.
|
||||
if not self._closing: self._error = self.tcp.errorString(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # GUI получает исходную диагностическую строку сокета.
|
||||
|
||||
def _serial_error(self, error): # Сохраняет штатные коды QSerialPort, включая отсутствие ошибки.
|
||||
if not self._closing: self._error = self.serial.errorString(); self.errorOccurred.emit(error) # USB-обрыв проходит тот же cleanup, что сетевой.
|
||||
|
||||
def _disconnected(self): # FIN от платы завершает сессию даже без отдельного socket error.
|
||||
if not self._closing: self._error = 'Ethernet: соединение закрыто платой'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Панель прекращает периодическую передачу и очищает подтверждённые настройки.
|
||||
|
||||
def _timed_out(self): # Срабатывает только для незавершённого TCP-подключения.
|
||||
if not self.isOpen(): self._error = 'Нет ответа CAN-службы Zynq за 5 секунд. Для USB-консоли нужны root shell и установленная zynq-can485d.'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Ожидание отсутствующего сервиса либо login prompt не блокирует GUI.
|
||||
|
||||
def _serial_ready(self): # В режиме консоли отделяет готовность туннеля от простого открытия COM-порта.
|
||||
if self._closing: return # Во время короткого ожидания shell prompt данные читает cleanup, не вызывая повторные GUI callbacks.
|
||||
if not self._console: self.readyRead.emit(); return # Прямой USB CDC/UART сохраняет исходные bytes без буферной копии.
|
||||
self._console_rx.extend(bytes(self.serial.readAll())) # Shell и nc могут выдавать произвольные фрагменты строк.
|
||||
if not self._console_ready and b'#INFO Zynq CAN485 / templates' in self._console_rx: self._console_ready=True; self.timeout.stop(); self.connected.emit() # Команды CAN разрешаются только после реального ответа общего daemon.
|
||||
if self._console_ready: self.readyRead.emit() # После подтверждения весь поток, включая сохранённый заголовок, поступает штатному монитору.
|
||||
elif len(self._console_rx)>8192: del self._console_rx[:-4096] # До handshake хранит ограниченный хвост консольного вывода.
|
||||
|
||||
def start_console_tunnel(self, port=37579): # Использует уже открытую root UART-консоль как USB-канал к тому же TCP-порту службы.
|
||||
port=int(port) # Числовой аргумент проверяется до формирования команды shell.
|
||||
if not 1<=port<=65535: raise ValueError('TCP-порт CAN должен быть 1..65535') # Исключает неверный endpoint и внедрение shell-команд из параметра.
|
||||
self._console=True; self._console_ready=False; self._console_rx.clear(); self.console_restored=False; self.timeout.start(5000) # Открытый tty не выдаётся за подтверждённый CAN-сеанс.
|
||||
self.serial.write(f'\rstty -echo -icanon min 1 time 0; nc 127.0.0.1 {port}; stty sane\r'.encode('ascii')) # Штатный nc передаёт общий протокол без копии daemon и без перенастройки USB/Device Tree.
|
||||
|
||||
def connect_tcp(self, host, port): # Endpoint уже проверен UI, DNS и синхронное ожидание не требуются.
|
||||
self.close(); self.kind = 'Ethernet'; self._endpoint = f'{host}:{port}'; self._error = ''; self.timeout.start(5000); self.tcp.connectToHost(host, port) # Пять секунд ограничивают ожидание недоступного TCP-порта 37579.
|
||||
|
||||
def connect_console(self,name,port=37579): # Общий FTDI канал открывает отдельную CAN TCP сессию без захвата COM второй вкладкой.
|
||||
from .console_link_port import ConsoleLink # Общий менеджер делится с Modbus и сохраняет действующий USB host HDMI.
|
||||
self.close(); self.kind='USB'; self._endpoint=name; self._error=''; self.console_restored=False; self._shared=ConsoleLink.acquire(name,port,self); channel=self._shared; self.timeout.start(5000) # Начальное открытие остаётся отменяемым до подтверждения backend.
|
||||
channel.readyRead.connect(self.readyRead); channel.opened.connect(self._connected); channel.errorOccurred.connect(lambda error:self._shared_error(channel,error)) # Прикладные CAN строки и ошибки принадлежат только своему логическому каналу.
|
||||
|
||||
def _shared_error(self,channel,error): # Ошибка локального CAN backend не должна выдавать сообщение физического QtSerialPort другого протокола.
|
||||
if not self._closing: self._error=channel.errorString(); self.errorOccurred.emit(error) # Стандартный BoardTab cleanup завершает только CAN сессию.
|
||||
|
||||
def isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала.
|
||||
if self._shared is not None: return self._shared.active and self._shared.isOpen() # Общий COM не считается готовым CAN до OPEN конкретного loopback backend.
|
||||
return self.tcp.state() == QAbstractSocket.SocketState.ConnectedState if self.kind == 'Ethernet' else self.serial.isOpen() and (not self._console or self._console_ready) # Открытый USB tty с незавершённым nc handshake тоже не считается CAN-подключением.
|
||||
|
||||
def isConnecting(self): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки.
|
||||
return self.timeout.isActive() # Таймер активен только в фазе TCP handshake.
|
||||
|
||||
def close(self): # Закрытие идемпотентно и не порождает рекурсивную ошибку панели.
|
||||
self._closing = True; self.timeout.stop(); self.tcp.abort() # Сначала отменяет deadline и сетевые сигналы старой сессии.
|
||||
if self._shared is not None: channel=self._shared; self._shared=None; channel.close(); self.console_restored=channel.link.console_restored; channel.deleteLater() # Освобождается логический CAN канал, тогда как Modbus продолжает использовать тот же FTDI.
|
||||
if self._console and self.serial.isOpen(): # Дожидается реального ответа UART, чтобы close не отбросил ещё не переданный Ctrl-C в драйвере Windows.
|
||||
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # Удаляет старый поток статуса и немедленно передаёт сигнал завершения собственного foreground nc.
|
||||
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Ограничивает ожидание освобождения локальной выходной очереди.
|
||||
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Подтверждение восстановления shell имеет конечный срок 400 мс.
|
||||
while elapsed.elapsed()<400 and not self.console_restored: # Нельзя закрыть FTDI раньше передачи управляющего байта и возврата root prompt.
|
||||
self.serial.waitForReadyRead(40); response.extend(bytes(self.serial.readAll())); self.console_restored=response.rstrip(b'\r\n').endswith((b'# ',b'$ ')) # Настоящий prompt подтверждает завершение nc и выполнение stty sane.
|
||||
if not self.console_restored: self._error='Закрыт USB, но возврат Linux-консоли за 400 мс не подтверждён' # Не выдаёт простое закрытие COM за подтверждённое восстановление терминала.
|
||||
if self.serial.isOpen(): self.serial.close() # Не вызывает NotOpenError при повторном cleanup уже закрытого USB-порта.
|
||||
self._console=False; self._console_ready=False; self._console_rx.clear(); self._closing = False # Не оставляет старые данные handshake для следующего COM-порта.
|
||||
|
||||
def readAll(self): # Возвращает сырой QByteArray для существующего LineAssembler templates.
|
||||
if self._shared is not None: return self._shared.readAll() # Общий физический поток уже разделён на независимые CAN/Modbus сессии.
|
||||
if self._console: data=bytes(self._console_rx); self._console_rx.clear(); return data # Консольные bytes прочитаны единожды при поиске подтверждённого заголовка.
|
||||
return self.tcp.readAll() if self.kind == 'Ethernet' else self.serial.readAll() # Сегментация TCP/USB не влияет на протокол.
|
||||
|
||||
def write(self, data): # Длина результата позволяет обнаружить непринятую целиком команду.
|
||||
if self._shared is not None: return self._shared.write(data) # Канал кодирует только транспортное обрамление, не меняя исходные CAN команды.
|
||||
return self.tcp.write(data) if self.kind == 'Ethernet' else self.serial.write(data) # Порт не добавляет заголовков либо переводов строк к готовой команде.
|
||||
|
||||
def errorString(self): # Единая диагностика сохраняется до следующего соединения.
|
||||
return self._error or (self.tcp.errorString() if self.kind == 'Ethernet' else self.serial.errorString()) # Сообщение Qt не подменяется общим «нет связи».
|
||||
|
||||
def portName(self): # Нижняя строка монитора показывает фактический канал.
|
||||
if self._shared is not None: return self._shared.portName() # Общий COM остаётся видимым пользователю при одновременной работе двух протоколов.
|
||||
return self._endpoint if self.kind == 'Ethernet' else self.serial.portName() # Сетевое соединение отличается от COM-порта платы.
|
||||
|
||||
def setPortName(self, name): # Штатный USB-путь BoardTab выбирает физический порт без изменения своего кода.
|
||||
self.kind = 'USB'; self.serial.setPortName(name) # Следующие настройки применяются только к закрытому USB-порту.
|
||||
|
||||
def setBaudRate(self, value): return self.serial.setBaudRate(value) # Физический USB-UART требует согласованной скорости, USB CDC принимает line coding.
|
||||
def setDataBits(self, value): return self.serial.setDataBits(value) # Использует 8 бит данных штатной панели.
|
||||
def setParity(self, value): return self.serial.setParity(value) # Сохраняет отсутствие parity в общем контракте 8N1.
|
||||
def setStopBits(self, value): return self.serial.setStopBits(value) # Сохраняет один стоповый бит.
|
||||
def setFlowControl(self, value): return self.serial.setFlowControl(value) # RTS/CTS не задействуются для управления платой.
|
||||
def open(self, mode): return self.serial.open(mode) # Открытие USB выполняется штатным QSerialPort с исходной диагностикой ошибок.
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Общий владелец FTDI и независимые логические TCP-каналы CAN/Modbus поверх Linux shell.""" # Прикладные кодеки не зависят от способа совместного использования COM.
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QByteArray, QCoreApplication, QElapsedTimer, QIODevice # Один Qt event loop обслуживает все логические каналы без фоновых гонок.
|
||||
from PySide6.QtSerialPort import QSerialPort # Физический COM открывается ровно одним экземпляром менеджера.
|
||||
import shlex # Путь установленного либо явно выбранного тестового helper передаётся shell одним безопасным аргументом.
|
||||
import re # Подтверждённый root prompt отделяет shell от login/getty и запроса пароля после холодной загрузки.
|
||||
class ConsoleChannel(QObject): # Контракт readAll/write/close позволяет подключить существующие thin-порты протоколов.
|
||||
readyRead=Signal(); opened=Signal(); errorOccurred=Signal(object) # OPEN подтверждается loopback службой отдельно от открытия FTDI.
|
||||
def __init__(self,link,number,port,parent): # Канал принадлежит конкретному потребителю, физическая сессия — приложению.
|
||||
super().__init__(parent); self.link=link; self.number=number; self.port=port; self.active=False; self.closed=False; self.incoming=bytearray(); self.error='' # Соседние протоколы имеют независимые буферы и состояния.
|
||||
def isOpen(self): return not self.closed # Ожидающую handshake сессию можно отменить той же кнопкой отключения.
|
||||
def portName(self): return self.link.name # Панель показывает реальный COM, общий для обеих служб платы.
|
||||
def errorString(self): return self.error # Ошибка сохраняется до уничтожения конкретной логической сессии.
|
||||
def readAll(self): data=QByteArray(bytes(self.incoming)); self.incoming.clear(); return data # Каждый прикладной байт читается только своим протоколом.
|
||||
def write(self,data): # Возвращает длину принятых в очередь данных без подтверждения внешней операции.
|
||||
raw=bytes(data) # QByteArray и bytes преобразуются без изменения бинарного содержимого.
|
||||
if self.closed or not self.active: return -1 # Команда не уходит в неподтверждённый или уже закрытый канал.
|
||||
for offset in range(0,len(raw),512): # Короткая строка HEX позволяет чередовать CAN и Modbus при 115200 бод.
|
||||
if not self.link.send(f'D{self.number:02X}:'.encode('ascii')+raw[offset:offset+512].hex().encode('ascii')+b'\n'): return -1 # Переполнение физической очереди прекращает сессию без повтора команды.
|
||||
return len(raw) # Успех означает только локальную постановку в очередь QSerialPort.
|
||||
def close(self): # Закрытие протокола не отбирает общий COM у оставшихся потребителей.
|
||||
if self.closed: return # Идемпотентный cleanup безопасен при синхронных Qt error callbacks.
|
||||
self.closed=True; self.active=False; self.incoming.clear(); self.link.release(self) # Менеджер завершает shell helper только после последнего канала.
|
||||
def failed(self,message): # Ошибка одного backend не маскируется состоянием другого протокола.
|
||||
if self.closed: return # Запоздалое событие не возобновляет отключённую страницу.
|
||||
self.error=message; self.close(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Потребитель очищает собственные незавершённые запросы.
|
||||
class ConsoleLink(QObject): # Один экземпляр на имя COM обслуживает сколько угодно независимых backend сессий.
|
||||
links={} # Реестр процесса сохраняет одного физического владельца между вкладками SETGUI.
|
||||
program='/opt/zynq-link/console_link.py' # Проверка staged-пакета может выбрать его точный локальный путь до постоянной установки helper.
|
||||
@classmethod # Потребитель запрашивает канал, не управляя временем жизни общей UART-консоли.
|
||||
def acquire(cls,name,port,parent=None): # Сетевой адрес не передаётся из UI: разрешены только две локальные службы платы.
|
||||
if int(port) not in (37579,502): raise ValueError('Общий USB поддерживает CAN 37579 и Modbus 502') # Разрешены штатный CAN и стандартный Modbus TCP, без произвольного loopback proxy.
|
||||
name=str(name).strip(); key=name.upper() if name.upper().startswith('COM') else name # Windows COM нечувствителен к регистру, Linux PTY сохраняет точное имя.
|
||||
link=cls.links.get(key) # Повторная вкладка присоединяется к уже открытому физическому каналу.
|
||||
if link is None: link=cls(name,key); cls.links[key]=link; QTimer.singleShot(0,link.start) # Асинхронный запуск даёт потребителю время подключить error/opened callbacks.
|
||||
number=next((value for value in range(1,256) if value not in link.used),None) # Номер не переиспользуется до закрытия физической сессии, поэтому поздний DATA старого клиента не попадёт новому.
|
||||
if number is None: raise RuntimeError('Исчерпаны USB-каналы платы') # Ограничение совпадает с однобайтовым номером общего протокола.
|
||||
channel=ConsoleChannel(link,number,int(port),parent); link.channels[number]=channel; link.used.add(number) # Множество использованных номеров защищает от смешения с поздними queued ответами закрытого backend.
|
||||
if link.ready: QTimer.singleShot(0,lambda:link.open_channel(channel)) # Существующий helper открывает только новый backend без повторной shell команды.
|
||||
return channel # Никакая прикладная команда ещё не отправляется в CAN или Modbus.
|
||||
def __init__(self,name,key): # Общая сессия переживает переключение и закрытие одной рабочей страницы.
|
||||
super().__init__(QCoreApplication.instance()); self.name=name; self.key=key; self.serial=QSerialPort(self); self.channels={}; self.used=set(); self.incoming=bytearray(); self.ready=False; self.closing=False; self.console_restored=True # QObject приложения освобождает менеджер до завершения Qt runtime, а история номеров изолирует поздние DATA.
|
||||
self.timer=QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda:self.fail('Нет ответа общего USB-туннеля: проверьте root shell и /opt/zynq-link/console_link.py')) # Ошибка начального handshake имеет конечный срок 5 секунд.
|
||||
self.serial.readyRead.connect(self.receive); self.serial.errorOccurred.connect(self.serial_error) # Единственный readyRead читает физические байты для всех протоколов.
|
||||
def start(self): # FTDI открывается только после явного запроса первой страницы подключения.
|
||||
if self.closing or not self.channels: return # Закрытый до первого event loop запрос не занимает COM.
|
||||
self.serial.setPortName(self.name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры соответствуют проверенной root-консоли платы, независимо от внешней RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.fail(self.serial.errorString()); return # Занятый сторонним терминалом COM не перехватывается.
|
||||
self.console_restored=False; self.phase='shell'; self.login_sent=False; self.cursor_replied=False; self.timer.start(5000); self.send(b'\x15\r') # Сначала запрашивается фактический prompt: после холодного boot COM может ещё принадлежать getty, а не shell.
|
||||
def start_helper(self): # Команды tty разрешены только после подтверждения root shell известной платы.
|
||||
self.phase='helper'; self.incoming.clear(); self.send(("stty -echo -icanon -ixon -ixoff min 1 time 0; python3 "+shlex.quote(self.program)+"; stty sane; printf '\\n#ZYNQ-LINK-CLOSED\\n'\r").encode('ascii')) # Путь экранирован одним аргументом, завершение helper восстанавливает обычный терминал.
|
||||
def send(self,data): # Общая очередь Windows ограничена независимо от скорости прикладных клиентов.
|
||||
if self.closing or not self.serial.isOpen(): return False # Закрытый physical fd не накапливает команды для будущего запуска.
|
||||
if self.serial.bytesToWrite()+len(data)>262144 or self.serial.write(data)!=len(data): self.fail('Переполнена очередь общего USB; уменьшите поток CAN/Modbus'); return False # Предел 256 КиБ не допускает бесконечного отставания от UART.
|
||||
return True # Реальное подтверждение операции остаётся обязанностью прикладного протокола.
|
||||
def open_channel(self,channel): # Каждый backend соединяется отдельно внутри уже работающего общего helper.
|
||||
if not channel.closed and self.ready: self.send(f'O{channel.number:02X}:{channel.port}\n'.encode('ascii')) # Номер и порт сформированы из проверенных локальных чисел.
|
||||
def receive(self): # Произвольные USB фрагменты собираются в ограниченные ASCII строки обрамления.
|
||||
if self.closing: return # Во время короткого cleanup принятые байты проверяет finish, избегая рекурсии callback.
|
||||
self.incoming.extend(bytes(self.serial.readAll())) # Физический поток читается ровно одним QObject.
|
||||
if len(self.incoming)>16384: self.fail('Неверный поток общего USB-туннеля'); return # Ни shell, ни повреждённый backend не могут бесконечно накапливать данные.
|
||||
if not self.ready and self.phase=='shell': # Login и prompt не обязаны оканчиваться переводом строки, поэтому проверяются до разбора multiplex строк.
|
||||
if b'Password:' in self.incoming: self.fail('Консоль платы требует пароль: войдите через терминал и повторите подключение'); return # Пароль не угадывается и не отправляется автоматически.
|
||||
if b'\x1b[6n' in self.incoming and not self.cursor_replied: self.cursor_replied=True; self.send(b'\x1b[24;80R') # Factory autologin вызывает resize с ANSI запросом курсора; стандартный ответ 24 строки и 80 столбцов освобождает shell без ввода фиктивной команды.
|
||||
plain=re.sub(rb'\x1b(?:\[[0-?]*[ -/]*[@-~]|[78])',b'',bytes(self.incoming)) # ANSI save/restore cursor и resize не являются частью реального root prompt.
|
||||
if re.search(rb'(?:^|[\r\n])root@image_7020:[^\r\n]*#[ \t]*$',plain): self.start_helper(); return # Только фактическая root shell разрешает запуск общего helper после штатного factory autologin.
|
||||
if re.search(rb'login:[ \t]*$',plain) and not self.login_sent: self.login_sent=True; self.incoming.clear(); self.send(b'root\r') # Известное имя отправляется только getty, действительно ожидающему ввод; строка automatic login не вызывает лишнюю root команду.
|
||||
return # Текст getty не попадает в Modbus/CAN parser и не считается banner helper.
|
||||
while b'\n' in self.incoming: # Одно уведомление может содержать ответы нескольких логических каналов.
|
||||
raw,_,tail=self.incoming.partition(b'\n'); self.incoming[:]=tail; line=raw.rstrip(b'\r') # Терминальные CR не входят в HEX данные прикладных протоколов.
|
||||
if not self.ready: # Вывод shell и echo стартовой команды не становятся Modbus ответом.
|
||||
if line==b'#ZYNQ-LINK 1': self.ready=True; self.timer.stop(); [self.open_channel(channel) for channel in list(self.channels.values())] # Только настоящий banner разрешает OPEN backend каналов.
|
||||
continue # Старый консольный вывод не передаётся ни одному прикладному parser.
|
||||
if len(line)<3: continue # Пустые строки не влияют на состояние готовых каналов.
|
||||
try: number=int(line[1:3],16) # Однобайтовый номер позволяет выбрать единственного получателя.
|
||||
except ValueError: self.fail('Повреждено обрамление общего USB'); return # Ошибка multiplex framing закрывает физическую сессию без повторов.
|
||||
channel=self.channels.get(number) # Закрытый потребитель может оставить поздние байты, которые следует игнорировать.
|
||||
if channel is None: continue # Поздний DATA не создаёт сессию автоматически.
|
||||
if line[:1]==b'O' and len(line)==3: channel.active=True; channel.opened.emit() # Connected относится к установленному loopback соединению конкретной службы.
|
||||
elif line[:1]==b'C' and len(line)==3: channel.failed('Служба платы закрыла USB-канал') # FIN одного сервиса не отключает остальные протоколы.
|
||||
elif line[:1]==b'E' and line[3:4]==b':': channel.failed('Ошибка USB-канала службы: errno '+line[4:].decode('ascii','replace')) # Числовая причина Linux доступна рядом с соответствующей вкладкой.
|
||||
elif line[:1]==b'D' and line[3:4]==b':': # Прикладные данные раскодируются только после выбора активного канала.
|
||||
try: data=bytes.fromhex(line[4:].decode('ascii')) # Все управляющие значения байта остаются неизменными данными TCP потока.
|
||||
except (ValueError,UnicodeError): self.fail('Повреждены HEX данные общего USB'); return # Неисправное обрамление не проходит к Modbus C-кодеку как частичный успех.
|
||||
if len(channel.incoming)+len(data)>65536: channel.failed('Переполнен буфер USB-канала'); continue # Ограничение одного потребителя не мешает работе остальных.
|
||||
channel.incoming.extend(data); channel.readyRead.emit() # Существующий parser получает произвольный фрагмент исходного TCP.
|
||||
else: self.fail('Неизвестный ответ общего USB-туннеля'); return # Неожиданное возвращение shell не выдаётся за действующий протокольный поток.
|
||||
def serial_error(self,error): # Qt NoError не считается отказом физического FTDI.
|
||||
if not self.closing and self.serial.isOpen() and error not in (QSerialPort.SerialPortError.NoError,QSerialPort.SerialPortError.TimeoutError): self.fail(self.serial.errorString()) # Отключение кабеля завершает все каналы одной общей сессии.
|
||||
def release(self,channel): # Канал удаляется до посылки CLOSE, исключая повторную обработку поздних данных.
|
||||
self.channels.pop(channel.number,None) # Повторный cleanup не затрагивает другой номер сессии.
|
||||
if self.ready and not self.closing: self.send(f'C{channel.number:02X}\n'.encode('ascii')) # Backend получает штатный обрыв собственного TCP соединения.
|
||||
if not self.channels: self.finish() # Последняя страница возвращает обычную Linux-консоль и освобождает COM.
|
||||
def fail(self,message): # Физическая ошибка закрывает всех владельцев без автоматического повторного запуска.
|
||||
if self.closing: return # Синхронный QSerialPort error во время close не вызывает рекурсию.
|
||||
channels=list(self.channels.values()); self.closing=True # Сначала запрещаются новые записи всех потребителей.
|
||||
for channel in channels: channel.failed(message) # Каждая вкладка получает собственный cleanup и прекращает опрос.
|
||||
self.finish() # Общий UART освобождается после доставки ошибок всем потребителям.
|
||||
def finish(self): # Только последний потребитель завершает собственный foreground helper.
|
||||
self.closing=True; self.timer.stop() # Остальные Qt callbacks не читают tty во время подтверждения возврата shell.
|
||||
if self.serial.isOpen(): # Отсоединённый физический кабель не заставляет ждать несуществующее подтверждение.
|
||||
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # ISIG остаётся включённым, поэтому Ctrl-C завершает только собственную foreground группу helper.
|
||||
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Очередь передачи ограничена коротким ожиданием фактической отправки Ctrl-C.
|
||||
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Консоль должна подтвердить выполнение stty sane за ограниченные 500 мс.
|
||||
while elapsed.elapsed()<500 and not self.console_restored: self.serial.waitForReadyRead(25); response.extend(bytes(self.serial.readAll())); self.console_restored=b'\n#ZYNQ-LINK-CLOSED\r' in response or b'\n#ZYNQ-LINK-CLOSED\n' in response # Собственный marker отличает реальный возврат shell от простого закрытия COM.
|
||||
self.serial.close() # Сторонний терминал может открыть COM после завершения последнего протокола.
|
||||
if self.links.get(self.key) is self: self.links.pop(self.key) # Новый запрос пользователя создаст новую чистую физическую сессию.
|
||||
self.deleteLater() # Удаление QObject выполняется штатным Qt event loop до выгрузки runtime.
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Асинхронный USB-порт шлюза и локальный TCP endpoint для существующих Modbus-клиентов.""" # Wire-кодек и проверка ответов выполняются только C-ядром templates.
|
||||
from collections import deque # Ограниченная последовательная очередь исключает перемешивание RTU-ответов разных клиентов.
|
||||
import ctypes as ct # Передаёт native-запрос в общую C-проверку до отправки по USB.
|
||||
from PySide6.QtCore import QObject, QIODevice, QTimer, Signal # Жизненный цикл и дедлайны остаются в GUI event loop без фоновых гонок.
|
||||
from PySide6.QtNetwork import QHostAddress, QTcpServer # Loopback связывает существующий Qt Modbus-клиент с единственным владельцем USB.
|
||||
from PySide6.QtSerialPort import QSerialPort # Выделенный USB CDC COM открывается только по действию пользователя.
|
||||
from ..modbus_gateway import GatewayCodec # Python не вычисляет CRC, MBAP, смещения конфигурации и правила корреляции.
|
||||
class ModbusGatewayPort(QObject): # Один экземпляр обслуживает настройки и опрос через один физический USB endpoint.
|
||||
state = Signal(str) # Сообщает текущую фазу подключения и подтверждённые ошибки транспорта.
|
||||
configuration = Signal(object) # Передаёт только прочитанную с Zynq конфигурацию ABI.
|
||||
connected = Signal(bool) # Позволяет UI закрыть встроенный опрос при потере USB.
|
||||
def __init__(self, parent=None, codec=None): # Тест может предоставить библиотеку конкретной проверяемой сборки.
|
||||
super().__init__(parent); self.codec = codec or GatewayCodec(); self.serial = QSerialPort(self); self.server = QTcpServer(self); self.queue = deque(); self.pending = None; self.buffers = {}; self.received = bytearray(); self.config = self.codec.default(); self.ready = False; self.closing = False # Все очереди и callbacks принадлежат одному Qt-потоку.
|
||||
self.direct=self.serial; self.tcp_transport=False # Прямой CDC сохраняет RTU, а общий FTDI канал переносит стандартный Modbus TCP к той же службе.
|
||||
self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda: self.fail('Тайм-аут USB; проверьте OTG-порт и службу шлюза на Zynq')) # После тайм-аута сессия закрывается, чтобы поздний RTU-ответ не подтвердил следующий запрос.
|
||||
self.serial.readyRead.connect(self._receive); self.serial.errorOccurred.connect(self._serial_error); self.server.newConnection.connect(self._accept) # Уведомления не запускают блокирующих read/waitForReadyRead.
|
||||
@property # Локальный endpoint назначает ОС после успешного чтения настроек платы.
|
||||
def port(self): return self.server.serverPort() # Нулевой порт до подключения не выдаётся за готовый канал опроса.
|
||||
def open(self, name, console=False): # Прямой CDC либо общий FTDI сначала подтверждают службу и читают настройки без внешних операций.
|
||||
self.close() # Прежняя сессия и её локальные клиенты завершаются до выбора нового физического канала.
|
||||
if console: # Linux host для HDMI сохраняется, а существующий FTDI разделяется с CAN.
|
||||
from .console_link_port import ConsoleLink # Общий менеджер является единственным владельцем COM для обеих страниц.
|
||||
channel=ConsoleLink.acquire(name,502,self); self.serial=channel; self.tcp_transport=True; channel.readyRead.connect(self._receive); channel.opened.connect(self.read_configuration); channel.errorOccurred.connect(lambda error:self.fail(channel.errorString()) if not self.closing else None) # Чтение конфигурации начинается только после OPEN стандартного Modbus TCP listener той же службы платы.
|
||||
self.state.emit('Общий USB FTDI: подключение к Modbus-службе Zynq…'); return True # Ожидающую сессию можно отменить, не закрывая соседний CAN.
|
||||
self.serial.setPortName(name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры прямого CDC не зависят от физической линии RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.state.emit(self.serial.errorString()); return False # Занятый CAN-монитором COM не перехватывается и не закрывается принудительно.
|
||||
self.serial.clear(); self.serial.setDataTerminalReady(True); self.state.emit('USB открыт; чтение настроек Zynq…'); self.read_configuration(); return True # DTR объявляет gadget готовность host к передаче данных.
|
||||
def close(self): # Вызывается при закрытии общей вкладки, ошибке и ручном отключении.
|
||||
self.closing = True; self.timer.stop(); self.server.close(); sockets = list(self.buffers); self.buffers.clear(); self.queue.clear(); self.pending = None; self.received.clear(); self.ready = False # Ни один старый запрос не переносится в следующую сессию USB.
|
||||
for socket in sockets: socket.abort(); socket.deleteLater() # Loopback-клиенты немедленно получают потерю соединения и прекращают опрос.
|
||||
if self.serial.isOpen(): self.serial.close() # Физический COM освобождается для других приложений.
|
||||
if self.tcp_transport: self.serial.deleteLater(); self.serial=self.direct; self.tcp_transport=False # Только логический FTDI канал возвращается к прямому порту; тестовый либо другой прямой I/O сохраняет свой контракт.
|
||||
self.closing = False; self.connected.emit(False) # UI отключает рабочие действия после очистки транспорта.
|
||||
def fail(self, message): # Ошибка не провоцирует повтор внешней команды записи.
|
||||
if self.closing: return # Синхронные serial error callbacks во время close не вызывают рекурсию.
|
||||
self.close(); self.state.emit(message) # Пользователь явно восстанавливает соединение после неопределённого результата записи.
|
||||
def _invalid_response(self,reason): # Диагностические bytes сохраняются до закрытия сессии, очищающего буфер.
|
||||
preview=bytes(self.received[:32]).hex(' ').upper(); suffix=' …' if len(self.received)>32 else ''; message=f'{self.serial.portName()}: {reason}. Получено {len(self.received)} байт; HEX: {preview}{suffix}.' # Предел 32 байта сохраняет полезное начало ответа без неограниченного роста сообщения GUI.
|
||||
if not self.ready and not self.tcp_transport: message+=' Для штатного FTDI включите «Linux-консоль FTDI»; прямой CDC/UART требует выделенной службы шлюза.' # Единый образ не отправляет бинарный RTU в консоль Linux при правильно выбранном общем транспорте.
|
||||
self.fail(message) # Строгая проверка ответа по-прежнему закрывает сессию без повтора внешней операции.
|
||||
def _serial_error(self, error): # Ошибки NoError и ожидания не закрывают исправный endpoint.
|
||||
if error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError) and self.serial.isOpen(): self.fail(self.serial.errorString()) # Отсоединение USB прекращает и настройки, и встроенный опрос.
|
||||
def read_configuration(self): self._enqueue(self.codec.control(), None) # Чтение настроек не посылает запросы внешним устройствам.
|
||||
def apply_configuration(self, config): self._enqueue(self.codec.control(config), None, config) # Полная таблица отправляется одной атомарной командой управления.
|
||||
def _enqueue(self, request, socket, candidate=None): # Один незавершённый RTU запрос сохраняет однозначность ответа без Transaction ID.
|
||||
if not self.serial.isOpen(): self.state.emit('Сначала подключите USB Zynq'); return # Закрытый COM не накапливает команды на будущий запуск.
|
||||
if len(self.queue) >= 32: self.fail('Переполнена очередь Modbus; уменьшите частоту опроса'); return # Ограничивает память при слишком быстром loopback-клиенте.
|
||||
self.queue.append((request, socket, candidate)); self._next() # Кандидат настроек нужен только для достаточного дедлайна применения.
|
||||
def _next(self): # Следующая команда отправляется только после полного проверенного ответа предыдущей.
|
||||
if self.pending is not None or not self.queue: return # Исключает перемешивание данных опроса с чтением конфигурации.
|
||||
self.pending = self.queue.popleft(); request, socket, candidate = self.pending # Неподтверждённая запись остаётся ровно одной записью в очереди.
|
||||
if socket is not None and socket not in self.buffers: self.pending = None; self._next(); return # Запрос закрытого клиента удаляется до обращения к внешней шине.
|
||||
wire = self.codec.tcp_reply(request,bytes(request.pdu[:request.length])) if self.tcp_transport else self.codec.rtu(request); self.received.clear() # Общий FTDI передаёт MBAP, прямой CDC — RTU; оба формата собирает один native-кодек.
|
||||
if not wire or self.serial.write(wire) != len(wire): self.fail('Не удалось передать запрос по USB'); return # Ошибка локальной очереди записи не приводит к автоматическому повтору.
|
||||
timeout = max(self.config.timeout_ms, candidate.timeout_ms if candidate is not None else 0); self.timer.start(max(5000, timeout + 1500)) # Учитывает внешний тайм-аут платы, USB и небольшую очередь обслуживания.
|
||||
def _receive(self): # Границы USB transfer не считаются границами Modbus RTU ADU.
|
||||
chunk = bytes(self.serial.readAll()) # Читает уже доступные байты без ожидания.
|
||||
if self.pending is None: return # Посторонние байты вне транзакции не становятся ответом будущего запроса.
|
||||
self.received.extend(chunk); size = (self.codec.lib.smg_tcp_size if self.tcp_transport else self.codec.lib.smg_rtu_response_size)(bytes(self.received), len(self.received)) # Границы MBAP либо RTU, включая частную FC65, вычисляет только C-ядро.
|
||||
if size < 0 or len(self.received) > (260 if self.tcp_transport else 256) or (size > 0 and len(self.received) > size): self._invalid_response('Неверный формат или длина ответа USB Zynq'); return # Сохраняет диагностические bytes установленной панели при строгих native границах RTU/MBAP.
|
||||
if not size or len(self.received) < size: return # Незавершённый ADU остаётся в ограниченном буфере до следующего readyRead.
|
||||
request, socket, candidate = self.pending # Ответ принадлежит единственному исходящему запросу.
|
||||
try: pdu = self.codec.accept(request, self.received, tcp=self.tcp_transport) # Native проверка CRC/MBAP и корреляции обязательна для обоих вариантов физического USB.
|
||||
except ValueError as error: self._invalid_response(str(error)); return # Ошибки CRC, адреса и MBAP получают ту же диагностику физического канала и фактических bytes.
|
||||
self.timer.stop(); self.pending = None; self.received.clear() # Освобождает транзакцию только после проверки полного ответа.
|
||||
if socket is None: # Управление шлюзом обрабатывается отдельно от обычных регистров устройств.
|
||||
try: config = self.codec.read_config(pdu) # Отказ ARM при настройке UART остаётся видимой ошибкой применения.
|
||||
except ValueError as error: self.state.emit(str(error)); self._next(); return # Прежняя подтверждённая конфигурация остаётся действующей.
|
||||
self.config = config # Тайм-аут и маршруты подтверждены работающей службой платы.
|
||||
if not self.ready: # Локальный endpoint открывается после handshake с правильной службой, а не после простого открытия COM.
|
||||
if not self.server.listen(QHostAddress.SpecialAddress.LocalHost, 0): self.fail(self.server.errorString()); return # ОС выделяет свободный порт без конфликтов с другими приложениями.
|
||||
self.ready = True; self.connected.emit(True) # Встроенная панель может подключиться к готовому каналу опроса.
|
||||
self.configuration.emit(config); self.state.emit('USB Zynq подключён; настройки подтверждены платой') # UI не выдаёт локальный черновик за применённую таблицу.
|
||||
elif socket in self.buffers: socket.write(self.codec.tcp_reply(request, pdu)) # Исходные Transaction ID и Unit ID возвращаются клиенту через C builder.
|
||||
self._next() # Продолжает очередь после доставки ответа его владельцу.
|
||||
def _accept(self): # Loopback принимает только локальные Modbus-клиенты процесса или ПК.
|
||||
while self.server.hasPendingConnections(): # Несколько ожидающих подключений обрабатываются за одно уведомление Qt.
|
||||
socket = self.server.nextPendingConnection() # QObject TCP-сокета остаётся дочерним серверу.
|
||||
if len(self.buffers) >= 8: socket.abort(); socket.deleteLater(); continue # Лимит совпадает с числом TCP-клиентов ARM-службы.
|
||||
self.buffers[socket] = bytearray(); socket.readyRead.connect(lambda s=socket: self._tcp_read(s)); socket.disconnected.connect(lambda s=socket: self._tcp_closed(s)) # Каждая сессия имеет свой MBAP stream buffer.
|
||||
def _tcp_closed(self, socket): self.buffers.pop(socket, None); socket.deleteLater() # Запросы закрытого клиента удаляются из очереди перед отправкой по USB.
|
||||
def _tcp_read(self, socket): # Qt Modbus использует существующий TCP backend, сохраняя единый UI опроса.
|
||||
if socket not in self.buffers: return # Позднее queued notification после disconnect не обращается к удалённому буферу.
|
||||
buffer = self.buffers[socket]; buffer.extend(bytes(socket.readAll())) # Склеенные и частичные TCP ADU сохраняются без потери байтов.
|
||||
if len(buffer) > 1040: socket.abort(); return # Четыре максимальных ADU ограничивают память недоверенного локального клиента.
|
||||
while buffer: # Один read может содержать несколько законченных Modbus-запросов.
|
||||
size = self.codec.lib.smg_tcp_size(bytes(buffer), len(buffer)) # Границы MBAP проверяет тот же код, что ARM-служба.
|
||||
if size < 0: socket.abort(); return # Неизвестный Protocol ID и длина прекращают только эту TCP-сессию.
|
||||
if not size or len(buffer) < size: return # Неполный заголовок не считается ошибкой и ждёт следующий пакет.
|
||||
request = self.codec.decode_tcp(buffer[:size]); del buffer[:size]; error = self.codec.lib.smg_check(ct.byref(request)) # Неподдерживаемая функция либо ошибочный размер PDU не превращаются в неклассифицируемый USB stream.
|
||||
if error: socket.write(self.codec.tcp_reply(request,self.codec.exception(request,error))) # Возвращает стандартное исключение без какой-либо внешней операции.
|
||||
else: self._enqueue(request, socket) # Внешний запрос остаётся единственным, даже когда входной TCP разбит или склеен.
|
||||
@@ -79,6 +79,7 @@ class SlcanAdapter(QObject):
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
protocol_error = Signal(str) # Сообщает явный отказ BEL вне настройки; принятие байтов COM не подтверждает выполнение команды адаптером.
|
||||
fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN.
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
@@ -222,6 +223,8 @@ class SlcanAdapter(QObject):
|
||||
if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды.
|
||||
if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки.
|
||||
self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами.
|
||||
elif not self._waiting_command and self._query_prefix is None: # Отказ диагностического запроса не приписывается передаче CAN; установку режима обслуживает собственное ожидание.
|
||||
self.protocol_error.emit("SLCAN: адаптер вернул отказ BEL; проверьте состояние CAN и очередь передачи") # BEL не содержит идентификатора кадра и причины, поэтому не выдаём его за доказательство отсутствия CAN ACK.
|
||||
continue # BEL не должен приклеиваться к следующему CAN-кадру.
|
||||
if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы.
|
||||
continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам.
|
||||
@@ -286,7 +289,7 @@ class SlcanAdapter(QObject):
|
||||
lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel.
|
||||
canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR.
|
||||
if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии.
|
||||
lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y.
|
||||
lines.append(f"Профиль {self.options.profile}: команды настройки по COM без ответов CR/BEL; это не аппаратное подтверждение ACK на шине CAN.") # Отделяет отсутствие ответов протокола SLCAN от аппаратного ACK, который активный CAN-контроллер формирует самостоятельно.
|
||||
for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки.
|
||||
self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса.
|
||||
self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Асинхронный J-Link-порт: backup, запись, readback, проверка и отдельный reset/run.""" # Внешний процесс не блокирует цикл событий SETGUI.
|
||||
from pathlib import Path # Артефакты одной операции хранятся вместе в отдельном каталоге.
|
||||
import re # Итоговый запуск подтверждается выполненной командой g, а не одним кодом возврата.
|
||||
import tempfile # Уникальный каталог исключает перезапись предыдущей резервной копии.
|
||||
try: # Общая библиотека работает с современным Qt и существующими сборками Windows 7.
|
||||
from PySide6.QtCore import QObject, QProcess, QTimer, Signal # QProcess обслуживает stdout, завершение и отмену без потоковой блокировки.
|
||||
except ImportError: # PySide2 сохраняет тот же публичный контракт сигналов этого порта.
|
||||
from PySide2.QtCore import QObject, QProcess, QTimer, Signal # Никакой зависимости от внутреннего устройства SETGUI здесь нет.
|
||||
from set_devices.stm32_jtag import encode_hex, programming_script, start_script, verify_readback # Вся подготовка и проверка сосредоточены в переносимом модуле templates.
|
||||
|
||||
|
||||
class Stm32JtagProgrammer(QObject): # Владеет одной аппаратной операцией до полного завершения обоих этапов.
|
||||
progress = Signal(int, str) # Основная форма показывает этапы рядом с существующей полосой прогресса.
|
||||
finished = Signal(bool, str) # Единственный итог освобождает форму также при ошибке запуска executable.
|
||||
log = Signal(str) # Полный вывод Commander доступен в терминале и сохраняется на диск.
|
||||
|
||||
def __init__(self, parent=None): # Создание порта не открывает программатор и не обращается к USB.
|
||||
super().__init__(parent) # Время жизни порта соответствует его Qt-владельцу.
|
||||
self._process = None # None означает отсутствие запущенного Commander.
|
||||
self._active = False # Флаг защищает от повторного finished после errorOccurred и finished одного процесса.
|
||||
self._timer = QTimer(self) # Ограничивает зависшую внешнюю утилиту без ожидания в GUI.
|
||||
self._timer.setSingleShot(True) # На один этап приходится один предельный тайм-аут.
|
||||
self._timer.timeout.connect(lambda: self._stop('Истекло время ожидания J-Link; проверьте журнал')) # Остановка не объявляет частично записанный образ успешным.
|
||||
|
||||
@property # Форма может проверить занятость перед повторным нажатием или закрытием.
|
||||
def active(self): # Состояние охватывает программирование, независимую проверку и reset/run.
|
||||
return self._active # Между двумя процессами нельзя начать конкурирующую запись.
|
||||
|
||||
def start(self, executable, options, segments, backup_root): # Принимает уже проверенный неизменяемый план, подтверждённый пользователем.
|
||||
if self._active: raise ValueError('J-Link уже выполняет операцию') # Второй запуск не подменяет исходный путь backup и цель MCU.
|
||||
options.validate() # Защищает прямые вызовы порта вне стандартной формы.
|
||||
self._options, self._segments, self._executable = options, segments, str(executable) # Копирует параметры текущей операции до запуска QProcess.
|
||||
self._active, self._stop_reason = True, '' # Обозначает владение программатором и отсутствие запроса отмены.
|
||||
try: # Ошибки каталога и снимка должны завершать операцию штатным сигналом формы.
|
||||
root = Path(backup_root).resolve() # Выбранный каталог не зависит от текущей рабочей папки процесса GUI.
|
||||
root.mkdir(parents=True, exist_ok=True) # Создаёт только папку артефактов пользователя, без установки инструментов.
|
||||
self.folder = Path(tempfile.mkdtemp(prefix='stm32-jtag-', dir=root)) # Предыдущие backup остаются нетронутыми даже при повторной неудачной попытке.
|
||||
(self.folder / 'image.hex').write_text(encode_hex(segments), encoding='utf-8') # Фиксирует проверенные адресованные данные, исключая изменение исходника во время записи.
|
||||
self.log.emit(f'Артефакты J-Link и резервная копия: {self.folder}') # Путь известен оператору до первого аппаратного действия.
|
||||
self.progress.emit(0, 'Резервная копия, запись и чтение Flash') # Число отражает этап, поскольку Commander сам выводит ход программирования в терминал.
|
||||
self._launch('program', programming_script(self.folder, options)) # Сначала исполняется только этап с остановленным ядром и двумя SaveBin.
|
||||
except (OSError, ValueError) as error: self._finish(False, str(error)) # Отказ файловой системы не оставляет интерфейс заблокированным.
|
||||
|
||||
def _launch(self, stage, commands): # Каждый этап использует новый QProcess и собственный проверяемый command file.
|
||||
self._stage, self._output = stage, '' # Вывод текущего этапа нужен также для подтверждения факта команды запуска.
|
||||
script = self.folder / (stage + '.jlink') # Точный выполненный сценарий сохраняется рядом с backup.
|
||||
script.write_text(commands, encoding='utf-8') # UTF-8 без BOM совместим с установленным Commander.
|
||||
process = QProcess(self) # QObject-владелец удерживает процесс до обработки сигнала finished.
|
||||
self._process = process # Отмена и тайм-аут относятся только к текущему этапу.
|
||||
process.setProcessChannelMode(QProcess.MergedChannels) # Ошибки stderr сохраняются в том же порядке, что служебные сообщения stdout.
|
||||
process.readyReadStandardOutput.connect(self._read_output) # Строки отображаются по мере поступления без waitForFinished.
|
||||
process.errorOccurred.connect(self._process_error) # Отсутствующий executable и невозможность запуска дают понятный итог.
|
||||
process.finished.connect(self._process_finished) # Только штатное завершение запускает независимую проверку и следующий этап.
|
||||
self._timer.start(300000) # Пять минут на этап покрывают медленный JTAG; GUI продолжает обрабатывать отмену.
|
||||
process.start(self._executable, self._options.arguments() + ['-Log', str(self.folder / (stage + '-dll.log')), '-CommanderScript', str(script)]) # Путь, серийный номер и файл передаются отдельными аргументами без командной оболочки.
|
||||
|
||||
def _read_output(self): # Вызывается также при finished, чтобы не потерять последний фрагмент stdout.
|
||||
if self._process is None: return # Сигнал уже освобождённого этапа не должен обращаться к новому процессу.
|
||||
chunk = bytes(self._process.readAllStandardOutput()).decode('utf-8', errors='replace').replace('\b', '') # Управляющие backspace процентов Commander не ломают текстовый терминал.
|
||||
self._output += chunk # Полный вывод сохраняется для анализа результата и диагностики ошибки.
|
||||
if chunk: self.log.emit(chunk.rstrip()) # Отображает сообщения сразу, включая отсутствие питания и занятой J-Link.
|
||||
|
||||
def _process_error(self, error): # FailedToStart не сопровождается finished и требует самостоятельного завершения.
|
||||
if self._active and error == QProcess.FailedToStart: self._finish(False, 'Не удалось запустить J-Link: ' + self._process.errorString()) # Не оставляет активный таймер после неверного пути executable.
|
||||
|
||||
def _process_finished(self, code, status): # Последовательно выполняет readback-проверку и отдельный reset/run.
|
||||
if not self._active: return # Игнорирует повторное уведомление ранее завершённой ошибки.
|
||||
self._timer.stop() # Проверка локальных файлов не должна срабатывать как тайм-аут уже завершённого Commander.
|
||||
self._read_output() # Забирает остаток вывода перед сохранением журнала.
|
||||
try: # Все ошибки проверки и записи журнала превращаются в один finished(False).
|
||||
(self.folder / (self._stage + '.log')).write_text(self._output, encoding='utf-8') # Читаемый журнал содержит именно тот вывод, который видел оператор.
|
||||
process, self._process = self._process, None # Старый QObject больше не считается владельцем текущего этапа.
|
||||
process.deleteLater() # Удаление выполняется циклом Qt после выхода из обработчика finished.
|
||||
if self._stop_reason: raise ValueError(self._stop_reason) # Отмена никогда не переходит к командам reset/run.
|
||||
if code != 0 or status != QProcess.NormalExit: raise ValueError(f'J-Link завершился с ошибкой {code}; журнал: {self.folder}') # Успех требует нормального завершения внешней утилиты.
|
||||
if self._stage == 'program': # Даже код ноль не подтверждает, что интерактивное подключение реально дошло до записи.
|
||||
before, after = (self.folder / 'before.bin').read_bytes(), (self.folder / 'after.bin').read_bytes() # Отсутствующий SaveBin автоматически исключает ложный успех.
|
||||
verify_readback(self._segments, self._options, before, after) # Проверяет реальные байты записанных диапазонов и защищённую страницу UKSVEP.
|
||||
self.progress.emit(90, 'Flash проверена, сброс и запуск STM32') # Переход к запуску разрешён только после независимой проверки обоих снимков.
|
||||
self._launch('run', start_script(self._options)) # Новый Commander больше не стирает и не программирует Flash.
|
||||
else: # Второй этап должен дойти до явного возобновления исполнения ядра.
|
||||
if not re.search(r'J-Link>\s*g\s*(?:\r?\n|$)', self._output): raise ValueError('J-Link не подтвердил выполнение команды запуска; проверьте журнал') # Преждевременный выход с кодом ноль не превращается в сообщение о работающем MCU.
|
||||
self.progress.emit(100, 'Запись и проверка завершены, выполнен reset/run') # Проценты завершаются только после обоих этапов.
|
||||
self._finish(True, f'STM32 прошит через {self._options.interface}; Flash проверена, reset/run выполнен. Backup: {self.folder / "before.bin"}') # Итог содержит доступную для восстановления резервную копию.
|
||||
except (OSError, ValueError) as error: self._finish(False, str(error)) # При отказе проверки ядро не запускается новым этапом.
|
||||
|
||||
def cancel(self): # Вызывается штатной кнопкой отмены формы прошивки.
|
||||
self._stop('J-Link: операция отменена; частичная Flash требует повторной прошивки, резервная копия сохранена в каталоге операции') # Не обещает целый образ после прерывания аппаратной записи.
|
||||
|
||||
def _stop(self, reason): # Общая остановка обслуживает кнопку отмены и предельный тайм-аут.
|
||||
if not self._active: return # Повторная отмена завершённого порта ничего не меняет.
|
||||
self._stop_reason = reason # finished процесса должен сохранить причину вместо сообщения об обычном ненулевом коде.
|
||||
if self._process is not None: self._process.kill() # Останавливает только запущенный этим портом Commander, не затрагивая Keil и другие программаторы.
|
||||
else: self._finish(False, reason) # Ошибка между этапами также освобождает форму без дополнительного процесса.
|
||||
|
||||
def _finish(self, success, message): # Единственная точка завершения защищает форму от двойных уведомлений.
|
||||
if not self._active: return # Второй сигнал ошибки того же QProcess не открывает повторное окно.
|
||||
self._active = False # Новая операция разрешается только после окончательного результата текущей.
|
||||
self._timer.stop() # Поздний тайм-аут не должен отменить следующий пользовательский запуск.
|
||||
if self._process is not None: self._process.deleteLater() # FailedToStart не использует общий обработчик finished.
|
||||
self._process = None # Освобождает ссылку на завершённый QObject.
|
||||
self.finished.emit(success, message) # Владелец разблокирует форму и покажет итог стандартным механизмом SETGUI.
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Общие настройки STM32/J-Link для встраивания в существующий экран прошивки.""" # SETGUI предоставляет лишь выбранный образ и свой терминал.
|
||||
from pathlib import Path # Проверяет путь внешнего инструмента до подтверждения аппаратной операции.
|
||||
try: # Поддерживает обе используемые SETGUI ветви Qt без зависимости от gui_desktop.
|
||||
from PySide6.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Настройки и каталог backup принадлежат текущему приложению.
|
||||
from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Использует обычные виджеты, доступные в Qt 5 и Qt 6.
|
||||
except ImportError: # Старые Windows-сборки сохраняют тот же публичный интерфейс панели.
|
||||
from PySide2.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Не навязывает основному приложению версию Qt.
|
||||
from PySide2.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Набор контролов одинаков для обеих реализаций.
|
||||
from set_devices.stm32_jtag import JLinkOptions, find_jlink, prepare_image # Общий план выполняет все проверки MCU, адресов и manifest до записи.
|
||||
from .stm32_jtag import Stm32JtagProgrammer # Порт QProcess отвечает за журнал, отмену и оба этапа операции.
|
||||
|
||||
|
||||
class Stm32JtagPanel(QWidget): # Встраивается в FirmwareTab вместо UART/CAN-настроек выбранного режима.
|
||||
progress = Signal(int, str) # Перенаправляет этапы в полосу прогресса основной формы.
|
||||
finished = Signal(bool, str) # Результат обрабатывается тем же методом, что остальные загрузчики.
|
||||
log = Signal(str) # Вывод J-Link направляется в существующий терминал прошивки.
|
||||
|
||||
def __init__(self, parent=None, settings=None): # Явное хранилище позволяет изолировать настройки в тестах и других приложениях.
|
||||
super().__init__(parent) # Панель следует обычному времени жизни владельца QWidget.
|
||||
self.settings = settings if settings is not None else QSettings() # Не подменяет тестовый профиль глобальным пользовательским реестром.
|
||||
self.programmer = Stm32JtagProgrammer(self) # Создание формы само по себе не подключает USB и не запускает Commander.
|
||||
self.programmer.progress.connect(self.progress.emit) # Прогресс проходит через общий публичный интерфейс панели.
|
||||
self.programmer.finished.connect(self.finished.emit) # Ошибки и успех освобождают внешнюю форму одинаковым способом.
|
||||
self.programmer.log.connect(self.log.emit) # Терминал получает и служебные строки, и сообщения SEGGER.
|
||||
if QCoreApplication.instance() is not None: QCoreApplication.instance().aboutToQuit.connect(self.programmer.cancel) # Закрытие приложения не оставляет скрытый процесс программирования без владельца.
|
||||
root = QVBoxLayout(self) # Все настройки образуют одну колонку внутри существующей левой панели.
|
||||
root.setContentsMargins(0, 0, 0, 0) # Внешние отступы уже заданы формой прошивки.
|
||||
hint = QLabel('J-Link через JTAG или SWD. Доступ к BOOT0 не требуется. Перед записью сохраняется полная резервная копия Flash.') # Оператор сразу видит назначение режима и способ первого программирования.
|
||||
hint.setWordWrap(True) # Описание помещается в узкую колонку рядом с терминалом.
|
||||
root.addWidget(hint) # Пояснение располагается над параметрами подключения.
|
||||
form = QFormLayout() # Подписи не смешиваются с UART-полями других загрузчиков.
|
||||
self.executable = QLineEdit(str(self._value('executable', find_jlink()))) # Позволяет использовать SEGGER из Keil либо отдельную установленную версию.
|
||||
executable_row = QHBoxLayout() # Кнопка выбора остаётся рядом с полным путём инструмента.
|
||||
executable_row.addWidget(self.executable) # Введённый путь передаётся QProcess отдельным аргументом.
|
||||
executable_button = QPushButton('Обзор…') # Поиск установленного инструмента не требует скачивания и установки.
|
||||
executable_button.clicked.connect(self._choose_executable) # Диалог выбирает только путь, без запуска программатора.
|
||||
executable_row.addWidget(executable_button) # Кнопка сохраняет естественную компактную ширину.
|
||||
form.addRow('J-Link Commander', executable_row) # Имя executable явно отделено от названия MCU.
|
||||
self.device = QComboBox() # Стандартные имена подходят базе устройств установленного Commander.
|
||||
self.device.setEditable(True) # Другой STM32 можно указать вручную вместе с фактическим объёмом Flash.
|
||||
self.device.addItems(['STM32F103RC', 'STM32F103C8', 'STM32F103ZE', 'STM32F407VE', 'STM32G474VE']) # Список охватывает используемые проектами templates семейства.
|
||||
self.device.setCurrentText(str(self._value('device', 'STM32F103RC'))) # По умолчанию выбирается текущая плата, ранее проверенная через JTAG.
|
||||
form.addRow('Микроконтроллер', self.device) # Полное имя цели видно до подтверждения записи.
|
||||
self.interface = QComboBox() # Интерфейс не зависит от UART-скорости верхней панели SETGUI.
|
||||
self.interface.addItems(['JTAG', 'SWD']) # Оба варианта используют J-Link, а не встроенный ROM Boot.
|
||||
self.interface.setCurrentText(str(self._value('interface', 'JTAG'))) # Исходное подключение текущей платы выполнено по JTAG.
|
||||
form.addRow('Интерфейс', self.interface) # Пользователь видит выбранный физический способ подключения.
|
||||
self.serial = QLineEdit(str(self._value('serial', ''))) # Не закрепляет серийный номер чужого программатора в общей библиотеке.
|
||||
self.serial.setPlaceholderText('Необязательно, если подключён один J-Link') # Несколько программаторов можно однозначно различить по серийному номеру.
|
||||
form.addRow('Серийный номер J-Link', self.serial) # Поле соответствует штатному параметру Commander -USB.
|
||||
self.speed = QSpinBox() # Частота задаётся числом с явными единицами.
|
||||
self.speed.setRange(1, 50000) # Допускает медленную плату и быстрые поддерживаемые подключения.
|
||||
self.speed.setValue(int(self._value('speed', 1000))) # Проверенная начальная частота 1000 кГц подходит текущему JTAG.
|
||||
self.speed.setSuffix(' кГц') # Исключает смешение TCK/SWCLK со скоростью COM в бодах.
|
||||
form.addRow('Частота JTAG/SWD', self.speed) # Значение передаётся напрямую в -speed.
|
||||
self.flash_size = QSpinBox() # Полный объём нужен для сохранения Flash перед любым программированием.
|
||||
self.flash_size.setRange(16, 4096) # Интервал покрывает поддерживаемый переносимым планом объём 16 КиБ..4 МиБ.
|
||||
self.flash_size.setValue(int(self._value('flash_size', 256))) # У STM32F103RC установлены 256 КиБ внутренней Flash.
|
||||
self.flash_size.setSuffix(' КиБ') # Длина SaveBin вычисляется из этого значения в байтах.
|
||||
form.addRow('Объём Flash', self.flash_size) # Для другого MCU оператор должен указать его фактический объём.
|
||||
self.uksvep = QCheckBox('UKSVEP F103RC: сохранить параметры и обновить CRC приложения') # Явный профиль разрешает специфические адреса manifest и регистров BKP.
|
||||
self.uksvep.setChecked(self.settings.value('firmware/stm32-jtag/uksvep', True, type=bool)) # Выбор сохраняется независимо от общего имени MCU.
|
||||
self.uksvep.setToolTip('Для другой платы снимите этот флажок. UKSVEP принимает полный app с 0x08008000 или установочный HEX boot+app.') # Объясняет действие профиля при осмысленном выборе оператором.
|
||||
form.addRow('Профиль платы', self.uksvep) # Обычные STM32 не наследуют защиту и служебную запись UKSVEP автоматически.
|
||||
default_backup = str(Path(QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)) / 'stm32-jtag') # Каталог остаётся записываемым также у установленного в Program Files приложения.
|
||||
self.backup_root = QLineEdit(str(self._value('backup_root', default_backup))) # Каждая операция создаёт внутри отдельную подпапку с образами и журналами.
|
||||
backup_row = QHBoxLayout() # Настройка архива не требует ручного редактирования путей Windows.
|
||||
backup_row.addWidget(self.backup_root) # Полный путь остаётся доступным для копирования оператором.
|
||||
backup_button = QPushButton('Обзор…') # Выбирает существующий базовый каталог резервных копий.
|
||||
backup_button.clicked.connect(self._choose_backup) # Диалог не изменяет Flash и настройки программатора.
|
||||
backup_row.addWidget(backup_button) # Действие располагается у соответствующего пути.
|
||||
form.addRow('Резервные копии', backup_row) # Backup включён всегда и не прячется в настройках UART.
|
||||
root.addLayout(form) # Готовая панель встраивается без собственной кнопки прошивки и повторной полосы прогресса.
|
||||
|
||||
def _value(self, name, default): # Все ключи принадлежат отдельному стабильному разделу прошивки J-Link.
|
||||
return self.settings.value('firmware/stm32-jtag/' + name, default) # Прежние UART/CAN-настройки не меняются.
|
||||
|
||||
def _choose_executable(self): # Пользователь явно указывает уже установленный J-Link Commander.
|
||||
path, _ = QFileDialog.getOpenFileName(self, 'J-Link Commander', self.executable.text(), 'J-Link (JLink.exe JLinkExe);;Все файлы (*)') # Расширение не ограничивает Linux-версию SEGGER.
|
||||
if path: self.executable.setText(path) # Отмена диалога сохраняет текущий путь.
|
||||
|
||||
def _choose_backup(self): # Позволяет хранить backup вне каталога установки GUI и исходников.
|
||||
path = QFileDialog.getExistingDirectory(self, 'Каталог резервных копий STM32', self.backup_root.text()) # Выбор каталога не запускает аппаратную операцию.
|
||||
if path: self.backup_root.setText(path) # Прежний архив остаётся выбранным при отмене диалога.
|
||||
|
||||
def prepare(self, image): # Основная форма вызывает проверку до стандартного подтверждения записи.
|
||||
executable = Path(self.executable.text().strip()) # Путь не интерпретируется как команда оболочки.
|
||||
if not executable.is_file(): raise ValueError('Выберите установленный J-Link Commander (JLink.exe)') # Отказывает до блокировки формы и открытия программатора.
|
||||
if not self.backup_root.text().strip(): raise ValueError('Выберите каталог резервных копий Flash') # Не сохраняет обязательный backup в непредсказуемый текущий каталог.
|
||||
options = JLinkOptions(self.device.currentText().strip(), self.interface.currentText(), self.speed.value(), self.serial.text().strip(), self.flash_size.value(), self.uksvep.isChecked()) # Фиксирует все параметры одновременно с неизменяемым образом.
|
||||
return str(executable), options, prepare_image(image, options), self.backup_root.text().strip() # Возвращает точный план для единственного последующего подтверждённого запуска.
|
||||
|
||||
def start(self, job): # Вызывается только после существующего подтверждения прошивки в FirmwareTab.
|
||||
for name, widget in (('executable', self.executable), ('serial', self.serial), ('backup_root', self.backup_root)): self.settings.setValue('firmware/stm32-jtag/' + name, widget.text().strip()) # Сохраняет пути и идентификатор без доступа к остальным настройкам GUI.
|
||||
for name, value in (('device', self.device.currentText()), ('interface', self.interface.currentText()), ('speed', self.speed.value()), ('flash_size', self.flash_size.value()), ('uksvep', self.uksvep.isChecked())): self.settings.setValue('firmware/stm32-jtag/' + name, value) # Следующий запуск формы восстанавливает фактическое последнее подключение.
|
||||
self.programmer.start(*job) # Процесс получает уже проверенный снимок, а не повторно читает изменяемые поля формы.
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Подготовка и независимая проверка J-Link-прошивки STM32 без зависимости от Qt.""" # Один план используют SETGUI и консольные клиенты templates.
|
||||
from dataclasses import dataclass # Неизменяемые настройки фиксируют назначение до первой записи Flash.
|
||||
from pathlib import Path # Пути передаются программатору как данные, без выполнения оболочкой.
|
||||
import os # Стандартные каталоги Windows позволяют найти уже установленный J-Link.
|
||||
import re # Ограничивает поля, попадающие в аргументы J-Link Commander.
|
||||
import shutil # Поиск PATH сохраняет возможность использовать установленный SEGGER вне Keil.
|
||||
import struct # Manifest UKSVEP содержит шесть little-endian слов uint32.
|
||||
import zlib # CRC32 IEEE совпадает с валидатором production-загрузчика UKSVEP.
|
||||
from firmware_image.core import HexSegment, parse_intel_hex, contiguous_image # Проверка формата и checksum использует каноническое C-ядро templates.
|
||||
from .mcu_profiles import UKSVEP_F103RC_BOOT # Адрес приложения, SRAM и Product берутся из общего профиля платы.
|
||||
|
||||
FLASH_BASE = 0x08000000 # Внутренняя Flash STM32 отображается с этого адреса в карте памяти Cortex-M.
|
||||
UKS_APP = UKSVEP_F103RC_BOOT['app_base'] # Начальные 32 КиБ UKSVEP занимает постоянный загрузчик.
|
||||
UKS_PARAMS = UKS_APP + UKSVEP_F103RC_BOOT['app_size'] # Следующая страница 0x0801F800 хранит параметры и исключается из записи.
|
||||
UKS_META = 0x0803F800 # Последняя страница 256-КиБ Flash содержит production-manifest, см. boot_profile.h UKSVEP.
|
||||
|
||||
|
||||
@dataclass(frozen=True) # Значения нельзя незаметно изменить между подтверждением формы и запуском процесса.
|
||||
class JLinkOptions: # Описывает конкретный STM32 и физическое подключение программатора.
|
||||
device: str = 'STM32F103RC' # Имя совпадает с базой устройств J-Link; корпусный суффикс T6 не требуется.
|
||||
interface: str = 'JTAG' # SWD использует тот же Commander и выбирается пользователем отдельно.
|
||||
speed_khz: int = 1000 # Частота TCK/SWCLK задаётся в кГц и не связана со скоростью UART.
|
||||
serial: str = '' # Пустое значение разрешает штатный выбор единственного подключённого J-Link.
|
||||
flash_kib: int = 256 # Размер всей Flash задаёт длину обязательных backup и readback.
|
||||
uksvep: bool = True # Только явно выбранный профиль UKSVEP включает защиту параметров и manifest.
|
||||
|
||||
def validate(self): # Отказ происходит до создания command file и доступа к плате.
|
||||
if not re.fullmatch(r'STM32[A-Za-z0-9_]+', self.device): raise ValueError('Укажите имя STM32 из базы J-Link') # Не допускает пробелы и команды вместо идентификатора MCU.
|
||||
if self.interface not in ('JTAG', 'SWD'): raise ValueError('Выберите JTAG или SWD') # Остальные интерфейсы не относятся к этой панели.
|
||||
if not 1 <= self.speed_khz <= 50000: raise ValueError('Частота JTAG/SWD должна быть 1..50000 кГц') # Верхняя граница соответствует доступному диапазону формы.
|
||||
if self.serial and not re.fullmatch(r'[0-9]+', self.serial): raise ValueError('Серийный номер J-Link должен содержать только цифры') # Идентификатор не должен включать дополнительные аргументы.
|
||||
if not 16 <= self.flash_kib <= 4096: raise ValueError('Размер Flash должен быть 16..4096 КиБ') # Ограничивает аппаратное чтение выбранным пользователем объёмом STM32.
|
||||
if self.uksvep and (self.device != 'STM32F103RC' or self.flash_kib != 256): raise ValueError('Профиль UKSVEP требует STM32F103RC и Flash 256 КиБ') # Не применяет карту F103RC и регистры BKP к другому семейству.
|
||||
|
||||
def arguments(self): # Возвращает отдельные аргументы QProcess без shell=True и строковой сборки команды.
|
||||
self.validate() # Публичный метод безопасен также вне GUI.
|
||||
args = ['-device', self.device, '-if', self.interface, '-JTAGConf', '-1,-1', '-speed', str(self.speed_khz), '-autoconnect', '1', '-ExitOnError', '1', '-NoGui', '1'] # Явная цепочка исключает интерактивный JTAGConf, преждевременно завершавший batch с кодом ноль.
|
||||
return (['-USB', self.serial] if self.serial else []) + args # Серийный номер закрепляет конкретный программатор при нескольких подключениях.
|
||||
|
||||
|
||||
def find_jlink(): # Обнаруживает существующий инструмент и ничего не скачивает в систему.
|
||||
found = shutil.which('JLink.exe') or shutil.which('JLinkExe') # PATH может содержать также одноимённый сборщик модулей Java JDK.
|
||||
candidates = ['C:/Keil_v5_41/ARM/Segger/JLink.exe', 'C:/Keil_v5/ARM/Segger/JLink.exe'] # Проверенные каталоги SEGGER в Keil имеют приоритет над неоднозначным PATH.
|
||||
for variable in ('ProgramFiles', 'ProgramFiles(x86)'): # SEGGER может быть установлен в 32- или 64-разрядный каталог Windows.
|
||||
if os.environ.get(variable): candidates.append(str(Path(os.environ[variable]) / 'SEGGER/JLink/JLink.exe')) # Не читает реестр и не требует прав администратора.
|
||||
if found and not any((Path(found).parent / name).is_file() for name in ('java.exe', 'java', 'jmod.exe', 'jmod')): candidates.append(found) # Инструмент из bin Java JDK не является программатором SEGGER и не должен выбираться автоматически.
|
||||
return next((str(Path(item)) for item in candidates if item and Path(item).is_file()), '') # Пустой результат предлагает явный выбор executable в форме.
|
||||
|
||||
|
||||
def prepare_image(image, options): # Проверяет выбранный ранее FirmwareImage и сохраняет разреженную адресацию HEX.
|
||||
options.validate() # Настройки проверяются до чтения внешних файлов и доступа к программатору.
|
||||
path = Path(image.path) # Образ может прийти как из формы, так и из Публикатора.
|
||||
if path.stat().st_size > 16 * 1024 * 1024: raise ValueError('Файл прошивки превышает 16 МиБ') # Ограничение покрывает 4 МиБ данных Intel HEX с текстовыми накладными расходами.
|
||||
raw = path.read_bytes() # Читает единственный снимок исходника, который затем сравнивается с подтверждённым выбором.
|
||||
if path.suffix.lower() == '.hex': # HEX уже содержит абсолютные адреса всех отдельных диапазонов Flash.
|
||||
flat, base = contiguous_image(raw, options.flash_kib * 1024) # Канонический parser проверяет checksum, пересечения и полный адресный размах.
|
||||
if flat != image.data or base != image.base_address: raise ValueError('Файл или адрес HEX изменён после выбора; выберите образ заново') # Не записывает новый файл под видом ранее выбранного образа и не смещает HEX.
|
||||
segments = parse_intel_hex(raw) # Разрывы остаются отсутствующими записями, а не превращаются в байты FF для loadfile.
|
||||
elif path.suffix.lower() == '.bin': # BIN требует явно указанного адреса, поскольку сам его не хранит.
|
||||
if raw != image.data: raise ValueError('BIN изменён после выбора; выберите образ заново') # Не использует устаревший SHA-256 формы для другого содержимого.
|
||||
segments = [HexSegment(image.base_address, raw)] # Адрес из формы относится к началу полезных байтов BIN.
|
||||
else: raise ValueError('Для J-Link выберите Intel HEX или BIN') # OUT/ELF не обходят единый проверяемый план записи Flash.
|
||||
if not segments or any(not part.data or part.start < FLASH_BASE or part.end >= FLASH_BASE + options.flash_kib * 1024 for part in segments): raise ValueError('Образ выходит за выбранный объём Flash STM32') # Не допускает запись RAM, option bytes или соседней памяти.
|
||||
if options.uksvep: # Защита и manifest относятся только к конкретному порту UKSVEP.
|
||||
if any(part.start < UKS_PARAMS + 2048 and part.end >= UKS_PARAMS for part in segments): raise ValueError('Образ затрагивает страницу параметров UKSVEP 0x0801F800') # Размер страницы F103RC равен 2048 байтам по профилю boot.
|
||||
app = [part for part in segments if part.start < UKS_PARAMS and part.end >= UKS_APP] # Находит приложение без включения загрузчика и служебной страницы.
|
||||
if app: # Запись одного app HEX также должна обновить CRC, иначе постоянный boot не разрешит запуск.
|
||||
if len(app) != 1 or app[0].start != UKS_APP or app[0].end >= UKS_PARAMS: raise ValueError('Для UKSVEP нужен полный непрерывный образ приложения с 0x08008000') # Не выдаёт фрагментарный патч или смешанный BIN за полное приложение.
|
||||
data = app[0].data # CRC считается по полезной длине приложения, без промежутков Flash.
|
||||
if len(data) < 8: raise ValueError('В приложении отсутствует таблица векторов Cortex-M3') # Два первых слова содержат MSP и Reset_Handler.
|
||||
sp, pc = struct.unpack_from('<II', data) # Векторы Cortex-M3 хранятся в little-endian.
|
||||
if sp & 7 or not 0x20000000 < sp <= UKSVEP_F103RC_BOOT['stack_end'] or not pc & 1 or not UKS_APP <= (pc & ~1) < UKS_APP + len(data): raise ValueError('Неверные векторы приложения UKSVEP') # Требует SRAM 48 КиБ, выравнивание стека и Thumb-вход внутрь образа.
|
||||
crc = zlib.crc32(data) & 0xFFFFFFFF # IEEE CRC32 совпадает с uks_boot_installed_valid() production-порта.
|
||||
manifest = struct.pack('<6I', 0x554B5356, len(data), crc, ~len(data) & 0xFFFFFFFF, ~crc & 0xFFFFFFFF, UKSVEP_F103RC_BOOT['product']) # Magic и шесть слов заданы UKS_META_MAGIC/UKS_META_WORDS в boot_port.c.
|
||||
existing = [part for part in segments if part.start < UKS_META + 2048 and part.end >= UKS_META] # Проверяет также явный manifest установочного HEX.
|
||||
if existing and (len(existing) != 1 or existing[0].start != UKS_META or existing[0].data != manifest): raise ValueError('Manifest UKSVEP не соответствует приложению') # Не скрывает неверную служебную запись готового комплекта.
|
||||
if not existing: segments.append(HexSegment(UKS_META, manifest)) # Для app-only JTAG формирует ту же запись активации, что UART/CAN boot.
|
||||
return tuple(sorted(segments, key=lambda item: item.start)) # Неизменяемый отсортированный снимок используется для loadfile и последующего readback.
|
||||
|
||||
|
||||
def encode_hex(segments): # Сохраняет только фактические диапазоны плана без заполнения защищённых разрывов.
|
||||
lines, upper = [], None # Старшие шестнадцать бит записываются при каждом переходе между областями 64 КиБ.
|
||||
def record(address, kind, data): # Стандартная строка Intel HEX содержит длину, адрес, тип, данные и дополнение суммы до нуля.
|
||||
body = bytes((len(data), address >> 8, address & 255, kind)) + data # Адрес внутри записи ограничен текущим окном 64 КиБ.
|
||||
return ':' + (body + bytes((-sum(body) & 255,))).hex().upper() # Проверяемая checksum делает снимок пригодным для независимого J-Link parser.
|
||||
for part in segments: # Каждый диапазон начинает адресованные данные без добавления промежутков.
|
||||
offset = 0 # Положение выражено в байтах текущего сегмента.
|
||||
while offset < len(part.data): # Не создаёт пустых записей в конце последнего блока.
|
||||
address = part.start + offset # Вычисляет абсолютный адрес следующей порции.
|
||||
if address >> 16 != upper: # Type 04 обновляет старшую половину линейного адреса.
|
||||
upper = address >> 16 # Последующие data-записи используют именно это окно.
|
||||
lines.append(record(0, 4, upper.to_bytes(2, 'big'))) # Порядок байтов type 04 задан Intel HEX и не зависит от Cortex-M.
|
||||
count = min(16, len(part.data) - offset, 0x10000 - (address & 0xFFFF)) # Строка не пересекает границу окна даже при невыровненном начале.
|
||||
lines.append(record(address & 0xFFFF, 0, part.data[offset:offset + count])) # Добавляет только реальные байты исходного диапазона.
|
||||
offset += count # Следующая строка продолжает непрерывные данные данного сегмента.
|
||||
return '\n'.join(lines + [record(0, 1, b'')]) + '\n' # EOF завершает UTF-8/ASCII-снимок с обычными переводами строк.
|
||||
|
||||
|
||||
def quote_path(path): # Экранирование J-Link command file отличается от аргументов системного процесса.
|
||||
value = Path(path).resolve().as_posix() # Абсолютные пути исключают зависимость backup от рабочего каталога.
|
||||
if any(char in value for char in ('"', '\r', '\n')): raise ValueError('Путь J-Link не должен содержать кавычки или переносы строк') # Не позволяет имени каталога стать дополнительной командой Commander.
|
||||
return '"' + value + '"' # Пробелы и кириллица остаются частью одного имени файла.
|
||||
|
||||
|
||||
def programming_script(folder, options): # Первый этап сохраняет backup и readback, оставляя ядро остановленным до независимой проверки.
|
||||
folder = Path(folder) # Все артефакты принадлежат отдельному каталогу одной операции.
|
||||
length = hex(options.flash_kib * 1024) # SaveBin принимает длину полного Flash в байтах.
|
||||
return '\n'.join(['ExitOnError 1', 'r', 'h', f'savebin {quote_path(folder / "before.bin")} 0x08000000 {length}', f'loadfile {quote_path(folder / "image.hex")}', f'savebin {quote_path(folder / "after.bin")} 0x08000000 {length}', 'q', '']) # Полного erase нет; loadfile программирует разреженный проверенный HEX с собственной Verify.
|
||||
|
||||
|
||||
def verify_readback(segments, options, before, after): # Проверяет аппаратные снимки независимо от кода возврата Commander.
|
||||
if len(before) != options.flash_kib * 1024 or len(after) != len(before): raise ValueError('J-Link не сохранил полный backup/readback Flash; запуск отменён') # Нулевой код интерактивно прерванного Commander не считается успехом.
|
||||
for part in segments: # Каждая реально записываемая область должна совпасть побайтно.
|
||||
offset = part.start - FLASH_BASE # Смещение внутри полного аппаратного снимка.
|
||||
if after[offset:offset + len(part.data)] != part.data: raise ValueError(f'Ошибка проверки Flash по адресу 0x{part.start:08X}; запуск отменён') # Не запускает MCU после неполного программирования.
|
||||
if options.uksvep: # Проверяет сохранность старых настроек именно там, где их хранит UKSVEP.
|
||||
offset = UKS_PARAMS - FLASH_BASE # Страница параметров находится между boot/app и manifest.
|
||||
if before[offset:offset + 2048] != after[offset:offset + 2048]: raise ValueError('Изменилась страница параметров UKSVEP; запуск отменён') # Проверка касается реальной Flash, а не только отсутствия области в HEX.
|
||||
|
||||
|
||||
def start_script(options): # Второй этап выполняется только после успешной проверки readback на хосте.
|
||||
commands = ['ExitOnError 1', 'r'] # Сброс снимает состояние RAM-алгоритма Flash, оставшееся после loadfile.
|
||||
if options.uksvep: commands += ['w4 0x4002101C 0x18000000', 'w4 0x40007000 0x00000100', 'w4 0x40006C24 0x00000000', 'w4 0x40006C28 0x00000000'] # F103RC: включение PWR/BKP, DBP bit8 и очистка DR9/DR10 убирают старый одноразовый запрос boot без стирания резервного домена.
|
||||
return '\n'.join(commands + ['g', 'q', '']) # Возобновляет выполнение после reset и освобождает программатор без повторной записи Flash.
|
||||
@@ -13,13 +13,14 @@ def decode_gas167(frame, node):
|
||||
result.protocol = "УМП %d / ProtoCAN GAS 167/v1" % node
|
||||
if not frame.ide or frame.rtr or frame.is_error:
|
||||
return result
|
||||
if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE,
|
||||
pc.MsgType.MODBUS_HOLDING):
|
||||
if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE, pc.MsgType.MODBUS): # Текущий ProtoCAN использует единый тип Modbus 0x5; отдельного MODBUS_HOLDING больше нет.
|
||||
return result
|
||||
if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE:
|
||||
start = result.id.body
|
||||
else:
|
||||
start, _count = pc.split_modbus(result.id.body)
|
||||
start, _count, function = pc.split_modbus(result.id.body) # Современный Body возвращает также функцию, определяющую банк регистров.
|
||||
if function != pc.ModbusFunction.HOLDING: # Ручная карта GAS 167/v1 относится к прежнему банку Holding, а не к дискретам или входным регистрам Modbus.
|
||||
return result # Остальные банки сохраняют штатную расшифровку ProtoCAN без ошибочной подстановки адресов карты УМП.
|
||||
words = result.registers
|
||||
if words is None:
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Проверки адресованной прошивки и защиты данных без подключения реального MCU.""" # Подмена касается только файлов снимков, production-план остаётся настоящим.
|
||||
from dataclasses import replace # Позволяет воспроизводить неверные настройки и адрес BIN независимо от формы.
|
||||
from pathlib import Path # Тесты создают все входы только во временном каталоге pytest.
|
||||
import struct # Создаёт корректные Cortex-M3-векторы и проверяет настоящий формат manifest UKSVEP.
|
||||
import zlib # Независимо вычисляет ожидаемый CRC32 приложения.
|
||||
import pytest # Параметры покрывают ошибки до первой аппаратной записи.
|
||||
from firmware_image.core import HexSegment, parse_intel_hex # Канонический parser проверяет сформированный снимок для Commander.
|
||||
from set_devices.firmware import FirmwareImage # Испытывает тот же тип образа, который передаёт SETGUI.
|
||||
from set_devices.stm32_jtag import JLinkOptions, FLASH_BASE, UKS_APP, UKS_PARAMS, UKS_META, prepare_image, encode_hex, programming_script, start_script, verify_readback # Проверяет публичную переносимую реализацию templates.
|
||||
from set_devices import stm32_jtag as module # Позволяет изолировать поиск установленного программатора от настоящего PATH Windows.
|
||||
|
||||
|
||||
def app_image(tmp_path, extra=()): # Создаёт небольшой корректный app HEX и необязательные отдельные диапазоны.
|
||||
data = struct.pack('<II', 0x2000C000, UKS_APP + 9) + bytes(range(64)) # MSP соответствует 48-КиБ SRAM; Thumb Reset_Handler находится внутри payload.
|
||||
path = tmp_path / 'app.hex' # Исходный файл не содержит служебного manifest, как обычный postbuild приложения.
|
||||
path.write_text(encode_hex([HexSegment(UKS_APP, data), *extra]), encoding='utf-8') # Реальный Intel HEX проходит штатную загрузку FirmwareImage.
|
||||
return FirmwareImage.load(path) # Позволяет проверять также сохранность выбора и отказ при изменении файла.
|
||||
|
||||
|
||||
def test_app_only_gets_production_manifest_and_sparse_snapshot(tmp_path): # JTAG app-only обязан остаться запускаемым постоянным загрузчиком.
|
||||
image, options = app_image(tmp_path), JLinkOptions() # Использует проверенный профиль UKSVEP STM32F103RC.
|
||||
plan = prepare_image(image, options) # Production-план автоматически добавляет запись активации приложения.
|
||||
assert [(part.start, len(part.data)) for part in plan] == [(UKS_APP, 72), (UKS_META, 24)] # Параметры и разрыв не превращаются в данные для записи.
|
||||
magic, size, crc, nsize, ncrc, product = struct.unpack('<6I', plan[-1].data) # Формат должен читаться настоящим boot_port.c без адаптации.
|
||||
assert (magic, size, crc, nsize, ncrc, product) == (0x554B5356, 72, zlib.crc32(image.data), ~72 & 0xFFFFFFFF, ~zlib.crc32(image.data) & 0xFFFFFFFF, 0x5606) # Проверяет взаимные инверсии и аппаратный Product UKSVEP.
|
||||
assert tuple(parse_intel_hex(encode_hex(plan))) == plan # Снимок Commander сохраняет точные адреса и байты обоих диапазонов.
|
||||
assert not any(part.start <= UKS_PARAMS <= part.end for part in plan) # Страница настроек отсутствует в адресованных data-записях.
|
||||
|
||||
|
||||
def test_factory_image_with_boot_and_manifest_is_not_flattened(tmp_path): # Полный install HEX не должен стереть промежуточную страницу параметров.
|
||||
image = app_image(tmp_path, [HexSegment(FLASH_BASE, b'boot' * 64)]) # Адрес загрузчика отделён от приложения разрывом.
|
||||
plan = prepare_image(image, JLinkOptions()) # Сначала получает правильный manifest для готового установочного комплекта.
|
||||
image.path.write_text(encode_hex(plan), encoding='utf-8') # Моделирует опубликованный boot+app+manifest HEX.
|
||||
assert prepare_image(FirmwareImage.load(image.path), JLinkOptions()) == plan # Повторная подготовка не добавляет FF и не меняет готовый manifest.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('address', [UKS_PARAMS - 1, UKS_PARAMS, UKS_PARAMS + 2047]) # Касание любого края защищённой страницы запрещено.
|
||||
def test_parameter_page_overlap_is_rejected(tmp_path, address): # Ошибка выявляется до обращения к J-Link.
|
||||
image = app_image(tmp_path, [HexSegment(address, b'xx')]) # Два байта проверяют также вход в страницу с предыдущего адреса.
|
||||
with pytest.raises(ValueError, match='страницу параметров'): prepare_image(image, JLinkOptions()) # Полный дамп нельзя случайно записать вместо установочного разреженного HEX.
|
||||
|
||||
|
||||
def test_stale_image_and_relocated_hex_are_rejected(tmp_path): # Выбор и подтверждение должны относиться к тем же байтам и адресам.
|
||||
image = app_image(tmp_path) # Сохраняет исходный снимок формы до вмешательства в файл.
|
||||
with pytest.raises(ValueError, match='адрес HEX'): prepare_image(replace(image, base_address=UKS_APP + 4), JLinkOptions()) # Явный адрес HEX нельзя переопределять как адрес BIN.
|
||||
image.path.write_text(encode_hex([HexSegment(UKS_APP, image.data + b'changed')]), encoding='utf-8') # Моделирует пересборку файла после выбора пользователем.
|
||||
with pytest.raises(ValueError, match='изменён'): prepare_image(image, JLinkOptions()) # До повторного выбора аппаратное программирование запрещено.
|
||||
|
||||
|
||||
def test_bin_needs_address_and_accepts_another_stm32(tmp_path): # Общий режим не применяет специальные адреса и manifest UKSVEP.
|
||||
path = tmp_path / 'raw.bin' # BIN не содержит собственной карты памяти.
|
||||
path.write_bytes(bytes(range(64))) # Для обычного MCU не навязывается структура приложения UKSVEP.
|
||||
image, options = FirmwareImage.load(path), JLinkOptions(device='STM32G474VE', flash_kib=512, interface='SWD', uksvep=False) # Использует другую физическую карту STM32.
|
||||
with pytest.raises(ValueError, match='объём Flash'): prepare_image(image, options) # Нулевой адрес BIN не означает RAM или неявное начало Flash.
|
||||
assert prepare_image(replace(image, base_address=FLASH_BASE), options) == (HexSegment(FLASH_BASE, image.data),) # Явный корректный адрес создаёт ровно один диапазон без постороннего manifest.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [{'device': 'STM32F103RC\ng'}, {'serial': '123 -erase'}, {'interface': 'UART'}, {'speed_khz': 0}, {'flash_kib': 0}, {'device': 'STM32F407VE'}]) # Проверяет интерфейс, размеры, профиль и внедрение дополнительных команд.
|
||||
def test_invalid_target_is_rejected(changes): # Поля будущего command file валидируются единым контрактом.
|
||||
with pytest.raises(ValueError): replace(JLinkOptions(), **changes).arguments() # Ошибка не требует создания процесса и не открывает программатор.
|
||||
|
||||
|
||||
def test_readback_rejects_partial_write_and_lost_parameters(tmp_path): # Ложный нулевой exit code не должен привести к запуску испорченного MCU.
|
||||
options = JLinkOptions() # Сохранность параметров проверяется для конкретного UKSVEP-профиля.
|
||||
plan = prepare_image(app_image(tmp_path), options) # Использует реальный production-план с manifest.
|
||||
before = bytes((index * 7) & 255 for index in range(256 * 1024)) # Полный backup содержит не-FF настройки, чтобы их потеря была заметной.
|
||||
after = bytearray(before) # Моделирует неизменённые области между реально записанными диапазонами.
|
||||
for part in plan: after[part.start - FLASH_BASE:part.start - FLASH_BASE + len(part.data)] = part.data # Применяет только адресованные данные плана.
|
||||
verify_readback(plan, options, before, after) # Полная корректная запись разрешает отдельный этап reset/run.
|
||||
after[UKS_PARAMS - FLASH_BASE] ^= 1 # Воспроизводит потерю одного бита существующих параметров.
|
||||
with pytest.raises(ValueError, match='параметров'): verify_readback(plan, options, before, after) # Сохранность protected page проверяется по аппаратным снимкам.
|
||||
after[UKS_PARAMS - FLASH_BASE] ^= 1 # Восстанавливает параметры для отдельной проверки ошибки программирования приложения.
|
||||
after[UKS_APP - FLASH_BASE + 8] ^= 1 # Повреждает один реально записываемый байт приложения.
|
||||
with pytest.raises(ValueError, match='Ошибка проверки'): verify_readback(plan, options, before, after) # Запуск запрещён даже при совпавшей длине обоих SaveBin.
|
||||
with pytest.raises(ValueError, match='полный backup'): verify_readback(plan, options, before, b'') # Преждевременный выход Commander с кодом ноль не считается успехом.
|
||||
|
||||
|
||||
def test_scripts_separate_programming_from_reset_run(tmp_path): # Архитектура не должна запускать ядро до проверки на хосте.
|
||||
options = JLinkOptions(serial='603000869') # Явный серийный номер должен сохраняться отдельным аргументом.
|
||||
program, run = programming_script(tmp_path / 'folder with spaces', options), start_script(options) # Пути с пробелами должны быть корректно заключены в кавычки.
|
||||
assert program.index('before.bin') < program.index('loadfile') < program.index('after.bin') # Backup выполняется до первой записи, readback — после неё.
|
||||
assert '\ng\n' not in program and '\ng\n' in run and 'loadfile' not in run # Отдельный проверенный этап содержит только reset/служебные регистры/run.
|
||||
assert 'erase' not in program.lower() # Не вводит массовое стирание через новую общую реализацию.
|
||||
assert options.arguments()[:2] == ['-USB', '603000869'] # Выбранный J-Link не заменяется другим подключённым адаптером.
|
||||
assert '-JTAGConf' in options.arguments() and '-1,-1' in options.arguments() # Batch не должен остановиться на интерактивном запросе цепочки JTAG.
|
||||
|
||||
|
||||
def test_hex_encoder_handles_unaligned_64k_boundary(): # Граничный адрес внутри сегмента не должен переполнить 16-битное поле записи.
|
||||
segments = (HexSegment(0x0800FFF9, bytes(range(40))),) # Невыровненный старт пересекает окно 64 КиБ внутри первой порции.
|
||||
assert tuple(parse_intel_hex(encode_hex(segments))) == segments # Независимый C-parser подтверждает корректную смену Extended Linear Address.
|
||||
|
||||
|
||||
def test_autodetection_does_not_select_java_jlink(monkeypatch): # Windows PATH может раньше SEGGER содержать одноимённый инструмент JDK.
|
||||
java = 'C:/jdk/bin/JLink.exe' # Имя совпадает без учёта регистра с программатором SEGGER.
|
||||
monkeypatch.setattr(module.shutil, 'which', lambda name: java) # Установленный в системе J-Link не участвует в этой проверке.
|
||||
monkeypatch.setattr(Path, 'is_file', lambda path: path.as_posix() in (java, 'C:/jdk/bin/java.exe')) # Моделирует только bin JDK и отсутствие Keil/SEGGER.
|
||||
assert module.find_jlink() == '' # Общая форма должна запросить настоящий executable вместо запуска Java.
|
||||
monkeypatch.setattr(Path, 'is_file', lambda path: path.as_posix() in (java, 'C:/jdk/bin/java.exe', 'C:/Keil_v5_41/ARM/Segger/JLink.exe')) # Добавляет именно установленный SEGGER, не меняя неоднозначный PATH.
|
||||
assert module.find_jlink().replace('\\', '/') == 'C:/Keil_v5_41/ARM/Segger/JLink.exe' # Проверенный каталог Keil получает приоритет над JDK.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Проверки тонкого порта ВЭП на настоящей C DLL templates.""" # Транспорт и Qt здесь не участвуют.
|
||||
import pytest # Параметризует все физические адреса и неверные аргументы.
|
||||
from protocan.uksvep import UksvepCan # Проверяет общий production-порт без отдельного Python codec.
|
||||
|
||||
@pytest.fixture(scope="module") # Все сценарии используют одну загруженную нативную библиотеку.
|
||||
def core(): # Отсутствие C-профиля должно приводить к ошибке теста, а не fallback.
|
||||
return UksvepCan() # Привязывает те же экспорты, что панель SETGUI.
|
||||
|
||||
@pytest.mark.parametrize("device", range(16)) # Четыре перемычки задают все шестнадцать адресов.
|
||||
def test_routes_and_mode(core, device): # Разделяет DeviceID, Mode и legacy CAN-пару.
|
||||
assert core.can_id(device) == 0x001CE020 + device # Команды идут по маршруту Setup_CAN_addr.
|
||||
assert core.can_id(device, True) == 0x001CE000 + device # Данные устройства не содержат командное смещение.
|
||||
fields = {item.address: item for item in core.fields(device)} # Карта адресов приходит только из C.
|
||||
assert fields[126].initial == device + 1 and fields[100].maximum == 10 # Mode и десятиотсчётная яркость совпадают с прошивкой.
|
||||
|
||||
@pytest.mark.parametrize("device,address,values", [(16, 0, (1,)), (-1, 0, (1,)), (0, -1, (1,)), (0, 128, (1,)), (0, 127, (1, 2)), (0, 0, ()), (0, 0, (-1,)), (0, 0, (65536,))]) # Все ошибки должны обнаруживаться до ctypes-обрезания.
|
||||
def test_invalid_arguments(core, device, address, values): # Неверный ввод не адресует соседнюю плату или слово.
|
||||
with pytest.raises(ValueError): # Требует явный отказ вместо скрытого modulo.
|
||||
core.frames(device, address, values) # Использует реальный путь формирования GUI-снимка.
|
||||
|
||||
def test_golden_and_sparse_rx(core): # Эталон независимо проверяет endian, маску и пропущенное слово.
|
||||
frames = core.frames(5, 8, (0x1234, 0xABCD, 0x8001)) # Значения различаются во всех значащих позициях.
|
||||
assert [(frame.can_id, frame.data) for frame in frames] == [(0x001CE025, bytes.fromhex("AB CD 80 01 E0 08 12 34"))] # Фиксирует точный wire-vector STM32-проекта.
|
||||
packet = core.decode(5, 0x001CE005, bytes.fromhex("AA 55 33 44 A0 7D 11 22")) # Маска101 разрешает R125 и R127.
|
||||
assert packet.from_device and packet.values == ((125, 0x1122), (127, 0x3344)) # Отсутствующий R126 не затирается чужим значением.
|
||||
assert core.decode(5, 0xBA0018, frames[0].data) is None # Другой ВЭП Бальзам не смешивается с UKSVEP.
|
||||
with pytest.raises(ValueError): # Повреждённый собственный пакет должен считаться ошибкой RX.
|
||||
core.decode(5, 0x001CE005, frames[0].data[:7]) # DLC7 недостаточен для исторического payload.
|
||||
|
||||
def test_fields_inputs_and_special_polarity(core): # Карта и расчёт не должны копировать семантику задатчика.
|
||||
fields = {item.address: item for item in core.fields()} # Все defaults взяты из C-профиля package.h.
|
||||
assert set(fields) == {0, 1, 2, 8, 9, 23, 48, 100, 101, 102, 125, 126, 127} # Не добавляет несуществующие аналоговые измерения.
|
||||
assert fields[8].initial == 0x277F and fields[9].initial == 0x200 # Маски повторяют Default_params.
|
||||
assert "PB12" in core.names("inputs")[9] and "активный 1" in core.names("inputs")[9] # Единственный прямой вход имеет особую полярность.
|
||||
assert core.names("commands")[0] == "Тест ламп" and not core.names("commands")[4] # Команды задатчика и резервные биты не подставляются.
|
||||
assert core.evaluate(0x247F, 0x277F, 0x200) == (0, 0) # Исправное состояние не вызывает ложных аварий.
|
||||
assert core.evaluate(0x27FF, 0, 0) == (0x300, 0x200) # Разряд и 24 В сохраняют особую семантику без масок; заряд не влияет.
|
||||
@@ -77,6 +77,11 @@ requires Windows 10/11; it does not add Windows 7 support.
|
||||
|
||||
## SET decoders
|
||||
|
||||
The **Sigma-delta** decoder (`sigma_delta`) reconstructs a clocked one-bit
|
||||
DATA/CLK stream using sinc1/2/3 decimation. It includes sampling-edge and polarity
|
||||
selection, normalized samples, voltage scaling and clock/output-rate annotations.
|
||||
See [sigma-delta settings](decoders/sigma_delta/README.md).
|
||||
|
||||
`decoders/` contains the sigrok front ends for `gate_driver_timing` (1SP0635 /
|
||||
1SD536F2), `set_uart` (SET v1/v2, ProtoCAN bridge), `set_can` (ProtoCAN, SET v2,
|
||||
Balsam), and `pm35_uart` (TMS320F28335 MODBUS 03/06).
|
||||
@@ -226,8 +231,35 @@ section headings. The changes are packaged in `decoder-panel.patch`.
|
||||
Добавление пакетов обновляет Python-декодеры без пересборки EXE;
|
||||
изменение раздела «Пользовательские» применяется при следующей нативной сборке.
|
||||
|
||||
## Фазные токи асинхронного двигателя
|
||||
|
||||
**PWM motor currents** (`pwm_motor`) оценивает линейные Ia/Ib/Ic в амперах
|
||||
по ШИМ двухуровневого или трёхуровневого инвертора, полному напряжению звена
|
||||
и параметрам короткозамкнутого асинхронного двигателя. Входы: три верхние
|
||||
команды, шесть команд P/N или двенадцать затворов NPC. Скорость ротора
|
||||
задаётся постоянной; доступны звезда/треугольник и начальные токи/поток.
|
||||
|
||||
[Назначение входов, параметры и ограничения](decoders/pwm_motor/README.md).
|
||||
Цветные кривые и шкала ампер требуют `motor-currents.patch`, который штатный
|
||||
рецепт применяет после остальных графических патчей. Адаптер и общее ядро
|
||||
входят в общую ведомость установки. Проверка:
|
||||
`python -m unittest discover -s tools/dsview/tests -p test_pwm_motor.py -v`.
|
||||
Нативная проверка готовой DSL-записи:
|
||||
`python tools/dsview/tests/check_motor_native.py --runtime <dsview> --capture <motor.dsl> --output <artifacts>`.
|
||||
|
||||
## PWM waveform / ШИМ → синус
|
||||
|
||||
Double-click an individual decoder plot to open it in a separate scope window.
|
||||
The decoder label context menu offers the same action for each visible plot.
|
||||
Combined A/B/C opens as one scope; split phases, theta and carrier open separately.
|
||||
Wheel zoom, drag to pan, full-record fit, amplitude fit, physical Y range and
|
||||
phase visibility affect only the extra window. It shares the decoder's indexed
|
||||
results and follows input colours; removing the source decoder closes the scope.
|
||||
The build includes `decoder-scope.patch` and `native/decoder_scope.h`.
|
||||
|
||||
Native interaction check (a 120000-sample DSL with the motor decoder last):
|
||||
`python tools/dsview/tests/check_decoder_scope_native.py --runtime <dsview> --capture <example.dsl> --output <artifacts>`.
|
||||
|
||||
Декодер восстанавливает кривые заполнения для 1–3 цифровых входов.
|
||||
В SETGUI он находится в меню **Анализаторы SET → ШИМ → синус · 1–3 канала**.
|
||||
[Настройки, режимы и ограничения](decoders/pwm_waveform/README.md).
|
||||
|
||||
@@ -31,6 +31,9 @@ def recipe_digest():
|
||||
recipe = Path(__file__).resolve().parent
|
||||
inputs = [recipe / 'build.py', recipe / 'protocol-files.json', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h', recipe / 'pwm-waveform.patch', recipe / 'native/pwm_waveform.h'] # Ведомость и нативные патчи входят в версию рецепта.
|
||||
inputs += [source for source, _ in protocol_payload()] # Изменение парсера, адаптера или JSON-карты делает установленный компонент устаревшим.
|
||||
inputs += [recipe / 'zynq-ethernet/ethernet.patch'] # Геометрия цифровых дорожек и анализаторов входит в Ethernet-патч, поэтому его изменение также требует пересборки runtime.
|
||||
inputs += [recipe / 'scope-channel.patch', recipe / 'native/scope_demo.h', recipe / 'native/scope_trace.h', recipe / 'motor-currents.patch'] # Шкала фазных токов и поддержка нового графического декодера входят в версию нативного компонента.
|
||||
inputs += [recipe / 'decoder-scope.patch', recipe / 'native/decoder_scope.h'] # Отдельные окна выбранных осциллограмм входят в версию рецепта и требуют обновления нативного компонента.
|
||||
digest = hashlib.sha256()
|
||||
for item in inputs:
|
||||
digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0')
|
||||
@@ -85,6 +88,9 @@ def main():
|
||||
('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch', 'pwm-waveform.patch')]
|
||||
if args.extra_patch:
|
||||
patches.append(args.extra_patch.resolve())
|
||||
patches.append(Path(__file__).with_name('scope-channel.patch')) # Аналоговые дорожки подключаются после настройки цифрового интерфейса, включая Ethernet-патч.
|
||||
patches.append(Path(__file__).with_name('motor-currents.patch')) # Токовые кривые расширяют готовую компоновку PWM после базовых и Ethernet-изменений.
|
||||
patches.append(Path(__file__).with_name('decoder-scope.patch')) # Независимые окна подключаются после окончательной компоновки графических строк и фазных токов.
|
||||
# Later patches may change the context of earlier ones. Check and apply
|
||||
# their combined diff without touching the checkout's real index.
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
@@ -98,6 +104,9 @@ def main():
|
||||
capture_output=True).returncode:
|
||||
subprocess.run(patch_cmd, cwd=source, input=combined, check=True)
|
||||
shutil.copy2(Path(__file__).with_name('native') / 'pwm_waveform.h', source / 'DSView/pv/view/pwm_waveform.h')
|
||||
for name in ('scope_demo.h', 'scope_trace.h'): # Переносимая модель и Qt-дорожка устанавливаются рядом с базовым Trace.
|
||||
shutil.copy2(Path(__file__).with_name('native') / name, source / 'DSView/pv/view' / name) # Копируем канонические исходники без изменения внешнего проекта Zynq.
|
||||
shutil.copy2(Path(__file__).with_name('native') / 'decoder_scope.h', source / 'DSView/pv/view/decoder_scope.h') # Виджет отдельного окна поставляется как обычный C++11-заголовок без отдельного процесса и генерации moc.
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = os.pathsep.join([str(prefix/'bin'), str(args.msys_root/'usr/bin'), env.get('PATH', '')])
|
||||
env['PKG_CONFIG_PATH'] = str(prefix/'lib/pkgconfig')
|
||||
@@ -150,7 +159,18 @@ def main():
|
||||
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch', 'pwm-waveform.patch'):
|
||||
shutil.copy2(Path(__file__).with_name(patch_name), output/patch_name)
|
||||
shutil.copy2(Path(__file__).with_name('native') / 'pwm_waveform.h', output / 'pwm_waveform.h')
|
||||
for name in ('scope_demo.h', 'scope_trace.h'): # Вместе с GPL-компонентом поставляется исходник новой демонстрационной дорожки.
|
||||
shutil.copy2(Path(__file__).with_name('native') / name, output / name) # Содержимое пакета совпадает с реально скомпилированными заголовками.
|
||||
shutil.copy2(Path(__file__).with_name('scope-channel.patch'), output / 'scope-channel.patch') # Патч меню и компоновки позволяет воспроизвести расширение на закреплённом upstream.
|
||||
shutil.copy2(Path(__file__).with_name('motor-currents.patch'), output / 'motor-currents.patch') # Исходники расширения токовых графиков поставляются вместе с GPL-компонентом.
|
||||
for name in ('decoder-scope.patch', 'native/decoder_scope.h'): # Исходники самостоятельного осциллографа нужны для воспроизводимости поставляемого GPL-компонента.
|
||||
shutil.copy2(Path(__file__).parent / name, output / Path(name).name) # Патч интеграции и заголовок виджета соответствуют скомпилированному расширению.
|
||||
(output / 'scope-channel-1').write_text('DSView Zynq demo scope channels 1\n', encoding='utf-8') # Маркер различает установленный компонент с добавлением осциллографических дорожек.
|
||||
(output/'SOURCE.txt').write_text('https://github.com/DreamSourceLab/DSView\nCommit: '+revision+'\nPatches: setgui-host.patch, decoder-panel.patch, windows-usb-events.patch, pwm-waveform.patch (pwm_waveform.h)\nLicense: GPL-3.0-or-later (COPYING)\n', encoding='utf-8')
|
||||
with (output / 'SOURCE.txt').open('a', encoding='utf-8') as manifest: # Происхождение нового источника сохраняется в пользовательском runtime.
|
||||
manifest.write('Scope: scope-channel.patch, scope_demo.h, scope_trace.h\nModel: zynq/demo/hdmi_usb_menu/scope_signal.h (scope_hz, scope_peak_v, scope_colors, scope_value_v, scope_interval_extrema)\n') # Указываем исходные параметры и функции для проверки соответствия проекту Zynq.
|
||||
manifest.write('Motor currents: motor-currents.patch; decoders/pwm_motor; common/setgui_decoders/pwm_motor.py\n') # Поставляемые исходники связывают графики ампер с единственной переносимой моделью двигателя.
|
||||
manifest.write('Detached decoder scopes: decoder-scope.patch; decoder_scope.h\n') # Манифест фиксирует отдельные окна конкретных графических областей декодеров.
|
||||
(output/'host-build.json').write_text(json.dumps({
|
||||
'recipe': recipe_digest(),
|
||||
'executable': hashlib.sha256((output/'DSView.exe').read_bytes()).hexdigest(),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
|
||||
# ШИМ 2/3 уровня → токи асинхронного двигателя
|
||||
|
||||
В SETGUI: **DSView → Анализаторы SET → ШИМ 2/3 уровня → токи асинхронного двигателя**.
|
||||
В списке декодеров DSView: **Пользовательские → PWM motor currents**.
|
||||
На общей временной шкале отображаются расчётные линейные **Ia, Ib, Ic в амперах**:
|
||||
A — голубой, B — жёлтый, C — зелёный. `Waveform layout` переключает
|
||||
совмещённый (`combined`) и раздельный (`split`) вид.
|
||||
|
||||
Это модельная оценка. Напряжения звена и ШИМ недостаточно, чтобы однозначно
|
||||
определить реальный ток: нужны параметры двигателя, скорость и начальное
|
||||
электромагнитное состояние. Значения по умолчанию — демонстрационный пример,
|
||||
они не являются параметрами подключённой машины.
|
||||
|
||||
## Входы и уровни
|
||||
|
||||
Обязательные `A/B/C P / Q1` — верхние команды трёх фаз.
|
||||
Режим выбирается вручную в `Inverter / Уровни и кодировка`.
|
||||
|
||||
| Режим | Назначенные входы | Соответствие напряжению полюса |
|
||||
|---|---|---|
|
||||
| `2-level` | 3 верхние команды | 1 → +Uверх, 0 → −Uниз; идеальная комплементарная пара |
|
||||
| `2-level` с нижними ключами | 3 верхние и 3 нижние команды | 10 → +Uверх, 01 → −Uниз; 00 → приближение обратных диодов |
|
||||
| `3-level P/N` | 3 команды P и 3 команды N | 10 → +Uверх, 00 → средняя точка, 01 → −Uниз; 11 недопустимо |
|
||||
| `3-level NPC` | 12 затворов: Q1, Q2, Q3, Q4 каждой фазы | 1100 → +Uверх, 0110 → средняя точка, 0011 → −Uниз |
|
||||
|
||||
В NPC физический порядок Q1…Q4 идёт сверху вниз по плечу. В диалоге сначала
|
||||
расположены Q1 всех фаз, затем Q4 всех фаз, затем Q2/Q3 каждой фазы.
|
||||
Назначать входы нужно по их именам, а не по порядку физических каналов прибора.
|
||||
Нижние команды двухуровневой схемы назначаются либо все три, либо ни одной.
|
||||
Для NPC необходимы все двенадцать входов. `Gate active level` инвертирует
|
||||
все назначенные входы одновременно.
|
||||
|
||||
`Total DC link` — полное напряжение между положительной и отрицательной
|
||||
шинами, **не напряжение одного конденсатора**. При Udc = 600 В и `Upper DC
|
||||
link = 50%` уровни равны +300, 0 и −300 В. При 60% уровни +360, 0 и −240 В.
|
||||
Напряжение считается постоянным на всей записи; пульсации звена по отдельному
|
||||
аналоговому каналу в этой версии не измеряются.
|
||||
|
||||
Токи интегрируются по фактическим длительностям состояний ключей. Одинаковые
|
||||
потенциалы всех фаз не создают ток плавающей звезды. Заполнение ШИМ не
|
||||
подменяется искусственным синусом и не сглаживается перед расчётом.
|
||||
Постоянные команды, включая 0/100%, также обрабатываются.
|
||||
|
||||
## Настройка двигателя
|
||||
|
||||
| Параметр | Смысл |
|
||||
|---|---|
|
||||
| `Winding` | `star` — звезда без нейтрального провода; `delta` — симметричный треугольник |
|
||||
| `Stator R` | Сопротивление **одной обмотки** статора, Ом |
|
||||
| `Rotor R referred` | Сопротивление ротора, приведённое к одной обмотке статора, Ом |
|
||||
| `Stator leakage` | Индуктивность рассеяния статора, мГн |
|
||||
| `Rotor leakage referred` | Индуктивность рассеяния ротора, приведённая к статору, мГн |
|
||||
| `Magnetizing` | Взаимная индуктивность намагничивания, мГн |
|
||||
| `Pole pairs` | Целое число пар полюсов, не число полюсов |
|
||||
| `Fixed rotor speed` | Механическая скорость ротора, об/мин; отрицательное значение означает обратное вращение |
|
||||
| `Initial rotor flux linkage` | Начальное потокосцепление ротора, Вб·виток = В·с |
|
||||
| `Initial flux angle` | Начальный угол потока относительно оси A, градусы |
|
||||
| `Initial line Ia/Ib` | Начальные линейные токи; Ic = −Ia−Ib, А |
|
||||
| `Plot +/- range` | Полушкала графика, А; не ограничивает физический расчёт |
|
||||
| `Plot sampling` | Частота дополнительных точек графика, Гц; каждый фактический фронт также создаёт точку |
|
||||
|
||||
Параметры обмотки и параметры эквивалентной звезды нельзя смешивать.
|
||||
Для `delta` задайте параметры фактической обмотки треугольника; декодер
|
||||
сам преобразует токи ветвей в линейные токи проводов инвертора.
|
||||
Ток нулевой последовательности, циркулирующий внутри треугольника,
|
||||
в симметричной модели не учитывается.
|
||||
|
||||
При поле 50 Гц и двух парах полюсов синхронная скорость равна 1500 об/мин.
|
||||
Например, 1440 об/мин задаёт скольжение 4%. `speed = 0` описывает включение
|
||||
с неподвижным ротором и нарастание потока. **Скорость остаётся постоянной**:
|
||||
механический разгон, момент нагрузки и инерция в этой версии не рассчитываются.
|
||||
Поэтому для участка разгона нужна отдельная оценка скорости; одна постоянная
|
||||
настройка не воспроизводит весь механический переходный процесс.
|
||||
|
||||
Нулевые начальные токи и поток соответствуют невозбуждённому двигателю.
|
||||
Если запись начинается в рабочем режиме, начальный участок содержит
|
||||
переходный процесс модели: задайте известные начальные условия или
|
||||
анализируйте установившуюся часть достаточно длинной записи.
|
||||
|
||||
## Уравнения и точность
|
||||
|
||||
Используется линейная αβ-модель короткозамкнутого асинхронного двигателя
|
||||
в неподвижных осях статора. Для комплексных векторов тока `is` и
|
||||
потокосцепления ротора `ψr`:
|
||||
|
||||
```text
|
||||
Lr = Llr + Lm
|
||||
k = Lm / Lr
|
||||
Lσ = Lls + Lm·Llr/Lr
|
||||
ωr = 2π · pole_pairs · speed_rpm / 60
|
||||
dψr/dt = Rr·k·is − (Rr/Lr − jωr)·ψr
|
||||
dis/dt = (vs − Rs·is − k·dψr/dt) / Lσ
|
||||
```
|
||||
|
||||
Между фронтами используется аналитическая матричная экспонента для
|
||||
постоянного напряжения. Изменение частоты точек графика не меняет решение
|
||||
на неизменном участке. Чем выше частота точек, тем точнее криволинейный
|
||||
переход передаётся отрезками на экране. Интервалы изменения проводимости
|
||||
обратных диодов определяются приближённо на этой же сетке.
|
||||
|
||||
Физическая основа модели и обозначения параметров:
|
||||
[MathWorks: Induction Motor](https://www.mathworks.com/help/mcb/ref/inductionmotor.html),
|
||||
[MathWorks: Induction Machine Squirrel Cage](https://www.mathworks.com/help/simscape-electrical/ref/inductionmachinesquirrelcage.html).
|
||||
|
||||
Не моделируются магнитное насыщение, потери в стали, температурное изменение
|
||||
сопротивлений, скин-эффект ротора, падения напряжения на силовых ключах,
|
||||
паразитные ёмкости и высокочастотные выбросы. Без параметров конкретного
|
||||
двигателя и проверки по датчику тока нельзя заявить погрешность относительно
|
||||
реального агрегата.
|
||||
|
||||
Строка `Model / gate warnings` помечает токи как расчётные. При недопустимой
|
||||
комбинации затворов используется последний определённый потенциал
|
||||
соответствующей фазы и выдаётся предупреждение: оценка тока на этом участке
|
||||
недостоверна. Интервалы полностью закрытого плеча отмечаются как приближение
|
||||
диодной проводимости. Частичные состояния NPC в dead time также помечаются
|
||||
как неоднозначные, их нельзя принимать за достоверную модель коммутации.
|
||||
|
||||
При выходе за `Plot +/- range` обрезается только изображение, появляется
|
||||
предупреждение; реальные амперы сохраняются в таблице и в Python-выходе
|
||||
`CURRENTS`. Увеличьте шкалу для просмотра полного размаха.
|
||||
|
||||
## Сборка и проверка
|
||||
|
||||
Ядро: `python/logic_analyzer/decoders/pwm_motor.py`. Адаптер и ядро входят
|
||||
в общую ведомость `protocol-files.json`, доступны штатному установщику.
|
||||
В обычном DSView выводятся числовые аннотации. Для цветных кривых и шкалы
|
||||
в амперах требуется пересборка нативного DSView штатным рецептом с
|
||||
`pwm-waveform.patch` и `motor-currents.patch`.
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tools/dsview/tests -p test_pwm_motor.py -v
|
||||
```
|
||||
|
||||
Независимый эталон — комплексное сопротивление Т-схемы замещения:
|
||||
проверяются амплитуда и фаза на неподвижном роторе, рабочем скольжении,
|
||||
синхронной скорости, рекуперации и реверсе с допуском 0,2% относительно
|
||||
идеализированной схемы. Этот допуск относится к математической проверке,
|
||||
а не к точности восстановления реального тока.
|
||||
@@ -0,0 +1 @@
|
||||
from .pd import Decoder # Стандартная точка входа sigrok регистрирует декодер асинхронного двигателя.
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Фазные токи асинхронного двигателя из двух- или трёхуровневого ШИМ.""" # Аннотации работают и без расширенного графического рендерера.
|
||||
import math # Масштаб графика и частота выходных точек проверяются до начала декодирования.
|
||||
import sigrokdecode as srd # Штатный API DSView предоставляет цифровые входы и выходные аннотации.
|
||||
from common.setgui_decoders.pwm_motor import PwmMotor # Единственная реализация модели поставляется из переносимого ядра templates.
|
||||
|
||||
|
||||
class Decoder(srd.Decoder): # Адаптер только связывает события sigrok с общей физической моделью.
|
||||
api_version = 3 # DSView использует третью версию API ожидания фронтов.
|
||||
id = 'pwm_motor' # Постоянный идентификатор связывает меню, сохранённые DSL и нативный рендерер.
|
||||
name = 'PWM motor currents' # Короткое имя различает расчётные токи и существующую огибающую заполнения.
|
||||
longname = 'PWM 2/3-level / induction motor phase currents' # Полное имя доступно в поиске анализаторов DSView.
|
||||
desc = 'Estimated induction motor line currents in amperes from switching states; motor parameters required.' # Подсказка явно сообщает расчётный характер токов.
|
||||
license = 'gplv2+' # Лицензия совместима с остальными переносимыми декодерами поставки.
|
||||
inputs = ['logic'] # Декодер получает реальные логические затворные сигналы без базового UART или CAN.
|
||||
outputs = ['pwm_motor'] # Структурированный выход позволяет повторно использовать токи в стеке анализаторов.
|
||||
tags = ['Clock/timing', 'Util'] # Стандартные категории сохраняют совместимость со штатным поиском DSView.
|
||||
channels = tuple({'id': ch + '_p', 'name': ch.upper() + ' P / Q1', 'desc': 'Upper gate or positive-level command'} for ch in 'abc') # Верхние команды всех трёх фаз обязательны для восстановления линейных напряжений.
|
||||
optional_channels = tuple({'id': ch + '_n', 'name': ch.upper() + ' N / Q4', 'desc': 'Lower gate; required for 3-level modes'} for ch in 'abc') + tuple({'id': ch + '_q' + str(q), 'name': ch.upper() + ' Q' + str(q), 'desc': 'NPC inner gate; required in 3-level NPC'} for ch in 'abc' for q in (2, 3)) # Дополнительные входы поддерживают шесть команд P/N и двенадцать затворов NPC.
|
||||
options = ( # Физические параметры задаются для одной обмотки; роторные величины приведены к статору.
|
||||
{'id': 'topology', 'desc': 'Inverter / Уровни и кодировка', 'default': '2-level', 'values': ('2-level', '3-level P/N', '3-level NPC')}, # Режим определяет таблицу преобразования логических входов в полюсные напряжения.
|
||||
{'id': 'dc_voltage', 'desc': 'Total DC link / Полное Udc (V)', 'default': 560.0}, # Пример 560 В относится ко всему звену между шинами плюс и минус.
|
||||
{'id': 'midpoint_percent', 'desc': 'Upper DC link / Верхняя половина (%)', 'default': 50.0}, # 50 процентов соответствует двум равным конденсаторам трёхуровневого звена.
|
||||
{'id': 'connection', 'desc': 'Winding / Соединение обмоток', 'default': 'star', 'values': ('star', 'delta')}, # На графике в обоих случаях выводятся линейные токи проводов A, B, C.
|
||||
{'id': 'active_level', 'desc': 'Gate active level / Активный уровень', 'default': 'high', 'values': ('high', 'low')}, # Общая полярность применяется ко всем назначенным затворам.
|
||||
{'id': 'rs', 'desc': 'Stator R / R статора (ohm)', 'default': 1.0}, # Пример сопротивления одной обмотки статора в омах необходимо заменить параметром двигателя.
|
||||
{'id': 'rr', 'desc': 'Rotor R referred / R ротора приведённое (ohm)', 'default': 0.8}, # Сопротивление ротора задаётся приведённым к одной обмотке статора.
|
||||
{'id': 'lls_mh', 'desc': 'Stator leakage / L рассеяния статора (mH)', 'default': 5.0}, # Индуктивность рассеяния отличается от полной индуктивности обмотки.
|
||||
{'id': 'llr_mh', 'desc': 'Rotor leakage referred / L рассеяния ротора (mH)', 'default': 5.0}, # Значение ротора также должно быть приведено к статору.
|
||||
{'id': 'lm_mh', 'desc': 'Magnetizing / L намагничивания (mH)', 'default': 150.0}, # Взаимная индуктивность задаёт магнитный поток линейной ненасыщенной модели.
|
||||
{'id': 'pole_pairs', 'desc': 'Pole pairs / Число пар полюсов', 'default': 2}, # Две пары полюсов дают синхронную скорость 1500 об/мин при поле 50 Гц.
|
||||
{'id': 'speed_rpm', 'desc': 'Fixed rotor speed / Скорость ротора (rpm)', 'default': 1440.0}, # Постоянная механическая скорость задаёт скольжение; ноль моделирует неподвижный ротор.
|
||||
{'id': 'initial_flux', 'desc': 'Initial rotor flux linkage / Начальный поток (Wb)', 'default': 0.0}, # Нулевое начальное потокосцепление соответствует невозбуждённой машине.
|
||||
{'id': 'flux_angle', 'desc': 'Initial flux angle / Угол потока (deg)', 'default': 0.0}, # Угол начального вектора отсчитывается от оси фазы A к оси β.
|
||||
{'id': 'ia', 'desc': 'Initial line Ia / Начальный Ia (A)', 'default': 0.0}, # Ток A задаёт одно из двух независимых начальных состояний трёхпроводной системы.
|
||||
{'id': 'ib', 'desc': 'Initial line Ib / Начальный Ib (A)', 'default': 0.0}, # Третий ток автоматически равен минус сумме Ia и Ib.
|
||||
{'id': 'plot_range_a', 'desc': 'Plot +/- range / Шкала графика +/- (A)', 'default': 100.0}, # Масштаб меняет только отрисовку, а не расчёт или физическую амплитуду.
|
||||
{'id': 'output_hz', 'desc': 'Plot sampling / Точек графика в секунду (Hz)', 'default': 100000}, # Сетка 100 кГц дополняет обязательные точки каждого фронта и работает при постоянных уровнях.
|
||||
{'id': 'waveform_layout', 'desc': 'Waveform layout / Вид графиков', 'default': 'combined', 'values': ('combined', 'split')}, # Три фазных тока можно совместить или разнести по отдельным областям.
|
||||
) # Значения сохраняются штатным механизмом настроек и DSL-сессий.
|
||||
annotations = (('current-a', 'Estimated Ia (A)'), ('current-b', 'Estimated Ib (A)'), ('current-c', 'Estimated Ic (A)'), ('warnings', 'Model / gate warnings')) # Первые три класса используют контракт сегментов графического рендерера.
|
||||
annotation_rows = (('wave_a', 'Ia estimate (A)', (0,)), ('wave_b', 'Ib estimate (A)', (1,)), ('wave_c', 'Ic estimate (A)', (2,)), ('warnings', 'Model / gate warnings', (3,))) # Идентификаторы wave_a…wave_c поддерживают прежние цвета и индекс экстремумов.
|
||||
|
||||
def __init__(self): # Конструктор не обращается к частоте захвата до получения metadata.
|
||||
self.reset() # Каждый экземпляр анализатора начинает с независимого состояния двигателя.
|
||||
|
||||
def reset(self): # Повторное декодирование не должно использовать токи или поток предыдущей записи.
|
||||
self.samplerate, self.model, self.warning_start, self.warning_text = None, None, None, None # Диагностика и физическое состояние сбрасываются совместно.
|
||||
self.clipped = False # Превышение шкалы прежней записи не переносится в новый расчёт.
|
||||
|
||||
def metadata(self, key, value): # Движок сообщает фактическую частоту цифрового захвата.
|
||||
if key == srd.SRD_CONF_SAMPLERATE: # Остальные метаданные не участвуют в переводе отсчётов в секунды.
|
||||
self.samplerate = value # Частота применяется при создании модели после валидации настроек.
|
||||
|
||||
def start(self): # Выходные дескрипторы создаются штатным механизмом sigrok.
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN) # Аннотации доступны в таблице результатов и на графиках.
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON) # Структурированные данные содержат ненормированные амперы.
|
||||
|
||||
def _emit(self, event): # Представление сегмента не изменяет физических токов модели.
|
||||
if event is None: # Первый отсчёт или повторная временная отметка не образуют интервала.
|
||||
return # Нулевая длительность не создаёт ложную линию на графике.
|
||||
start, end, before, after = event # Время хранится в отсчётах, оба вектора тока заданы в амперах.
|
||||
clipped = max(abs(value) for value in before + after) > self.scale # Превышение пользовательской шкалы должно быть видно в диагностике.
|
||||
self.clipped = clipped # Состояние масштаба используется общей диагностической строкой текущего интервала.
|
||||
for phase in range(3): # Каждый линейный провод получает отдельный цветной сегмент и физическую подпись.
|
||||
label = 'Estimated I{}={:+.6f} A{}'.format('ABC'[phase], after[phase], ' [outside plot range]' if clipped else '') # Таблица сохраняет реальную амплитуду даже при выходе кривой за шкалу.
|
||||
self.put(start, end, self.out_ann, [phase, [label, 'PWM1 {:.12g} {:.12g}'.format(max(-1, min(1, before[phase] / self.scale)), max(-1, min(1, after[phase] / self.scale)))]]) # За границей шкалы ограничивается только геометрия; физические амперы сохраняются в подписи и структурированном выходе.
|
||||
self.put(start, end, self.out_python, ['CURRENTS', {'ia': after[0], 'ib': after[1], 'ic': after[2], 'unit': 'A', 'estimated': True, 'warnings': self.model.interval_warnings, 'outside_plot_range': clipped}]) # Диагностика относится к завершённому электрическому интервалу, а не к следующей команде затворов.
|
||||
|
||||
def _warning(self, sample, final=False): # Одинаковая диагностика объединяется в один интервал вместо повторения на каждой точке графика.
|
||||
text = 'Estimated induction motor currents; fixed speed; motor parameters and initial state required.' # Постоянная пометка предотвращает восприятие оценки как измеренных токов.
|
||||
if self.model.warnings: # Невозможные команды ключей и модель диодов дополняют общую оговорку о расчёте.
|
||||
text += ' ' + '; '.join(self.model.warnings) # В подпись включаются конкретные фазы и состояния затворов.
|
||||
if getattr(self, 'clipped', False): # Ограниченная шкалой геометрия должна сопровождаться заметным предупреждением.
|
||||
text += ' Current exceeds plot range; increase +/- A.' # Пользователь может расширить шкалу без изменения физической модели двигателя.
|
||||
if self.warning_text is not None and (final or text != self.warning_text) and sample > self.warning_start: # Завершённый интервал выводится при смене условий или окончании записи.
|
||||
self.put(self.warning_start, sample, self.out_ann, [3, [self.warning_text]]) # Диагностика видна на соответствующем по времени участке захвата.
|
||||
if final or text != self.warning_text: # Начало нового интервала совпадает с изменением модельной достоверности.
|
||||
self.warning_start, self.warning_text = sample, text # Повторяющийся текст не раздувает число аннотаций.
|
||||
|
||||
def decode(self): # Ожидание фронтов дополняется сеткой, поэтому постоянный ШИМ 0/100 процентов также рассчитывается.
|
||||
if not self.samplerate: # Без частоты нельзя перевести длительность затворных команд в физическое время.
|
||||
raise ValueError('Cannot estimate motor current without samplerate.') # Расчёт не подменяет неизвестную частоту условной константой.
|
||||
self.scale, output = float(self.options['plot_range_a']), float(self.options['output_hz']) # Шкала в амперах и частота точек задаются независимо от параметров двигателя.
|
||||
if not math.isfinite(self.scale) or self.scale <= 0 or not math.isfinite(output) or output <= 0: # Нулевой масштаб и неконечная сетка не допускаются.
|
||||
raise ValueError('Plot range and plot sample rate must be finite and positive.') # Исправление настройки не требует изменения записи.
|
||||
if self.options['active_level'] not in ('high', 'low') or self.options['waveform_layout'] not in ('combined', 'split'): # Повреждённые сохранённые опции не интерпретируются произвольно.
|
||||
raise ValueError('Invalid gate polarity or waveform layout.') # Явная ошибка сохраняет смысл полярности сигналов.
|
||||
keys = ('topology', 'dc_voltage', 'midpoint_percent', 'connection', 'rs', 'rr', 'lls_mh', 'llr_mh', 'lm_mh', 'pole_pairs', 'speed_rpm', 'initial_flux', 'flux_angle', 'ia', 'ib') # В ядро передаются только физические параметры модели.
|
||||
self.model = PwmMotor(self.samplerate, active_high=self.options['active_level'] == 'high', **{key: self.options[key] for key in keys}) # Проверка схемы и единиц выполняется единственной общей реализацией.
|
||||
present = tuple(self.has_channel(index) for index in range(12)) # Неназначенные необязательные входы не считаются логическим нулём.
|
||||
pins = list(self.wait({})) # Копирование освобождает внутренний tuple DSView перед следующим ожиданием.
|
||||
self.model.update(self.samplenum, tuple(pins[index] if present[index] else None for index in range(12))) # Первый набор уровней определяет начальные состояния ключей.
|
||||
self._warning(self.samplenum) # Отметка расчётного характера начинается с первого фактического отсчёта записи.
|
||||
step = max(1, round(self.samplerate / output)) # Шаг в целых отсчётах ограничивается разрешением исходного захвата.
|
||||
boundary = self.samplenum + step # Равномерная сетка привязана к началу выбранной области декодирования.
|
||||
edges = [{index: 'e'} for index in range(12) if present[index]] # Одновременные фронты считываются одним набором уровней.
|
||||
while True: # Завершение захвата штатно останавливает wait без достраивания неизвестного сигнала.
|
||||
pins = list(self.wait(edges + [{'skip': max(1, boundary - self.samplenum)}])) # События ключей обрабатываются даже между соседними точками сетки.
|
||||
self._emit(self.model.update(self.samplenum, tuple(pins[index] if present[index] else None for index in range(12)))) # Старое напряжение интегрируется до фронта, новая команда действует после него.
|
||||
self._warning(self.samplenum) # Изменившиеся ошибки затворов получают собственный временной интервал.
|
||||
if self.samplenum >= boundary: # Фронт точно на сетке не должен создавать второй сегмент нулевой длины.
|
||||
boundary += ((self.samplenum - boundary) // step + 1) * step # Сетка остаётся равномерной независимо от количества переключений между её узлами.
|
||||
|
||||
def end(self): # Последний доступный отсчёт завершает только известную часть записи.
|
||||
if self.model is not None and self.model.sample is not None: # Ошибка настроек до первого отсчёта не оставляет пригодного физического состояния.
|
||||
last = max(self.samplenum, getattr(self, 'last_samplenum', self.samplenum)) # DSView передаёт фактический конец через last_samplenum, отдельно от последнего сработавшего wait.
|
||||
self._emit(self.model.update(last)) # Завершается только известный хвост записи до её фактической правой границы.
|
||||
self._warning(last, final=True) # Последний диагностический интервал сохраняется даже при неизменных ключах.
|
||||
@@ -6,6 +6,72 @@
|
||||
цветную кривую с общей временной шкалой записи и строку заполнения.
|
||||
Для большего числа входов добавьте ещё один экземпляр декодера.
|
||||
|
||||
Всё перечисленное ниже — опции **этого же анализатора PWM waveform**:
|
||||
|
||||
- **PWM topology / Схема ШИМ**: `two-level` — один вход на фазу;
|
||||
`npc-three-level` — классический NPC, четыре затвора S1…S4 сверху вниз.
|
||||
Прежние входы PWM A/B/C соответствуют S1; дополнительные A2/A3/A4,
|
||||
B2/B3/B4, C2/C3/C4 — остальным затворам. Для трёх фаз нужны 12 входов.
|
||||
Каждая подключённая фаза требует все четыре сигнала.
|
||||
- **Waveform / Форма сигнала**: `envelope` показывает средний уровень,
|
||||
`switching` — приближённое коммутационное напряжение по состояниям
|
||||
затворов. Для двух уровней шкала −1…+1; для NPC — −1, 0, +1
|
||||
(соответственно −Udc/2, нейтраль, +Udc/2). Это относительные уровни:
|
||||
напряжение шины, аналоговые выбросы и скорость переключения не измеряются.
|
||||
- **Theta ABC 0-360 deg / Угол вектора**: отдельный график θ по всем трём
|
||||
огибающим. Они интерполируются к общему времени; преобразование Кларка
|
||||
удаляет синфазную составляющую, затем вычисляется atan2(β, α).
|
||||
0° соответствует положительному направлению оси A. При амплитуде вектора
|
||||
менее 0,02 о.е. угол не определяется; переход 360→0 рисуется разрывом.
|
||||
Это угол восстановленного вектора напряжения, не измерение угла ротора
|
||||
или магнитного потока двигателя.
|
||||
- **Calculated triangle / Расчётная несущая**: отдельная строка одного
|
||||
треугольника −1…+1 либо двух синфазных смещённых треугольников NPC
|
||||
(−1…0 и 0…+1). Период задаётся окном или оценивается по одноимённым
|
||||
фронтам первой подключённой фазы. Начальная фаза условна: внутренний
|
||||
счётчик модулятора и его реальную несущую цифровой захват не измеряет.
|
||||
- **Phase angles AB BC CA / Углы между фазами**: измеренные сдвиги
|
||||
A→B, B→C, C→A в диапазоне −180…+180°. Положительный знак означает
|
||||
запаздывание второй фазы. Используются одноимённые пересечения огибающих
|
||||
и последний полный период каждой фазы; старые данные через два периода
|
||||
без обновления не выводятся. При разгоне это локальная оценка сдвига.
|
||||
Для двух подключённых фаз выводится только доступная пара.
|
||||
|
||||
NPC: `1100 → +1`, `0110 → 0`, `0011 → −1`. Остальные состояния,
|
||||
включая все выключенные ключи во время dead-time, имеют неизвестное
|
||||
напряжение и отмечаются отдельной строкой. В коммутационном виде это
|
||||
разрывы. Огибающая оценивается по известной части периода с явной
|
||||
подписью `valid`; при покрытии менее 95% период пропускается и история
|
||||
поля сбрасывается. Поэтому короткие паузы дают приближённую оценку,
|
||||
а длительные нарушения не маскируются нейтралью. Для устойчивого
|
||||
усреднения, особенно при насыщении, укажите известную **Carrier Hz**;
|
||||
автоматический режим использует начала ненулевых импульсов одинакового
|
||||
знака и заново синхронизируется при смене полуволны.
|
||||
|
||||
Таблица NPC: [Output Voltage and Switching States](https://e2e.ti.com/cfs-file/__key/communityserver-discussions-components-files/38/Three_2D00_Level-Converters_2D00_1.pdf).
|
||||
Преобразование координат: [Microchip Clarke Transformation](https://onlinedocs.microchip.com/oxy/GUID-0A4BC4EE-29F5-4736-8125-17139B84E7B5-en-US-2/GUID-92ACB0C5-9954-44B5-8583-47F0E6960A32.html).
|
||||
|
||||
В настройках **Decoders → PWM waveform → Waveform layout / Вид графиков**:
|
||||
|
||||
- `split` — отдельные области A/B/C с общей временной шкалой (по умолчанию).
|
||||
- `combined` — все видимые кривые в одной области с общей шкалой −1…+1.
|
||||
|
||||
Цвета сохраняются в обоих режимах: A — голубой, B — жёлтый, C — зелёный.
|
||||
Неподключённые и скрытые каналы не занимают место в общем графике.
|
||||
Выбранный режим сохраняется вместе с настройками декодера в записи DSL.
|
||||
Высота текстовых аннотаций ограничена высотой шрифта плюс 2 экранных пикселя
|
||||
(по 1 сверху и снизу), включая увеличение высоты дорожки анализатора.
|
||||
|
||||
Строка **PWM carrier / field** каждого канала показывает измеренную среднюю
|
||||
несущую ШИМ и электрическую частоту поля за полный период огибающей, в Гц.
|
||||
Она появляется после двух восходящих пересечений уровня заполнения 50%.
|
||||
Время пересечения интерполируется между точками кривой; гистерезис ±1
|
||||
процентный пункт заполнения подавляет дрожание около 50%. При разгоне
|
||||
частота обновляется каждый электрический период. Фильтр Low-pass влияет
|
||||
также на измеряемую огибающую. Для постоянного заполнения, неполного
|
||||
периода и огибающей, не пересекающей полосу 49–51%, частота поля не выводится.
|
||||
Это электрическая частота модуляции, а не механическая частота вращения.
|
||||
|
||||
Кривая — измеренное заполнение `2 * Ton / T - 1`, а не искусственный синус.
|
||||
50% соответствует нулю, 0% — −1, 100% — +1. Значения между серединами
|
||||
периодов соединены прямыми. Это нормированная модуляция, не измерение
|
||||
@@ -20,8 +86,11 @@
|
||||
- **Carrier Hz > 0**: интегрирование заполнения в равных временных окнах
|
||||
длиной `1/f`, начиная с начала области декодирования. Работает на 0/100%.
|
||||
Окна не синхронизированы с фронтами; при неверной несущей или быстро
|
||||
меняющейся модуляции возможны биения/искажения. В таблице указана частота
|
||||
окна, а не измеренная частота ШИМ. Неполное последнее окно пропускается.
|
||||
меняющейся модуляции возможны биения/искажения. В строке заполнения
|
||||
`window` обозначает частоту окна, а `PWM carrier` — частоту между
|
||||
фактическими активными фронтами. Пока не получены два фронта или после
|
||||
отсутствия фронтов дольше двух последних измеренных периодов несущая
|
||||
помечается `unavailable`. Неполное последнее окно пропускается.
|
||||
- **Low-pass time constant (ms)**: 0 отключает фильтр; положительное значение
|
||||
включает фильтр первого порядка. Он подавляет пульсации, но вносит задержку
|
||||
и ослабление. Для начала используйте 0.
|
||||
@@ -35,7 +104,23 @@
|
||||
`native/pwm_waveform.h`** (штатный `tools/dsview/build.py` включает оба).
|
||||
В старом/стандартном DSView будут только числовые аннотации.
|
||||
|
||||
Проверки: `python -m unittest discover -s tools/dsview/tests -p test_pwm_waveform.py`.
|
||||
Проверки: `python -m unittest discover -s tools/dsview/tests -p "test_pwm*.py"`.
|
||||
|
||||
Опции NPC в реальном DSView проверяет
|
||||
`python tools/dsview/tests/check_pwm_npc_native.py --runtime C:/path/to/dsview --output C:/path/to/npc-check`.
|
||||
Добавьте `--mode envelope` или `--layout split` для второго вида основной
|
||||
кривой либо раздельных фаз. Сценарий не использует оборудование.
|
||||
|
||||
Длинные записи отображаются через индекс минимумов и максимумов: обзорная
|
||||
перерисовка выдаёт не более двух отрезков на пиксель независимо от количества
|
||||
периодов. Одиночные выбросы сохраняются; при увеличении масштаба возвращаются
|
||||
исходные сегменты. Полные числовые аннотации остаются в таблице и экспорте.
|
||||
Индекс очищается вместе с записью при повторном анализе.
|
||||
|
||||
Проверка нативного индекса и миллиона периодов (Windows, MSYS2 UCRT64 с Qt 5):
|
||||
`python -m unittest discover -s tools/dsview/tests -p test_pwm_envelope.py`.
|
||||
Тест сравнивает экстремумы с полным перебором, проверяет увеличение масштаба,
|
||||
разрывы, повторное заполнение и ограниченное число экранных отрезков.
|
||||
|
||||
Нативная проверка (Python с PySide6, Windows):
|
||||
|
||||
@@ -46,3 +131,11 @@ python tools/dsview/tests/check_pwm_native.py --runtime C:/path/to/dsview --outp
|
||||
Она создаёт синтетическую трёхфазную запись, открывает её в отдельном DSView,
|
||||
сохраняет `pwm-native.png` и закрывает процесс. На изображении должны быть
|
||||
три синусоиды со сдвигом 120°. Аппаратный захват не запускается.
|
||||
По умолчанию проверяется `split`; добавьте `--layout combined`, чтобы проверить
|
||||
наложение трёх фаз. Параметр `--max-height 4X` увеличивает высоту дорожек:
|
||||
кривые растут, а текстовые блоки должны оставаться высотой шрифта плюс 2 пикселя.
|
||||
|
||||
Для проверки длинного захвата добавьте `--seconds 10 --carrier-hz 20000
|
||||
--timeout 60`. Тест ждёт подтверждения полного декодирования, проверяет
|
||||
ответы потока интерфейса на WM_NULL с тайм-аутом в одну секунду и сохраняет
|
||||
итоговый экран в `pwm-complete.png`.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"""PWM envelope on up to three independent logic channels."""
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.pwm_waveform import PwmChannel
|
||||
from common.setgui_decoders.pwm_waveform import PwmChannel, NpcChannel, PhaseAnalysis # Общие ядра восстанавливают два уровня, стойку NPC и согласованный трёхфазный вектор без зависимости от интерфейса.
|
||||
|
||||
|
||||
def short_frequency(value): # Компактная подпись сохраняет обе частоты даже при небольшой ширине электрического периода на экране.
|
||||
if value is None: # Отсутствующее измерение не должно выглядеть как нулевая частота.
|
||||
return '?' # Короткий знак оставляет место второму, доступному измерению.
|
||||
scale, unit = (1000000, 'MHz') if value >= 1000000 else (1000, 'kHz') if value >= 1000 else (1, 'Hz') # Приставки СИ переводят герцы в кило- и мегагерцы без изменения физического значения.
|
||||
return '{:.3g}{}'.format(value / scale, unit) # Три значащие цифры достаточны для обзорной подписи; полная точность остаётся в первой строке аннотации.
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
@@ -17,17 +24,27 @@ class Decoder(srd.Decoder):
|
||||
optional_channels = (
|
||||
{'id': 'b', 'name': 'PWM B', 'desc': 'Second PWM input'},
|
||||
{'id': 'c', 'name': 'PWM C', 'desc': 'Third PWM input'},
|
||||
*tuple({'id': ch + str(gate), 'name': ch.upper() + str(gate), 'desc': 'NPC ' + ch.upper() + ' S' + str(gate) + ' (top to bottom)'} for ch in 'abc' for gate in (2, 3, 4)), # Прежние A/B/C становятся S1 фаз; дополнительные девять входов завершают 12 затворов NPC, сохраняя совместимость записей DSL.
|
||||
)
|
||||
options = tuple(
|
||||
{'id': 'active_' + ch, 'desc': 'PWM ' + ch.upper() + ' active level',
|
||||
'default': 'high', 'values': ('high', 'low')} for ch in 'abc'
|
||||
) + (
|
||||
{'id': 'input_mode', 'desc': 'PWM topology / Схема ШИМ', 'default': 'two-level', 'values': ('two-level', 'npc-three-level')}, # NPC использует четыре затвора на фазу, обычный режим сохраняет один вход на фазу.
|
||||
{'id': 'waveform_mode', 'desc': 'Waveform / Форма сигнала', 'default': 'envelope', 'values': ('envelope', 'switching')}, # Envelope выводит средние уровни, switching — оценку коммутационного напряжения непосредственно по состояниям затворов.
|
||||
{'id': 'show_theta', 'desc': 'Theta ABC 0-360 deg / Угол вектора', 'default': 'no', 'values': ('no', 'yes')}, # θ является отдельной опцией и требует всех трёх восстановленных фаз.
|
||||
{'id': 'show_carrier', 'desc': 'Calculated triangle / Расчётная несущая', 'default': 'no', 'values': ('no', 'yes')}, # Треугольник восстанавливается по периодам и не выдаётся за измеренную аналоговую несущую.
|
||||
{'id': 'show_phase_angles', 'desc': 'Phase angles AB BC CA / Углы между фазами', 'default': 'no', 'values': ('no', 'yes')}, # Знаковые сдвиги фаз измеряются по одноимённым пересечениям огибающих.
|
||||
{'id': 'waveform_layout', 'desc': 'Waveform layout / Вид графиков', 'default': 'split', 'values': ('split', 'combined')}, # Split сохраняет отдельную область каждого входа; combined совмещает цветные кривые на общей шкале −1…+1.
|
||||
{'id': 'carrier_hz', 'desc': 'Carrier Hz (0 = edge-to-edge)', 'default': 0},
|
||||
{'id': 'smoothing_ms', 'desc': 'Low-pass time constant (ms, 0 = off)', 'default': 0.0},
|
||||
)
|
||||
annotations = (
|
||||
('wave-a', 'Wave A'), ('wave-b', 'Wave B'), ('wave-c', 'Wave C'),
|
||||
('duty-a', 'Duty A'), ('duty-b', 'Duty B'), ('duty-c', 'Duty C'),
|
||||
('frequency-a', 'PWM carrier / field A'), ('frequency-b', 'PWM carrier / field B'), ('frequency-c', 'PWM carrier / field C'), # Индексы 6–8 дополняют прежние аннотации, сохраняя контракт графических строк 0–2.
|
||||
('theta', 'Theta ABC (deg)'), ('carrier', 'Calculated carrier'), ('phase-angles', 'Phase lag (deg)'), # Индексы 9–11 отделяют графические углы и несущую от числового измерения сдвигов.
|
||||
('npc-unknown-a', 'NPC A unknown'), ('npc-unknown-b', 'NPC B unknown'), ('npc-unknown-c', 'NPC C unknown'), # Индексы 12–14 явно обозначают комбинации затворов, по которым напряжение не определяется.
|
||||
)
|
||||
annotation_rows = (
|
||||
('wave_a', 'A: -1 .. +1', (0,)),
|
||||
@@ -36,6 +53,13 @@ class Decoder(srd.Decoder):
|
||||
('duty_a', 'A duty / frequency', (3,)),
|
||||
('duty_b', 'B duty / frequency', (4,)),
|
||||
('duty_c', 'C duty / frequency', (5,)),
|
||||
('frequency_a', 'A PWM carrier / field', (6,)), # Длинная аннотация охватывает полный период поля и показывает обе частоты канала A при обзорном масштабе.
|
||||
('frequency_b', 'B PWM carrier / field', (7,)), # Независимый расчёт канала B сохраняет его собственную частоту и положение электрического периода.
|
||||
('frequency_c', 'C PWM carrier / field', (8,)), # Для необязательного канала C строка заполняется только при фактическом подключении.
|
||||
('wave_theta', 'Theta ABC: 0 .. 360 deg', (9,)), # Нормированный графический контракт переводится в электрические градусы подписью шкалы и числовыми аннотациями.
|
||||
('wave_carrier', 'Calculated triangle / NPC pair', (10,)), # Одна общая строка показывает один треугольник для двух уровней или два смещённых треугольника NPC.
|
||||
('phase_angles', 'Phase lag A->B / B->C / C->A (deg)', (11,)), # Положительное значение означает запаздывание второй указанной фазы.
|
||||
('npc_unknown', 'NPC dead-time / unknown voltage', (12, 13, 14)), # Недопустимое состояние не маскируется фиктивным нулём коммутационного напряжения.
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
@@ -44,6 +68,11 @@ class Decoder(srd.Decoder):
|
||||
def reset(self):
|
||||
self.samplerate = None
|
||||
self.channels_state = {}
|
||||
self._mode, self._npc = 'envelope', False # Старые сохранённые настройки продолжают открываться как обычная огибающая двухуровневого ШИМ.
|
||||
self._show_theta = self._show_carrier = self._show_angles = False # Дополнительные расчётные строки включаются только явными опциями данного экземпляра.
|
||||
self._phase_channels = {ch: (ch,) for ch in range(3)} # Прежняя нумерация A/B/C сохраняется для тестов, записей и обычного режима.
|
||||
self._raw, self._theta_previous = {}, None # Координаты коммутаций и предыдущий θ принадлежат только текущему декодированию.
|
||||
self._phase_analysis, self._last_pins = None, None # Согласование фаз и завершающий снимок не переносятся в следующую запись.
|
||||
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
@@ -53,30 +82,105 @@ class Decoder(srd.Decoder):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
|
||||
def _update(self, pins):
|
||||
self._last_pins = list(pins) # sigrok повторно использует свой кортеж; независимая копия нужна для корректного завершения окон.
|
||||
for ch, state in self.channels_state.items():
|
||||
for event in state.update(self.samplenum, pins[ch]):
|
||||
label = 'D={:.3f}% {}'.format(event['duty'] * 100,
|
||||
('window ' if state.window else 'f=') + '{:.3f} Hz'.format(event['frequency']))
|
||||
inputs = [pins[index] for index in self._phase_channels[ch]] # В NPC одному восстановленному каналу соответствуют четыре физических входа.
|
||||
level = state.state(inputs) if self._npc else (1 if bool(inputs[0]) == state.active_high else -1) # Двухуровневый уровень нормирован к ±1, а неопределённая комбинация NPC остаётся None.
|
||||
if self._mode == 'switching' or self._npc: # Коммутационный график и предупреждения NPC используют ту же временную последовательность цифровых состояний.
|
||||
self._switching(ch, level) # Неизвестные интервалы разрывают кривую и явно отмечаются отдельной аннотацией.
|
||||
for event in state.update(self.samplenum, inputs if self._npc else inputs[0]): # Ядра получают собственный формат входа, сохраняя единый формат восстановленных результатов.
|
||||
carrier = '{:.3f} Hz'.format(event['carrier_frequency']) if event['carrier_frequency'] is not None else 'unavailable' # Не подменяем измеренную несущую заданной частотой окна или устаревшим значением после исчезновения фронтов.
|
||||
window = ' window={:.3f} Hz'.format(event['frequency']) if state.window else '' # Частота интегрирования обозначается отдельно и только в режиме заданной несущей.
|
||||
label = 'D={:.3f}% PWM carrier={}{}'.format(event['duty'] * 100, carrier, window) # Подпись каждого периода явно связывает несущую с измеренными фронтами ШИМ.
|
||||
if self._npc: # В NPC D является эквивалентным заполнением среднего уровня, а не временем включения одного ключа.
|
||||
label = 'NPC mean={:+.6f} valid={:.3f}% PWM carrier={}{}'.format(2 * event['duty'] - 1, 100 * event['valid_fraction'], carrier, window) # Доля известного времени показывает влияние исключённых коммутационных пауз на оценку.
|
||||
self.put(event['start'], event['end'], self.out_ann,
|
||||
[3 + ch, [label, '{:.2f}%'.format(event['duty'] * 100)]])
|
||||
field = event['field'] # Дополнительная строка появляется только после завершения полного электрического периода.
|
||||
if field is not None: # Неполные периоды и постоянное заполнение не создают числового результата поля.
|
||||
carrier = '{:.3f} Hz'.format(field['carrier_frequency']) if field['carrier_frequency'] is not None else 'unavailable' # Средняя несущая измеряется независимо от числа окон восстановления за период поля.
|
||||
field_text = 'Field={:.3f} Hz'.format(field['frequency']) # Частота огибающей выражена в электрических герцах, без пересчёта в механические обороты.
|
||||
carrier_short, field_short = short_frequency(field['carrier_frequency']), short_frequency(field['frequency']) # При обзорном масштабе приставки СИ позволяют одновременно прочитать несущую и поле.
|
||||
self.put(field['start'], field['end'], self.out_ann, [6 + ch, ['PWM carrier={} {}'.format(carrier, field_text), 'PWM={} / {}'.format(carrier, field_text), 'PWM={} F={}'.format(carrier_short, field_short), '{}/{}'.format(carrier_short, field_short)]]) # Все варианты подписи сохраняют порядок «несущая / поле», включая самый короткий для узкого периода.
|
||||
previous = event['previous']
|
||||
if previous is not None:
|
||||
if previous is not None and self._mode == 'envelope': # Переключение вида меняет только график; частоты и углы всегда рассчитываются по огибающим.
|
||||
# Last label is the versioned native renderer contract.
|
||||
self.put(previous[0], event['midpoint'], self.out_ann, [ch, [
|
||||
'PWM {}: {:+.6f}'.format('ABC'[ch], event['value']),
|
||||
'PWM1 {:.9f} {:.9f}'.format(previous[1], event['value'])]])
|
||||
if self._show_carrier and ch == min(self.channels_state): # Общая расчётная несущая использует периоды первой подключённой фазы и не дублируется для B/C.
|
||||
self._curve(10, event['start'], event['midpoint'], -1, 1, 'Calculated carrier') # Первая половина условного периода линейно поднимается от минимума к максимуму.
|
||||
self._curve(10, event['midpoint'], event['end'], 1, -1, 'Calculated carrier') # Вторая половина завершает треугольник без накопления ошибки времени.
|
||||
if self._phase_analysis is not None: # При выключенных угловых опциях согласование фаз не тратит время на длинных записях.
|
||||
if event['previous'] is None: # Новая история огибающей означает начало записи либо разрыв неизвестного состояния NPC.
|
||||
self._theta_previous = None # График θ не соединяется через интервал, где одна из фаз не была восстановлена.
|
||||
vector = self._phase_analysis.add(ch, event) # Точки всех фаз интерполируются к общему наблюдавшемуся времени.
|
||||
if self._show_theta and vector is not None: # Начальная неполная тройка фаз не создаёт угла пространственного вектора.
|
||||
self._theta(vector) # Переход 360→0 разрывается на границе, сохраняя правильную пилообразную форму.
|
||||
if self._show_angles and event['field'] is not None and ch == min(self.channels_state): # Один полный период опорной фазы задаёт интервал числового результата всех доступных пар.
|
||||
angles = self._phase_analysis.differences(event['midpoint']) # Сдвиг вычисляется из фактических фазовых часов, а не из заданных 120 градусов.
|
||||
if angles: # Устаревшие или ещё не измеренные пары не должны получать числовую подпись.
|
||||
labels = [' '.join('{}={:+.3f} deg'.format(pair, value) for pair, value in angles.items()), ' / '.join('{:+.1f}'.format(value) for value in angles.values())] # Полная подпись сохраняет направление каждой пары; короткая следует порядку заголовка.
|
||||
self.put(event['field']['start'], event['field']['end'], self.out_ann, [11, labels]) # Значения доступны на временной диаграмме, в таблице результатов и при экспорте.
|
||||
|
||||
def _curve(self, kind, start, end, a, b, label): # Все расчётные кривые используют существующий индекс экстремумов и контракт PWM1.
|
||||
start, end = round(start), round(end) # Целые номера отсчётов требуются API аннотаций sigrok.
|
||||
if end > start: # Пустой интервал не может быть сегментом индексированной кривой.
|
||||
self.put(start, end, self.out_ann, [kind, [label, 'PWM1 {:.9f} {:.9f}'.format(max(-1, min(1, a)), max(-1, min(1, b)))]]) # Ограничение защищает нативный контракт нормированных значений от машинного округления.
|
||||
|
||||
def _switching(self, ch, level): # Импульсная осциллограмма строится по сменам состояний без выдуманных аналоговых выбросов.
|
||||
previous = self._raw.get(ch) # Предыдущее состояние определяет уровень между двумя цифровыми событиями.
|
||||
if previous is not None and self.samplenum > previous[0]: # Совпадающие события не создают отрицательную или нулевую длительность.
|
||||
start, old = previous # Начало интервала и уровень относятся только к выбранной фазе.
|
||||
if old is None: # Ни одна из трёх штатных комбинаций NPC не определяла напряжение на этом участке.
|
||||
self.put(start, self.samplenum, self.out_ann, [12 + ch, ['NPC {}: unknown voltage / dead-time'.format('ABC'[ch]), 'unknown']]) # Неизвестное время остаётся видимым даже при выборе огибающей.
|
||||
elif self._mode == 'switching': # Основная графическая строка может показывать коммутации вместо средней огибающей.
|
||||
corner = self.samplenum - 1 if level is not None and level != old else self.samplenum # Фронт занимает один отсчёт, поскольку аналоговая скорость переключения не измерена.
|
||||
self._curve(ch, start, max(start, corner), old, old, 'Switching {}: {:+d}'.format('ABC'[ch], old)) # Постоянный уровень сохраняет точную длительность между событиями захвата.
|
||||
if level is not None and level != old: # Через неизвестный уровень не проводится выдуманный соединительный фронт.
|
||||
self._curve(ch, max(start, corner), self.samplenum, old, level, 'Switching {} edge'.format('ABC'[ch])) # Нормированный переход отображается только в пределах разрешения записи.
|
||||
self._raw[ch] = (self.samplenum, level) # Следующее событие продолжит график только от текущего достоверного состояния.
|
||||
|
||||
def _theta(self, vector): # Угол общего вектора выводится отдельно от углов между фазами.
|
||||
sample, angle = vector['sample'], vector['theta'] # Координата соответствует единому времени A/B/C после интерполяции.
|
||||
previous = self._theta_previous # Предыдущая точка нужна для графика и обнаружения циклического перехода через нуль.
|
||||
if angle is not None and previous is not None: # Нулевая амплитуда или разрыв не соединяются произвольным направлением вектора.
|
||||
start, old = previous # Угол выражен в градусах до преобразования в нативную шкалу −1…+1.
|
||||
if abs(angle - old) <= 180: # Обычный участок не пересекает границу электрического оборота.
|
||||
self._curve(9, start, sample, old / 180 - 1, angle / 180 - 1, 'Theta={:.3f} deg'.format(angle)) # Подпись содержит физическую величину, а renderer получает нормированные концы.
|
||||
else: # Циклическое значение должно сбрасываться вертикально, а не проходить ложные промежуточные углы.
|
||||
forward = old > angle # Направление определяет границу +360→0 либо 0→+360.
|
||||
delta = angle - old + (360 if forward else -360) # Кратчайшее изменение восстанавливает непрерывный угол на одном малом интервале.
|
||||
boundary = start + ((360 if forward else 0) - old) * (sample - start) / delta # Линейная интерполяция определяет момент перехода через границу оборота.
|
||||
self._curve(9, start, boundary, old / 180 - 1, 1 if forward else -1, 'Theta wrap') # Первая часть заканчивается на достигнутой границе шкалы.
|
||||
self._curve(9, boundary, sample, -1 if forward else 1, angle / 180 - 1, 'Theta={:.3f} deg'.format(angle)) # Вторая часть начинается с противоположного края без ложной диагонали.
|
||||
self._theta_previous = (sample, angle) if angle is not None else None # Неопределённое направление явно разрывает историю угла.
|
||||
|
||||
def decode(self):
|
||||
if not self.samplerate:
|
||||
raise ValueError('Cannot decode without samplerate.')
|
||||
self.channels_state = {ch: PwmChannel(self.samplerate,
|
||||
self._npc = self.options.get('input_mode', 'two-level') == 'npc-three-level' # Отсутствующая опция старой записи сохраняет прежнюю схему одного входа на фазу.
|
||||
self._mode = self.options.get('waveform_mode', 'envelope') # Вид кривой не меняет численные измерения заполнения, частот и углов.
|
||||
self._show_theta, self._show_carrier, self._show_angles = (self.options.get(key, 'no') == 'yes' for key in ('show_theta', 'show_carrier', 'show_phase_angles')) # Все дополнительные расчёты включаются независимо в настройках одного анализатора.
|
||||
self._phase_channels = {ch: (ch, 3 + ch * 3, 4 + ch * 3, 5 + ch * 3) if self._npc else (ch,) for ch in range(3)} # S1 сохраняет прежние индексы A/B/C, а S2…S4 последовательно занимают девять новых входов.
|
||||
for ch, indices in self._phase_channels.items(): # Неполная стойка NPC не должна трактоваться как неподключённый или нулевой уровень фазы.
|
||||
connected = [self.has_channel(index) for index in indices] # Наличие проверяется для каждого физического затвора отдельно.
|
||||
if any(connected) and not all(connected): # Каждая используемая фаза NPC требует всех четырёх управляющих сигналов.
|
||||
raise ValueError('NPC phase {} requires all four gates S1..S4 (top to bottom).'.format('ABC'[ch])) # Понятная ошибка предотвращает неверную реконструкцию из неполных входов.
|
||||
channel_class = NpcChannel if self._npc else PwmChannel # Один и тот же экземпляр декодера поддерживает две схемы без отдельного анализатора.
|
||||
self.channels_state = {ch: channel_class(self.samplerate, # Тип канала определяет преобразование цифровых состояний в средний уровень.
|
||||
self.options['active_' + 'abc'[ch]] == 'high',
|
||||
self.options['carrier_hz'], self.options['smoothing_ms'])
|
||||
for ch in range(3) if self.has_channel(ch)}
|
||||
if self._show_theta and len(self.channels_state) != 3: # Для угла пространственного вектора нужны три реальные огибающие, а не предположение о симметричной нагрузке.
|
||||
raise ValueError('Theta requires all three phases A/B/C.') # Отсутствующая фаза не достраивается искусственной синусоидой.
|
||||
if self._show_angles and len(self.channels_state) < 2: # Межфазный угол невозможно измерить по одному подключённому входу.
|
||||
raise ValueError('Phase angles require at least two phases.') # В двухфазной записи рассчитываются только доступные пары.
|
||||
self._phase_analysis = PhaseAnalysis(self.samplerate) if self._show_theta or self._show_angles else None # Выключенные опции сохраняют прежнюю стоимость обработки обычного ШИМ.
|
||||
pins = list(self.wait({}))
|
||||
self._update(pins)
|
||||
while True:
|
||||
conditions = [{ch: 'e'} for ch in self.channels_state]
|
||||
conditions = [{index: 'e'} for ch in self.channels_state for index in self._phase_channels[ch]] # В NPC пробуждение вызывается каждым затвором, включая короткие dead-time и запрещённые комбинации.
|
||||
boundaries = [s.next_boundary() for s in self.channels_state.values()
|
||||
if s.next_boundary() is not None]
|
||||
if boundaries:
|
||||
@@ -85,9 +189,5 @@ class Decoder(srd.Decoder):
|
||||
self._update(pins)
|
||||
|
||||
def end(self):
|
||||
# Complete fixed windows only; never infer a partial edge-based period.
|
||||
if self.channels_state:
|
||||
pins = [0, 0, 0]
|
||||
for ch, state in self.channels_state.items():
|
||||
pins[ch] = state.level == state.active_high
|
||||
self._update(pins)
|
||||
if self.channels_state and self._last_pins is not None: # Последний снимок содержит все двенадцать затворов NPC либо прежние входы A/B/C.
|
||||
self._update(self._last_pins) # Завершаются только полные окна и постоянные участки; отсутствующие фронты и неполные периоды не выдумываются.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Сигма-дельта: DATA + CLK
|
||||
|
||||
Декодер `sigma_delta` восстанавливает сигнал из однобитного потока
|
||||
сигма-дельта модулятора (PDM) с отдельным тактовым входом. В SETGUI он
|
||||
доступен в меню анализаторов как **Сигма-дельта · DATA/CLK, sinc1/2/3**,
|
||||
в обычном списке DSView — как **Sigma-delta**.
|
||||
|
||||
Назначьте захваченные линии **DATA** и **CLK**. Выберите фронт приёма согласно
|
||||
документации модулятора: `rising` или `falling`. Если полярность DATA обратная,
|
||||
включите `Invert DATA`. Биты принимаются на фронтах CLK даже при постоянном DATA;
|
||||
начальный уровень захвата до первого выбранного фронта не считается битом.
|
||||
Для нескольких модуляторов добавьте несколько экземпляров анализатора с общим CLK.
|
||||
|
||||
| Настройка | Значение и смысл |
|
||||
| --- | --- |
|
||||
| Decimation filter | `sinc1`, `sinc2`, `sinc3`; по умолчанию `sinc3` |
|
||||
| Decimation / OSR | Целое число входных тактов на выходной отсчёт: 1–65536; по умолчанию 256 |
|
||||
| Bipolar full-scale input | Положительное напряжение идеального входа при 100% единиц; по умолчанию 1 В |
|
||||
|
||||
Выход CIC нормируется на усиление `OSR**order`: нулевая плотность соответствует
|
||||
−1, плотность 50% — нулю, 100% — +1. Напряжение равно нормированному значению,
|
||||
умноженному на `Bipolar full-scale input`. Это идеальный масштаб модулятора,
|
||||
который следует брать из его документации; напряжение питания и опорное напряжение
|
||||
не обязательно совпадают с этим параметром. Код — биполярное целое в диапазоне
|
||||
`−OSR**order … +OSR**order`, без привязки к разрядности конкретного АЦП.
|
||||
|
||||
В таблице результатов доступны нормированное значение и код, напряжение,
|
||||
фильтрованная плотность единиц, частота CLK и выходная частота `CLK / OSR`.
|
||||
Для sinc2/3 плотность взвешена фильтром и отличается от простого подсчёта
|
||||
единиц последнего блока. Амплитуда восстанавливается и без метаданных частоты
|
||||
захвата; частоты в герцах тогда не выводятся.
|
||||
|
||||
Начальные `order − 1` выходных блоков пропускаются до установления фильтра.
|
||||
Неполный блок в конце записи не выдаётся. Аннотация охватывает фронты последнего
|
||||
блока OSR; фильтр сохраняет причинную задержку и не сдвигает результат назад.
|
||||
Групповая задержка sincN равна `N × (OSR − 1) / 2` периодов CLK.
|
||||
Изменение выбранного фронта, инверсии или OSR требует повторного декодирования.
|
||||
|
||||
Поддерживается однобитный поток с отдельным CLK. Для готовых слов АЦП по SPI
|
||||
используйте соответствующий SPI-декодер; Manchester без отдельного CLK здесь
|
||||
не разбирается. Точность ограничена захватом: пропущенные тактовые фронты
|
||||
невозможно восстановить автоматически.
|
||||
|
||||
Двухлинейный интерфейс и семейство sinc1/2/3 описаны в
|
||||
[TI SDFM Interface Design](https://software-dl.ti.com/processor-industrial-sw/esd/motor_control_sdk/am243x/11_00_00_06/docs/api_guide_am243x/SDFM_DESIGN.html).
|
||||
Реализация фильтра находится в `python/logic_analyzer/decoders/sigma_delta.py`.
|
||||
Общая ведомость `protocol-files.json` включает адаптер и ядро в сборку DSView
|
||||
и в `install_decoders.ps1`; после установки перезапустите DSView.
|
||||
|
||||
Проверки: `python -m unittest discover -s tools/dsview/tests -p test_sigma_delta.py -v`.
|
||||
@@ -0,0 +1 @@
|
||||
from .pd import Decoder # Стандартная точка входа sigrok загружает адаптер сигма-дельта потока из этого пакета.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user