From 2ad29e7ffdab8ddbc83371b80d1866ba7c678ac6 Mon Sep 17 00:00:00 2001 From: andrey Date: Sun, 27 Sep 2026 01:44:59 +0300 Subject: [PATCH] Add embedded storage drivers and extend firmware metadata --- c/ds18b20/CMakeLists.txt | 2 + c/ds18b20/instance/CMakeLists.txt | 11 + c/ds18b20/instance/HELP.md | 384 ++++++++ c/ds18b20/instance/Inc/ds18b20.h | 126 +++ c/ds18b20/instance/Inc/ds18b20_config.h | 17 + c/ds18b20/instance/PORTING.md | 235 +++++ c/ds18b20/instance/README.md | 97 ++ c/ds18b20/instance/Src/ds18b20.c | 353 ++++++++ c/ds18b20/instance/Tests/run_host_tests.ps1 | 31 + .../instance/Tests/test_ds18b20_copy_delay.c | 92 ++ .../Tests/test_ds18b20_incremental_search.c | 152 ++++ c/firmware-info/include/firmware_info.h | 10 + c/firmware-info/tests/test_firmware_info.c | 6 + c/flash-storage/CMakeLists.txt | 11 + c/flash-storage/Inc/flash_storage.h | 104 +++ c/flash-storage/Inc/flash_storage_config.h | 8 + c/flash-storage/README.md | 43 + c/flash-storage/Src/flash_storage.c | 63 ++ c/flash-storage/Tests/test_flash_storage.c | 27 + c/modbus/CMakeLists.txt | 9 + c/modbus/README.md | 41 + c/modbus/include/modbus_data.h | 15 + c/modbus/ports/stm32-legacy/modbus.c | 836 ++++++++++++++++++ c/modbus/src/modbus_data.c | 57 ++ c/modbus/tests/test_modbus_data.c | 29 + c/sd-file-browser/CMakeLists.txt | 10 + c/sd-file-browser/Inc/sd_file_browser.h | 82 ++ c/sd-file-browser/README.md | 30 + c/sd-file-browser/Src/sd_file_browser.c | 177 ++++ .../Tests/test_sd_file_browser.c | 67 ++ c/settings-backup/CMakeLists.txt | 10 + c/settings-backup/Inc/settings_backup.h | 125 +++ .../Inc/settings_backup_config.h | 11 + c/settings-backup/README.md | 89 ++ c/settings-backup/Src/settings_backup.c | 362 ++++++++ .../Tests/test_settings_backup.c | 43 + .../Tests/test_settings_backup_model.py | 196 ++++ c/spi-nor/CMakeLists.txt | 10 + .../Diagnostics/Inc/spi_nor_command_service.h | 72 ++ .../Diagnostics/Inc/spi_nor_read_service.h | 73 ++ .../Diagnostics/Src/spi_nor_command_service.c | 165 ++++ .../Diagnostics/Src/spi_nor_read_service.c | 59 ++ c/spi-nor/Inc/spi_nor.h | 140 +++ c/spi-nor/PORTING.md | 123 +++ c/spi-nor/README.md | 113 +++ c/spi-nor/Src/spi_nor.c | 472 ++++++++++ c/spi-nor/Tests/test_spi_nor.c | 363 ++++++++ .../Tests/test_spi_nor_command_service.c | 166 ++++ c/spi-nor/Tests/test_spi_nor_read_service.c | 74 ++ tools/firmware-publish/PORTING.md | 2 +- tools/firmware-publish/PUBLISH_FIRMWARE.bat | 7 +- tools/firmware-publish/README.md | 4 + 52 files changed, 5801 insertions(+), 3 deletions(-) create mode 100644 c/ds18b20/instance/CMakeLists.txt create mode 100644 c/ds18b20/instance/HELP.md create mode 100644 c/ds18b20/instance/Inc/ds18b20.h create mode 100644 c/ds18b20/instance/Inc/ds18b20_config.h create mode 100644 c/ds18b20/instance/PORTING.md create mode 100644 c/ds18b20/instance/README.md create mode 100644 c/ds18b20/instance/Src/ds18b20.c create mode 100644 c/ds18b20/instance/Tests/run_host_tests.ps1 create mode 100644 c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c create mode 100644 c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c create mode 100644 c/flash-storage/CMakeLists.txt create mode 100644 c/flash-storage/Inc/flash_storage.h create mode 100644 c/flash-storage/Inc/flash_storage_config.h create mode 100644 c/flash-storage/README.md create mode 100644 c/flash-storage/Src/flash_storage.c create mode 100644 c/flash-storage/Tests/test_flash_storage.c create mode 100644 c/modbus/CMakeLists.txt create mode 100644 c/modbus/README.md create mode 100644 c/modbus/include/modbus_data.h create mode 100644 c/modbus/ports/stm32-legacy/modbus.c create mode 100644 c/modbus/src/modbus_data.c create mode 100644 c/modbus/tests/test_modbus_data.c create mode 100644 c/sd-file-browser/CMakeLists.txt create mode 100644 c/sd-file-browser/Inc/sd_file_browser.h create mode 100644 c/sd-file-browser/README.md create mode 100644 c/sd-file-browser/Src/sd_file_browser.c create mode 100644 c/sd-file-browser/Tests/test_sd_file_browser.c create mode 100644 c/settings-backup/CMakeLists.txt create mode 100644 c/settings-backup/Inc/settings_backup.h create mode 100644 c/settings-backup/Inc/settings_backup_config.h create mode 100644 c/settings-backup/README.md create mode 100644 c/settings-backup/Src/settings_backup.c create mode 100644 c/settings-backup/Tests/test_settings_backup.c create mode 100644 c/settings-backup/Tests/test_settings_backup_model.py create mode 100644 c/spi-nor/CMakeLists.txt create mode 100644 c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h create mode 100644 c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h create mode 100644 c/spi-nor/Diagnostics/Src/spi_nor_command_service.c create mode 100644 c/spi-nor/Diagnostics/Src/spi_nor_read_service.c create mode 100644 c/spi-nor/Inc/spi_nor.h create mode 100644 c/spi-nor/PORTING.md create mode 100644 c/spi-nor/README.md create mode 100644 c/spi-nor/Src/spi_nor.c create mode 100644 c/spi-nor/Tests/test_spi_nor.c create mode 100644 c/spi-nor/Tests/test_spi_nor_command_service.c create mode 100644 c/spi-nor/Tests/test_spi_nor_read_service.c diff --git a/c/ds18b20/CMakeLists.txt b/c/ds18b20/CMakeLists.txt index 10aec88..59f0d60 100644 --- a/c/ds18b20/CMakeLists.txt +++ b/c/ds18b20/CMakeLists.txt @@ -33,3 +33,5 @@ if(DS18B20_BUILD_TESTS) target_include_directories(test_ds18b20 PRIVATE tests) add_test(NAME ds18b20 COMMAND test_ds18b20) endif() + +add_subdirectory(instance) diff --git a/c/ds18b20/instance/CMakeLists.txt b/c/ds18b20/instance/CMakeLists.txt new file mode 100644 index 0000000..eb3fa80 --- /dev/null +++ b/c/ds18b20/instance/CMakeLists.txt @@ -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() diff --git a/c/ds18b20/instance/HELP.md b/c/ds18b20/instance/HELP.md new file mode 100644 index 0000000..2aa5f53 --- /dev/null +++ b/c/ds18b20/instance/HELP.md @@ -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 м, +отключение/подключение датчика при поиске, отмену и восстановление опроса. diff --git a/c/ds18b20/instance/Inc/ds18b20.h b/c/ds18b20/instance/Inc/ds18b20.h new file mode 100644 index 0000000..138b6a2 --- /dev/null +++ b/c/ds18b20/instance/Inc/ds18b20.h @@ -0,0 +1,126 @@ +#ifndef PORTABLE_DS18B20_H +#define PORTABLE_DS18B20_H + +#include +#include + +#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 diff --git a/c/ds18b20/instance/Inc/ds18b20_config.h b/c/ds18b20/instance/Inc/ds18b20_config.h new file mode 100644 index 0000000..a2a8305 --- /dev/null +++ b/c/ds18b20/instance/Inc/ds18b20_config.h @@ -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 diff --git a/c/ds18b20/instance/PORTING.md b/c/ds18b20/instance/PORTING.md new file mode 100644 index 0000000..d5af72c --- /dev/null +++ b/c/ds18b20/instance/PORTING.md @@ -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//Inc` и `Port//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 переносите два поля +диагностики лишь в свободные регистры, не сдвигая последующие окна. diff --git a/c/ds18b20/instance/README.md b/c/ds18b20/instance/README.md new file mode 100644 index 0000000..aae3c93 --- /dev/null +++ b/c/ds18b20/instance/README.md @@ -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. diff --git a/c/ds18b20/instance/Src/ds18b20.c b/c/ds18b20/instance/Src/ds18b20.c new file mode 100644 index 0000000..41e38e2 --- /dev/null +++ b/c/ds18b20/instance/Src/ds18b20.c @@ -0,0 +1,353 @@ +#include "ds18b20.h" +#include "ds18b20_config.h" +#include + +#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_nolast_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)&&irom_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; +} diff --git a/c/ds18b20/instance/Tests/run_host_tests.ps1 b/c/ds18b20/instance/Tests/run_host_tests.ps1 new file mode 100644 index 0000000..b223b67 --- /dev/null +++ b/c/ds18b20/instance/Tests/run_host_tests.ps1 @@ -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 diff --git a/c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c b/c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c new file mode 100644 index 0000000..13529f4 --- /dev/null +++ b/c/ds18b20/instance/Tests/test_ds18b20_copy_delay.c @@ -0,0 +1,92 @@ +#include "ds18b20.h" + +#include +#include +#include + +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; +} diff --git a/c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c b/c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c new file mode 100644 index 0000000..073ff62 --- /dev/null +++ b/c/ds18b20/instance/Tests/test_ds18b20_incremental_search.c @@ -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 +#include +#include + +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; +} diff --git a/c/firmware-info/include/firmware_info.h b/c/firmware-info/include/firmware_info.h index 7dc8dd6..07531f8 100644 --- a/c/firmware-info/include/firmware_info.h +++ b/c/firmware-info/include/firmware_info.h @@ -32,6 +32,16 @@ typedef enum { FIRMWARE_INFO_OUT_OF_RANGE } 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_parse_build_stamp( const char *date_text, const char *time_text, firmware_info_t *info); diff --git a/c/firmware-info/tests/test_firmware_info.c b/c/firmware-info/tests/test_firmware_info.c index 70fad0c..4c52854 100644 --- a/c/firmware-info/tests/test_firmware_info.c +++ b/c/firmware-info/tests/test_firmware_info.c @@ -16,5 +16,11 @@ int main(void) assert(words[8] == 0x6162U && words[9] == 0x6330U); 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); + { + 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; } diff --git a/c/flash-storage/CMakeLists.txt b/c/flash-storage/CMakeLists.txt new file mode 100644 index 0000000..e0fe422 --- /dev/null +++ b/c/flash-storage/CMakeLists.txt @@ -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) diff --git a/c/flash-storage/Inc/flash_storage.h b/c/flash-storage/Inc/flash_storage.h new file mode 100644 index 0000000..db59a6d --- /dev/null +++ b/c/flash-storage/Inc/flash_storage.h @@ -0,0 +1,104 @@ +#ifndef PORTABLE_FLASH_STORAGE_H +#define PORTABLE_FLASH_STORAGE_H +#include +#include + +#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 diff --git a/c/flash-storage/Inc/flash_storage_config.h b/c/flash-storage/Inc/flash_storage_config.h new file mode 100644 index 0000000..f4b8fb7 --- /dev/null +++ b/c/flash-storage/Inc/flash_storage_config.h @@ -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 diff --git a/c/flash-storage/README.md b/c/flash-storage/README.md new file mode 100644 index 0000000..416a19b --- /dev/null +++ b/c/flash-storage/README.md @@ -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. diff --git a/c/flash-storage/Src/flash_storage.c b/c/flash-storage/Src/flash_storage.c new file mode 100644 index 0000000..2cbef71 --- /dev/null +++ b/c/flash-storage/Src/flash_storage.c @@ -0,0 +1,63 @@ +#include "flash_storage.h" +#include "flash_storage_config.h" +#include +#include + +#define NO_ADDRESS 0U +#define V2_PAYLOAD 512U +#define V1_PAYLOAD 256U + +typedef struct { uint32_t magic;uint16_t version,header_size;uint32_t sequence,length,crc32,reserved;uint8_t payload[V2_PAYLOAD];uint32_t pad,commit; } record_v2_t; +typedef struct { uint32_t magic;uint16_t version,header_size;uint32_t sequence,length,crc32,reserved;uint8_t payload[V1_PAYLOAD];uint32_t pad,commit; } record_v1_t; +typedef char v2_size_check[(sizeof(record_v2_t)==FLASH_STORAGE_RECORD_SIZE)?1:-1]; +typedef char v1_size_check[(sizeof(record_v1_t)==FLASH_STORAGE_V1_RECORD_SIZE)?1:-1]; + +static uint32_t crc_update(uint32_t crc,const uint8_t *p,size_t n) +{ size_t i;uint8_t bit;for(i=0;i>1U)^((0U-(crc&1U))&0xEDB88320UL);}return crc; } +static uint32_t fields_crc(uint16_t v,uint32_t seq,uint32_t len,const uint8_t *p) +{ uint32_t c=0xFFFFFFFFUL;c=crc_update(c,(uint8_t*)&v,sizeof(v));c=crc_update(c,(uint8_t*)&seq,sizeof(seq));c=crc_update(c,(uint8_t*)&len,sizeof(len));c=crc_update(c,p,len);return c^0xFFFFFFFFUL; } +static int newer(uint32_t a,uint32_t b){return (int32_t)(a-b)>0;} +static uint32_t count_v2(const flash_storage_t *s){return ((s->layout.bank_size*(100U-s->layout.reserve_percent))/100U)/FLASH_STORAGE_RECORD_SIZE;} +static int read_at(flash_storage_t*s,uint32_t a,void*p,size_t n){return s->ops->read(s->platform_context,a,p,n)==FLASH_STORAGE_OK;} +static int erased(flash_storage_t*s,uint32_t a,size_t n) +{ uint32_t w;size_t i;for(i=0;icommit==FLASH_STORAGE_COMMIT&&r->magic==FLASH_STORAGE_MAGIC&&r->version==2U&&r->header_size==offsetof(record_v2_t,payload)&&r->length&&r->length<=V2_PAYLOAD&&r->crc32==fields_crc(r->version,r->sequence,r->length,r->payload);} +static int valid_v1(const record_v1_t*r){return r->commit==FLASH_STORAGE_COMMIT&&r->magic==FLASH_STORAGE_MAGIC&&r->version==1U&&r->header_size==offsetof(record_v1_t,payload)&&r->length&&r->length<=V1_PAYLOAD&&r->crc32==fields_crc(r->version,r->sequence,r->length,r->payload);} + +static flash_storage_status_t scan(flash_storage_t*s) +{ uint32_t b,i,a,active=0U,n2=count_v2(s),n1=(s->layout.bank_size*90U/100U)/FLASH_STORAGE_V1_RECORD_SIZE;int have=0;record_v2_t *r2=(record_v2_t *)s->workspace;record_v1_t *r1=(record_v1_t *)s->workspace; + s->latest_address=NO_ADDRESS;s->latest_sequence=0;s->latest_version=0;s->next_address=NO_ADDRESS;s->valid_records=0;s->programmed_slots=0; + for(b=0;b<2U;++b){for(i=0;ilayout.bank_address[b]+i*FLASH_STORAGE_RECORD_SIZE;if(!erased(s,a,FLASH_STORAGE_RECORD_SIZE))++s->programmed_slots;if(read_at(s,a,r2,sizeof(*r2))&&valid_v2(r2)){++s->valid_records;if(!have||newer(r2->sequence,s->latest_sequence)){have=1;s->latest_address=a;s->latest_sequence=r2->sequence;s->latest_version=2U;active=b;}}} + /* Старую сетку сканируем отдельно: CRC/version исключают ложное совпадение с v2. */ + for(i=0;ilayout.bank_address[b]+i*FLASH_STORAGE_V1_RECORD_SIZE;if(read_at(s,a,r1,sizeof(*r1))&&valid_v1(r1)){++s->valid_records;if(!have||newer(r1->sequence,s->latest_sequence)){have=1;s->latest_address=a;s->latest_sequence=r1->sequence;s->latest_version=1U;active=b;}}}} + if(have){for(i=0;ilayout.bank_address[active]+i*FLASH_STORAGE_RECORD_SIZE;if(a>s->latest_address&&erased(s,a,FLASH_STORAGE_RECORD_SIZE)){s->next_address=a;break;}}} + else for(b=0;b<2U&&!s->next_address;++b)for(i=0;ilayout.bank_address[b]+i*FLASH_STORAGE_RECORD_SIZE;if(erased(s,a,FLASH_STORAGE_RECORD_SIZE)){s->next_address=a;break;}} + return FLASH_STORAGE_OK; } + +static flash_storage_status_t validate_layout(const flash_storage_ops_t*o,const flash_storage_layout_t*l) +{ if(!o||!l||!o->read||!o->program||!o->erase||!l->bank_address[0]||!l->bank_address[1]||l->bank_address[0]==l->bank_address[1]||l->bank_sizereserve_percent<10U||l->reserve_percent>=100U||!l->program_alignment||!l->erase_alignment||FLASH_STORAGE_RECORD_SIZE%l->program_alignment||l->bank_address[0]%l->erase_alignment||l->bank_address[1]%l->erase_alignment||l->bank_size%l->erase_alignment)return FLASH_STORAGE_E_LAYOUT;return FLASH_STORAGE_OK; } +flash_storage_status_t flash_storage_init(flash_storage_t*s,const flash_storage_ops_t*o,void*c,const flash_storage_layout_t*l) +{ flash_storage_status_t st;if(!s)return FLASH_STORAGE_E_ARGUMENT;st=validate_layout(o,l);if(st)return st;memset(s,0,sizeof(*s));s->ops=o;s->platform_context=c;s->layout=*l;st=scan(s);if(!st)s->initialized=1U;return st; } + +flash_storage_status_t flash_storage_read_latest(flash_storage_t*s,void*d,size_t cap,size_t*len,uint32_t*seq) +{ record_v2_t *r;if(!s||!s->initialized||!d)return FLASH_STORAGE_E_ARGUMENT;r=(record_v2_t *)s->workspace;if(!s->latest_address)return FLASH_STORAGE_E_NOT_FOUND;if(!read_at(s,s->latest_address,r,sizeof(*r)))return FLASH_STORAGE_E_IO;if(s->latest_version==2U){if(!valid_v2(r))return FLASH_STORAGE_E_CORRUPT;}else{record_v1_t*r1=(record_v1_t*)r;if(!valid_v1(r1))return FLASH_STORAGE_E_CORRUPT;}if(caplength)return FLASH_STORAGE_E_BOUNDS;memcpy(d,r->payload,r->length);if(len)*len=r->length;if(seq)*seq=r->sequence;return FLASH_STORAGE_OK; } + +flash_storage_status_t flash_storage_write(flash_storage_t*s,const void*d,size_t len) +{ record_v2_t *r;uint32_t now=0,target,bank;flash_storage_status_t st;if(!s||!s->initialized||!d||!len||len>V2_PAYLOAD)return FLASH_STORAGE_E_ARGUMENT;r=(record_v2_t *)s->workspace;if(s->busy)return FLASH_STORAGE_E_BUSY;if(s->latest_address&&read_at(s,s->latest_address,r,sizeof(*r))&&r->length==len&&!memcmp(r->payload,d,len))return FLASH_STORAGE_OK;if(s->ops->tick_ms)now=s->ops->tick_ms(s->platform_context);if(s->wrote_this_boot&&s->ops->tick_ms&&(uint32_t)(now-s->last_write_tick)layout.minimum_write_ms)return FLASH_STORAGE_E_BUSY;s->busy=1U;target=s->next_address; + if(!target){bank=(s->latest_address>=s->layout.bank_address[1])?0U:1U;st=s->ops->erase(s->platform_context,s->layout.bank_address[bank],s->layout.bank_size);if(st){s->busy=0;return FLASH_STORAGE_E_IO;}target=s->layout.bank_address[bank];} + memset(r,0xFF,sizeof(*r));r->magic=FLASH_STORAGE_MAGIC;r->version=2U;r->header_size=(uint16_t)offsetof(record_v2_t,payload);r->sequence=s->latest_sequence+1U;r->length=(uint32_t)len;memcpy(r->payload,d,len);r->crc32=fields_crc(r->version,r->sequence,r->length,r->payload);r->commit=FLASH_STORAGE_COMMIT; + /* commit записывается последним: оборванная запись никогда не вытеснит старую. */ + st=s->ops->program(s->platform_context,target,r,offsetof(record_v2_t,commit));if(!st)st=s->ops->program(s->platform_context,target+offsetof(record_v2_t,commit),&r->commit,4U);s->busy=0;scan(s);if(st)return FLASH_STORAGE_E_IO;if(s->latest_address!=target)return FLASH_STORAGE_E_CORRUPT;s->wrote_this_boot=1U;s->last_write_tick=now;return FLASH_STORAGE_OK; } +flash_storage_status_t flash_storage_format(flash_storage_t*s) +{ flash_storage_status_t st;if(!s||!s->initialized)return FLASH_STORAGE_E_ARGUMENT;if(s->busy)return FLASH_STORAGE_E_BUSY;s->busy=1;st=s->ops->erase(s->platform_context,s->layout.bank_address[0],s->layout.bank_size);if(!st)st=s->ops->erase(s->platform_context,s->layout.bank_address[1],s->layout.bank_size);s->busy=0;scan(s);s->wrote_this_boot=0;return st?FLASH_STORAGE_E_IO:FLASH_STORAGE_OK; } +flash_storage_status_t flash_storage_get_info(flash_storage_t*s,flash_storage_info_t*i) +{ uint32_t usable;if(!s||!s->initialized||!i)return FLASH_STORAGE_E_ARGUMENT;usable=count_v2(s)*FLASH_STORAGE_RECORD_SIZE;i->sequence=s->latest_sequence;i->valid_records=s->valid_records;i->programmed_slots=s->programmed_slots;i->allocated_bytes=2U*s->layout.bank_size;i->reserved_bytes=2U*(s->layout.bank_size-usable);return FLASH_STORAGE_OK; } + +flash_storage_status_t eeprom_store_init(eeprom_store_t*e,const flash_storage_ops_t*o,void*c,const flash_storage_layout_t*l) +{ size_t n=0;flash_storage_status_t s;if(!e)return FLASH_STORAGE_E_ARGUMENT;memset(e,0,sizeof(*e));memset(e->image,0xFF,sizeof(e->image));s=flash_storage_init(&e->journal,o,c,l);if(s)return s;s=flash_storage_read_latest(&e->journal,e->image,sizeof(e->image),&n,NULL);if(s==FLASH_STORAGE_E_NOT_FOUND){e->initialized=1;return FLASH_STORAGE_OK;}if(s||!(n==128U||n==256U||n==512U)){memset(e->image,0xFF,sizeof(e->image));e->initialized=1;return FLASH_STORAGE_OK;}e->initialized=1;return FLASH_STORAGE_OK; } +flash_storage_status_t eeprom_store_read(eeprom_store_t*e,size_t off,void*d,size_t n) +{ if(!e||!e->initialized||!d||!n||off>sizeof(e->image)||n>sizeof(e->image)-off)return FLASH_STORAGE_E_BOUNDS;memcpy(d,e->image+off,n);return FLASH_STORAGE_OK; } +flash_storage_status_t eeprom_store_write(eeprom_store_t*e,size_t off,const void*d,size_t n) +{ flash_storage_status_t s;if(!e||!e->initialized||!d||!n||off>512U||n>512U-off)return FLASH_STORAGE_E_BOUNDS;memcpy(e->next_image,e->image,512U);memcpy(e->next_image+off,d,n);s=flash_storage_write(&e->journal,e->next_image,512U);if(!s)memcpy(e->image,e->next_image,512U);return s; } +flash_storage_status_t eeprom_store_format(eeprom_store_t*e) +{ flash_storage_status_t s;if(!e)return FLASH_STORAGE_E_ARGUMENT;s=flash_storage_format(&e->journal);if(!s){memset(e->image,0xFF,512U);e->initialized=1;}return s; } diff --git a/c/flash-storage/Tests/test_flash_storage.c b/c/flash-storage/Tests/test_flash_storage.c new file mode 100644 index 0000000..fe36ecd --- /dev/null +++ b/c/flash-storage/Tests/test_flash_storage.c @@ -0,0 +1,27 @@ +#include "ds18b20.h" +#include "flash_storage.h" +#include +#include +#include + +#define BASE0 0x10000000U +#define BANK (128U*1024U) +typedef struct { uint8_t bytes[2U*BANK];uint32_t tick;int fail_after;int calls; } mock_flash_t; +static flash_storage_status_t mread(void*c,uint32_t a,void*d,size_t n){mock_flash_t*m=c;if(asizeof(m->bytes)-(a-BASE0))return FLASH_STORAGE_E_BOUNDS;memcpy(d,m->bytes+a-BASE0,n);return FLASH_STORAGE_OK;} +static flash_storage_status_t mprogram(void*c,uint32_t a,const void*d,size_t n){mock_flash_t*m=c;size_t i;if(m->fail_after>=0&&m->calls++>=m->fail_after)return FLASH_STORAGE_E_IO;if(asizeof(m->bytes)-(a-BASE0))return FLASH_STORAGE_E_BOUNDS;for(i=0;ibytes[a-BASE0+i]&=((const uint8_t*)d)[i];return FLASH_STORAGE_OK;} +static flash_storage_status_t merase(void*c,uint32_t a,size_t n){mock_flash_t*m=c;if(asizeof(m->bytes)-(a-BASE0))return FLASH_STORAGE_E_BOUNDS;memset(m->bytes+a-BASE0,0xFF,n);return FLASH_STORAGE_OK;} +static uint32_t mtick(void*c){return ((mock_flash_t*)c)->tick;} +static const flash_storage_ops_t fops={mread,mprogram,merase,mtick}; +static const flash_storage_layout_t flayout={{BASE0,BASE0+BANK},BANK,4U,BANK,0U,10U}; + +typedef struct { uint8_t level;uint32_t tick; } mock_wire_t; +static void wl(void*c){((mock_wire_t*)c)->level=0;}static void wr(void*c){((mock_wire_t*)c)->level=1;}static uint8_t wi(void*c){return ((mock_wire_t*)c)->level;}static void wd(void*c,uint32_t u){((mock_wire_t*)c)->tick+=u/1000U;}static uint32_t wt(void*c){return ((mock_wire_t*)c)->tick;}static const ds18b20_onewire_ops_t wops={wl,wr,wi,wd,wt,0,0}; + +static void test_ds(void){uint8_t good[8]={0x28,1,2,3,4,5,6,0};uint8_t sp[9]={0x91,0x01,0x4b,0x46,0x7f,0xff,0x0c,0x10,0};uint8_t r1[2][8],r2[3][8];ds18b20_t a,b;mock_wire_t wa={1,0},wb={1,0};float t;good[7]=ds18b20_crc8(good,7);sp[8]=ds18b20_crc8(sp,8);assert(ds18b20_validate_rom(good)==DS18B20_OK);good[7]^=1;assert(ds18b20_validate_rom(good)==DS18B20_E_CRC);assert(ds18b20_decode_temperature(sp,&t)==DS18B20_OK&&t>25.0f&&t<25.1f);sp[8]^=1;assert(ds18b20_decode_temperature(sp,&t)==DS18B20_E_CRC);assert(ds18b20_init(&a,&wops,&wa,r1,2)==DS18B20_OK);assert(ds18b20_init(&b,&wops,&wb,r2,3)==DS18B20_OK);assert(a.platform_context!=b.platform_context&&a.roms!=b.roms);} + +static void test_flash(void){mock_flash_t m;flash_storage_t s,recovered;flash_storage_info_t info;uint8_t one[512],two[512],out[512];size_t n;memset(&m,0,sizeof(m));memset(m.bytes,0xFF,sizeof(m.bytes));m.fail_after=-1;memset(one,0x11,sizeof(one));memset(two,0x22,sizeof(two));assert(flash_storage_init(&s,&fops,&m,&flayout)==0);assert(flash_storage_write(&s,one,sizeof(one))==0);m.fail_after=m.calls;assert(flash_storage_write(&s,two,sizeof(two))==FLASH_STORAGE_E_IO);m.fail_after=-1;assert(flash_storage_init(&recovered,&fops,&m,&flayout)==0);assert(flash_storage_read_latest(&recovered,out,sizeof(out),&n,0)==0&&n==512&&!memcmp(out,one,512));assert(flash_storage_get_info(&recovered,&info)==0);assert(info.reserved_bytes*100U>=info.allocated_bytes*10U); + /* Заполняем до перехода банка; последовательность и данные обязаны пережить wrap. */ + for(unsigned i=0;i<220U;++i){two[0]=(uint8_t)i;assert(flash_storage_write(&recovered,two,512)==0);}assert(recovered.latest_address>=BASE0+BANK);assert(flash_storage_init(&s,&fops,&m,&flayout)==0);assert(flash_storage_read_latest(&s,out,512,&n,0)==0&&out[0]==(uint8_t)219U);} + +static void test_instances(void){mock_flash_t a,b;eeprom_store_t ea,eb;uint8_t x=7,y=9,z=0;memset(&a,0,sizeof(a));memset(&b,0,sizeof(b));memset(a.bytes,0xFF,sizeof(a.bytes));memset(b.bytes,0xFF,sizeof(b.bytes));a.fail_after=b.fail_after=-1;assert(eeprom_store_init(&ea,&fops,&a,&flayout)==0);assert(eeprom_store_init(&eb,&fops,&b,&flayout)==0);assert(eeprom_store_write(&ea,10,&x,1)==0);assert(eeprom_store_write(&eb,10,&y,1)==0);assert(eeprom_store_read(&ea,10,&z,1)==0&&z==7);assert(eeprom_store_read(&eb,10,&z,1)==0&&z==9);} +int main(void){test_ds();test_flash();test_instances();puts("portable library tests: OK");return 0;} diff --git a/c/modbus/CMakeLists.txt b/c/modbus/CMakeLists.txt new file mode 100644 index 0000000..96237ac --- /dev/null +++ b/c/modbus/CMakeLists.txt @@ -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) diff --git a/c/modbus/README.md b/c/modbus/README.md new file mode 100644 index 0000000..e268fd0 --- /dev/null +++ b/c/modbus/README.md @@ -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. diff --git a/c/modbus/include/modbus_data.h b/c/modbus/include/modbus_data.h new file mode 100644 index 0000000..878897d --- /dev/null +++ b/c/modbus/include/modbus_data.h @@ -0,0 +1,15 @@ +#ifndef SET_MODBUS_DATA_H +#define SET_MODBUS_DATA_H +#include +#include +/* 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 diff --git a/c/modbus/ports/stm32-legacy/modbus.c b/c/modbus/ports/stm32-legacy/modbus.c new file mode 100644 index 0000000..8b7c965 --- /dev/null +++ b/c/modbus/ports/stm32-legacy/modbus.c @@ -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<= 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; iMB_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); +} + + + diff --git a/c/modbus/src/modbus_data.c b/c/modbus/src/modbus_data.c new file mode 100644 index 0000000..a2e867d --- /dev/null +++ b/c/modbus/src/modbus_data.c @@ -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; +} diff --git a/c/modbus/tests/test_modbus_data.c b/c/modbus/tests/test_modbus_data.c new file mode 100644 index 0000000..14064f5 --- /dev/null +++ b/c/modbus/tests/test_modbus_data.c @@ -0,0 +1,29 @@ +#include "modbus_data.h" +#include +#include +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>((bit%16)^8))&1)==((bank[(bit+offset)/16]>>((bit+offset)%16))&1)); + for(bit=0;bit + +/* Ограничения одинаковы для 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 */ diff --git a/c/sd-file-browser/README.md b/c/sd-file-browser/README.md new file mode 100644 index 0000000..3a559c7 --- /dev/null +++ b/c/sd-file-browser/README.md @@ -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. diff --git a/c/sd-file-browser/Src/sd_file_browser.c b/c/sd-file-browser/Src/sd_file_browser.c new file mode 100644 index 0000000..6c414e2 --- /dev/null +++ b/c/sd-file-browser/Src/sd_file_browser.c @@ -0,0 +1,177 @@ +#include "sd_file_browser.h" + +#include +#include + +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); +} diff --git a/c/sd-file-browser/Tests/test_sd_file_browser.c b/c/sd-file-browser/Tests/test_sd_file_browser.c new file mode 100644 index 0000000..497d938 --- /dev/null +++ b/c/sd-file-browser/Tests/test_sd_file_browser.c @@ -0,0 +1,67 @@ +#include "sd_file_browser.h" + +#include +#include + +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; +} diff --git a/c/settings-backup/CMakeLists.txt b/c/settings-backup/CMakeLists.txt new file mode 100644 index 0000000..a7cc47b --- /dev/null +++ b/c/settings-backup/CMakeLists.txt @@ -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) diff --git a/c/settings-backup/Inc/settings_backup.h b/c/settings-backup/Inc/settings_backup.h new file mode 100644 index 0000000..43f8310 --- /dev/null +++ b/c/settings-backup/Inc/settings_backup.h @@ -0,0 +1,125 @@ +#ifndef SETTINGS_BACKUP_H +#define SETTINGS_BACKUP_H + +/* Переносимое ядро резервирования не включает HAL, FatFs, AppStorage и Modbus. */ +#include +#include + +#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 */ diff --git a/c/settings-backup/Inc/settings_backup_config.h b/c/settings-backup/Inc/settings_backup_config.h new file mode 100644 index 0000000..cd16620 --- /dev/null +++ b/c/settings-backup/Inc/settings_backup_config.h @@ -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 */ diff --git a/c/settings-backup/README.md b/c/settings-backup/README.md new file mode 100644 index 0000000..442eafa --- /dev/null +++ b/c/settings-backup/README.md @@ -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. diff --git a/c/settings-backup/Src/settings_backup.c b/c/settings-backup/Src/settings_backup.c new file mode 100644 index 0000000..efdeb21 --- /dev/null +++ b/c/settings-backup/Src/settings_backup.c @@ -0,0 +1,362 @@ +#include "../Inc/settings_backup.h" +#include "../Inc/settings_backup_config.h" + +#include +#include +#include + +/* + * Архитектура переносимого автомата + * --------------------------------- + * 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; +} diff --git a/c/settings-backup/Tests/test_settings_backup.c b/c/settings-backup/Tests/test_settings_backup.c new file mode 100644 index 0000000..87e2c23 --- /dev/null +++ b/c/settings-backup/Tests/test_settings_backup.c @@ -0,0 +1,43 @@ +#include "settings_backup.h" +#include +#include +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 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() diff --git a/c/spi-nor/CMakeLists.txt b/c/spi-nor/CMakeLists.txt new file mode 100644 index 0000000..c570eda --- /dev/null +++ b/c/spi-nor/CMakeLists.txt @@ -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) diff --git a/c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h b/c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h new file mode 100644 index 0000000..0440fda --- /dev/null +++ b/c/spi-nor/Diagnostics/Inc/spi_nor_command_service.h @@ -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 + +#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 */ diff --git a/c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h b/c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h new file mode 100644 index 0000000..cae0f01 --- /dev/null +++ b/c/spi-nor/Diagnostics/Inc/spi_nor_read_service.h @@ -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 +#include + +#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 */ diff --git a/c/spi-nor/Diagnostics/Src/spi_nor_command_service.c b/c/spi-nor/Diagnostics/Src/spi_nor_command_service.c new file mode 100644 index 0000000..6b895f8 --- /dev/null +++ b/c/spi-nor/Diagnostics/Src/spi_nor_command_service.c @@ -0,0 +1,165 @@ +#include "spi_nor_command_service.h" + +#include + +/* Только эти три 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; +} diff --git a/c/spi-nor/Diagnostics/Src/spi_nor_read_service.c b/c/spi-nor/Diagnostics/Src/spi_nor_read_service.c new file mode 100644 index 0000000..f18f60a --- /dev/null +++ b/c/spi-nor/Diagnostics/Src/spi_nor_read_service.c @@ -0,0 +1,59 @@ +#include "spi_nor_read_service.h" + +#include + +/* Диагностический слой намеренно не содержит 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; +} diff --git a/c/spi-nor/Inc/spi_nor.h b/c/spi-nor/Inc/spi_nor.h new file mode 100644 index 0000000..306cf3f --- /dev/null +++ b/c/spi-nor/Inc/spi_nor.h @@ -0,0 +1,140 @@ +#ifndef PORTABLE_SPI_NOR_H +#define PORTABLE_SPI_NOR_H + +/* + * Переносимое ядро SPI NOR. + * + * Ядро знает только стандартные SPI-команды и callbacks платформы. Оно не + * включает HAL, регистры MCU, GPIO проекта, Modbus или код приложения. Каждый + * экземпляр хранит собственное состояние и поэтому не использует глобальные + * изменяемые переменные. + */ + +#include +#include + +#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 */ diff --git a/c/spi-nor/PORTING.md b/c/spi-nor/PORTING.md new file mode 100644 index 0000000..e0dae4c --- /dev/null +++ b/c/spi-nor/PORTING.md @@ -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. diff --git a/c/spi-nor/README.md b/c/spi-nor/README.md new file mode 100644 index 0000000..f540617 --- /dev/null +++ b/c/spi-nor/README.md @@ -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. diff --git a/c/spi-nor/Src/spi_nor.c b/c/spi-nor/Src/spi_nor.c new file mode 100644 index 0000000..8f914e8 --- /dev/null +++ b/c/spi-nor/Src/spi_nor.c @@ -0,0 +1,472 @@ +#include "spi_nor.h" + +#include + +/* Стандартные команды одинаковы для проверенных 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; +} diff --git a/c/spi-nor/Tests/test_spi_nor.c b/c/spi-nor/Tests/test_spi_nor.c new file mode 100644 index 0000000..4433d45 --- /dev/null +++ b/c/spi-nor/Tests/test_spi_nor.c @@ -0,0 +1,363 @@ +#include "spi_nor.h" + +#include +#include + +/* 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; +} diff --git a/c/spi-nor/Tests/test_spi_nor_command_service.c b/c/spi-nor/Tests/test_spi_nor_command_service.c new file mode 100644 index 0000000..167b5ac --- /dev/null +++ b/c/spi-nor/Tests/test_spi_nor_command_service.c @@ -0,0 +1,166 @@ +#include "spi_nor_command_service.h" + +#include +#include + +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; +} diff --git a/c/spi-nor/Tests/test_spi_nor_read_service.c b/c/spi-nor/Tests/test_spi_nor_read_service.c new file mode 100644 index 0000000..db29f8c --- /dev/null +++ b/c/spi-nor/Tests/test_spi_nor_read_service.c @@ -0,0 +1,74 @@ +#include "spi_nor_read_service.h" + +#include +#include + +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; +} diff --git a/tools/firmware-publish/PORTING.md b/tools/firmware-publish/PORTING.md index fba9f82..0616960 100644 --- a/tools/firmware-publish/PORTING.md +++ b/tools/firmware-publish/PORTING.md @@ -66,7 +66,7 @@ Flash — отдельные компоненты (`rs485-boot`, `protocan-boot` ```text workspace/ SETGUI/ - PUBLISH_FIRMWARE.bat + buildb_bat/PUBLISH_FIRMWARE.bat .venv/Scripts/python.exe MyFirmware/ firmware-release.cmd diff --git a/tools/firmware-publish/PUBLISH_FIRMWARE.bat b/tools/firmware-publish/PUBLISH_FIRMWARE.bat index af08934..60c727a 100644 --- a/tools/firmware-publish/PUBLISH_FIRMWARE.bat +++ b/tools/firmware-publish/PUBLISH_FIRMWARE.bat @@ -92,10 +92,12 @@ if not defined FW_SETGUI_ROOT_ABS ( echo Set FW_SETGUI_ROOT in %FW_CONFIG% exit /b 2 ) -set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat" +set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\buildb_bat\PUBLISH_FIRMWARE.bat" +if not exist "%FW_SETGUI_PUBLISHER%" set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat" if not exist "%FW_SETGUI_PUBLISHER%" ( echo ERROR: SETGUI publisher not found: - echo %FW_SETGUI_PUBLISHER% + echo %FW_SETGUI_ROOT_ABS%\buildb_bat\PUBLISH_FIRMWARE.bat + echo %FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat exit /b 2 ) if not exist "%FW_IMAGE_ABS%" ( @@ -187,6 +189,7 @@ exit /b 3 :find_setgui for %%I in ("%FW_CONFIG_DIR%..\SETGUI" "%FW_CONFIG_DIR%..\..\SETGUI" "%FW_CONFIG_DIR%..\..\..\SETGUI") do ( + if exist "%%~fI\buildb_bat\PUBLISH_FIRMWARE.bat" if not defined FW_SETGUI_ROOT_ABS set "FW_SETGUI_ROOT_ABS=%%~fI" if exist "%%~fI\PUBLISH_FIRMWARE.bat" if not defined FW_SETGUI_ROOT_ABS set "FW_SETGUI_ROOT_ABS=%%~fI" ) exit /b 0 diff --git a/tools/firmware-publish/README.md b/tools/firmware-publish/README.md index 1111ea0..0df3c43 100644 --- a/tools/firmware-publish/README.md +++ b/tools/firmware-publish/README.md @@ -51,6 +51,10 @@ set "FW_NOTES=Краткое описание выпуска" лежит рядом с проектом, укажите абсолютный `FW_SETGUI_ROOT` или системную переменную с тем же именем. +`FW_SETGUI_ROOT` указывает на корень SETGUI. Публикатор ищется сначала в +`buildb_bat/PUBLISH_FIRMWARE.bat`, затем в корне для совместимости со старой +раскладкой. Автоматический поиск соседнего SETGUI поддерживает оба варианта. + ### Обязательные поля | Поле | Значение |