Add embedded storage drivers and extend firmware metadata
This commit is contained in:
@@ -33,3 +33,5 @@ if(DS18B20_BUILD_TESTS)
|
|||||||
target_include_directories(test_ds18b20 PRIVATE tests)
|
target_include_directories(test_ds18b20 PRIVATE tests)
|
||||||
add_test(NAME ds18b20 COMMAND test_ds18b20)
|
add_test(NAME ds18b20 COMMAND test_ds18b20)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
add_subdirectory(instance)
|
||||||
|
|||||||
11
c/ds18b20/instance/CMakeLists.txt
Normal file
11
c/ds18b20/instance/CMakeLists.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(ds18b20_instance C)
|
||||||
|
add_library(ds18b20_instance STATIC Src/ds18b20.c)
|
||||||
|
target_include_directories(ds18b20_instance PUBLIC Inc)
|
||||||
|
set_target_properties(ds18b20_instance PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
enable_testing()
|
||||||
|
foreach(name copy_delay incremental_search)
|
||||||
|
add_executable(test_ds18b20_${name} Tests/test_ds18b20_${name}.c)
|
||||||
|
target_link_libraries(test_ds18b20_${name} PRIVATE ds18b20_instance)
|
||||||
|
add_test(NAME ds18b20_${name} COMMAND test_ds18b20_${name})
|
||||||
|
endforeach()
|
||||||
384
c/ds18b20/instance/HELP.md
Normal file
384
c/ds18b20/instance/HELP.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# DS18B20: справочник API
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Публичный API ядра объявлен только в `Inc/ds18b20.h`. Приложение компилирует
|
||||||
|
`Src/ds18b20.c`, но не включает этот файл. Библиотека выполняет Search ROM,
|
||||||
|
проверяет Dallas CRC8, запускает Convert T, читает scratchpad, декодирует
|
||||||
|
температуру, меняет разрешение и TH/TL.
|
||||||
|
|
||||||
|
## Константы и состояние
|
||||||
|
|
||||||
|
- `DS18B20_ROM_SIZE` — 8 байт полного 64-битного ROM.
|
||||||
|
- `DS18B20_SCRATCHPAD_SIZE` — 9 байт scratchpad вместе с CRC.
|
||||||
|
- `DS18B20_DEFAULT_TIMEOUT_MS` — 750 мс, значение конфигурации по умолчанию.
|
||||||
|
- `DS18B20_FAMILY_CODE` — `0x28`.
|
||||||
|
- `ds18b20_t` — состояние одной шины. После инициализации его поля напрямую не
|
||||||
|
изменяют; список датчиков читают через `ds18b20_count()` и `ds18b20_rom()`.
|
||||||
|
|
||||||
|
## Platform callbacks
|
||||||
|
|
||||||
|
`ds18b20_onewire_ops_t` связывает переносимое ядро с аппаратурой:
|
||||||
|
|
||||||
|
| Callback | Обязателен | Контракт |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `drive_low(context)` | да | Активно притянуть open-drain линию к 0 |
|
||||||
|
| `release(context)` | да | Отпустить линию; внешний/внутренний pull-up поднимает её |
|
||||||
|
| `read(context)` | да | Вернуть текущий логический уровень `0` или `1` |
|
||||||
|
| `delay_us(context, us)` | да | Синхронная задержка с микросекундной точностью |
|
||||||
|
| `tick_ms(context)` | для `ds18b20_wait` | Монотонный, допускающий uint32 wrap tick |
|
||||||
|
| `critical_enter(context)` | нет | Начать защиту одного временного слота |
|
||||||
|
| critical_exit(context) | нет | Восстановить состояние после защиты слота |
|
||||||
|
| strong_pullup(context, enable) | для parasite Copy | Активно удерживать high и безопасно вернуть open-drain |
|
||||||
|
|
||||||
|
Обе функции critical section задаются парой либо обе оставляются `NULL`.
|
||||||
|
|
||||||
|
## Коды возврата
|
||||||
|
|
||||||
|
| Код | Значение | Значение для приложения |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `DS18B20_OK` | 0 | Успех или преобразование готово |
|
||||||
|
| `DS18B20_E_ARGUMENT` | -1 | Неверный указатель, callback, размер или параметр |
|
||||||
|
| `DS18B20_E_IO` | -2 | Некорректная конфигурация scratchpad |
|
||||||
|
| `DS18B20_E_NO_DEVICE` | -3 | Нет presence pulse / подходящих устройств |
|
||||||
|
| `DS18B20_E_CRC` | -4 | CRC ROM или scratchpad не совпал |
|
||||||
|
| `DS18B20_E_TIMEOUT` | -5 | Истёк timeout блокирующего ожидания |
|
||||||
|
| `DS18B20_E_BUSY` | -6 | Преобразование ещё не готово |
|
||||||
|
| `DS18B20_E_CAPACITY` | -7 | Найдено больше ROM, чем помещается в storage |
|
||||||
|
| DS18B20_E_ROM | -8 | ROM имеет неверный family code либо отклонён адресной операцией |
|
||||||
|
| DS18B20_E_POWER | -9 | Parasite Copy запрошен без strong-pull-up callback |
|
||||||
|
|
||||||
|
## Инициализация и поиск
|
||||||
|
|
||||||
|
### `ds18b20_init`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_init(
|
||||||
|
ds18b20_t *instance,
|
||||||
|
const ds18b20_onewire_ops_t *ops,
|
||||||
|
void *platform_context,
|
||||||
|
uint8_t (*rom_storage)[DS18B20_ROM_SIZE],
|
||||||
|
size_t rom_capacity);
|
||||||
|
```
|
||||||
|
|
||||||
|
Обнуляет состояние, сохраняет callback/context/storage и отпускает линию.
|
||||||
|
`rom_storage` — массив приложения, `rom_capacity` — число ROM, не число байт.
|
||||||
|
Core не выделяет и не освобождает память.
|
||||||
|
|
||||||
|
### `ds18b20_search`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_search(ds18b20_t *instance);
|
||||||
|
```
|
||||||
|
|
||||||
|
Заново выполняет Search ROM и заменяет прежний список. Сохраняются только ROM с
|
||||||
|
family `0x28` и корректным CRC. Результаты поиска:
|
||||||
|
|
||||||
|
- `DS18B20_OK` — найден минимум один корректный DS18B20;
|
||||||
|
- `DS18B20_E_NO_DEVICE` — корректные DS18B20 не найдены;
|
||||||
|
- `DS18B20_E_CAPACITY` — storage заполнен; уже записанные ROM остаются доступны.
|
||||||
|
|
||||||
|
### `ds18b20_count` и `ds18b20_rom`
|
||||||
|
|
||||||
|
```c
|
||||||
|
size_t ds18b20_count(const ds18b20_t *instance);
|
||||||
|
const uint8_t *ds18b20_rom(const ds18b20_t *instance, size_t index);
|
||||||
|
```
|
||||||
|
|
||||||
|
`count` возвращает число сохранённых ROM либо 0 для неверного экземпляра.
|
||||||
|
`rom` возвращает указатель на 8 байт либо `NULL` для неверного индекса.
|
||||||
|
Указатель становится логически устаревшим после следующего поиска.
|
||||||
|
|
||||||
|
## Преобразование и чтение
|
||||||
|
|
||||||
|
### `ds18b20_start_all`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_start_all(ds18b20_t *instance);
|
||||||
|
```
|
||||||
|
|
||||||
|
Посылает `Skip ROM + Convert T` всем устройствам шины. Возвращает
|
||||||
|
`DS18B20_E_NO_DEVICE`, если нет presence pulse.
|
||||||
|
|
||||||
|
### `ds18b20_start`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_start(
|
||||||
|
ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяет ROM и посылает `Match ROM + Convert T` одному датчику.
|
||||||
|
|
||||||
|
### `ds18b20_conversion_ready`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *instance);
|
||||||
|
```
|
||||||
|
|
||||||
|
Один раз читает 1-Wire ready bit: `DS18B20_OK` означает готовность,
|
||||||
|
`DS18B20_E_BUSY` — преобразование продолжается. Это предпочтительная
|
||||||
|
неблокирующая проверка для main loop/RTOS.
|
||||||
|
|
||||||
|
### `ds18b20_wait`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_wait(
|
||||||
|
ds18b20_t *instance, uint32_t timeout_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
Блокирующе опрашивает ready bit до готовности или `DS18B20_E_TIMEOUT`. Требует
|
||||||
|
`tick_ms`. Функция не делает sleep/yield и не рекомендуется в основном цикле.
|
||||||
|
|
||||||
|
### `ds18b20_read_scratchpad`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_read_scratchpad(
|
||||||
|
ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяет ROM, посылает `Match ROM + Read Scratchpad`, читает 9 байт и
|
||||||
|
проверяет CRC. Функция не запускает Convert T и не проверяет, что преобразование
|
||||||
|
ранее завершилось.
|
||||||
|
|
||||||
|
### `ds18b20_decode_temperature`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_decode_temperature(
|
||||||
|
const uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE],
|
||||||
|
float *temperature_c);
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяет CRC, маскирует неопределённые младшие биты согласно разрешению 9–12
|
||||||
|
бит и возвращает градусы Цельсия. Неизвестная комбинация configuration bits
|
||||||
|
даёт `DS18B20_E_IO`.
|
||||||
|
|
||||||
|
## Конфигурация и User Bytes
|
||||||
|
|
||||||
|
### `ds18b20_set_resolution`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_set_resolution(
|
||||||
|
ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
uint8_t bits);
|
||||||
|
```
|
||||||
|
|
||||||
|
`bits` принимает только `9`, `10`, `11` или `12`. Функция читает
|
||||||
|
scratchpad, сохраняет TH/TL, записывает новый configuration byte и посылает
|
||||||
|
`Copy Scratchpad`.
|
||||||
|
|
||||||
|
### `ds18b20_write_user_bytes`
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t ds18b20_write_user_bytes(
|
||||||
|
ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
int16_t bytes12,
|
||||||
|
int16_t bytes34,
|
||||||
|
uint8_t mask);
|
||||||
|
```
|
||||||
|
|
||||||
|
Текущий контракт отражает физические writable bytes DS18B20:
|
||||||
|
|
||||||
|
- `mask & 0x01` записывает младшие 8 бит `bytes12` в TH, scratchpad[2];
|
||||||
|
- `mask & 0x02` записывает старшие 8 бит `bytes12` в TL, scratchpad[3];
|
||||||
|
- `bytes34` зарезервирован и не используется: scratchpad[6]/[7] read-only;
|
||||||
|
- остальные биты `mask` игнорируются.
|
||||||
|
|
||||||
|
Перед записью функция читает scratchpad, поэтому невыбранный TH/TL и
|
||||||
|
configuration byte сохраняются. Затем выполняются `Write Scratchpad` и
|
||||||
|
`Copy Scratchpad`. Функция не выполняет no-op detection, Recall E2 и readback.
|
||||||
|
Application adapter обязан добавить эти шаги, если операция используется как
|
||||||
|
подтверждённая пользовательская запись.
|
||||||
|
|
||||||
|
## CRC и ROM
|
||||||
|
|
||||||
|
```c
|
||||||
|
uint8_t ds18b20_crc8(const void *data, size_t length);
|
||||||
|
ds18b20_status_t ds18b20_validate_rom(
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
```
|
||||||
|
|
||||||
|
`crc8` вычисляет Dallas/Maxim CRC-8. `validate_rom` отдельно проверяет family
|
||||||
|
`0x28` и CRC первых семи байтов против восьмого.
|
||||||
|
|
||||||
|
## Неблокирующий пример опроса
|
||||||
|
|
||||||
|
```c
|
||||||
|
enum poll_state { POLL_START, POLL_WAIT };
|
||||||
|
static enum poll_state state = POLL_START;
|
||||||
|
static uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE];
|
||||||
|
|
||||||
|
void poll_ds18b20(void)
|
||||||
|
{
|
||||||
|
if (state == POLL_START) {
|
||||||
|
if (ds18b20_start_all(&bus) == DS18B20_OK) {
|
||||||
|
state = POLL_WAIT;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ds18b20_conversion_ready(&bus) != DS18B20_OK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < ds18b20_count(&bus); ++i) {
|
||||||
|
const uint8_t *rom = ds18b20_rom(&bus, i);
|
||||||
|
float temperature_c;
|
||||||
|
if (ds18b20_read_scratchpad(&bus, rom, scratchpad) == DS18B20_OK &&
|
||||||
|
ds18b20_decode_temperature(scratchpad, &temperature_c) ==
|
||||||
|
DS18B20_OK) {
|
||||||
|
publish_temperature(rom, temperature_c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = POLL_START;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Production adapter должен дополнительно иметь deadline: постоянный
|
||||||
|
`DS18B20_E_BUSY` не должен навсегда удерживать state machine.
|
||||||
|
|
||||||
|
## Диагностика
|
||||||
|
|
||||||
|
| Симптом | Проверка |
|
||||||
|
| --- | --- |
|
||||||
|
| `DS18B20_E_NO_DEVICE` | Питание, общий GND, pull-up, pin, presence pulse |
|
||||||
|
| Постоянный `DS18B20_E_BUSY` | Deadline, питание датчика, конфликт операций |
|
||||||
|
| `DS18B20_E_CRC` | Длина/топология шины, помехи, timing, pull-up |
|
||||||
|
| `DS18B20_E_CAPACITY` | Увеличить ROM storage или ограничить число устройств |
|
||||||
|
| Неверная температура | Не читать до ready; проверить CRC и resolution bits |
|
||||||
|
| Зависание в delay | Таймер должен быть запущен и считать непрерывно |
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Вызовы одной шины не reentrant и не ISR-safe.
|
||||||
|
- Микросекундные 1-Wire-слоты синхронные; длительное преобразование должно быть
|
||||||
|
вынесено в state machine.
|
||||||
|
- Callback `strong_pullup` используется модулем UserByte для parasite-powered
|
||||||
|
`Copy Scratchpad`; аппаратная схема и timing должны быть проверены на плате.
|
||||||
|
- Low-level `Copy Scratchpad` намеренно разделён на `start/finish`; выдержку
|
||||||
|
10 ms, Recall E2, CRC/readback и освобождение шины обеспечивает state machine
|
||||||
|
`Modules/UserByte`, а не одиночный low-level вызов.
|
||||||
|
- Поиск синхронный и не имеет cancel callback; для большой шины или жёстких
|
||||||
|
realtime-требований нужен пошаговый автомат поиска.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest Libraries.PortableTests.test_portable_models
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель проверяет CRC, независимость экземпляров и неблокирующую интеграцию.
|
||||||
|
`Libraries/PortableTests/test_portable_libraries.c` дополнительно проверяет C
|
||||||
|
API CRC, декодирование температуры и независимость двух шин. Для полной задачи
|
||||||
|
также обязательны `git diff --check` и целевая Keil-сборка `0/0`.
|
||||||
|
# User Byte и EEPROM
|
||||||
|
|
||||||
|
Для новой логики приложения используйте внутренний модуль `Modules/UserByte`
|
||||||
|
с явным selector `TH/TL`, а не legacy `ds18b20_write_user_bytes`. Low-level API `write_scratchpad`, `copy_*`,
|
||||||
|
`recall_e2`, `recall_ready` и `recover_bus` предназначен для его state machine.
|
||||||
|
`strong_pullup` обязателен только для parasite-powered Copy Scratchpad.
|
||||||
|
|
||||||
|
`ds18b20_search_retry_due` помогает приложению повторять поиск при `count=0`,
|
||||||
|
не вмешиваясь в активную температурную конверсию; период и tick задаёт adapter.
|
||||||
|
## Версия Modbus-контракта User Byte
|
||||||
|
|
||||||
|
### Явный неблокирующий поиск
|
||||||
|
|
||||||
|
```c
|
||||||
|
ds18b20_status_t status = ds18b20_search_begin(&bus);
|
||||||
|
while (status == DS18B20_E_BUSY ||
|
||||||
|
status == DS18B20_E_CRC ||
|
||||||
|
status == DS18B20_E_ROM) {
|
||||||
|
status = ds18b20_search_step(&bus);
|
||||||
|
/* Между шагами основной цикл продолжает обслуживать Modbus/RTC/SD. */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не вызывайте `ds18b20_search_retry_due()` для GUI-команды: reconnect и polling
|
||||||
|
не должны автоматически менять таблицу ROM. В проектном адаптере команда
|
||||||
|
захватывается только по `apply=1`, а `sequence` защищает от старого ответа.
|
||||||
|
|
||||||
|
Legacy API `ds18b20_user_byte_submit()` остаётся TH-only. Для явного TL
|
||||||
|
используется selector `DS18B20_USER_BYTE_TL`; STM32 adapter принимает его только
|
||||||
|
с `contractVersion=2`, записанным атомарно с selector до APPLY. Это защищает GUI
|
||||||
|
от старого bridge/firmware, которое всегда маршрутизировало запрос как TH.
|
||||||
|
|
||||||
|
### Известные ROM и ошибка поиска detail 2
|
||||||
|
|
||||||
|
В F407 сохранённые SensorBindings восстанавливаются в список опроса после
|
||||||
|
AppStorage_Init. Если список непустой, стартовый SEARCH ROM не запускается.
|
||||||
|
Температура читается адресно через MATCH ROM; запуск преобразования общий.
|
||||||
|
Даже при отсутствии датчика во время старта его адрес остаётся доступен для
|
||||||
|
повторного чтения. Подключение подтверждается успешным чтением температуры.
|
||||||
|
|
||||||
|
Явный поиск добавляет новые ID. При успехе, ошибке, тайм-ауте и отмене
|
||||||
|
Dallas_FinishSearch объединяет результат с прежними ID, включая ещё не
|
||||||
|
сохранённые во Flash. Ёмкость списка — 32 ID; найденные ID занимают места
|
||||||
|
первыми. Сохранение использует существующий SensorBindings_SyncFound и
|
||||||
|
повтор при BUSY. Отключать питание следует после завершения сохранения.
|
||||||
|
|
||||||
|
MCU detail 2 = DS18B20_E_IO: поиск ROM получил недопустимую комбинацию
|
||||||
|
битов либо неполный ROM. Это не доказательство конкретной причины на кабеле.
|
||||||
|
Ошибка поиска остаётся видна, но известные ID продолжают опрашиваться.
|
||||||
|
Число в каталоге включает известные адреса; наличие определяется connected.
|
||||||
|
|
||||||
|
API ds18b20_add_known_rom проверяет family/CRC, возвращает E_ARGUMENT,
|
||||||
|
E_ROM, E_CRC или E_CAPACITY; дубликат возвращает OK. Функция не делает
|
||||||
|
I/O и не подтверждает наличие датчика. Вызывать вне поиска и конверсии.
|
||||||
|
|
||||||
|
Проверки: `Libraries/DS18B20/Tests/run_host_tests.ps1` и
|
||||||
|
`python -m unittest discover -s tests -p test_known_rom_host.py`.
|
||||||
|
Для аппаратной проверки: найти датчики по одному, дождаться сохранения,
|
||||||
|
подключить все на 20 м, перезапустить МК без команды поиска и проверить
|
||||||
|
обновление температур; затем повторить при ошибке/отмене поиска.
|
||||||
|
|
||||||
|
### Поиск с восстановлением после ошибок
|
||||||
|
|
||||||
|
Настройки в `Inc/ds18b20_config.h`: три прохода дерева, четыре дополнительных
|
||||||
|
повтора каждой неудачной ветки (пять попыток суммарно). Один вызов step делает
|
||||||
|
не более одной попытки ROM: Modbus и остальные сервисы работают между ними.
|
||||||
|
Перед попыткой сохраняются ROM-путь и discrepancy; после IO, отсутствия presence
|
||||||
|
или CRC они восстанавливаются. После пяти ошибок начинается следующий проход.
|
||||||
|
Результаты всех проходов объединяются; дубликаты не занимают ёмкость. CRC
|
||||||
|
проверяется до принятия пути. Чужое семейство с корректным CRC пропускается.
|
||||||
|
|
||||||
|
Успех означает, что хотя бы один проход полностью обошёл дерево и найден хотя
|
||||||
|
бы один DS18B20. Ранее восстановленные ошибки сохраняются в диагностике, но
|
||||||
|
не превращают успешный поиск в CRC_ERROR. Это не гарантия обнаружения каждого
|
||||||
|
физического датчика на нестабильной линии. Если ни один проход не завершён,
|
||||||
|
возвращается последняя ошибка; проверенные частичные результаты сохраняются.
|
||||||
|
Пустая линия ограничена 15 попытками reset. Общий предел 512 попыток защищает
|
||||||
|
и блокирующий API; приложение дополнительно ограничивает поиск 12000 мс.
|
||||||
|
|
||||||
|
Во время поиска после каждого слота добавляется 20 мкс высокого уровня:
|
||||||
|
после записи нуля получается минимум 30 мкс вместо 10 мкс. Импульсы 6/60 мкс,
|
||||||
|
read-init 3 мкс и выборка через дополнительные 10 мкс остаются прежними.
|
||||||
|
Добавочная пауза выполняется с разрешёнными прерываниями. Обычное чтение,
|
||||||
|
конверсия и Copy Scratchpad не получают эту добавку. Подбор паузы для кабеля
|
||||||
|
требует измерений; программная модель не подтверждает аналоговый фронт.
|
||||||
|
|
||||||
|
`bus.search_diagnostics` содержит attempts, retries, crc_errors, io_errors,
|
||||||
|
passes_finished, complete_passes и последнее место ошибки. Номер бита 1..64;
|
||||||
|
0 означает reset/общий лимит, pair=0xFF — пары нет. Диагностика сохраняется
|
||||||
|
после последующего успеха и обнуляется новым search_begin.
|
||||||
|
|
||||||
|
FC04: 1210–1217 совместимы, 1218 и 1219 используют прежний резерв:
|
||||||
|
- 1218: биты 0..6 — номер ROM-бита, бит 8 — complement, бит 9 — id,
|
||||||
|
биты 12..15 — положительный код последней ошибки; 0 — ошибок не было.
|
||||||
|
- 1219: биты 0..7 — число повторов с насыщением 255, биты 8..15 — число
|
||||||
|
законченных проходов, включая прерванные после исчерпания повторов.
|
||||||
|
|
||||||
|
Bridge читает 10 регистров и возвращает `diagnostics` в ответе поиска.
|
||||||
|
Для ошибки шины сообщение дополнено битом, id/complement, повторами и проходами.
|
||||||
|
Старые восемь полей сохранены. При bit=0 пара не интерпретируется.
|
||||||
|
|
||||||
|
Проверки: `run_host_tests.ps1`, `test_known_rom_host.py`,
|
||||||
|
`test_sensor_search_diagnostics_host.py`, `test_remote_ds18b20_host.py`.
|
||||||
|
На плате проверить 20 последовательных поисков трёх датчиков на 20 м,
|
||||||
|
отключение/подключение датчика при поиске, отмену и восстановление опроса.
|
||||||
126
c/ds18b20/instance/Inc/ds18b20.h
Normal file
126
c/ds18b20/instance/Inc/ds18b20.h
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
#ifndef PORTABLE_DS18B20_H
|
||||||
|
#define PORTABLE_DS18B20_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define DS18B20_ROM_SIZE 8U
|
||||||
|
#define DS18B20_SCRATCHPAD_SIZE 9U
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
DS18B20_OK = 0,
|
||||||
|
DS18B20_E_ARGUMENT = -1,
|
||||||
|
DS18B20_E_IO = -2,
|
||||||
|
DS18B20_E_NO_DEVICE = -3,
|
||||||
|
DS18B20_E_CRC = -4,
|
||||||
|
DS18B20_E_TIMEOUT = -5,
|
||||||
|
DS18B20_E_BUSY = -6,
|
||||||
|
DS18B20_E_CAPACITY = -7,
|
||||||
|
DS18B20_E_ROM = -8,
|
||||||
|
DS18B20_E_POWER = -9
|
||||||
|
} ds18b20_status_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void (*drive_low)(void *context);
|
||||||
|
void (*release)(void *context);
|
||||||
|
uint8_t (*read)(void *context);
|
||||||
|
void (*delay_us)(void *context, uint32_t us);
|
||||||
|
uint32_t (*tick_ms)(void *context);
|
||||||
|
void (*critical_enter)(void *context);
|
||||||
|
void (*critical_exit)(void *context);
|
||||||
|
/* Optional callback used only while parasite-powered EEPROM is copied. */
|
||||||
|
void (*strong_pullup)(void *context, uint8_t enable);
|
||||||
|
} ds18b20_onewire_ops_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint16_t attempts;
|
||||||
|
uint16_t retries;
|
||||||
|
uint16_t crc_errors;
|
||||||
|
uint16_t io_errors;
|
||||||
|
uint8_t passes_finished;
|
||||||
|
uint8_t complete_passes;
|
||||||
|
uint8_t last_error_bit; /* 1..64; 0 = reset or overall attempt limit. */
|
||||||
|
uint8_t last_error_pair; /* bit 1 = id, bit 0 = complement; 0xFF = no pair. */
|
||||||
|
ds18b20_status_t last_error;
|
||||||
|
} ds18b20_search_diagnostics_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const ds18b20_onewire_ops_t *ops;
|
||||||
|
void *platform_context;
|
||||||
|
uint8_t (*roms)[DS18B20_ROM_SIZE];
|
||||||
|
size_t rom_capacity;
|
||||||
|
size_t rom_count;
|
||||||
|
uint8_t search_rom[DS18B20_ROM_SIZE];
|
||||||
|
uint8_t last_discrepancy;
|
||||||
|
uint8_t last_family_discrepancy;
|
||||||
|
uint8_t last_device;
|
||||||
|
uint8_t strong_pullup_active;
|
||||||
|
uint8_t search_active;
|
||||||
|
uint8_t search_retries;
|
||||||
|
ds18b20_status_t search_result;
|
||||||
|
ds18b20_search_diagnostics_t search_diagnostics;
|
||||||
|
} ds18b20_t;
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_init(ds18b20_t *instance,
|
||||||
|
const ds18b20_onewire_ops_t *ops,
|
||||||
|
void *platform_context,
|
||||||
|
uint8_t (*rom_storage)[DS18B20_ROM_SIZE],
|
||||||
|
size_t rom_capacity);
|
||||||
|
ds18b20_status_t ds18b20_search(ds18b20_t *instance);
|
||||||
|
/* Incremental search keeps application services responsive by processing one
|
||||||
|
* physical ROM attempt per call. Begin clears the previous result table.
|
||||||
|
* BUSY includes internal retries/passes. All other results are terminal;
|
||||||
|
* a failed search may still contain validated partial results. */
|
||||||
|
ds18b20_status_t ds18b20_search_begin(ds18b20_t *instance);
|
||||||
|
ds18b20_status_t ds18b20_search_step(ds18b20_t *instance);
|
||||||
|
/* Add a validated known ROM without bus traffic; duplicates are idempotent.
|
||||||
|
* Call only outside an active search/conversion. Presence is checked by reads. */
|
||||||
|
ds18b20_status_t ds18b20_add_known_rom(ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
size_t ds18b20_count(const ds18b20_t *instance);
|
||||||
|
const uint8_t *ds18b20_rom(const ds18b20_t *instance, size_t index);
|
||||||
|
uint8_t ds18b20_crc8(const void *data, size_t length);
|
||||||
|
ds18b20_status_t ds18b20_validate_rom(const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
ds18b20_status_t ds18b20_start_all(ds18b20_t *instance);
|
||||||
|
ds18b20_status_t ds18b20_start(ds18b20_t *instance,
|
||||||
|
const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *instance);
|
||||||
|
ds18b20_status_t ds18b20_wait(ds18b20_t *instance, uint32_t timeout_ms);
|
||||||
|
ds18b20_status_t ds18b20_read_scratchpad(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE]);
|
||||||
|
ds18b20_status_t ds18b20_decode_temperature(
|
||||||
|
const uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE], float *temperature_c);
|
||||||
|
ds18b20_status_t ds18b20_set_resolution(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE], uint8_t bits);
|
||||||
|
ds18b20_status_t ds18b20_write_user_bytes(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
int16_t bytes12, int16_t bytes34, uint8_t mask);
|
||||||
|
ds18b20_status_t ds18b20_write_scratchpad(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
uint8_t th, uint8_t tl, uint8_t configuration);
|
||||||
|
ds18b20_status_t ds18b20_copy_scratchpad_start(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
|
||||||
|
uint8_t parasite_power);
|
||||||
|
ds18b20_status_t ds18b20_copy_scratchpad_finish(
|
||||||
|
ds18b20_t *instance, uint8_t parasite_power);
|
||||||
|
ds18b20_status_t ds18b20_recall_e2(
|
||||||
|
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE]);
|
||||||
|
ds18b20_status_t ds18b20_recall_ready(ds18b20_t *instance);
|
||||||
|
void ds18b20_recover_bus(ds18b20_t *instance);
|
||||||
|
uint8_t ds18b20_search_retry_due(const ds18b20_t *instance,
|
||||||
|
uint8_t conversion_active,
|
||||||
|
uint32_t now_ms, uint32_t last_attempt_ms,
|
||||||
|
uint32_t retry_period_ms);
|
||||||
|
|
||||||
|
/* Экземпляр не синхронизируется внутри: один вызов на одной шине должен быть
|
||||||
|
* завершён до следующего. Из ISR API вызывать нельзя из-за задержек до 750 ms. */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
17
c/ds18b20/instance/Inc/ds18b20_config.h
Normal file
17
c/ds18b20/instance/Inc/ds18b20_config.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#ifndef PORTABLE_DS18B20_CONFIG_H
|
||||||
|
#define PORTABLE_DS18B20_CONFIG_H
|
||||||
|
#define DS18B20_DEFAULT_TIMEOUT_MS 750U
|
||||||
|
#define DS18B20_FAMILY_CODE 0x28U
|
||||||
|
/* Initial attempt + four retries for each tree branch, three full passes. */
|
||||||
|
#define DS18B20_SEARCH_RETRIES 4U
|
||||||
|
#define DS18B20_SEARCH_PASSES 3U
|
||||||
|
/* Bounds the blocking API too, even if noisy devices keep changing the tree. */
|
||||||
|
#define DS18B20_SEARCH_MAX_ATTEMPTS 512U
|
||||||
|
/* Extra released-high time AFTER each search slot; sampling is unchanged. */
|
||||||
|
#define DS18B20_SLOT_RECOVERY_EXTRA_US 20U
|
||||||
|
#if DS18B20_SEARCH_PASSES < 1U || DS18B20_SEARCH_PASSES > 255U || \
|
||||||
|
DS18B20_SEARCH_RETRIES > 254U || DS18B20_SEARCH_MAX_ATTEMPTS < 1U || \
|
||||||
|
DS18B20_SEARCH_MAX_ATTEMPTS > 65535U
|
||||||
|
#error Invalid DS18B20 search limits
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
235
c/ds18b20/instance/PORTING.md
Normal file
235
c/ds18b20/instance/PORTING.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# DS18B20: перенос на другую платформу
|
||||||
|
|
||||||
|
## Что переносится без изменений
|
||||||
|
|
||||||
|
`Inc/ds18b20.h`, `Inc/ds18b20_config.h` и `Src/ds18b20.c` — переносимое
|
||||||
|
C-ядро. Оно использует `stdint.h`, `stddef.h`, `string.h` и аппаратные
|
||||||
|
callback. Не добавляйте в core HAL, RTOS, Modbus или глобальные дескрипторы
|
||||||
|
конкретного проекта.
|
||||||
|
|
||||||
|
## Шаг 1. Добавить файлы в сборку
|
||||||
|
|
||||||
|
Добавьте `Libraries/DS18B20/Inc` в include path и
|
||||||
|
`Libraries/DS18B20/Src/ds18b20.c` в список исходников. Создайте отдельные
|
||||||
|
каталоги `Port/<PLATFORM>/Inc` и `Port/<PLATFORM>/Src`.
|
||||||
|
|
||||||
|
Application adapter держите вне библиотеки либо в отдельном `Adapter`:
|
||||||
|
он планирует операции, переводит ошибки и связывает результат с остальным
|
||||||
|
проектом.
|
||||||
|
|
||||||
|
## Шаг 2. Создать platform context
|
||||||
|
|
||||||
|
Context содержит только ресурсы одного физического 1-Wire master:
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct {
|
||||||
|
gpio_handle_t gpio;
|
||||||
|
timer_handle_t timer;
|
||||||
|
uint32_t timer_ticks_per_us;
|
||||||
|
irq_state_t saved_irq_state;
|
||||||
|
} ds18b20_my_mcu_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
Не используйте скрытый изменяемый singleton. Для двух шин создаются два context,
|
||||||
|
два `ds18b20_t` и два массива ROM.
|
||||||
|
|
||||||
|
## Шаг 3. Реализовать callbacks
|
||||||
|
|
||||||
|
```c
|
||||||
|
static void drive_low(void *context);
|
||||||
|
static void release_line(void *context);
|
||||||
|
static uint8_t read_line(void *context);
|
||||||
|
static void delay_us(void *context, uint32_t us);
|
||||||
|
static uint32_t tick_ms(void *context);
|
||||||
|
static void critical_enter(void *context);
|
||||||
|
static void critical_exit(void *context);
|
||||||
|
|
||||||
|
const ds18b20_onewire_ops_t ds18b20_my_mcu_ops = {
|
||||||
|
drive_low, release_line, read_line, delay_us, tick_ms,
|
||||||
|
critical_enter, critical_exit
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPIO
|
||||||
|
|
||||||
|
- Линия 1-Wire работает только как open-drain: порт либо тянет её к 0, либо
|
||||||
|
переходит в высокоимпедансное состояние.
|
||||||
|
- Запрещён push-pull высокий уровень.
|
||||||
|
- `release_line` не должна ждать; `read_line` читает реальный pin level.
|
||||||
|
- Номинал внешнего pull-up и допустимая длина/ёмкость шины выбираются по
|
||||||
|
электрическим условиям конкретной платы.
|
||||||
|
|
||||||
|
STM32F4 port оставляет pin в `GPIO_MODE_OUTPUT_OD`, отпускает линию записью
|
||||||
|
единицы в BSRR и читает IDR без переключения MODER.
|
||||||
|
|
||||||
|
### Микросекундная задержка
|
||||||
|
|
||||||
|
`delay_us` обязана быть монотонной и достаточно точной для 1-Wire standard
|
||||||
|
speed. Не используйте scheduler sleep с миллисекундной гранулярностью. Если
|
||||||
|
задержка основана на hardware timer:
|
||||||
|
|
||||||
|
- таймер запускается до инициализации библиотеки;
|
||||||
|
- он считает непрерывно во всех вызывающих контекстах;
|
||||||
|
- учитывается переполнение счётчика;
|
||||||
|
- произведение `us * timer_ticks_per_us` не должно переполняться в диапазоне
|
||||||
|
используемых библиотекой задержек;
|
||||||
|
- частота таймера и `timer_ticks_per_us` должны совпадать.
|
||||||
|
|
||||||
|
### Tick и критическая секция
|
||||||
|
|
||||||
|
`tick_ms` нужен только для `ds18b20_wait()`; state-machine adapter может не
|
||||||
|
использовать блокирующий wait. Вычитание tick выполняется как `uint32_t` и
|
||||||
|
допускает wrap.
|
||||||
|
|
||||||
|
Critical callbacks должны сохранять и восстанавливать предыдущее состояние
|
||||||
|
прерываний, а не безусловно включать их. Если платформа гарантирует timing иначе,
|
||||||
|
оба callback можно оставить `NULL`.
|
||||||
|
|
||||||
|
## Шаг 4. Инициализировать экземпляр
|
||||||
|
|
||||||
|
```c
|
||||||
|
#define DS_CAPACITY 8U
|
||||||
|
|
||||||
|
static ds18b20_my_mcu_t port_context;
|
||||||
|
static ds18b20_t bus;
|
||||||
|
static uint8_t rom_storage[DS_CAPACITY][DS18B20_ROM_SIZE];
|
||||||
|
|
||||||
|
platform_gpio_timer_init(&port_context);
|
||||||
|
|
||||||
|
ds18b20_status_t status =
|
||||||
|
ds18b20_init(&bus, &ds18b20_my_mcu_ops, &port_context,
|
||||||
|
rom_storage, DS_CAPACITY);
|
||||||
|
```
|
||||||
|
|
||||||
|
Все три объекта должны жить столько же, сколько используется `bus`. Стековый
|
||||||
|
context или ROM storage нельзя передавать экземпляру, переживающему функцию.
|
||||||
|
|
||||||
|
## Шаг 5. Создать application adapter
|
||||||
|
|
||||||
|
Adapter должен:
|
||||||
|
|
||||||
|
1. выполнить явный поиск или восстановить/проверить известные ROM;
|
||||||
|
2. сериализовать Search ROM, Convert T, scratchpad и EEPROM-команды;
|
||||||
|
3. запустить Convert T и вернуть управление;
|
||||||
|
4. опрашивать ready с общим deadline;
|
||||||
|
5. читать каждый ROM и публиковать значение только после CRC;
|
||||||
|
6. восстановить state machine после disconnect, CRC error и timeout;
|
||||||
|
7. не запускать поиск/EEPROM-запись из обычного temperature polling;
|
||||||
|
8. синхронизировать доступ задач RTOS mutex-ом на уровне экземпляра.
|
||||||
|
|
||||||
|
Для циклического приложения вызывайте `ds18b20_search_step()` только когда
|
||||||
|
шина не занята преобразованием температуры или User Byte. Храните timeout,
|
||||||
|
отмену и sequence в адаптере приложения: portable core не зависит от HAL,
|
||||||
|
Modbus, GUI и глобального `hdallas`.
|
||||||
|
|
||||||
|
Не используйте critical callbacks библиотеки как mutex: они защищают короткий
|
||||||
|
1-Wire slot и могут запрещать прерывания.
|
||||||
|
|
||||||
|
## Память и выравнивание
|
||||||
|
|
||||||
|
- Core не использует heap.
|
||||||
|
- На каждую шину требуется `sizeof(ds18b20_t)` плюс
|
||||||
|
`rom_capacity * DS18B20_ROM_SIZE` байт ROM storage.
|
||||||
|
- Scratchpad — 9 байт у вызывающей стороны.
|
||||||
|
- Специального DMA-выравнивания core не требует; соблюдайте обычное выравнивание
|
||||||
|
C-типов для `ds18b20_t` и context.
|
||||||
|
- `rom_capacity` измеряется в элементах `uint8_t[8]`.
|
||||||
|
|
||||||
|
## Timing и питание
|
||||||
|
|
||||||
|
DS18B20 с внешним питанием может сигнализировать готовность через read slot.
|
||||||
|
Для parasite power требуется strong pull-up на всё время Convert T и
|
||||||
|
Copy Scratchpad. Контракт предоставляет optional callback
|
||||||
|
`strong_pullup(context, enable)`. Для поддержки режима порт обязан:
|
||||||
|
|
||||||
|
1. включить активный high не позднее 10 мкс после команды Copy Scratchpad;
|
||||||
|
2. удерживать его не менее 10 ms без другой активности 1-Wire;
|
||||||
|
3. безопасно отключать strong pull-up при success, timeout и error;
|
||||||
|
4. возвращать линию в open-drain idle перед Recall/readback;
|
||||||
|
5. проверить обычное и parasite-powered подключение на реальной плате.
|
||||||
|
|
||||||
|
## Перенос STM32F4 HAL port
|
||||||
|
|
||||||
|
Перед `ds18b20_stm32f4_hal_init()` заполните:
|
||||||
|
|
||||||
|
| Поле | Требование |
|
||||||
|
| --- | --- |
|
||||||
|
| `port` | Валидный `GPIO_TypeDef *` с включённым clock |
|
||||||
|
| `pin` | Одна ненулевая GPIO mask |
|
||||||
|
| `timer` | Запущенный свободно работающий `TIM_TypeDef *` |
|
||||||
|
| `timer_ticks_per_us` | Ненулевое число timer ticks за 1 мкс |
|
||||||
|
|
||||||
|
Порт использует `HAL_GetTick()` и CMSIS PRIMASK. При переносе на другую STM32
|
||||||
|
семью проверьте HAL-заголовок, разрядность/частоту timer, GPIO BSRR/IDR и способ
|
||||||
|
сохранения interrupt state.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
Host/mock-проверки выполняются без платы:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest Libraries.PortableTests.test_portable_models
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Для нового порта добавьте тесты reset/presence, write/read slots, timing bounds,
|
||||||
|
CRC error, нескольких экземпляров, timeout и восстановления после ошибки.
|
||||||
|
Затем выполните целевую сборку без ошибок/предупреждений.
|
||||||
|
|
||||||
|
## Перенос User Byte mailbox adapter
|
||||||
|
|
||||||
|
Core-модуль не зависит от Modbus. При переносе STM32 adapter сохраните
|
||||||
|
`selector + contractVersion=2` как одну транзакцию и APPLY как отдельную
|
||||||
|
последнюю запись. Input обязан возвращать selector/version echo. Legacy version
|
||||||
|
`0` можно принимать только для TH; TL без version `2` должен завершаться
|
||||||
|
`invalid`, а не значением TH.
|
||||||
|
|
||||||
|
Host-проверки adapter:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File Modules/UserByte/Adapter/STM32_Modbus/Tests/run_host_tests.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Core собирается без HAL/RTOS/Modbus include.
|
||||||
|
- [ ] У каждой шины отдельные instance, context и ROM storage.
|
||||||
|
- [ ] GPIO физически open-drain и никогда не выдаёт push-pull high.
|
||||||
|
- [ ] Есть корректный внешний pull-up и общий GND.
|
||||||
|
- [ ] Таймер запущен, частота и overflow проверены.
|
||||||
|
- [ ] Critical section восстанавливает предыдущее состояние.
|
||||||
|
- [ ] Поиск, conversion, scratchpad и EEPROM сериализованы.
|
||||||
|
- [ ] У state machine есть deadline и восстановление после ошибок.
|
||||||
|
- [ ] CRC ROM и scratchpad проверяется до публикации данных.
|
||||||
|
- [ ] Host/mock-тесты и `git diff --check` проходят.
|
||||||
|
- [ ] Целевая сборка даёт 0 ошибок и 0 предупреждений.
|
||||||
|
- [ ] Работа проверена на реальной шине с 0, 1 и несколькими датчиками.
|
||||||
|
- [ ] Parasite power отмечен неподдерживаемым либо проверен со strong pull-up.
|
||||||
|
# Strong pull-up
|
||||||
|
|
||||||
|
Если порт поддерживает parasite power, добавьте callback
|
||||||
|
`strong_pullup(context, enable)`: включение должно немедленно активно держать
|
||||||
|
high после Copy Scratchpad, выключение — вернуть open-drain idle. Без безопасной
|
||||||
|
аппаратной реализации не разрешайте parasite mode вызывающему приложению.
|
||||||
|
|
||||||
|
### Импорт известных адресов
|
||||||
|
|
||||||
|
После ds18b20_init передайте каждый сохранённый ROM в
|
||||||
|
ds18b20_add_known_rom. Проверяйте возвращаемый статус и лимит ёмкости.
|
||||||
|
Хранилище ROM принадлежит вызывающему коду и живёт всё время работы шины.
|
||||||
|
Импорт не требует GPIO-транзакций и не заменяет проверку CRC температуры.
|
||||||
|
Сохраняйте список перед search_begin (он очищает результаты); по завершении
|
||||||
|
или отмене добавляйте прежние ID обратно вне активной конверсии.
|
||||||
|
Для нескольких шин храните снимки и происхождение ID отдельно для каждой.
|
||||||
|
|
||||||
|
### Перенос поиска с повторами
|
||||||
|
|
||||||
|
Пересоберите всех потребителей: ds18b20_t расширен состоянием поиска и
|
||||||
|
диагностикой. Рабочие буферы остаются caller-owned, HAL и Flash в ядре не нужны.
|
||||||
|
Только BUSY означает продолжение; остальные результаты терминальные.
|
||||||
|
Держите одного владельца шины до завершения/отмены поиска. Адаптер при отмене
|
||||||
|
сбрасывает search_active перед адресным опросом. Search begin нельзя запускать
|
||||||
|
во время strong pull-up. Пауза recovery действует только при search_active.
|
||||||
|
Новые callbacks не требуются; delay_us должен поддерживать добавочные 20 мкс.
|
||||||
|
Проверьте пределы config и внешний deadline. Для Modbus переносите два поля
|
||||||
|
диагностики лишь в свободные регистры, не сдвигая последующие окна.
|
||||||
97
c/ds18b20/instance/README.md
Normal file
97
c/ds18b20/instance/README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Portable DS18B20
|
||||||
|
|
||||||
|
Переносимое ядро для поиска DS18B20 на шине 1-Wire, запуска преобразования,
|
||||||
|
чтения температуры, настройки разрешения и записи alarm/user bytes. Ядро не
|
||||||
|
зависит от STM32 HAL, не выделяет память динамически и поддерживает несколько
|
||||||
|
независимых экземпляров шин.
|
||||||
|
|
||||||
|
Подтверждённые неблокирующие операции `TH`/`TL` с Copy/Recall/readback находятся
|
||||||
|
во внутреннем модуле `Libraries/DS18B20/Modules/UserByte`. Основной core предоставляет ему
|
||||||
|
низкоуровневые операции scratchpad и optional callback `strong_pullup`.
|
||||||
|
|
||||||
|
## Подтверждённые EEPROM-байты
|
||||||
|
|
||||||
|
Официальный [datasheet Analog Devices/Maxim DS18B20](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf)
|
||||||
|
определяет `TH=scratchpad[2]` и `TL=scratchpad[3]` как два независимо
|
||||||
|
программируемых alarm-регистра. `Write Scratchpad` принимает TH, TL и
|
||||||
|
configuration (`scratchpad[4]`), `Copy Scratchpad` сохраняет все три байта в
|
||||||
|
EEPROM, а `Recall E2` возвращает их в scratchpad. Заводские значения после
|
||||||
|
сброса: TH `+75` (`0x4B`), TL `+70` (`0x46`), configuration `0x7F`.
|
||||||
|
Scratchpad[5..7] зарезервированы/read-only и пользовательскими не считаются.
|
||||||
|
|
||||||
|
На реальном датчике пользователь отдельно прочитал TH `14`, TL `128` и config
|
||||||
|
`31`, подтвердив, что поля GUI должны оставаться независимыми. Проверка
|
||||||
|
сохранения после полного power-cycle и parasite-power всё ещё требует отдельной
|
||||||
|
аппаратной приёмки.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- [HELP.md](HELP.md) — публичный API, инициализация, примеры, коды ошибок,
|
||||||
|
диагностика, ограничения и тесты.
|
||||||
|
- [PORTING.md](PORTING.md) — перенос на другой MCU/проект и checklist порта.
|
||||||
|
- [PROJECT_RELATIONS.md](PROJECT_RELATIONS.md) — слои, зависимости, владение
|
||||||
|
памятью и связи с текущей прошивкой.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```text
|
||||||
|
DS18B20/
|
||||||
|
├── Inc/ публичный API и конфигурация
|
||||||
|
├── Src/ переносимое ядро 1-Wire/DS18B20
|
||||||
|
├── Port/STM32F4_HAL/Inc/ публичный API STM32F4-порта
|
||||||
|
├── Port/STM32F4_HAL/Src/ реализация GPIO/таймера STM32F4
|
||||||
|
├── README.md точка входа
|
||||||
|
├── HELP.md справочник API
|
||||||
|
├── PORTING.md руководство по переносу
|
||||||
|
└── PROJECT_RELATIONS.md место библиотеки в проекте
|
||||||
|
```
|
||||||
|
|
||||||
|
Адаптер текущего приложения находится отдельно:
|
||||||
|
`climate_control_f407vet6_f4/Core/Src/dallas_tools.c`.
|
||||||
|
|
||||||
|
## Минимальное подключение
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include ds18b20.h
|
||||||
|
#include ds18b20_stm32f4_hal.h
|
||||||
|
|
||||||
|
#define DS_CAPACITY 8U
|
||||||
|
static ds18b20_t bus;
|
||||||
|
static uint8_t roms[DS_CAPACITY][DS18B20_ROM_SIZE];
|
||||||
|
static ds18b20_stm32f4_hal_t port = {
|
||||||
|
.port = GPIOE, .pin = GPIO_PIN_2,
|
||||||
|
.timer = TIM2, .timer_ticks_per_us = 72U
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ds18b20_stm32f4_hal_init(&port) == DS18B20_OK &&
|
||||||
|
ds18b20_init(&bus, &ds18b20_stm32f4_hal_ops, &port,
|
||||||
|
roms, DS_CAPACITY) == DS18B20_OK) {
|
||||||
|
(void)ds18b20_search(&bus);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пошаговый поиск для GUI и Modbus
|
||||||
|
|
||||||
|
Для приложения с постоянно работающими сервисами используйте
|
||||||
|
`ds18b20_search_begin()` и `ds18b20_search_step()`. Один вызов `step`
|
||||||
|
обрабатывает не более одного кандидата ROM. `DS18B20_E_BUSY` означает, что
|
||||||
|
нужно вызвать функцию в следующем проходе цикла; `DS18B20_OK` завершает поиск.
|
||||||
|
`DS18B20_E_BUSY` также включает внутренние повторы и переходы между проходами.
|
||||||
|
Все остальные статусы терминальные; CRC_ERROR после исчерпания повторов
|
||||||
|
нельзя продолжать вызывать в цикле. Подробности — в [HELP.md](HELP.md).
|
||||||
|
|
||||||
|
Таймер должен быть заранее запущен и считать непрерывно. Значение
|
||||||
|
`timer_ticks_per_us` задаётся частотой счёта таймера, а не частотой ядра.
|
||||||
|
Преобразование температуры выполняйте неблокирующей парой
|
||||||
|
`ds18b20_start_all()` / `ds18b20_conversion_ready()`; полный сценарий приведён
|
||||||
|
в [HELP.md](HELP.md).
|
||||||
|
|
||||||
|
### Опрос по сохранённым ID
|
||||||
|
|
||||||
|
`ds18b20_add_known_rom(bus, rom)` добавляет проверенный ROM без обращения к
|
||||||
|
линии. Повторное добавление не создаёт дубликат. См. [HELP.md](HELP.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Shared source
|
||||||
|
|
||||||
|
Canonical source: `templates/c/ds18b20/instance`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||||
353
c/ds18b20/instance/Src/ds18b20.c
Normal file
353
c/ds18b20/instance/Src/ds18b20.c
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
#include "ds18b20.h"
|
||||||
|
#include "ds18b20_config.h"
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define OW_SEARCH_ROM 0xF0U
|
||||||
|
#define OW_MATCH_ROM 0x55U
|
||||||
|
#define OW_SKIP_ROM 0xCCU
|
||||||
|
#define DS_CONVERT 0x44U
|
||||||
|
#define DS_READ_SCRATCHPAD 0xBEU
|
||||||
|
#define DS_WRITE_SCRATCHPAD 0x4EU
|
||||||
|
#define DS_COPY_SCRATCHPAD 0x48U
|
||||||
|
#define DS_RECALL_E2 0xB8U
|
||||||
|
|
||||||
|
static int valid_instance(const ds18b20_t *d)
|
||||||
|
{
|
||||||
|
return d && d->ops && d->ops->drive_low && d->ops->release &&
|
||||||
|
d->ops->read && d->ops->delay_us && d->roms && d->rom_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void critical(ds18b20_t *d, int enter)
|
||||||
|
{
|
||||||
|
if (enter && d->ops->critical_enter) d->ops->critical_enter(d->platform_context);
|
||||||
|
if (!enter && d->ops->critical_exit) d->ops->critical_exit(d->platform_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_bit(ds18b20_t *d, uint8_t bit)
|
||||||
|
{
|
||||||
|
critical(d, 1);
|
||||||
|
d->ops->drive_low(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, bit ? 6U : 60U);
|
||||||
|
d->ops->release(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, bit ? 64U : 10U);
|
||||||
|
critical(d, 0);
|
||||||
|
if (d->search_active)
|
||||||
|
d->ops->delay_us(d->platform_context, DS18B20_SLOT_RECOVERY_EXTRA_US);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t read_bit(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
uint8_t bit;
|
||||||
|
critical(d, 1);
|
||||||
|
d->ops->drive_low(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, 3U);
|
||||||
|
d->ops->release(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, 10U);
|
||||||
|
bit = d->ops->read(d->platform_context) ? 1U : 0U;
|
||||||
|
d->ops->delay_us(d->platform_context, 57U);
|
||||||
|
critical(d, 0);
|
||||||
|
if (d->search_active)
|
||||||
|
d->ops->delay_us(d->platform_context, DS18B20_SLOT_RECOVERY_EXTRA_US);
|
||||||
|
return bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_byte(ds18b20_t *d, uint8_t value)
|
||||||
|
{
|
||||||
|
uint8_t i;
|
||||||
|
for (i = 0; i < 8U; ++i) { write_bit(d, value & 1U); value >>= 1U; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t read_byte(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
uint8_t i, value = 0U;
|
||||||
|
for (i = 0; i < 8U; ++i) value |= (uint8_t)(read_bit(d) << i);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ds18b20_status_t reset(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
uint8_t level;
|
||||||
|
d->ops->drive_low(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, 480U);
|
||||||
|
/* Protect release-to-presence sampling from interrupt latency. */
|
||||||
|
critical(d, 1);
|
||||||
|
d->ops->release(d->platform_context);
|
||||||
|
d->ops->delay_us(d->platform_context, 70U);
|
||||||
|
level = d->ops->read(d->platform_context);
|
||||||
|
critical(d, 0);
|
||||||
|
d->ops->delay_us(d->platform_context, 410U);
|
||||||
|
return level ? DS18B20_E_NO_DEVICE : DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void match(ds18b20_t *d, const uint8_t *rom)
|
||||||
|
{
|
||||||
|
uint8_t i; write_byte(d, OW_MATCH_ROM);
|
||||||
|
for (i = 0U; i < 8U; ++i) write_byte(d, rom[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ds18b20_crc8(const void *data, size_t length)
|
||||||
|
{
|
||||||
|
const uint8_t *p = (const uint8_t *)data; uint8_t crc = 0U;
|
||||||
|
while (length--) { uint8_t in = *p++, i; for (i = 0U; i < 8U; ++i) {
|
||||||
|
uint8_t mix = (uint8_t)((crc ^ in) & 1U); crc >>= 1U;
|
||||||
|
if (mix) crc ^= 0x8CU; in >>= 1U; } }
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_validate_rom(const uint8_t rom[8])
|
||||||
|
{
|
||||||
|
if (!rom) return DS18B20_E_ARGUMENT;
|
||||||
|
if (rom[0] != DS18B20_FAMILY_CODE) return DS18B20_E_ROM;
|
||||||
|
return ds18b20_crc8(rom, 7U) == rom[7] ? DS18B20_OK : DS18B20_E_CRC;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_init(ds18b20_t *d, const ds18b20_onewire_ops_t *ops,
|
||||||
|
void *ctx, uint8_t (*roms)[8], size_t capacity)
|
||||||
|
{
|
||||||
|
if (!d || !ops || !roms || !capacity || !ops->drive_low || !ops->release ||
|
||||||
|
!ops->read || !ops->delay_us) return DS18B20_E_ARGUMENT;
|
||||||
|
memset(d, 0, sizeof(*d)); d->ops = ops; d->platform_context = ctx;
|
||||||
|
d->roms = roms; d->rom_capacity = capacity; ops->release(ctx);
|
||||||
|
return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ds18b20_status_t search_next(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
uint8_t bit_no=1U,last_zero=0U,byte_no=0U,mask=1U;
|
||||||
|
ds18b20_status_t reset_status;
|
||||||
|
if (d->last_device) return DS18B20_OK;
|
||||||
|
d->search_diagnostics.last_error_bit = 0U;
|
||||||
|
d->search_diagnostics.last_error_pair = 0xFFU;
|
||||||
|
reset_status=reset(d);
|
||||||
|
if (reset_status != DS18B20_OK) return reset_status;
|
||||||
|
write_byte(d, OW_SEARCH_ROM);
|
||||||
|
while (byte_no < 8U) {
|
||||||
|
uint8_t id=read_bit(d), cmp=read_bit(d), dir;
|
||||||
|
d->search_diagnostics.last_error_bit = bit_no;
|
||||||
|
d->search_diagnostics.last_error_pair = (uint8_t)((id << 1U) | cmp);
|
||||||
|
if (id && cmp) break;
|
||||||
|
if (id != cmp) dir=id; else { dir=(bit_no<d->last_discrepancy) ?
|
||||||
|
((d->search_rom[byte_no]&mask)!=0U) : (bit_no==d->last_discrepancy);
|
||||||
|
if (!dir) { last_zero=bit_no; if (last_zero<9U) d->last_family_discrepancy=last_zero; } }
|
||||||
|
if (dir) d->search_rom[byte_no]|=mask; else d->search_rom[byte_no]&=(uint8_t)~mask;
|
||||||
|
write_bit(d,dir); ++bit_no; mask<<=1U; if (!mask) { ++byte_no; mask=1U; }
|
||||||
|
}
|
||||||
|
if (bit_no < 65U || !d->search_rom[0]) {
|
||||||
|
d->last_discrepancy=0; d->last_device=0; return DS18B20_E_IO;
|
||||||
|
}
|
||||||
|
d->last_discrepancy=last_zero;
|
||||||
|
if (!last_zero) d->last_device=1U;
|
||||||
|
return DS18B20_E_BUSY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset only traversal state: the union of validated ROMs survives passes. */
|
||||||
|
static void search_tree_reset(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
d->last_discrepancy = 0U;
|
||||||
|
d->last_family_discrepancy = 0U;
|
||||||
|
d->last_device = 0U;
|
||||||
|
d->search_retries = 0U;
|
||||||
|
memset(d->search_rom, 0, sizeof(d->search_rom));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ds18b20_status_t search_stop(ds18b20_t *d, ds18b20_status_t result)
|
||||||
|
{
|
||||||
|
d->search_active = 0U;
|
||||||
|
d->search_result = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ds18b20_status_t search_pass_finish(ds18b20_t *d, uint8_t complete)
|
||||||
|
{
|
||||||
|
ds18b20_search_diagnostics_t *diag = &d->search_diagnostics;
|
||||||
|
++diag->passes_finished;
|
||||||
|
if (complete) ++diag->complete_passes;
|
||||||
|
if (diag->passes_finished < DS18B20_SEARCH_PASSES) {
|
||||||
|
search_tree_reset(d);
|
||||||
|
return DS18B20_E_BUSY;
|
||||||
|
}
|
||||||
|
/* A completed traversal can recover earlier faults. Without one, retain
|
||||||
|
* the partial catalog but report the failure instead of claiming success. */
|
||||||
|
if (diag->complete_passes != 0U)
|
||||||
|
return search_stop(d, d->rom_count ? DS18B20_OK : DS18B20_E_NO_DEVICE);
|
||||||
|
return search_stop(d, diag->last_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_search_begin(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
|
||||||
|
if (d->strong_pullup_active) return DS18B20_E_BUSY;
|
||||||
|
d->rom_count = 0U;
|
||||||
|
search_tree_reset(d);
|
||||||
|
memset(&d->search_diagnostics, 0, sizeof(d->search_diagnostics));
|
||||||
|
d->search_diagnostics.last_error_pair = 0xFFU;
|
||||||
|
d->search_active = 1U;
|
||||||
|
d->search_result = DS18B20_E_BUSY;
|
||||||
|
return DS18B20_E_BUSY;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_search_step(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
uint8_t previous_rom[8], previous_discrepancy, previous_family;
|
||||||
|
ds18b20_search_diagnostics_t previous_diag;
|
||||||
|
ds18b20_status_t status;
|
||||||
|
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
|
||||||
|
if (!d->search_active) return d->search_result;
|
||||||
|
if (d->search_diagnostics.attempts >= DS18B20_SEARCH_MAX_ATTEMPTS) {
|
||||||
|
d->search_diagnostics.last_error = DS18B20_E_TIMEOUT;
|
||||||
|
d->search_diagnostics.last_error_bit = 0U;
|
||||||
|
d->search_diagnostics.last_error_pair = 0xFFU;
|
||||||
|
return search_stop(d, DS18B20_E_TIMEOUT);
|
||||||
|
}
|
||||||
|
/* Snapshot the preceding validated tree path BEFORE touching the wire.
|
||||||
|
* A corrupt ROM must never become the path for the following attempt. */
|
||||||
|
memcpy(previous_rom, d->search_rom, 8U);
|
||||||
|
previous_discrepancy = d->last_discrepancy;
|
||||||
|
previous_family = d->last_family_discrepancy;
|
||||||
|
previous_diag = d->search_diagnostics;
|
||||||
|
++d->search_diagnostics.attempts;
|
||||||
|
status = search_next(d);
|
||||||
|
if (status == DS18B20_E_BUSY) {
|
||||||
|
/* CRC first: a valid non-DS18B20 family is skipped without corrupting
|
||||||
|
* the traversal; a bad family caused by noise still gets retried. */
|
||||||
|
status = ds18b20_crc8(d->search_rom, 7U) == d->search_rom[7] ?
|
||||||
|
DS18B20_OK : DS18B20_E_CRC;
|
||||||
|
if (status == DS18B20_OK && d->search_rom[0] == DS18B20_FAMILY_CODE)
|
||||||
|
status = ds18b20_add_known_rom(d, d->search_rom);
|
||||||
|
}
|
||||||
|
if (status == DS18B20_OK) {
|
||||||
|
/* Keep the last FAILURE location, even when a later retry succeeds. */
|
||||||
|
d->search_diagnostics.last_error_bit = previous_diag.last_error_bit;
|
||||||
|
d->search_diagnostics.last_error_pair = previous_diag.last_error_pair;
|
||||||
|
d->search_retries = 0U;
|
||||||
|
return d->last_device ? search_pass_finish(d, 1U) : DS18B20_E_BUSY;
|
||||||
|
}
|
||||||
|
d->search_diagnostics.last_error = status;
|
||||||
|
if (status == DS18B20_E_CAPACITY) return search_stop(d, status);
|
||||||
|
if (status == DS18B20_E_CRC) ++d->search_diagnostics.crc_errors;
|
||||||
|
else ++d->search_diagnostics.io_errors;
|
||||||
|
memcpy(d->search_rom, previous_rom, 8U);
|
||||||
|
d->last_discrepancy = previous_discrepancy;
|
||||||
|
d->last_family_discrepancy = previous_family;
|
||||||
|
d->last_device = 0U;
|
||||||
|
if (d->search_retries < DS18B20_SEARCH_RETRIES) {
|
||||||
|
++d->search_retries;
|
||||||
|
++d->search_diagnostics.retries;
|
||||||
|
return DS18B20_E_BUSY;
|
||||||
|
}
|
||||||
|
return search_pass_finish(d, 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_search(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
ds18b20_status_t status;
|
||||||
|
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
|
||||||
|
if (d->strong_pullup_active) return DS18B20_E_BUSY;
|
||||||
|
status = ds18b20_search_begin(d);
|
||||||
|
while (status == DS18B20_E_BUSY) status = ds18b20_search_step(d);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_add_known_rom(ds18b20_t *d, const uint8_t rom[8])
|
||||||
|
{
|
||||||
|
size_t i;
|
||||||
|
ds18b20_status_t status;
|
||||||
|
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
|
||||||
|
status = ds18b20_validate_rom(rom);
|
||||||
|
if (status != DS18B20_OK) return status;
|
||||||
|
for (i = 0U; i < d->rom_count; ++i)
|
||||||
|
if (memcmp(d->roms[i], rom, 8U) == 0) return DS18B20_OK;
|
||||||
|
if (d->rom_count >= d->rom_capacity) return DS18B20_E_CAPACITY;
|
||||||
|
memcpy(d->roms[d->rom_count++], rom, 8U);
|
||||||
|
return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t ds18b20_count(const ds18b20_t *d) { return valid_instance(d) ? d->rom_count : 0U; }
|
||||||
|
const uint8_t *ds18b20_rom(const ds18b20_t *d,size_t i) { return valid_instance(d)&&i<d->rom_count?d->roms[i]:NULL; }
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_start_all(ds18b20_t *d)
|
||||||
|
{ if(!valid_instance(d))return DS18B20_E_ARGUMENT; if(reset(d))return DS18B20_E_NO_DEVICE; write_byte(d,OW_SKIP_ROM);write_byte(d,DS_CONVERT);return DS18B20_OK; }
|
||||||
|
ds18b20_status_t ds18b20_start(ds18b20_t *d,const uint8_t *rom)
|
||||||
|
{ ds18b20_status_t s;if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;if(ds18b20_validate_rom(rom))return DS18B20_E_ROM;s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_CONVERT);return DS18B20_OK; }
|
||||||
|
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *d)
|
||||||
|
{ if(!valid_instance(d))return DS18B20_E_ARGUMENT;return read_bit(d)?DS18B20_OK:DS18B20_E_BUSY; }
|
||||||
|
ds18b20_status_t ds18b20_wait(ds18b20_t *d,uint32_t timeout)
|
||||||
|
{ uint32_t start;if(!valid_instance(d)||!d->ops->tick_ms)return DS18B20_E_ARGUMENT;start=d->ops->tick_ms(d->platform_context);while(!read_bit(d))if((uint32_t)(d->ops->tick_ms(d->platform_context)-start)>timeout)return DS18B20_E_TIMEOUT;return DS18B20_OK; }
|
||||||
|
ds18b20_status_t ds18b20_read_scratchpad(ds18b20_t *d,const uint8_t *rom,uint8_t *sp)
|
||||||
|
{ uint8_t i;ds18b20_status_t s;if(!valid_instance(d)||!rom||!sp)return DS18B20_E_ARGUMENT;if(ds18b20_validate_rom(rom))return DS18B20_E_ROM;s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_READ_SCRATCHPAD);for(i=0;i<9U;++i)sp[i]=read_byte(d);return ds18b20_crc8(sp,8U)==sp[8]?DS18B20_OK:DS18B20_E_CRC; }
|
||||||
|
ds18b20_status_t ds18b20_decode_temperature(const uint8_t *sp,float *out)
|
||||||
|
{ int16_t raw;uint8_t cfg;if(!sp||!out)return DS18B20_E_ARGUMENT;if(ds18b20_crc8(sp,8U)!=sp[8])return DS18B20_E_CRC;cfg=sp[4]&0x60U;raw=(int16_t)((uint16_t)sp[0]|((uint16_t)sp[1]<<8));if(cfg==0)raw&=(int16_t)~7;else if(cfg==0x20)raw&=(int16_t)~3;else if(cfg==0x40)raw&=(int16_t)~1;else if(cfg!=0x60)return DS18B20_E_IO;*out=(float)raw/16.0f;return DS18B20_OK; }
|
||||||
|
|
||||||
|
static ds18b20_status_t write_config(ds18b20_t *d,const uint8_t *rom,uint8_t th,uint8_t tl,uint8_t cfg)
|
||||||
|
{
|
||||||
|
uint8_t chunk;
|
||||||
|
ds18b20_status_t s=ds18b20_write_scratchpad(d,rom,th,tl,cfg);
|
||||||
|
if(s)return s;s=ds18b20_copy_scratchpad_start(d,rom,0U);if(s)return s;
|
||||||
|
/* TIM1 counter is 16-bit: at 72 ticks/us a single 10 ms wait can never
|
||||||
|
* satisfy the port comparison. Short chunks preserve wrap-safe timing. */
|
||||||
|
for(chunk=0U;chunk<100U;++chunk)d->ops->delay_us(d->platform_context,100U);
|
||||||
|
return ds18b20_copy_scratchpad_finish(d,0U);
|
||||||
|
}
|
||||||
|
ds18b20_status_t ds18b20_set_resolution(ds18b20_t *d,const uint8_t *rom,uint8_t bits)
|
||||||
|
{ uint8_t sp[9],cfg;ds18b20_status_t s;if(bits<9U||bits>12U)return DS18B20_E_ARGUMENT;s=ds18b20_read_scratchpad(d,rom,sp);if(s)return s;cfg=(uint8_t)(0x1FU|((bits-9U)<<5));return write_config(d,rom,sp[2],sp[3],cfg); }
|
||||||
|
ds18b20_status_t ds18b20_write_user_bytes(ds18b20_t *d,const uint8_t *rom,int16_t b12,int16_t b34,uint8_t mask)
|
||||||
|
{ uint8_t sp[9];ds18b20_status_t s=ds18b20_read_scratchpad(d,rom,sp);(void)b34;if(s)return s;if(mask&1U)sp[2]=(uint8_t)b12;if(mask&2U)sp[3]=(uint8_t)(b12>>8);/* DS18B20 физически позволяет записать только TH/TL/config; байты 6/7 read-only. */return write_config(d,rom,sp[2],sp[3],sp[4]); }
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_write_scratchpad(ds18b20_t *d,const uint8_t *rom,
|
||||||
|
uint8_t th,uint8_t tl,uint8_t cfg)
|
||||||
|
{
|
||||||
|
ds18b20_status_t s;
|
||||||
|
if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
|
||||||
|
s=ds18b20_validate_rom(rom);if(s)return s;
|
||||||
|
s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_WRITE_SCRATCHPAD);
|
||||||
|
write_byte(d,th);write_byte(d,tl);write_byte(d,cfg);return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_copy_scratchpad_start(ds18b20_t *d,const uint8_t *rom,
|
||||||
|
uint8_t parasite)
|
||||||
|
{
|
||||||
|
ds18b20_status_t s;
|
||||||
|
if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
|
||||||
|
if(parasite && !d->ops->strong_pullup)return DS18B20_E_POWER;
|
||||||
|
s=ds18b20_validate_rom(rom);if(s)return s;
|
||||||
|
s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_COPY_SCRATCHPAD);
|
||||||
|
/* The pull-up must be asserted immediately after the command slot. */
|
||||||
|
if(parasite){d->ops->strong_pullup(d->platform_context,1U);
|
||||||
|
d->strong_pullup_active=1U;}
|
||||||
|
return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_copy_scratchpad_finish(ds18b20_t *d,uint8_t parasite)
|
||||||
|
{
|
||||||
|
if(!valid_instance(d))return DS18B20_E_ARGUMENT;
|
||||||
|
if(parasite){if(!d->ops->strong_pullup)return DS18B20_E_POWER;
|
||||||
|
if(d->strong_pullup_active){d->ops->strong_pullup(d->platform_context,0U);
|
||||||
|
d->strong_pullup_active=0U;}}
|
||||||
|
d->ops->release(d->platform_context);return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_recall_e2(ds18b20_t *d,const uint8_t *rom)
|
||||||
|
{
|
||||||
|
ds18b20_status_t s;if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
|
||||||
|
s=ds18b20_validate_rom(rom);if(s)return s;s=reset(d);if(s)return s;
|
||||||
|
match(d,rom);write_byte(d,DS_RECALL_E2);return DS18B20_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ds18b20_status_t ds18b20_recall_ready(ds18b20_t *d)
|
||||||
|
{if(!valid_instance(d))return DS18B20_E_ARGUMENT;return read_bit(d)?DS18B20_OK:DS18B20_E_BUSY;}
|
||||||
|
|
||||||
|
void ds18b20_recover_bus(ds18b20_t *d)
|
||||||
|
{
|
||||||
|
if(!valid_instance(d))return;
|
||||||
|
if(d->strong_pullup_active&&d->ops->strong_pullup){
|
||||||
|
d->ops->strong_pullup(d->platform_context,0U);d->strong_pullup_active=0U;}
|
||||||
|
d->ops->release(d->platform_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ds18b20_search_retry_due(const ds18b20_t *d,uint8_t conversion_active,
|
||||||
|
uint32_t now,uint32_t last,uint32_t period)
|
||||||
|
{
|
||||||
|
if(!valid_instance(d)||d->rom_count!=0U||conversion_active||period==0U)return 0U;
|
||||||
|
/* Unsigned subtraction keeps the retry correct across HAL tick rollover. */
|
||||||
|
return ((uint32_t)(now-last)>=period)?1U:0U;
|
||||||
|
}
|
||||||
31
c/ds18b20/instance/Tests/run_host_tests.ps1
Normal file
31
c/ds18b20/instance/Tests/run_host_tests.ps1
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$out = Join-Path $env:TEMP "test_ds18b20_copy_delay.exe"
|
||||||
|
$test = Join-Path $PSScriptRoot "test_ds18b20_copy_delay.c"
|
||||||
|
$core = Join-Path $PSScriptRoot "..\Src\ds18b20.c"
|
||||||
|
$inc = Join-Path $PSScriptRoot "..\Inc"
|
||||||
|
$gcc = Get-Command gcc -ErrorAction SilentlyContinue
|
||||||
|
if ($gcc) {
|
||||||
|
& $gcc.Source -std=c99 -Wall -Wextra -Werror -I $inc $test $core -o $out
|
||||||
|
} else {
|
||||||
|
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
|
||||||
|
if (-not (Test-Path $vcvars)) { throw "Neither gcc nor MSVC was found" }
|
||||||
|
$command = "call `"$vcvars`" >nul && pushd `"$env:TEMP`" && cl /nologo /std:c11 /W4 /WX /I `"$inc`" `"$test`" `"$core`" /Fe:`"$out`""
|
||||||
|
& cmd.exe /d /c $command
|
||||||
|
}
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "DS18B20 regression compilation failed" }
|
||||||
|
& $out
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "DS18B20 regression failed" }
|
||||||
|
Remove-Item -LiteralPath $out -Force
|
||||||
|
|
||||||
|
$searchOut = Join-Path $env:TEMP "test_ds18b20_incremental_search.exe"
|
||||||
|
$searchTest = Join-Path $PSScriptRoot "test_ds18b20_incremental_search.c"
|
||||||
|
if ($gcc) {
|
||||||
|
& $gcc.Source -std=c99 -Wall -Wextra -Werror -I $inc $searchTest $core -o $searchOut
|
||||||
|
} else {
|
||||||
|
$command = "call `"$vcvars`" >nul && pushd `"$env:TEMP`" && cl /nologo /std:c11 /W4 /WX /I `"$inc`" `"$searchTest`" `"$core`" /Fe:`"$searchOut`""
|
||||||
|
& cmd.exe /d /c $command
|
||||||
|
}
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "DS18B20 incremental search compilation failed" }
|
||||||
|
& $searchOut
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "DS18B20 incremental search failed" }
|
||||||
|
Remove-Item -LiteralPath $searchOut -Force
|
||||||
92
c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c
Normal file
92
c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#include "ds18b20.h"
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE];
|
||||||
|
uint16_t read_call;
|
||||||
|
uint32_t max_delay_us;
|
||||||
|
uint16_t copy_delay_chunks;
|
||||||
|
uint8_t critical_active;
|
||||||
|
} mock_port_t;
|
||||||
|
|
||||||
|
static void drive_low(void *context) { (void)context; }
|
||||||
|
static void release_line(void *context) { (void)context; }
|
||||||
|
|
||||||
|
static uint8_t read_line(void *context)
|
||||||
|
{
|
||||||
|
mock_port_t *port = (mock_port_t *)context;
|
||||||
|
uint16_t call = port->read_call++;
|
||||||
|
assert(port->critical_active); /* Presence and data sampling must be protected. */
|
||||||
|
if (call == 0U) return 0U; /* Read Scratchpad reset presence. */
|
||||||
|
if (call <= 72U) {
|
||||||
|
uint16_t bit = (uint16_t)(call - 1U);
|
||||||
|
return (uint8_t)((port->scratchpad[bit / 8U] >> (bit % 8U)) & 1U);
|
||||||
|
}
|
||||||
|
/* Write Scratchpad and Copy Scratchpad reset presence pulses. */
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void delay_us(void *context, uint32_t us)
|
||||||
|
{
|
||||||
|
mock_port_t *port = (mock_port_t *)context;
|
||||||
|
if (us > port->max_delay_us) port->max_delay_us = us;
|
||||||
|
if (us == 100U) port->copy_delay_chunks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void critical_enter(void *context)
|
||||||
|
{
|
||||||
|
mock_port_t *port = (mock_port_t *)context;
|
||||||
|
assert(!port->critical_active);
|
||||||
|
port->critical_active = 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void critical_exit(void *context)
|
||||||
|
{
|
||||||
|
mock_port_t *port = (mock_port_t *)context;
|
||||||
|
assert(port->critical_active);
|
||||||
|
port->critical_active = 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
static const ds18b20_onewire_ops_t ops = {
|
||||||
|
drive_low, release_line, read_line, delay_us, NULL, critical_enter, critical_exit, NULL
|
||||||
|
};
|
||||||
|
ds18b20_t bus;
|
||||||
|
mock_port_t port;
|
||||||
|
uint8_t roms[1][DS18B20_ROM_SIZE] = {{0}};
|
||||||
|
|
||||||
|
memset(&port, 0, sizeof(port));
|
||||||
|
roms[0][0] = 0x28U;
|
||||||
|
roms[0][1] = 0x11U;
|
||||||
|
roms[0][7] = ds18b20_crc8(roms[0], 7U);
|
||||||
|
port.scratchpad[0] = 0x50U;
|
||||||
|
port.scratchpad[1] = 0x05U;
|
||||||
|
port.scratchpad[2] = 0x4BU;
|
||||||
|
port.scratchpad[3] = 0x46U;
|
||||||
|
port.scratchpad[4] = 0x7FU;
|
||||||
|
port.scratchpad[5] = 0xFFU;
|
||||||
|
port.scratchpad[6] = 0x0CU;
|
||||||
|
port.scratchpad[7] = 0x10U;
|
||||||
|
port.scratchpad[8] = ds18b20_crc8(port.scratchpad, 8U);
|
||||||
|
|
||||||
|
assert(ds18b20_init(&bus, &ops, &port, roms, 1U) == DS18B20_OK);
|
||||||
|
bus.rom_count = 1U;
|
||||||
|
assert(ds18b20_set_resolution(&bus, roms[0], 9U) == DS18B20_OK);
|
||||||
|
/* Regression: a 10 ms callback overflows the 16-bit 72 MHz timer port. */
|
||||||
|
assert(port.max_delay_us <= 480U);
|
||||||
|
assert(port.copy_delay_chunks >= 100U);
|
||||||
|
bus.rom_count = 0U;
|
||||||
|
assert(ds18b20_search_retry_due(&bus, 0U, 1999U, 0U, 2000U) == 0U);
|
||||||
|
assert(ds18b20_search_retry_due(&bus, 0U, 2000U, 0U, 2000U) == 1U);
|
||||||
|
assert(ds18b20_search_retry_due(&bus, 1U, 4000U, 0U, 2000U) == 0U);
|
||||||
|
/* Wrap-safe deadline: 0x20 - 0xFFFFFF00 = 0x120 ms. */
|
||||||
|
assert(ds18b20_search_retry_due(&bus, 0U, 0x20U, 0xFFFFFF00U, 0x120U) == 1U);
|
||||||
|
bus.rom_count = 1U;
|
||||||
|
assert(ds18b20_search_retry_due(&bus, 0U, 4000U, 0U, 2000U) == 0U);
|
||||||
|
puts("DS18B20 copy delay regression: OK");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
152
c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c
Normal file
152
c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/* Model real SEARCH ROM participation: each branch filters the active slaves.
|
||||||
|
* Fault injection tests transport recovery, not analog cable characteristics. */
|
||||||
|
#include "ds18b20.h"
|
||||||
|
#include "ds18b20_config.h"
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
ds18b20_t *bus;
|
||||||
|
uint8_t roms[3][8];
|
||||||
|
uint8_t masks[3];
|
||||||
|
unsigned active, command_bits, bit, pair, presence, attempts;
|
||||||
|
unsigned low_time, elapsed, low, critical, recovery;
|
||||||
|
unsigned fault_attempt, fault_bit, persistent_fault, crc_fault;
|
||||||
|
} port_t;
|
||||||
|
|
||||||
|
static void low(void *ctx) {
|
||||||
|
port_t *p = ctx; p->low = 1U; p->low_time = 0U; p->elapsed = 0U;
|
||||||
|
}
|
||||||
|
static unsigned rom_bit(port_t *p, unsigned i) {
|
||||||
|
return (p->roms[i][p->bit / 8U] >> (p->bit % 8U)) & 1U;
|
||||||
|
}
|
||||||
|
static unsigned fault(port_t *p) {
|
||||||
|
return p->attempts == p->fault_attempt ||
|
||||||
|
(p->persistent_fault && p->attempts >= p->fault_attempt);
|
||||||
|
}
|
||||||
|
static void release_line(void *ctx) {
|
||||||
|
port_t *p = ctx; unsigned i, direction;
|
||||||
|
if (!p->low) return;
|
||||||
|
p->low = 0U;
|
||||||
|
if (p->low_time == 480U) {
|
||||||
|
++p->attempts;
|
||||||
|
p->active = p->masks[p->bus->search_diagnostics.passes_finished];
|
||||||
|
p->presence = 1U; p->command_bits = 0U; p->bit = 0U; p->pair = 0U;
|
||||||
|
} else if (p->low_time != 3U) {
|
||||||
|
assert(p->low_time == 6U || p->low_time == 60U);
|
||||||
|
if (p->command_bits < 8U) { ++p->command_bits; return; }
|
||||||
|
direction = p->low_time == 6U;
|
||||||
|
for (i = 0U; i < 3U; ++i)
|
||||||
|
if (rom_bit(p, i) != direction) p->active &= ~(1U << i);
|
||||||
|
++p->bit; p->pair = 0U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static uint8_t read_line(void *ctx) {
|
||||||
|
port_t *p = ctx; unsigned i, zeros = 0U, ones = 0U, value;
|
||||||
|
assert(p->critical);
|
||||||
|
if (p->presence) {
|
||||||
|
assert(p->elapsed == 550U); p->presence = 0U;
|
||||||
|
return p->active ? 0U : 1U;
|
||||||
|
}
|
||||||
|
assert(p->elapsed == 13U); /* The read sample did not move. */
|
||||||
|
assert(p->bit < 64U);
|
||||||
|
for (i = 0U; i < 3U; ++i) if (p->active & (1U << i)) {
|
||||||
|
if (rom_bit(p, i)) ++ones; else ++zeros;
|
||||||
|
}
|
||||||
|
value = p->pair++ == 0U ? !zeros : !ones;
|
||||||
|
if (fault(p) && p->bit + 1U == p->fault_bit)
|
||||||
|
value = p->crc_fault ? !value : 1U;
|
||||||
|
return (uint8_t)value;
|
||||||
|
}
|
||||||
|
static void delay(void *ctx, uint32_t us) {
|
||||||
|
port_t *p = ctx; p->elapsed += us;
|
||||||
|
if (p->low) p->low_time += us;
|
||||||
|
if (us == DS18B20_SLOT_RECOVERY_EXTRA_US) {
|
||||||
|
assert(!p->critical && !p->low); ++p->recovery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void enter(void *ctx) { port_t *p = ctx; assert(!p->critical); p->critical = 1U; }
|
||||||
|
static void leave(void *ctx) { port_t *p = ctx; assert(p->critical); p->critical = 0U; }
|
||||||
|
static const ds18b20_onewire_ops_t ops = {low, release_line, read_line, delay, NULL, enter, leave, NULL};
|
||||||
|
|
||||||
|
static void setup(ds18b20_t *bus, port_t *p, uint8_t storage[][8], size_t cap) {
|
||||||
|
unsigned i;
|
||||||
|
memset(p, 0, sizeof(*p)); p->bus = bus;
|
||||||
|
for (i = 0U; i < 3U; ++i) {
|
||||||
|
p->roms[i][0] = 0x28U; p->roms[i][1] = (uint8_t)(i + 1U);
|
||||||
|
p->roms[i][7] = ds18b20_crc8(p->roms[i], 7U);
|
||||||
|
p->masks[i] = 7U;
|
||||||
|
}
|
||||||
|
assert(ds18b20_init(bus, &ops, p, storage, cap) == DS18B20_OK);
|
||||||
|
}
|
||||||
|
static ds18b20_status_t finish(ds18b20_t *bus, port_t *p) {
|
||||||
|
ds18b20_status_t s; unsigned calls = 0U, before;
|
||||||
|
do {
|
||||||
|
before = p->attempts; s = ds18b20_search_step(bus);
|
||||||
|
assert(p->attempts <= before + 1U); /* Never retries in a tight loop. */
|
||||||
|
assert(++calls <= DS18B20_SEARCH_MAX_ATTEMPTS + 1U);
|
||||||
|
} while (s == DS18B20_E_BUSY);
|
||||||
|
before = p->attempts;
|
||||||
|
assert(ds18b20_search_step(bus) == s && p->attempts == before);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
int main(void) {
|
||||||
|
ds18b20_t bus; port_t p; uint8_t storage[3][8], saved[8], discrepancy, family;
|
||||||
|
setup(&bus, &p, storage, 3U);
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_OK);
|
||||||
|
assert(bus.rom_count == 3U && p.attempts == 9U);
|
||||||
|
assert(bus.search_diagnostics.complete_passes == 3U && p.recovery != 0U);
|
||||||
|
assert(bus.search_diagnostics.last_error == DS18B20_OK);
|
||||||
|
|
||||||
|
/* A fault on the second branch must restore the PREVIOUS tree path. */
|
||||||
|
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 17U;
|
||||||
|
ds18b20_search_begin(&bus);
|
||||||
|
assert(ds18b20_search_step(&bus) == DS18B20_E_BUSY);
|
||||||
|
memcpy(saved, bus.search_rom, 8U); discrepancy = bus.last_discrepancy;
|
||||||
|
family = bus.last_family_discrepancy;
|
||||||
|
assert(ds18b20_search_step(&bus) == DS18B20_E_BUSY);
|
||||||
|
assert(memcmp(saved, bus.search_rom, 8U) == 0);
|
||||||
|
assert(bus.last_discrepancy == discrepancy && bus.last_family_discrepancy == family);
|
||||||
|
assert(finish(&bus, &p) == DS18B20_OK && bus.rom_count == 3U);
|
||||||
|
assert(bus.search_diagnostics.retries == 1U);
|
||||||
|
assert(bus.search_diagnostics.last_error_bit == 17U && bus.search_diagnostics.last_error_pair == 3U);
|
||||||
|
|
||||||
|
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 64U; p.crc_fault = 1U;
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_OK && bus.rom_count == 3U);
|
||||||
|
assert(bus.search_diagnostics.crc_errors == 1U && bus.search_diagnostics.retries == 1U);
|
||||||
|
assert(bus.search_diagnostics.last_error_bit == 64U);
|
||||||
|
|
||||||
|
/* Each pass sees a different device; the validated union retains all three. */
|
||||||
|
setup(&bus, &p, storage, 3U); p.masks[0] = 1U; p.masks[1] = 2U; p.masks[2] = 4U;
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_OK && bus.rom_count == 3U);
|
||||||
|
assert(p.attempts == 3U);
|
||||||
|
|
||||||
|
setup(&bus, &p, storage, 3U); memset(p.masks, 0, sizeof(p.masks));
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_E_NO_DEVICE);
|
||||||
|
assert(p.attempts == 15U && bus.search_diagnostics.retries == 12U);
|
||||||
|
assert(bus.search_diagnostics.last_error_bit == 0U);
|
||||||
|
assert(ds18b20_add_known_rom(&bus, p.roms[0]) == DS18B20_OK);
|
||||||
|
assert(ds18b20_add_known_rom(&bus, p.roms[0]) == DS18B20_OK && bus.rom_count == 1U);
|
||||||
|
assert(ds18b20_add_known_rom(&bus, NULL) == DS18B20_E_ARGUMENT);
|
||||||
|
p.roms[1][7] ^= 1U;
|
||||||
|
assert(ds18b20_add_known_rom(&bus, p.roms[1]) == DS18B20_E_CRC);
|
||||||
|
|
||||||
|
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 17U; p.persistent_fault = 1U;
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_E_IO && bus.rom_count == 1U);
|
||||||
|
assert(p.attempts == 16U && bus.search_diagnostics.retries == 12U);
|
||||||
|
assert(bus.search_diagnostics.complete_passes == 0U);
|
||||||
|
|
||||||
|
setup(&bus, &p, storage, 3U); p.fault_attempt = 1U; p.fault_bit = 64U;
|
||||||
|
p.persistent_fault = 1U; p.crc_fault = 1U;
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_E_CRC && bus.rom_count == 0U);
|
||||||
|
assert(bus.search_diagnostics.crc_errors == 15U);
|
||||||
|
|
||||||
|
setup(&bus, &p, storage, 1U);
|
||||||
|
assert(ds18b20_search(&bus) == DS18B20_E_CAPACITY && bus.rom_count == 1U);
|
||||||
|
setup(&bus, &p, storage, 3U); ds18b20_search_begin(&bus);
|
||||||
|
bus.search_diagnostics.attempts = DS18B20_SEARCH_MAX_ATTEMPTS;
|
||||||
|
assert(finish(&bus, &p) == DS18B20_E_TIMEOUT && p.attempts == 0U);
|
||||||
|
puts("DS18B20 robust search, retries, CRC, union, limits and timing: OK");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -32,6 +32,16 @@ typedef enum {
|
|||||||
FIRMWARE_INFO_OUT_OF_RANGE
|
FIRMWARE_INFO_OUT_OF_RANGE
|
||||||
} firmware_info_status_t;
|
} firmware_info_status_t;
|
||||||
|
|
||||||
|
/* Compatibility names for climate firmware; same layout and wire contract. */
|
||||||
|
typedef firmware_info_t FirmwareInfo;
|
||||||
|
typedef firmware_info_status_t FirmwareInfoStatus;
|
||||||
|
#define FIRMWARE_INFO_REG_COUNT FIRMWARE_INFO_REGISTER_COUNT
|
||||||
|
#define FIRMWARE_INFO_BUILD_ID_CHARS FIRMWARE_INFO_BUILD_ID_SIZE
|
||||||
|
#define FirmwareInfo_Validate firmware_info_validate
|
||||||
|
#define FirmwareInfo_ToRegisters firmware_info_to_registers
|
||||||
|
#define FirmwareInfo_ParseBuildStamp firmware_info_parse_build_stamp
|
||||||
|
#define FirmwareInfo_SetBuildId firmware_info_set_build_id
|
||||||
|
|
||||||
firmware_info_status_t firmware_info_validate(const firmware_info_t *info);
|
firmware_info_status_t firmware_info_validate(const firmware_info_t *info);
|
||||||
firmware_info_status_t firmware_info_parse_build_stamp(
|
firmware_info_status_t firmware_info_parse_build_stamp(
|
||||||
const char *date_text, const char *time_text, firmware_info_t *info);
|
const char *date_text, const char *time_text, firmware_info_t *info);
|
||||||
|
|||||||
@@ -16,5 +16,11 @@ int main(void)
|
|||||||
assert(words[8] == 0x6162U && words[9] == 0x6330U);
|
assert(words[8] == 0x6162U && words[9] == 0x6330U);
|
||||||
assert(firmware_info_to_le_bytes(&info, bytes, sizeof(bytes)) == FIRMWARE_INFO_OK);
|
assert(firmware_info_to_le_bytes(&info, bytes, sizeof(bytes)) == FIRMWARE_INFO_OK);
|
||||||
assert(bytes[0] == 1U && bytes[1] == 0U && bytes[16] == 0x62U && bytes[17] == 0x61U);
|
assert(bytes[0] == 1U && bytes[1] == 0U && bytes[16] == 0x62U && bytes[17] == 0x61U);
|
||||||
|
{
|
||||||
|
FirmwareInfo climate = info;
|
||||||
|
uint16_t compat[FIRMWARE_INFO_REG_COUNT];
|
||||||
|
assert(FirmwareInfo_ToRegisters(&climate, compat, FIRMWARE_INFO_REG_COUNT) == FIRMWARE_INFO_OK);
|
||||||
|
assert(memcmp(words, compat, sizeof(words)) == 0);
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
11
c/flash-storage/CMakeLists.txt
Normal file
11
c/flash-storage/CMakeLists.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(flash_storage C)
|
||||||
|
add_library(flash_storage STATIC Src/flash_storage.c)
|
||||||
|
target_include_directories(flash_storage PUBLIC Inc)
|
||||||
|
set_target_properties(flash_storage PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_executable(test_flash_storage Tests/test_flash_storage.c ../ds18b20/instance/Src/ds18b20.c)
|
||||||
|
target_link_libraries(test_flash_storage PRIVATE flash_storage)
|
||||||
|
target_include_directories(test_flash_storage PRIVATE ../ds18b20/instance/Inc)
|
||||||
|
add_test(NAME test_flash_storage COMMAND test_flash_storage)
|
||||||
104
c/flash-storage/Inc/flash_storage.h
Normal file
104
c/flash-storage/Inc/flash_storage.h
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#ifndef PORTABLE_FLASH_STORAGE_H
|
||||||
|
#define PORTABLE_FLASH_STORAGE_H
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define FLASH_STORAGE_IMAGE_SIZE 512U
|
||||||
|
#define FLASH_STORAGE_RECORD_SIZE 544U
|
||||||
|
#define FLASH_STORAGE_V1_RECORD_SIZE 288U
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FLASH_STORAGE_OK = 0,
|
||||||
|
FLASH_STORAGE_E_ARGUMENT = -1,
|
||||||
|
FLASH_STORAGE_E_LAYOUT = -2,
|
||||||
|
FLASH_STORAGE_E_IO = -3,
|
||||||
|
FLASH_STORAGE_E_NOT_FOUND = -4,
|
||||||
|
FLASH_STORAGE_E_CORRUPT = -5,
|
||||||
|
FLASH_STORAGE_E_BUSY = -6,
|
||||||
|
FLASH_STORAGE_E_BOUNDS = -7
|
||||||
|
} flash_storage_status_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
flash_storage_status_t (*read)(void *context, uint32_t address,
|
||||||
|
void *data, size_t size);
|
||||||
|
flash_storage_status_t (*program)(void *context, uint32_t address,
|
||||||
|
const void *data, size_t size);
|
||||||
|
flash_storage_status_t (*erase)(void *context, uint32_t address,
|
||||||
|
size_t size);
|
||||||
|
uint32_t (*tick_ms)(void *context);
|
||||||
|
} flash_storage_ops_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t bank_address[2];
|
||||||
|
uint32_t bank_size;
|
||||||
|
uint32_t program_alignment;
|
||||||
|
uint32_t erase_alignment;
|
||||||
|
uint32_t minimum_write_ms;
|
||||||
|
uint8_t reserve_percent;
|
||||||
|
} flash_storage_layout_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const flash_storage_ops_t *ops;
|
||||||
|
void *platform_context;
|
||||||
|
flash_storage_layout_t layout;
|
||||||
|
uint32_t latest_address;
|
||||||
|
uint32_t latest_sequence;
|
||||||
|
uint32_t next_address;
|
||||||
|
uint32_t last_write_tick;
|
||||||
|
uint32_t valid_records;
|
||||||
|
uint32_t programmed_slots;
|
||||||
|
uint16_t latest_version;
|
||||||
|
uint8_t initialized;
|
||||||
|
uint8_t busy;
|
||||||
|
uint8_t wrote_this_boot;
|
||||||
|
/* Рабочая запись принадлежит экземпляру: Flash API не расходует малый
|
||||||
|
* embedded-стек на 544-байтные автоматические структуры. */
|
||||||
|
uint32_t workspace[FLASH_STORAGE_RECORD_SIZE / sizeof(uint32_t)];
|
||||||
|
} flash_storage_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
flash_storage_t journal;
|
||||||
|
uint8_t image[FLASH_STORAGE_IMAGE_SIZE];
|
||||||
|
uint8_t next_image[FLASH_STORAGE_IMAGE_SIZE];
|
||||||
|
uint8_t initialized;
|
||||||
|
} eeprom_store_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t sequence;
|
||||||
|
uint32_t valid_records;
|
||||||
|
uint32_t programmed_slots;
|
||||||
|
uint32_t allocated_bytes;
|
||||||
|
uint32_t reserved_bytes;
|
||||||
|
} flash_storage_info_t;
|
||||||
|
|
||||||
|
flash_storage_status_t flash_storage_init(flash_storage_t *instance,
|
||||||
|
const flash_storage_ops_t *ops, void *platform_context,
|
||||||
|
const flash_storage_layout_t *layout);
|
||||||
|
flash_storage_status_t flash_storage_read_latest(flash_storage_t *instance,
|
||||||
|
void *data, size_t capacity, size_t *length, uint32_t *sequence);
|
||||||
|
flash_storage_status_t flash_storage_write(flash_storage_t *instance,
|
||||||
|
const void *data, size_t length);
|
||||||
|
flash_storage_status_t flash_storage_format(flash_storage_t *instance);
|
||||||
|
flash_storage_status_t flash_storage_get_info(flash_storage_t *instance,
|
||||||
|
flash_storage_info_t *info);
|
||||||
|
|
||||||
|
flash_storage_status_t eeprom_store_init(eeprom_store_t *instance,
|
||||||
|
const flash_storage_ops_t *ops, void *platform_context,
|
||||||
|
const flash_storage_layout_t *layout);
|
||||||
|
flash_storage_status_t eeprom_store_read(eeprom_store_t *instance,
|
||||||
|
size_t offset, void *data, size_t size);
|
||||||
|
flash_storage_status_t eeprom_store_write(eeprom_store_t *instance,
|
||||||
|
size_t offset, const void *data, size_t size);
|
||||||
|
flash_storage_status_t eeprom_store_format(eeprom_store_t *instance);
|
||||||
|
|
||||||
|
/* API не ISR-safe: erase/program могут надолго блокировать CPU. Один экземпляр
|
||||||
|
* обслуживается последовательно; разные экземпляры допустимы на независимых
|
||||||
|
* областях, а общий HAL Flash должен сериализовать платформенный адаптер. */
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
8
c/flash-storage/Inc/flash_storage_config.h
Normal file
8
c/flash-storage/Inc/flash_storage_config.h
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#ifndef PORTABLE_FLASH_STORAGE_CONFIG_H
|
||||||
|
#define PORTABLE_FLASH_STORAGE_CONFIG_H
|
||||||
|
#define FLASH_STORAGE_MAGIC 0x474E4952UL
|
||||||
|
#define FLASH_STORAGE_COMMIT 0x54494D43UL
|
||||||
|
#define FLASH_STORAGE_VERSION 2U
|
||||||
|
#define FLASH_STORAGE_VERSION_V1 1U
|
||||||
|
#define FLASH_STORAGE_MIN_RESERVE_PERCENT 10U
|
||||||
|
#endif
|
||||||
43
c/flash-storage/README.md
Normal file
43
c/flash-storage/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# FlashStorage
|
||||||
|
|
||||||
|
Переносимый журнал двух банков Flash с подтверждёнными записями и восстановлением
|
||||||
|
после прерванной записи. Предоставляет также EEPROM-подобный образ 512 байт.
|
||||||
|
Не содержит HAL, файловой системы, адресов конкретной платы или malloc.
|
||||||
|
|
||||||
|
Приложение → `flash_storage` / `eeprom_store` → callbacks → Flash-порт платы.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `Inc/flash_storage.h` | Контекст, раскладка банков, read/program/erase/tick callbacks |
|
||||||
|
| `Inc/flash_storage_config.h` | Версия формата, magic, commit marker |
|
||||||
|
| `Src/flash_storage.c` | Восстановление, запись, ротация банков; только стандартный C |
|
||||||
|
| `Tests/test_flash_storage.c` | Прерывание записи, восстановление, несколько экземпляров |
|
||||||
|
|
||||||
|
Порт получает контекст, адрес и длину. `read`/`program`/`erase` возвращают
|
||||||
|
`flash_storage_status_t`; `tick_ms` возвращает uint32_t. Буферы принадлежат
|
||||||
|
экземпляру. Стирание и программирование могут блокировать процессор: API не
|
||||||
|
предназначен для ISR. Раскладку и резерв места задаёт `flash_storage_layout_t`.
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* board_ops, board_context и layout определяет порт конкретной платы. */
|
||||||
|
flash_storage_t journal;
|
||||||
|
uint8_t payload[512] = {0}, restored[512];
|
||||||
|
size_t length = 0;
|
||||||
|
flash_storage_status_t result;
|
||||||
|
result = flash_storage_init(&journal, &board_ops, board_context, &layout);
|
||||||
|
if (result == FLASH_STORAGE_OK) {
|
||||||
|
result = flash_storage_write(&journal, payload, sizeof(payload));
|
||||||
|
}
|
||||||
|
if (result == FLASH_STORAGE_OK) {
|
||||||
|
result = flash_storage_read_latest(&journal, restored,
|
||||||
|
sizeof(restored), &length, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Существующий потребитель — climate. Порты STM32F4 и SPI NOR остаются там.
|
||||||
|
Проверка: `python tools/test_shared_libraries.py` из корня templates.
|
||||||
|
|
||||||
|
|
||||||
|
## Shared source
|
||||||
|
|
||||||
|
Canonical source: `templates/c/flash-storage`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||||
63
c/flash-storage/Src/flash_storage.c
Normal file
63
c/flash-storage/Src/flash_storage.c
Normal file
File diff suppressed because one or more lines are too long
27
c/flash-storage/Tests/test_flash_storage.c
Normal file
27
c/flash-storage/Tests/test_flash_storage.c
Normal file
File diff suppressed because one or more lines are too long
9
c/modbus/CMakeLists.txt
Normal file
9
c/modbus/CMakeLists.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(set_modbus C)
|
||||||
|
add_library(set_modbus STATIC src/modbus_data.c)
|
||||||
|
target_include_directories(set_modbus PUBLIC include)
|
||||||
|
set_target_properties(set_modbus PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
enable_testing()
|
||||||
|
add_executable(test_modbus_data tests/test_modbus_data.c)
|
||||||
|
target_link_libraries(test_modbus_data PRIVATE set_modbus)
|
||||||
|
add_test(NAME modbus_data COMMAND test_modbus_data)
|
||||||
41
c/modbus/README.md
Normal file
41
c/modbus/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Общее ядро команд Modbus
|
||||||
|
|
||||||
|
Обработка coils и регистров функций 01/03/04/05/06/0F/10 без HAL, UART,
|
||||||
|
таймеров, глобальной карты регистров и выделения памяти.
|
||||||
|
|
||||||
|
Приложение → декодированный запрос → проверка и отображение адреса портом →
|
||||||
|
`mb_data_transfer` → банк регистров/битов приложения.
|
||||||
|
|
||||||
|
| Файл | Назначение и зависимости |
|
||||||
|
|---|---|
|
||||||
|
| `include/modbus_data.h` | Контракт, только stdint/stddef |
|
||||||
|
| `src/modbus_data.c` | Общие ограничения количества, обработка coils и регистров |
|
||||||
|
| `ports/stm32-legacy/modbus.c` | Совместимый адаптер старого John; зависит от проектного `rs_message.h` |
|
||||||
|
| `tests/test_modbus_data.c` | Границы и проход через все смещения coils 0..15 |
|
||||||
|
|
||||||
|
Порт сначала вызывает `mb_data_validate`, затем отображает **весь** диапазон
|
||||||
|
адресов в память, и только после успешного отображения вызывает transfer.
|
||||||
|
Коды возврата соответствуют исключениям Modbus: 0 — успех, 1 — функция,
|
||||||
|
3 — значение. Ёмкость данных передаётся в 16-битных словах. Адресная карта,
|
||||||
|
права записи, RTU framing, CRC и таймеры остаются обязанностью транспорта.
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "modbus_data.h"
|
||||||
|
uint16_t registers[2] = {123, 456};
|
||||||
|
uint16_t response[2];
|
||||||
|
uint16_t response_bytes = 0;
|
||||||
|
uint8_t status = mb_data_validate(3, 2, 0, 2);
|
||||||
|
/* Пример банка уже отображён; реальный порт проверяет адрес до вызова. */
|
||||||
|
if (status == 0) {
|
||||||
|
status = mb_data_transfer(3, 2, 0, registers, 0,
|
||||||
|
response, 2, &response_bytes);
|
||||||
|
}
|
||||||
|
/* status == 0, response_bytes == 4; сериализацию выполняет транспорт. */
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется в `home/climate/core/STM32_Modbus`, `john103C6T6` и
|
||||||
|
`ds18b20-c8t6test`. Два последних подключают один совместимый адаптер;
|
||||||
|
климат сохраняет свой master/slave транспорт и диагностические функции.
|
||||||
|
Это не замена `pcan_modbus_server`, у которого другой транспортный контракт.
|
||||||
|
|
||||||
|
Проверка: `python tools/test_shared_libraries.py` из корня templates.
|
||||||
15
c/modbus/include/modbus_data.h
Normal file
15
c/modbus/include/modbus_data.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#ifndef SET_MODBUS_DATA_H
|
||||||
|
#define SET_MODBUS_DATA_H
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
/* Decoded Modbus values, independent of UART/HAL and register addresses.
|
||||||
|
* Coils in bank use bit 0 first in 16-bit words. Message words contain two
|
||||||
|
* wire bytes, first byte in bits 15..8 (same convention as register messages).
|
||||||
|
* The port validates/maps the complete address range before calling transfer.
|
||||||
|
* Return standard Modbus exceptions: 0 success, 1 function, 3 value. */
|
||||||
|
uint8_t mb_data_validate(uint8_t function, uint16_t quantity, uint16_t byte_count,
|
||||||
|
size_t data_capacity);
|
||||||
|
uint8_t mb_data_transfer(uint8_t function, uint16_t quantity, uint16_t byte_count,
|
||||||
|
uint16_t *bank, uint16_t bit_offset, uint16_t *data,
|
||||||
|
size_t data_capacity, uint16_t *response_bytes);
|
||||||
|
#endif
|
||||||
836
c/modbus/ports/stm32-legacy/modbus.c
Normal file
836
c/modbus/ports/stm32-legacy/modbus.c
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
/**
|
||||||
|
**************************************************************************
|
||||||
|
* @file modbus.c
|
||||||
|
* @brief Модуль для реализации MODBUS.
|
||||||
|
**************************************************************************
|
||||||
|
* @details Файл содержит реализацию функций работы с Modbus, включая:
|
||||||
|
* - доступ к coils и registers;
|
||||||
|
* - обработку команд протокола;
|
||||||
|
* - взаимодействие с RS (UART);
|
||||||
|
* - инициализацию.
|
||||||
|
*
|
||||||
|
* @section Функции и макросы
|
||||||
|
*
|
||||||
|
* ### Доступ к coils:
|
||||||
|
* - MB_Set_Coil_Local() — Установить coil по локальному адресу.
|
||||||
|
* - MB_Reset_Coil_Local() — Сбросить coil по локальному адресу.
|
||||||
|
* - MB_Toogle_Coil_Local() — Инвертировать coil по локальному адресу.
|
||||||
|
* - MB_Read_Coil_Local() — Прочитать coil по локальному адресу.
|
||||||
|
* - MB_Write_Coil_Global() — Установить/сбросить coil по глобальному адресу.
|
||||||
|
* - MB_Read_Coil_Global() — Прочитать coil по глобальному адресу.
|
||||||
|
*
|
||||||
|
* ### Обработка команд Modbus:
|
||||||
|
* - MB_DefineRegistersAddress() — Определить начальный адрес регистра.
|
||||||
|
* - MB_DefineCoilsAddress() — Определить начальный адрес coils.
|
||||||
|
* - MB_Check_Address_For_Arr() — Проверить, принадлежит ли адрес массиву.
|
||||||
|
* - Основные команды Modbus:
|
||||||
|
* - MB_Read_Coils()
|
||||||
|
* - MB_Read_Hold_Regs()
|
||||||
|
* - MB_Write_Single_Coil()
|
||||||
|
* - MB_Write_Miltuple_Coils()
|
||||||
|
* - MB_Write_Miltuple_Regs()
|
||||||
|
*
|
||||||
|
* ### Функции для работы с RS (UART):
|
||||||
|
* - RS_Parse_Message() / RS_Collect_Message() — Парсинг и сборка сообщения.
|
||||||
|
* - RS_Response() — Отправка ответа.
|
||||||
|
* - RS_Define_Size_of_RX_Message() — Определение размера принимаемого сообщения.
|
||||||
|
* - RS_Init() — Инициализация UART.
|
||||||
|
*
|
||||||
|
* ### Инициализация:
|
||||||
|
* - MODBUS_FirstInit() — Инициализация модуля Modbus.
|
||||||
|
*
|
||||||
|
* @section Структура данных Modbus
|
||||||
|
*
|
||||||
|
* #### Holding/Input Registers:
|
||||||
|
* - Регистры — 16-битные слова. Доступ к регистрам осуществляется через указатель.
|
||||||
|
* Таким образом, сами регистры могут представлять собой как массив так и структуру.
|
||||||
|
*
|
||||||
|
* #### Coils:
|
||||||
|
* - Coils — это биты, упакованные в 16-битные слова. Доступ к коилам осуществляется через указатель.
|
||||||
|
* Таким образом, сами коилы могут представлять собой как массив так и структуру.
|
||||||
|
*
|
||||||
|
* @section Инструкция по подключению
|
||||||
|
* Для корректной работы надо подключить обработчики RS_UART_Handler(), RS_TIM_Handler(),
|
||||||
|
* в соответствубщие низкоуровневые прерывания UART_IRQHandler, TIM_IRQHandler. После HAL'овского обработчика
|
||||||
|
*
|
||||||
|
* Также необходимо в modbus_config.h настроить дефайны для нужной работы UART
|
||||||
|
* После для запуска Modbus:
|
||||||
|
* @verbatim
|
||||||
|
//----------------Прием модбас----------------//
|
||||||
|
#include "rs_message.h"
|
||||||
|
#include "../../src/modbus_data.c"
|
||||||
|
MODBUS_FirstInit();
|
||||||
|
RS_Receive_IT(&hmodbus1, &MODBUS_MSG);
|
||||||
|
* @endverbatim
|
||||||
|
*
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
#include "rs_message.h"
|
||||||
|
#include "../../src/modbus_data.c"
|
||||||
|
uint32_t dbg_temp, dbg_temp2, dbg_temp3; // for debug
|
||||||
|
/* MODBUS HANDLES */
|
||||||
|
extern UART_HandleTypeDef rs_huart;
|
||||||
|
extern TIM_HandleTypeDef rs_htim;
|
||||||
|
RS_HandleTypeDef hmodbus1;
|
||||||
|
|
||||||
|
/* DEFINE REGISTERS/COILS */
|
||||||
|
MB_DeviceIdentificationTypeDef MB_INFO;
|
||||||
|
MB_DataStructureTypeDef MB_DATA;
|
||||||
|
RS_MsgTypeDef MODBUS_MSG;
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------
|
||||||
|
//-----------------------------FOR USER------------------------------
|
||||||
|
/**
|
||||||
|
* @brief First set up of MODBUS.
|
||||||
|
* @details Первый инит модбас. Заполняет структуры и инициализирует таймер и юарт для общения по модбас.
|
||||||
|
* @note This called from main
|
||||||
|
*/
|
||||||
|
void MODBUS_FirstInit(void)
|
||||||
|
{
|
||||||
|
MB_DevoceInentificationInit();
|
||||||
|
//-----------SETUP MODBUS-------------
|
||||||
|
// set up modbus: MB_RX_Size_NotConst and Timeout enable
|
||||||
|
hmodbus1.ID = MODBUS_DEVICE_ID;
|
||||||
|
hmodbus1.sRS_Timeout = MODBUS_TIMEOUT;
|
||||||
|
hmodbus1.sRS_Mode = SLAVE_ALWAYS_WAIT;
|
||||||
|
hmodbus1.sRS_RX_Size_Mode = RS_RX_Size_NotConst;
|
||||||
|
|
||||||
|
// INIT
|
||||||
|
hmodbus1.RS_STATUS = RS_Init(&hmodbus1, &rs_huart, &rs_htim, 0);
|
||||||
|
|
||||||
|
RS_EnableReceive();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Set or Reset Coil at its global address.
|
||||||
|
* @param Addr - адрес коила.
|
||||||
|
* @param WriteVal - Что записать в коил: 0 или 1.
|
||||||
|
* @return ExceptionCode - Код исключения если коила по адресу не существует, и NO_ERRORS если все ок.
|
||||||
|
*
|
||||||
|
* @details Позволяет обратиться к любому коилу по его глобальному адрессу.
|
||||||
|
Вне зависимости от того как коилы размещены в памяти.
|
||||||
|
*/
|
||||||
|
MB_ExceptionTypeDef MB_Write_Coil_Global(uint16_t Addr, MB_CoilsOpTypeDef WriteVal)
|
||||||
|
{
|
||||||
|
//---------CHECK FOR ERRORS----------
|
||||||
|
MB_ExceptionTypeDef Exception = NO_ERRORS;
|
||||||
|
uint16_t *coils;
|
||||||
|
uint16_t start_shift = 0; // shift in coils register
|
||||||
|
|
||||||
|
//------------WRITE COIL-------------
|
||||||
|
Exception = MB_DefineCoilsAddress(&coils, Addr, 1, &start_shift, 1);
|
||||||
|
if(Exception == NO_ERRORS)
|
||||||
|
{
|
||||||
|
switch(WriteVal)
|
||||||
|
{
|
||||||
|
case SET_COIL:
|
||||||
|
*coils |= (1<<start_shift);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RESET_COIL:
|
||||||
|
*coils &= ~(1<<start_shift);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TOOGLE_COIL:
|
||||||
|
*coils ^= (1<<start_shift);
|
||||||
|
break;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Read Coil at its global address.
|
||||||
|
* @param Addr - адрес коила.
|
||||||
|
* @param Exception - Указатель на переменную для кода исключения, в случа неудачи при чтении.
|
||||||
|
* @return uint16_t - Возвращает весь регистр с маской на запрошенном коиле.
|
||||||
|
*
|
||||||
|
* @details Позволяет обратиться к любому коилу по его глобальному адрессу.
|
||||||
|
Вне зависимости от того как коилы размещены в памяти.
|
||||||
|
*/
|
||||||
|
uint16_t MB_Read_Coil_Global(uint16_t Addr, MB_ExceptionTypeDef *Exception)
|
||||||
|
{
|
||||||
|
//---------CHECK FOR ERRORS----------
|
||||||
|
MB_ExceptionTypeDef Exception_tmp;
|
||||||
|
if(Exception == NULL) // if exception is not given to func fill it
|
||||||
|
Exception = &Exception_tmp;
|
||||||
|
|
||||||
|
uint16_t *coils;
|
||||||
|
uint16_t start_shift = 0; // shift in coils register
|
||||||
|
|
||||||
|
//------------READ COIL--------------
|
||||||
|
*Exception = MB_DefineCoilsAddress(&coils, Addr, 1, &start_shift, 0);
|
||||||
|
if(*Exception == NO_ERRORS)
|
||||||
|
{
|
||||||
|
return ((*coils)&(1<<start_shift));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//-------------------------------------------------------------------
|
||||||
|
//----------------FUNCTIONS FOR PROCESSING MESSAGE-------------------
|
||||||
|
/**
|
||||||
|
* @brief Check is address valid for certain array.
|
||||||
|
* @param Addr - начальный адресс.
|
||||||
|
* @param Qnt - количество запрашиваемых элементов.
|
||||||
|
* @param R_ARR_ADDR - начальный адресс массива R_ARR.
|
||||||
|
* @param R_ARR_NUMB - количество элементов в массиве R_ARR.
|
||||||
|
* @return ExceptionCode - ILLEGAL DATA ADRESS если адресс недействителен, и NO_ERRORS если все ок.
|
||||||
|
*
|
||||||
|
* @details Позволяет определить, принадлежит ли адресс Addr массиву R_ARR:
|
||||||
|
* Если адресс Addr находится в диапазоне адрессов массива R_ARR, то возвращаем NO_ERROR.
|
||||||
|
* Если адресс Addr находится за пределами адрессов массива R_ARR - ILLEGAL_DATA_ADDRESSю.
|
||||||
|
*/
|
||||||
|
MB_ExceptionTypeDef MB_Check_Address_For_Arr(uint16_t Addr, uint16_t Qnt, uint16_t R_ARR_ADDR, uint16_t R_ARR_NUMB)
|
||||||
|
{
|
||||||
|
// if address from this array
|
||||||
|
if(Addr >= R_ARR_ADDR)
|
||||||
|
{
|
||||||
|
// if quantity too big return error
|
||||||
|
if ((Addr - R_ARR_ADDR) + Qnt > R_ARR_NUMB)
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_ADDRESS; // return exception code
|
||||||
|
}
|
||||||
|
// if all ok - return no errors
|
||||||
|
return NO_ERRORS;
|
||||||
|
}
|
||||||
|
// if address isnt from this array return error
|
||||||
|
else
|
||||||
|
return ILLEGAL_DATA_ADDRESS; // return exception code
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Define Address Origin for Input/Holding Registers
|
||||||
|
* @param pRegs - указатель на указатель регистров.
|
||||||
|
* @param Addr - адрес начального регистра.
|
||||||
|
* @param Qnt - количество запрашиваемых регистров.
|
||||||
|
* @param WriteFlag - флаг регистр нужны для чтения или записи.
|
||||||
|
* @return ExceptionCode - Код исключения если есть, и NO_ERRORS если нет.
|
||||||
|
*
|
||||||
|
* @details Определение адреса начального регистра.
|
||||||
|
* @note WriteFlag пока не используется.
|
||||||
|
*/
|
||||||
|
MB_ExceptionTypeDef MB_DefineRegistersAddress(uint16_t **pRegs, uint16_t Addr, uint16_t Qnt, uint8_t RegisterType)
|
||||||
|
{
|
||||||
|
/* check quantity error */
|
||||||
|
if (Qnt > 125)
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_VALUE; // return exception code
|
||||||
|
}
|
||||||
|
|
||||||
|
if(RegisterType == RegisterType_Holding)
|
||||||
|
{
|
||||||
|
// Default holding registers
|
||||||
|
if(MB_Check_Address_For_Arr(Addr, Qnt, R_HOLDING_ADDR, R_HOLDING_QNT) == NO_ERRORS)
|
||||||
|
{
|
||||||
|
*pRegs = MB_Set_Register_Ptr(&MB_DATA.HoldRegs, Addr); // указатель на выбранный по Addr регистр
|
||||||
|
}
|
||||||
|
// if address doesnt match any array - return illegal data address response
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_ADDRESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(RegisterType == RegisterType_Input)
|
||||||
|
{
|
||||||
|
// Default input registers
|
||||||
|
if(MB_Check_Address_For_Arr(Addr, Qnt, R_INPUT_ADDR, R_INPUT_QNT) == NO_ERRORS)
|
||||||
|
{
|
||||||
|
*pRegs = MB_Set_Register_Ptr(&MB_DATA.InRegs, Addr); // указатель на выбранный по Addr регистр
|
||||||
|
}
|
||||||
|
// if address doesnt match any array - return illegal data address response
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_ADDRESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return ILLEGAL_FUNCTION;
|
||||||
|
}
|
||||||
|
// if found requeried array return no err
|
||||||
|
return NO_ERRORS; // return no errors
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Define Address Origin for coils
|
||||||
|
* @param pCoils - указатель на указатель коилов.
|
||||||
|
* @param Addr - адресс начального коила.
|
||||||
|
* @param Qnt - количество запрашиваемых коилов.
|
||||||
|
* @param start_shift - указатель на переменную содержащую сдвиг внутри регистра для начального коила.
|
||||||
|
* @param WriteFlag - флаг коилы нужны для чтения или записи.
|
||||||
|
* @return ExceptionCode - Код исключения если есть, и NO_ERRORS если нет.
|
||||||
|
*
|
||||||
|
* @details Определение адреса начального регистра запрашиваемых коилов.
|
||||||
|
* @note WriteFlag используется для определния регистров GPIO: ODR или IDR.
|
||||||
|
*/
|
||||||
|
MB_ExceptionTypeDef MB_DefineCoilsAddress(uint16_t **pCoils, uint16_t Addr, uint16_t Qnt, uint16_t *start_shift, uint8_t WriteFlag)
|
||||||
|
{
|
||||||
|
/* check quantity error */
|
||||||
|
if (Qnt > 2000)
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_VALUE; // return exception code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default coils
|
||||||
|
if(MB_Check_Address_For_Arr(Addr, Qnt, C_CONTROL_ADDR, C_CONTROL_QNT) == NO_ERRORS)
|
||||||
|
{
|
||||||
|
*pCoils = MB_Set_Coil_Reg_Ptr(&MB_DATA.Coils, Addr); // указатель на выбранный по Addr массив коилов
|
||||||
|
}
|
||||||
|
// if address doesnt match any array - return illegal data address response
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return ILLEGAL_DATA_ADDRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
*start_shift = Addr % 16; // set shift to requested coil
|
||||||
|
// if found requeried array return no err
|
||||||
|
return NO_ERRORS; // return no errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Read Coils (01 - 0x01).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Read Coils.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Read_Coils(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(1, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, &shift, 0);
|
||||||
|
if (!status) status = mb_data_transfer(1, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Read Holding Registers (03 - 0x03).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Read Holding Registers.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Read_Hold_Regs(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(3, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Holding);
|
||||||
|
if (!status) status = mb_data_transfer(3, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Read Input Registers (04 - 0x04).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Read Input Registers.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Read_Input_Regs(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(4, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Input);
|
||||||
|
if (!status) status = mb_data_transfer(4, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Write Single Coils (05 - 0x05).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Write Single Coils.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Write_Single_Coil(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(5, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, 1, &shift, 1);
|
||||||
|
if (!status) status = mb_data_transfer(5, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Write Single Register (06 - 0x06).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Write Single Register.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Write_Single_Reg(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(6, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, 1, RegisterType_Holding);
|
||||||
|
if (!status) status = mb_data_transfer(6, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Write Multiple Coils (15 - 0x0F).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Write Multiple Coils.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Write_Miltuple_Coils(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(15, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, &shift, 1);
|
||||||
|
if (!status) status = mb_data_transfer(15, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Write Multiple Registers (16 - 0x10).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Write Multiple Registers.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Write_Miltuple_Regs(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
|
||||||
|
uint8_t status = mb_data_validate(16, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
|
||||||
|
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Holding);
|
||||||
|
if (!status) status = mb_data_transfer(16, modbus_msg->Qnt, modbus_msg->ByteCnt,
|
||||||
|
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
|
||||||
|
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
|
||||||
|
if (status) return 0;
|
||||||
|
modbus_msg->ByteCnt = response_bytes;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MB_WriteObjectToMessage(char *mbdata, unsigned *ind, MB_DeviceObjectTypeDef *obj)
|
||||||
|
{
|
||||||
|
mbdata[(*ind)++] = obj->length;
|
||||||
|
for (int i = 0; i < obj->length; i++)
|
||||||
|
{
|
||||||
|
mbdata[(*ind)++] = obj->name[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Proccess command Read Device Identification (43/14 - 0x2B/0E).
|
||||||
|
* @param modbus_msg - указатель на структуру собщения modbus.
|
||||||
|
* @return fMessageHandled - статус о результате обработки комманды.
|
||||||
|
* @details Обработка команды Write Single Register.
|
||||||
|
*/
|
||||||
|
uint8_t MB_Read_Device_Identification(RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
char *mbdata = (char *)modbus_msg->DATA;
|
||||||
|
unsigned ind = 0;
|
||||||
|
switch(modbus_msg->DevId.ReadDevId)
|
||||||
|
{
|
||||||
|
case MB_BASIC_IDENTIFICATION:
|
||||||
|
mbdata[ind++] = 0x00;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.VendorName);
|
||||||
|
mbdata[ind++] = 0x01;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ProductCode);
|
||||||
|
mbdata[ind++] = 0x02;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.Revision);
|
||||||
|
modbus_msg->DevId.NumbOfObj = 3;
|
||||||
|
break;
|
||||||
|
case MB_REGULAR_IDENTIFICATION:
|
||||||
|
mbdata[ind++] = 0x03;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.VendorUrl);
|
||||||
|
mbdata[ind++] = 0x04;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ProductName);
|
||||||
|
mbdata[ind++] = 0x05;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ModelName);
|
||||||
|
mbdata[ind++] = 0x06;
|
||||||
|
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.UserApplicationName);
|
||||||
|
modbus_msg->DevId.NumbOfObj = 4;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
modbus_msg->ByteCnt = ind;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Respond accord to received message.
|
||||||
|
* @param hRS - указатель на хендлер RS.
|
||||||
|
* @param RS_msg - указатель на структуру сообщения.
|
||||||
|
* @return RS_RES - статус о результате ответа на комманду.
|
||||||
|
* @details Обработка принятой комманды и ответ на неё.
|
||||||
|
*/
|
||||||
|
RS_StatusTypeDef RS_Response(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg)
|
||||||
|
{
|
||||||
|
RS_StatusTypeDef MB_RES = 0;
|
||||||
|
hmodbus->f.MessageHandled = 0;
|
||||||
|
hmodbus->f.EchoResponse = 0;
|
||||||
|
RS_Reset_TX_Flags(hmodbus); // reset flag for correct transmit
|
||||||
|
|
||||||
|
if(modbus_msg->Func_Code < ERR_VALUES_START)// if no errors after parsing
|
||||||
|
{
|
||||||
|
switch (modbus_msg->Func_Code)
|
||||||
|
{
|
||||||
|
// Read Coils
|
||||||
|
case MB_R_COILS:
|
||||||
|
hmodbus->f.MessageHandled = MB_Read_Coils(hmodbus->pMessagePtr);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Read Hodling Registers
|
||||||
|
case MB_R_HOLD_REGS:
|
||||||
|
hmodbus->f.MessageHandled = MB_Read_Hold_Regs(hmodbus->pMessagePtr);
|
||||||
|
break;
|
||||||
|
case MB_R_IN_REGS:
|
||||||
|
hmodbus->f.MessageHandled = MB_Read_Input_Regs(hmodbus->pMessagePtr);
|
||||||
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
// Write Single Coils
|
||||||
|
case MB_W_COIL:
|
||||||
|
hmodbus->f.MessageHandled = MB_Write_Single_Coil(hmodbus->pMessagePtr);
|
||||||
|
if(hmodbus->f.MessageHandled)
|
||||||
|
{
|
||||||
|
hmodbus->f.EchoResponse = 1;
|
||||||
|
hmodbus->RS_Message_Size -= 2; // echo response if write ok (minus 2 cause of two CRC bytes)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MB_W_HOLD_REG:
|
||||||
|
hmodbus->f.MessageHandled = MB_Write_Single_Reg(hmodbus->pMessagePtr);
|
||||||
|
if(hmodbus->f.MessageHandled)
|
||||||
|
{
|
||||||
|
hmodbus->f.EchoResponse = 1;
|
||||||
|
hmodbus->RS_Message_Size -= 2; // echo response if write ok (minus 2 cause of two CRC bytes)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Write Multiple Coils
|
||||||
|
case MB_W_COILS:
|
||||||
|
hmodbus->f.MessageHandled = MB_Write_Miltuple_Coils(hmodbus->pMessagePtr);
|
||||||
|
if(hmodbus->f.MessageHandled)
|
||||||
|
{
|
||||||
|
hmodbus->f.EchoResponse = 1;
|
||||||
|
hmodbus->RS_Message_Size = 6; // echo response if write ok (withous data bytes)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Write Multiple Registers
|
||||||
|
case MB_W_HOLD_REGS:
|
||||||
|
hmodbus->f.MessageHandled = MB_Write_Miltuple_Regs(hmodbus->pMessagePtr);
|
||||||
|
if(hmodbus->f.MessageHandled)
|
||||||
|
{
|
||||||
|
hmodbus->f.EchoResponse = 1;
|
||||||
|
hmodbus->RS_Message_Size = 6; // echo response if write ok (withous data bytes)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MB_R_DEVICE_INFO:
|
||||||
|
hmodbus->f.MessageHandled = MB_Read_Device_Identification(hmodbus->pMessagePtr);
|
||||||
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
/* unknown func code */
|
||||||
|
default: modbus_msg->Except_Code = 0x01; /* set exception code: illegal function */
|
||||||
|
}
|
||||||
|
|
||||||
|
if(hmodbus->f.MessageHandled == 0)
|
||||||
|
{
|
||||||
|
modbus_msg->Func_Code += ERR_VALUES_START;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we need response - check that transmit isnt busy
|
||||||
|
if( RS_Is_TX_Busy(hmodbus) )
|
||||||
|
RS_Abort(hmodbus, ABORT_TX); // if tx busy - set it free
|
||||||
|
|
||||||
|
// Transmit right there, or sets (fDeferredResponse) to transmit response in main code
|
||||||
|
MB_RES = RS_Handle_Transmit_Start(hmodbus, modbus_msg);
|
||||||
|
|
||||||
|
hmodbus->RS_STATUS = MB_RES;
|
||||||
|
return MB_RES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Collect message in buffer to transmit it.
|
||||||
|
* @param hRS - указатель на хендлер RS.
|
||||||
|
* @param RS_msg - указатель на структуру сообщения.
|
||||||
|
* @param msg_uart_buff - указатель на буффер UART.
|
||||||
|
* @return RS_RES - статус о результате заполнения буфера.
|
||||||
|
* @details Заполнение буффера UART из структуры сообщения.
|
||||||
|
*/
|
||||||
|
RS_StatusTypeDef RS_Collect_Message(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg, uint8_t *modbus_uart_buff)
|
||||||
|
{
|
||||||
|
int ind = 0; // ind for modbus-uart buffer
|
||||||
|
|
||||||
|
if(hmodbus->f.EchoResponse && hmodbus->f.MessageHandled) // if echo response need
|
||||||
|
ind = hmodbus->RS_Message_Size;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//------INFO ABOUT DATA/MESSAGE------
|
||||||
|
//-----------[first bytes]-----------
|
||||||
|
// set ID of message/user
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->MbAddr;
|
||||||
|
|
||||||
|
// set dat or err response
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->Func_Code;
|
||||||
|
|
||||||
|
if (modbus_msg->Func_Code < ERR_VALUES_START) // if no error occur
|
||||||
|
{
|
||||||
|
// fill modbus header
|
||||||
|
if(modbus_msg->Func_Code == MB_R_DEVICE_INFO) // devide identification header
|
||||||
|
{
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.MEI_Type;
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.ReadDevId;
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.Conformity;
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.MoreFollows;
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.NextObjId;
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->DevId.NumbOfObj;
|
||||||
|
|
||||||
|
if (modbus_msg->ByteCnt > DATA_SIZE*2) // if ByteCnt less than DATA_SIZE
|
||||||
|
{
|
||||||
|
return RS_COLLECT_MSG_ERR;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//---------------DATA----------------
|
||||||
|
//-----------[data bytes]------------
|
||||||
|
uint8_t *tmp_data_addr = (uint8_t *)modbus_msg->DATA;
|
||||||
|
for(int i = 0; i < modbus_msg->ByteCnt; i++) // filling buffer with data
|
||||||
|
{ // set data
|
||||||
|
modbus_uart_buff[ind++] = *tmp_data_addr;
|
||||||
|
tmp_data_addr++;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else // modbus data header
|
||||||
|
{
|
||||||
|
// set size of received data
|
||||||
|
if (modbus_msg->ByteCnt <= DATA_SIZE*2) // if ByteCnt less than DATA_SIZE
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->ByteCnt;
|
||||||
|
else // otherwise return data_size err
|
||||||
|
{
|
||||||
|
return RS_COLLECT_MSG_ERR;
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------DATA----------------
|
||||||
|
//-----------[data bytes]------------
|
||||||
|
uint16_t *tmp_data_addr = (uint16_t *)modbus_msg->DATA;
|
||||||
|
for(int i = 0; i < modbus_msg->ByteCnt; i++) // filling buffer with data
|
||||||
|
{ // set data
|
||||||
|
if (i%2 == 0) // HI byte
|
||||||
|
modbus_uart_buff[ind++] = (*tmp_data_addr)>>8;
|
||||||
|
else // LO byte
|
||||||
|
{
|
||||||
|
modbus_uart_buff[ind++] = *tmp_data_addr;
|
||||||
|
tmp_data_addr++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else // if some error occur
|
||||||
|
{ // send expection code
|
||||||
|
modbus_uart_buff[ind++] = modbus_msg->Except_Code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//---------------CRC----------------
|
||||||
|
//---------[last 16 bytes]----------
|
||||||
|
// calc crc of received data
|
||||||
|
uint16_t CRC_VALUE = crc16(modbus_uart_buff, ind);
|
||||||
|
// write crc to message structure and modbus-uart buffer
|
||||||
|
modbus_msg->MB_CRC = CRC_VALUE;
|
||||||
|
modbus_uart_buff[ind++] = CRC_VALUE;
|
||||||
|
modbus_uart_buff[ind++] = CRC_VALUE >> 8;
|
||||||
|
|
||||||
|
hmodbus->RS_Message_Size = ind;
|
||||||
|
|
||||||
|
return RS_OK; // returns ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse message from buffer to process it.
|
||||||
|
* @param hRS - указатель на хендлер RS.
|
||||||
|
* @param RS_msg - указатель на структуру сообщения.
|
||||||
|
* @param msg_uart_buff - указатель на буффер UART.
|
||||||
|
* @return RS_RES - статус о результате заполнения структуры.
|
||||||
|
* @details Заполнение структуры сообщения из буффера UART.
|
||||||
|
*/
|
||||||
|
RS_StatusTypeDef RS_Parse_Message(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg, uint8_t *modbus_uart_buff)
|
||||||
|
{
|
||||||
|
uint32_t check_empty_buff;
|
||||||
|
int ind = 0; // ind for modbus-uart buffer
|
||||||
|
//-----INFO ABOUT DATA/MESSAGE-------
|
||||||
|
//-----------[first bits]------------
|
||||||
|
// get ID of message/user
|
||||||
|
modbus_msg->MbAddr = modbus_uart_buff[ind++];
|
||||||
|
if(modbus_msg->MbAddr != hmodbus->ID)
|
||||||
|
return RS_SKIP;
|
||||||
|
|
||||||
|
// get func code
|
||||||
|
modbus_msg->Func_Code = modbus_uart_buff[ind++];
|
||||||
|
if(modbus_msg->Func_Code == MB_R_DEVICE_INFO) // if it device identification request
|
||||||
|
{
|
||||||
|
modbus_msg->DevId.MEI_Type = modbus_uart_buff[ind++];
|
||||||
|
modbus_msg->DevId.ReadDevId = modbus_uart_buff[ind++];
|
||||||
|
modbus_msg->DevId.NextObjId = modbus_uart_buff[ind++];
|
||||||
|
modbus_msg->ByteCnt = 0;
|
||||||
|
}
|
||||||
|
else // if its classic modbus request
|
||||||
|
{
|
||||||
|
// get address from CMD
|
||||||
|
modbus_msg->Addr = modbus_uart_buff[ind++] << 8;
|
||||||
|
modbus_msg->Addr |= modbus_uart_buff[ind++];
|
||||||
|
|
||||||
|
// get address from CMD
|
||||||
|
modbus_msg->Qnt = modbus_uart_buff[ind++] << 8;
|
||||||
|
modbus_msg->Qnt |= modbus_uart_buff[ind++];
|
||||||
|
}
|
||||||
|
if(hmodbus->f.RX_Half == 0) // if all message received
|
||||||
|
{
|
||||||
|
//---------------DATA----------------
|
||||||
|
// (optional)
|
||||||
|
if (modbus_msg->ByteCnt != 0)
|
||||||
|
{
|
||||||
|
ind++; // increment ind for data_size byte
|
||||||
|
//check that data size is correct
|
||||||
|
if (modbus_msg->ByteCnt > DATA_SIZE*2)
|
||||||
|
{
|
||||||
|
modbus_msg->Func_Code += ERR_VALUES_START;
|
||||||
|
return RS_PARSE_MSG_ERR;
|
||||||
|
}
|
||||||
|
uint16_t *tmp_data_addr = (uint16_t *)modbus_msg->DATA;
|
||||||
|
for(int i = 0; i < modbus_msg->ByteCnt; i++) // /2 because we transmit 8 bits, not 16 bits
|
||||||
|
{ // set data
|
||||||
|
if (i%2 == 0)
|
||||||
|
*tmp_data_addr = ((uint16_t)modbus_uart_buff[ind++] << 8);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
*tmp_data_addr |= modbus_uart_buff[ind++];
|
||||||
|
tmp_data_addr++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------CRC----------------
|
||||||
|
//----------[last 16 bits]----------
|
||||||
|
// calc crc of received data
|
||||||
|
uint16_t CRC_VALUE = crc16(modbus_uart_buff, ind);
|
||||||
|
// get crc of received data
|
||||||
|
modbus_msg->MB_CRC = modbus_uart_buff[ind++];
|
||||||
|
modbus_msg->MB_CRC |= modbus_uart_buff[ind++] << 8;
|
||||||
|
// compare crc
|
||||||
|
if (modbus_msg->MB_CRC != CRC_VALUE)
|
||||||
|
{
|
||||||
|
modbus_msg->Func_Code += ERR_VALUES_START;
|
||||||
|
}
|
||||||
|
// hmodbus->MB_RESPONSE = MB_CRC_ERR; // set func code - error about wrong crc
|
||||||
|
|
||||||
|
// check is buffer empty
|
||||||
|
check_empty_buff = 0;
|
||||||
|
for(int i=0; i<ind;i++)
|
||||||
|
check_empty_buff += modbus_uart_buff[i];
|
||||||
|
// if(check_empty_buff == 0)
|
||||||
|
// hmodbus->MB_RESPONSE = MB_EMPTY_MSG; //
|
||||||
|
}
|
||||||
|
|
||||||
|
return RS_OK;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Define size of RX Message that need to be received.
|
||||||
|
* @param hRS - указатель на хендлер RS.
|
||||||
|
* @param rx_data_size - указатель на переменную для записи кол-ва байт для принятия.
|
||||||
|
* @return RS_RES - статус о корректности рассчета кол-ва байт для принятия.
|
||||||
|
* @details Определение сколько байтов надо принять по протоколу.
|
||||||
|
*/
|
||||||
|
RS_StatusTypeDef RS_Define_Size_of_RX_Message(RS_HandleTypeDef *hmodbus, uint32_t *rx_data_size)
|
||||||
|
{
|
||||||
|
RS_StatusTypeDef MB_RES = 0;
|
||||||
|
|
||||||
|
MB_RES = RS_Parse_Message(hmodbus, hmodbus->pMessagePtr, hmodbus->pBufferPtr);
|
||||||
|
if(MB_RES == RS_SKIP) // if message not for us
|
||||||
|
return MB_RES; // return
|
||||||
|
|
||||||
|
|
||||||
|
if ((hmodbus->pMessagePtr->Func_Code & ~ERR_VALUES_START) < 0x0F)
|
||||||
|
{
|
||||||
|
hmodbus->pMessagePtr->ByteCnt = 0;
|
||||||
|
*rx_data_size = 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hmodbus->pMessagePtr->ByteCnt = hmodbus->pBufferPtr[RX_FIRST_PART_SIZE-1]; // get numb of data in command
|
||||||
|
// +1 because that defines is size, not ind.
|
||||||
|
*rx_data_size = hmodbus->pMessagePtr->ByteCnt + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if(hmodbus->pMessagePtr->Func_Code == MB_R_DEVICE_INFO)
|
||||||
|
{
|
||||||
|
*rx_data_size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hmodbus->RS_Message_Size = RX_FIRST_PART_SIZE + *rx_data_size; // size of whole message
|
||||||
|
return RS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------FOR USER------------------------------
|
||||||
|
//-------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
void MB_DevoceInentificationInit(void)
|
||||||
|
{
|
||||||
|
MB_INFO.VendorName.name = MODBUS_VENDOR_NAME;
|
||||||
|
MB_INFO.ProductCode.name = MODBUS_PRODUCT_CODE;
|
||||||
|
MB_INFO.Revision.name = MODBUS_REVISION;
|
||||||
|
MB_INFO.VendorUrl.name = MODBUS_VENDOR_URL;
|
||||||
|
MB_INFO.ProductName.name = MODBUS_PRODUCT_NAME;
|
||||||
|
MB_INFO.ModelName.name = MODBUS_MODEL_NAME;
|
||||||
|
MB_INFO.UserApplicationName.name = MODBUS_USER_APPLICATION_NAME;
|
||||||
|
|
||||||
|
|
||||||
|
MB_INFO.VendorName.length = sizeof(MODBUS_VENDOR_NAME);
|
||||||
|
MB_INFO.ProductCode.length = sizeof(MODBUS_PRODUCT_CODE);
|
||||||
|
MB_INFO.Revision.length = sizeof(MODBUS_REVISION);
|
||||||
|
MB_INFO.VendorUrl.length = sizeof(MODBUS_VENDOR_URL);
|
||||||
|
MB_INFO.ProductName.length = sizeof(MODBUS_PRODUCT_NAME);
|
||||||
|
MB_INFO.ModelName.length = sizeof(MODBUS_MODEL_NAME);
|
||||||
|
MB_INFO.UserApplicationName.length = sizeof(MODBUS_USER_APPLICATION_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
57
c/modbus/src/modbus_data.c
Normal file
57
c/modbus/src/modbus_data.c
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#include "../include/modbus_data.h"
|
||||||
|
|
||||||
|
uint8_t mb_data_validate(uint8_t function, uint16_t quantity, uint16_t bytes, size_t capacity)
|
||||||
|
{
|
||||||
|
size_t needed;
|
||||||
|
switch (function) {
|
||||||
|
case 1: case 15:
|
||||||
|
if (!quantity || quantity > (function == 1 ? 2000U : 1968U)) return 3;
|
||||||
|
needed = ((size_t)quantity+15U)/16U;
|
||||||
|
if (function == 15 && bytes != (quantity+7U)/8U) return 3;
|
||||||
|
break;
|
||||||
|
case 3: case 4: case 16:
|
||||||
|
if (!quantity || quantity > (function == 16 ? 123U : 125U)) return 3;
|
||||||
|
needed = quantity;
|
||||||
|
if (function == 16 && bytes != quantity*2U) return 3;
|
||||||
|
break;
|
||||||
|
case 5: return (quantity == 0 || quantity == 0xff00U) ? 0 : 3;
|
||||||
|
case 6: return 0;
|
||||||
|
default: return 1;
|
||||||
|
}
|
||||||
|
return needed <= capacity ? 0 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t mb_data_transfer(uint8_t function, uint16_t quantity, uint16_t bytes,
|
||||||
|
uint16_t *bank, uint16_t offset, uint16_t *data, size_t capacity, uint16_t *response_bytes)
|
||||||
|
{
|
||||||
|
uint16_t i;
|
||||||
|
uint8_t status = mb_data_validate(function, quantity, bytes, capacity);
|
||||||
|
if (status) return status;
|
||||||
|
if (bank == NULL || data == NULL || response_bytes == NULL || offset > 15U) return 3;
|
||||||
|
if (function == 1) {
|
||||||
|
for (i = 0; i < (quantity+15U)/16U; ++i) data[i] = 0;
|
||||||
|
}
|
||||||
|
if (function == 1 || function == 15) {
|
||||||
|
for (i = 0; i < quantity; ++i) {
|
||||||
|
uint16_t position = (uint16_t)(i+offset);
|
||||||
|
uint16_t mask = (uint16_t)(1U << (position%16U));
|
||||||
|
uint16_t wire_mask = (uint16_t)(1U << ((i%16U)^8U));
|
||||||
|
if (function == 1) {
|
||||||
|
if (bank[position/16U] & mask) data[i/16U] |= wire_mask;
|
||||||
|
} else if (data[i/16U] & wire_mask) bank[position/16U] |= mask;
|
||||||
|
else bank[position/16U] &= (uint16_t)~mask;
|
||||||
|
}
|
||||||
|
if (function == 1) *response_bytes = (uint16_t)((quantity+7U)/8U);
|
||||||
|
} else if (function == 5) {
|
||||||
|
if (quantity) *bank |= (uint16_t)(1U << offset);
|
||||||
|
else *bank &= (uint16_t)~(1U << offset);
|
||||||
|
} else if (function == 6) *bank = quantity;
|
||||||
|
else {
|
||||||
|
for (i = 0; i < quantity; ++i) {
|
||||||
|
if (function == 16) bank[i] = data[i];
|
||||||
|
else data[i] = bank[i];
|
||||||
|
}
|
||||||
|
if (function != 16) *response_bytes = (uint16_t)(quantity*2U);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
29
c/modbus/tests/test_modbus_data.c
Normal file
29
c/modbus/tests/test_modbus_data.c
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#include "modbus_data.h"
|
||||||
|
#include <assert.h>
|
||||||
|
#include <string.h>
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
uint16_t bank[4]={0xa5a5,0x5a5a,0x1234,0xbeef}, original[4], data[130], out;
|
||||||
|
unsigned offset, quantity, bit;
|
||||||
|
assert(mb_data_transfer(3,4,0,bank,0,data,130,&out)==0 && out==8 && data[3]==0xbeef);
|
||||||
|
assert(mb_data_transfer(16,4,7,bank,0,data,130,&out)==3);
|
||||||
|
assert(mb_data_validate(1,0,0,130)==3);
|
||||||
|
assert(mb_data_validate(3,126,0,130)==3);
|
||||||
|
assert(mb_data_validate(16,124,248,130)==3);
|
||||||
|
assert(mb_data_validate(15,17,3,1)==3);
|
||||||
|
assert(mb_data_validate(5,1,0,0)==3);
|
||||||
|
assert(mb_data_validate(2,1,0,130)==1);
|
||||||
|
for(offset=0;offset<16;++offset) for(quantity=1;quantity<=32;++quantity) {
|
||||||
|
bank[0]=0xa5a5; bank[1]=0x5a5a; bank[2]=0x1234; bank[3]=0xbeef;
|
||||||
|
memcpy(original,bank,sizeof(bank));
|
||||||
|
memset(data,0xcc,sizeof(data));
|
||||||
|
assert(mb_data_transfer(1,(uint16_t)quantity,0,bank,(uint16_t)offset,data,130,&out)==0);
|
||||||
|
for(bit=0;bit<quantity;++bit)
|
||||||
|
assert(((data[bit/16]>>((bit%16)^8))&1)==((bank[(bit+offset)/16]>>((bit+offset)%16))&1));
|
||||||
|
for(bit=0;bit<quantity;++bit) bank[(bit+offset)/16]^=(uint16_t)(1U<<((bit+offset)%16));
|
||||||
|
assert(mb_data_transfer(15,(uint16_t)quantity,out,bank,(uint16_t)offset,data,130,&out)==0);
|
||||||
|
assert(memcmp(bank,original,sizeof(bank))==0);
|
||||||
|
assert(data[(quantity+15)/16]==0xcccc); /* no write past response */
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
10
c/sd-file-browser/CMakeLists.txt
Normal file
10
c/sd-file-browser/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(sd_file_browser C)
|
||||||
|
add_library(sd_file_browser STATIC Src/sd_file_browser.c)
|
||||||
|
target_include_directories(sd_file_browser PUBLIC Inc)
|
||||||
|
set_target_properties(sd_file_browser PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_executable(test_sd_file_browser Tests/test_sd_file_browser.c)
|
||||||
|
target_link_libraries(test_sd_file_browser PRIVATE sd_file_browser)
|
||||||
|
add_test(NAME test_sd_file_browser COMMAND test_sd_file_browser)
|
||||||
82
c/sd-file-browser/Inc/sd_file_browser.h
Normal file
82
c/sd-file-browser/Inc/sd_file_browser.h
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#ifndef SD_FILE_BROWSER_H
|
||||||
|
#define SD_FILE_BROWSER_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* Ограничения одинаковы для portable core, Modbus mailbox и GUI. */
|
||||||
|
#define SD_FILE_BROWSER_MAX_PATH 96U
|
||||||
|
#define SD_FILE_BROWSER_MAX_NAME 48U
|
||||||
|
#define SD_FILE_BROWSER_MAX_PAGE_ITEMS 3U
|
||||||
|
#define SD_FILE_BROWSER_MAX_CHUNK_BYTES 64U
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
/* Успех означает полностью сформированную страницу, а не только open. */
|
||||||
|
SD_FILE_BROWSER_OK = 0,
|
||||||
|
/* BUSY оставляет retry политике приложения и ничего не пишет на носитель. */
|
||||||
|
SD_FILE_BROWSER_BUSY,
|
||||||
|
SD_FILE_BROWSER_NOT_READY,
|
||||||
|
SD_FILE_BROWSER_IO_ERROR,
|
||||||
|
SD_FILE_BROWSER_INVALID_PATH,
|
||||||
|
SD_FILE_BROWSER_INVALID_CURSOR,
|
||||||
|
SD_FILE_BROWSER_NAME_TOO_LONG,
|
||||||
|
SD_FILE_BROWSER_FILE_CHANGED,
|
||||||
|
SD_FILE_BROWSER_NOT_LOG_FILE
|
||||||
|
} SdFileBrowserStatus;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
/* Имя всегда завершается нулём и не содержит родительского пути. */
|
||||||
|
char name[SD_FILE_BROWSER_MAX_NAME + 1U];
|
||||||
|
uint32_t size;
|
||||||
|
uint16_t modified_date;
|
||||||
|
uint16_t modified_time;
|
||||||
|
uint8_t is_directory;
|
||||||
|
} SdFileBrowserEntry;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
/* Ответ имеет фиксированную RAM-ёмкость и никогда не выделяет heap. */
|
||||||
|
SdFileBrowserEntry items[SD_FILE_BROWSER_MAX_PAGE_ITEMS];
|
||||||
|
uint16_t next_offset;
|
||||||
|
uint8_t count;
|
||||||
|
uint8_t has_more;
|
||||||
|
} SdFileBrowserPage;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
/* Metadata повторяется в каждом chunk и защищает bridge от склейки разных версий файла. */
|
||||||
|
uint32_t total_size;
|
||||||
|
uint32_t offset;
|
||||||
|
uint16_t modified_date;
|
||||||
|
uint16_t modified_time;
|
||||||
|
uint8_t length;
|
||||||
|
uint8_t end_of_file;
|
||||||
|
uint8_t data[SD_FILE_BROWSER_MAX_CHUNK_BYTES];
|
||||||
|
} SdFileBrowserChunk;
|
||||||
|
|
||||||
|
typedef SdFileBrowserStatus (*SdFileBrowserListPageFn)(
|
||||||
|
void *context, const char *path, uint16_t offset, uint8_t limit,
|
||||||
|
SdFileBrowserPage *page);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
/* Context принадлежит платформе; portable core его не освобождает. */
|
||||||
|
void *context;
|
||||||
|
SdFileBrowserListPageFn list_page;
|
||||||
|
} SdFileBrowser;
|
||||||
|
|
||||||
|
/* Принимается только логический путь от корня карты: "/" или "/dir/file".
|
||||||
|
* Проверка не исправляет вход: неканонические строки отклоняются fail-closed. */
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path);
|
||||||
|
|
||||||
|
/* Разрешает только legacy TEMPLOG.MD или суточный temperature_*.md внутри
|
||||||
|
* штатного корня niceOne/mounth; служебные JSON/прошивки сюда не попадают. */
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path);
|
||||||
|
|
||||||
|
/* Разрешает только сырой образ .bin/.fw внутри каталога niceOne/firmware.
|
||||||
|
* Каталог отделён от журналов, чтобы прошивка и логи не смешивались. */
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path);
|
||||||
|
|
||||||
|
/* Вызов делегирует порту ровно одну ограниченную страницу каталога.
|
||||||
|
* Output предварительно очищается, поэтому ошибка не публикует старые имена. */
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ListPage(
|
||||||
|
const SdFileBrowser *browser, const char *path, uint16_t offset,
|
||||||
|
uint8_t limit, SdFileBrowserPage *page);
|
||||||
|
|
||||||
|
#endif /* SD_FILE_BROWSER_H */
|
||||||
30
c/sd-file-browser/README.md
Normal file
30
c/sd-file-browser/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# SD File Browser
|
||||||
|
|
||||||
|
Небольшое переносимое read-only ядро для постраничного просмотра каталогов.
|
||||||
|
Оно не зависит от STM32 HAL, FatFs, Modbus или GUI. Платформа передаёт callback
|
||||||
|
`list_page`; ядро проверяет путь, предел страницы и очищает ответ перед вызовом.
|
||||||
|
|
||||||
|
Логический путь всегда начинается с `/`. Запрещены `..`, `.`, пустые части,
|
||||||
|
обратная косая черта, двоеточие, управляющие символы, путь от ОС и компоненты
|
||||||
|
длиннее 48 ASCII-байт. Максимум — три элемента на страницу и 95 байт пути.
|
||||||
|
|
||||||
|
FatFs-порт сканирует позднюю страницу пошагово: один вызов service выполняет не
|
||||||
|
более шестнадцати `readdir`. Поэтому любой `uint16` offset доступен без длинного
|
||||||
|
блокирующего вызова main loop. Между страницами `DIR` закрыт. Если логгер или
|
||||||
|
backup получает работу во время сканирования, приложение отменяет viewer,
|
||||||
|
освобождает `DIR` и возвращает `logger busy`; writer всегда имеет приоритет.
|
||||||
|
|
||||||
|
Для переноса реализуйте `SdFileBrowserListPageFn`, обеспечьте read-only открытие
|
||||||
|
каталога, конечные тайм-ауты носителя и закройте cursor при извлечении карты.
|
||||||
|
|
||||||
|
Порт также предоставляет `SdFileBrowserFatFs_ReadLogChunk`: он разрешает только
|
||||||
|
legacy `/TEMPLOG.MD` и `temperature_*.md` внутри `/niceOne/mounth`, читает не
|
||||||
|
более 64 байт и закрывает `FIL` до возврата. Первый ответ публикует размер и FAT
|
||||||
|
date/time; каждый следующий запрос обязан повторить их. Замена, обрезание или
|
||||||
|
изменение файла возвращает `SD_FILE_BROWSER_FILE_CHANGED`. В API нет функций
|
||||||
|
записи, удаления, rename, выполнения Markdown или чтения служебных каталогов.
|
||||||
|
|
||||||
|
|
||||||
|
## Shared source
|
||||||
|
|
||||||
|
Canonical source: `templates/c/sd-file-browser`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||||
177
c/sd-file-browser/Src/sd_file_browser.c
Normal file
177
c/sd-file-browser/Src/sd_file_browser.c
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#include "sd_file_browser.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static uint8_t has_temperature_name(const char *name)
|
||||||
|
{
|
||||||
|
const size_t length = strlen(name);
|
||||||
|
const char prefix[] = "temperature_";
|
||||||
|
|
||||||
|
return (uint8_t)((length > (sizeof(prefix) - 1U + 3U)) &&
|
||||||
|
(strncmp(name, prefix, sizeof(prefix) - 1U) == 0) &&
|
||||||
|
(strcmp(name + length - 3U, ".md") == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сравнение расширения без учёта регистра для короткого суффикса ASCII. */
|
||||||
|
static uint8_t has_suffix_ci(const char *name, const char *suffix)
|
||||||
|
{
|
||||||
|
const size_t name_length = strlen(name);
|
||||||
|
const size_t suffix_length = strlen(suffix);
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (name_length <= suffix_length) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < suffix_length; ++index) {
|
||||||
|
char left = name[name_length - suffix_length + index];
|
||||||
|
char right = suffix[index];
|
||||||
|
|
||||||
|
if ((left >= 'A') && (left <= 'Z')) {
|
||||||
|
left = (char)(left - 'A' + 'a');
|
||||||
|
}
|
||||||
|
if ((right >= 'A') && (right <= 'Z')) {
|
||||||
|
right = (char)(right - 'A' + 'a');
|
||||||
|
}
|
||||||
|
if (left != right) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сырой образ прошивки: .bin или .fw. Intel HEX здесь не принимается — его
|
||||||
|
* адреса лежат внутри файла и требуют разбора, недоступного на этом этапе. */
|
||||||
|
static uint8_t has_firmware_name(const char *name)
|
||||||
|
{
|
||||||
|
return (uint8_t)((has_suffix_ci(name, ".bin") != 0U) ||
|
||||||
|
(has_suffix_ci(name, ".fw") != 0U));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Разрешены печатные ASCII-имена без разделителей ОС и управляющих байтов. */
|
||||||
|
static uint8_t path_character_is_safe(char character)
|
||||||
|
{
|
||||||
|
unsigned char value = (unsigned char)character;
|
||||||
|
return (uint8_t)((value >= 0x20U) && (value <= 0x7EU) &&
|
||||||
|
(character != '\\') && (character != ':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path)
|
||||||
|
{
|
||||||
|
size_t length;
|
||||||
|
size_t component_start;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
/* strlen вызывается только после явной проверки внешнего указателя. */
|
||||||
|
if (path == NULL) {
|
||||||
|
return SD_FILE_BROWSER_INVALID_PATH;
|
||||||
|
}
|
||||||
|
length = strlen(path);
|
||||||
|
if ((length == 0U) || (length >= SD_FILE_BROWSER_MAX_PATH) ||
|
||||||
|
(path[0] != '/')) {
|
||||||
|
/* Абсолютный путь накопителя и усечённый путь fail-closed отклоняются. */
|
||||||
|
return SD_FILE_BROWSER_INVALID_PATH;
|
||||||
|
}
|
||||||
|
if (length == 1U) {
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
/* Компоненты анализируются за один проход, включая завершающий ноль. */
|
||||||
|
component_start = 1U;
|
||||||
|
for (index = 1U; index <= length; ++index) {
|
||||||
|
char character = path[index];
|
||||||
|
if ((character == '/') || (character == '\0')) {
|
||||||
|
size_t component_length = index - component_start;
|
||||||
|
if ((component_length == 0U) ||
|
||||||
|
(component_length > SD_FILE_BROWSER_MAX_NAME) ||
|
||||||
|
((component_length == 1U) &&
|
||||||
|
(path[component_start] == '.')) ||
|
||||||
|
((component_length == 2U) &&
|
||||||
|
(path[component_start] == '.') &&
|
||||||
|
(path[component_start + 1U] == '.'))) {
|
||||||
|
/* Пустые, точечные и слишком длинные компоненты не нормализуются. */
|
||||||
|
return SD_FILE_BROWSER_INVALID_PATH;
|
||||||
|
}
|
||||||
|
component_start = index + 1U;
|
||||||
|
} else if (path_character_is_safe(character) == 0U) {
|
||||||
|
/* Кодировка текущего FatFs-порта ASCII; байты UTF-8 не угадываются. */
|
||||||
|
return SD_FILE_BROWSER_INVALID_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path)
|
||||||
|
{
|
||||||
|
const char root[] = "/niceOne/mounth/";
|
||||||
|
const char *relative;
|
||||||
|
const char *separator;
|
||||||
|
const char *name;
|
||||||
|
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
|
||||||
|
|
||||||
|
if (status != SD_FILE_BROWSER_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
/* TEMPLOG.MD сохранён только для старых карт, где журнал лежал в корне. */
|
||||||
|
if (strcmp(path, "/TEMPLOG.MD") == 0) {
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
relative = path + sizeof(root) - 1U;
|
||||||
|
separator = strchr(relative, '/');
|
||||||
|
/* Штатное дерево содержит ровно месяц и имя; обход дополнительных уровней запрещён. */
|
||||||
|
if ((separator == NULL) || (separator == relative) ||
|
||||||
|
(strchr(separator + 1U, '/') != NULL)) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
name = strrchr(path, '/');
|
||||||
|
if ((name == NULL) || (has_temperature_name(name + 1U) == 0U)) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path)
|
||||||
|
{
|
||||||
|
const char root[] = "/niceOne/firmware/";
|
||||||
|
const char *name;
|
||||||
|
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
|
||||||
|
|
||||||
|
if (status != SD_FILE_BROWSER_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
/* Образы лежат ровно в одном каталоге; вложенные уровни не обходятся. */
|
||||||
|
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
name = path + sizeof(root) - 1U;
|
||||||
|
if ((name[0] == '\0') || (strchr(name, '/') != NULL)) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
if (has_firmware_name(name) == 0U) {
|
||||||
|
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||||
|
}
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
SdFileBrowserStatus SdFileBrowser_ListPage(
|
||||||
|
const SdFileBrowser *browser, const char *path, uint16_t offset,
|
||||||
|
uint8_t limit, SdFileBrowserPage *page)
|
||||||
|
{
|
||||||
|
SdFileBrowserStatus status;
|
||||||
|
|
||||||
|
/* limit проверяется до очистки, чтобы invalid call не трогал чужую память. */
|
||||||
|
if ((browser == NULL) || (browser->list_page == NULL) || (page == NULL) ||
|
||||||
|
(limit == 0U) || (limit > SD_FILE_BROWSER_MAX_PAGE_ITEMS)) {
|
||||||
|
return SD_FILE_BROWSER_INVALID_CURSOR;
|
||||||
|
}
|
||||||
|
status = SdFileBrowser_ValidatePath(path);
|
||||||
|
if (status != SD_FILE_BROWSER_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
/* Нулевой tail исключает утечку содержимого предыдущего Modbus-ответа. */
|
||||||
|
memset(page, 0, sizeof(*page));
|
||||||
|
/* Portable слой не знает FatFs/HAL и передаёт только проверенный запрос. */
|
||||||
|
return browser->list_page(browser->context, path, offset, limit, page);
|
||||||
|
}
|
||||||
67
c/sd-file-browser/Tests/test_sd_file_browser.c
Normal file
67
c/sd-file-browser/Tests/test_sd_file_browser.c
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#include "sd_file_browser.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static unsigned checks;
|
||||||
|
static unsigned failures;
|
||||||
|
static unsigned calls;
|
||||||
|
|
||||||
|
#define CHECK(condition) do { ++checks; if (!(condition)) ++failures; } while (0)
|
||||||
|
|
||||||
|
/* Mock подтверждает, что core передаёт только проверенный bounded запрос. */
|
||||||
|
static SdFileBrowserStatus mock_list(
|
||||||
|
void *context, const char *path, uint16_t offset, uint8_t limit,
|
||||||
|
SdFileBrowserPage *page)
|
||||||
|
{
|
||||||
|
(void)context;
|
||||||
|
++calls;
|
||||||
|
CHECK(strcmp(path, "/niceOne") == 0);
|
||||||
|
CHECK(offset == 3U);
|
||||||
|
CHECK(limit == 2U);
|
||||||
|
memcpy(page->items[0].name, "July", sizeof("July"));
|
||||||
|
page->items[0].is_directory = 1U;
|
||||||
|
page->count = 1U;
|
||||||
|
page->next_offset = 4U;
|
||||||
|
return SD_FILE_BROWSER_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
static const char *const invalid[] = {
|
||||||
|
"", "niceOne", "/../secret", "/./logs", "/a//b", "/a\\b",
|
||||||
|
"/a:b", "/trailing/"
|
||||||
|
};
|
||||||
|
SdFileBrowser browser = { NULL, mock_list };
|
||||||
|
SdFileBrowserPage page;
|
||||||
|
unsigned index;
|
||||||
|
|
||||||
|
CHECK(SdFileBrowser_ValidatePath("/") == SD_FILE_BROWSER_OK);
|
||||||
|
CHECK(SdFileBrowser_ValidateLogFilePath("/TEMPLOG.MD") == SD_FILE_BROWSER_OK);
|
||||||
|
CHECK(SdFileBrowser_ValidateLogFilePath(
|
||||||
|
"/niceOne/mounth/2026-07_July/temperature_2026-07-18_10-00-00.md") ==
|
||||||
|
SD_FILE_BROWSER_OK);
|
||||||
|
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/settings/private.json") ==
|
||||||
|
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||||
|
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/fw.bin") ==
|
||||||
|
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||||
|
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/temperature_bad.md") ==
|
||||||
|
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||||
|
CHECK(SdFileBrowser_ValidatePath("/niceOne/2026-07_July") ==
|
||||||
|
SD_FILE_BROWSER_OK);
|
||||||
|
for (index = 0U; index < sizeof(invalid) / sizeof(invalid[0]); ++index) {
|
||||||
|
CHECK(SdFileBrowser_ValidatePath(invalid[index]) ==
|
||||||
|
SD_FILE_BROWSER_INVALID_PATH);
|
||||||
|
}
|
||||||
|
memset(&page, 0xA5, sizeof(page));
|
||||||
|
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 3U, 2U, &page) ==
|
||||||
|
SD_FILE_BROWSER_OK);
|
||||||
|
CHECK(calls == 1U);
|
||||||
|
CHECK(page.count == 1U);
|
||||||
|
CHECK(strcmp(page.items[0].name, "July") == 0);
|
||||||
|
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 0U, 4U, &page) ==
|
||||||
|
SD_FILE_BROWSER_INVALID_CURSOR);
|
||||||
|
CHECK(calls == 1U);
|
||||||
|
(void)printf("SD browser core: %u checks, %u failures\n", checks, failures);
|
||||||
|
return failures == 0U ? 0 : 1;
|
||||||
|
}
|
||||||
10
c/settings-backup/CMakeLists.txt
Normal file
10
c/settings-backup/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(settings_backup C)
|
||||||
|
add_library(settings_backup STATIC Src/settings_backup.c)
|
||||||
|
target_include_directories(settings_backup PUBLIC Inc)
|
||||||
|
set_target_properties(settings_backup PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_executable(test_settings_backup Tests/test_settings_backup.c)
|
||||||
|
target_link_libraries(test_settings_backup PRIVATE settings_backup)
|
||||||
|
add_test(NAME test_settings_backup COMMAND test_settings_backup)
|
||||||
125
c/settings-backup/Inc/settings_backup.h
Normal file
125
c/settings-backup/Inc/settings_backup.h
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#ifndef SETTINGS_BACKUP_H
|
||||||
|
#define SETTINGS_BACKUP_H
|
||||||
|
|
||||||
|
/* Переносимое ядро резервирования не включает HAL, FatFs, AppStorage и Modbus. */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define SETTINGS_BACKUP_SCHEMA_VERSION 1U
|
||||||
|
#define SETTINGS_BACKUP_MAX_ROOMS 32U
|
||||||
|
#define SETTINGS_BACKUP_MAX_SENSORS 32U
|
||||||
|
#define SETTINGS_BACKUP_MAX_PATH 160U
|
||||||
|
|
||||||
|
/* Календарь передаётся приложением вместе с признаком доверия к RTC. */
|
||||||
|
typedef struct {
|
||||||
|
uint16_t year;
|
||||||
|
uint8_t month;
|
||||||
|
uint8_t date;
|
||||||
|
uint8_t hours;
|
||||||
|
uint8_t minutes;
|
||||||
|
uint8_t seconds;
|
||||||
|
uint8_t valid;
|
||||||
|
} SettingsBackupDateTime;
|
||||||
|
|
||||||
|
/* Одна комната содержит только документированные инженерные параметры. */
|
||||||
|
typedef struct {
|
||||||
|
uint16_t setpoint_x10;
|
||||||
|
uint8_t hysteresis_x10;
|
||||||
|
uint8_t calibration_start_pct;
|
||||||
|
uint16_t full_open_time_100ms;
|
||||||
|
uint8_t target_position_pct;
|
||||||
|
uint8_t confirmed_position_pct;
|
||||||
|
} SettingsBackupRoom;
|
||||||
|
|
||||||
|
/* В JSON всегда попадает полный ROM, включая family и Dallas CRC. */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t rom[8];
|
||||||
|
uint8_t room;
|
||||||
|
} SettingsBackupSensor;
|
||||||
|
|
||||||
|
/* Снимок создаётся адаптером только из committed-записи AppStorage. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t revision;
|
||||||
|
uint32_t content_crc32;
|
||||||
|
uint8_t requested_backend;
|
||||||
|
uint8_t active_backend;
|
||||||
|
uint8_t room_count;
|
||||||
|
uint8_t sensor_count;
|
||||||
|
SettingsBackupDateTime rtc;
|
||||||
|
SettingsBackupRoom rooms[SETTINGS_BACKUP_MAX_ROOMS];
|
||||||
|
SettingsBackupSensor sensors[SETTINGS_BACKUP_MAX_SENSORS];
|
||||||
|
} SettingsBackupSnapshot;
|
||||||
|
|
||||||
|
/* Короткий набор результатов позволяет порту отобразить SD absent/full/I/O. */
|
||||||
|
typedef enum {
|
||||||
|
SETTINGS_BACKUP_PORT_OK = 0,
|
||||||
|
SETTINGS_BACKUP_PORT_NOT_READY,
|
||||||
|
SETTINGS_BACKUP_PORT_NO_SPACE,
|
||||||
|
SETTINGS_BACKUP_PORT_IO_ERROR
|
||||||
|
} SettingsBackupPortResult;
|
||||||
|
|
||||||
|
/* Наблюдаемое состояние не скрывает retry и отказ носителя от Modbus/GUI. */
|
||||||
|
typedef enum {
|
||||||
|
SETTINGS_BACKUP_IDLE = 0,
|
||||||
|
SETTINGS_BACKUP_PENDING,
|
||||||
|
SETTINGS_BACKUP_LAST_OK,
|
||||||
|
SETTINGS_BACKUP_LAST_NOT_READY,
|
||||||
|
SETTINGS_BACKUP_LAST_NO_SPACE,
|
||||||
|
SETTINGS_BACKUP_LAST_IO_ERROR,
|
||||||
|
SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT
|
||||||
|
} SettingsBackupStatus;
|
||||||
|
|
||||||
|
/* Все операции платформы ограничены одной файловой транзакцией за вызов. */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t (*tick_ms)(void *context);
|
||||||
|
SettingsBackupPortResult (*mount)(void *context);
|
||||||
|
void (*unmount)(void *context);
|
||||||
|
SettingsBackupPortResult (*find_identical)(void *context,
|
||||||
|
uint32_t revision, uint32_t crc32, uint8_t *found);
|
||||||
|
SettingsBackupPortResult (*resolve_paths)(void *context,
|
||||||
|
const SettingsBackupSnapshot *snapshot, char *final_path,
|
||||||
|
uint32_t final_capacity, char *temporary_path,
|
||||||
|
uint32_t temporary_capacity);
|
||||||
|
SettingsBackupPortResult (*atomic_write)(void *context,
|
||||||
|
const char *temporary_path, const char *final_path,
|
||||||
|
const char *utf8_json, uint32_t length);
|
||||||
|
SettingsBackupPortResult (*apply_retention)(void *context,
|
||||||
|
uint16_t maximum_copies);
|
||||||
|
} SettingsBackupPort;
|
||||||
|
|
||||||
|
/* Контекст хранит pending-снимок: вызывающий может освобождать свой буфер. */
|
||||||
|
typedef struct {
|
||||||
|
SettingsBackupPort port;
|
||||||
|
void *port_context;
|
||||||
|
SettingsBackupSnapshot pending_snapshot;
|
||||||
|
char *json_buffer;
|
||||||
|
uint32_t json_capacity;
|
||||||
|
char final_path[SETTINGS_BACKUP_MAX_PATH];
|
||||||
|
char temporary_path[SETTINGS_BACKUP_MAX_PATH];
|
||||||
|
uint32_t next_retry_ms;
|
||||||
|
uint16_t maximum_copies;
|
||||||
|
uint8_t phase;
|
||||||
|
uint8_t retry_count;
|
||||||
|
uint8_t pending;
|
||||||
|
SettingsBackupStatus status;
|
||||||
|
} SettingsBackup;
|
||||||
|
|
||||||
|
/* Инициализация не обращается к SD и потому безопасна во время старта МК. */
|
||||||
|
uint8_t SettingsBackup_Init(SettingsBackup *instance,
|
||||||
|
const SettingsBackupPort *port, void *port_context,
|
||||||
|
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies);
|
||||||
|
|
||||||
|
/* Новая committed-ревизия заменяет только ещё не записанный pending-снимок. */
|
||||||
|
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
|
||||||
|
const SettingsBackupSnapshot *snapshot);
|
||||||
|
|
||||||
|
/* Service делает не более одного mount/scan/write/retention шага за проход. */
|
||||||
|
void SettingsBackup_Service(SettingsBackup *instance);
|
||||||
|
|
||||||
|
/* Форматирование и CRC открыты для host-тестов без файловой системы. */
|
||||||
|
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot);
|
||||||
|
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
|
||||||
|
char *output, uint32_t capacity);
|
||||||
|
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance);
|
||||||
|
|
||||||
|
#endif /* SETTINGS_BACKUP_H */
|
||||||
11
c/settings-backup/Inc/settings_backup_config.h
Normal file
11
c/settings-backup/Inc/settings_backup_config.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#ifndef SETTINGS_BACKUP_CONFIG_H
|
||||||
|
#define SETTINGS_BACKUP_CONFIG_H
|
||||||
|
|
||||||
|
/* Политика продукта отделена от переносимого автомата и FatFs-порта. */
|
||||||
|
#define SETTINGS_BACKUP_JSON_BUFFER_SIZE 12288U
|
||||||
|
#define SETTINGS_BACKUP_MAXIMUM_COPIES 24U
|
||||||
|
#define SETTINGS_BACKUP_RETRY_MS 30000UL
|
||||||
|
#define SETTINGS_BACKUP_RETRY_LIMIT 3U
|
||||||
|
#define SETTINGS_BACKUP_COOLDOWN_MS 300000UL
|
||||||
|
|
||||||
|
#endif /* SETTINGS_BACKUP_CONFIG_H */
|
||||||
89
c/settings-backup/README.md
Normal file
89
c/settings-backup/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# SettingsBackup
|
||||||
|
|
||||||
|
`SettingsBackup` сохраняет только подтверждённые конфигурации в JSON на
|
||||||
|
SD-карту. Ядро `Src/settings_backup.c` не включает STM32 HAL, FatFs, Modbus,
|
||||||
|
RTC или `AppStorage`: календарь и нормализованный снимок передаёт приложение,
|
||||||
|
а файловые операции задаются callbacks структуры `SettingsBackupPort`.
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
При достоверном RTC используется путь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
niceOne/settings/YYYY-MM_EnglishMonth/settings_YYYY-MM-DD_HH-MM-SS.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Названия месяцев фиксированы на английском и состоят только из FAT-безопасных
|
||||||
|
ASCII-символов. При недостоверном RTC файл помещается в `invalid_rtc` и получает
|
||||||
|
имя `settings_invalid_rtc_rev_N.json`; это имя не выдаётся за календарную дату.
|
||||||
|
Коллизии разрешаются суффиксами `_1`, `_2` и далее. Проверяются одновременно
|
||||||
|
итоговое имя и `.tmp`, поэтому незавершённая транзакция после перезапуска не
|
||||||
|
перезаписывается.
|
||||||
|
|
||||||
|
## Schema v1
|
||||||
|
|
||||||
|
Корневые поля: `schema`, `schema_version`, `snapshot_revision`,
|
||||||
|
`snapshot_crc32`, `rtc`, `storage`, `units`, `rooms`, `sensors` и
|
||||||
|
`restore_policy`. Все 32 комнаты содержат уставку и гистерезис в десятых долях
|
||||||
|
градуса, старт калибровки и время полного открытия в 100 мс, целевое и
|
||||||
|
подтверждённое положение в процентах. Датчик содержит полный 16-символьный HEX
|
||||||
|
ROM: family, шесть serial-байтов и Dallas CRC, а также номер комнаты.
|
||||||
|
|
||||||
|
В `RoomSettings` v1 во Flash фиксируется достигнутый безопасный checkpoint.
|
||||||
|
Поэтому `target_position_pct` резервной копии равен
|
||||||
|
`confirmed_position_pct`; ещё не подтверждённая цель Modbus/GUI принципиально
|
||||||
|
не попадает в файл.
|
||||||
|
|
||||||
|
## Когда создаётся копия
|
||||||
|
|
||||||
|
`settings_backup_app.c` сравнивает номер committed-записи. Только при его
|
||||||
|
изменении он физически перечитывает 512 байт через
|
||||||
|
`AppStorage_GetConfirmedSnapshot`, где кольцевой журнал повторно проверяет
|
||||||
|
version, длину, commit marker и CRC. На старте та же ревизия ищется во всех
|
||||||
|
месячных каталогах по content CRC; revision остаётся в файле для аудита, но
|
||||||
|
повторный commit тех же настроек не создаёт дубль. Десятиминутный температурный
|
||||||
|
журнал не является trigger backup.
|
||||||
|
|
||||||
|
Запись атомарна: уникальный `.tmp` создаётся с `FA_CREATE_NEW`, полностью
|
||||||
|
записывается, синхронизируется, закрывается и только затем переименовывается в
|
||||||
|
`.json`. Старые JSON не удаляются при ошибке open/write/sync/close/rename.
|
||||||
|
Ошибки absent/full/removal переводят автомат в диагностическое состояние и
|
||||||
|
назначают ограниченный retry без задержек и busy wait в main loop.
|
||||||
|
|
||||||
|
Retention задаётся `SETTINGS_BACKUP_MAXIMUM_COPIES` (по умолчанию 24). При
|
||||||
|
превышении лимита сначала удаляется самый старый подтверждённый duplicate hash,
|
||||||
|
затем самый старый уникальный файл; последняя корректная копия не удаляется.
|
||||||
|
|
||||||
|
## Восстановление
|
||||||
|
|
||||||
|
Автоматического восстановления нет. Наличие JSON никогда не вызывает запись в
|
||||||
|
`AppStorage`. Будущая отдельная команда должна прочитать выбранный файл только
|
||||||
|
для проверки, подтвердить schema/version, UTF-8/JSON, диапазоны 32 комнат,
|
||||||
|
полные ROM и Dallas CRC, content CRC, совместимость backend и получить явное
|
||||||
|
подтверждение оператора. Лишь после этого отдельная реализация может собрать
|
||||||
|
новый AppStorage image и выполнить обычный committed write с обратным чтением.
|
||||||
|
|
||||||
|
## Перенос на другую платформу
|
||||||
|
|
||||||
|
Скопируйте `Inc`, `Src` и конфигурацию. Реализуйте callbacks mount, global
|
||||||
|
dedupe scan, collision-safe path resolve, atomic write и retention. Каждая
|
||||||
|
callback-функция должна иметь ограниченный timeout и не выполняться из ISR.
|
||||||
|
FatFs-порт данного проекта находится в `Port/FatFs`; это единственный модуль
|
||||||
|
библиотеки, который включает `ff.h` и использует общий том SD logger.
|
||||||
|
|
||||||
|
Модельные тесты запускаются так:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m unittest discover -s Libraries/SettingsBackup/Tests -p "test_*.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Shared source
|
||||||
|
|
||||||
|
Canonical source: `templates/c/settings-backup`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||||
362
c/settings-backup/Src/settings_backup.c
Normal file
362
c/settings-backup/Src/settings_backup.c
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
#include "../Inc/settings_backup.h"
|
||||||
|
#include "../Inc/settings_backup_config.h"
|
||||||
|
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Архитектура переносимого автомата
|
||||||
|
* ---------------------------------
|
||||||
|
* 1. Приложение передаёт только подтверждённый нормализованный snapshot.
|
||||||
|
* 2. Notify копирует его, поэтому lifetime исходного указателя не важен.
|
||||||
|
* 3. Canonical CRC охватывает только пользовательскую конфигурацию.
|
||||||
|
* 4. RTC и backend остаются metadata и не создают ложный content duplicate.
|
||||||
|
* 5. Mount, поиск, resolve, write и retention разнесены по main-loop проходам.
|
||||||
|
* 6. Ни один переход не содержит delay, busy wait или прямого доступа к HAL.
|
||||||
|
* 7. Ошибка файловой callback закрывает логическую попытку и ставит retry.
|
||||||
|
* 8. Первые три retry короткие; дальнейшие выполняются после cooldown.
|
||||||
|
* 9. Новая committed revision немедленно заменяет устаревший pending snapshot.
|
||||||
|
* 10. JSON строится перед write целиком и не передаётся порту усечённым.
|
||||||
|
* 11. Все числовые единицы зафиксированы отдельным объектом schema.
|
||||||
|
* 12. ROM выводится как полный uppercase HEX без locale и разделителей.
|
||||||
|
* 13. Никакие строки проекта не попадают в JSON, поэтому секреты исключены.
|
||||||
|
* 14. Автомат никогда не читает JSON и принципиально не восстанавливает Flash.
|
||||||
|
* 15. Диагностический getter не меняет phase и пригоден для Modbus polling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Фазы разделяют потенциально медленные FAT-операции между main-loop проходами. */
|
||||||
|
enum {
|
||||||
|
BACKUP_PHASE_MOUNT = 0,
|
||||||
|
BACKUP_PHASE_DEDUPE,
|
||||||
|
BACKUP_PHASE_RESOLVE,
|
||||||
|
BACKUP_PHASE_WRITE,
|
||||||
|
BACKUP_PHASE_RETENTION
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Добавляет форматированный фрагмент и никогда не оставляет усечённый JSON. */
|
||||||
|
static uint8_t append_text(char *output, uint32_t capacity, uint32_t *used,
|
||||||
|
const char *format, ...)
|
||||||
|
{
|
||||||
|
va_list arguments;
|
||||||
|
int length;
|
||||||
|
|
||||||
|
if ((*used >= capacity) || (format == NULL)) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
va_start(arguments, format);
|
||||||
|
length = vsnprintf(output + *used, capacity - *used, format, arguments);
|
||||||
|
va_end(arguments);
|
||||||
|
if ((length < 0) || ((uint32_t)length >= (capacity - *used))) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
*used += (uint32_t)length;
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRC32 IEEE считается по стабильному бинарному представлению настроек. */
|
||||||
|
static uint32_t crc32_add(uint32_t crc, uint8_t value)
|
||||||
|
{
|
||||||
|
uint8_t bit;
|
||||||
|
|
||||||
|
crc ^= value;
|
||||||
|
for (bit = 0U; bit < 8U; ++bit) {
|
||||||
|
crc = ((crc & 1U) != 0U) ? (crc >> 1U) ^ 0xEDB88320UL : crc >> 1U;
|
||||||
|
}
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Время и backend исключены: hash обозначает именно пользовательские настройки. */
|
||||||
|
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot)
|
||||||
|
{
|
||||||
|
uint32_t crc = 0xFFFFFFFFUL;
|
||||||
|
uint8_t index;
|
||||||
|
uint8_t byte;
|
||||||
|
|
||||||
|
if ((snapshot == NULL) || (snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||||
|
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
crc = crc32_add(crc, snapshot->room_count);
|
||||||
|
crc = crc32_add(crc, snapshot->sensor_count);
|
||||||
|
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||||
|
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||||
|
crc = crc32_add(crc, (uint8_t)room->setpoint_x10);
|
||||||
|
crc = crc32_add(crc, (uint8_t)(room->setpoint_x10 >> 8U));
|
||||||
|
crc = crc32_add(crc, room->hysteresis_x10);
|
||||||
|
crc = crc32_add(crc, room->calibration_start_pct);
|
||||||
|
crc = crc32_add(crc, (uint8_t)room->full_open_time_100ms);
|
||||||
|
crc = crc32_add(crc, (uint8_t)(room->full_open_time_100ms >> 8U));
|
||||||
|
crc = crc32_add(crc, room->target_position_pct);
|
||||||
|
crc = crc32_add(crc, room->confirmed_position_pct);
|
||||||
|
}
|
||||||
|
for (index = 0U; index < snapshot->sensor_count; ++index) {
|
||||||
|
for (byte = 0U; byte < 8U; ++byte) {
|
||||||
|
crc = crc32_add(crc, snapshot->sensors[index].rom[byte]);
|
||||||
|
}
|
||||||
|
crc = crc32_add(crc, snapshot->sensors[index].room);
|
||||||
|
}
|
||||||
|
return ~crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Формирует schema v1: все строки ASCII/UTF-8 и не требуют locale/float. */
|
||||||
|
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
|
||||||
|
char *output, uint32_t capacity)
|
||||||
|
{
|
||||||
|
uint32_t used = 0U;
|
||||||
|
uint8_t index;
|
||||||
|
uint8_t byte;
|
||||||
|
|
||||||
|
if ((snapshot == NULL) || (output == NULL) || (capacity == 0U) ||
|
||||||
|
(snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||||
|
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
output[0] = '\0';
|
||||||
|
if (!append_text(output, capacity, &used,
|
||||||
|
"{\n \"schema\":\"niceOne.settings\",\n \"schema_version\":1,\n"
|
||||||
|
" \"snapshot_revision\":%lu,\n \"snapshot_crc32\":\"%08lX\",\n",
|
||||||
|
(unsigned long)snapshot->revision,
|
||||||
|
(unsigned long)snapshot->content_crc32)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (snapshot->rtc.valid != 0U) {
|
||||||
|
if (!append_text(output, capacity, &used,
|
||||||
|
" \"rtc\":{\"valid\":true,\"datetime\":\"%04u-%02u-%02uT%02u:%02u:%02u\"},\n",
|
||||||
|
snapshot->rtc.year, snapshot->rtc.month, snapshot->rtc.date,
|
||||||
|
snapshot->rtc.hours, snapshot->rtc.minutes, snapshot->rtc.seconds)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else if (!append_text(output, capacity, &used,
|
||||||
|
" \"rtc\":{\"valid\":false,\"datetime\":null},\n")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!append_text(output, capacity, &used,
|
||||||
|
" \"storage\":{\"requested_backend\":\"%s\",\"active_backend\":\"%s\"},\n"
|
||||||
|
" \"units\":{\"temperature\":\"0.1_degC\",\"hysteresis\":\"0.1_degC\","
|
||||||
|
"\"position\":\"percent\",\"full_open_time\":\"100_ms\"},\n \"rooms\":[\n",
|
||||||
|
snapshot->requested_backend ? "external_spi_nor" : "internal_flash",
|
||||||
|
snapshot->active_backend ? "external_spi_nor" : "internal_flash")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||||
|
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||||
|
if (!append_text(output, capacity, &used,
|
||||||
|
" {\"room\":%u,\"setpoint_x10\":%u,\"hysteresis_x10\":%u,"
|
||||||
|
"\"calibration\":{\"start_position_pct\":%u,\"full_open_time_100ms\":%u},"
|
||||||
|
"\"valve\":{\"target_position_pct\":%u,\"confirmed_position_pct\":%u}}%s\n",
|
||||||
|
(unsigned)(index + 1U), room->setpoint_x10, room->hysteresis_x10,
|
||||||
|
room->calibration_start_pct, room->full_open_time_100ms,
|
||||||
|
room->target_position_pct, room->confirmed_position_pct,
|
||||||
|
(index + 1U < snapshot->room_count) ? "," : "")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!append_text(output, capacity, &used, " ],\n \"sensors\":[\n")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < snapshot->sensor_count; ++index) {
|
||||||
|
if (!append_text(output, capacity, &used, " {\"rom\":\"")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (byte = 0U; byte < 8U; ++byte) {
|
||||||
|
if (!append_text(output, capacity, &used, "%02X",
|
||||||
|
snapshot->sensors[index].rom[byte])) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!append_text(output, capacity, &used, "\",\"room\":%u}%s\n",
|
||||||
|
snapshot->sensors[index].room,
|
||||||
|
(index + 1U < snapshot->sensor_count) ? "," : "")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!append_text(output, capacity, &used,
|
||||||
|
" ],\n \"restore_policy\":\"manual_validation_only\"\n}\n")) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return (int)used;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Проверяет инженерные диапазоны до помещения снимка в очередь. */
|
||||||
|
static uint8_t snapshot_is_valid(const SettingsBackupSnapshot *snapshot)
|
||||||
|
{
|
||||||
|
uint8_t index;
|
||||||
|
|
||||||
|
if ((snapshot == NULL) || (snapshot->revision == 0U) ||
|
||||||
|
(snapshot->room_count != SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||||
|
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||||
|
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||||
|
if ((room->setpoint_x10 < 50U) || (room->setpoint_x10 > 400U) ||
|
||||||
|
(room->hysteresis_x10 < 1U) || (room->hysteresis_x10 > 100U) ||
|
||||||
|
(room->calibration_start_pct > 100U) ||
|
||||||
|
(room->target_position_pct > 100U) ||
|
||||||
|
(room->confirmed_position_pct > 100U) ||
|
||||||
|
(room->full_open_time_100ms < 10U) ||
|
||||||
|
(room->full_open_time_100ms > 36000U)) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Инициализация валидирует полный набор callback, нужный атомарному протоколу. */
|
||||||
|
uint8_t SettingsBackup_Init(SettingsBackup *instance,
|
||||||
|
const SettingsBackupPort *port, void *port_context,
|
||||||
|
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies)
|
||||||
|
{
|
||||||
|
if ((instance == NULL) || (port == NULL) || (json_buffer == NULL) ||
|
||||||
|
(json_capacity < 1024U) || (port->tick_ms == NULL) ||
|
||||||
|
(port->mount == NULL) || (port->unmount == NULL) ||
|
||||||
|
(port->find_identical == NULL) || (port->resolve_paths == NULL) ||
|
||||||
|
(port->atomic_write == NULL) || (port->apply_retention == NULL)) {
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
memset(instance, 0, sizeof(*instance));
|
||||||
|
instance->port = *port;
|
||||||
|
instance->port_context = port_context;
|
||||||
|
instance->json_buffer = json_buffer;
|
||||||
|
instance->json_capacity = json_capacity;
|
||||||
|
instance->maximum_copies = (maximum_copies == 0U) ? 1U : maximum_copies;
|
||||||
|
instance->status = SETTINGS_BACKUP_IDLE;
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending копируется целиком и получает вычисленный canonical CRC. */
|
||||||
|
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
|
||||||
|
const SettingsBackupSnapshot *snapshot)
|
||||||
|
{
|
||||||
|
if ((instance == NULL) || (snapshot_is_valid(snapshot) == 0U)) {
|
||||||
|
if (instance != NULL) {
|
||||||
|
instance->status = SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT;
|
||||||
|
}
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
instance->pending_snapshot = *snapshot;
|
||||||
|
instance->pending_snapshot.content_crc32 =
|
||||||
|
SettingsBackup_CalculateContentCrc(&instance->pending_snapshot);
|
||||||
|
instance->phase = BACKUP_PHASE_MOUNT;
|
||||||
|
instance->retry_count = 0U;
|
||||||
|
instance->pending = 1U;
|
||||||
|
instance->status = SETTINGS_BACKUP_PENDING;
|
||||||
|
return 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ошибка закрывает том и назначает ограниченный retry/cooldown без busy wait. */
|
||||||
|
static void schedule_retry(SettingsBackup *instance,
|
||||||
|
SettingsBackupPortResult result)
|
||||||
|
{
|
||||||
|
uint32_t delay_ms;
|
||||||
|
|
||||||
|
/* Порт завершает владение попыткой; общий физический том может оставить
|
||||||
|
* смонтированным, если его разделяет с независимым temperature logger. */
|
||||||
|
instance->port.unmount(instance->port_context);
|
||||||
|
instance->status = (result == SETTINGS_BACKUP_PORT_NOT_READY) ?
|
||||||
|
SETTINGS_BACKUP_LAST_NOT_READY :
|
||||||
|
(result == SETTINGS_BACKUP_PORT_NO_SPACE) ?
|
||||||
|
SETTINGS_BACKUP_LAST_NO_SPACE : SETTINGS_BACKUP_LAST_IO_ERROR;
|
||||||
|
/* Счётчик насыщать не требуется: после 255 ошибок unsigned wrap только
|
||||||
|
* вернёт одну короткую попытку и не нарушит сохранность данных. */
|
||||||
|
++instance->retry_count;
|
||||||
|
delay_ms = (instance->retry_count <= SETTINGS_BACKUP_RETRY_LIMIT) ?
|
||||||
|
SETTINGS_BACKUP_RETRY_MS : SETTINGS_BACKUP_COOLDOWN_MS;
|
||||||
|
instance->next_retry_ms = instance->port.tick_ms(instance->port_context) + delay_ms;
|
||||||
|
instance->phase = BACKUP_PHASE_MOUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Автомат намеренно выполняет только одну callback-операцию на каждом проходе. */
|
||||||
|
void SettingsBackup_Service(SettingsBackup *instance)
|
||||||
|
{
|
||||||
|
SettingsBackupPortResult result;
|
||||||
|
uint8_t found = 0U;
|
||||||
|
int json_length;
|
||||||
|
uint32_t now_ms;
|
||||||
|
|
||||||
|
if ((instance == NULL) || (instance->pending == 0U)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
now_ms = instance->port.tick_ms(instance->port_context);
|
||||||
|
if ((instance->next_retry_ms != 0U) &&
|
||||||
|
((int32_t)(now_ms - instance->next_retry_ms) < 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->next_retry_ms = 0U;
|
||||||
|
if (instance->phase == BACKUP_PHASE_MOUNT) {
|
||||||
|
/* Mount — единственная операция этой итерации; scan начнётся позже. */
|
||||||
|
result = instance->port.mount(instance->port_context);
|
||||||
|
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||||
|
schedule_retry(instance, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->phase = BACKUP_PHASE_DEDUPE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instance->phase == BACKUP_PHASE_DEDUPE) {
|
||||||
|
/* Restart dedupe обязан выполняться до выбора нового имени файла. */
|
||||||
|
result = instance->port.find_identical(instance->port_context,
|
||||||
|
instance->pending_snapshot.revision,
|
||||||
|
instance->pending_snapshot.content_crc32, &found);
|
||||||
|
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||||
|
schedule_retry(instance, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (found != 0U) {
|
||||||
|
instance->pending = 0U;
|
||||||
|
instance->status = SETTINGS_BACKUP_LAST_OK;
|
||||||
|
instance->port.unmount(instance->port_context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->phase = BACKUP_PHASE_RESOLVE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instance->phase == BACKUP_PHASE_RESOLVE) {
|
||||||
|
/* Resolve резервирует концептуальную пару final/temp без их создания. */
|
||||||
|
result = instance->port.resolve_paths(instance->port_context,
|
||||||
|
&instance->pending_snapshot, instance->final_path,
|
||||||
|
sizeof(instance->final_path), instance->temporary_path,
|
||||||
|
sizeof(instance->temporary_path));
|
||||||
|
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||||
|
schedule_retry(instance, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->phase = BACKUP_PHASE_WRITE;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instance->phase == BACKUP_PHASE_WRITE) {
|
||||||
|
/* Форматирование RAM не касается FAT; atomicity начинается в callback. */
|
||||||
|
json_length = SettingsBackup_FormatJson(&instance->pending_snapshot,
|
||||||
|
instance->json_buffer, instance->json_capacity);
|
||||||
|
if (json_length < 0) {
|
||||||
|
schedule_retry(instance, SETTINGS_BACKUP_PORT_IO_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = instance->port.atomic_write(instance->port_context,
|
||||||
|
instance->temporary_path, instance->final_path,
|
||||||
|
instance->json_buffer, (uint32_t)json_length);
|
||||||
|
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||||
|
schedule_retry(instance, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->phase = BACKUP_PHASE_RETENTION;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Retention выполняется только после успешного rename новой good copy. */
|
||||||
|
result = instance->port.apply_retention(instance->port_context,
|
||||||
|
instance->maximum_copies);
|
||||||
|
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||||
|
schedule_retry(instance, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance->pending = 0U;
|
||||||
|
instance->status = SETTINGS_BACKUP_LAST_OK;
|
||||||
|
instance->port.unmount(instance->port_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Getter не меняет автомат и безопасен для диагностического регистра. */
|
||||||
|
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance)
|
||||||
|
{
|
||||||
|
return (instance == NULL) ? SETTINGS_BACKUP_LAST_IO_ERROR : instance->status;
|
||||||
|
}
|
||||||
43
c/settings-backup/Tests/test_settings_backup.c
Normal file
43
c/settings-backup/Tests/test_settings_backup.c
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#include "settings_backup.h"
|
||||||
|
#include <assert.h>
|
||||||
|
#include <string.h>
|
||||||
|
typedef struct { uint32_t now; unsigned writes, unmounts; int fail; } mock_t;
|
||||||
|
static uint32_t tick(void *p) { return ((mock_t*)p)->now; }
|
||||||
|
static SettingsBackupPortResult mount(void *p) { (void)p; return SETTINGS_BACKUP_PORT_OK; }
|
||||||
|
static void unmount(void *p) { ++((mock_t*)p)->unmounts; }
|
||||||
|
static SettingsBackupPortResult find(void *p,uint32_t r,uint32_t c,uint8_t *found)
|
||||||
|
{ (void)p; (void)r; (void)c; *found=0; return SETTINGS_BACKUP_PORT_OK; }
|
||||||
|
static SettingsBackupPortResult paths(void *p,const SettingsBackupSnapshot *s,char *f,uint32_t fc,char *t,uint32_t tc)
|
||||||
|
{ (void)p; (void)s; assert(fc>8 && tc>8); memcpy(f,"ok.json",8); memcpy(t,"ok.tmp",7); return SETTINGS_BACKUP_PORT_OK; }
|
||||||
|
static SettingsBackupPortResult write_json(void *p,const char *t,const char *f,const char *json,uint32_t n)
|
||||||
|
{
|
||||||
|
mock_t *m=p; (void)t; (void)f;
|
||||||
|
assert(n==strlen(json) && n>10 && json[0]=='{');
|
||||||
|
if (m->fail) return SETTINGS_BACKUP_PORT_IO_ERROR;
|
||||||
|
++m->writes; return SETTINGS_BACKUP_PORT_OK;
|
||||||
|
}
|
||||||
|
static SettingsBackupPortResult retain(void *p,uint16_t n)
|
||||||
|
{ (void)p; assert(n==2); return SETTINGS_BACKUP_PORT_OK; }
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
SettingsBackup b; SettingsBackupSnapshot s={0}; mock_t m={0}; char json[16000];
|
||||||
|
SettingsBackupPort port={tick,mount,unmount,find,paths,write_json,retain};
|
||||||
|
unsigned i; uint32_t crc;
|
||||||
|
s.revision=1; s.room_count=SETTINGS_BACKUP_MAX_ROOMS;
|
||||||
|
for(i=0;i<s.room_count;++i) { s.rooms[i].setpoint_x10=200; s.rooms[i].hysteresis_x10=10; s.rooms[i].full_open_time_100ms=100; }
|
||||||
|
crc=SettingsBackup_CalculateContentCrc(&s);
|
||||||
|
s.rtc.year=2026; s.active_backend=1;
|
||||||
|
assert(SettingsBackup_CalculateContentCrc(&s)==crc);
|
||||||
|
assert(SettingsBackup_FormatJson(&s,json,5)<0);
|
||||||
|
assert(SettingsBackup_Init(&b,&port,&m,json,sizeof(json),2));
|
||||||
|
assert(SettingsBackup_Notify(&b,&s));
|
||||||
|
s.rooms[0].setpoint_x10=300;
|
||||||
|
assert(b.pending_snapshot.rooms[0].setpoint_x10==200);
|
||||||
|
m.fail=1;
|
||||||
|
for(i=0;i<4;++i) SettingsBackup_Service(&b);
|
||||||
|
assert(m.writes==0 && b.pending && b.status==SETTINGS_BACKUP_LAST_IO_ERROR);
|
||||||
|
m.fail=0; m.now=b.next_retry_ms;
|
||||||
|
for(i=0;i<5;++i) SettingsBackup_Service(&b);
|
||||||
|
assert(m.writes==1 && !b.pending && b.status==SETTINGS_BACKUP_LAST_OK);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
196
c/settings-backup/Tests/test_settings_backup_model.py
Normal file
196
c/settings-backup/Tests/test_settings_backup_model.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""Модельные проверки полного контракта JSON backup без реальной SD-карты."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
MONTHS = (
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Snapshot:
|
||||||
|
"""Подтверждённый снимок модели соответствует metadata C-ядра."""
|
||||||
|
|
||||||
|
revision: int
|
||||||
|
payload: bytes
|
||||||
|
rtc: datetime | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crc(self) -> int:
|
||||||
|
"""CRC моделирует стабильный content hash без календаря."""
|
||||||
|
return zlib.crc32(self.payload) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCard:
|
||||||
|
"""In-memory FAT-модель фиксирует атомарность, коллизии и отказы."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.files: dict[str, bytes] = {}
|
||||||
|
self.ready = True
|
||||||
|
self.fail_step: str | None = None
|
||||||
|
|
||||||
|
def resolve(self, snapshot: Snapshot) -> tuple[str, str]:
|
||||||
|
"""Выбирает календарное либо честно помеченное fallback-имя."""
|
||||||
|
if snapshot.rtc:
|
||||||
|
month = f"{snapshot.rtc:%Y-%m}_{MONTHS[snapshot.rtc.month - 1]}"
|
||||||
|
stem = f"settings_{snapshot.rtc:%Y-%m-%d_%H-%M-%S}"
|
||||||
|
else:
|
||||||
|
month = "invalid_rtc"
|
||||||
|
stem = f"settings_invalid_rtc_rev_{snapshot.revision}"
|
||||||
|
root = PurePosixPath("niceOne/settings") / month
|
||||||
|
suffix = 0
|
||||||
|
while True:
|
||||||
|
marker = "" if suffix == 0 else f"_{suffix}"
|
||||||
|
final = str(root / f"{stem}{marker}.json")
|
||||||
|
temporary = str(root / f"{stem}{marker}.tmp")
|
||||||
|
if final not in self.files and temporary not in self.files:
|
||||||
|
return final, temporary
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
def atomic_write(self, temporary: str, final: str, content: bytes) -> bool:
|
||||||
|
"""Сбой любого этапа никогда не изменяет старый final JSON."""
|
||||||
|
if not self.ready or self.fail_step == "open":
|
||||||
|
return False
|
||||||
|
self.files[temporary] = b""
|
||||||
|
if self.fail_step == "write":
|
||||||
|
return False
|
||||||
|
self.files[temporary] = content
|
||||||
|
if self.fail_step in {"sync", "close", "rename"}:
|
||||||
|
return False
|
||||||
|
self.files[final] = self.files.pop(temporary)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def document(snapshot: Snapshot) -> bytes:
|
||||||
|
"""Минимальная schema-модель проверяет UTF-8 и обязательные разделы."""
|
||||||
|
rooms = [
|
||||||
|
{
|
||||||
|
"room": room,
|
||||||
|
"setpoint_x10": 200,
|
||||||
|
"hysteresis_x10": 10,
|
||||||
|
"calibration": {"start_position_pct": 0, "full_open_time_100ms": 300},
|
||||||
|
"valve": {"target_position_pct": 50, "confirmed_position_pct": 50},
|
||||||
|
}
|
||||||
|
for room in range(1, 33)
|
||||||
|
]
|
||||||
|
sensors = [{"rom": "280102030405069E", "room": 1}]
|
||||||
|
return (json.dumps(
|
||||||
|
{
|
||||||
|
"schema": "niceOne.settings",
|
||||||
|
"schema_version": 1,
|
||||||
|
"snapshot_revision": snapshot.revision,
|
||||||
|
"snapshot_crc32": f"{snapshot.crc:08X}",
|
||||||
|
"rtc": {"valid": snapshot.rtc is not None,
|
||||||
|
"datetime": snapshot.rtc.isoformat() if snapshot.rtc else None},
|
||||||
|
"storage": {"requested_backend": "internal_flash",
|
||||||
|
"active_backend": "internal_flash"},
|
||||||
|
"units": {"temperature": "0.1_degC", "position": "percent"},
|
||||||
|
"rooms": rooms,
|
||||||
|
"sensors": sensors,
|
||||||
|
"restore_policy": "manual_validation_only",
|
||||||
|
}, ensure_ascii=False, indent=2,
|
||||||
|
) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsBackupModelTests(unittest.TestCase):
|
||||||
|
"""Каждый тест соответствует одному или нескольким пунктам task.MD."""
|
||||||
|
|
||||||
|
def test_calendar_tree_and_fat_safe_english_month(self) -> None:
|
||||||
|
card = FakeCard()
|
||||||
|
final, temporary = card.resolve(Snapshot(7, b"a", datetime(2026, 7, 17, 14, 30, 5)))
|
||||||
|
self.assertEqual(final, "niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json")
|
||||||
|
self.assertTrue(temporary.endswith(".tmp"))
|
||||||
|
self.assertNotIn(":", final)
|
||||||
|
|
||||||
|
def test_json_schema_contains_all_32_rooms_full_rom_and_units(self) -> None:
|
||||||
|
snapshot = Snapshot(9, b"confirmed", datetime(2026, 12, 31, 23, 59, 59))
|
||||||
|
parsed = json.loads(document(snapshot).decode("utf-8"))
|
||||||
|
self.assertEqual(parsed["schema_version"], 1)
|
||||||
|
self.assertEqual(len(parsed["rooms"]), 32)
|
||||||
|
self.assertEqual(len(parsed["sensors"][0]["rom"]), 16)
|
||||||
|
self.assertIn("target_position_pct", parsed["rooms"][0]["valve"])
|
||||||
|
self.assertIn("confirmed_position_pct", parsed["rooms"][0]["valve"])
|
||||||
|
self.assertEqual(parsed["restore_policy"], "manual_validation_only")
|
||||||
|
|
||||||
|
def test_hash_excludes_rtc_and_prevents_restart_duplicate(self) -> None:
|
||||||
|
first = Snapshot(12, b"same settings", datetime(2026, 7, 1))
|
||||||
|
restart = Snapshot(13, b"same settings", datetime(2026, 8, 1))
|
||||||
|
self.assertEqual(first.crc, restart.crc)
|
||||||
|
identities = {first.crc}
|
||||||
|
self.assertIn(restart.crc, identities)
|
||||||
|
|
||||||
|
def test_confirmed_revision_change_creates_copy_not_periodic_tick(self) -> None:
|
||||||
|
revisions = [4, 4, 4, 5, 5]
|
||||||
|
writes = []
|
||||||
|
previous = None
|
||||||
|
for revision in revisions:
|
||||||
|
if revision != previous:
|
||||||
|
writes.append(revision)
|
||||||
|
previous = revision
|
||||||
|
self.assertEqual(writes, [4, 5])
|
||||||
|
|
||||||
|
def test_collision_survives_restart_and_checks_temp_too(self) -> None:
|
||||||
|
card = FakeCard()
|
||||||
|
snapshot = Snapshot(3, b"x", datetime(2026, 1, 2, 3, 4, 5))
|
||||||
|
final, temporary = card.resolve(snapshot)
|
||||||
|
card.files[final] = b"old"
|
||||||
|
card.files[temporary] = b"interrupted"
|
||||||
|
next_final, next_temporary = card.resolve(snapshot)
|
||||||
|
self.assertTrue(next_final.endswith("_1.json"))
|
||||||
|
self.assertTrue(next_temporary.endswith("_1.tmp"))
|
||||||
|
|
||||||
|
def test_atomic_failures_preserve_last_good(self) -> None:
|
||||||
|
for failure in ("open", "write", "sync", "close", "rename"):
|
||||||
|
with self.subTest(failure=failure):
|
||||||
|
card = FakeCard()
|
||||||
|
card.fail_step = failure
|
||||||
|
card.files["last.json"] = b"valid"
|
||||||
|
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
||||||
|
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
||||||
|
self.assertEqual(card.files["last.json"], b"valid")
|
||||||
|
self.assertNotIn(final, card.files)
|
||||||
|
|
||||||
|
def test_absent_removed_or_full_card_is_non_destructive(self) -> None:
|
||||||
|
for failure in (False, "open", "write"):
|
||||||
|
with self.subTest(failure=failure):
|
||||||
|
card = FakeCard()
|
||||||
|
card.ready = bool(failure)
|
||||||
|
card.fail_step = failure if isinstance(failure, str) else None
|
||||||
|
card.files["last.json"] = b"valid"
|
||||||
|
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
||||||
|
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
||||||
|
self.assertEqual(card.files["last.json"], b"valid")
|
||||||
|
|
||||||
|
def test_invalid_rtc_uses_revision_and_never_fake_date(self) -> None:
|
||||||
|
final, _ = FakeCard().resolve(Snapshot(0x1234, b"x", None))
|
||||||
|
self.assertIn("invalid_rtc", final)
|
||||||
|
self.assertIn("rev_4660", final)
|
||||||
|
self.assertNotRegex(final, r"20\d\d-\d\d-\d\d")
|
||||||
|
|
||||||
|
def test_retention_prefers_old_duplicate_and_keeps_one_good(self) -> None:
|
||||||
|
entries = [
|
||||||
|
("2026-01/a.json", 1), ("2026-02/b.json", 1),
|
||||||
|
("2026-03/c.json", 2),
|
||||||
|
]
|
||||||
|
duplicates = [path for path, crc in entries if sum(v == crc for _, v in entries) > 1]
|
||||||
|
victim = min(duplicates)
|
||||||
|
remaining = [entry for entry in entries if entry[0] != victim]
|
||||||
|
self.assertEqual(victim, "2026-01/a.json")
|
||||||
|
self.assertGreaterEqual(len(remaining), 1)
|
||||||
|
|
||||||
|
def test_restore_is_never_automatic_and_secrets_are_absent(self) -> None:
|
||||||
|
text = document(Snapshot(1, b"x", None)).decode("utf-8")
|
||||||
|
self.assertIn('"restore_policy": "manual_validation_only"', text)
|
||||||
|
for forbidden in ("password", "secret", "unused_flash", "pending_gui"):
|
||||||
|
self.assertNotIn(forbidden, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
10
c/spi-nor/CMakeLists.txt
Normal file
10
c/spi-nor/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
project(spi_nor C)
|
||||||
|
add_library(spi_nor STATIC Src/spi_nor.c)
|
||||||
|
target_include_directories(spi_nor PUBLIC Inc)
|
||||||
|
set_target_properties(spi_nor PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_executable(test_spi_nor Tests/test_spi_nor.c)
|
||||||
|
target_link_libraries(test_spi_nor PRIVATE spi_nor)
|
||||||
|
add_test(NAME test_spi_nor COMMAND test_spi_nor)
|
||||||
72
c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h
Normal file
72
c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#ifndef SPI_NOR_COMMAND_SERVICE_H
|
||||||
|
#define SPI_NOR_COMMAND_SERVICE_H
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Переносимый read-only сервис коротких сервисных команд SPI NOR.
|
||||||
|
*
|
||||||
|
* Модуль не знает о HAL, Modbus, HTTP и GUI. Он принимает уже распознанный
|
||||||
|
* spi_nor_t, применяет собственный allowlist и выполняет ровно одну законченную
|
||||||
|
* транзакцию. Изменяющие команды намеренно отсутствуют в публичном API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "spi_nor.h"
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Четырёх байт достаточно для opcode и 24-битного адреса READ 0x03. */
|
||||||
|
#define SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES 4U
|
||||||
|
#define SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES 64U
|
||||||
|
|
||||||
|
typedef enum
|
||||||
|
{
|
||||||
|
SPI_NOR_COMMAND_SERVICE_IDLE = 0,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_BUSY,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_INVALID,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_BLOCKED,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_UNAVAILABLE,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_IO_ERROR,
|
||||||
|
SPI_NOR_COMMAND_SERVICE_TIMEOUT
|
||||||
|
} spi_nor_command_service_status_t;
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint16_t sequence;
|
||||||
|
uint8_t tx_length;
|
||||||
|
uint8_t rx_length;
|
||||||
|
uint8_t tx[SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES];
|
||||||
|
} spi_nor_command_service_request_t;
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
spi_nor_command_service_status_t status;
|
||||||
|
uint16_t sequence;
|
||||||
|
uint8_t tx_length;
|
||||||
|
uint8_t rx_length;
|
||||||
|
uint8_t tx_echo[SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES];
|
||||||
|
uint8_t rx[SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES];
|
||||||
|
} spi_nor_command_service_response_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Allowlist общий для W25Q64/W25Q128 и SST25VF016B:
|
||||||
|
* 0x9F JEDEC ID, 0x05 status register 1, 0x03 обычное чтение данных.
|
||||||
|
*/
|
||||||
|
spi_nor_command_service_status_t spi_nor_command_service_validate(
|
||||||
|
const spi_nor_t *device,
|
||||||
|
const spi_nor_command_service_request_t *request);
|
||||||
|
|
||||||
|
/* Process очищает старый ответ и выполняет не более одной SPI-транзакции. */
|
||||||
|
spi_nor_command_service_status_t spi_nor_command_service_process(
|
||||||
|
spi_nor_t *device,
|
||||||
|
const spi_nor_command_service_request_t *request,
|
||||||
|
spi_nor_command_service_response_t *response);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SPI_NOR_COMMAND_SERVICE_H */
|
||||||
73
c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h
Normal file
73
c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#ifndef SPI_NOR_READ_SERVICE_H
|
||||||
|
#define SPI_NOR_READ_SERVICE_H
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ограниченный read-only сервис диагностики SPI NOR.
|
||||||
|
*
|
||||||
|
* Сервис отделён от HAL, Modbus и приложения. Владелец передаёт только ёмкость
|
||||||
|
* и callback чтения, поэтому portable-ядро SpiNor не получает зависимостей GUI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Один маленький пакет ограничивает длительность SPI и Modbus main loop. */
|
||||||
|
#define SPI_NOR_READ_SERVICE_MAX_BYTES 64U
|
||||||
|
|
||||||
|
typedef enum
|
||||||
|
{
|
||||||
|
SPI_NOR_READ_SERVICE_IDLE = 0,
|
||||||
|
SPI_NOR_READ_SERVICE_BUSY,
|
||||||
|
SPI_NOR_READ_SERVICE_OK,
|
||||||
|
SPI_NOR_READ_SERVICE_INVALID,
|
||||||
|
SPI_NOR_READ_SERVICE_UNAVAILABLE,
|
||||||
|
SPI_NOR_READ_SERVICE_IO_ERROR
|
||||||
|
} spi_nor_read_service_status_t;
|
||||||
|
|
||||||
|
/* Callback адаптера не раскрывает драйверу контекст конкретной платформы. */
|
||||||
|
typedef spi_nor_read_service_status_t (*spi_nor_read_service_read_fn)(
|
||||||
|
void *context, uint32_t address, void *data, size_t size);
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
void *context;
|
||||||
|
spi_nor_read_service_read_fn read;
|
||||||
|
uint32_t capacity_bytes;
|
||||||
|
} spi_nor_read_service_t;
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint32_t address;
|
||||||
|
uint16_t length;
|
||||||
|
uint16_t sequence;
|
||||||
|
} spi_nor_read_service_request_t;
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
spi_nor_read_service_status_t status;
|
||||||
|
uint32_t address;
|
||||||
|
uint16_t length;
|
||||||
|
uint16_t sequence;
|
||||||
|
uint8_t data[SPI_NOR_READ_SERVICE_MAX_BYTES];
|
||||||
|
} spi_nor_read_service_response_t;
|
||||||
|
|
||||||
|
/* Init проверяет callback и ёмкость, но сам не обращается к SPI. */
|
||||||
|
spi_nor_read_service_status_t spi_nor_read_service_init(
|
||||||
|
spi_nor_read_service_t *service, void *context,
|
||||||
|
spi_nor_read_service_read_fn read, uint32_t capacity_bytes);
|
||||||
|
|
||||||
|
/* Process всегда очищает старые данные до проверки нового запроса. */
|
||||||
|
spi_nor_read_service_status_t spi_nor_read_service_process(
|
||||||
|
const spi_nor_read_service_t *service,
|
||||||
|
const spi_nor_read_service_request_t *request,
|
||||||
|
spi_nor_read_service_response_t *response);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SPI_NOR_READ_SERVICE_H */
|
||||||
165
c/spi-nor/Diagnostics/Src/spi_nor_command_service.c
Normal file
165
c/spi-nor/Diagnostics/Src/spi_nor_command_service.c
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#include "spi_nor_command_service.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Только эти три opcode документированы одинаково для всех поддержанных NOR. */
|
||||||
|
#define SPI_NOR_DIAG_READ_DATA 0x03U
|
||||||
|
#define SPI_NOR_DIAG_READ_STATUS_1 0x05U
|
||||||
|
#define SPI_NOR_DIAG_READ_JEDEC_ID 0x9FU
|
||||||
|
|
||||||
|
/* HAL-коды переводятся в стабильные статусы диагностического протокола. */
|
||||||
|
static spi_nor_command_service_status_t map_io_status(
|
||||||
|
spi_nor_io_status_t status)
|
||||||
|
{
|
||||||
|
if (status == SPI_NOR_IO_OK) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_OK;
|
||||||
|
}
|
||||||
|
if (status == SPI_NOR_IO_TIMEOUT) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_TIMEOUT;
|
||||||
|
}
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_IO_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* READ использует 24-битный big-endian адрес, как основное ядро SpiNor. */
|
||||||
|
static uint32_t read_address(const uint8_t tx[4])
|
||||||
|
{
|
||||||
|
return ((uint32_t)tx[1] << 16U) |
|
||||||
|
((uint32_t)tx[2] << 8U) |
|
||||||
|
(uint32_t)tx[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
spi_nor_command_service_status_t spi_nor_command_service_validate(
|
||||||
|
const spi_nor_t *device,
|
||||||
|
const spi_nor_command_service_request_t *request)
|
||||||
|
{
|
||||||
|
uint8_t opcode;
|
||||||
|
|
||||||
|
if ((device == NULL) || (request == NULL) ||
|
||||||
|
(device->initialized == 0U) || (device->detected == 0U) ||
|
||||||
|
(device->io.transfer == NULL) || (device->io.chip_select == NULL)) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
if ((request->tx_length == 0U) ||
|
||||||
|
(request->tx_length > SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES) ||
|
||||||
|
(request->rx_length == 0U) ||
|
||||||
|
(request->rx_length > SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES)) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
opcode = request->tx[0];
|
||||||
|
/* JEDEC возвращает ровно manufacturer/type/capacity для определения модели. */
|
||||||
|
if (opcode == SPI_NOR_DIAG_READ_JEDEC_ID) {
|
||||||
|
return ((request->tx_length == 1U) && (request->rx_length == 3U)) ?
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK : SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
/* SR1 — единственный общий и однозначно read-only status всех трёх NOR. */
|
||||||
|
if (opcode == SPI_NOR_DIAG_READ_STATUS_1) {
|
||||||
|
return ((request->tx_length == 1U) && (request->rx_length == 1U)) ?
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK : SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
/* Обычный READ допускает произвольный bounded RX, но требует весь адрес. */
|
||||||
|
if (opcode == SPI_NOR_DIAG_READ_DATA) {
|
||||||
|
uint32_t address;
|
||||||
|
|
||||||
|
if (request->tx_length != 4U) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
address = read_address(request->tx);
|
||||||
|
if ((address >= device->info.capacity_bytes) ||
|
||||||
|
((uint32_t)request->rx_length >
|
||||||
|
(device->info.capacity_bytes - address))) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fail-closed default блокирует WREN, program/erase, status/protection
|
||||||
|
* writes, power-down/wake, reset и любой неизвестный opcode. Будущий
|
||||||
|
* изменяющий режим должен быть отдельным API, а не расширением allowlist.
|
||||||
|
*/
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_BLOCKED;
|
||||||
|
}
|
||||||
|
|
||||||
|
spi_nor_command_service_status_t spi_nor_command_service_process(
|
||||||
|
spi_nor_t *device,
|
||||||
|
const spi_nor_command_service_request_t *request,
|
||||||
|
spi_nor_command_service_response_t *response)
|
||||||
|
{
|
||||||
|
spi_nor_command_service_status_t status;
|
||||||
|
spi_nor_io_status_t io_status;
|
||||||
|
uint8_t locked = 0U;
|
||||||
|
|
||||||
|
if (response == NULL) {
|
||||||
|
return SPI_NOR_COMMAND_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
/* Очистка запрещает повторное использование старого RX после любого отказа. */
|
||||||
|
memset(response, 0, sizeof(*response));
|
||||||
|
if (request != NULL) {
|
||||||
|
response->sequence = request->sequence;
|
||||||
|
response->tx_length = request->tx_length;
|
||||||
|
if (request->tx_length <= SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES) {
|
||||||
|
memcpy(response->tx_echo, request->tx, request->tx_length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status = spi_nor_command_service_validate(device, request);
|
||||||
|
if (status != SPI_NOR_COMMAND_SERVICE_OK) {
|
||||||
|
response->status = status;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (device->busy != 0U) {
|
||||||
|
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
if (device->io.lock != NULL) {
|
||||||
|
/* Mutex относится ко всей CS-транзакции, а не к отдельному transfer. */
|
||||||
|
io_status = device->io.lock(device->io.context,
|
||||||
|
device->config.lock_timeout_ms);
|
||||||
|
if (io_status != SPI_NOR_IO_OK) {
|
||||||
|
response->status = (io_status == SPI_NOR_IO_TIMEOUT) ?
|
||||||
|
SPI_NOR_COMMAND_SERVICE_TIMEOUT :
|
||||||
|
SPI_NOR_COMMAND_SERVICE_BUSY;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
locked = 1U;
|
||||||
|
}
|
||||||
|
if (device->busy != 0U) {
|
||||||
|
if ((locked != 0U) && (device->io.unlock != NULL)) {
|
||||||
|
device->io.unlock(device->io.context);
|
||||||
|
}
|
||||||
|
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Busy устанавливается после mutex и снимается до unlock основного устройства. */
|
||||||
|
device->busy = 1U;
|
||||||
|
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
|
||||||
|
/* CS освобождается безусловно после TX либо RX ошибки/тайм-аута. */
|
||||||
|
device->io.chip_select(device->io.context, 1U);
|
||||||
|
io_status = device->io.transfer(device->io.context, request->tx, NULL,
|
||||||
|
request->tx_length,
|
||||||
|
device->config.io_timeout_ms);
|
||||||
|
if (io_status == SPI_NOR_IO_OK) {
|
||||||
|
io_status = device->io.transfer(device->io.context, NULL, response->rx,
|
||||||
|
request->rx_length,
|
||||||
|
device->config.io_timeout_ms);
|
||||||
|
}
|
||||||
|
device->io.chip_select(device->io.context, 0U);
|
||||||
|
device->busy = 0U;
|
||||||
|
if ((locked != 0U) && (device->io.unlock != NULL)) {
|
||||||
|
device->io.unlock(device->io.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
status = map_io_status(io_status);
|
||||||
|
if (status != SPI_NOR_COMMAND_SERVICE_OK) {
|
||||||
|
/* Частичный RX не должен выглядеть достоверным после физической ошибки. */
|
||||||
|
memset(response->rx, 0, sizeof(response->rx));
|
||||||
|
response->rx_length = 0U;
|
||||||
|
response->status = status;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
/* RX length подтверждается только когда оба transfer завершились успешно. */
|
||||||
|
response->rx_length = request->rx_length;
|
||||||
|
response->status = SPI_NOR_COMMAND_SERVICE_OK;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
59
c/spi-nor/Diagnostics/Src/spi_nor_read_service.c
Normal file
59
c/spi-nor/Diagnostics/Src/spi_nor_read_service.c
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#include "spi_nor_read_service.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Диагностический слой намеренно не содержит program/erase callbacks. */
|
||||||
|
spi_nor_read_service_status_t spi_nor_read_service_init(
|
||||||
|
spi_nor_read_service_t *service, void *context,
|
||||||
|
spi_nor_read_service_read_fn read, uint32_t capacity_bytes)
|
||||||
|
{
|
||||||
|
if ((service == NULL) || (read == NULL) || (capacity_bytes == 0U)) {
|
||||||
|
return SPI_NOR_READ_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
service->context = context;
|
||||||
|
service->read = read;
|
||||||
|
service->capacity_bytes = capacity_bytes;
|
||||||
|
return SPI_NOR_READ_SERVICE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вычитание после проверки address исключает uint32 overflow address+length. */
|
||||||
|
spi_nor_read_service_status_t spi_nor_read_service_process(
|
||||||
|
const spi_nor_read_service_t *service,
|
||||||
|
const spi_nor_read_service_request_t *request,
|
||||||
|
spi_nor_read_service_response_t *response)
|
||||||
|
{
|
||||||
|
spi_nor_read_service_status_t status;
|
||||||
|
|
||||||
|
if (response == NULL) {
|
||||||
|
return SPI_NOR_READ_SERVICE_INVALID;
|
||||||
|
}
|
||||||
|
memset(response, 0, sizeof(*response));
|
||||||
|
if ((service == NULL) || (request == NULL) || (service->read == NULL) ||
|
||||||
|
(service->capacity_bytes == 0U)) {
|
||||||
|
response->status = SPI_NOR_READ_SERVICE_UNAVAILABLE;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
response->sequence = request->sequence;
|
||||||
|
response->address = request->address;
|
||||||
|
if ((request->length == 0U) ||
|
||||||
|
(request->length > SPI_NOR_READ_SERVICE_MAX_BYTES) ||
|
||||||
|
(request->address >= service->capacity_bytes) ||
|
||||||
|
((uint32_t)request->length >
|
||||||
|
(service->capacity_bytes - request->address))) {
|
||||||
|
response->status = SPI_NOR_READ_SERVICE_INVALID;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
response->status = SPI_NOR_READ_SERVICE_BUSY;
|
||||||
|
/* Callback остаётся единственной точкой физического чтения платформы. */
|
||||||
|
status = service->read(service->context, request->address,
|
||||||
|
response->data, request->length);
|
||||||
|
if (status != SPI_NOR_READ_SERVICE_OK) {
|
||||||
|
/* После SPI-ошибки вызывающий не получает частично заполненный буфер. */
|
||||||
|
memset(response->data, 0, sizeof(response->data));
|
||||||
|
response->status = status;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
|
response->length = request->length;
|
||||||
|
response->status = SPI_NOR_READ_SERVICE_OK;
|
||||||
|
return response->status;
|
||||||
|
}
|
||||||
140
c/spi-nor/Inc/spi_nor.h
Normal file
140
c/spi-nor/Inc/spi_nor.h
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#ifndef PORTABLE_SPI_NOR_H
|
||||||
|
#define PORTABLE_SPI_NOR_H
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Переносимое ядро SPI NOR.
|
||||||
|
*
|
||||||
|
* Ядро знает только стандартные SPI-команды и callbacks платформы. Оно не
|
||||||
|
* включает HAL, регистры MCU, GPIO проекта, Modbus или код приложения. Каждый
|
||||||
|
* экземпляр хранит собственное состояние и поэтому не использует глобальные
|
||||||
|
* изменяемые переменные.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Поддерживаемые микросхемы ограничены устройствами с 24-битной адресацией. */
|
||||||
|
typedef enum
|
||||||
|
{
|
||||||
|
SPI_NOR_MODEL_NONE = 0,
|
||||||
|
SPI_NOR_MODEL_W25Q64,
|
||||||
|
SPI_NOR_MODEL_W25Q128,
|
||||||
|
SPI_NOR_MODEL_SST25VF016B
|
||||||
|
} spi_nor_model_t;
|
||||||
|
|
||||||
|
/* Ошибки протокола не зависят от кодов конкретного HAL. */
|
||||||
|
typedef enum
|
||||||
|
{
|
||||||
|
SPI_NOR_OK = 0,
|
||||||
|
SPI_NOR_E_ARGUMENT = -1,
|
||||||
|
SPI_NOR_E_IO = -2,
|
||||||
|
SPI_NOR_E_TIMEOUT = -3,
|
||||||
|
SPI_NOR_E_NOT_FOUND = -4,
|
||||||
|
SPI_NOR_E_UNSUPPORTED = -5,
|
||||||
|
SPI_NOR_E_BOUNDS = -6,
|
||||||
|
SPI_NOR_E_ALIGNMENT = -7,
|
||||||
|
SPI_NOR_E_VERIFY = -8,
|
||||||
|
SPI_NOR_E_BUSY = -9
|
||||||
|
} spi_nor_status_t;
|
||||||
|
|
||||||
|
/* Callback SPI возвращает только переносимый результат физического обмена. */
|
||||||
|
typedef enum
|
||||||
|
{
|
||||||
|
SPI_NOR_IO_OK = 0,
|
||||||
|
SPI_NOR_IO_ERROR = -1,
|
||||||
|
SPI_NOR_IO_TIMEOUT = -2
|
||||||
|
} spi_nor_io_status_t;
|
||||||
|
|
||||||
|
/* Геометрия и JEDEC доступны приложению только как копируемая диагностика. */
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
spi_nor_model_t model;
|
||||||
|
uint8_t manufacturer_id;
|
||||||
|
uint8_t memory_type;
|
||||||
|
uint8_t capacity_code;
|
||||||
|
uint32_t capacity_bytes;
|
||||||
|
uint32_t sector_size;
|
||||||
|
uint16_t page_size;
|
||||||
|
} spi_nor_info_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Контракт платформы описывает одну SPI-шину и один сигнал CS.
|
||||||
|
*
|
||||||
|
* transfer выполняется при уже активном CS. NULL в tx означает передачу
|
||||||
|
* dummy-байтов, NULL в rx — игнорирование принятого потока. lock/unlock
|
||||||
|
* необязательны; RTOS-порт может ими сериализовать полную read/program/erase
|
||||||
|
* операцию, а не отдельный SPI пакет.
|
||||||
|
*/
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
void *context;
|
||||||
|
spi_nor_io_status_t (*transfer)(void *context, const uint8_t *tx,
|
||||||
|
uint8_t *rx, size_t size,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
void (*chip_select)(void *context, uint8_t active);
|
||||||
|
uint32_t (*tick_ms)(void *context);
|
||||||
|
void (*delay_ms)(void *context, uint32_t delay_ms);
|
||||||
|
spi_nor_io_status_t (*lock)(void *context, uint32_t timeout_ms);
|
||||||
|
void (*unlock)(void *context);
|
||||||
|
} spi_nor_io_t;
|
||||||
|
|
||||||
|
/* Тайм-ауты вынесены в конфигурацию для медленных шин и разных RTOS. */
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint32_t io_timeout_ms;
|
||||||
|
uint32_t lock_timeout_ms;
|
||||||
|
uint32_t program_timeout_ms;
|
||||||
|
uint32_t erase_timeout_ms;
|
||||||
|
uint32_t ready_poll_delay_ms;
|
||||||
|
} spi_nor_config_t;
|
||||||
|
|
||||||
|
/* Контекст полностью принадлежит вызывающему коду и не требует malloc. */
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
spi_nor_io_t io;
|
||||||
|
spi_nor_config_t config;
|
||||||
|
spi_nor_info_t info;
|
||||||
|
uint8_t initialized;
|
||||||
|
uint8_t detected;
|
||||||
|
uint8_t busy;
|
||||||
|
} spi_nor_t;
|
||||||
|
|
||||||
|
/* Возвращает документированные безопасные тайм-ауты библиотеки. */
|
||||||
|
spi_nor_config_t spi_nor_default_config(void);
|
||||||
|
|
||||||
|
/* Инициализация только копирует callbacks и не обращается к физической шине. */
|
||||||
|
spi_nor_status_t spi_nor_init(spi_nor_t *device, const spi_nor_io_t *io,
|
||||||
|
const spi_nor_config_t *config);
|
||||||
|
|
||||||
|
/* Probe будит память, читает JEDEC и разрешает работу известной геометрии. */
|
||||||
|
spi_nor_status_t spi_nor_probe(spi_nor_t *device);
|
||||||
|
|
||||||
|
/* Операции используют физические смещения микросхемы, начиная с нуля. */
|
||||||
|
spi_nor_status_t spi_nor_read(spi_nor_t *device, uint32_t address,
|
||||||
|
void *data, size_t size);
|
||||||
|
spi_nor_status_t spi_nor_program(spi_nor_t *device, uint32_t address,
|
||||||
|
const void *data, size_t size);
|
||||||
|
spi_nor_status_t spi_nor_erase(spi_nor_t *device, uint32_t address,
|
||||||
|
size_t size);
|
||||||
|
|
||||||
|
/* Информация выдаётся только после успешного распознавания поддержанной модели. */
|
||||||
|
spi_nor_status_t spi_nor_get_info(const spi_nor_t *device,
|
||||||
|
spi_nor_info_t *info);
|
||||||
|
|
||||||
|
/* Последний JEDEC доступен и для различения пустой шины и неизвестной модели. */
|
||||||
|
spi_nor_status_t spi_nor_get_last_jedec(const spi_nor_t *device,
|
||||||
|
uint8_t jedec[3]);
|
||||||
|
|
||||||
|
/* Read-only диагностика возвращает status register 1 под общей блокировкой. */
|
||||||
|
spi_nor_status_t spi_nor_read_status_register(spi_nor_t *device,
|
||||||
|
uint8_t *status);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* PORTABLE_SPI_NOR_H */
|
||||||
123
c/spi-nor/PORTING.md
Normal file
123
c/spi-nor/PORTING.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Портирование SPI NOR на другую платформу
|
||||||
|
|
||||||
|
## 1. Добавить переносимое ядро
|
||||||
|
|
||||||
|
В сборку новой цели включите `Src/spi_nor.c`, а `Inc` добавьте в include path.
|
||||||
|
Это единственные обязательные файлы. Они используют только C99-заголовки
|
||||||
|
`stddef.h`, `stdint.h` и `string.h`.
|
||||||
|
|
||||||
|
## 2. Реализовать platform callbacks
|
||||||
|
|
||||||
|
Создайте собственный контекст шины и заполните `spi_nor_io_t`:
|
||||||
|
|
||||||
|
- `transfer` — полный синхронный SPI-обмен при уже активном CS;
|
||||||
|
- `chip_select` — `active=1` опускает CS, `active=0` поднимает его;
|
||||||
|
- `tick_ms` — монотонный 32-битный счётчик миллисекунд;
|
||||||
|
- `delay_ms` — задержка выхода из power-down и пауза BUSY polling;
|
||||||
|
- `lock/unlock` — необязательная пара для RTOS, оба либо заданы, либо `NULL`.
|
||||||
|
|
||||||
|
`transfer` должен поддерживать следующие варианты:
|
||||||
|
|
||||||
|
| `tx` | `rx` | Действие |
|
||||||
|
|---|---|---|
|
||||||
|
| не `NULL` | `NULL` | передать command или payload |
|
||||||
|
| `NULL` | не `NULL` | передавать dummy и сохранить входящие байты |
|
||||||
|
| не `NULL` | не `NULL` | full-duplex обмен, допустим для порта |
|
||||||
|
|
||||||
|
Callback не должен самостоятельно переключать CS. Ядро может вызвать transfer
|
||||||
|
дважды под одним CS: сначала command, затем data. Ошибка должна возвращаться как
|
||||||
|
`SPI_NOR_IO_ERROR` или `SPI_NOR_IO_TIMEOUT`.
|
||||||
|
|
||||||
|
## 3. Настроить SPI
|
||||||
|
|
||||||
|
Для W25Q/SST25 используйте master, 8 бит, MSB first и режим, разрешённый
|
||||||
|
datasheet. Частота не должна превышать предел микросхемы и возможности разводки.
|
||||||
|
CS перед `spi_nor_init()` должен быть настроен output push-pull и находиться в 1.
|
||||||
|
Проверьте питание, общую землю и отсутствие конфликта MISO с другими CS.
|
||||||
|
|
||||||
|
## 4. Инициализировать и выполнить probe
|
||||||
|
|
||||||
|
```c
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io = MakeMyPlatformIo();
|
||||||
|
spi_nor_config_t config = spi_nor_default_config();
|
||||||
|
|
||||||
|
config.erase_timeout_ms = 10000U; /* Пример для медленной микросхемы. */
|
||||||
|
if (spi_nor_init(&device, &io, &config) != SPI_NOR_OK) {
|
||||||
|
HandleConfigurationError();
|
||||||
|
}
|
||||||
|
if (spi_nor_probe(&device) != SPI_NOR_OK) {
|
||||||
|
HandleAbsentOrUnsupportedFlash();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
До успешного probe read/program/erase отклоняются. Для диагностики неизвестной
|
||||||
|
памяти вызовите `spi_nor_get_last_jedec()` после неуспешного probe.
|
||||||
|
|
||||||
|
## 5. Подключить FlashStorage при необходимости
|
||||||
|
|
||||||
|
Добавьте в сборку:
|
||||||
|
|
||||||
|
- `Libraries/FlashStorage/Src/flash_storage.c`;
|
||||||
|
- `Libraries/FlashStorage/Adapter/SPI_NOR/Src/flash_storage_spi_nor_adapter.c`;
|
||||||
|
- include paths `FlashStorage/Inc` и `FlashStorage/Adapter/SPI_NOR/Inc`.
|
||||||
|
|
||||||
|
После probe создайте `flash_storage_spi_nor_adapter_t`, вызовите
|
||||||
|
`flash_storage_spi_nor_adapter_init()` и передайте `flash_storage_spi_nor_ops`
|
||||||
|
в `flash_storage_init()` или `eeprom_store_init()`. Layout должен лежать внутри
|
||||||
|
фактической `capacity_bytes` и быть выровнен по 4096 байтам.
|
||||||
|
|
||||||
|
## 6. STM32 HAL
|
||||||
|
|
||||||
|
Для другого STM32 можно использовать существующий порт, если семейство имеет
|
||||||
|
совместимый HAL API. Подключите `spi_nor_stm32f4_hal.c`, замените HAL include на
|
||||||
|
заголовок нужного семейства и переименуйте порт. Имена конкретных `hspi`, GPIO
|
||||||
|
и pin передавайте из приложения; не добавляйте их как `extern` в библиотеку.
|
||||||
|
|
||||||
|
Если используется DMA или RTOS, простой STM32F4 порт следует заменить:
|
||||||
|
|
||||||
|
- transfer обязан дождаться завершения DMA до возврата;
|
||||||
|
- lock/unlock должны использовать mutex, не semaphore из ISR;
|
||||||
|
- mutex должен быть общим для всех устройств на одной SPI-шине;
|
||||||
|
- callback delay должен освобождать CPU через RTOS delay, если это допустимо.
|
||||||
|
|
||||||
|
## 7. Приёмочные проверки
|
||||||
|
|
||||||
|
1. На пустой шине получить `SPI_NOR_E_NOT_FOUND` и CS=1.
|
||||||
|
2. Прочитать ожидаемый JEDEC.
|
||||||
|
3. Проверить чтение первого и последнего адреса.
|
||||||
|
4. В тестовом секторе выполнить erase и полный verify `0xFF`.
|
||||||
|
5. Записать данные через границу страницы и сравнить readback.
|
||||||
|
6. Имитировать SPI error/timeout и убедиться, что CS поднят.
|
||||||
|
7. Проверить отказ для диапазона за концом памяти и невыровненного erase.
|
||||||
|
8. При RTOS параллельно запустить два клиента и проверить mutex-анализатором.
|
||||||
|
|
||||||
|
Для текущего репозитория host mock запускается через
|
||||||
|
`python -m unittest tests.test_spi_nor_host` и не требует STM32 HAL.
|
||||||
|
|
||||||
|
## 8. Перенос read-only command service
|
||||||
|
|
||||||
|
Если нужен сервисный просмотр команд, добавьте в сборку
|
||||||
|
`Diagnostics/Src/spi_nor_command_service.c` и include path `Diagnostics/Inc`.
|
||||||
|
Передавайте ему уже успешно probed `spi_nor_t`; отдельный HAL-порт не нужен:
|
||||||
|
|
||||||
|
```c
|
||||||
|
spi_nor_command_service_request_t request = {0};
|
||||||
|
spi_nor_command_service_response_t response;
|
||||||
|
|
||||||
|
request.sequence = 1U;
|
||||||
|
request.tx_length = 1U;
|
||||||
|
request.rx_length = 3U;
|
||||||
|
request.tx[0] = 0x9FU;
|
||||||
|
(void)spi_nor_command_service_process(&device, &request, &response);
|
||||||
|
```
|
||||||
|
|
||||||
|
Не расширяйте существующий allowlist ради записи. Если продукту когда-нибудь
|
||||||
|
потребуются изменяющие операции, они должны иметь отдельный API, threat model,
|
||||||
|
защиту диапазонов и аппаратные тесты. Текущий сервис не содержит compile-time
|
||||||
|
или runtime переключателя, снимающего блокировку.
|
||||||
|
|
||||||
|
Проверьте mock-тестом своей платформы: TX/RX порядок под одним CS, освобождение
|
||||||
|
CS при timeout каждого transfer, общий mutex SPI bus, строгие максимумы 4/64 и
|
||||||
|
отказ для `06`, `02`, всех erase, status/protection writes, power/reset и
|
||||||
|
неизвестных opcode.
|
||||||
113
c/spi-nor/README.md
Normal file
113
c/spi-nor/README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Переносимая библиотека SPI NOR
|
||||||
|
|
||||||
|
`SpiNor` — самостоятельный синхронный драйвер последовательной NOR Flash. Он
|
||||||
|
не зависит от STM32, HAL, `main.c`, GPIO проекта, Modbus, GUI и FlashStorage.
|
||||||
|
Память для контекста предоставляет приложение; `malloc` и глобальное изменяемое
|
||||||
|
состояние не используются.
|
||||||
|
|
||||||
|
## Поддержанные микросхемы
|
||||||
|
|
||||||
|
| Модель | JEDEC | Ёмкость | Program | Erase |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| W25Q64 | `EF 40 17` | 8 МиБ | страницы до 256 байт | 4 КиБ |
|
||||||
|
| W25Q128 | `EF 40 18` | 16 МиБ | страницы до 256 байт | 4 КиБ |
|
||||||
|
| SST25VF016B | `BF 25 41` | 2 МиБ | безопасный Byte Program | 4 КиБ |
|
||||||
|
|
||||||
|
Ядро использует команды с 24-битным адресом и намеренно не принимает
|
||||||
|
микросхемы больше 16 МиБ. Неизвестный JEDEC возвращает
|
||||||
|
`SPI_NOR_E_UNSUPPORTED`; пустая шина `00 00 00` или `FF FF FF` —
|
||||||
|
`SPI_NOR_E_NOT_FOUND`.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `Inc/spi_nor.h` — публичные типы, callbacks, конфигурация и API;
|
||||||
|
- `Src/spi_nor.c` — переносимый протокол, JEDEC, bounds, тайм-ауты и verify;
|
||||||
|
- `Port/STM32F4_HAL` — единственное место с зависимостью от STM32F4 HAL;
|
||||||
|
- `Examples/portable_init.c` — минимальная интеграция без имён текущей платы;
|
||||||
|
- `PORTING.md` — пошаговый перенос на другой STM32 или другой MCU;
|
||||||
|
- `Tests` — mock-шина и сценарии без HAL и реального оборудования.
|
||||||
|
- `Diagnostics` — два bounded read-only сервиса без HAL/Modbus/GUI;
|
||||||
|
`spi_nor_read_service` читает физический диапазон через callback, а
|
||||||
|
`spi_nor_command_service` выполняет одну разрешённую сервисную транзакцию.
|
||||||
|
|
||||||
|
Адаптер журнала находится отдельно в
|
||||||
|
`Libraries/FlashStorage/Adapter/SPI_NOR`: FlashStorage не проникает в ядро
|
||||||
|
SPI NOR, а SPI NOR не знает формат журнала.
|
||||||
|
|
||||||
|
Диагностический сервис принимает только capacity и callback чтения, ограничивает
|
||||||
|
один пакет 64 байт и не имеет `program`/`erase` API. Приложение может связать
|
||||||
|
его с Modbus, не добавляя GUI или прикладные зависимости в ядро `SpiNor`.
|
||||||
|
|
||||||
|
### Сервис коротких SPI-команд
|
||||||
|
|
||||||
|
`spi_nor_command_service` принимает `spi_nor_t`, request с `sequence`, TX до
|
||||||
|
4 байт и RX до 64 байт, а response возвращает status, echo TX и RX. Владельцем
|
||||||
|
контекста остаётся приложение; сервис не выделяет память и не хранит историю.
|
||||||
|
|
||||||
|
Allowlist одинаков для всех трёх поддержанных моделей:
|
||||||
|
|
||||||
|
| Opcode | Строгая форма | Причина допуска |
|
||||||
|
|---:|---|---|
|
||||||
|
| `9F` | TX 1, RX 3 | JEDEC ID |
|
||||||
|
| `05` | TX 1, RX 1 | status register 1 |
|
||||||
|
| `03` | TX 4 (`03 A2 A1 A0`), RX 1–64 | обычное 24-битное чтение с bounds |
|
||||||
|
|
||||||
|
Любой другой opcode возвращает `BLOCKED`. В частности, запрещены WREN/WRDI,
|
||||||
|
program, все erase, status/protection writes, power-down/release, reset и
|
||||||
|
неизвестные команды. Это fail-closed read-only API, а не универсальный SPI
|
||||||
|
terminal. Изменяющий сервисный режим не требуется и в библиотеку не включён.
|
||||||
|
|
||||||
|
Одна операция имеет порядок `CS active -> TX -> dummy RX -> CS inactive`.
|
||||||
|
После ошибки или тайм-аута TX/RX сервис безусловно освобождает CS и очищает
|
||||||
|
частичный RX. Он использует тот же `busy` и необязательные `lock/unlock`, что
|
||||||
|
основной экземпляр `spi_nor_t`.
|
||||||
|
|
||||||
|
## Публичный API
|
||||||
|
|
||||||
|
```c
|
||||||
|
spi_nor_io_t io;
|
||||||
|
spi_nor_t flash;
|
||||||
|
|
||||||
|
/* Порт приложения заполняет callbacks и собственный io.context. */
|
||||||
|
FillPlatformCallbacks(&io);
|
||||||
|
|
||||||
|
if (spi_nor_init(&flash, &io, NULL) == SPI_NOR_OK &&
|
||||||
|
spi_nor_probe(&flash) == SPI_NOR_OK) {
|
||||||
|
uint8_t bytes[16];
|
||||||
|
(void)spi_nor_read(&flash, 0U, bytes, sizeof(bytes));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`spi_nor_program()` автоматически делит поток по страницам и проверяет каждый
|
||||||
|
chunk обратным чтением. `spi_nor_erase()` принимает только полные выровненные
|
||||||
|
4-КиБ секторы и проверяет каждый стёртый байт на `0xFF`.
|
||||||
|
|
||||||
|
## Электрическое подключение
|
||||||
|
|
||||||
|
Нужны `SCK`, `MOSI`, `MISO`, отдельный active-low `CS`, питание и земля.
|
||||||
|
Питание и допустимую частоту следует брать из datasheet конкретной модели.
|
||||||
|
Для текущей платы используются SPI2: `PB10/SCK`, `PC3/MOSI`, `PC2/MISO` и
|
||||||
|
`PE3/CS`, но эти имена находятся только в `app_storage.c`.
|
||||||
|
|
||||||
|
## Ограничения выполнения
|
||||||
|
|
||||||
|
- API синхронный и не предназначен для ISR;
|
||||||
|
- erase может блокировать вызывающий поток до заданного тайм-аута;
|
||||||
|
- один `spi_nor_t` не допускает рекурсивных операций;
|
||||||
|
- при RTOS callbacks `lock/unlock` должны защищать всю составную операцию;
|
||||||
|
- общий SPI bus требует согласованного mutex и корректного управления CS всех
|
||||||
|
подключённых устройств;
|
||||||
|
- DMA callback нельзя считать завершённым до фактического окончания обмена;
|
||||||
|
- библиотека не меняет SPI mode и clock: это обязанность платформенного порта.
|
||||||
|
|
||||||
|
## Гарантии ошибок
|
||||||
|
|
||||||
|
При любой ошибке обмена CS возвращается в неактивное состояние. Ошибка program
|
||||||
|
или erase не маскируется: результат считается успешным только после ожидания
|
||||||
|
BUSY и полного readback verify. Повторять изменяющую операцию автоматически
|
||||||
|
библиотека не будет, потому что политика повторов принадлежит приложению.
|
||||||
|
|
||||||
|
|
||||||
|
## Shared source
|
||||||
|
|
||||||
|
Canonical source: `templates/c/spi-nor`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||||
472
c/spi-nor/Src/spi_nor.c
Normal file
472
c/spi-nor/Src/spi_nor.c
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
#include "spi_nor.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Стандартные команды одинаковы для проверенных W25Q и SST25. */
|
||||||
|
#define SPI_NOR_COMMAND_WRITE_ENABLE 0x06U
|
||||||
|
#define SPI_NOR_COMMAND_READ_STATUS 0x05U
|
||||||
|
#define SPI_NOR_COMMAND_READ_DATA 0x03U
|
||||||
|
#define SPI_NOR_COMMAND_PAGE_PROGRAM 0x02U
|
||||||
|
#define SPI_NOR_COMMAND_SECTOR_ERASE 0x20U
|
||||||
|
#define SPI_NOR_COMMAND_READ_JEDEC_ID 0x9FU
|
||||||
|
#define SPI_NOR_COMMAND_RELEASE_POWER_DOWN 0xABU
|
||||||
|
#define SPI_NOR_STATUS_BUSY 0x01U
|
||||||
|
#define SPI_NOR_SECTOR_SIZE 4096UL
|
||||||
|
#define SPI_NOR_MAX_PAGE_SIZE 256U
|
||||||
|
#define SPI_NOR_VERIFY_CHUNK_SIZE 32U
|
||||||
|
#define SPI_NOR_24BIT_CAPACITY_LIMIT 0x01000000UL
|
||||||
|
|
||||||
|
/* Преобразует результат физического обмена в ошибку протокольного слоя. */
|
||||||
|
static spi_nor_status_t map_io_status(spi_nor_io_status_t status)
|
||||||
|
{
|
||||||
|
if (status == SPI_NOR_IO_OK) {
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
if (status == SPI_NOR_IO_TIMEOUT) {
|
||||||
|
return SPI_NOR_E_TIMEOUT;
|
||||||
|
}
|
||||||
|
return SPI_NOR_E_IO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Захватывает весь составной вызов, чтобы другой поток не вклинился после WREN. */
|
||||||
|
static spi_nor_status_t begin_operation(spi_nor_t *device)
|
||||||
|
{
|
||||||
|
spi_nor_io_status_t lock_status;
|
||||||
|
|
||||||
|
if ((device == NULL) || (device->initialized == 0U)) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
/* Рекурсивный вызов отклоняется до mutex, чтобы не ждать самого себя. */
|
||||||
|
if (device->busy != 0U) {
|
||||||
|
return SPI_NOR_E_BUSY;
|
||||||
|
}
|
||||||
|
if (device->io.lock != NULL) {
|
||||||
|
lock_status = device->io.lock(device->io.context,
|
||||||
|
device->config.lock_timeout_ms);
|
||||||
|
if (lock_status != SPI_NOR_IO_OK) {
|
||||||
|
return (lock_status == SPI_NOR_IO_TIMEOUT) ?
|
||||||
|
SPI_NOR_E_TIMEOUT : SPI_NOR_E_BUSY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (device->busy != 0U) {
|
||||||
|
if (device->io.unlock != NULL) {
|
||||||
|
device->io.unlock(device->io.context);
|
||||||
|
}
|
||||||
|
return SPI_NOR_E_BUSY;
|
||||||
|
}
|
||||||
|
device->busy = 1U;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Всегда освобождает как локальный флаг, так и необязательный mutex платформы. */
|
||||||
|
static void end_operation(spi_nor_t *device)
|
||||||
|
{
|
||||||
|
device->busy = 0U;
|
||||||
|
if (device->io.unlock != NULL) {
|
||||||
|
device->io.unlock(device->io.context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Обмен выполняется только при активном CS, а ошибка не оставляет CS в нуле. */
|
||||||
|
static spi_nor_status_t transaction(spi_nor_t *device,
|
||||||
|
const uint8_t *command,
|
||||||
|
size_t command_size,
|
||||||
|
const uint8_t *tx_data,
|
||||||
|
uint8_t *rx_data,
|
||||||
|
size_t data_size)
|
||||||
|
{
|
||||||
|
spi_nor_io_status_t io_status;
|
||||||
|
|
||||||
|
device->io.chip_select(device->io.context, 1U);
|
||||||
|
io_status = device->io.transfer(device->io.context, command, NULL,
|
||||||
|
command_size,
|
||||||
|
device->config.io_timeout_ms);
|
||||||
|
if ((io_status == SPI_NOR_IO_OK) && (data_size != 0U)) {
|
||||||
|
io_status = device->io.transfer(device->io.context, tx_data, rx_data,
|
||||||
|
data_size,
|
||||||
|
device->config.io_timeout_ms);
|
||||||
|
}
|
||||||
|
device->io.chip_select(device->io.context, 0U);
|
||||||
|
return map_io_status(io_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Формирует opcode и 24-битный big-endian адрес независимо от endian CPU. */
|
||||||
|
static void make_address_command(uint8_t command[4], uint8_t opcode,
|
||||||
|
uint32_t address)
|
||||||
|
{
|
||||||
|
command[0] = opcode;
|
||||||
|
command[1] = (uint8_t)(address >> 16U);
|
||||||
|
command[2] = (uint8_t)(address >> 8U);
|
||||||
|
command[3] = (uint8_t)address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Проверяет весь полуоткрытый диапазон без переполнения address + size. */
|
||||||
|
static spi_nor_status_t validate_range(const spi_nor_t *device,
|
||||||
|
uint32_t address,
|
||||||
|
size_t size)
|
||||||
|
{
|
||||||
|
if ((device->detected == 0U) || (size == 0U)) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
if ((address >= device->info.capacity_bytes) ||
|
||||||
|
(size > (size_t)(device->info.capacity_bytes - address)) ||
|
||||||
|
(device->info.capacity_bytes > SPI_NOR_24BIT_CAPACITY_LIMIT)) {
|
||||||
|
return SPI_NOR_E_BOUNDS;
|
||||||
|
}
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Читает регистр состояния в отдельной законченной SPI-транзакции. */
|
||||||
|
static spi_nor_status_t read_status(spi_nor_t *device, uint8_t *status)
|
||||||
|
{
|
||||||
|
uint8_t command = SPI_NOR_COMMAND_READ_STATUS;
|
||||||
|
|
||||||
|
return transaction(device, &command, 1U, NULL, status, 1U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Публичное чтение статуса не допускает пересечения с program/erase/read. */
|
||||||
|
spi_nor_status_t spi_nor_read_status_register(spi_nor_t *device,
|
||||||
|
uint8_t *status)
|
||||||
|
{
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
if ((device == NULL) || (status == NULL) || (device->detected == 0U)) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
result = begin_operation(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = read_status(device, status);
|
||||||
|
end_operation(device);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ожидает снятия BUSY с wrap-safe арифметикой tick и конечным тайм-аутом. */
|
||||||
|
static spi_nor_status_t wait_ready(spi_nor_t *device, uint32_t timeout_ms)
|
||||||
|
{
|
||||||
|
uint32_t started_ms = device->io.tick_ms(device->io.context);
|
||||||
|
uint8_t status = SPI_NOR_STATUS_BUSY;
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
while ((status & SPI_NOR_STATUS_BUSY) != 0U) {
|
||||||
|
result = read_status(device, &status);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if ((status & SPI_NOR_STATUS_BUSY) == 0U) {
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
if ((uint32_t)(device->io.tick_ms(device->io.context) - started_ms) >=
|
||||||
|
timeout_ms) {
|
||||||
|
return SPI_NOR_E_TIMEOUT;
|
||||||
|
}
|
||||||
|
if (device->config.ready_poll_delay_ms != 0U) {
|
||||||
|
device->io.delay_ms(device->io.context,
|
||||||
|
device->config.ready_poll_delay_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Перед каждой изменяющей командой устанавливает volatile Write Enable Latch. */
|
||||||
|
static spi_nor_status_t write_enable(spi_nor_t *device)
|
||||||
|
{
|
||||||
|
uint8_t command = SPI_NOR_COMMAND_WRITE_ENABLE;
|
||||||
|
|
||||||
|
return transaction(device, &command, 1U, NULL, NULL, 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Внутреннее чтение не захватывает mutex повторно и используется verify-кодом. */
|
||||||
|
static spi_nor_status_t raw_read(spi_nor_t *device, uint32_t address,
|
||||||
|
void *data, size_t size)
|
||||||
|
{
|
||||||
|
uint8_t command[4];
|
||||||
|
|
||||||
|
make_address_command(command, SPI_NOR_COMMAND_READ_DATA, address);
|
||||||
|
return transaction(device, command, sizeof(command), NULL, data, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Программирует один page/byte chunk и немедленно проверяет его чтением. */
|
||||||
|
static spi_nor_status_t program_chunk(spi_nor_t *device,
|
||||||
|
uint32_t address,
|
||||||
|
const uint8_t *data,
|
||||||
|
size_t size)
|
||||||
|
{
|
||||||
|
uint8_t command[4];
|
||||||
|
uint8_t verify[SPI_NOR_MAX_PAGE_SIZE];
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
result = write_enable(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
make_address_command(command, SPI_NOR_COMMAND_PAGE_PROGRAM, address);
|
||||||
|
result = transaction(device, command, sizeof(command), data, NULL, size);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = wait_ready(device, device->config.program_timeout_ms);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = raw_read(device, address, verify, size);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return (memcmp(verify, data, size) == 0) ?
|
||||||
|
SPI_NOR_OK : SPI_NOR_E_VERIFY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Проверяет каждый байт сектора после erase, а не только первый word. */
|
||||||
|
static spi_nor_status_t verify_erased_sector(spi_nor_t *device,
|
||||||
|
uint32_t address)
|
||||||
|
{
|
||||||
|
uint8_t verify[SPI_NOR_VERIFY_CHUNK_SIZE];
|
||||||
|
uint32_t offset;
|
||||||
|
size_t index;
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
for (offset = 0U; offset < SPI_NOR_SECTOR_SIZE;
|
||||||
|
offset += sizeof(verify)) {
|
||||||
|
result = raw_read(device, address + offset, verify, sizeof(verify));
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < sizeof(verify); ++index) {
|
||||||
|
if (verify[index] != 0xFFU) {
|
||||||
|
return SPI_NOR_E_VERIFY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сопоставляет только проверенные JEDEC; неизвестную ёмкость не угадывает. */
|
||||||
|
static spi_nor_status_t identify_device(spi_nor_t *device)
|
||||||
|
{
|
||||||
|
const uint8_t manufacturer = device->info.manufacturer_id;
|
||||||
|
const uint8_t memory_type = device->info.memory_type;
|
||||||
|
const uint8_t capacity = device->info.capacity_code;
|
||||||
|
|
||||||
|
device->info.sector_size = SPI_NOR_SECTOR_SIZE;
|
||||||
|
if ((manufacturer == 0xEFU) && (memory_type == 0x40U) &&
|
||||||
|
(capacity == 0x17U)) {
|
||||||
|
device->info.model = SPI_NOR_MODEL_W25Q64;
|
||||||
|
device->info.capacity_bytes = 8UL * 1024UL * 1024UL;
|
||||||
|
device->info.page_size = 256U;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
if ((manufacturer == 0xEFU) && (memory_type == 0x40U) &&
|
||||||
|
(capacity == 0x18U)) {
|
||||||
|
device->info.model = SPI_NOR_MODEL_W25Q128;
|
||||||
|
device->info.capacity_bytes = 16UL * 1024UL * 1024UL;
|
||||||
|
device->info.page_size = 256U;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
if ((manufacturer == 0xBFU) && (memory_type == 0x25U) &&
|
||||||
|
(capacity == 0x41U)) {
|
||||||
|
device->info.model = SPI_NOR_MODEL_SST25VF016B;
|
||||||
|
device->info.capacity_bytes = 2UL * 1024UL * 1024UL;
|
||||||
|
/* Byte Program 0x02 не требует SST AAI sequencing. */
|
||||||
|
device->info.page_size = 1U;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
if (((manufacturer == 0x00U) && (memory_type == 0x00U) &&
|
||||||
|
(capacity == 0x00U)) ||
|
||||||
|
((manufacturer == 0xFFU) && (memory_type == 0xFFU) &&
|
||||||
|
(capacity == 0xFFU))) {
|
||||||
|
return SPI_NOR_E_NOT_FOUND;
|
||||||
|
}
|
||||||
|
return SPI_NOR_E_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Публичные defaults сохраняют тайм-ауты исходного HAL-драйвера. */
|
||||||
|
spi_nor_config_t spi_nor_default_config(void)
|
||||||
|
{
|
||||||
|
spi_nor_config_t config;
|
||||||
|
|
||||||
|
config.io_timeout_ms = 100U;
|
||||||
|
config.lock_timeout_ms = 100U;
|
||||||
|
config.program_timeout_ms = 1000U;
|
||||||
|
config.erase_timeout_ms = 5000U;
|
||||||
|
config.ready_poll_delay_ms = 1U;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Проверяет обязательные callbacks и сохраняет независимую копию контракта. */
|
||||||
|
spi_nor_status_t spi_nor_init(spi_nor_t *device, const spi_nor_io_t *io,
|
||||||
|
const spi_nor_config_t *config)
|
||||||
|
{
|
||||||
|
if ((device == NULL) || (io == NULL) || (io->transfer == NULL) ||
|
||||||
|
(io->chip_select == NULL) || (io->tick_ms == NULL) ||
|
||||||
|
(io->delay_ms == NULL) ||
|
||||||
|
((io->lock == NULL) != (io->unlock == NULL))) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
memset(device, 0, sizeof(*device));
|
||||||
|
device->io = *io;
|
||||||
|
device->config = (config != NULL) ? *config : spi_nor_default_config();
|
||||||
|
if ((device->config.io_timeout_ms == 0U) ||
|
||||||
|
(device->config.program_timeout_ms == 0U) ||
|
||||||
|
(device->config.erase_timeout_ms == 0U)) {
|
||||||
|
memset(device, 0, sizeof(*device));
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
device->io.chip_select(device->io.context, 0U);
|
||||||
|
device->initialized = 1U;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Будит устройство и сохраняет raw JEDEC даже при неизвестной модели. */
|
||||||
|
spi_nor_status_t spi_nor_probe(spi_nor_t *device)
|
||||||
|
{
|
||||||
|
uint8_t command = SPI_NOR_COMMAND_RELEASE_POWER_DOWN;
|
||||||
|
uint8_t jedec[3] = {0U, 0U, 0U};
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
result = begin_operation(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
device->detected = 0U;
|
||||||
|
memset(&device->info, 0, sizeof(device->info));
|
||||||
|
result = transaction(device, &command, 1U, NULL, NULL, 0U);
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
device->io.delay_ms(device->io.context, 1U);
|
||||||
|
command = SPI_NOR_COMMAND_READ_JEDEC_ID;
|
||||||
|
result = transaction(device, &command, 1U, NULL, jedec,
|
||||||
|
sizeof(jedec));
|
||||||
|
}
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
device->info.manufacturer_id = jedec[0];
|
||||||
|
device->info.memory_type = jedec[1];
|
||||||
|
device->info.capacity_code = jedec[2];
|
||||||
|
result = identify_device(device);
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
device->detected = 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end_operation(device);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Чтение проверяет адрес до активации CS и не использует внутренний RAM-кэш. */
|
||||||
|
spi_nor_status_t spi_nor_read(spi_nor_t *device, uint32_t address,
|
||||||
|
void *data, size_t size)
|
||||||
|
{
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
if (data == NULL) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
result = begin_operation(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = validate_range(device, address, size);
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
result = raw_read(device, address, data, size);
|
||||||
|
}
|
||||||
|
end_operation(device);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Запись разбивается по границам страниц; SST получает byte-program chunks. */
|
||||||
|
spi_nor_status_t spi_nor_program(spi_nor_t *device, uint32_t address,
|
||||||
|
const void *data, size_t size)
|
||||||
|
{
|
||||||
|
const uint8_t *source = data;
|
||||||
|
size_t remaining = size;
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
if (data == NULL) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
result = begin_operation(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result = validate_range(device, address, size);
|
||||||
|
while ((result == SPI_NOR_OK) && (remaining != 0U)) {
|
||||||
|
size_t page_remaining = device->info.page_size -
|
||||||
|
(address % device->info.page_size);
|
||||||
|
size_t chunk = (remaining < page_remaining) ?
|
||||||
|
remaining : page_remaining;
|
||||||
|
|
||||||
|
result = program_chunk(device, address, source, chunk);
|
||||||
|
address += (uint32_t)chunk;
|
||||||
|
source += chunk;
|
||||||
|
remaining -= chunk;
|
||||||
|
}
|
||||||
|
end_operation(device);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erase принимает только целые выровненные 4-КиБ секторы в пределах памяти. */
|
||||||
|
spi_nor_status_t spi_nor_erase(spi_nor_t *device, uint32_t address,
|
||||||
|
size_t size)
|
||||||
|
{
|
||||||
|
size_t remaining = size;
|
||||||
|
spi_nor_status_t result;
|
||||||
|
|
||||||
|
result = begin_operation(device);
|
||||||
|
if (result != SPI_NOR_OK) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (((address % SPI_NOR_SECTOR_SIZE) != 0U) ||
|
||||||
|
((size % SPI_NOR_SECTOR_SIZE) != 0U)) {
|
||||||
|
result = SPI_NOR_E_ALIGNMENT;
|
||||||
|
} else {
|
||||||
|
result = validate_range(device, address, size);
|
||||||
|
}
|
||||||
|
while ((result == SPI_NOR_OK) && (remaining != 0U)) {
|
||||||
|
uint8_t command[4];
|
||||||
|
|
||||||
|
result = write_enable(device);
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
make_address_command(command, SPI_NOR_COMMAND_SECTOR_ERASE,
|
||||||
|
address);
|
||||||
|
result = transaction(device, command, sizeof(command), NULL,
|
||||||
|
NULL, 0U);
|
||||||
|
}
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
result = wait_ready(device, device->config.erase_timeout_ms);
|
||||||
|
}
|
||||||
|
if (result == SPI_NOR_OK) {
|
||||||
|
result = verify_erased_sector(device, address);
|
||||||
|
}
|
||||||
|
address += SPI_NOR_SECTOR_SIZE;
|
||||||
|
remaining -= SPI_NOR_SECTOR_SIZE;
|
||||||
|
}
|
||||||
|
end_operation(device);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Копия info не позволяет внешнему коду менять геометрию активного контекста. */
|
||||||
|
spi_nor_status_t spi_nor_get_info(const spi_nor_t *device,
|
||||||
|
spi_nor_info_t *info)
|
||||||
|
{
|
||||||
|
if ((device == NULL) || (info == NULL)) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
if (device->detected == 0U) {
|
||||||
|
return SPI_NOR_E_NOT_FOUND;
|
||||||
|
}
|
||||||
|
*info = device->info;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raw JEDEC остаётся диагностически доступен после NOT_FOUND/UNSUPPORTED. */
|
||||||
|
spi_nor_status_t spi_nor_get_last_jedec(const spi_nor_t *device,
|
||||||
|
uint8_t jedec[3])
|
||||||
|
{
|
||||||
|
if ((device == NULL) || (jedec == NULL) ||
|
||||||
|
(device->initialized == 0U)) {
|
||||||
|
return SPI_NOR_E_ARGUMENT;
|
||||||
|
}
|
||||||
|
jedec[0] = device->info.manufacturer_id;
|
||||||
|
jedec[1] = device->info.memory_type;
|
||||||
|
jedec[2] = device->info.capacity_code;
|
||||||
|
return SPI_NOR_OK;
|
||||||
|
}
|
||||||
363
c/spi-nor/Tests/test_spi_nor.c
Normal file
363
c/spi-nor/Tests/test_spi_nor.c
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
#include "spi_nor.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Host mock хранит максимальную W25Q128 целиком и не использует STM32 HAL. */
|
||||||
|
#define MOCK_CAPACITY (16UL * 1024UL * 1024UL)
|
||||||
|
#define MOCK_SECTOR_SIZE 4096UL
|
||||||
|
#define MOCK_COMMAND_READ 0x03U
|
||||||
|
#define MOCK_COMMAND_WRITE 0x02U
|
||||||
|
#define MOCK_COMMAND_ERASE 0x20U
|
||||||
|
#define MOCK_COMMAND_STATUS 0x05U
|
||||||
|
#define MOCK_COMMAND_WREN 0x06U
|
||||||
|
#define MOCK_COMMAND_JEDEC 0x9FU
|
||||||
|
|
||||||
|
/* Состояние fake SPI моделирует команды, CS, ошибки и readback-порчу. */
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint8_t memory[MOCK_CAPACITY];
|
||||||
|
uint8_t jedec[3];
|
||||||
|
uint8_t opcode;
|
||||||
|
uint8_t chip_selected;
|
||||||
|
uint8_t write_enabled;
|
||||||
|
uint8_t fail_io;
|
||||||
|
uint8_t busy_forever;
|
||||||
|
uint8_t corrupt_program;
|
||||||
|
uint8_t corrupt_erase;
|
||||||
|
uint32_t address;
|
||||||
|
uint32_t tick_ms;
|
||||||
|
uint32_t page_program_count;
|
||||||
|
uint32_t erase_count;
|
||||||
|
} mock_spi_t;
|
||||||
|
|
||||||
|
/* Счётчики формируют короткий самостоятельный test runner без framework. */
|
||||||
|
static unsigned int tests_run;
|
||||||
|
static unsigned int tests_failed;
|
||||||
|
|
||||||
|
/* CHECK сохраняет имя строки и позволяет выполнить остальные сценарии. */
|
||||||
|
#define CHECK(condition) TestCheck((condition), #condition, __LINE__)
|
||||||
|
|
||||||
|
/* Регистрирует одно утверждение и печатает только диагностическую ошибку. */
|
||||||
|
static void TestCheck(int condition, const char *expression, int line)
|
||||||
|
{
|
||||||
|
++tests_run;
|
||||||
|
if (!condition) {
|
||||||
|
++tests_failed;
|
||||||
|
(void)printf("FAIL line %d: %s\n", line, expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Инициализация задаёт erased-состояние и JEDEC W25Q128 по умолчанию. */
|
||||||
|
static void MockInit(mock_spi_t *mock)
|
||||||
|
{
|
||||||
|
memset(mock, 0, sizeof(*mock));
|
||||||
|
memset(mock->memory, 0xFF, sizeof(mock->memory));
|
||||||
|
mock->jedec[0] = 0xEFU;
|
||||||
|
mock->jedec[1] = 0x40U;
|
||||||
|
mock->jedec[2] = 0x18U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Декодирует три адресных байта команды в физическое 24-битное смещение. */
|
||||||
|
static uint32_t MockAddress(const uint8_t *tx)
|
||||||
|
{
|
||||||
|
return ((uint32_t)tx[1] << 16U) |
|
||||||
|
((uint32_t)tx[2] << 8U) |
|
||||||
|
(uint32_t)tx[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fake transfer исполняет ровно тот callback-контракт, который видит ядро. */
|
||||||
|
static spi_nor_io_status_t MockTransfer(void *context, const uint8_t *tx,
|
||||||
|
uint8_t *rx, size_t size,
|
||||||
|
uint32_t timeout_ms)
|
||||||
|
{
|
||||||
|
mock_spi_t *mock = context;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
(void)timeout_ms;
|
||||||
|
if ((mock->fail_io != 0U) || (mock->chip_selected == 0U)) {
|
||||||
|
return SPI_NOR_IO_ERROR;
|
||||||
|
}
|
||||||
|
if (tx != NULL) {
|
||||||
|
if ((mock->opcode == MOCK_COMMAND_WRITE) && (size != 4U)) {
|
||||||
|
if (mock->write_enabled == 0U) {
|
||||||
|
return SPI_NOR_IO_ERROR;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < size; ++index) {
|
||||||
|
mock->memory[mock->address + index] &= tx[index];
|
||||||
|
}
|
||||||
|
if ((mock->corrupt_program != 0U) && (size != 0U)) {
|
||||||
|
mock->memory[mock->address] ^= 0x01U;
|
||||||
|
}
|
||||||
|
mock->address += (uint32_t)size;
|
||||||
|
mock->write_enabled = 0U;
|
||||||
|
++mock->page_program_count;
|
||||||
|
return SPI_NOR_IO_OK;
|
||||||
|
}
|
||||||
|
mock->opcode = tx[0];
|
||||||
|
if ((size == 4U) && ((mock->opcode == MOCK_COMMAND_READ) ||
|
||||||
|
(mock->opcode == MOCK_COMMAND_WRITE) ||
|
||||||
|
(mock->opcode == MOCK_COMMAND_ERASE))) {
|
||||||
|
mock->address = MockAddress(tx);
|
||||||
|
}
|
||||||
|
if (mock->opcode == MOCK_COMMAND_WREN) {
|
||||||
|
mock->write_enabled = 1U;
|
||||||
|
} else if (mock->opcode == MOCK_COMMAND_ERASE) {
|
||||||
|
if (mock->write_enabled == 0U) {
|
||||||
|
return SPI_NOR_IO_ERROR;
|
||||||
|
}
|
||||||
|
memset(&mock->memory[mock->address], 0xFF, MOCK_SECTOR_SIZE);
|
||||||
|
if (mock->corrupt_erase != 0U) {
|
||||||
|
mock->memory[mock->address + 7U] = 0x00U;
|
||||||
|
}
|
||||||
|
mock->write_enabled = 0U;
|
||||||
|
++mock->erase_count;
|
||||||
|
}
|
||||||
|
return SPI_NOR_IO_OK;
|
||||||
|
}
|
||||||
|
if (rx == NULL) {
|
||||||
|
return SPI_NOR_IO_ERROR;
|
||||||
|
}
|
||||||
|
if (mock->opcode == MOCK_COMMAND_JEDEC) {
|
||||||
|
memcpy(rx, mock->jedec, size);
|
||||||
|
} else if (mock->opcode == MOCK_COMMAND_STATUS) {
|
||||||
|
memset(rx, (mock->busy_forever != 0U) ? 1 : 0, size);
|
||||||
|
} else if (mock->opcode == MOCK_COMMAND_READ) {
|
||||||
|
memcpy(rx, &mock->memory[mock->address], size);
|
||||||
|
mock->address += (uint32_t)size;
|
||||||
|
} else {
|
||||||
|
memset(rx, 0xFF, size);
|
||||||
|
}
|
||||||
|
return SPI_NOR_IO_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mock CS фиксирует активность и сбрасывает opcode на границе транзакции. */
|
||||||
|
static void MockChipSelect(void *context, uint8_t active)
|
||||||
|
{
|
||||||
|
mock_spi_t *mock = context;
|
||||||
|
|
||||||
|
mock->chip_selected = active;
|
||||||
|
if (active != 0U) {
|
||||||
|
mock->opcode = 0U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mock tick управляется delay callback и делает timeout детерминированным. */
|
||||||
|
static uint32_t MockTick(void *context)
|
||||||
|
{
|
||||||
|
mock_spi_t *mock = context;
|
||||||
|
|
||||||
|
return mock->tick_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mock delay не ждёт реальное время, а продвигает виртуальные миллисекунды. */
|
||||||
|
static void MockDelay(void *context, uint32_t delay_ms)
|
||||||
|
{
|
||||||
|
mock_spi_t *mock = context;
|
||||||
|
|
||||||
|
mock->tick_ms += delay_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Создаёт IO contract без HAL, GPIO-регистров и глобальных handles. */
|
||||||
|
static spi_nor_io_t MockIo(mock_spi_t *mock)
|
||||||
|
{
|
||||||
|
spi_nor_io_t io;
|
||||||
|
|
||||||
|
memset(&io, 0, sizeof(io));
|
||||||
|
io.context = mock;
|
||||||
|
io.transfer = MockTransfer;
|
||||||
|
io.chip_select = MockChipSelect;
|
||||||
|
io.tick_ms = MockTick;
|
||||||
|
io.delay_ms = MockDelay;
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Успешный probe должен определить каждую заявленную модель и геометрию. */
|
||||||
|
static void TestSupportedJedec(void)
|
||||||
|
{
|
||||||
|
const uint8_t ids[3][3] = {
|
||||||
|
{0xEFU, 0x40U, 0x17U},
|
||||||
|
{0xEFU, 0x40U, 0x18U},
|
||||||
|
{0xBFU, 0x25U, 0x41U}
|
||||||
|
};
|
||||||
|
const uint32_t capacities[3] = {
|
||||||
|
8UL * 1024UL * 1024UL,
|
||||||
|
16UL * 1024UL * 1024UL,
|
||||||
|
2UL * 1024UL * 1024UL
|
||||||
|
};
|
||||||
|
unsigned int index;
|
||||||
|
|
||||||
|
for (index = 0U; index < 3U; ++index) {
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_info_t info;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
memcpy(mock.jedec, ids[index], sizeof(mock.jedec));
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_get_info(&device, &info) == SPI_NOR_OK);
|
||||||
|
CHECK(info.capacity_bytes == capacities[index]);
|
||||||
|
CHECK(mock.chip_selected == 0U);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unknown и пустая шина должны различаться, сохраняя raw JEDEC. */
|
||||||
|
static void TestProbeFailures(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
uint8_t raw[3];
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
mock.jedec[0] = 0x12U;
|
||||||
|
mock.jedec[1] = 0x34U;
|
||||||
|
mock.jedec[2] = 0x56U;
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_E_UNSUPPORTED);
|
||||||
|
CHECK(spi_nor_get_last_jedec(&device, raw) == SPI_NOR_OK);
|
||||||
|
CHECK(memcmp(raw, mock.jedec, sizeof(raw)) == 0);
|
||||||
|
memset(mock.jedec, 0xFF, sizeof(mock.jedec));
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_E_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Program через границу страницы обязан создать два chunks и точный readback. */
|
||||||
|
static void TestProgramAndRead(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
uint8_t source[20];
|
||||||
|
uint8_t result[20];
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
for (index = 0U; index < sizeof(source); ++index) {
|
||||||
|
source[index] = (uint8_t)(index + 1U);
|
||||||
|
}
|
||||||
|
CHECK(spi_nor_program(&device, 250U, source, sizeof(source)) == SPI_NOR_OK);
|
||||||
|
CHECK(mock.page_program_count == 2U);
|
||||||
|
memset(result, 0, sizeof(result));
|
||||||
|
CHECK(spi_nor_read(&device, 250U, result, sizeof(result)) == SPI_NOR_OK);
|
||||||
|
CHECK(memcmp(source, result, sizeof(source)) == 0);
|
||||||
|
CHECK(spi_nor_read(&device, MOCK_CAPACITY - 2U, result, 4U) ==
|
||||||
|
SPI_NOR_E_BOUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SST25 использует безопасную последовательность из отдельных Byte Program. */
|
||||||
|
static void TestSstByteProgram(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
const uint8_t source[3] = {0x11U, 0x22U, 0x33U};
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
mock.jedec[0] = 0xBFU;
|
||||||
|
mock.jedec[1] = 0x25U;
|
||||||
|
mock.jedec[2] = 0x41U;
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_program(&device, 0U, source, sizeof(source)) == SPI_NOR_OK);
|
||||||
|
CHECK(mock.page_program_count == 3U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readback-порча program должна возвращаться отдельной verify-ошибкой. */
|
||||||
|
static void TestProgramVerifyFailure(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
const uint8_t source[2] = {0x12U, 0x34U};
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
mock.corrupt_program = 1U;
|
||||||
|
CHECK(spi_nor_program(&device, 0U, source, sizeof(source)) ==
|
||||||
|
SPI_NOR_E_VERIFY);
|
||||||
|
CHECK(mock.chip_selected == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erase проверяет alignment, весь сектор и readback-порчу. */
|
||||||
|
static void TestErase(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
mock.memory[9U] = 0x00U;
|
||||||
|
CHECK(spi_nor_erase(&device, 1U, MOCK_SECTOR_SIZE) ==
|
||||||
|
SPI_NOR_E_ALIGNMENT);
|
||||||
|
CHECK(spi_nor_erase(&device, 0U, MOCK_SECTOR_SIZE) == SPI_NOR_OK);
|
||||||
|
CHECK(mock.memory[9U] == 0xFFU);
|
||||||
|
mock.corrupt_erase = 1U;
|
||||||
|
CHECK(spi_nor_erase(&device, 0U, MOCK_SECTOR_SIZE) == SPI_NOR_E_VERIFY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вечный BUSY использует виртуальный tick и заканчивается timeout, не зависая. */
|
||||||
|
static void TestTimeout(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
spi_nor_config_t config = spi_nor_default_config();
|
||||||
|
const uint8_t value = 0x55U;
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
io = MockIo(&mock);
|
||||||
|
config.program_timeout_ms = 3U;
|
||||||
|
CHECK(spi_nor_init(&device, &io, &config) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
mock.busy_forever = 1U;
|
||||||
|
CHECK(spi_nor_program(&device, 0U, &value, 1U) == SPI_NOR_E_TIMEOUT);
|
||||||
|
CHECK(mock.tick_ms >= 3U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ошибка обмена обязана поднять CS и не оставлять контекст permanently busy. */
|
||||||
|
static void TestIoFailureReleasesState(void)
|
||||||
|
{
|
||||||
|
static mock_spi_t mock;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_io_t io;
|
||||||
|
uint8_t value;
|
||||||
|
|
||||||
|
MockInit(&mock);
|
||||||
|
io = MockIo(&mock);
|
||||||
|
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
|
||||||
|
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
|
||||||
|
mock.fail_io = 1U;
|
||||||
|
CHECK(spi_nor_read(&device, 0U, &value, 1U) == SPI_NOR_E_IO);
|
||||||
|
CHECK(mock.chip_selected == 0U);
|
||||||
|
mock.fail_io = 0U;
|
||||||
|
CHECK(spi_nor_read(&device, 0U, &value, 1U) == SPI_NOR_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main выполняет все host-сценарии и возвращает ненулевой код при сбое. */
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
TestSupportedJedec();
|
||||||
|
TestProbeFailures();
|
||||||
|
TestProgramAndRead();
|
||||||
|
TestSstByteProgram();
|
||||||
|
TestProgramVerifyFailure();
|
||||||
|
TestErase();
|
||||||
|
TestTimeout();
|
||||||
|
TestIoFailureReleasesState();
|
||||||
|
(void)printf("SPI NOR host mock: %u checks, %u failures\n",
|
||||||
|
tests_run, tests_failed);
|
||||||
|
return (tests_failed == 0U) ? 0 : 1;
|
||||||
|
}
|
||||||
166
c/spi-nor/Tests/test_spi_nor_command_service.c
Normal file
166
c/spi-nor/Tests/test_spi_nor_command_service.c
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#include "spi_nor_command_service.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
/* Счётчики доказывают единственную пару assert/release для каждого вызова. */
|
||||||
|
uint8_t cs_active;
|
||||||
|
uint8_t cs_asserts;
|
||||||
|
uint8_t cs_releases;
|
||||||
|
uint8_t transfers;
|
||||||
|
spi_nor_io_status_t fail_on_transfer;
|
||||||
|
uint8_t fail_transfer_number;
|
||||||
|
} mock_bus_t;
|
||||||
|
|
||||||
|
static unsigned checks;
|
||||||
|
static unsigned failures;
|
||||||
|
|
||||||
|
/* Каждый CHECK увеличивает общий счётчик, чтобы тест не мог пройти пустым. */
|
||||||
|
#define CHECK(value) do { checks++; if (!(value)) { failures++; } } while (0)
|
||||||
|
|
||||||
|
/* Mock различает TX и dummy RX, сохраняя реальный порядок одной транзакции. */
|
||||||
|
static spi_nor_io_status_t mock_transfer(void *context, const uint8_t *tx,
|
||||||
|
uint8_t *rx, size_t size,
|
||||||
|
uint32_t timeout_ms)
|
||||||
|
{
|
||||||
|
mock_bus_t *bus = context;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
(void)timeout_ms;
|
||||||
|
/* Номер transfer позволяет отдельно сломать TX и последующий dummy RX. */
|
||||||
|
bus->transfers++;
|
||||||
|
if ((bus->fail_transfer_number != 0U) &&
|
||||||
|
(bus->transfers == bus->fail_transfer_number)) {
|
||||||
|
return bus->fail_on_transfer;
|
||||||
|
}
|
||||||
|
if ((tx == NULL) && (rx != NULL)) {
|
||||||
|
for (index = 0U; index < size; index++) {
|
||||||
|
rx[index] = (uint8_t)(0xA0U + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SPI_NOR_IO_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void mock_cs(void *context, uint8_t active)
|
||||||
|
{
|
||||||
|
mock_bus_t *bus = context;
|
||||||
|
|
||||||
|
/* Последнее состояние проверяется после каждого error/timeout сценария. */
|
||||||
|
bus->cs_active = active;
|
||||||
|
if (active != 0U) {
|
||||||
|
bus->cs_asserts++;
|
||||||
|
} else {
|
||||||
|
bus->cs_releases++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reset_bus(mock_bus_t *bus)
|
||||||
|
{
|
||||||
|
/* Сценарии не наследуют счётчики и forced error предыдущего вызова. */
|
||||||
|
memset(bus, 0, sizeof(*bus));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
mock_bus_t bus;
|
||||||
|
spi_nor_t device;
|
||||||
|
spi_nor_command_service_request_t request;
|
||||||
|
spi_nor_command_service_response_t response;
|
||||||
|
static const uint8_t blocked[] = {
|
||||||
|
0x06U, 0x02U, 0x20U, 0x52U, 0xD8U, 0xC7U, 0x60U,
|
||||||
|
0x01U, 0x31U, 0x11U, 0x36U, 0x39U, 0xB9U, 0xABU,
|
||||||
|
0x66U, 0x99U, 0x04U, 0xFFU
|
||||||
|
};
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
/* Mock device считается уже успешно probed поддержанной SPI NOR. */
|
||||||
|
memset(&device, 0, sizeof(device));
|
||||||
|
reset_bus(&bus);
|
||||||
|
device.io.context = &bus;
|
||||||
|
device.io.transfer = mock_transfer;
|
||||||
|
device.io.chip_select = mock_cs;
|
||||||
|
device.config.io_timeout_ms = 10U;
|
||||||
|
device.config.lock_timeout_ms = 10U;
|
||||||
|
device.info.capacity_bytes = 256U;
|
||||||
|
device.initialized = 1U;
|
||||||
|
device.detected = 1U;
|
||||||
|
|
||||||
|
memset(&request, 0, sizeof(request));
|
||||||
|
request.sequence = 7U;
|
||||||
|
request.tx_length = 1U;
|
||||||
|
request.rx_length = 3U;
|
||||||
|
request.tx[0] = 0x9FU;
|
||||||
|
/* JEDEC проверяет данные, sequence, echo и физический порядок transfers. */
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK);
|
||||||
|
CHECK(response.sequence == 7U && response.tx_echo[0] == 0x9FU);
|
||||||
|
CHECK(response.rx_length == 3U && response.rx[0] == 0xA0U &&
|
||||||
|
response.rx[2] == 0xA2U);
|
||||||
|
CHECK(bus.cs_asserts == 1U && bus.cs_releases == 1U &&
|
||||||
|
bus.cs_active == 0U && bus.transfers == 2U);
|
||||||
|
|
||||||
|
/* Status и последний физический byte разрешены строгими shape/bounds. */
|
||||||
|
reset_bus(&bus);
|
||||||
|
request.tx[0] = 0x05U;
|
||||||
|
request.rx_length = 1U;
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK);
|
||||||
|
request.tx[0] = 0x03U;
|
||||||
|
request.tx[1] = 0U;
|
||||||
|
request.tx[2] = 0U;
|
||||||
|
request.tx[3] = 0xFFU;
|
||||||
|
request.tx_length = 4U;
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_OK);
|
||||||
|
request.rx_length = 2U;
|
||||||
|
/* Последний адрес плюс два байта обязан отклониться до активации CS. */
|
||||||
|
CHECK(spi_nor_command_service_validate(&device, &request) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_INVALID);
|
||||||
|
|
||||||
|
/* Весь mutating/state-changing набор и неизвестный opcode fail-closed. */
|
||||||
|
request.tx_length = 1U;
|
||||||
|
request.rx_length = 1U;
|
||||||
|
/* Список включает все опасные семейства opcode и произвольный unknown FF. */
|
||||||
|
for (index = 0U; index < sizeof(blocked); index++) {
|
||||||
|
request.tx[0] = blocked[index];
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_BLOCKED);
|
||||||
|
CHECK(bus.cs_active == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Неверные длины не обращаются к шине. */
|
||||||
|
reset_bus(&bus);
|
||||||
|
request.tx[0] = 0x9FU;
|
||||||
|
request.tx_length = 1U;
|
||||||
|
request.rx_length = SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES + 1U;
|
||||||
|
/* Проверяем не только статус, но и полное отсутствие физического I/O. */
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_INVALID);
|
||||||
|
CHECK(bus.transfers == 0U && bus.cs_asserts == 0U);
|
||||||
|
|
||||||
|
/* CS обязан стать inactive при ошибке TX, ошибке RX и timeout RX. */
|
||||||
|
request.rx_length = 3U;
|
||||||
|
/* Одинаковая гарантия CS применяется к первому и второму transfer. */
|
||||||
|
for (index = 1U; index <= 2U; index++) {
|
||||||
|
reset_bus(&bus);
|
||||||
|
bus.fail_transfer_number = (uint8_t)index;
|
||||||
|
bus.fail_on_transfer = SPI_NOR_IO_ERROR;
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_IO_ERROR);
|
||||||
|
CHECK(bus.cs_releases == 1U && bus.cs_active == 0U &&
|
||||||
|
response.rx_length == 0U);
|
||||||
|
}
|
||||||
|
reset_bus(&bus);
|
||||||
|
bus.fail_transfer_number = 2U;
|
||||||
|
bus.fail_on_transfer = SPI_NOR_IO_TIMEOUT;
|
||||||
|
/* Timeout имеет отдельный статус, но ту же очистку RX и освобождение CS. */
|
||||||
|
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
|
||||||
|
SPI_NOR_COMMAND_SERVICE_TIMEOUT);
|
||||||
|
CHECK(bus.cs_releases == 1U && bus.cs_active == 0U);
|
||||||
|
|
||||||
|
(void)printf("SPI NOR command service: %u checks, %u failures\n",
|
||||||
|
checks, failures);
|
||||||
|
return failures == 0U ? 0 : 1;
|
||||||
|
}
|
||||||
74
c/spi-nor/Tests/test_spi_nor_read_service.c
Normal file
74
c/spi-nor/Tests/test_spi_nor_read_service.c
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#include "spi_nor_read_service.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static unsigned checks;
|
||||||
|
static unsigned failures;
|
||||||
|
static uint8_t image[256];
|
||||||
|
static spi_nor_read_service_status_t forced_status = SPI_NOR_READ_SERVICE_OK;
|
||||||
|
|
||||||
|
#define CHECK(value) do { checks++; if (!(value)) { failures++; } } while (0)
|
||||||
|
|
||||||
|
/* Mock копирует детерминированный образ и умеет вернуть физическую ошибку. */
|
||||||
|
static spi_nor_read_service_status_t mock_read(void *context, uint32_t address,
|
||||||
|
void *data, size_t size)
|
||||||
|
{
|
||||||
|
(void)context;
|
||||||
|
if (forced_status != SPI_NOR_READ_SERVICE_OK) {
|
||||||
|
memset(data, 0xA5, size);
|
||||||
|
return forced_status;
|
||||||
|
}
|
||||||
|
memcpy(data, &image[address], size);
|
||||||
|
return SPI_NOR_READ_SERVICE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
spi_nor_read_service_t service;
|
||||||
|
spi_nor_read_service_request_t request;
|
||||||
|
spi_nor_read_service_response_t response;
|
||||||
|
unsigned index;
|
||||||
|
|
||||||
|
for (index = 0U; index < sizeof(image); index++) {
|
||||||
|
image[index] = (uint8_t)index;
|
||||||
|
}
|
||||||
|
CHECK(spi_nor_read_service_init(&service, NULL, mock_read,
|
||||||
|
sizeof(image)) == SPI_NOR_READ_SERVICE_OK);
|
||||||
|
request.address = 0U;
|
||||||
|
request.length = 1U;
|
||||||
|
request.sequence = 7U;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_OK);
|
||||||
|
CHECK(response.data[0] == 0U && response.sequence == 7U);
|
||||||
|
|
||||||
|
/* Последний физический байт является допустимой включительной границей. */
|
||||||
|
request.address = sizeof(image) - 1U;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_OK);
|
||||||
|
CHECK(response.data[0] == 0xFFU);
|
||||||
|
|
||||||
|
request.address = sizeof(image);
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_INVALID);
|
||||||
|
request.address = sizeof(image) - 32U;
|
||||||
|
request.length = 33U;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_INVALID);
|
||||||
|
request.address = 0U;
|
||||||
|
request.length = 0U;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_INVALID);
|
||||||
|
request.length = SPI_NOR_READ_SERVICE_MAX_BYTES + 1U;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_INVALID);
|
||||||
|
|
||||||
|
request.length = 8U;
|
||||||
|
forced_status = SPI_NOR_READ_SERVICE_IO_ERROR;
|
||||||
|
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
|
||||||
|
SPI_NOR_READ_SERVICE_IO_ERROR);
|
||||||
|
CHECK(response.length == 0U && response.data[0] == 0U);
|
||||||
|
|
||||||
|
(void)printf("SPI NOR read service: %u checks, %u failures\n", checks, failures);
|
||||||
|
return failures == 0U ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ Flash — отдельные компоненты (`rs485-boot`, `protocan-boot`
|
|||||||
```text
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
SETGUI/
|
SETGUI/
|
||||||
PUBLISH_FIRMWARE.bat
|
buildb_bat/PUBLISH_FIRMWARE.bat
|
||||||
.venv/Scripts/python.exe
|
.venv/Scripts/python.exe
|
||||||
MyFirmware/
|
MyFirmware/
|
||||||
firmware-release.cmd
|
firmware-release.cmd
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user