Compare commits
80 Commits
codex/set-
...
deif-andro
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfa8bd94d | |||
| a63c1dd7d8 | |||
| 083ab55cb6 | |||
| d2578299d8 | |||
| fdfae8fa6c | |||
| 66fad32a81 | |||
| f7a6f767e1 | |||
| 675ce063f0 | |||
| 3d9174a124 | |||
| 46ecbe3210 | |||
| 6df5674996 | |||
| 196d083098 | |||
| c0ee7c46ea | |||
| 3457657979 | |||
| 79411dbc26 | |||
| 5260186c16 | |||
| 10bfa587c7 | |||
| ac3025ee32 | |||
| 37508f058b | |||
| 3334f4cddd | |||
| aedd5fc082 | |||
| 9ba3742b29 | |||
| 4f23f48896 | |||
| 8e9fd144ca | |||
| 49c65ea78d | |||
| 34f728cfaa | |||
| 72205216f5 | |||
| 5d48ddf0b0 | |||
| 245d486a63 | |||
| e262b0acdd | |||
| ed92ec346e | |||
| 3d374e7777 | |||
| d03e039cb4 | |||
| d002b14e4e | |||
| 11007b0704 | |||
| 821202b655 | |||
| 8596918bf7 | |||
| bada3bbe8d | |||
| 87f2f2f56b | |||
| 5523f049ec | |||
| b08ea02a5c | |||
| 1b3a6318eb | |||
| 57622ccd66 | |||
| ba529f579c | |||
| 4f788d39d6 | |||
| 1799b00167 | |||
| 940d9ada76 | |||
| 6eaa47d3a8 | |||
| 1f49ba49bb | |||
| b7f00acb24 | |||
| 70b2ee3cad | |||
| b3ec401318 | |||
| 666173b2f0 | |||
| 17259f5e28 | |||
| 79ac0d52a7 | |||
| 52d687b5ae | |||
| 7897fe9f0d | |||
| 45582cda4a | |||
| 2bb9fea90a | |||
| 6392d36741 | |||
| 0c635ce1be | |||
| 5caca07ad4 | |||
| 31a111ee46 | |||
| 20f63bc389 | |||
| 0a30c39e07 | |||
| e8490b3122 | |||
| a14140f794 | |||
| 261f2ca6f1 | |||
| c8eec55978 | |||
| ab60e58318 | |||
| 94fc8c5ef2 | |||
| 58da8aa2fe | |||
| bd8d22312e | |||
| 01de7651e8 | |||
| 34c46ec679 | |||
| 23e37d1c4d | |||
| aea2a79d2c | |||
| bb7c94f1b7 | |||
| ce6c4327a4 | |||
| 3cf85a278e |
27
Doxyfile
Normal file
27
Doxyfile
Normal file
@@ -0,0 +1,27 @@
|
||||
PROJECT_NAME = "templates"
|
||||
PROJECT_BRIEF = "Переносимые библиотеки и правила интеграции"
|
||||
OUTPUT_DIRECTORY = .codex-build/doxygen
|
||||
CREATE_SUBDIRS = NO
|
||||
OUTPUT_LANGUAGE = Russian
|
||||
INPUT = README.md NEW_PROJECT.md CONTRIBUTING.md doc/submodules.dox c python
|
||||
FILE_PATTERNS = *.h *.c *.dox *.md
|
||||
RECURSIVE = YES
|
||||
USE_MDFILE_AS_MAINPAGE = README.md
|
||||
MARKDOWN_SUPPORT = YES
|
||||
AUTOLINK_SUPPORT = YES
|
||||
EXTRACT_ALL = NO
|
||||
EXTRACT_STATIC = NO
|
||||
SOURCE_BROWSER = YES
|
||||
INLINE_SOURCES = NO
|
||||
STRIP_FROM_PATH = .
|
||||
GENERATE_HTML = YES
|
||||
HTML_OUTPUT = html
|
||||
GENERATE_TREEVIEW = YES
|
||||
DISABLE_INDEX = NO
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
WARNINGS = YES
|
||||
WARN_IF_UNDOCUMENTED = NO
|
||||
WARN_IF_DOC_ERROR = YES
|
||||
WARN_AS_ERROR = FAIL_ON_WARNINGS
|
||||
HAVE_DOT = NO
|
||||
185
NEW_PROJECT.md
185
NEW_PROJECT.md
@@ -120,3 +120,188 @@ USB gs_usb и конкретной разводки адаптера; перен
|
||||
|
||||
Так новый проект не является копией старого: он использует общие ядра одной
|
||||
версии, а аппаратные отличия видны в одном небольшом каталоге `port/`.
|
||||
|
||||
## Слияние и обновление `templates` как Git submodule
|
||||
|
||||
### Что именно хранит основной проект
|
||||
|
||||
Основной проект не хранит содержимое `lib/templates` в своей истории. Вместо
|
||||
этого он хранит **gitlink** — ссылку на один конкретный commit репозитория
|
||||
`templates`. Поэтому при работе есть два независимых уровня истории:
|
||||
|
||||
1. изменения библиотек сливаются и публикуются в репозитории `templates`;
|
||||
2. основной проект отдельным коммитом переводит gitlink на проверенную ревизию
|
||||
`templates`.
|
||||
|
||||
Обычный `git merge` в основном проекте не переносит исходники между ветками
|
||||
`templates` и не создаёт merge-коммит внутри сабмодуля. Он может только выбрать
|
||||
одну из уже существующих ссылок на commit. Если ссылки разошлись, сначала нужно
|
||||
получить общий commit в самом `templates`, а затем зафиксировать его в основном
|
||||
проекте.
|
||||
|
||||
Текущую зафиксированную ревизию удобно смотреть из корня основного проекта:
|
||||
|
||||
```bash
|
||||
git submodule status lib/templates
|
||||
git diff --submodule=log
|
||||
```
|
||||
|
||||
Символ `-` перед SHA в `git submodule status` означает, что сабмодуль ещё не
|
||||
инициализирован; `+` — что рабочая копия сабмодуля находится не на том commit,
|
||||
который записан в основном проекте; `U` — конфликт gitlink.
|
||||
|
||||
### Клонирование проекта и переключение его веток
|
||||
|
||||
Новый клон лучше сразу создавать вместе с сабмодулями:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules <url-основного-проекта>
|
||||
cd <основной-проект>
|
||||
```
|
||||
|
||||
Если проект уже клонирован:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
После `git switch`, `git checkout`, `git pull` или завершения слияния основной
|
||||
проект может начать ссылаться на другую ревизию `templates`. Рабочее дерево
|
||||
сабмодуля следует явно привести к записанному состоянию:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Это нормальный и воспроизводимый режим: внутри `lib/templates` обычно будет
|
||||
detached HEAD, потому что проект фиксирует commit, а не ветку. Не следует делать
|
||||
там `git pull`, пока не выбрана рабочая ветка и не понятно, какую ревизию должен
|
||||
получить основной проект.
|
||||
|
||||
### Обычное обновление проекта на новую версию `templates`
|
||||
|
||||
Сначала изменение должно быть проверено, закоммичено, отправлено и слито в
|
||||
целевую ветку самого репозитория `templates`. После этого из корня основного
|
||||
проекта выполняется:
|
||||
|
||||
```bash
|
||||
git -C lib/templates fetch origin
|
||||
git -C lib/templates switch --detach origin/master
|
||||
git add lib/templates
|
||||
git diff --cached --submodule=log
|
||||
git commit -m "build(templates): обновить общие библиотеки"
|
||||
```
|
||||
|
||||
Перед коммитом нужно собрать основной проект и прогнать его тесты: успешные
|
||||
тесты в `templates` подтверждают работу библиотеки, но не проверяют конкретный
|
||||
порт, настройки платы и интеграцию приложения.
|
||||
|
||||
Коммит основного проекта содержит только переход со старого SHA сабмодуля на
|
||||
новый. Случайные незакоммиченные файлы внутри `lib/templates` в него не входят.
|
||||
Проверить оба уровня перед фиксацией можно так:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git -C lib/templates status
|
||||
git diff --submodule=log
|
||||
```
|
||||
|
||||
Не нужно обновлять сабмодуль на последний `master` автоматически при каждой
|
||||
сборке. Зафиксированный SHA нужен именно для того, чтобы одна и та же версия
|
||||
проекта всегда собиралась с одной и той же версией библиотек.
|
||||
|
||||
### Если библиотеку правят из рабочего дерева основного проекта
|
||||
|
||||
Нельзя коммитить изменение, оставаясь на detached HEAD: такой commit легко
|
||||
потерять при следующем `git submodule update`. Сначала внутри сабмодуля создаётся
|
||||
обычная ветка:
|
||||
|
||||
```bash
|
||||
git -C lib/templates fetch origin
|
||||
git -C lib/templates switch -c feat/<имя> origin/master
|
||||
```
|
||||
|
||||
Затем изменения коммитятся и отправляются именно в репозиторий `templates`:
|
||||
|
||||
```bash
|
||||
git -C lib/templates add <файлы>
|
||||
git -C lib/templates commit -m "feat(<библиотека>): <описание>"
|
||||
git -C lib/templates push -u origin feat/<имя>
|
||||
```
|
||||
|
||||
После проверки ветка сливается в `master` репозитория `templates`. Только после
|
||||
публикации итогового commit основной проект обновляет свой gitlink по процедуре
|
||||
из предыдущего раздела. Не следует отправлять в общий основной проект ссылку на
|
||||
commit сабмодуля, которого ещё нет на сервере: остальные разработчики и CI не
|
||||
смогут его получить.
|
||||
|
||||
### Что происходит при слиянии веток основного проекта
|
||||
|
||||
Возможны три ситуации:
|
||||
|
||||
1. Gitlink изменён только в одной ветке. Git обычно принимает эту ревизию
|
||||
автоматически. После merge нужно выполнить `git submodule update` и тесты.
|
||||
2. Обе ветки указывают на разные commits, но один commit `templates` является
|
||||
предком другого. Следует выбрать более новый проверенный commit, выполнить
|
||||
`git add lib/templates` и продолжить merge.
|
||||
3. Обе ветки указывают на расходящиеся commits `templates`. Это настоящий
|
||||
конфликт истории сабмодуля: выбирать один SHA наугад нельзя, потому что так
|
||||
потеряются изменения второй ветки.
|
||||
|
||||
При конфликте сначала смотрят, какие ссылки пришли с обеих сторон:
|
||||
|
||||
```bash
|
||||
git ls-files -u lib/templates
|
||||
git -C lib/templates fetch origin
|
||||
git -C lib/templates log --oneline --graph --decorate --all
|
||||
```
|
||||
|
||||
Затем в отдельной ветке **репозитория `templates`** объединяют оба commit,
|
||||
устраняют конфликты исходников, запускают тесты и публикуют результат:
|
||||
|
||||
```bash
|
||||
git -C lib/templates switch -c merge/<имя> <sha-одной-стороны>
|
||||
git -C lib/templates merge <sha-другой-стороны>
|
||||
# исправить конфликты и запустить тесты templates
|
||||
git -C lib/templates add <исправленные-файлы>
|
||||
git -C lib/templates commit
|
||||
git -C lib/templates push -u origin merge/<имя>
|
||||
```
|
||||
|
||||
Если общий merge-коммит уже существует в `origin/master`, создавать ещё один не
|
||||
нужно: достаточно выбрать существующий SHA. После получения итогового commit
|
||||
конфликт завершается на уровне основного проекта:
|
||||
|
||||
```bash
|
||||
git -C lib/templates switch --detach <итоговый-sha>
|
||||
git add lib/templates
|
||||
git diff --cached --submodule=log
|
||||
git merge --continue
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
`git add lib/templates` здесь отмечает разрешённым именно gitlink. Не нужно
|
||||
удалять каталог, копировать библиотеку поверх него или разрешать конфликт как
|
||||
обычный текстовый файл.
|
||||
|
||||
### Что должно попасть в merge request основного проекта
|
||||
|
||||
Перед отправкой результата следует проверить:
|
||||
|
||||
1. итоговый commit `templates` доступен в удалённом репозитории;
|
||||
2. `git -C lib/templates status` не показывает локальных изменений;
|
||||
3. `git submodule status` не начинается с `+`, `-` или `U`;
|
||||
4. `git diff --submodule=log <целевая-ветка>...HEAD` показывает ожидаемый набор
|
||||
commits библиотек;
|
||||
5. тесты `templates`, сборка основного проекта и проверки на целевой плате
|
||||
прошли;
|
||||
6. в основном проекте закоммичены необходимые изменения адаптеров, конфигурации
|
||||
и сам новый gitlink.
|
||||
|
||||
После получения ветки другой разработчик восстанавливает ровно выбранное
|
||||
состояние одной командой:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
13
README.md
13
README.md
@@ -48,6 +48,10 @@ templates/
|
||||
|
||||
### Python
|
||||
|
||||
Экспериментальный [`c/gas-logger`](c/gas-logger/README.md): непрерывная запись
|
||||
в два банка RAM, снимки через GAS/Modbus/ProtoCAN, JSON-карта имён и адресов,
|
||||
проектный порт ПМ35 и MCU-порты C28x / STM32. Клиент в `set_devices.gas_logger`.
|
||||
|
||||
| Модуль | Что делает | Зависимости |
|
||||
|---|---|---|
|
||||
| [`python/setprotocol/firmware_database.py`](python/setprotocol/firmware_database.py) | база прошивок: HTTPS-каталог, скачивание с SHA-256 и кэшем, публикация в Gitea без SETGUI; [подключение и CLI](tools/firmware-publish/DATABASE.md) | stdlib, Python 3.10+ |
|
||||
@@ -72,6 +76,11 @@ templates/
|
||||
В ней отдельно описаны граница ядра, ABI, память, три wire format и состояние
|
||||
портов Windows, Android, Linux и MCU.
|
||||
|
||||
Обзор всего репозитория, включая слияние при подключении через submodule:
|
||||
[`doc/index.html`](doc/index.html). Справочник Doxygen собирается командой
|
||||
`powershell -ExecutionPolicy Bypass -File doc/build-doxygen.ps1` в
|
||||
`.codex-build/doxygen/html/index.html`.
|
||||
|
||||
## Как подключить к проекту
|
||||
|
||||
**Сабмодуль** — когда нужна одна конкретная версия и обновление по команде:
|
||||
@@ -81,6 +90,10 @@ git submodule add https://git.rd12.ru/Andrey/templates.git lib/templates
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Как обновлять зафиксированную ревизию, переносить изменения из проекта и
|
||||
разрешать конфликт gitlink при слиянии веток, подробно описано в разделе
|
||||
[`Слияние и обновление templates как Git submodule`](NEW_PROJECT.md#слияние-и-обновление-templates-как-git-submodule).
|
||||
|
||||
Дальше в сборку добавляются только нужные каталоги:
|
||||
|
||||
```
|
||||
|
||||
16
c/gas-logger/CMakeLists.txt
Normal file
16
c/gas-logger/CMakeLists.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(gas_logger C)
|
||||
set(CMAKE_C_STANDARD 99)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
add_library(gas_logger src/gas_logger.c)
|
||||
target_include_directories(gas_logger PUBLIC include)
|
||||
add_library(gas_logger_pm35 ports/pm35/gas_logger_pm35.c)
|
||||
target_include_directories(gas_logger_pm35 PUBLIC ports/pm35)
|
||||
target_link_libraries(gas_logger_pm35 PUBLIC gas_logger)
|
||||
add_library(gas_logger_pcan src/gas_logger_pcan.c)
|
||||
target_include_directories(gas_logger_pcan PUBLIC ../set-protocol/include)
|
||||
target_link_libraries(gas_logger_pcan PUBLIC gas_logger)
|
||||
add_executable(test_gas_logger tests/test_gas_logger.c)
|
||||
target_link_libraries(test_gas_logger PRIVATE gas_logger)
|
||||
enable_testing()
|
||||
add_test(NAME gas_logger_core COMMAND test_gas_logger)
|
||||
177
c/gas-logger/README.md
Normal file
177
c/gas-logger/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# GAS Logger — экспериментальная версия 1
|
||||
|
||||
Непрерывный регистратор с двумя кольцевыми банками, неизменяемым снимком и
|
||||
общей картой GAS / ProtoCAN GAS / Modbus FC03/FC06. Весь код и проектные/MCU
|
||||
адаптеры находятся в `templates`; приложения подключают ревизию сабмодулем.
|
||||
Ядро C99 не содержит HAL, malloc, глобальных переменных, драйвера связи или
|
||||
прерываний. Хранение — RAM, после отключения питания данные исчезают.
|
||||
|
||||
Приложение → проектный источник сигналов → `gas_logger` → MCU-порт блокировки.
|
||||
GAS/Modbus/CAN диспетчеры → тот же экземпляр `gas_logger`.
|
||||
|
||||
## Состав
|
||||
|
||||
| Файл | Назначение / зависимости |
|
||||
|---|---|
|
||||
| `include/gas_logger.h`, `src/gas_logger.c` | переносимое ядро, только стандартный C99 |
|
||||
| `ports/pm35/` | источники ПМ35 и кэш напряжений ПМ67, сгенерированная карта |
|
||||
| `ports/c28x/` | сохранение/восстановление IRQ для TI F2812/F28335 |
|
||||
| `ports/stm32/` | PRIMASK для STM32 F1/F4/G4, CMSIS проекта |
|
||||
| `src/gas_logger_pcan.c` | регион сервиса `pcan_gas`, только MCU с поддержкой `uint8_t` |
|
||||
| `../../python/set_devices/gas_logger.py` | валидация карты, генератор C, клиент снимков; stdlib |
|
||||
| `../../python/set_devices/gas_logger_maps/pm35.json` | имена, адреса GAS/CAN/Modbus, источники и регистры сервиса |
|
||||
| `tests/` | ядро и сквозной тест клиента с настоящим C-регистратором |
|
||||
|
||||
## JSON и адреса
|
||||
|
||||
JSON — исходное описание. `ports/pm35/pm35_map.h` — автоматически созданная
|
||||
копия для прошивки. `tools/generate_map.py --check` проверяет их соответствие.
|
||||
Изменения выполняются в JSON, затем запускается генератор. CRC32 канонического
|
||||
JSON публикуется платой: клиент откажется читать/записывать при несовпадении
|
||||
карты. Это идентификатор совместимости, не механизм аутентификации.
|
||||
|
||||
Все адреса — **нулевые индексы 16-битных регистров**, без префикса 4xxxx.
|
||||
В поле `can.address` указан MsgBody для **ProtoCAN GAS**, а не полный CAN ID.
|
||||
DeviceType/DeviceID/Route выбирает транспорт проекта. Идентификаторы legacy
|
||||
CAN ПМ35 и ПМ67 не переименовываются в GAS: проект сначала обновляет их кэш,
|
||||
после чего регистратор читает его через `source`.
|
||||
|
||||
Экспериментальный профиль ПМ35:
|
||||
|
||||
| GAS | Modbus FC03 | ProtoCAN GAS | Имя | Источник |
|
||||
|---|---|---|---|---|
|
||||
| 0x5000 | 0x5000 | 0x5000 | Команды УМП | modbus[127] |
|
||||
| 0x5001 | 0x5001 | 0x5001 | Ток, 0.1 мА | modbus[28] |
|
||||
| 0x5002 | 0x5002 | 0x5002 | Код ЦАП | modbus[123] |
|
||||
| 0x5003 | 0x5003 | 0x5003 | Начальная уставка | modbus[80] |
|
||||
| 0x5004 | 0x5004 | 0x5004 | Конечная уставка | modbus[81] |
|
||||
| 0x5005 | 0x5005 | 0x5005 | ЗПТ1 | pm67_voltage[0], ранее принятый CAN адрес 305 |
|
||||
| 0x5006 | 0x5006 | 0x5006 | ЗПТ2 | pm67_voltage[1], ранее принятый CAN адрес 8 |
|
||||
|
||||
Адреса 0x5000/0x6000 — выделение **для тестового профиля**, не утверждение о
|
||||
поддержке этих окон существующей прошивкой. Перед включением в другую карту
|
||||
проект обязан проверить отсутствие пересечений со своими регионами.
|
||||
|
||||
## Карта сервиса
|
||||
|
||||
База одинакова для всех трёх транспортов: `0x6000`; имена и смещения также
|
||||
перечислены в JSON. Многословные числа: сначала младшее 16-битное слово.
|
||||
Endian байтов задаёт транспорт: Modbus BE, ProtoCAN GAS LE.
|
||||
|
||||
| Смещение | Доступ | Значение |
|
||||
|---|---|---|
|
||||
| 0, 1 | R | сигнатура 0x474C, версия 1 |
|
||||
| 2 | R | бит 0 запись, бит 1 снимок закреплён |
|
||||
| 3..5 | R | каналов, слов в записи, ёмкость банка |
|
||||
| 6, 7 | R | записей в текущем банке / снимке |
|
||||
| 8..9 | R | поколение снимка u32 |
|
||||
| 10..11 | R | CRC32 JSON-карты |
|
||||
| 12..13 | R | следующий номер записи u32 |
|
||||
| 14..15 | R | пропущенные снимки: запрос во время закреплённого снимка |
|
||||
| 16..17 | R | ошибки источников при записи |
|
||||
| 18 | W | 1 START, 2 SNAPSHOT, 3 RELEASE |
|
||||
| 19..20 | W | поколение, разрешённое к освобождению |
|
||||
| 0x100.. | R | записи снимка по прямому адресу, без команд курсора |
|
||||
|
||||
Запись: `time_ms:u32, sequence:u32, event:u16, channels[N]:u16`.
|
||||
В профиле ПМ35 — 12 слов, 760 записей на банк, всего 18240 слов RAM для двух
|
||||
банков. Блочное чтение разрешено по любой границе, максимум 125 слов.
|
||||
Клиент собирает записи из блоков, в том числе неполных по размеру записи.
|
||||
Классический CAN использует до 4 слов в кадре. Для UART подходит 120/125 слов.
|
||||
Для 760 записей этого профиля требуется 76 чтений по 120 слов без записи
|
||||
курсора; состав полей отличается от старого 42-словного УМП, поэтому это не
|
||||
прямое измерение ускорения прежнего лога.
|
||||
|
||||
## Непрерывная запись и снимки
|
||||
|
||||
`START` идемпотентен: включает запись, не очищает историю. STOP-команды нет.
|
||||
`SNAPSHOT` кратко блокирует IRQ, переключает активный банк и закрепляет старый.
|
||||
Во время загрузки новые записи продолжают поступать в другой кольцевой банк.
|
||||
Снимок не перезаписывается, пока клиент не передаст совпадающее поколение и
|
||||
`RELEASE`. Одновременные управляющие клиенты не поддерживаются.
|
||||
|
||||
Повторный SNAPSHOT при занятом снимке возвращает BUSY и увеличивает счётчик.
|
||||
Пустой банк возвращает EMPTY. Долгий обрыв связи не останавливает запись, но
|
||||
закреплённый снимок остаётся занят: нужно явно продолжить чтение (`resume=True`)
|
||||
либо освободить его. Автоматического таймаута освобождения нет.
|
||||
После переключения новый банк начинает историю с нуля; непрерывность означает
|
||||
продолжение сбора, а не дублирование предыстории между снимками.
|
||||
|
||||
Триггер/постинтервал определяет проект. Например, ПМ35 вызывает `gl_snapshot()`
|
||||
по завершении своей секунды после отключения, а не при начале события.
|
||||
Этот модуль не заменяет автоматически старую логику УМП.
|
||||
|
||||
## Контракт порта
|
||||
|
||||
```c
|
||||
uint32_t enter(void *user); /* сохранить и запретить IRQ */
|
||||
void leave(void *user, uint32_t previous); /* восстановить ровно previous */
|
||||
int source(void *user, uint16_t gas, uint16_t *value);
|
||||
```
|
||||
|
||||
`source` выполняется под блокировкой: только быстрые чтения RAM. Ни запросов
|
||||
CAN/Modbus, ни ожиданий периферии. Ошибка любого канала отбрасывает всю запись.
|
||||
Кэш CAN обновляется под той же блокировкой. Для RTOS/многоядерных платформ
|
||||
нужен порт с подходящей межпоточной синхронизацией; PRIMASK — для одного ядра.
|
||||
На стеке блочного чтения до 125 слов; при захвате до 64 слов каналов.
|
||||
Массивы банков не должны перекрываться друг с другом и контекстом.
|
||||
|
||||
## Подключение ПМ35 / F28335
|
||||
|
||||
```c
|
||||
#include "gas_logger_pm35.h"
|
||||
#include "gas_logger_c28x.h"
|
||||
static gas_logger logger;
|
||||
static uint16_t bank0[9120], bank1[9120]; /* разместить в RAM по linker map */
|
||||
static gl_pm35_context source;
|
||||
void logger_init(volatile uint16_t *modbus, volatile uint16_t *pm67) {
|
||||
source.modbus = modbus; source.pm67_voltage = pm67;
|
||||
source.enter = gl_c28x_enter; source.leave = gl_c28x_leave;
|
||||
source.irq_user = 0;
|
||||
if (gl_pm35_init(&logger, &source, bank0, bank1, 9120) == GL_OK)
|
||||
gl_write(&logger, 0x6012, GL_START);
|
||||
}
|
||||
/* Из существующего таймера: gl_capture(&logger, time_ms, event); */
|
||||
```
|
||||
|
||||
Включить `src/gas_logger.c`, `ports/pm35/gas_logger_pm35.c` и
|
||||
`ports/c28x/gas_logger_c28x.c`. Include-пути — `include`, `ports/pm35`,
|
||||
`ports/c28x`. F2812/F28335 используют одни intrinsics TI.
|
||||
Для STM32 подключить `ports/stm32/gas_logger_stm32.c`, выбрать
|
||||
`GL_STM32_DEVICE_HEADER` (`stm32f1xx.h`/`stm32f4xx.h`/`stm32g4xx.h`) и передать
|
||||
`gl_stm32_enter/leave`. Проектный PM35-адаптер применим к STM-эмулятору только
|
||||
если его массивы соответствуют указанному контракту.
|
||||
|
||||
FC03 диспетчер вызывает `gl_modbus_read()`, FC06 — `gl_modbus_write()`.
|
||||
GL_ADDRESS → exception 2, GL_ARGUMENT → 3, GL_BUSY → 6, остальные ошибки → 4.
|
||||
Ответ на запись отправляется лишь после GL_OK. CRC/кадры остаются в транспорте.
|
||||
Для `pcan_gas` добавляется регион `gl_pcan_region_init()`; сигнальные адреса
|
||||
маршрутизируются отдельно в `gl_can_read()` (или уже существующие GAS-регионы).
|
||||
Стандартные GAS-записи CAN не имеют ACK: CAN-адаптер клиента должен подтвердить
|
||||
результат чтением статуса; просто отправить CAN-кадр недостаточно.
|
||||
Передача UART/CAN выполняется **после** снятия IRQ-блокировки.
|
||||
|
||||
## Клиент и проверка
|
||||
|
||||
```python
|
||||
from set_devices.gas_logger import GasLoggerClient
|
||||
client = GasLoggerClient(read_registers, write_register, block_words=120)
|
||||
client.start() # после проверки сигнатуры и JSON CRC
|
||||
records = client.download() # фиксирует, читает, проверяет, освобождает
|
||||
```
|
||||
|
||||
Вызовы транспорта синхронные, подтверждённые; для GUI запускать в его рабочем
|
||||
потоке. Исключение/отмена оставляет снимок закреплённым для явного продолжения.
|
||||
Записи с таймаутом автоматически не повторяются.
|
||||
|
||||
```powershell
|
||||
python c/gas-logger/tools/generate_map.py --check
|
||||
python c/gas-logger/tests/run_tests.py --cc C:/msys64/mingw64/bin/gcc.exe
|
||||
```
|
||||
|
||||
Либо `cmake -S c/gas-logger -B <build>`, сборка и `ctest --test-dir <build>`.
|
||||
Ветка `codex/test-gas-logger`: потребитель SETGUI импортирует модуль из
|
||||
`third_party/templates` и включает JSON в пакет. Производственные прошивки
|
||||
ПМ35/166 пока не переключены; адреса не включены в их текущие диспетчеры.
|
||||
Нужны подключение к таймеру/карте конкретной платы, проверка linker RAM и
|
||||
испытание на устройстве. Проверки на хосте не измеряют аппаратные тайминги.
|
||||
70
c/gas-logger/include/gas_logger.h
Normal file
70
c/gas-logger/include/gas_logger.h
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Portable continuous recorder. All addresses/counts are 16-bit WORDS,
|
||||
* including on C28x (CHAR_BIT=16). No heap, UART, HAL or global state. */
|
||||
#ifndef GAS_LOGGER_H
|
||||
#define GAS_LOGGER_H
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define GL_MAGIC 0x474CU
|
||||
#define GL_VERSION 1U
|
||||
#define GL_HEADER_WORDS 5U /* time_ms:u32, sequence:u32, event:u16, values... */
|
||||
#define GL_DATA_OFFSET 0x100U
|
||||
#define GL_MAX_CHANNELS 64U
|
||||
#define GL_COMMAND 18U
|
||||
#define GL_RELEASE_LO 19U
|
||||
#define GL_RELEASE_HI 20U
|
||||
#define GL_START 1U
|
||||
#define GL_SNAPSHOT 2U
|
||||
#define GL_RELEASE 3U
|
||||
typedef enum { GL_OK=0, GL_ADDRESS, GL_ARGUMENT, GL_BUSY, GL_EMPTY,
|
||||
GL_STALE, GL_SOURCE_ERROR } gl_result;
|
||||
typedef struct {
|
||||
uint16_t gas, modbus, can;
|
||||
} gl_channel;
|
||||
typedef struct {
|
||||
uint16_t base, channels, capacity;
|
||||
uint32_t schema_id;
|
||||
const gl_channel *map;
|
||||
} gl_config;
|
||||
/* enter returns the previous IRQ state; leave MUST restore it, not enable
|
||||
* IRQs unconditionally. source executes under this lock: bounded RAM reads
|
||||
* only, never I/O/waits. Cache remote CAN values before sampling. */
|
||||
typedef struct {
|
||||
void *user;
|
||||
uint32_t (*enter)(void *user);
|
||||
void (*leave)(void *user, uint32_t state);
|
||||
int (*source)(void *user, uint16_t gas, uint16_t *value);
|
||||
} gl_port;
|
||||
typedef struct {
|
||||
gl_config config;
|
||||
gl_port port;
|
||||
uint16_t *banks[2];
|
||||
uint16_t active, count, next, recording, pinned, snap_count, snap_next;
|
||||
uint32_t generation, release_generation, sequence, missed, source_errors;
|
||||
} gas_logger;
|
||||
/* Each distinct bank must have >=capacity*(channels+5) uint16_t elements.
|
||||
* All callbacks and a nonoverlapping register map are required. */
|
||||
gl_result gl_init(gas_logger *log, const gl_config *config, const gl_port *port,
|
||||
uint16_t *bank0, uint16_t *bank1, uint32_t bank_words);
|
||||
/* Called by the project timer/event hook. A failed source discards the whole
|
||||
* sample. Sequence/time remain uint32, wrapping modulo 2^32. */
|
||||
gl_result gl_capture(gas_logger *log, uint32_t time_ms, uint16_t event);
|
||||
/* Snapshot swaps banks, pins the old one, and NEVER stops acquisition.
|
||||
* Another snapshot while pinned returns BUSY and increments missed. */
|
||||
gl_result gl_snapshot(gas_logger *log);
|
||||
/* Atomic, all-or-nothing block read. Snapshot records are chronological;
|
||||
* reads past snap_count are rejected, not zero-filled. */
|
||||
gl_result gl_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count);
|
||||
/* No STOP/reset command. START is idempotent. RELEASE requires the generation
|
||||
* staged in base+19/20. One controlling client per device. */
|
||||
gl_result gl_write(gas_logger *log, uint16_t address, uint16_t value);
|
||||
/* Register aliases for FC03/06 and ProtoCAN GAS. Transports keep their own
|
||||
* framing/CRC; addresses are zero-based and words preserve their values. */
|
||||
gl_result gl_modbus_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count);
|
||||
gl_result gl_modbus_write(gas_logger *log, uint16_t address, uint16_t value);
|
||||
gl_result gl_can_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
8
c/gas-logger/include/gas_logger_pcan.h
Normal file
8
c/gas-logger/include/gas_logger_pcan.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifndef GAS_LOGGER_PCAN_H
|
||||
#define GAS_LOGGER_PCAN_H
|
||||
#include "gas_logger.h"
|
||||
#include "pcan_gas.h"
|
||||
/* Service region; signal aliases may be registered separately in the project's
|
||||
* GAS dispatcher. CAN callbacks use the logger's alias map. */
|
||||
void gl_pcan_region_init(pcan_gas_region_t *region,gas_logger *log);
|
||||
#endif
|
||||
4
c/gas-logger/ports/c28x/gas_logger_c28x.c
Normal file
4
c/gas-logger/ports/c28x/gas_logger_c28x.c
Normal file
@@ -0,0 +1,4 @@
|
||||
#include "gas_logger_c28x.h"
|
||||
/* __disable_interrupts/__restore_interrupts are TI C28x compiler intrinsics. */
|
||||
uint32_t gl_c28x_enter(void *user) { (void)user; return __disable_interrupts(); }
|
||||
void gl_c28x_leave(void *user,uint32_t state) { (void)user; __restore_interrupts((unsigned int)state); }
|
||||
7
c/gas-logger/ports/c28x/gas_logger_c28x.h
Normal file
7
c/gas-logger/ports/c28x/gas_logger_c28x.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef GAS_LOGGER_C28X_H
|
||||
#define GAS_LOGGER_C28X_H
|
||||
#include <stdint.h>
|
||||
/* TI C2000 compiler; F2812/F28335. Supply these as gl_port lock callbacks. */
|
||||
uint32_t gl_c28x_enter(void *user);
|
||||
void gl_c28x_leave(void *user,uint32_t state);
|
||||
#endif
|
||||
22
c/gas-logger/ports/pm35/gas_logger_pm35.c
Normal file
22
c/gas-logger/ports/pm35/gas_logger_pm35.c
Normal file
@@ -0,0 +1,22 @@
|
||||
#include "gas_logger_pm35.h"
|
||||
#include "pm35_map.h"
|
||||
static uint32_t enter(void *u) { gl_pm35_context *p=u; return p->enter(p->irq_user); }
|
||||
static void leave(void *u,uint32_t state) { gl_pm35_context *p=u; p->leave(p->irq_user,state); }
|
||||
static int source(void *u,uint16_t gas,uint16_t *v)
|
||||
{
|
||||
gl_pm35_context *p=u;
|
||||
uint16_t i;
|
||||
for(i=0;i<gl_pm35_config.channels;i++) if(gl_pm35_channels[i].gas==gas) {
|
||||
*v=gl_pm35_source_space[i] ? p->pm67_voltage[gl_pm35_source_address[i]] :
|
||||
p->modbus[gl_pm35_source_address[i]];
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
gl_result gl_pm35_init(gas_logger *l,gl_pm35_context *p,uint16_t *a,uint16_t *b,uint32_t n)
|
||||
{
|
||||
gl_port port;
|
||||
if(!p || !p->modbus || !p->pm67_voltage || !p->enter || !p->leave) return GL_ARGUMENT;
|
||||
port.user=p;port.enter=enter;port.leave=leave;port.source=source;
|
||||
return gl_init(l,&gl_pm35_config,&port,a,b,n);
|
||||
}
|
||||
16
c/gas-logger/ports/pm35/gas_logger_pm35.h
Normal file
16
c/gas-logger/ports/pm35/gas_logger_pm35.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#ifndef GAS_LOGGER_PM35_H
|
||||
#define GAS_LOGGER_PM35_H
|
||||
#include "gas_logger.h"
|
||||
/* Project adapter: pm35_modbus uses the firmware's zero-based modbus[128].
|
||||
* pm67_voltage[2] is the latest CAN cache, updated under the same IRQ lock.
|
||||
* No direct CAN requests are performed while capturing. */
|
||||
typedef struct {
|
||||
volatile uint16_t *modbus;
|
||||
volatile uint16_t *pm67_voltage;
|
||||
uint32_t (*enter)(void *);
|
||||
void (*leave)(void *,uint32_t);
|
||||
void *irq_user;
|
||||
} gl_pm35_context;
|
||||
gl_result gl_pm35_init(gas_logger *log, gl_pm35_context *context,
|
||||
uint16_t *bank0, uint16_t *bank1, uint32_t bank_words);
|
||||
#endif
|
||||
19
c/gas-logger/ports/pm35/pm35_map.h
Normal file
19
c/gas-logger/ports/pm35/pm35_map.h
Normal file
@@ -0,0 +1,19 @@
|
||||
/* Generated from pm35.json. Do not edit. */
|
||||
#ifndef GL_PM35_MAP_H
|
||||
#define GL_PM35_MAP_H
|
||||
#include "gas_logger.h"
|
||||
static const gl_channel gl_pm35_channels[] = {
|
||||
{20480U, 20480U, 20480U}, /* commands */
|
||||
{20481U, 20481U, 20481U}, /* current */
|
||||
{20482U, 20482U, 20482U}, /* dac */
|
||||
{20483U, 20483U, 20483U}, /* go */
|
||||
{20484U, 20484U, 20484U}, /* stop */
|
||||
{20485U, 20485U, 20485U}, /* zpt1 */
|
||||
{20486U, 20486U, 20486U}, /* zpt2 */
|
||||
};
|
||||
static const uint16_t gl_pm35_source_space[] = { 0U, 0U, 0U, 0U, 0U, 1U, 1U };
|
||||
static const uint16_t gl_pm35_source_address[] = { 127U, 28U, 123U, 80U, 81U, 0U, 1U };
|
||||
static const gl_config gl_pm35_config = {
|
||||
24576U, 7U, 760U, 0x635F84CBUL, gl_pm35_channels
|
||||
};
|
||||
#endif
|
||||
8
c/gas-logger/ports/stm32/gas_logger_stm32.c
Normal file
8
c/gas-logger/ports/stm32/gas_logger_stm32.c
Normal file
@@ -0,0 +1,8 @@
|
||||
#include "gas_logger_stm32.h"
|
||||
#ifndef GL_STM32_DEVICE_HEADER
|
||||
#define GL_STM32_DEVICE_HEADER "stm32f4xx.h"
|
||||
#endif
|
||||
#include GL_STM32_DEVICE_HEADER
|
||||
uint32_t gl_stm32_enter(void *user)
|
||||
{ uint32_t saved; (void)user; saved=__get_PRIMASK(); __disable_irq(); return saved; }
|
||||
void gl_stm32_leave(void *user,uint32_t state) { (void)user; __set_PRIMASK(state); }
|
||||
8
c/gas-logger/ports/stm32/gas_logger_stm32.h
Normal file
8
c/gas-logger/ports/stm32/gas_logger_stm32.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifndef GAS_LOGGER_STM32_H
|
||||
#define GAS_LOGGER_STM32_H
|
||||
#include <stdint.h>
|
||||
/* Define GL_STM32_DEVICE_HEADER to the project's CMSIS device header.
|
||||
* F1/F4/G4 use the same PRIMASK contract. No HAL/interrupt handler ownership. */
|
||||
uint32_t gl_stm32_enter(void *user);
|
||||
void gl_stm32_leave(void *user,uint32_t state);
|
||||
#endif
|
||||
161
c/gas-logger/src/gas_logger.c
Normal file
161
c/gas-logger/src/gas_logger.c
Normal file
@@ -0,0 +1,161 @@
|
||||
#include "gas_logger.h"
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
static uint16_t width(const gas_logger *l) { return l->config.channels + GL_HEADER_WORDS; }
|
||||
static int service(const gl_config *c, uint16_t a)
|
||||
{
|
||||
uint32_t end = (uint32_t)c->base + GL_DATA_OFFSET +
|
||||
(uint32_t)c->capacity * (c->channels + GL_HEADER_WORDS);
|
||||
return a >= c->base && (uint32_t)a < end;
|
||||
}
|
||||
gl_result gl_init(gas_logger *l, const gl_config *c, const gl_port *p,
|
||||
uint16_t *b0, uint16_t *b1, uint32_t words)
|
||||
{
|
||||
uint16_t i,j;
|
||||
uint32_t needed;
|
||||
if (!l || !c || !p || !b0 || !b1 || b0 == b1 || !c->map ||
|
||||
!p->enter || !p->leave || !p->source || !c->capacity ||
|
||||
!c->channels || c->channels > GL_MAX_CHANNELS) return GL_ARGUMENT;
|
||||
needed = (uint32_t)c->capacity * (c->channels + GL_HEADER_WORDS);
|
||||
if (needed > words || (uint32_t)c->base + GL_DATA_OFFSET + needed > 65536UL)
|
||||
return GL_ARGUMENT;
|
||||
for (i=0;i<c->channels;i++) {
|
||||
if (service(c,c->map[i].gas) || service(c,c->map[i].modbus) ||
|
||||
service(c,c->map[i].can)) return GL_ARGUMENT;
|
||||
for (j=0;j<i;j++)
|
||||
if (c->map[i].gas == c->map[j].gas ||
|
||||
c->map[i].modbus == c->map[j].modbus ||
|
||||
c->map[i].can == c->map[j].can) return GL_ARGUMENT;
|
||||
}
|
||||
memset(l,0,sizeof(*l)); l->config=*c; l->port=*p; l->banks[0]=b0; l->banks[1]=b1;
|
||||
return GL_OK;
|
||||
}
|
||||
gl_result gl_capture(gas_logger *l, uint32_t time, uint16_t event)
|
||||
{
|
||||
uint16_t values[GL_MAX_CHANNELS],i,*record;
|
||||
uint32_t irq = l->port.enter(l->port.user);
|
||||
if (!l->recording) { l->port.leave(l->port.user,irq); return GL_BUSY; }
|
||||
for (i=0;i<l->config.channels;i++) {
|
||||
if (!l->port.source(l->port.user,l->config.map[i].gas,&values[i])) {
|
||||
l->source_errors++; l->port.leave(l->port.user,irq); return GL_SOURCE_ERROR;
|
||||
}
|
||||
}
|
||||
record=l->banks[l->active]+(uint32_t)l->next*width(l);
|
||||
record[0]=(uint16_t)time; record[1]=(uint16_t)(time>>16);
|
||||
record[2]=(uint16_t)l->sequence; record[3]=(uint16_t)(l->sequence>>16);
|
||||
record[4]=event;
|
||||
for (i=0;i<l->config.channels;i++) record[GL_HEADER_WORDS+i]=values[i];
|
||||
l->sequence++;
|
||||
if (++l->next == l->config.capacity) l->next=0;
|
||||
if (l->count < l->config.capacity) l->count++;
|
||||
l->port.leave(l->port.user,irq); return GL_OK;
|
||||
}
|
||||
static gl_result snapshot(gas_logger *l)
|
||||
{
|
||||
if (l->pinned) { l->missed++; return GL_BUSY; }
|
||||
if (!l->count) return GL_EMPTY;
|
||||
l->snap_count=l->count; l->snap_next=l->next;
|
||||
l->active^=1U; l->count=0; l->next=0; l->pinned=1;
|
||||
l->generation++; if (!l->generation) l->generation=1;
|
||||
return GL_OK;
|
||||
}
|
||||
gl_result gl_snapshot(gas_logger *l)
|
||||
{
|
||||
uint32_t irq=l->port.enter(l->port.user);
|
||||
gl_result r=snapshot(l); l->port.leave(l->port.user,irq); return r;
|
||||
}
|
||||
static uint16_t status(const gas_logger *l, uint16_t offset)
|
||||
{
|
||||
switch(offset) {
|
||||
case 0:return GL_MAGIC; case 1:return GL_VERSION;
|
||||
case 2:return l->recording | (l->pinned<<1);
|
||||
case 3:return l->config.channels; case 4:return width(l);
|
||||
case 5:return l->config.capacity; case 6:return l->count; case 7:return l->snap_count;
|
||||
case 8:return (uint16_t)l->generation; case 9:return (uint16_t)(l->generation>>16);
|
||||
case 10:return (uint16_t)l->config.schema_id; case 11:return (uint16_t)(l->config.schema_id>>16);
|
||||
case 12:return (uint16_t)l->sequence; case 13:return (uint16_t)(l->sequence>>16);
|
||||
case 14:return (uint16_t)l->missed; case 15:return (uint16_t)(l->missed>>16);
|
||||
case 16:return (uint16_t)l->source_errors; case 17:return (uint16_t)(l->source_errors>>16);
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
static gl_result read_locked(gas_logger *l, uint16_t a, uint16_t *out, uint16_t n)
|
||||
{
|
||||
uint16_t i,j,slot;
|
||||
uint32_t off;
|
||||
if (a>=l->config.base && (uint32_t)a+n <= (uint32_t)l->config.base+18) {
|
||||
for(i=0;i<n;i++) out[i]=status(l,(uint16_t)(a+i-l->config.base));
|
||||
return GL_OK;
|
||||
}
|
||||
if ((uint32_t)a >= (uint32_t)l->config.base+GL_DATA_OFFSET) {
|
||||
off=(uint32_t)a-l->config.base-GL_DATA_OFFSET;
|
||||
if (l->pinned && off+n <= (uint32_t)l->snap_count*width(l)) {
|
||||
for(i=0;i<n;i++) {
|
||||
slot=(uint16_t)((off+i)/width(l));
|
||||
if(l->snap_count==l->config.capacity)
|
||||
slot=(uint16_t)(((uint32_t)slot+l->snap_next)%l->config.capacity);
|
||||
out[i]=l->banks[l->active^1U][(uint32_t)slot*width(l)+(off+i)%width(l)];
|
||||
}
|
||||
return GL_OK;
|
||||
}
|
||||
}
|
||||
/* A live signal block must be completely mapped before any read. */
|
||||
for(i=0;i<n;i++) {
|
||||
for(j=0;j<l->config.channels;j++) if(l->config.map[j].gas==a+i) break;
|
||||
if(j==l->config.channels) return GL_ADDRESS;
|
||||
}
|
||||
for(i=0;i<n;i++)
|
||||
if(!l->port.source(l->port.user,(uint16_t)(a+i),&out[i])) return GL_SOURCE_ERROR;
|
||||
return GL_OK;
|
||||
}
|
||||
gl_result gl_read(gas_logger *l, uint16_t a, uint16_t *out, uint16_t n)
|
||||
{
|
||||
uint16_t temp[125],i;
|
||||
uint32_t irq;
|
||||
gl_result r;
|
||||
if(!out || !n || n>125 || (uint32_t)a+n>65536UL) return GL_ARGUMENT;
|
||||
irq=l->port.enter(l->port.user); r=read_locked(l,a,temp,n);
|
||||
if(r==GL_OK) for(i=0;i<n;i++) out[i]=temp[i];
|
||||
l->port.leave(l->port.user,irq); return r;
|
||||
}
|
||||
gl_result gl_write(gas_logger *l, uint16_t a, uint16_t v)
|
||||
{
|
||||
uint32_t irq=l->port.enter(l->port.user);
|
||||
gl_result r=GL_OK;
|
||||
if((uint32_t)a==(uint32_t)l->config.base+GL_RELEASE_LO)
|
||||
l->release_generation=(l->release_generation&0xFFFF0000UL)|v;
|
||||
else if((uint32_t)a==(uint32_t)l->config.base+GL_RELEASE_HI)
|
||||
l->release_generation=(l->release_generation&0xFFFFUL)|((uint32_t)v<<16);
|
||||
else if((uint32_t)a!=(uint32_t)l->config.base+GL_COMMAND) r=GL_ADDRESS;
|
||||
else if(v==GL_START) l->recording=1;
|
||||
else if(v==GL_SNAPSHOT) r=snapshot(l);
|
||||
else if(v==GL_RELEASE) {
|
||||
if(!l->pinned || l->release_generation!=l->generation) r=GL_STALE;
|
||||
else { l->pinned=0; l->snap_count=0; }
|
||||
} else r=GL_ARGUMENT;
|
||||
l->port.leave(l->port.user,irq); return r;
|
||||
}
|
||||
static gl_result alias_read(gas_logger *l,uint16_t a,uint16_t *out,uint16_t n,int can)
|
||||
{
|
||||
uint16_t i,j,gas=0,found;
|
||||
if(!n || n>125 || (uint32_t)a+n>65536UL) return GL_ARGUMENT;
|
||||
if(service(&l->config,a)) return gl_read(l,a,out,n);
|
||||
for(i=0;i<n;i++) {
|
||||
found=0;
|
||||
for(j=0;j<l->config.channels;j++)
|
||||
if((can?l->config.map[j].can:l->config.map[j].modbus)==a+i) {
|
||||
if(!i) gas=l->config.map[j].gas;
|
||||
if((uint32_t)gas+i!=l->config.map[j].gas) return GL_ADDRESS;
|
||||
found=1;break;
|
||||
}
|
||||
if(!found) return GL_ADDRESS;
|
||||
}
|
||||
return gl_read(l,gas,out,n);
|
||||
}
|
||||
gl_result gl_modbus_read(gas_logger *l,uint16_t a,uint16_t *o,uint16_t n)
|
||||
{ return alias_read(l,a,o,n,0); }
|
||||
gl_result gl_can_read(gas_logger *l,uint16_t a,uint16_t *o,uint16_t n)
|
||||
{ return alias_read(l,a,o,n,1); }
|
||||
gl_result gl_modbus_write(gas_logger *l,uint16_t a,uint16_t v)
|
||||
{ return gl_write(l,a,v); }
|
||||
13
c/gas-logger/src/gas_logger_pcan.c
Normal file
13
c/gas-logger/src/gas_logger_pcan.c
Normal file
@@ -0,0 +1,13 @@
|
||||
#include "gas_logger_pcan.h"
|
||||
static pcan_gas_status_t result(gl_result r)
|
||||
{ return r==GL_OK ? PCAN_GAS_OK : r==GL_ADDRESS ? PCAN_GAS_NO_REG : PCAN_GAS_REJECTED; }
|
||||
static pcan_gas_status_t read_word(const pcan_gas_region_t *r,uint16_t o,uint16_t *v)
|
||||
{ return result(gl_can_read(r->user,(uint16_t)(r->base+o),v,1)); }
|
||||
static pcan_gas_status_t write_word(const pcan_gas_region_t *r,uint16_t o,uint16_t v)
|
||||
{ return result(gl_write(r->user,(uint16_t)(r->base+o),v)); }
|
||||
void gl_pcan_region_init(pcan_gas_region_t *r,gas_logger *l)
|
||||
{
|
||||
r->base=l->config.base;
|
||||
r->count=(uint16_t)(GL_DATA_OFFSET+(uint32_t)l->config.capacity*(l->config.channels+GL_HEADER_WORDS));
|
||||
r->storage=0;r->read=read_word;r->write=write_word;r->flags=0;r->user=l;r->name="gas-logger";
|
||||
}
|
||||
22
c/gas-logger/tests/host_device.c
Normal file
22
c/gas-logger/tests/host_device.c
Normal file
@@ -0,0 +1,22 @@
|
||||
/* Host-only firmware harness for the actual PM35 port and Python client. */
|
||||
#include "gas_logger_pm35.h"
|
||||
#include "gas_logger_pcan.h"
|
||||
#include <assert.h>
|
||||
static gas_logger log;
|
||||
static uint16_t bank0[9120],bank1[9120],modbus[128],voltage[2];
|
||||
static uint32_t irq;
|
||||
static uint32_t enter(void *u) { uint32_t old=irq;(void)u;irq=1;return old; }
|
||||
static void leave(void *u,uint32_t state) { (void)u;assert(irq==1);irq=state; }
|
||||
static gl_pm35_context context={modbus,voltage,enter,leave,0};
|
||||
int host_init(void) { irq=0;return gl_pm35_init(&log,&context,bank0,bank1,9120); }
|
||||
int host_capture(uint32_t time,uint16_t value)
|
||||
{ modbus[28]=value;modbus[127]=128;voltage[0]=1234;voltage[1]=5678;return gl_capture(&log,time,1); }
|
||||
int host_read(uint16_t address,uint16_t *out,uint16_t count)
|
||||
{ return gl_modbus_read(&log,address,out,count); }
|
||||
int host_write(uint16_t address,uint16_t value) { return gl_modbus_write(&log,address,value); }
|
||||
int host_pcan_read(uint16_t address,uint16_t *out)
|
||||
{
|
||||
pcan_gas_region_t region;gl_pcan_region_init(®ion,&log);
|
||||
if(address<region.base || (uint32_t)address>=region.base+region.count) return GL_ADDRESS;
|
||||
return region.read(®ion,(uint16_t)(address-region.base),out);
|
||||
}
|
||||
46
c/gas-logger/tests/run_tests.py
Normal file
46
c/gas-logger/tests/run_tests.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Build in a temporary directory; run core + Python/C end-to-end tests."""
|
||||
import argparse
|
||||
import ctypes
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--cc',default=shutil.which('gcc'))
|
||||
args=parser.parse_args()
|
||||
if not args.cc:raise SystemExit('Specify --cc /path/to/gcc')
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
templates=root.parents[1]
|
||||
sys.path.insert(0,str(templates/'python'))
|
||||
env=dict(os.environ)
|
||||
env['PATH']=str(Path(args.cc).resolve().parent)+os.pathsep+env.get('PATH','')
|
||||
flags=[args.cc,'-std=c99','-Wall','-Wextra','-Werror','-pedantic','-I'+str(root/'include')]
|
||||
with tempfile.TemporaryDirectory(prefix='gas-logger-') as directory:
|
||||
binary=Path(directory)/'core.exe'
|
||||
subprocess.run(flags+[str(root/'src/gas_logger.c'),str(root/'tests/test_gas_logger.c'),'-o',str(binary)],check=True,env=env)
|
||||
subprocess.run([str(binary)],check=True,env=env)
|
||||
lib=Path(directory)/('device.dll' if os.name=='nt' else 'device.so')
|
||||
subprocess.run(flags+([] if os.name=='nt' else ['-fPIC'])+['-shared','-I'+str(root/'ports/pm35'),
|
||||
'-I'+str(templates/'c/set-protocol/include'),
|
||||
str(root/'src/gas_logger.c'),str(root/'src/gas_logger_pcan.c'),
|
||||
str(root/'ports/pm35/gas_logger_pm35.c'),str(root/'tests/host_device.c'),
|
||||
'-o',str(lib)],check=True,env=env)
|
||||
# Child releases the loaded DLL before TemporaryDirectory cleanup on Windows.
|
||||
runner='''import ctypes,importlib.util,sys,unittest
|
||||
spec=importlib.util.spec_from_file_location('test_client',sys.argv[1])
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
m.DEVICE=ctypes.CDLL(sys.argv[2])
|
||||
m.DEVICE.host_capture.argtypes=[ctypes.c_uint32,ctypes.c_uint16]
|
||||
m.DEVICE.host_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16),ctypes.c_uint16]
|
||||
m.DEVICE.host_write.argtypes=[ctypes.c_uint16,ctypes.c_uint16]
|
||||
m.DEVICE.host_pcan_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16)]
|
||||
r=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromModule(m))
|
||||
sys.exit(not r.wasSuccessful())'''
|
||||
env['PYTHONPATH']=str(templates/'python')
|
||||
subprocess.run([sys.executable,'-c',runner,str(root/'tests/test_client.py'),str(lib)],check=True,env=env)
|
||||
subprocess.run([sys.executable,str(root/'tools/generate_map.py'),'--check'],check=True,env=env)
|
||||
111
c/gas-logger/tests/test_client.py
Normal file
111
c/gas-logger/tests/test_client.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Integration: Python client -> register callbacks -> compiled C PM35 logger."""
|
||||
import copy
|
||||
import ctypes
|
||||
import unittest
|
||||
from set_devices.gas_logger import GasLoggerClient, load_map, validate_map
|
||||
|
||||
DEVICE = None # set by run_tests.py before loading the suite
|
||||
|
||||
|
||||
class ClientTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.lib=DEVICE
|
||||
self.assertEqual(self.lib.host_init(),0)
|
||||
self.client=GasLoggerClient(self.read,self.write)
|
||||
self.client.start()
|
||||
for i in range(800):
|
||||
self.assertEqual(self.lib.host_capture(i,i),0)
|
||||
|
||||
def read(self,address,count):
|
||||
out=(ctypes.c_uint16*count)()
|
||||
result=self.lib.host_read(address,out,count)
|
||||
if result: raise RuntimeError('read '+str(result))
|
||||
return list(out)
|
||||
|
||||
def write(self,address,value):
|
||||
result=self.lib.host_write(address,value)
|
||||
if result: raise RuntimeError('write '+str(result))
|
||||
|
||||
def test_download_keeps_recording_and_order(self):
|
||||
def progress(done,total):
|
||||
for i in range(20): self.assertEqual(self.lib.host_capture(900+done+i,99),0)
|
||||
records=self.client.download(progress=progress)
|
||||
self.assertEqual([r['time_ms'] for r in records],list(range(40,800)))
|
||||
self.assertEqual([r['current'] for r in records],list(range(40,800)))
|
||||
self.assertTrue(all(r['zpt2']==5678 for r in records))
|
||||
self.assertEqual(self.client.status()[2],1)
|
||||
self.assertGreater(self.client.status()[6],0)
|
||||
|
||||
def test_cancel_retains_snapshot_and_explicit_resume(self):
|
||||
cancel=[False]
|
||||
def progress(done,total): cancel[0]=True
|
||||
with self.assertRaises(InterruptedError):
|
||||
self.client.download(cancelled=lambda:cancel[0],progress=progress)
|
||||
self.assertEqual(self.client.status()[2],3)
|
||||
with self.assertRaises(RuntimeError): self.client.download()
|
||||
self.assertEqual(len(self.client.download(resume=True)),760)
|
||||
|
||||
def test_partial_reply_leaves_pin(self):
|
||||
original=self.client.read
|
||||
self.client.read=lambda a,n: original(a,n)[:-1] if a>=0x6100 else original(a,n)
|
||||
with self.assertRaises(ValueError): self.client.download()
|
||||
self.client.read=original
|
||||
self.assertEqual(self.client.status()[2],3)
|
||||
|
||||
def test_no_writes_for_wrong_schema(self):
|
||||
m=load_map();m['channels'][0]['name']='Other'
|
||||
writes=[]
|
||||
client=GasLoggerClient(self.read,lambda *a:writes.append(a),m)
|
||||
with self.assertRaises(ValueError): client.download()
|
||||
self.assertEqual(writes,[])
|
||||
|
||||
def test_generation_mismatch_does_not_release(self):
|
||||
self.write(0x6012,2)
|
||||
with self.assertRaises(ValueError):self.client.release(999)
|
||||
self.assertEqual(self.client.status()[2],3)
|
||||
|
||||
def test_classic_can_chunks_and_pcan_region(self):
|
||||
def read(a,n):
|
||||
words=[]
|
||||
for i in range(n):
|
||||
out=ctypes.c_uint16()
|
||||
self.assertEqual(self.lib.host_pcan_read(a+i,ctypes.byref(out)),0)
|
||||
words.append(out.value)
|
||||
return words
|
||||
client=GasLoggerClient(read,self.write,block_words=4)
|
||||
self.assertEqual(len(client.download()),760)
|
||||
|
||||
def test_json_rejects_collisions_and_drift(self):
|
||||
base=load_map()
|
||||
for change in (
|
||||
lambda m:m['channels'][0].update(gas=0x6000),
|
||||
lambda m:m['channels'][1].update(gas=m['channels'][0]['gas']),
|
||||
lambda m:m['service'].update(data_offset=512),
|
||||
lambda m:m['service']['registers'][0].update(key='wrong'),
|
||||
lambda m:m.update(capacity=65535),
|
||||
):
|
||||
m=copy.deepcopy(base);change(m)
|
||||
with self.assertRaises(ValueError):validate_map(m)
|
||||
|
||||
def test_replaced_snapshot_is_rejected_before_release(self):
|
||||
changed=[False]
|
||||
def progress(done,total):
|
||||
if changed[0]:return
|
||||
changed[0]=True
|
||||
self.client.release(1)
|
||||
self.assertEqual(self.lib.host_capture(2000,999),0)
|
||||
self.write(0x6012,2)
|
||||
# Simulates a second, unsupported controlling client. We must never
|
||||
# accept a mixture of generations or release the newer snapshot.
|
||||
with self.assertRaises((ValueError,RuntimeError)):
|
||||
self.client.download(progress=progress)
|
||||
status=self.client.status()
|
||||
self.assertEqual(status[2],3)
|
||||
self.assertEqual(status[8],2)
|
||||
|
||||
def test_start_does_not_reset_history(self):
|
||||
before=self.client.status()
|
||||
self.client.start()
|
||||
after=self.client.status()
|
||||
self.assertEqual(before[6],after[6])
|
||||
self.assertEqual(before[12:14],after[12:14])
|
||||
54
c/gas-logger/tests/test_gas_logger.c
Normal file
54
c/gas-logger/tests/test_gas_logger.c
Normal file
@@ -0,0 +1,54 @@
|
||||
#include "gas_logger.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
typedef struct { uint32_t irq; uint16_t value; int fail; } board;
|
||||
static uint32_t enter(void *u) { board *b=u; uint32_t old=b->irq; b->irq=1; return old; }
|
||||
static void leave(void *u,uint32_t state) { board *b=u; assert(b->irq==1); b->irq=state; }
|
||||
static int source(void *u,uint16_t a,uint16_t *v)
|
||||
{ board *b=u; assert(b->irq==1); if(b->fail && a==11) return 0; *v=b->value+a; return 1; }
|
||||
int main(void)
|
||||
{
|
||||
gl_channel channels[]={{10,100,200},{11,101,201}};
|
||||
gl_config c={0x6000,2,3,0x12345678UL,channels};
|
||||
board b={0,0,0};
|
||||
gl_port p={&b,enter,leave,source};
|
||||
gas_logger l;
|
||||
uint16_t bank0[21],bank1[21],out[125],i;
|
||||
assert(gl_init(&l,&c,&p,bank0,bank1,20)==GL_ARGUMENT);
|
||||
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_OK);
|
||||
assert(gl_snapshot(&l)==GL_EMPTY);
|
||||
assert(gl_capture(&l,0,0)==GL_BUSY);
|
||||
assert(gl_write(&l,0x6012,GL_START)==GL_OK);
|
||||
for(i=0;i<5;i++) { b.value=i; assert(gl_capture(&l,i,2)==GL_OK); }
|
||||
assert(gl_snapshot(&l)==GL_OK && l.recording && l.count==0);
|
||||
assert(gl_read(&l,0x6100,out,21)==GL_OK);
|
||||
assert(out[0]==2 && out[7]==3 && out[14]==4 && out[5]==12);
|
||||
for(i=5;i<15;i++) { b.value=i; assert(gl_capture(&l,i,3)==GL_OK); }
|
||||
assert(gl_snapshot(&l)==GL_BUSY && l.missed==1 && l.recording);
|
||||
assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==2 && out[14]==4);
|
||||
assert(gl_read(&l,0x6114,out,2)==GL_ADDRESS);
|
||||
assert(gl_read(&l,65535,out,2)==GL_ARGUMENT);
|
||||
assert(gl_write(&l,0x6012,GL_RELEASE)==GL_STALE);
|
||||
assert(gl_write(&l,0x6013,1)==GL_OK);
|
||||
assert(gl_write(&l,0x6014,0)==GL_OK);
|
||||
assert(gl_write(&l,0x6012,GL_RELEASE)==GL_OK && l.recording);
|
||||
assert(gl_read(&l,0x6100,out,1)==GL_ADDRESS);
|
||||
assert(gl_snapshot(&l)==GL_OK);
|
||||
assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==12 && out[14]==14);
|
||||
assert(gl_modbus_read(&l,100,out,2)==GL_OK && out[0]==24 && out[1]==25);
|
||||
assert(gl_can_read(&l,200,out,2)==GL_OK && out[0]==24 && out[1]==25);
|
||||
b.fail=1; out[0]=555;out[1]=666;
|
||||
assert(gl_read(&l,10,out,2)==GL_SOURCE_ERROR && out[0]==555 && out[1]==666);
|
||||
assert(gl_capture(&l,15,0)==GL_SOURCE_ERROR && l.count==0 && l.source_errors==1);
|
||||
b.fail=0;b.irq=1;
|
||||
assert(gl_capture(&l,16,0)==GL_OK && b.irq==1);
|
||||
b.irq=0;l.sequence=0xffffffffUL;
|
||||
assert(gl_capture(&l,0xffffffffUL,0)==GL_OK && l.sequence==0);
|
||||
assert(gl_write(&l,0x6012,0)==GL_ARGUMENT && l.recording);
|
||||
c.base=0xff00;
|
||||
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT);
|
||||
c.base=0x6000;channels[1].can=200;
|
||||
assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT);
|
||||
assert(b.irq==0);
|
||||
puts("GAS logger core: PASS");return 0;
|
||||
}
|
||||
19
c/gas-logger/tools/generate_map.py
Normal file
19
c/gas-logger/tools/generate_map.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Regenerate/verify the firmware mirror of the packaged JSON map."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import sys
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(root / 'python'))
|
||||
from set_devices.gas_logger import load_map, c_header
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--check', action='store_true')
|
||||
args = parser.parse_args()
|
||||
target = root / 'c/gas-logger/ports/pm35/pm35_map.h'
|
||||
expected = c_header(load_map())
|
||||
if args.check:
|
||||
if not target.exists() or target.read_text(encoding='utf-8') != expected:
|
||||
raise SystemExit('pm35_map.h differs from JSON; run generate_map.py')
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(expected, encoding='utf-8')
|
||||
@@ -12,6 +12,7 @@ set(SETPROTOCOL_V2_SOURCES
|
||||
src/set_protocol.c
|
||||
src/set_can.c
|
||||
src/set_firmware.c
|
||||
src/set_boot.c
|
||||
src/set_telemetry.c
|
||||
src/set_plot.c
|
||||
src/set_trends.c
|
||||
@@ -39,6 +40,11 @@ set(SETPROTOCOL_LEGACY_SOURCES
|
||||
)
|
||||
|
||||
set(SETPROTOCOL_SOURCES
|
||||
src/set_regmap.c
|
||||
src/set_emu_ump.c
|
||||
src/set_emu_tms.c
|
||||
src/set_emu_igbt.c
|
||||
src/set_emu_server.c
|
||||
${SETPROTOCOL_V2_SOURCES}
|
||||
${SETPROTOCOL_LEGACY_SOURCES}
|
||||
)
|
||||
@@ -84,6 +90,19 @@ endif()
|
||||
option(SETP_BUILD_TESTS "Build host tests" ON)
|
||||
if(SETP_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_executable(test_emulator_services tests/test_emulator_services.c ports/example-emulator/service.c)
|
||||
target_link_libraries(test_emulator_services PRIVATE setprotocol_static)
|
||||
add_test(NAME emulator_services COMMAND test_emulator_services)
|
||||
add_executable(test_boot tests/test_boot.c)
|
||||
target_link_libraries(test_boot PRIVATE setprotocol_static)
|
||||
add_test(NAME shared_boot COMMAND test_boot)
|
||||
if(MSVC)
|
||||
target_compile_options(test_emulator_services PRIVATE /UNDEBUG)
|
||||
target_compile_options(test_boot PRIVATE /UNDEBUG)
|
||||
else()
|
||||
target_compile_options(test_emulator_services PRIVATE -UNDEBUG)
|
||||
target_compile_options(test_boot PRIVATE -UNDEBUG)
|
||||
endif()
|
||||
add_executable(test_signal_wave tests/test_signal_wave.c)
|
||||
target_link_libraries(test_signal_wave PRIVATE setprotocol_static)
|
||||
add_test(NAME signal_wave COMMAND test_signal_wave)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# SETProtocol
|
||||
|
||||
Универсальные сервисы эмуляторов: [адресное пространство, API и порт новой платы](docs/EMULATOR_SERVICES.md).
|
||||
`set_regmap` объединяет УМП, 2812, IGBT и DAC за общим API для RTU, CAN и других оболочек.
|
||||
|
||||
Генератор произвольного сигнала, интерполяция, таблицы ЦАП и порты
|
||||
STM32F407/G474: [SIGNAL_GENERATOR.md](docs/SIGNAL_GENERATOR.md).
|
||||
|
||||
|
||||
37
c/set-protocol/TMS2812.md
Normal file
37
c/set-protocol/TMS2812.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Legacy TMS2812 / BALZAM protocol
|
||||
|
||||
`src/tms2812.c` is the shared wire implementation for the host terminal,
|
||||
memory access, firmware framing and CRC validation. The wire format is the
|
||||
legacy controller protocol, **not SETProtocol v2**. The shared library name
|
||||
does not select or change the wire format.
|
||||
|
||||
- `tms2812_build_memory_request`: PEEK (56), POKE (57), host operation 0
|
||||
for PING (PEEK of service word address `0x00000100`).
|
||||
- `tms2812_build_test_request`: TEST_ALL (66), two uint16 timings and
|
||||
twelve control bytes.
|
||||
- `tms2812_build_fixed_request`: address, command, payload, CRC16/MODBUS.
|
||||
- `tms2812_validate_response`: checks controller, command, length and CRC;
|
||||
may accept omission of the two reserved bytes after CRC. The transport
|
||||
must wait for a receive gap before sending the next command.
|
||||
- UPLOAD (52) retains its separate validator: four reserved trailing bytes.
|
||||
|
||||
Bindings:
|
||||
|
||||
- Python: `python/protocan/tms_terminal.py` calls the exported `pcan_abi.h`
|
||||
functions through ctypes. `set_devices.tms_terminal` keeps telemetry
|
||||
layouts/parsing; it uses the C framing for CMD_STD.
|
||||
- Kotlin: `ports/android/kotlin/ru/setcorp/setflash/core/Tms2812Protocol.kt`
|
||||
calls the same C functions through `setprotocol_jni.c` and
|
||||
`NativeSetProtocol`. Consumers include the templates Kotlin source tree;
|
||||
they must not keep a second Tms2812Protocol class in their app source tree.
|
||||
|
||||
Consumers own UART I/O, timeout/settle timers, sequential write/readback
|
||||
transactions, GUI field names and JSON display profiles. They do not own
|
||||
copies of request builders or response CRC checks.
|
||||
|
||||
Rebuild the native library after updating templates. There is no fallback
|
||||
to a separate local encoder if the terminal ABI is missing.
|
||||
|
||||
Tests: `tests/test_tms2812.c`, `python/tests/test_tms_terminal_shared.py`,
|
||||
and the Android `TmsTerminalSharedTest`. All use the same Android/captured
|
||||
PING and PEEK vectors and TEST_ALL field layout.
|
||||
228
c/set-protocol/docs/EMULATOR_SERVICES.md
Normal file
228
c/set-protocol/docs/EMULATOR_SERVICES.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Универсальное адресное пространство эмуляторов
|
||||
|
||||
Общий код находится в `templates/c/set-protocol`. Прошивка платы задаёт память,
|
||||
GPIO и аппаратные callbacks; протокол и модели не включают STM32/HAL/USB/ОС,
|
||||
не выделяют heap и не читают системное время. Все вызовы одного экземпляра
|
||||
сериализуются главным циклом или блокировкой вызывающего.
|
||||
|
||||
## Слои и точка расширения
|
||||
|
||||
- `set_regmap.h/.c`: таблица непересекающихся регионов и общий вызов
|
||||
`set_regmap_request(map, function, address, value, out, capacity, &count)`.
|
||||
Function 3 читает `value` слов, function 6 пишет одно слово.
|
||||
- `set_emu_ump`, `set_emu_tms`, `set_emu_igbt`, `set_wavegen`:
|
||||
независимые экземпляры моделей. Callbacks получают **относительный offset**,
|
||||
а не внешний адрес. Один модуль можно разместить несколько раз по разным адресам.
|
||||
- `set_emu_server`: готовая композиция для текущего стенда, режим УМП/2812,
|
||||
подготовка настроек с применением через 500 тиков.
|
||||
- `set_regmap_rtu` и `set_regmap_can`: готовые транспортные кодеки над одной картой.
|
||||
`set_emu_server_rtu/can` дополнительно сохраняют legacy поведение стенда.
|
||||
USB CDC, UART, TCP или иной поток только доставляет полный RTU-кадр.
|
||||
Другой протокол разбирает собственную оболочку и вызывает `set_regmap_request`.
|
||||
|
||||
Вызов с неизвестной функцией возвращает 1; неизвестный адрес, RO-запись,
|
||||
чтение через границу региона — 2; неверное значение или размер буфера — 3;
|
||||
ошибка аппаратного порта — 4; занятый модуль — 6. При ошибке `count=0`.
|
||||
Длина успешного чтения равна запрошенной, запись возвращает адрес/значение.
|
||||
Максимум — 124 слова. RTU-обёртка требует буфер 253 байта до выполнения команды;
|
||||
CAN — 124 слова. Недостаточный буфер не может выполнить запись без ответа.
|
||||
При ошибке содержимое выходного буфера не используется.
|
||||
|
||||
`set_regmap_init` проверяет границы 16-битного пространства, пересечения,
|
||||
права и callbacks. Таблица и состояния принадлежат вызывающему и должны жить
|
||||
дольше карты. После регистрации таблица неизменна; для изменения карты
|
||||
инициализируйте её заново в момент, когда нет обслуживаемых запросов.
|
||||
|
||||
## Каталог и стандартная карта
|
||||
|
||||
Каталог доступен на адресе 0 через любой транспорт.
|
||||
|
||||
| Слово заголовка | Значение |
|
||||
|---|---|
|
||||
| 0 | 0x5345, сигнатура SE |
|
||||
| 1 | версия каталога 1 |
|
||||
| 2 | количество регионов |
|
||||
| 3 | начало дескрипторов: 8 |
|
||||
| 4 | длина дескриптора: 8 |
|
||||
| 5 | максимум слов в одном чтении: 124 |
|
||||
| 6, 7 | резерв: 0 |
|
||||
|
||||
Дескриптор: `kind, version, instance, base, words, flags, 0, 0`.
|
||||
Flags: bit0 чтение, bit1 запись. Права описывают возможности региона;
|
||||
отдельные регистры внутри могут быть только для чтения. Пропуски внутри
|
||||
региона возвращают ошибку, если модуль их не поддерживает.
|
||||
|
||||
| Kind | Стандартный адрес | Размер | Модуль |
|
||||
|---|---|---|---|
|
||||
| 1 | 0x1000 | 0x17C | УМП: статус, окно, онлайн |
|
||||
| 2 | 0x1200 | 36 | управление, конфигурация и indexed capability |
|
||||
| 3 | 0x1300 | 8 | генератор DAC |
|
||||
| 4 | 0x1400 | 112 | IGBT, версия 3 |
|
||||
| 5 | 0x1500 | 80 | 2812 через регистры |
|
||||
| 6 | 0x2000 | capacity × 40 | архив/банк УМП |
|
||||
| 7 | 0xA000 | wave capacity | таблица DAC |
|
||||
|
||||
Эта карта — готовая композиция, а не требование к новым платам.
|
||||
`set_regmap` позволяет выбирать адреса, подмножество и несколько экземпляров
|
||||
модуля; каталог сообщает фактическую карту. Настройка UART и выбор УМП/2812
|
||||
принадлежат только готовому `set_emu_server`. Максимальная ёмкость УМП
|
||||
в этой карте — 819 записей на банк; текущая STM32 использует 800.
|
||||
|
||||
В режиме 2812 прямое обращение к регистрам УМП возвращает busy, legacy
|
||||
RTU/CAN к этим диапазонам игнорируются для совместимости. Остальные сервисы
|
||||
и каталог доступны. Регистры управления 2812 принимают запись только в режиме 2812.
|
||||
Чтение его состояния не запускает модель.
|
||||
|
||||
## IGBT
|
||||
|
||||
Предыдущий локальный диапазон IGBT 0x1300 конфликтовал с DAC. Его alias
|
||||
сохранить нельзя: он обращается к генератору. Используйте каталог kind=4 или
|
||||
новый стандартный адрес 0x1400.
|
||||
|
||||
| Offset | Назначение |
|
||||
|---|---|
|
||||
| 0, 1 | 0x4947, версия 3 |
|
||||
| 2 | enable 0/1; выключение сразу очищает состояние RX и ожидаемые ACK |
|
||||
| 3 | чтение последнего TX; запись подготовленной TX-маски |
|
||||
| 4 | RX-маска |
|
||||
| 5 | резерв 0 |
|
||||
| 6 | FAULT-маска 0…255 |
|
||||
| 7 | задержка ACK 0…1000 мс |
|
||||
| 8 | ширина ACK 1…255 мс |
|
||||
| 9 | счётчик фронтов modulo 65536; запись 0 сбрасывает |
|
||||
| 10 | число каналов 8 |
|
||||
| 11 | один шаг модели = 1 мс |
|
||||
| 12 | источник TX: 0 переданные GPIO, 1 подготовленная маска |
|
||||
| 13 | подготовленная TX-маска, чтение/запись |
|
||||
| 14 | запись маски запуска одиночного TX; чтение 0 |
|
||||
| 15 | длина записи канала: 12 слов |
|
||||
|
||||
Версия 3 сохраняет первые 14 регистров. Общие delay/width при записи
|
||||
устанавливают значения для всех каналов; при чтении показывают последнее
|
||||
общее задание, а не поканальные значения. Полное состояние: FC03 0x1400 / 112 слов.
|
||||
Запуск TX требует enabled=1, source=1, разрешённого TX канала и низкого TX;
|
||||
повторный запуск высокого TX отвергается кодом 6. Длительность отсчитывает
|
||||
сама модель, не клиент COM. Ручная запись TX отменяет все текущие TX-импульсы.
|
||||
Выключение эмуляции отменяет импульсы и сбрасывает их TX; статическое задание
|
||||
TX сохраняется. PB остаются входами: тестовый TX — внутренняя подстановка,
|
||||
физическая обратная связь RX выводится на PC0…PC7.
|
||||
|
||||
Поканальные регистры: `0x1410 + 12 * channel`, channel=0…7.
|
||||
|
||||
| Offset канала | Значение |
|
||||
|---|---|
|
||||
| 0 | flags: bit0 разрешение TX, bit1 разрешение RX, bit2 ACK active-low |
|
||||
| 1 | RX mode: 0 ACK, 1 физический 0, 2 физическая 1, 3 пропуск ACK |
|
||||
| 2, 3 | задержка ACK ON 0…1000 мс, ширина 1…1000 мс |
|
||||
| 4, 5 | задержка ACK OFF 0…1000 мс, ширина 1…1000 мс |
|
||||
| 6 | одиночный TX: 1…60000 мс |
|
||||
| 7 | маска фронтов для ACK: bit0 ON, bit1 OFF |
|
||||
| 8 | ширина FAULT 1…60000 мс; 0 — удержание до снятия FAULT |
|
||||
| 9 | RO счётчик фронтов канала, modulo 65536 |
|
||||
| 10, 11 | RO оставшаяся задержка / длительность ACK в мс |
|
||||
|
||||
Изменение flags/mode отменяет ожидаемый и активный ACK канала.
|
||||
Отключённый RX и выключенная модель всегда дают физический 0.
|
||||
Инверсия применяется к автоматическому ответу и idle при пропуске ACK;
|
||||
ручные уровни 0/1 не инвертируются. Сброс общего счётчика сбрасывает и канальные.
|
||||
FAULT моделирует длинный/удерживаемый ответ после фронта; это не модель Vce,
|
||||
Vge, DESAT, внутренней защиты или блокировки конкретного силового драйвера.
|
||||
|
||||
`set_igbt_tick(state, pins)` получает маску входов от порта платы.
|
||||
Фронт на тике T запускает ACK на T+delay. Повторный фронт до начала ACK
|
||||
перезапускает задержку; это определённая модельная обработка слишком частых команд.
|
||||
Каждый из восьми каналов независим. FAULT удерживает RX после его активации;
|
||||
снятие FAULT освобождает только ранее аварийные каналы и сохраняет обычные ACK.
|
||||
Чтобы TX через регистры не перезаписывался GPIO, выберите source=1.
|
||||
|
||||
**Текущая IGBT-модель имеет шаг 1 мс.** Она проверяет логическую последовательность
|
||||
Vin/Vstat, но не воспроизводит ACK 250/380 нс и ширины профилей 1SP/1SD в DSLogic.
|
||||
Такую аппаратную точность нельзя получить переносом модели или вызовом из SysTick:
|
||||
для неё требуется отдельный быстрый аппаратный порт и соответствующая модель времени.
|
||||
|
||||
## 2812
|
||||
|
||||
Состояние едино для legacy CMD_STD и адресного API:
|
||||
|
||||
| Offset | Назначение |
|
||||
|---|---|
|
||||
| 0, 1 | 0x544D, версия 1 |
|
||||
| 2, 3, 4 | running, частота и задание (int16 ×100 Гц) |
|
||||
| 8…11 | четыре подготовленных analog int16 |
|
||||
| 12…17 | шесть подготовленных управляющих байт (значение 0…255) |
|
||||
| 18 | записать 1: атомарно применить подготовленную команду |
|
||||
| 32…45 | 28 байт дискретов; младший байт слова — первый байт |
|
||||
| 46…79 | 34 аналоговых int16 |
|
||||
| остальные | чтение 0 |
|
||||
|
||||
Модель остаётся упрощённой BALZAM_7: Go, выбор remote, Setspeed,
|
||||
разгон 10 Гц/с и синтетическая телеметрия. Legacy CMD_STD имеет отдельный
|
||||
адрес 2812, запрос 18 и ответ 101 байт, с проверкой CRC до изменения состояния.
|
||||
Буфер ответа менее 101 байта запрещает выполнение команды.
|
||||
|
||||
## УМП и DAC
|
||||
|
||||
УМП сохраняет прежние записи по 40 слов, банк/архив, фронтовой триггер,
|
||||
постинтервал 1000 мс и indexed-чтение без изменения курсора. Два разных
|
||||
буфера по `capacity*40` слов предоставляет плата. Размещение SRAM/CCM
|
||||
не является частью библиотеки.
|
||||
|
||||
DAC использует прежний `set_wave_port` с `start/stop`, транзакционную загрузку,
|
||||
commit, чтение таблицы и запрет изменений при работе. Регистровые callbacks
|
||||
и прежний `set_wave_rtu` вызывают одну реализацию. До повторного init вызывающий
|
||||
останавливает аппаратный вывод; библиотека не угадывает текущее состояние DMA.
|
||||
|
||||
## Обёртки и новая плата
|
||||
|
||||
Пример: [ports/example-emulator/service.c](../ports/example-emulator/service.c).
|
||||
Он регистрирует два экземпляра IGBT и показывает прямой, RTU и CAN вызов.
|
||||
Порт новой платы должен:
|
||||
|
||||
1. Создать состояния и таблицу регионов, вызвать init и проверить результат.
|
||||
2. Предоставить буферы и аппаратный DAC-порт, если нужен генератор.
|
||||
3. Передавать реальные тики и входы; читать результат модели и обновлять GPIO.
|
||||
4. Доставлять запросы через выбранную оболочку в одну карту, сериализуя доступ.
|
||||
|
||||
CAN-envelope совместим с UMP: BE address/value/0x1F00/token/function.
|
||||
Идентификаторы CAN и маршрутизацию задаёт порт. Ответ делится по два слова,
|
||||
нечётное чтение дополняется нулём до чётного числа; клиент отбрасывает последнее
|
||||
слово по длине исходного запроса. Токен 1…255; один незавершённый ответ на соединение.
|
||||
Проверенная копия слов хранится до отправки всех фрагментов.
|
||||
|
||||
`set_emu_stream` — только сборщик известных запросов FC03/FC06 и CMD_STD,
|
||||
по одному экземпляру на поток. Он принимает монотонные uint32 миллисекунды,
|
||||
сохраняет кадр между USB-пакетами, сбрасывает незавершённый после паузы >1000 мс
|
||||
и восстанавливается после плохого CRC. Это не универсальный Modbus-парсер
|
||||
FC16/FC23: для другого формата нужна другая тонкая оболочка.
|
||||
|
||||
## Сборка и проверки
|
||||
|
||||
C99-ядро подключается через `setprotocol_static`/DLL, определения публичного
|
||||
API экспортируются через PCAN_ABI_API. Включить `set_regmap.h` или
|
||||
`set_emu_server.h`. Для иной архитектуры проверить наличие uint8/16/32_t;
|
||||
существующий 16-битный C28x octet-порт UMP остаётся отдельным, этот новый
|
||||
байтовый API пока не проверен TI-компилятором.
|
||||
|
||||
`test_emulator_services.c` проверяет каталог, пересечения, границы и короткие
|
||||
буферы, два экземпляра, переназначение адресов, RTU/CAN/stream, совместное
|
||||
состояние 2812 и DAC, длинные задержки IGBT и освобождение FAULT.
|
||||
Существующие тесты прошивки дополнительно проверяют архив УМП, USB-фрагментацию,
|
||||
смену режима/адресов и совместимость с декодерами SETGUI.
|
||||
|
||||
## Выбор единственного эмулятора
|
||||
|
||||
В SETGUI: «Эмулятор → Настройки STM → Тип эмулятора», затем
|
||||
«Применить в STM и проверить» и «Открыть управление эмулятором».
|
||||
Конфигурация версии 3: режим 0 — УМП, 1 — 2812, 2 — IGBT, 3 — ЦАП.
|
||||
Регистр 0x1219 подготавливает режим; 0x1218 = 0xA55A применяет его через
|
||||
500 мс после подтверждения. Статус FC03 0x1210 / 10 слов содержит версию 3,
|
||||
действующий режим в слове 8 и подготовленный в слове 9.
|
||||
|
||||
При смене режима останавливаются TIM6/DMA/DAC, ЦАП устанавливается в ноль,
|
||||
IGBT отключается и его выходы обнуляются, 2812 сбрасывается, УМП останавливается.
|
||||
Новый режим не запускается автоматически. Для IGBT нужно явно включить
|
||||
эмуляцию; для ЦАП заново загрузить таблицу и дать команду пуска.
|
||||
Повторное применение того же режима не прерывает текущую работу.
|
||||
Записи невыбранным IGBT/ЦАП/2812 отклоняются с кодом 6; чтение статуса
|
||||
и явные команды остановки IGBT/ЦАП доступны. Настройки связи доступны всегда.
|
||||
После сброса выбран УМП, IGBT выключен, ЦАП не запущен.
|
||||
@@ -102,6 +102,14 @@ PCAN_ABI_API const char *pcan_abi_periph28335_command_name(
|
||||
size_t project_index, size_t command_index);
|
||||
|
||||
/* PM67/TMS320F2812 legacy memory upload. */
|
||||
PCAN_ABI_API size_t pcan_abi_tms2812_build_fixed(uint8_t controller, uint8_t command,
|
||||
const uint8_t *payload, size_t payload_size, uint8_t *output, size_t output_size);
|
||||
PCAN_ABI_API size_t pcan_abi_tms2812_build_memory(uint8_t controller, uint8_t command,
|
||||
uint32_t word_address, uint16_t value, uint8_t *output, size_t output_size);
|
||||
PCAN_ABI_API size_t pcan_abi_tms2812_build_test(uint8_t controller, uint16_t duration,
|
||||
uint16_t pause, const uint8_t *digits, size_t digit_count, uint8_t *output, size_t output_size);
|
||||
PCAN_ABI_API int pcan_abi_tms2812_validate_response(const uint8_t *data, size_t size,
|
||||
uint8_t controller, uint8_t command, size_t full_size, int allow_short_tail);
|
||||
PCAN_ABI_API uint16_t pcan_abi_tms2812_crc16(
|
||||
const uint8_t *data, size_t size);
|
||||
PCAN_ABI_API size_t pcan_abi_tms2812_build_upload(
|
||||
|
||||
26
c/set-protocol/include/set_boot.h
Normal file
26
c/set-protocol/include/set_boot.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#ifndef SET_BOOT_H
|
||||
#define SET_BOOT_H
|
||||
#include "set_firmware.h"
|
||||
|
||||
/* Transport-independent, single-slot updater. invalidate MUST become durable
|
||||
* before erase; commit is called only after read-back verification. */
|
||||
typedef struct {
|
||||
bool (*invalidate)(void *user);
|
||||
bool (*erase)(void *user, uint32_t size);
|
||||
bool (*write)(void *user, uint32_t offset, const uint8_t *data, uint16_t size);
|
||||
bool (*verify)(void *user, uint32_t size, uint32_t crc);
|
||||
bool (*commit)(void *user, uint32_t size, uint32_t crc);
|
||||
void (*activate)(void *user);
|
||||
} setp_boot_ops;
|
||||
typedef struct {
|
||||
setp_boot_ops ops;
|
||||
void *user;
|
||||
uint32_t base, capacity, crc;
|
||||
uint16_t block_size;
|
||||
setp_fw_status_t status;
|
||||
} setp_boot;
|
||||
void setp_boot_init(setp_boot *b, const setp_boot_ops *ops, void *user,
|
||||
uint32_t base, uint32_t capacity);
|
||||
/* Returns SET status; FW_STATUS body is encoded by the caller. */
|
||||
uint16_t setp_boot_command(setp_boot *b, uint16_t type, const uint8_t *p, uint16_t n);
|
||||
#endif
|
||||
32
c/set-protocol/include/set_emu_igbt.h
Normal file
32
c/set-protocol/include/set_emu_igbt.h
Normal file
@@ -0,0 +1,32 @@
|
||||
#ifndef SET_EMU_IGBT_H
|
||||
#define SET_EMU_IGBT_H
|
||||
#include "set_regmap.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_IGBT_CHANNELS 8u
|
||||
#define SET_IGBT_WORDS 112u
|
||||
#define SET_IGBT_CHANNEL_WORDS 12u
|
||||
typedef struct {
|
||||
uint16_t flags, mode, on_delay, on_width, off_delay, off_width;
|
||||
uint16_t tx_width, edges, fault_width, count;
|
||||
} set_igbt_channel;
|
||||
typedef struct {
|
||||
uint8_t enabled, tx, previous_tx, rx, fault, injected_tx, input_source;
|
||||
uint16_t pending[SET_IGBT_CHANNELS], active[SET_IGBT_CHANNELS];
|
||||
uint16_t delay_ms, width_ms, event_count;
|
||||
uint8_t logical_rx;
|
||||
uint16_t pulse[SET_IGBT_CHANNELS], response_width[SET_IGBT_CHANNELS];
|
||||
set_igbt_channel channel[SET_IGBT_CHANNELS];
|
||||
} set_igbt_emu;
|
||||
PCAN_ABI_API void set_igbt_init(set_igbt_emu *);
|
||||
/* One model tick = 1 ms, no system clock. This coarse logical model does NOT
|
||||
* reproduce the sub-microsecond timing of 1SP/1SD DSLogic hardware profiles.
|
||||
* input_source=0 takes the supplied pins, 1 uses register-injected TX. */
|
||||
PCAN_ABI_API void set_igbt_tick(set_igbt_emu *, uint8_t pins);
|
||||
PCAN_ABI_API unsigned set_igbt_read(void *, uint16_t offset, uint16_t count, uint16_t *);
|
||||
PCAN_ABI_API unsigned set_igbt_write(void *, uint16_t offset, uint16_t value);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
48
c/set-protocol/include/set_emu_server.h
Normal file
48
c/set-protocol/include/set_emu_server.h
Normal file
@@ -0,0 +1,48 @@
|
||||
/* Reference service composition for emulator boards; use set_regmap directly
|
||||
* for a different map, subset, or multiple instances of any module. */
|
||||
#ifndef SET_EMU_SERVER_H
|
||||
#define SET_EMU_SERVER_H
|
||||
#include "set_emu_ump.h"
|
||||
#include "set_emu_tms.h"
|
||||
#include "set_emu_igbt.h"
|
||||
#include "set_wavegen.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_EMU_CONTROL_BASE 0x1200u
|
||||
#define SET_EMU_IGBT_BASE 0x1400u
|
||||
#define SET_EMU_TMS_BASE 0x1500u
|
||||
#define SET_EMU_INDEXED_BASE 0x2000u
|
||||
enum { SET_EMU_KIND_UMP=1,SET_EMU_KIND_CONTROL=2,SET_EMU_KIND_WAVE=3,
|
||||
SET_EMU_KIND_IGBT=4,SET_EMU_KIND_TMS=5,SET_EMU_KIND_UMP_DATA=6,
|
||||
SET_EMU_KIND_WAVE_DATA=7 };
|
||||
typedef struct {
|
||||
set_regmap map;
|
||||
set_reg_region regions[7];
|
||||
set_ump_emu ump;
|
||||
set_tms_emu tms;
|
||||
set_igbt_emu igbt;
|
||||
set_wave_state *wave;
|
||||
/* config[3]: 0=UMP, 1=TMS2812, 2=IGBT, 3=DAC. Configuration version 3. */
|
||||
uint16_t config[4],staged[4],apply_delay;
|
||||
} set_emu_server;
|
||||
/* bank_capacity in records, each bank has capacity*40 words.
|
||||
* Wave may be NULL. UART rate index 0..4. No pins, heap or global instances.
|
||||
* Init is for a quiescent instance: caller stops hardware before reinitializing. */
|
||||
PCAN_ABI_API unsigned set_emu_server_init(set_emu_server *,uint16_t *bank0,
|
||||
uint16_t *bank1,uint16_t bank_capacity,set_wave_state *,unsigned address,
|
||||
unsigned tms_address,unsigned baud_index,unsigned can_mode,unsigned enabled);
|
||||
PCAN_ABI_API void set_emu_server_tick(set_emu_server *,uint8_t igbt_pins);
|
||||
PCAN_ABI_API uint32_t set_emu_server_baud(const set_emu_server *);
|
||||
PCAN_ABI_API size_t set_emu_server_rtu(set_emu_server *,const uint8_t *,size_t,uint8_t *,size_t);
|
||||
PCAN_ABI_API size_t set_emu_server_can(set_emu_server *,const uint8_t *,size_t,
|
||||
uint16_t *,size_t,unsigned *,unsigned *);
|
||||
/* Stream adapter for FC03/06 (8 octets) and TMS CMD_STD (18 octets). One
|
||||
* context per connection. Transport passes monotonic milliseconds. */
|
||||
typedef struct { uint8_t data[18],used;uint32_t last_byte; } set_emu_stream;
|
||||
PCAN_ABI_API size_t set_emu_stream_feed(set_emu_stream *,set_emu_server *,
|
||||
uint8_t byte,uint32_t now_ms,uint8_t *reply,size_t capacity);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
22
c/set-protocol/include/set_emu_tms.h
Normal file
22
c/set-protocol/include/set_emu_tms.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#ifndef SET_EMU_TMS_H
|
||||
#define SET_EMU_TMS_H
|
||||
#include "set_regmap.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_TMS_WORDS 80u
|
||||
typedef struct {
|
||||
uint8_t controls[6], running;
|
||||
uint16_t commands[10], divider;
|
||||
int32_t frequency, target;
|
||||
} set_tms_emu;
|
||||
PCAN_ABI_API void set_tms_init(set_tms_emu *);
|
||||
PCAN_ABI_API void set_tms_tick(set_tms_emu *);
|
||||
PCAN_ABI_API unsigned set_tms_read(void *,uint16_t,uint16_t,uint16_t *);
|
||||
PCAN_ABI_API unsigned set_tms_write(void *,uint16_t,uint16_t);
|
||||
PCAN_ABI_API size_t set_tms_rtu(set_tms_emu *,unsigned device,const uint8_t *,
|
||||
size_t,uint8_t *,size_t);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
25
c/set-protocol/include/set_emu_ump.h
Normal file
25
c/set-protocol/include/set_emu_ump.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef SET_EMU_UMP_H
|
||||
#define SET_EMU_UMP_H
|
||||
#include "set_regmap.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_UMP_WORDS 40u
|
||||
typedef struct {
|
||||
uint16_t (*banks[2])[SET_UMP_WORDS];
|
||||
uint16_t capacity,can_mode;
|
||||
uint8_t enabled;
|
||||
uint16_t state,used,next,bank,archive_bank,archive_count,archive_next,period,divider,cursor,source,generation,post,dropped,prev_command,prev_input,prev_output;
|
||||
uint32_t elapsed,sequence,scenario;
|
||||
} set_ump_emu;
|
||||
/* Two distinct caller-owned arrays, capacity*40 words EACH. No heap/MCU layout. */
|
||||
PCAN_ABI_API unsigned set_ump_init(set_ump_emu *,uint16_t *,uint16_t *,uint16_t capacity,
|
||||
uint16_t can_mode,uint8_t enabled);
|
||||
PCAN_ABI_API void set_ump_tick(set_ump_emu *); /* one millisecond */
|
||||
PCAN_ABI_API unsigned set_ump_read(void *,uint16_t,uint16_t,uint16_t *);
|
||||
PCAN_ABI_API unsigned set_ump_indexed_read(void *,uint16_t,uint16_t,uint16_t *);
|
||||
PCAN_ABI_API unsigned set_ump_write(void *,uint16_t,uint16_t);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
48
c/set-protocol/include/set_regmap.h
Normal file
48
c/set-protocol/include/set_regmap.h
Normal file
@@ -0,0 +1,48 @@
|
||||
/* Transport-independent 16-bit word address space. No heap, clocks or board headers. */
|
||||
#ifndef SET_REGMAP_H
|
||||
#define SET_REGMAP_H
|
||||
#include "pcan_abi.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define SET_REG_MAX_WORDS 124u
|
||||
#define SET_REG_READ 1u
|
||||
#define SET_REG_WRITE 2u
|
||||
enum { SET_REG_OK=0, SET_REG_FUNCTION=1, SET_REG_ADDRESS=2,
|
||||
SET_REG_VALUE=3, SET_REG_FAILURE=4, SET_REG_BUSY=6 };
|
||||
typedef unsigned (*set_reg_read_fn)(void *, uint16_t, uint16_t, uint16_t *);
|
||||
typedef unsigned (*set_reg_write_fn)(void *, uint16_t, uint16_t);
|
||||
typedef struct {
|
||||
uint16_t base, words, kind, version, instance, flags;
|
||||
void *context;
|
||||
set_reg_read_fn read;
|
||||
set_reg_write_fn write;
|
||||
} set_reg_region;
|
||||
typedef struct {
|
||||
const set_reg_region *regions;
|
||||
size_t count;
|
||||
} set_regmap;
|
||||
/* Directory at 0: 8-word header, then 8 words per region. Reject overlaps,
|
||||
* wraparound, invalid callbacks and overlap with the directory itself.
|
||||
* Regions and contexts must outlive the map and remain unchanged. */
|
||||
PCAN_ABI_API unsigned set_regmap_init(set_regmap *, const set_reg_region *, size_t);
|
||||
/* One region per request. Preflight all bounds/capacity before calling a module.
|
||||
* Writes return absolute address/value echo. Reads must be side-effect free.
|
||||
* A module validates the entire write before mutation. Caller serializes access. */
|
||||
PCAN_ABI_API unsigned set_regmap_request(const set_regmap *, unsigned function,
|
||||
uint16_t address, uint16_t value, uint16_t *out, size_t capacity, size_t *count);
|
||||
/* Complete Modbus RTU FC03/06 frame; no broadcast. Capacity >=253 bytes.
|
||||
* CRC/address/capacity validation precedes dispatch. */
|
||||
PCAN_ABI_API size_t set_regmap_rtu(const set_regmap *, unsigned device,
|
||||
const uint8_t *, size_t, uint8_t *, size_t);
|
||||
/* Legacy UMP CAN envelope, usable by any region: BE address/value/1F00/token/fn.
|
||||
* Returns padded EVEN words, last padding word=0. Caller retains original
|
||||
* requested count; token/error accompany fragments. Output capacity >=124. */
|
||||
PCAN_ABI_API size_t set_regmap_can(const set_regmap *, const uint8_t *, size_t,
|
||||
uint16_t *, size_t, unsigned *token, unsigned *error);
|
||||
PCAN_ABI_API size_t set_regmap_can_fragment(const uint16_t *, size_t count,
|
||||
size_t index, unsigned token, unsigned error, uint8_t *, size_t capacity);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -4,6 +4,12 @@
|
||||
*/
|
||||
#ifndef SETPROTOCOL_H
|
||||
#define SETPROTOCOL_H
|
||||
#include "set_regmap.h"
|
||||
#include "set_emu_server.h"
|
||||
|
||||
#include "balsam_can.h"
|
||||
#include "periph28335.h"
|
||||
#include "tms2812.h"
|
||||
|
||||
#include "balsam_can.h"
|
||||
#include "periph28335.h"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file tms2812.h
|
||||
* @brief Shared PM67/TMS320F2812 legacy upload protocol.
|
||||
* @brief Shared PM67/TMS320F2812 legacy terminal and memory protocol.
|
||||
*/
|
||||
#ifndef TMS2812_H
|
||||
#define TMS2812_H
|
||||
@@ -13,6 +13,10 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
#define TMS2812_CMD_UPLOAD 52U
|
||||
#define TMS2812_CMD_PEEK 56U
|
||||
#define TMS2812_CMD_POKE 57U
|
||||
#define TMS2812_CMD_TEST_ALL 66U
|
||||
#define TMS2812_PING_ADDRESS 0x00000100UL
|
||||
#define TMS2812_UPLOAD_REQUEST_SIZE 12U
|
||||
#define TMS2812_UPLOAD_RESPONSE_OVERHEAD 8U
|
||||
|
||||
@@ -28,6 +32,20 @@ typedef enum {
|
||||
|
||||
uint16_t tms2812_crc16(const uint8_t *data, size_t size);
|
||||
|
||||
size_t tms2812_build_fixed_request(uint8_t controller, uint8_t command,
|
||||
const uint8_t *payload, size_t payload_size, uint8_t *output, size_t output_size);
|
||||
/* command=0 is the host PING operation; on wire it is PEEK at 0x100. */
|
||||
size_t tms2812_build_memory_request(uint8_t controller, uint8_t command,
|
||||
uint32_t word_address, uint16_t value, uint8_t *output, size_t output_size);
|
||||
size_t tms2812_build_test_request(uint8_t controller, uint16_t duration,
|
||||
uint16_t pause, const uint8_t *digits, size_t digit_count,
|
||||
uint8_t *output, size_t output_size);
|
||||
/* Ordinary BALZAM responses reserve two bytes after CRC. If allow_short_tail
|
||||
* is true only those bytes may be omitted; caller must observe the UART gap.
|
||||
* UPLOAD responses use tms2812_validate_upload_response instead. */
|
||||
int tms2812_validate_response(const uint8_t *data, size_t size,
|
||||
uint8_t controller, uint8_t command, size_t full_size, int allow_short_tail);
|
||||
|
||||
size_t tms2812_build_upload_request(
|
||||
uint8_t controller, uint32_t word_address, uint32_t byte_count,
|
||||
uint8_t *output, size_t output_size);
|
||||
|
||||
55
c/set-protocol/include/ump_protocol.h
Normal file
55
c/set-protocol/include/ump_protocol.h
Normal file
@@ -0,0 +1,55 @@
|
||||
/* UMP logger v2 (PM35), independent of PM67 and SET protocol v2.
|
||||
* Octets use 16-bit storage: C28x has CHAR_BIT=16 and no uint8_t.
|
||||
* All lengths below count wire octets or register words, never sizeof bytes.
|
||||
*/
|
||||
#ifndef UMP_PROTOCOL_H
|
||||
#define UMP_PROTOCOL_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#define UMP_BASE 0x1000U
|
||||
#define UMP_DATA 0x1010U
|
||||
#define UMP_LIVE 0x1100U
|
||||
#define UMP_MARKER 0x1F00U
|
||||
#define UMP_MAX_WORDS 124U
|
||||
#define UMP_RTU_CAPACITY 253U
|
||||
typedef uint16_t ump_octet;
|
||||
/* Called under caller's snapshot lock. write returns 1 on success, 0 otherwise. */
|
||||
typedef struct {
|
||||
void *context;
|
||||
uint16_t (*read)(void *context, uint16_t address);
|
||||
int (*write)(void *context, uint16_t address, uint16_t value);
|
||||
} ump_backend;
|
||||
/* Returns Modbus exception 0/1/2/3. CAN has stricter whole-block reads. */
|
||||
unsigned ump_validate(unsigned function, uint16_t address, uint16_t value, int can);
|
||||
/* reply capacity is >=124 words. Errors and writes return address/value echo. */
|
||||
unsigned ump_prepare(const ump_backend *backend, unsigned function,
|
||||
uint16_t address, uint16_t value, int can, uint16_t *reply, unsigned *error);
|
||||
uint16_t ump_crc(const ump_octet *data, size_t count);
|
||||
/* Mode 1..16; kind 0=request, 1=normal telemetry, 2=logger reply; invalid ->0. */
|
||||
uint32_t ump_can_id(unsigned mode, unsigned kind);
|
||||
/* Returns 0 for non-service frames, malformed size, or zero token. */
|
||||
unsigned ump_can_request(const ump_backend *backend, const ump_octet *request,
|
||||
size_t size, uint16_t *reply, unsigned *token, unsigned *error);
|
||||
/* count must be an even number 2..124; index counts pairs. Returns 8 or 0. */
|
||||
unsigned ump_can_fragment(const uint16_t *words, unsigned count, unsigned index,
|
||||
unsigned token, unsigned error, ump_octet *reply);
|
||||
/* Complete RTU frame only, after UART framing. Ignores bad CRC/foreign address.
|
||||
* Caller provides >=253 octets; no writes occur on insufficient capacity.
|
||||
* Device address 1..247; no broadcasts or legacy padding in this extension.
|
||||
*/
|
||||
size_t ump_rtu(const ump_backend *backend, unsigned device,
|
||||
const ump_octet *request, size_t size, ump_octet *reply, size_t capacity);
|
||||
/* Response for an already CRC-checked/framed request (legacy UART receiver). */
|
||||
size_t ump_rtu_response(const ump_backend *backend, unsigned device, unsigned function,
|
||||
uint16_t address, uint16_t value, ump_octet *reply, size_t capacity);
|
||||
/* Serialize a captured reply after releasing the snapshot lock. Returns 0 on
|
||||
* invalid count/capacity; error replies need no words. */
|
||||
size_t ump_rtu_encode(unsigned device, unsigned function, unsigned error,
|
||||
const uint16_t *words, unsigned count, ump_octet *reply, size_t capacity);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,263 @@
|
||||
package ru.setcorp.setflash.core
|
||||
|
||||
import ru.setcorp.setprotocol.NativeSetProtocol
|
||||
|
||||
/** Shared templates facade for BALZAM TMS320F2812; framing and CRC use C99. */
|
||||
object Tms2812Protocol {
|
||||
const val CMD_LOAD = 51
|
||||
const val CMD_UPLOAD = 52
|
||||
const val CMD_RUN = 53
|
||||
const val CMD_XFLASH = 54
|
||||
const val CMD_TFLASH = 55
|
||||
const val CMD_PEEK = 56
|
||||
const val CMD_POKE = 57
|
||||
const val CMD_INITLOAD = 58
|
||||
const val CMD_INIT = 59
|
||||
const val CMD_EXTEND = 60
|
||||
const val CMD_VECTOR = 61
|
||||
const val CMD_IMPULSE = 62
|
||||
const val CMD_STD = 65
|
||||
const val CMD_TEST_ALL = 66
|
||||
|
||||
// BALZAM CHIEF bios.c returns 0x0055 at ADR_FOR_SPECIAL without reading
|
||||
// the peripheral bus. Address 0 goes through read_bus_special(), whose
|
||||
// failure suppresses the reply entirely, so it is unsuitable for PING.
|
||||
const val PING_MEMORY_ADDRESS = 0x00000100L
|
||||
|
||||
// extendbios() operation selectors from BALZAM_167/Src/mybalzam_7/bios.c.
|
||||
const val EXTEND_FLASH_REMOTE_EEPROM = 6
|
||||
const val EXTEND_READ_REMOTE_EEPROM = 7
|
||||
const val EXTEND_RESET_TO_INTERNAL_FLASH = 9
|
||||
const val EXTEND_FLASH_SPARTAN6 = 10
|
||||
const val EXTEND_VERIFY_REMOTE_EEPROM = 17
|
||||
|
||||
const val ACK_SIZE = 6
|
||||
const val PEEK_RESPONSE_SIZE = 10
|
||||
const val STD_RESPONSE_SIZE = 112
|
||||
const val UPLOAD_RESPONSE_OVERHEAD = 8
|
||||
const val EXTEND_RESPONSE_SIZE = 18
|
||||
|
||||
data class StandardResponse(
|
||||
val digital: ByteArray,
|
||||
val analog: List<Int>,
|
||||
)
|
||||
|
||||
data class ExtendResponse(
|
||||
val processedWords: Long,
|
||||
val errorCode: Long,
|
||||
val repeatErrors: Long,
|
||||
)
|
||||
|
||||
fun crc16(data: ByteArray, initial: Int = 0xFFFF): Int {
|
||||
require(initial == 0xFFFF) {
|
||||
"Произвольное начальное значение CRC не входит в протокол ПМ67"
|
||||
}
|
||||
requireNative()
|
||||
return NativeSetProtocol.nativeTms2812Crc16(data)
|
||||
}
|
||||
|
||||
fun initLoad(address: Int, ramAddress: Long, length: Int): ByteArray = fixed(
|
||||
address,
|
||||
CMD_INITLOAD,
|
||||
le32(ramAddress) + le32(length.toLong()),
|
||||
12,
|
||||
)
|
||||
|
||||
/**
|
||||
* LOAD is address + command, followed by raw data and CRC. RS485.c consumes
|
||||
* the first two bytes as a normal header, switches RS_FlagBegin off for
|
||||
* CMD_LOAD, and stores the remaining Length + 2 bytes in RAM.
|
||||
*/
|
||||
fun loadData(address: Int, image: ByteArray): ByteArray {
|
||||
return fixed(address, CMD_LOAD, image, image.size + 4)
|
||||
}
|
||||
|
||||
fun tFlash(address: Int, ramAddress: Long, flashAddress: Long, length: Int): ByteArray = fixed(
|
||||
address,
|
||||
CMD_TFLASH,
|
||||
le32(ramAddress) + le32(flashAddress) + le32(length.toLong()),
|
||||
16,
|
||||
)
|
||||
|
||||
fun upload(address: Int, memoryAddress: Long, length: Int): ByteArray {
|
||||
require(address in 0..255) { "Адрес контроллера должен быть 0…255" }
|
||||
require(memoryAddress in 0..0xFFFF_FFFFL) { "Адрес слова вне диапазона uint32" }
|
||||
require(length > 0) { "Размер блока должен быть положительным" }
|
||||
requireNative()
|
||||
return requireNotNull(
|
||||
NativeSetProtocol.nativeTms2812BuildUpload(
|
||||
address, memoryAddress, length.toLong(),
|
||||
),
|
||||
) { "SETProtocol отклонил CMD_UPLOAD ПМ67" }
|
||||
}
|
||||
|
||||
/**
|
||||
* bios.c sends UPLOAD as address, command, packed bytes, CRC16 and four
|
||||
* trailing bytes. Unlike ordinary replies its CRC immediately follows data.
|
||||
*/
|
||||
fun validateUploadResponse(packet: ByteArray, address: Int, length: Int): Boolean {
|
||||
if (length <= 0 || address !in 0..255) return false
|
||||
requireNative()
|
||||
return NativeSetProtocol.nativeTms2812ValidateUpload(packet, address, length)
|
||||
}
|
||||
|
||||
fun uploadData(packet: ByteArray, address: Int, length: Int): ByteArray {
|
||||
requireNative()
|
||||
return requireNotNull(
|
||||
NativeSetProtocol.nativeTms2812DecodeUpload(packet, address, length),
|
||||
) { "Поврежден ответ CMD_UPLOAD" }
|
||||
}
|
||||
|
||||
fun peek(address: Int, memoryAddress: Long): ByteArray =
|
||||
memory(address, CMD_PEEK, memoryAddress, 0)
|
||||
|
||||
fun ping(address: Int): ByteArray = memory(address, 0, 0, 0)
|
||||
|
||||
fun poke(address: Int, memoryAddress: Long, value: Int): ByteArray =
|
||||
memory(address, CMD_POKE, memoryAddress, value)
|
||||
|
||||
private fun memory(address: Int, command: Int, memoryAddress: Long, value: Int): ByteArray {
|
||||
require(address in 0..255 && memoryAddress in 0..0xFFFF_FFFFL && value in 0..65535) {
|
||||
"Адрес или значение TMS2812 вне диапазона"
|
||||
}
|
||||
requireNative()
|
||||
return requireNotNull(NativeSetProtocol.nativeTms2812BuildMemory(address, command, memoryAddress, value))
|
||||
}
|
||||
|
||||
fun testAll(address: Int, duration: Int, pause: Int, digits: ByteArray): ByteArray {
|
||||
require(address in 0..255 && duration in 0..65535 && pause in 0..65535 && digits.size == 12)
|
||||
requireNative()
|
||||
return requireNotNull(NativeSetProtocol.nativeTms2812BuildTest(address, duration, pause, digits))
|
||||
}
|
||||
|
||||
fun extend(
|
||||
address: Int,
|
||||
firstAddress: Long,
|
||||
secondAddress: Long,
|
||||
length: Long,
|
||||
code1: Int,
|
||||
code2: Int,
|
||||
): ByteArray = fixed(
|
||||
address,
|
||||
CMD_EXTEND,
|
||||
le32(firstAddress) + le32(secondAddress) + le32(length) + byteArrayOf(code1.toByte(), code2.toByte()),
|
||||
18,
|
||||
)
|
||||
|
||||
fun parseExtendResponse(packet: ByteArray, address: Int): ExtendResponse {
|
||||
require(packet.size == EXTEND_RESPONSE_SIZE && validateResponse(packet, address, CMD_EXTEND)) {
|
||||
"Поврежден ответ CMD_EXTEND"
|
||||
}
|
||||
return ExtendResponse(
|
||||
processedWords = le32Value(packet, 2),
|
||||
errorCode = le32Value(packet, 6),
|
||||
repeatErrors = le32Value(packet, 10),
|
||||
)
|
||||
}
|
||||
|
||||
fun standard(address: Int, analog: List<Int>, flags: Set<Int>): ByteArray {
|
||||
require(analog.size == 4) { "CMD_STD должен содержать четыре аналоговых значения" }
|
||||
val payload = ByteArray(14)
|
||||
analog.forEachIndexed { index, value ->
|
||||
require(value in Short.MIN_VALUE..Short.MAX_VALUE) { "Аналог ${index + 1} вне диапазона Int16" }
|
||||
payload[index * 2] = value.toByte()
|
||||
payload[index * 2 + 1] = (value ushr 8).toByte()
|
||||
}
|
||||
flags.forEach { oneBased ->
|
||||
require(oneBased in 1..48) { "Номер флага должен быть от 1 до 48" }
|
||||
val bit = oneBased - 1
|
||||
payload[8 + bit / 8] = (payload[8 + bit / 8].toInt() or (1 shl (bit % 8))).toByte()
|
||||
}
|
||||
return fixed(address, CMD_STD, payload, 18)
|
||||
}
|
||||
|
||||
fun validateResponse(packet: ByteArray, address: Int, command: Int): Boolean {
|
||||
requireNative()
|
||||
return NativeSetProtocol.nativeTms2812ValidateResponse(packet, address, command, packet.size, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* BALZAM replies can omit final reserved bytes after CRC (two in ordinary
|
||||
* replies, four in UPLOAD data replies).
|
||||
* Call after the receive settle interval so a delayed tail cannot overlap
|
||||
* the next request. Never pad missing data or CRC bytes. UPLOAD uses its
|
||||
* own validator because its CRC precedes four trailing bytes, not two.
|
||||
*/
|
||||
fun normalizeResponseTail(
|
||||
packet: ByteArray,
|
||||
address: Int,
|
||||
command: Int,
|
||||
fullSize: Int,
|
||||
): ByteArray? {
|
||||
val uploadData = command == CMD_UPLOAD && fullSize >= UPLOAD_RESPONSE_OVERHEAD
|
||||
val reservedBytes = if (uploadData) 4 else 2
|
||||
if (fullSize < ACK_SIZE || packet.size !in (fullSize - reservedBytes)..fullSize) return null
|
||||
val normalized = packet.copyOf(fullSize)
|
||||
val valid = if (uploadData) {
|
||||
validateUploadResponse(normalized, address, fullSize - UPLOAD_RESPONSE_OVERHEAD)
|
||||
} else {
|
||||
requireNative()
|
||||
NativeSetProtocol.nativeTms2812ValidateResponse(packet, address, command, fullSize, true)
|
||||
}
|
||||
return normalized.takeIf { valid }
|
||||
}
|
||||
|
||||
fun peekValue(packet: ByteArray, address: Int): Int {
|
||||
require(packet.size == PEEK_RESPONSE_SIZE && validateResponse(packet, address, CMD_PEEK)) {
|
||||
"Поврежден ответ CMD_PEEK"
|
||||
}
|
||||
return (packet[2].toInt() and 0xFF) or ((packet[3].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
|
||||
fun parseStandard(packet: ByteArray, address: Int): StandardResponse {
|
||||
require(packet.size == STD_RESPONSE_SIZE && validateResponse(packet, address, CMD_STD)) {
|
||||
"Поврежден ответ CMD_STD"
|
||||
}
|
||||
val digital = packet.copyOfRange(2, 40)
|
||||
val analog = (0 until 34).map { index ->
|
||||
val offset = 40 + index * 2
|
||||
(((packet[offset].toInt() and 0xFF) or
|
||||
((packet[offset + 1].toInt() and 0xFF) shl 8)).toShort()).toInt()
|
||||
}
|
||||
return StandardResponse(digital, analog)
|
||||
}
|
||||
|
||||
fun commandName(command: Int): String = when (command) {
|
||||
CMD_LOAD -> "CMD_LOAD"
|
||||
CMD_UPLOAD -> "CMD_UPLOAD"
|
||||
CMD_RUN -> "CMD_RUN"
|
||||
CMD_XFLASH -> "CMD_XFLASH"
|
||||
CMD_TFLASH -> "CMD_TFLASH"
|
||||
CMD_PEEK -> "CMD_PEEK"
|
||||
CMD_POKE -> "CMD_POKE"
|
||||
CMD_INITLOAD -> "CMD_INITLOAD"
|
||||
CMD_INIT -> "CMD_INIT"
|
||||
CMD_EXTEND -> "CMD_EXTEND"
|
||||
CMD_VECTOR -> "CMD_VECTOR"
|
||||
CMD_IMPULSE -> "CMD_IMPULSE"
|
||||
CMD_STD -> "CMD_STD"
|
||||
CMD_TEST_ALL -> "CMD_TEST_ALL"
|
||||
else -> "CMD_$command"
|
||||
}
|
||||
|
||||
private fun fixed(address: Int, command: Int, payload: ByteArray, size: Int): ByteArray {
|
||||
require(address in 0..255) { "Адрес контроллера должен быть 0…255" }
|
||||
require(payload.size + 4 == size) { "Неверная длина пакета" }
|
||||
requireNative()
|
||||
return requireNotNull(NativeSetProtocol.nativeTms2812BuildFixed(address, command, payload))
|
||||
}
|
||||
|
||||
private fun le32(value: Long): ByteArray = ByteArray(4) { index ->
|
||||
(value ushr (index * 8)).toByte()
|
||||
}
|
||||
|
||||
private fun le32Value(data: ByteArray, offset: Int): Long =
|
||||
(data[offset].toLong() and 0xFF) or
|
||||
((data[offset + 1].toLong() and 0xFF) shl 8) or
|
||||
((data[offset + 2].toLong() and 0xFF) shl 16) or
|
||||
((data[offset + 3].toLong() and 0xFF) shl 24)
|
||||
|
||||
private fun requireNative() {
|
||||
check(NativeSetProtocol.available) { "Нативное ядро SETProtocol недоступно" }
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ object NativeSetProtocol {
|
||||
external fun nativePeriph28335ProjectName(projectIndex: Int): String?
|
||||
external fun nativePeriph28335CommandName(projectIndex: Int, commandIndex: Int): String?
|
||||
external fun nativeTms2812Crc16(input: ByteArray): Int
|
||||
external fun nativeTms2812BuildFixed(controller: Int, command: Int, payload: ByteArray): ByteArray?
|
||||
external fun nativeTms2812BuildMemory(controller: Int, command: Int, address: Long, value: Int): ByteArray?
|
||||
external fun nativeTms2812BuildTest(controller: Int, duration: Int, pause: Int, digits: ByteArray): ByteArray?
|
||||
external fun nativeTms2812ValidateResponse(input: ByteArray, controller: Int, command: Int,
|
||||
fullSize: Int, allowShortTail: Boolean): Boolean
|
||||
external fun nativeTms2812BuildUpload(controller: Int, wordAddress: Long, byteCount: Long): ByteArray?
|
||||
external fun nativeTms2812ExpectedUploadSize(byteCount: Long): Int
|
||||
external fun nativeTms2812ValidateUpload(input: ByteArray, controller: Int, byteCount: Int): Boolean
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
data class DeifBinding(val index: Int, val subindex: Int = 2, val scale: Double = 1.0)
|
||||
data class DeifDisplayConfig(
|
||||
val node: Int = 1, val canId: Int = 0x201,
|
||||
val bindings: Map<Parameter, DeifBinding> = Parameter.entries.associateWith { DeifBinding(it.index, 2, it.scale) },
|
||||
)
|
||||
data class DeifReading(val value: Double, val at: Long)
|
||||
data class DeifState(
|
||||
val config: DeifProtocol.Config = DeifProtocol.Config(),
|
||||
val raw: Map<Parameter, Int> = Parameter.entries.associateWith { 0 },
|
||||
val controlScales: DeifControls.Scales = DeifControls.Scales(),
|
||||
val unlocked: Boolean = false, val running: Boolean = false,
|
||||
val fullScreen: Boolean = false,
|
||||
val displays: List<DeifDisplayConfig> = List(2) { DeifDisplayConfig() },
|
||||
val readings: List<Map<Parameter, DeifReading>> = List(2) { emptyMap() },
|
||||
val sent: Long = 0, val received: Long = 0, val errors: Long = 0,
|
||||
val echoes: Long = 0, val echoTimes: Map<Int, Long> = emptyMap(),
|
||||
val intervalMs: Long = 0, val maxGapMs: Long = 0, val maxCycleMs: Long = 0,
|
||||
val status: String = "Передача выключена", val lastReceived: String = "Нет принятых данных",
|
||||
)
|
||||
|
||||
/** Owns both timers and display freshness independently of the Android screen lifecycle. */
|
||||
class DeifController(
|
||||
private val scope: CoroutineScope,
|
||||
private val send: suspend (CanBridgeProtocol.Frame) -> Unit,
|
||||
private val save: (String) -> Unit = {},
|
||||
private val now: () -> Long = { System.nanoTime() / 1_000_000 },
|
||||
) {
|
||||
private val mutable = MutableStateFlow(DeifState())
|
||||
val state = mutable.asStateFlow()
|
||||
private var job: Job? = null
|
||||
private val wire = Mutex()
|
||||
private var previousCycle: Long? = null
|
||||
|
||||
fun setFullScreen(enabled: Boolean) { mutable.value = state.value.copy(fullScreen = enabled) }
|
||||
|
||||
fun stop(message: String = "Передача остановлена") {
|
||||
job?.cancel(); job = null
|
||||
mutable.value = mutable.value.copy(running = false, status = message)
|
||||
}
|
||||
fun unlock(value: Boolean) {
|
||||
stop()
|
||||
mutable.value = mutable.value.copy(unlocked = value)
|
||||
}
|
||||
fun configure(config: DeifProtocol.Config) {
|
||||
check(state.value.unlocked) { "Разблокируйте настройки" }
|
||||
config.validate(); stop()
|
||||
mutable.value = state.value.copy(config = config)
|
||||
persist()
|
||||
}
|
||||
fun select(parameter: Parameter, enabled: Boolean) {
|
||||
stop()
|
||||
val c = state.value.config
|
||||
mutable.value = state.value.copy(config = c.copy(enabled = if (enabled) c.enabled + parameter else c.enabled - parameter))
|
||||
persist()
|
||||
}
|
||||
fun setRaw(parameter: Parameter, raw: Int) {
|
||||
require(raw in -32768..32767)
|
||||
mutable.value = state.value.copy(raw = state.value.raw + (parameter to raw))
|
||||
persist()
|
||||
}
|
||||
fun requestInstrument(field: DeifControls.Field, value: Double) {
|
||||
mutable.value = state.value.copy(raw = state.value.raw + DeifControls.raw(field, value, state.value.controlScales))
|
||||
persist()
|
||||
}
|
||||
fun configureControls(scales: DeifControls.Scales) {
|
||||
check(state.value.unlocked); scales.validate(); stop()
|
||||
mutable.value = state.value.copy(controlScales = scales)
|
||||
persist()
|
||||
}
|
||||
fun configureDisplay(side: Int, config: DeifDisplayConfig) {
|
||||
check(state.value.unlocked)
|
||||
require(config.node in 1..127 && config.canId in 0x201..0x57F)
|
||||
require(config.bindings.values.all { it.index in 0..65535 && it.subindex in 0..255 && it.scale.isFinite() })
|
||||
stop()
|
||||
mutable.value = state.value.copy(
|
||||
displays = state.value.displays.mapIndexed { i, d -> if (i == side) config else d },
|
||||
readings = state.value.readings.mapIndexed { i, d -> if (i == side) emptyMap() else d },
|
||||
)
|
||||
persist()
|
||||
}
|
||||
fun start(repeat: Boolean) {
|
||||
if (state.value.unlocked) { stop("Заблокируйте настройки перед передачей"); return }
|
||||
stop()
|
||||
if (!repeat && state.value.config.enabled.isEmpty()) return
|
||||
if (state.value.config.enabled.isEmpty() && !state.value.config.heartbeat) return
|
||||
previousCycle = null
|
||||
mutable.value = state.value.copy(running = true, sent = 0, errors = 0, echoes = 0, echoTimes = emptyMap(), maxGapMs = 0, maxCycleMs = 0)
|
||||
job = scope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
if (repeat && state.value.config.heartbeat) launch {
|
||||
while (isActive) {
|
||||
transmit(DeifProtocol.heartbeat(state.value.config)); delay(500)
|
||||
}
|
||||
}
|
||||
do {
|
||||
val started = now()
|
||||
val snapshot = state.value
|
||||
val frames = DeifProtocol.cycle(snapshot.config, snapshot.raw)
|
||||
frames.forEach { transmit(it) }
|
||||
val elapsed = now() - started
|
||||
val gap = previousCycle?.let { started - it } ?: 0
|
||||
previousCycle = started
|
||||
mutable.value = state.value.copy(intervalMs = gap,
|
||||
maxGapMs = maxOf(state.value.maxGapMs, gap), maxCycleMs = maxOf(state.value.maxCycleMs, elapsed))
|
||||
if (!repeat) break
|
||||
delay(maxOf(1, snapshot.config.periodMs - elapsed))
|
||||
} while (isActive)
|
||||
}
|
||||
mutable.value = state.value.copy(running = false)
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (error: Exception) { stop("Ошибка передачи: ${error.message}") }
|
||||
}
|
||||
}
|
||||
private suspend fun transmit(frame: CanBridgeProtocol.Frame) = wire.withLock {
|
||||
currentCoroutineContext().ensureActive()
|
||||
check(!state.value.unlocked)
|
||||
DeifProtocol.validateTransmit(frame)
|
||||
send(frame)
|
||||
accept(0, frame)
|
||||
mutable.value = state.value.copy(sent = state.value.sent + 1, status = "Передано в транспорт; приём прибором не подтверждён")
|
||||
}
|
||||
fun receive(frame: CanBridgeProtocol.Frame) {
|
||||
if (frame.isError) {
|
||||
mutable.value = state.value.copy(errors = state.value.errors + 1, lastReceived = "Ошибка CAN: %03X".format(frame.canId))
|
||||
return
|
||||
}
|
||||
if (frame.toCan) {
|
||||
val d = DeifProtocol.decode(frame)
|
||||
if (d.index != null && d.subindex != null) mutable.value = state.value.copy(
|
||||
echoes = state.value.echoes + 1, echoTimes = state.value.echoTimes + ((d.index * 256 + d.subindex) to now()))
|
||||
return // Transport TX notifications must never masquerade as SLAVE RX.
|
||||
}
|
||||
val decoded = DeifProtocol.decode(frame)
|
||||
mutable.value = state.value.copy(received = state.value.received + 1, lastReceived = decoded.summary)
|
||||
accept(1, frame)
|
||||
}
|
||||
private fun accept(side: Int, frame: CanBridgeProtocol.Frame) {
|
||||
val d = DeifProtocol.decode(frame)
|
||||
if (d.raw == null) return
|
||||
val config = state.value.displays[side]
|
||||
if (d.node != config.node || (d.mode == DeifProtocol.Mode.SAM && frame.canId != config.canId.toLong())) return
|
||||
val readings = state.value.readings[side].toMutableMap()
|
||||
config.bindings.forEach { (key, binding) ->
|
||||
if (binding.index == d.index && binding.subindex == d.subindex) {
|
||||
if (d.valid) readings[key] = DeifReading(d.raw * binding.scale, now()) else readings.remove(key)
|
||||
}
|
||||
}
|
||||
mutable.value = state.value.copy(readings = state.value.readings.mapIndexed { i, r -> if (i == side) readings else r })
|
||||
}
|
||||
fun value(side: Int, parameter: Parameter): Double? = state.value.readings[side][parameter]
|
||||
?.takeIf { now() - it.at <= 5000 }?.value
|
||||
|
||||
fun echoAge(index: Int, subindex: Int): Long? = state.value.echoTimes[index * 256 + subindex]?.let { now() - it }
|
||||
|
||||
fun restore(text: String) {
|
||||
runCatching {
|
||||
val root = JSONObject(text)
|
||||
val c = DeifProtocol.Config(DeifProtocol.Mode.valueOf(root.getString("mode")), root.getInt("node"),
|
||||
root.getInt("canId"), root.getLong("period"), root.getBoolean("heartbeat"), root.getInt("masterNode"),
|
||||
Parameter.entries.filter { root.getJSONArray("enabled").toString().contains("\"${it.name}\"") }.toSet())
|
||||
c.validate()
|
||||
val raw = Parameter.entries.associateWith { root.getJSONObject("raw").getInt(it.name).also { v -> require(v in -32768..32767) } }
|
||||
val displays = (0..1).map { side ->
|
||||
val o = root.getJSONArray("displays").getJSONObject(side)
|
||||
DeifDisplayConfig(o.getInt("node"), o.getInt("canId"), Parameter.entries.associateWith {
|
||||
val b = o.getJSONObject(it.name)
|
||||
DeifBinding(b.getInt("index"), b.getInt("sub"), b.getDouble("scale"))
|
||||
})
|
||||
}
|
||||
require(displays.all { it.node in 1..127 && it.canId in 0x201..0x57F && it.bindings.values.all { b -> b.index in 0..65535 && b.subindex in 0..255 && b.scale.isFinite() } })
|
||||
val controls = root.optJSONObject("controls")
|
||||
val scales = controls?.let { DeifControls.Scales(it.getDouble("power100"), it.getDouble("rpm100"),
|
||||
it.getDouble("angleRawDegree"), it.getDouble("powerPercentRaw")) } ?: DeifControls.Scales()
|
||||
scales.validate()
|
||||
mutable.value = DeifState(config = c, raw = raw, displays = displays, controlScales = scales)
|
||||
}.onFailure { mutable.value = DeifState(status = "Сохранённые настройки DEIF повреждены; загружены значения по умолчанию") }
|
||||
}
|
||||
private fun persist() {
|
||||
val s = state.value; val c = s.config
|
||||
val root = JSONObject().put("mode", c.mode.name).put("node", c.node).put("canId", c.canId)
|
||||
.put("controls", JSONObject().put("power100", s.controlScales.power100).put("rpm100", s.controlScales.rpm100)
|
||||
.put("angleRawDegree", s.controlScales.angleRawDegree).put("powerPercentRaw", s.controlScales.powerPercentRaw))
|
||||
.put("period", c.periodMs).put("heartbeat", c.heartbeat).put("masterNode", c.masterNode)
|
||||
.put("enabled", JSONArray(c.enabled.map { it.name }))
|
||||
.put("raw", JSONObject().also { o -> s.raw.forEach { (p, v) -> o.put(p.name, v) } })
|
||||
.put("displays", JSONArray().also { a -> s.displays.forEach { d ->
|
||||
a.put(JSONObject().put("node", d.node).put("canId", d.canId).also { o -> d.bindings.forEach { (p, b) ->
|
||||
o.put(p.name, JSONObject().put("index", b.index).put("sub", b.subindex).put("scale", b.scale))
|
||||
} })
|
||||
} })
|
||||
save(root.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
import kotlin.math.*
|
||||
|
||||
/** SETGUI Propulsor uses the same 600 × 510 coordinate system and calibration. */
|
||||
object DeifControls {
|
||||
enum class Field { POWER, RPM, ANGLE }
|
||||
data class Scales(val power100: Double = 1000.0, val rpm100: Double = 1000.0,
|
||||
val angleRawDegree: Double = 10.0, val powerPercentRaw: Double = 10.0) {
|
||||
fun validate() {
|
||||
require(listOf(power100, rpm100, angleRawDegree, powerPercentRaw).all { it.isFinite() && it > 0 && it <= 100000 }) {
|
||||
"Масштабы должны быть больше нуля и не больше 100000"
|
||||
}
|
||||
}
|
||||
}
|
||||
fun hit(x: Float, y: Float): Field? = when {
|
||||
x in 46f..116f && y in 110f..410f -> Field.POWER
|
||||
x in 174f..244f && y in 110f..410f -> Field.RPM
|
||||
hypot(x - 411f, y - 264f) <= 155f -> Field.ANGLE
|
||||
else -> null
|
||||
}
|
||||
fun requested(field: Field, x: Float, y: Float): Double? = when (field) {
|
||||
Field.ANGLE -> if (hypot(x - 411f, y - 264f) < 12f) null else atan2((x - 411).toDouble(), (264 - y).toDouble()) * 180 / PI
|
||||
else -> { val low = if (field == Field.RPM) -20.0 else 0.0
|
||||
(120 - (y - 126) / 270 * (120 - low)).coerceIn(low, 120.0) }
|
||||
}
|
||||
fun raw(field: Field, value: Double, scales: Scales): Map<Parameter, Int> {
|
||||
scales.validate(); require(value.isFinite())
|
||||
fun limited(v: Double) = round(v).coerceIn(-32767.0, 32765.0).toInt()
|
||||
if (field == Field.ANGLE) return mapOf(Parameter.ANGLE to limited(value * scales.angleRawDegree))
|
||||
val power = field == Field.POWER
|
||||
val factor = if (power) 1.0 else 10.0
|
||||
val full = if (power) scales.power100 else scales.rpm100
|
||||
val raw = limited(value / 100 * full * factor)
|
||||
val percent = raw / factor / full * 100
|
||||
return mapOf((if (power) Parameter.POWER else Parameter.RPM) to raw,
|
||||
(if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT) to limited(percent * if (power) scales.powerPercentRaw else 10.0))
|
||||
}
|
||||
fun preview(raw: Map<Parameter, Int>, scales: Scales): Map<Field, Double> = mapOf(
|
||||
Field.POWER to (raw[Parameter.POWER] ?: 0) / scales.power100 * 100,
|
||||
Field.RPM to (raw[Parameter.RPM] ?: 0) / 10.0 / scales.rpm100 * 100,
|
||||
Field.ANGLE to (raw[Parameter.ANGLE] ?: 0) / scales.angleRawDegree,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
/** Host hides system chrome; controller survives rotation and maintains the active cycle. */
|
||||
@Composable
|
||||
fun DeifFullScreen(controller: DeifController, connected: Boolean) {
|
||||
val state by controller.state.collectAsState()
|
||||
var tick by remember { mutableLongStateOf(0) }
|
||||
LaunchedEffect(controller) { while (true) { delay(250); tick++ } }
|
||||
BackHandler { controller.setFullScreen(false) }
|
||||
Column(Modifier.fillMaxSize().background(Color(0xFF090C0E)).displayCutoutPadding()) {
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val preview = DeifControls.preview(state.raw, state.controlScales)
|
||||
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||
val values = remember(state.readings, tick) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||
values, preview, controller::requestInstrument, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
if (maxWidth > maxHeight) Row(Modifier.fillMaxSize()) {
|
||||
instrument(0, Modifier.weight(1f).fillMaxHeight())
|
||||
instrument(1, Modifier.weight(1f).fillMaxHeight())
|
||||
} else Column(Modifier.fillMaxSize()) {
|
||||
instrument(0, Modifier.weight(1f).fillMaxWidth())
|
||||
instrument(1, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Surface(color = Color(0xFF111820), contentColor = Color.White) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ controller.setFullScreen(false) }) { Text("Выйти из полного экрана") }
|
||||
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running) { Text("Цикл") }
|
||||
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running) { Text("Старт") }
|
||||
Button({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||
Text(if (!connected) "Нет подключения" else if (state.unlocked) "Настройки разблокированы" else if (state.running) "MASTER работает" else "Передача выключена")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
import kotlin.math.*
|
||||
|
||||
/** Vector port of SETGUI Propulsor.paintEvent, including its original silhouette. */
|
||||
@Composable
|
||||
fun DeifInstrument(title: String, values: Map<Parameter, Double?>, preview: Map<DeifControls.Field, Double>,
|
||||
onRequest: (DeifControls.Field, Double) -> Unit, modifier: Modifier = Modifier) {
|
||||
val request by rememberUpdatedState(onRequest)
|
||||
Canvas(modifier.fillMaxWidth().aspectRatio(600f / 510f).pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val scale = min(size.width / 600f, size.height / 510f)
|
||||
val ox = (size.width - 600 * scale) / 2
|
||||
val oy = (size.height - 510 * scale) / 2
|
||||
fun logical(p: androidx.compose.ui.geometry.Offset) = (p.x - ox) / scale to (p.y - oy) / scale
|
||||
val (x, y) = logical(down.position)
|
||||
val field = DeifControls.hit(x, y)
|
||||
if (field != null) {
|
||||
down.consume()
|
||||
DeifControls.requested(field, x, y)?.let { request(field, it) }
|
||||
do {
|
||||
val change = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
|
||||
val (px, py) = logical(change.position)
|
||||
DeifControls.requested(field, px, py)?.let { request(field, it) }
|
||||
change.consume()
|
||||
} while (change.pressed)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
val c = drawContext.canvas.nativeCanvas
|
||||
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
fun color(hex: String) = android.graphics.Color.parseColor(hex)
|
||||
fun text(x: Float, y: Float, w: Float, h: Float, value: String, size: Float = 18f, ink: String = "#e9edf4", bold: Boolean = false) {
|
||||
p.style = Paint.Style.FILL; p.color = color(ink); p.textSize = size * 1.25f
|
||||
p.typeface = Typeface.create("sans-serif", if (bold) Typeface.BOLD else Typeface.NORMAL)
|
||||
p.textAlign = Paint.Align.CENTER
|
||||
c.drawText(value, x + w / 2, y + h / 2 - (p.ascent() + p.descent()) / 2, p)
|
||||
}
|
||||
fun line(x1: Float, y1: Float, x2: Float, y2: Float, ink: String, width: Float = 1f) {
|
||||
p.color = color(ink); p.strokeWidth = width; p.style = Paint.Style.STROKE; p.strokeCap = Paint.Cap.BUTT
|
||||
c.drawLine(x1, y1, x2, y2, p)
|
||||
}
|
||||
fun number(key: Parameter, suffix: String = "") = values[key]?.let { "%s%s".format(java.math.BigDecimal.valueOf(it).stripTrailingZeros().toPlainString(), suffix) } ?: "—"
|
||||
c.save()
|
||||
c.clipRect(0f, 0f, size.width, size.height)
|
||||
c.drawColor(color("#090c0e"))
|
||||
val scale = min(size.width / 600, size.height / 510)
|
||||
c.translate((size.width - 600 * scale) / 2, (size.height - 510 * scale) / 2); c.scale(scale, scale)
|
||||
text(15f, 12f, 570f, 40f, title, 22f)
|
||||
text(205f, 54f, 95f, 25f, "Задание:", 11f, "#8798ad")
|
||||
text(300f, 54f, 95f, 25f, preview[DeifControls.Field.ANGLE]?.let { "%.1f°".format(it) } ?: "—°", 11f)
|
||||
for ((x, field) in listOf(64f to DeifControls.Field.POWER, 192f to DeifControls.Field.RPM)) {
|
||||
val power = field == DeifControls.Field.POWER
|
||||
val low = if (power) 0 else -20
|
||||
fun yy(v: Double) = (126 + (120 - v) / (120 - low) * 270).toFloat()
|
||||
for ((a, b, ink) in listOf(Triple(low, 0, "#ae616b"), Triple(0, 100, "#657caf"), Triple(100, 120, "#b6c799"))) {
|
||||
if (b > a) { p.style = Paint.Style.FILL; p.color = color(ink); c.drawRect(x, yy(b.toDouble()), x + 34, yy(a.toDouble()), p) }
|
||||
}
|
||||
for (v in low..120 step 20) {
|
||||
val y = yy(v.toDouble()); line(x, y, x + 34, y, "#202b3a")
|
||||
if (v in listOf(low, 0, 40, 80, 120)) text(x - 40, y - 12, 36f, 24f, v.toString(), 12f)
|
||||
}
|
||||
val percentKey = if (power) Parameter.POWER_PERCENT else Parameter.RPM_PERCENT
|
||||
val percent = values[percentKey]
|
||||
if (percent != null) { val y = yy(percent.coerceIn(low.toDouble(), 120.0)); line(x - 3, y, x + 38, y, "#ffffff", 4f) }
|
||||
else { p.style = Paint.Style.FILL; p.color = android.graphics.Color.argb(130, 16, 21, 29); c.drawRect(x, 126f, x + 34, 396f, p) }
|
||||
preview[field]?.let { val y = yy(it.coerceIn(low.toDouble(), 120.0)); line(x - 8, y, x + 42, y, "#eeef69", 3f) }
|
||||
text(x - 35, 89f, 110f, 30f, number(percentKey, "%"), 17f)
|
||||
text(x - 45, 410f, 128f, 28f, if (power) "POWER" else "RPM", 17f)
|
||||
text(x - 52, 444f, 145f, 30f, number(if (power) Parameter.POWER else Parameter.RPM), 22f)
|
||||
text(x - 40, 478f, 125f, 22f, if (power) "RAW" else "rpm", 11f, "#8798ad")
|
||||
}
|
||||
val cx = 411f; val cy = 264f; val radius = 125f
|
||||
val ring = RectF(cx - 120, cy - 120, cx + 120, cy + 120)
|
||||
p.style = Paint.Style.STROKE; p.strokeWidth = 10f; p.strokeCap = Paint.Cap.BUTT
|
||||
p.color = color("#78f033"); c.drawArc(ring, -90f, 180f, false, p)
|
||||
p.color = color("#ff4935"); c.drawArc(ring, -90f, -180f, false, p)
|
||||
for (degree in 0 until 360 step 5) {
|
||||
val a = (degree - 90) * PI / 180
|
||||
val major = degree % 30 == 0
|
||||
fun xx(r: Int) = cx + r * cos(a).toFloat()
|
||||
fun yy(r: Int) = cy + r * sin(a).toFloat()
|
||||
line(xx(if (major) 115 else 118), yy(if (major) 115 else 118), xx(if (major) 137 else 126), yy(if (major) 137 else 126), "#e9f3ff", if (major) 2.3f else 1f)
|
||||
if (major) text(xx(156) - 26, yy(156) - 16, 52f, 32f, min(degree, 360 - degree).toString() + if (degree % 90 == 0) "°" else "", 17f, bold = true)
|
||||
}
|
||||
val measured = values[Parameter.ANGLE]
|
||||
val angle = preview[DeifControls.Field.ANGLE] ?: measured
|
||||
val outline = if (angle != null) "#eeef69" else "#657181"
|
||||
c.save(); c.translate(cx, cy); c.rotate((angle ?: 0.0).toFloat() + 90)
|
||||
p.color = color(outline); p.style = Paint.Style.STROKE; p.strokeWidth = 5.3f; p.strokeJoin = Paint.Join.ROUND; p.strokeCap = Paint.Cap.ROUND
|
||||
c.save(); c.scale(radius / 315, radius / 315); c.translate(-460f, -610f)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(399f, 550f); cubicTo(478f, 516f, 577f, 525f, 630f, 562f)
|
||||
cubicTo(679f, 594f, 672f, 632f, 626f, 661f); cubicTo(570f, 701f, 478f, 702f, 399f, 674f); close()
|
||||
}, p)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(399f, 550f); lineTo(360f, 552f); moveTo(318f, 563f)
|
||||
cubicTo(278f, 575f, 278f, 619f, 312f, 642f); lineTo(315f, 629f); cubicTo(341f, 659f, 370f, 676f, 399f, 674f)
|
||||
}, p)
|
||||
c.drawPath(Path().apply {
|
||||
moveTo(384f, 631f); cubicTo(348f, 615f, 307f, 555f, 317f, 506f)
|
||||
cubicTo(329f, 466f, 350f, 418f, 366f, 438f); cubicTo(384f, 466f, 392f, 524f, 384f, 631f); close()
|
||||
}, p)
|
||||
c.drawLine(318f, 499f, 384f, 631f, p)
|
||||
c.drawPath(Path().apply { moveTo(315f, 629f); cubicTo(304f, 681f, 320f, 780f, 350f, 785f); cubicTo(388f, 792f, 386f, 712f, 373f, 670f) }, p)
|
||||
c.restore()
|
||||
if (angle != null) {
|
||||
p.style = Paint.Style.FILL
|
||||
c.drawPath(Path().apply { moveTo(-115f, 0f); lineTo(-77f, -15f); lineTo(-77f, 15f); close() }, p)
|
||||
c.drawPath(Path().apply { moveTo(-126f, 0f); lineTo(-138f, -5f); lineTo(-138f, 5f); close() }, p)
|
||||
}
|
||||
c.restore()
|
||||
text(300f, 435f, 225f, 36f, number(Parameter.ANGLE, "°"), 25f)
|
||||
text(278f, 478f, 275f, 22f, "AZIMUTH", 11f, "#8798ad")
|
||||
c.restore()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
/** Reusable Compose port. The host supplies only transport and persistence. */
|
||||
@Composable
|
||||
fun DeifPanel(controller: DeifController, connected: Boolean, channel: String) {
|
||||
val state by controller.state.collectAsState()
|
||||
var clock by remember { mutableLongStateOf(0) }
|
||||
LaunchedEffect(controller) { while (true) { delay(250); clock++ } }
|
||||
var invalidInputs by remember { mutableStateOf(emptySet<Parameter>()) }
|
||||
var showRaw by remember { mutableStateOf(false) }
|
||||
var controlsDialog by remember { mutableStateOf(false) }
|
||||
var configuring by remember { mutableStateOf(false) }
|
||||
var displaySide by remember { mutableStateOf<Int?>(null) }
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("DEIF XDi · MASTER / SLAVE", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Канал: $channel · ${if (connected) "подключён" else "нет подключения"}")
|
||||
Row {
|
||||
Checkbox(state.unlocked, { controller.unlock(it) })
|
||||
Text("Разблокировать настройки", Modifier.padding(top = 12.dp))
|
||||
}
|
||||
Text("${state.config.mode} · Node ${state.config.node} · STD 0x%03X · %d мс".format(state.config.canId, state.config.periodMs))
|
||||
OutlinedButton(onClick = { configuring = true }, enabled = state.unlocked) { Text("Настроить передачу") }
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ controller.start(false) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Цикл") }
|
||||
Button({ controller.start(true) }, enabled = connected && !state.unlocked && !state.running && invalidInputs.isEmpty()) { Text("Старт") }
|
||||
OutlinedButton({ controller.stop() }, enabled = state.running) { Text("Стоп") }
|
||||
OutlinedButton({ controller.setFullScreen(true) }) { Text("На весь экран") }
|
||||
}
|
||||
Text("Коснитесь или перетащите POWER, RPM либо винт. Жёлтый — задание, белый — показания.", style = MaterialTheme.typography.bodySmall)
|
||||
val preview = remember(state.raw, state.controlScales) { DeifControls.preview(state.raw, state.controlScales) }
|
||||
@Composable fun instrument(side: Int, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
val values = remember(state.readings, clock) { Parameter.entries.associateWith { controller.value(side, it) } }
|
||||
DeifInstrument("${if (side == 0) "MASTER" else "SLAVE"} · Node ${state.displays[side].node}",
|
||||
values, preview, { field, value ->
|
||||
controller.requestInstrument(field, value)
|
||||
invalidInputs = emptySet()
|
||||
})
|
||||
OutlinedButton({ displaySide = side }, enabled = state.unlocked) { Text("Настроить ${if (side == 0) "MASTER" else "SLAVE"}") }
|
||||
}
|
||||
}
|
||||
BoxWithConstraints {
|
||||
if (maxWidth >= 760.dp) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
instrument(0, Modifier.weight(1f)); instrument(1, Modifier.weight(1f))
|
||||
} else Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
instrument(0, Modifier.fillMaxWidth()); instrument(1, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
OutlinedButton({ controlsDialog = true }, enabled = state.unlocked) { Text("Масштабы управления приборами") }
|
||||
TextButton({ showRaw = !showRaw }) { Text(if (showRaw) "Скрыть RAW и выбор параметров" else "RAW и выбор параметров") }
|
||||
if (showRaw) {
|
||||
Parameter.entries.forEach { parameter ->
|
||||
var input by remember(state.raw[parameter]) { mutableStateOf((state.raw[parameter] ?: 0).toString()) }
|
||||
val raw = input.toIntOrNull()
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Checkbox(parameter in state.config.enabled, { controller.select(parameter, it) })
|
||||
OutlinedTextField(input, { text ->
|
||||
input = text
|
||||
val value = text.toIntOrNull()
|
||||
invalidInputs = if (value != null && value in -32768..32767) invalidInputs - parameter else invalidInputs + parameter
|
||||
if (value != null && value in -32768..32767) controller.setRaw(parameter, value)
|
||||
else controller.stop("Исправьте RAW перед передачей")
|
||||
}, label = { Text("${parameter.title} (${parameter.unit}) · RAW") },
|
||||
isError = raw == null || raw !in -32768..32767, singleLine = true, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text("0x%04X:02%s · %s".format(parameter.index, if (parameter == Parameter.ANGLE) " + :07" else "",
|
||||
raw?.let { "%.1f %s".format(it * parameter.scale, parameter.unit) } ?: "—"))
|
||||
}
|
||||
Text("AC2: физическая единица мощности не подтверждена. Угол передаётся как фактический и заданный.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(state.status)
|
||||
Text("Передано: ${state.sent} · RX: ${state.received} · ошибок CAN: ${state.errors}")
|
||||
Text("TX-эхо gs_usb: ${state.echoes}. Эхо не является ответом DEIF.")
|
||||
if (channel.contains("gs_usb")) {
|
||||
val echoStatus = remember(state.echoTimes, state.config.enabled, clock) {
|
||||
state.config.enabled.flatMap { p -> (if (p == Parameter.ANGLE) listOf(2, 7) else listOf(2)).map { sub ->
|
||||
"%04X:%02X — %s".format(p.index, sub, controller.echoAge(p.index, sub)?.let { if (it > 3000) "нет эха >3 с" else "$it мс" } ?: "нет эха")
|
||||
} }.joinToString("\n")
|
||||
}
|
||||
Text(echoStatus, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text("Интервал: ${state.intervalMs} мс · макс. пауза: ${state.maxGapMs} мс · макс. цикл: ${state.maxCycleMs} мс")
|
||||
Text("Heartbeat ${if (state.config.heartbeat) "включён · 500 мс" else "выключен"}. TX означает передачу в API адаптера, а не подтверждение DEIF.", style = MaterialTheme.typography.bodySmall)
|
||||
Text(state.lastReceived)
|
||||
Text("SLAVE не использует TX-эхо; отсутствие ответа допустимо. Данные скрываются через 5 с. NMT, LSS и SDO записи не отправляются.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (controlsDialog) {
|
||||
val scales = state.controlScales
|
||||
val labels = listOf("POWER при 100%, RAW", "RPM при 100%, rpm", "Угол: RAW на 1°", "POWER%: RAW на 1%")
|
||||
val values = remember { listOf(scales.power100, scales.rpm100, scales.angleRawDegree, scales.powerPercentRaw).map { mutableStateOf(it.toString()) } }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { controlsDialog = false }, title = { Text("Масштабы управления") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text("Оба индикатора меняют общие задания. При работающем master значения отправятся следующим циклом.")
|
||||
labels.forEachIndexed { i, label -> OutlinedTextField(values[i].value, { values[i].value = it }, label = { Text(label) }) }
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching { controller.configureControls(DeifControls.Scales(values[0].value.toDouble(), values[1].value.toDouble(),
|
||||
values[2].value.toDouble(), values[3].value.toDouble())) }
|
||||
.onSuccess { controlsDialog = false }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ controlsDialog = false }) { Text("Отмена") } })
|
||||
}
|
||||
if (configuring) {
|
||||
val c = state.config
|
||||
var mode by remember { mutableStateOf(c.mode) }
|
||||
var node by remember { mutableStateOf(c.node.toString()) }
|
||||
var id by remember { mutableStateOf(c.canId.toString(16)) }
|
||||
var period by remember { mutableStateOf(c.periodMs.toString()) }
|
||||
var heartbeat by remember { mutableStateOf(c.heartbeat) }
|
||||
var master by remember { mutableStateOf(c.masterNode.toString()) }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { configuring = false }, title = { Text("Передача DEIF") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Row { DeifProtocol.Mode.entries.forEach { m -> FilterChip(mode == m, { mode = m }, label = { Text(m.name) }) } }
|
||||
Text("SAM: источник; DAM: получатель. Адрес самого прибора не изменяется.")
|
||||
OutlinedTextField(node, { node = it; it.toIntOrNull()?.let { n -> id = (0x200 + n).toString(16) } }, label = { Text("Node-ID (1…127)") })
|
||||
OutlinedTextField(id, { id = it }, label = { Text("STD ID, HEX (201 / 20B / 30B…)") })
|
||||
OutlinedTextField(period, { period = it }, label = { Text("Период, мс") })
|
||||
Row { Checkbox(heartbeat, { heartbeat = it }); Text("Отправлять heartbeat") }
|
||||
OutlinedTextField(master, { master = it }, label = { Text("Node-ID master для heartbeat DAM") })
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching { controller.configure(c.copy(mode = mode, node = node.toInt(), canId = id.removePrefix("0x").toInt(16),
|
||||
periodMs = period.toLong(), heartbeat = heartbeat, masterNode = master.toInt())) }
|
||||
.onSuccess { configuring = false }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ configuring = false }) { Text("Отмена") } })
|
||||
}
|
||||
displaySide?.let { side ->
|
||||
val config = state.displays[side]
|
||||
var node by remember { mutableStateOf(config.node.toString()) }
|
||||
var id by remember { mutableStateOf(config.canId.toString(16)) }
|
||||
val fields = remember { Parameter.entries.associateWith { p ->
|
||||
val b = config.bindings.getValue(p)
|
||||
mutableStateOf("%04X:%02X:%s".format(b.index, b.subindex, b.scale))
|
||||
} }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(onDismissRequest = { displaySide = null }, title = { Text("Привязки ${if (side == 0) "MASTER" else "SLAVE"}") }, text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(node, { node = it }, label = { Text("Node-ID: SAM источник / DAM получатель") })
|
||||
OutlinedTextField(id, { id = it }, label = { Text("SAM CAN ID, HEX") })
|
||||
Text("Формат: индекс HEX:подиндекс HEX:масштаб. Например 3001:02:0.1. DAM принимается по Node-ID на RPDO1…4.")
|
||||
Parameter.entries.forEach { p -> OutlinedTextField(fields.getValue(p).value, { fields.getValue(p).value = it }, label = { Text("${p.title} ${p.unit}") }) }
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}, confirmButton = { TextButton({
|
||||
runCatching {
|
||||
controller.configureDisplay(side, DeifDisplayConfig(node.toInt(), id.removePrefix("0x").toInt(16), fields.mapValues { (_, text) ->
|
||||
val parts = text.value.split(':'); require(parts.size == 3) { "Нужны индекс:подиндекс:масштаб" }
|
||||
DeifBinding(parts[0].toInt(16), parts[1].toInt(16), parts[2].toDouble())
|
||||
}))
|
||||
}.onSuccess { displaySide = null }.onFailure { problem = it.message }
|
||||
}) { Text("Применить") } }, dismissButton = { TextButton({ displaySide = null }) { Text("Отмена") } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
|
||||
/** XDi-net MPDO contract shared by Android hosts; matches set_devices/deif_xdi.py. */
|
||||
object DeifProtocol {
|
||||
enum class Mode { SAM, DAM }
|
||||
enum class Parameter(val index: Int, val title: String, val unit: String, val scale: Double) {
|
||||
ANGLE(0x3001, "Угол", "°", 0.1),
|
||||
RPM(0x3081, "Обороты", "rpm", 0.1),
|
||||
RPM_PERCENT(0x3091, "Обороты винта", "%", 0.1),
|
||||
POWER(0x3421, "Мощность AC2", "RAW", 1.0),
|
||||
POWER_PERCENT(0x3431, "Мощность AC2", "%", 0.1),
|
||||
}
|
||||
data class Config(
|
||||
val mode: Mode = Mode.SAM, val node: Int = 1, val canId: Int = 0x201,
|
||||
val periodMs: Long = 100, val heartbeat: Boolean = false, val masterNode: Int = 1,
|
||||
val enabled: Set<Parameter> = Parameter.entries.toSet(),
|
||||
) {
|
||||
fun validate() {
|
||||
require(node in 1..127 && masterNode in 1..127) { "Node-ID: 1…127" }
|
||||
require(periodMs in 20..60000) { "Период: 20…60000 мс" }
|
||||
require(if (mode == Mode.SAM) canId == 0x200 + node || canId == 0x27F
|
||||
else canId - node in listOf(0x200, 0x300, 0x400, 0x500)) { "STD ID не соответствует режиму и Node-ID" }
|
||||
}
|
||||
}
|
||||
data class Decoded(
|
||||
val summary: String, val node: Int? = null, val index: Int? = null,
|
||||
val subindex: Int? = null, val raw: Int? = null, val value: Double? = null,
|
||||
val unit: String = "", val mode: Mode? = null, val valid: Boolean = false,
|
||||
)
|
||||
|
||||
fun cycle(config: Config, raw: Map<Parameter, Int>): List<CanBridgeProtocol.Frame> {
|
||||
config.validate()
|
||||
val frames = Parameter.entries.filter { it in config.enabled }.map { p ->
|
||||
dataFrame(config, p.index, 2, raw[p] ?: 0)
|
||||
}.toMutableList()
|
||||
if (Parameter.ANGLE in config.enabled) frames += dataFrame(config, 0x3001, 7, raw[Parameter.ANGLE] ?: 0)
|
||||
return frames
|
||||
}
|
||||
private fun dataFrame(c: Config, index: Int, sub: Int, raw: Int): CanBridgeProtocol.Frame {
|
||||
require(raw in -32768..32767) { "RAW: −32768…32767" }
|
||||
return CanBridgeProtocol.buildFrame(c.canId.toLong(), byteArrayOf(
|
||||
(c.node or if (c.mode == Mode.DAM) 0x80 else 0).toByte(),
|
||||
index.toByte(), (index shr 8).toByte(), sub.toByte(), raw.toByte(), (raw shr 8).toByte(), 0, 0,
|
||||
), extended = false)
|
||||
}
|
||||
fun heartbeat(config: Config): CanBridgeProtocol.Frame {
|
||||
config.validate()
|
||||
val node = if (config.mode == Mode.SAM) config.node else config.masterNode
|
||||
return CanBridgeProtocol.buildFrame((0x700 + node).toLong(), byteArrayOf(5), extended = false)
|
||||
}
|
||||
fun decode(frame: CanBridgeProtocol.Frame): Decoded {
|
||||
fun reject(s: String) = Decoded("DEIF: $s")
|
||||
if (frame.isExtended || frame.isRemote || frame.isError) return reject("ожидается STD data frame")
|
||||
val id = frame.canId.toInt()
|
||||
val data = frame.data
|
||||
if (id in 0x701..0x77F) {
|
||||
if (data.size != 1) return reject("heartbeat: ожидается DLC=1")
|
||||
val state = when (data[0].toInt() and 255) { 0 -> "Boot-up"; 5 -> "Operational"; else -> "состояние 0x%02X".format(data[0]) }
|
||||
return Decoded("Node ${id - 0x700}: $state", node = id - 0x700, valid = true)
|
||||
}
|
||||
if (id !in 0x201..0x57F || data.size != 8) return reject("неизвестный ID или DLC (MPDO: 8 байт)")
|
||||
val node = data[0].toInt() and 127
|
||||
val mode = if (data[0].toInt() and 128 != 0) Mode.DAM else Mode.SAM
|
||||
if (node == 0 || !(if (mode == Mode.DAM) id - node in listOf(0x200, 0x300, 0x400, 0x500)
|
||||
else id in 0x201..0x27F && (id == 0x27F || id == 0x200 + node))) return reject("адрес не соответствует COB-ID")
|
||||
val index = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8)
|
||||
val sub = data[3].toInt() and 255
|
||||
val parameter = Parameter.entries.find { it.index and 0xFFF0 == index and 0xFFF0 }
|
||||
if (parameter == null || index and 15 == 0 || sub !in listOf(2, 7)) return reject("неизвестный объект 0x%04X:%02X".format(index, sub))
|
||||
val raw = ((data[4].toInt() and 255) or ((data[5].toInt() and 255) shl 8)).toShort().toInt()
|
||||
val status = when (raw) { -32768 -> "ниже диапазона"; 32766 -> "выше диапазона"; 32767 -> "ошибка данных"; else -> null }
|
||||
val scaled = sub == 2 || parameter == Parameter.ANGLE
|
||||
val value = if (status == null && scaled) raw * parameter.scale else null
|
||||
val unit = if (scaled) parameter.unit else "масштаб не установлен"
|
||||
val padding = if (data[6] != 0.toByte() || data[7] != 0.toByte()) " · ненулевое заполнение" else ""
|
||||
val summary = "$mode Node $node · 0x%04X:%02X · %s · RAW=%d".format(index, sub, parameter.title, raw) +
|
||||
" · " + (status ?: value?.let { "%.1f %s".format(it, unit) } ?: unit) + padding
|
||||
return Decoded(summary, node, index, sub, raw, value, unit, mode, status == null && padding.isEmpty())
|
||||
}
|
||||
/** Only display data and Operational heartbeat may reach this transmit path. */
|
||||
fun validateTransmit(frame: CanBridgeProtocol.Frame) {
|
||||
val decoded = decode(frame)
|
||||
val heartbeat = frame.canId in 0x701..0x77F && frame.data.contentEquals(byteArrayOf(5))
|
||||
val data = frame.data
|
||||
val known = decoded.index in Parameter.entries.map { it.index } &&
|
||||
(decoded.subindex == 2 || decoded.index == 0x3001 && decoded.subindex == 7)
|
||||
require(!frame.isExtended && !frame.isRemote && !frame.isError &&
|
||||
(heartbeat || known && decoded.raw != null && data.size == 8 && data[6] == 0.toByte() && data[7] == 0.toByte())) {
|
||||
"DEIF: разрешены только данные индикации и heartbeat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,79 @@
|
||||
#include "periph28335.h"
|
||||
#include "tms2812.h"
|
||||
|
||||
static jbyteArray tms_result(JNIEnv *env, const uint8_t *data, size_t size)
|
||||
{
|
||||
if (size == 0U || size > INT32_MAX) return NULL;
|
||||
jbyteArray result = (*env)->NewByteArray(env, (jsize)size);
|
||||
if (result != NULL) (*env)->SetByteArrayRegion(env, result, 0, (jsize)size, (const jbyte *)data);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812BuildFixed(
|
||||
JNIEnv *env, jobject self, jint controller, jint command, jbyteArray input)
|
||||
{
|
||||
(void)self;
|
||||
if (input == NULL || controller < 0 || controller > 255 || command < 0 || command > 255) return NULL;
|
||||
jsize size = (*env)->GetArrayLength(env, input);
|
||||
if (size > INT32_MAX - 4) return NULL;
|
||||
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
|
||||
if (data == NULL) return NULL;
|
||||
uint8_t *output = (uint8_t *)malloc((size_t)size + 4U);
|
||||
if (output == NULL) { (*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT); return NULL; }
|
||||
size_t written = tms2812_build_fixed_request((uint8_t)controller, (uint8_t)command,
|
||||
(const uint8_t *)data, (size_t)size, output, (size_t)size + 4U);
|
||||
(*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
|
||||
jbyteArray result = tms_result(env, output, written);
|
||||
free(output);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812BuildMemory(
|
||||
JNIEnv *env, jobject self, jint controller, jint command, jlong address, jint value)
|
||||
{
|
||||
(void)self;
|
||||
uint8_t output[12];
|
||||
if (controller < 0 || controller > 255 || command < 0 || command > 255 ||
|
||||
address < 0 || (uint64_t)address > UINT32_MAX || value < 0 || value > 65535) return NULL;
|
||||
size_t written = tms2812_build_memory_request((uint8_t)controller, (uint8_t)command,
|
||||
(uint32_t)address, (uint16_t)value, output, sizeof output);
|
||||
return tms_result(env, output, written);
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812BuildTest(
|
||||
JNIEnv *env, jobject self, jint controller, jint duration, jint pause, jbyteArray input)
|
||||
{
|
||||
(void)self;
|
||||
uint8_t output[20];
|
||||
if (input == NULL || controller < 0 || controller > 255 || duration < 0 ||
|
||||
duration > 65535 || pause < 0 || pause > 65535 || (*env)->GetArrayLength(env, input) != 12) return NULL;
|
||||
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
|
||||
if (data == NULL) return NULL;
|
||||
size_t written = tms2812_build_test_request((uint8_t)controller, (uint16_t)duration,
|
||||
(uint16_t)pause, (const uint8_t *)data, 12U, output, sizeof output);
|
||||
(*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
|
||||
return tms_result(env, output, written);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812ValidateResponse(
|
||||
JNIEnv *env, jobject self, jbyteArray input, jint controller, jint command,
|
||||
jint full_size, jboolean allow_short_tail)
|
||||
{
|
||||
(void)self;
|
||||
if (input == NULL || controller < 0 || controller > 255 || command < 0 || command > 255 || full_size < 6) return JNI_FALSE;
|
||||
jsize size = (*env)->GetArrayLength(env, input);
|
||||
jbyte *data = (*env)->GetByteArrayElements(env, input, NULL);
|
||||
if (data == NULL) return JNI_FALSE;
|
||||
int status = tms2812_validate_response((const uint8_t *)data, (size_t)size,
|
||||
(uint8_t)controller, (uint8_t)command, (size_t)full_size, allow_short_tail != JNI_FALSE);
|
||||
(*env)->ReleaseByteArrayElements(env, input, data, JNI_ABORT);
|
||||
return status == TMS2812_OK ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTms2812Crc16(
|
||||
JNIEnv *env, jobject self, jbyteArray input)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.setcorp.setflash.core
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class TmsTerminalSharedTest {
|
||||
@Test fun testAllUsesSharedNativeFraming() {
|
||||
val digits = byteArrayOf(0x81.toByte(), 2, 4, 8, 9, 0x20, 0x10, 0x20, 0x40, 0x80.toByte(), 3, 4)
|
||||
val packet = Tms2812Protocol.testAll(7, 25, 18, digits)
|
||||
assertEquals(20, packet.size)
|
||||
assertArrayEquals(byteArrayOf(7, 66, 25, 0, 18, 0) + digits, packet.copyOf(18))
|
||||
assertEquals(0, Tms2812Protocol.crc16(packet))
|
||||
}
|
||||
|
||||
@Test fun pokeKeepsWordAddressAndZeroPadding() {
|
||||
val packet = Tms2812Protocol.poke(10, 0x12345678L, 0xABCD)
|
||||
assertArrayEquals(byteArrayOf(10, 57, 0x78, 0x56, 0x34, 0x12,
|
||||
0xCD.toByte(), 0xAB.toByte(), 0, 0), packet.copyOf(10))
|
||||
assertEquals(0, Tms2812Protocol.crc16(packet))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun invalidAddressIsNotTruncated() {
|
||||
Tms2812Protocol.peek(10, 0x1_0000_0000L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.*
|
||||
import ru.setcorp.setprotocol.deif.DeifControls.Field
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DeifControlsTest {
|
||||
@Test fun fullScreenTogglePreservesLiveMasterAndIsNotRestoredOnNextLaunch() = runTest {
|
||||
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, { sent += it }, { saved = it })
|
||||
controller.requestInstrument(Field.ANGLE, -90.0)
|
||||
controller.start(true); runCurrent()
|
||||
controller.setFullScreen(true)
|
||||
val before = sent.size
|
||||
advanceTimeBy(101); runCurrent()
|
||||
assertTrue(sent.size > before)
|
||||
assertTrue(controller.state.value.fullScreen)
|
||||
assertEquals(-900, controller.state.value.raw[Parameter.ANGLE])
|
||||
controller.setFullScreen(false)
|
||||
assertTrue(controller.state.value.running)
|
||||
assertFalse(controller.state.value.fullScreen)
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertFalse(restored.state.value.fullScreen)
|
||||
assertFalse(restored.state.value.running)
|
||||
controller.stop()
|
||||
}
|
||||
@Test fun hitRegionsMatchSetguiAndLeaveBackgroundScrollable() {
|
||||
assertEquals(Field.POWER, DeifControls.hit(64f, 126f))
|
||||
assertEquals(Field.RPM, DeifControls.hit(192f, 396f))
|
||||
assertEquals(Field.ANGLE, DeifControls.hit(411f, 120f))
|
||||
assertNull(DeifControls.hit(10f, 10f))
|
||||
assertNull(DeifControls.hit(250f, 450f))
|
||||
}
|
||||
@Test fun angleHasNorthZeroAndSignedLeftRight() {
|
||||
for ((point, angle) in listOf((411f to 120f) to 0.0, (550f to 264f) to 90.0,
|
||||
(411f to 410f) to 180.0, (270f to 264f) to -90.0)) {
|
||||
assertEquals(angle, DeifControls.requested(Field.ANGLE, point.first, point.second)!!, 0.001)
|
||||
}
|
||||
assertNull(DeifControls.requested(Field.ANGLE, 411f, 264f))
|
||||
}
|
||||
@Test fun verticalDragsIncludeOverloadAndReverseRpm() {
|
||||
assertEquals(120.0, DeifControls.requested(Field.POWER, 64f, 126f)!!, 0.001)
|
||||
assertEquals(0.0, DeifControls.requested(Field.POWER, 64f, 600f)!!, 0.001)
|
||||
assertEquals(-20.0, DeifControls.requested(Field.RPM, 192f, 396f)!!, 0.001)
|
||||
assertEquals(120.0, DeifControls.requested(Field.RPM, 192f, -50f)!!, 0.001)
|
||||
}
|
||||
@Test fun gesturesUpdateAbsoluteAndPercentTogetherLikeSetgui() {
|
||||
val scales = DeifControls.Scales()
|
||||
assertEquals(mapOf(Parameter.POWER to 600, Parameter.POWER_PERCENT to 600), DeifControls.raw(Field.POWER, 60.0, scales))
|
||||
assertEquals(mapOf(Parameter.RPM to 5000, Parameter.RPM_PERCENT to 500), DeifControls.raw(Field.RPM, 50.0, scales))
|
||||
assertEquals(mapOf(Parameter.RPM to -2000, Parameter.RPM_PERCENT to -200), DeifControls.raw(Field.RPM, -20.0, scales))
|
||||
assertEquals(mapOf(Parameter.ANGLE to -900), DeifControls.raw(Field.ANGLE, -90.0, scales))
|
||||
}
|
||||
@Test fun clampingNeverProducesStatusSentinelsAndKeepsPercentConsistent() {
|
||||
val scales = DeifControls.Scales(rpm100 = 10000.0)
|
||||
val raw = DeifControls.raw(Field.RPM, 120.0, scales)
|
||||
assertEquals(32765, raw[Parameter.RPM])
|
||||
assertEquals(328, raw[Parameter.RPM_PERCENT])
|
||||
assertEquals(32.765, DeifControls.preview(raw, scales)[Field.RPM]!!, 0.0001)
|
||||
assertEquals(-32767, DeifControls.raw(Field.ANGLE, -180.0, scales.copy(angleRawDegree = 1000.0))[Parameter.ANGLE])
|
||||
}
|
||||
@Test fun commandPreviewWorksBeforeTransmissionAndScalesPersist() = runTest {
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, { error("Must not send while editing") }, { saved = it })
|
||||
controller.unlock(true)
|
||||
controller.configureControls(DeifControls.Scales(power100 = 2000.0))
|
||||
controller.requestInstrument(Field.POWER, 60.0)
|
||||
assertEquals(1200, controller.state.value.raw[Parameter.POWER])
|
||||
assertEquals(60.0, DeifControls.preview(controller.state.value.raw, controller.state.value.controlScales)[Field.POWER]!!, 0.001)
|
||||
assertNull(controller.value(0, Parameter.POWER))
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertEquals(controller.state.value.controlScales, restored.state.value.controlScales)
|
||||
}
|
||||
@Test fun draggingDuringMasterChangesNextCycle() = runTest {
|
||||
val sent = mutableListOf<ru.setcorp.setflash.core.CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it })
|
||||
controller.start(true); runCurrent()
|
||||
controller.requestInstrument(Field.RPM, 50.0)
|
||||
advanceTimeBy(101); runCurrent()
|
||||
val rpm = sent.map(DeifProtocol::decode).filter { it.index == 0x3081 }
|
||||
assertEquals(0, rpm.first().raw)
|
||||
assertEquals(5000, rpm.last().raw)
|
||||
assertTrue(controller.state.value.running)
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ru.setcorp.setprotocol.deif
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import ru.setcorp.setflash.core.CanBridgeProtocol
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Config
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Mode
|
||||
import ru.setcorp.setprotocol.deif.DeifProtocol.Parameter
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DeifTest {
|
||||
@Test fun matchesPythonSetguiReferenceCycles() {
|
||||
val text = checkNotNull(javaClass.getResourceAsStream("/deif-setgui.json")).bufferedReader().use { it.readText() }
|
||||
val cases = org.json.JSONArray(text)
|
||||
repeat(cases.length()) { i ->
|
||||
val c = cases.getJSONObject(i)
|
||||
val raw = Parameter.entries.mapIndexed { n, p -> p to c.getJSONArray("raw").getInt(n) }.toMap()
|
||||
val frames = DeifProtocol.cycle(Config(Mode.valueOf(c.getString("mode")), c.getInt("node"), c.getInt("canId")), raw)
|
||||
frames.forEachIndexed { n, f ->
|
||||
assertEquals("case $i frame $n", c.getJSONArray("frames").getString(n), f.data.joinToString("") { "%02x".format(it) })
|
||||
DeifProtocol.validateTransmit(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun frame(id: Long, data: String, tx: Boolean = false) = CanBridgeProtocol.buildFrame(
|
||||
id, CanBridgeProtocol.parseHex(data), extended = false, toCan = tx)
|
||||
|
||||
@Test fun setguiDamVectorAndBothAngles() {
|
||||
val frames = DeifProtocol.cycle(Config(Mode.DAM, 11, 0x20B), mapOf(Parameter.POWER to 1234, Parameter.ANGLE to -900))
|
||||
assertEquals(6, frames.size)
|
||||
assertEquals(frame(0x20B, "8B 21 34 02 D2 04 00 00", true), frames[3])
|
||||
assertEquals(frame(0x20B, "8B 01 30 02 7C FC 00 00", true), frames[0])
|
||||
assertEquals(frame(0x20B, "8B 01 30 07 7C FC 00 00", true), frames[5])
|
||||
assertTrue(frames.all { !it.isExtended && !it.isRemote })
|
||||
}
|
||||
@Test fun routingAndInputValidation() {
|
||||
for (node in listOf(1, 11, 127)) for (base in listOf(0x200, 0x300, 0x400, 0x500)) {
|
||||
val frames = DeifProtocol.cycle(Config(Mode.DAM, node, base + node), emptyMap())
|
||||
assertTrue(frames.all { DeifProtocol.decode(it).valid })
|
||||
frames.forEach(DeifProtocol::validateTransmit)
|
||||
}
|
||||
assertTrue(DeifProtocol.decode(frame(0x27F, "0B 81 30 02 D2 04 00 00")).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "0B 81 30 02 D2 04 00 00")).valid)
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(node = 0), emptyMap()) }
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(), mapOf(Parameter.RPM to 32768)) }
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.cycle(Config(Mode.SAM, 11, 0x30B), emptyMap()) }
|
||||
}
|
||||
@Test fun decoderScalesStatusesAndRejectsUnknownFrames() {
|
||||
assertEquals(-90.0, DeifProtocol.decode(frame(0x201, "01 01 30 07 7C FC 00 00")).value!!, 0.001)
|
||||
assertEquals(123.4, DeifProtocol.decode(frame(0x201, "01 81 30 02 D2 04 00 00")).value!!, 0.001)
|
||||
assertEquals("RAW", DeifProtocol.decode(frame(0x201, "01 21 34 02 D2 04 00 00")).unit)
|
||||
assertNull(DeifProtocol.decode(frame(0x201, "01 81 30 07 D2 04 00 00")).value)
|
||||
listOf("00 80", "FE 7F", "FF 7F").forEach { raw ->
|
||||
val decoded = DeifProtocol.decode(frame(0x201, "01 81 30 02 $raw 00 00"))
|
||||
assertFalse(decoded.valid); assertNull(decoded.value)
|
||||
}
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 01")).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30 02 00 00 00 00").copy(flags = CanBridgeProtocol.FLAG_IDE)).valid)
|
||||
assertFalse(DeifProtocol.decode(frame(0x201, "01 81 30")).valid)
|
||||
}
|
||||
@Test fun transmitGuardRejectsConfigurationAndBadAddresses() {
|
||||
listOf(frame(0, "01 0B"), frame(0x60B, "23 00 20 00 01 00 00 00"), frame(0x7E5, "04"),
|
||||
frame(0x70B, "00"), frame(0x201, "0B 81 30 02 00 00 00 00")).forEach {
|
||||
assertThrows(IllegalArgumentException::class.java) { DeifProtocol.validateTransmit(it) }
|
||||
}
|
||||
DeifProtocol.validateTransmit(DeifProtocol.heartbeat(Config()))
|
||||
assertEquals(0x701L, DeifProtocol.heartbeat(Config(Mode.DAM, 11, 0x20B)).canId)
|
||||
}
|
||||
@Test fun heartbeatHasIndependentPeriodAndStopsOnUnlock() = runTest {
|
||||
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it }, now = { testScheduler.currentTime })
|
||||
controller.unlock(true); controller.configure(Config(periodMs = 1000, heartbeat = true)); controller.unlock(false)
|
||||
controller.start(true); runCurrent()
|
||||
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(1, sent.count { it.data.size == 1 })
|
||||
advanceTimeBy(501); runCurrent()
|
||||
assertEquals(6, sent.count { it.data.size == 8 }); assertEquals(2, sent.count { it.data.size == 1 })
|
||||
controller.unlock(true)
|
||||
val count = sent.size; advanceTimeBy(2000); runCurrent(); assertEquals(count, sent.size)
|
||||
assertFalse(controller.state.value.running)
|
||||
}
|
||||
@Test fun singleCycleHasNoHeartbeatAndSelectionStopsRepeat() = runTest {
|
||||
val sent = mutableListOf<CanBridgeProtocol.Frame>()
|
||||
val controller = DeifController(backgroundScope, { sent += it })
|
||||
controller.unlock(true); controller.configure(Config(heartbeat = true, enabled = setOf(Parameter.RPM))); controller.unlock(false)
|
||||
controller.start(false); runCurrent()
|
||||
assertEquals(1, sent.size); assertEquals(8, sent.single().data.size)
|
||||
assertFalse(controller.state.value.running)
|
||||
controller.start(true); runCurrent(); controller.select(Parameter.RPM, false)
|
||||
val count = sent.size; advanceTimeBy(1500); runCurrent(); assertEquals(count, sent.size)
|
||||
}
|
||||
@Test fun failedTransportStopsBothTimers() = runTest {
|
||||
var calls = 0
|
||||
val controller = DeifController(backgroundScope, { calls++; error("disconnected") })
|
||||
controller.start(true); runCurrent(); advanceTimeBy(1000); runCurrent()
|
||||
assertEquals(1, calls); assertFalse(controller.state.value.running)
|
||||
assertEquals(0L, controller.state.value.sent)
|
||||
}
|
||||
@Test fun slaveNeverUsesLocalTxOrEchoAndExpires() = runTest {
|
||||
var time = 0L
|
||||
val controller = DeifController(backgroundScope, {}, now = { time })
|
||||
controller.setRaw(Parameter.RPM, 1234); controller.start(false); runCurrent()
|
||||
assertEquals(123.4, controller.value(0, Parameter.RPM)!!, 0.001)
|
||||
assertNull(controller.value(1, Parameter.RPM))
|
||||
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00", true))
|
||||
assertNull(controller.value(1, Parameter.RPM)); assertEquals(1L, controller.state.value.echoes)
|
||||
controller.receive(frame(0x201, "01 81 30 02 D2 04 00 00"))
|
||||
assertEquals(123.4, controller.value(1, Parameter.RPM)!!, 0.001)
|
||||
time = 5001; assertNull(controller.value(1, Parameter.RPM))
|
||||
controller.receive(frame(0x201, "01 81 30 02 FF 7F 00 00"))
|
||||
assertNull(controller.value(1, Parameter.RPM))
|
||||
}
|
||||
@Test fun settingsRestoreIsLockedAndStoppedAndKeepsBindings() = runTest {
|
||||
var saved = ""
|
||||
val controller = DeifController(backgroundScope, {}, { saved = it })
|
||||
controller.unlock(true); controller.configure(Config(Mode.DAM, 11, 0x30B, heartbeat = true))
|
||||
controller.configureDisplay(1, DeifDisplayConfig(11, 0x30B))
|
||||
controller.setRaw(Parameter.POWER, 1234)
|
||||
val restored = DeifController(backgroundScope, {})
|
||||
restored.restore(saved)
|
||||
assertEquals(controller.state.value.config, restored.state.value.config)
|
||||
assertEquals(11, restored.state.value.displays[1].node)
|
||||
assertEquals(1234, restored.state.value.raw[Parameter.POWER])
|
||||
assertFalse(restored.state.value.running); assertFalse(restored.state.value.unlocked)
|
||||
restored.receive(frame(0x30B, "8B 21 34 02 D2 04 00 00"))
|
||||
assertEquals(1234.0, restored.value(1, Parameter.POWER)!!, 0.001)
|
||||
}
|
||||
}
|
||||
41
c/set-protocol/ports/example-emulator/service.c
Normal file
41
c/set-protocol/ports/example-emulator/service.c
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Minimal board-independent composition. This compiles without any MCU headers.
|
||||
* Adapt these entry points to the selected board's network/USB/UART/CAN port.
|
||||
* Run every call in one task (or protect the entire call with the same lock). */
|
||||
#include "set_emu_igbt.h"
|
||||
static set_igbt_emu drivers[2];
|
||||
static set_reg_region regions[2];
|
||||
static set_regmap map;
|
||||
unsigned example_service_init(void)
|
||||
{
|
||||
unsigned i;
|
||||
for(i=0;i<2;++i) {
|
||||
set_reg_region *r=®ions[i];
|
||||
set_igbt_init(&drivers[i]);
|
||||
r->base=(uint16_t)(0x300+i*0x100);r->words=SET_IGBT_WORDS;
|
||||
r->kind=4;r->version=2;r->instance=(uint16_t)i;r->flags=3;
|
||||
r->context=&drivers[i];r->read=set_igbt_read;r->write=set_igbt_write;
|
||||
}
|
||||
return set_regmap_init(&map,regions,2);
|
||||
}
|
||||
void example_service_tick(uint8_t inputs0,uint8_t inputs1,uint8_t outputs[2])
|
||||
{
|
||||
set_igbt_tick(&drivers[0],inputs0);set_igbt_tick(&drivers[1],inputs1);
|
||||
outputs[0]=drivers[0].rx;outputs[1]=drivers[1].rx;
|
||||
}
|
||||
unsigned example_custom_protocol(unsigned function,uint16_t address,uint16_t value,
|
||||
uint16_t *words,size_t capacity,size_t *count)
|
||||
{
|
||||
/* Caller has already decoded/authenticated its own transport envelope. */
|
||||
return set_regmap_request(&map,function,address,value,words,capacity,count);
|
||||
}
|
||||
size_t example_uart_or_usb(const uint8_t *frame,size_t length,uint8_t *reply,size_t capacity)
|
||||
{
|
||||
return set_regmap_rtu(&map,16,frame,length,reply,capacity);
|
||||
}
|
||||
size_t example_can(const uint8_t frame[8],uint16_t *snapshot,size_t capacity,
|
||||
unsigned *token,unsigned *error)
|
||||
{
|
||||
/* Select the CAN request ID outside the model; send the returned snapshot
|
||||
* using set_regmap_can_fragment and the board's chosen response CAN ID. */
|
||||
return set_regmap_can(&map,frame,8,snapshot,capacity,token,error);
|
||||
}
|
||||
54
c/set-protocol/ports/stm32-usb-boot/boot_usb_protocol.inc
Normal file
54
c/set-protocol/ports/stm32-usb-boot/boot_usb_protocol.inc
Normal file
@@ -0,0 +1,54 @@
|
||||
/* Shared SET USB boot dispatcher. The MCU wrapper supplies BOOT_* macros. */
|
||||
#include "usb_setp_stream.h"
|
||||
#include <string.h>
|
||||
static setp_boot boot;
|
||||
static bool reset_pending;
|
||||
static uint32_t reset_at;
|
||||
typedef struct { uint8_t *out; uint16_t size; uint32_t now; } reply_context;
|
||||
void boot_protocol_init(void) { setp_boot_init(&boot,&BOOT_OPS,0,BOOT_APP_BASE,BOOT_APP_SIZE); }
|
||||
void boot_protocol_poll(uint32_t now) {
|
||||
if(reset_pending && (int32_t)(now-reset_at)>=0) BOOT_RESET();
|
||||
}
|
||||
static void dispatch(const setp_frame_t *r,void *user) {
|
||||
reply_context *c=user;
|
||||
uint8_t body[128]; uint16_t status=SETP_STATUS_OK; size_t n=0;
|
||||
setp_frame_t response={0};
|
||||
if((r->destination!=0 && r->destination!=BOOT_NODE) ||
|
||||
(r->flags&(SETP_FLAG_RESPONSE|SETP_FLAG_EVENT))) return;
|
||||
if(r->flags&(SETP_FLAG_ERROR|SETP_FLAG_MORE)) status=SETP_STATUS_INVALID_ARGUMENT;
|
||||
else if(r->message_type>=SETP_MSG_FW_BEGIN && r->message_type<=SETP_MSG_FW_ACTIVATE) {
|
||||
status=setp_boot_command(&boot,r->message_type,r->payload,r->payload_length);
|
||||
if(!status && r->message_type==SETP_MSG_FW_STATUS)
|
||||
n=setp_fw_status_encode(&boot.status,body+2,sizeof(body)-2);
|
||||
if(!status && r->message_type==SETP_MSG_FW_ACTIVATE) {
|
||||
reset_pending=true; reset_at=c->now+500;
|
||||
}
|
||||
} else if(r->payload_length) status=SETP_STATUS_INVALID_LENGTH;
|
||||
else switch(r->message_type) {
|
||||
case SETP_MSG_PING: setp_put_u32(body+2,c->now); n=4; break;
|
||||
case SETP_MSG_DEVICE_INFO: {
|
||||
static const uint8_t model[]=BOOT_MODEL;
|
||||
const setp_device_info_t info={1,0,0,0x10000,3,0,sizeof(model)-1,model};
|
||||
n=setp_device_info_encode(&info,body+2,sizeof(body)-2); break;
|
||||
}
|
||||
case SETP_MSG_CAPABILITIES: {
|
||||
const setp_capabilities_t caps={1,SETP_MAX_PAYLOAD,
|
||||
SETP_IFACE_MASK(SETP_IFACE_USB_CDC),SETP_FEATURE_FIRMWARE,0,0,0,0};
|
||||
n=setp_capabilities_encode(&caps,body+2,sizeof(body)-2); break;
|
||||
}
|
||||
default: status=SETP_STATUS_UNSUPPORTED; break;
|
||||
}
|
||||
setp_put_u16(body,status); if(status) n=0;
|
||||
response.flags=SETP_FLAG_RESPONSE|(status ? SETP_FLAG_ERROR : 0);
|
||||
response.message_type=r->message_type; response.sequence=r->sequence;
|
||||
response.source=BOOT_NODE; response.destination=r->source;
|
||||
response.payload=body; response.payload_length=(uint16_t)(n+2);
|
||||
c->size=(uint16_t)setp_frame_encode(&response,c->out,SETP_FRAME_MAX);
|
||||
}
|
||||
void usb_setp_reset(usb_setp_stream *s) { memset(s,0,sizeof(*s)); }
|
||||
uint16_t usb_setp_feed(usb_setp_stream *s,uint8_t byte,uint32_t now,uint8_t *out) {
|
||||
reply_context c={out,0,now};
|
||||
if(now-s->last_byte>1000) setp_parser_reset(&s->parser);
|
||||
s->last_byte=now; (void)setp_parser_feed(&s->parser,&byte,1,dispatch,&c);
|
||||
return c.size;
|
||||
}
|
||||
13
c/set-protocol/ports/stm32f407-periph/README.md
Normal file
13
c/set-protocol/ports/stm32f407-periph/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Порт STM32F407 / эмулятор периферии
|
||||
|
||||
Добавьте `src/ump_protocol.c` и `ump_stm32f407.c` в сборку.
|
||||
Порт получает backend с read/write и переводит 8-битные буферы STM32 в
|
||||
октеты ядра. Приём UART, CRC framing, CAN ID/DLC/RTR-фильтры и очередь
|
||||
передачи находятся в проекте; ядро проверяет CRC полного RTU кадра.
|
||||
Вызывать из главного цикла. В эмуляторе SysTick лишь накапливает тики,
|
||||
поэтому read/write и генератор не выполняются одновременно.
|
||||
Ответ RTU требует 253 байта; CAN snapshot — 124 uint16_t. Не передавайте
|
||||
неполный snapshot в fragment, count должен быть фактическим числом слов.
|
||||
Подключено в `home/407vet6_emul_TMS_Periph/Src/shared_ump.c`.
|
||||
|
||||
[Контракт, пример и проверки](../../docs/UMP_PROTOCOL.md).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user