Add embedded storage drivers and extend firmware metadata

This commit is contained in:
2026-09-27 01:44:59 +03:00
parent 795a1279b1
commit 2ad29e7ffd
52 changed files with 5801 additions and 3 deletions

View File

@@ -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)

View File

@@ -0,0 +1,11 @@
cmake_minimum_required(VERSION 3.13)
project(ds18b20_instance C)
add_library(ds18b20_instance STATIC Src/ds18b20.c)
target_include_directories(ds18b20_instance PUBLIC Inc)
set_target_properties(ds18b20_instance PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
foreach(name copy_delay incremental_search)
add_executable(test_ds18b20_${name} Tests/test_ds18b20_${name}.c)
target_link_libraries(test_ds18b20_${name} PRIVATE ds18b20_instance)
add_test(NAME ds18b20_${name} COMMAND test_ds18b20_${name})
endforeach()

384
c/ds18b20/instance/HELP.md Normal file
View File

@@ -0,0 +1,384 @@
# DS18B20: справочник API
## Назначение
Публичный API ядра объявлен только в `Inc/ds18b20.h`. Приложение компилирует
`Src/ds18b20.c`, но не включает этот файл. Библиотека выполняет Search ROM,
проверяет Dallas CRC8, запускает Convert T, читает scratchpad, декодирует
температуру, меняет разрешение и TH/TL.
## Константы и состояние
- `DS18B20_ROM_SIZE` — 8 байт полного 64-битного ROM.
- `DS18B20_SCRATCHPAD_SIZE` — 9 байт scratchpad вместе с CRC.
- `DS18B20_DEFAULT_TIMEOUT_MS` — 750 мс, значение конфигурации по умолчанию.
- `DS18B20_FAMILY_CODE` — `0x28`.
- `ds18b20_t` — состояние одной шины. После инициализации его поля напрямую не
изменяют; список датчиков читают через `ds18b20_count()` и `ds18b20_rom()`.
## Platform callbacks
`ds18b20_onewire_ops_t` связывает переносимое ядро с аппаратурой:
| Callback | Обязателен | Контракт |
| --- | --- | --- |
| `drive_low(context)` | да | Активно притянуть open-drain линию к 0 |
| `release(context)` | да | Отпустить линию; внешний/внутренний pull-up поднимает её |
| `read(context)` | да | Вернуть текущий логический уровень `0` или `1` |
| `delay_us(context, us)` | да | Синхронная задержка с микросекундной точностью |
| `tick_ms(context)` | для `ds18b20_wait` | Монотонный, допускающий uint32 wrap tick |
| `critical_enter(context)` | нет | Начать защиту одного временного слота |
| critical_exit(context) | нет | Восстановить состояние после защиты слота |
| strong_pullup(context, enable) | для parasite Copy | Активно удерживать high и безопасно вернуть open-drain |
Обе функции critical section задаются парой либо обе оставляются `NULL`.
## Коды возврата
| Код | Значение | Значение для приложения |
| --- | ---: | --- |
| `DS18B20_OK` | 0 | Успех или преобразование готово |
| `DS18B20_E_ARGUMENT` | -1 | Неверный указатель, callback, размер или параметр |
| `DS18B20_E_IO` | -2 | Некорректная конфигурация scratchpad |
| `DS18B20_E_NO_DEVICE` | -3 | Нет presence pulse / подходящих устройств |
| `DS18B20_E_CRC` | -4 | CRC ROM или scratchpad не совпал |
| `DS18B20_E_TIMEOUT` | -5 | Истёк timeout блокирующего ожидания |
| `DS18B20_E_BUSY` | -6 | Преобразование ещё не готово |
| `DS18B20_E_CAPACITY` | -7 | Найдено больше ROM, чем помещается в storage |
| DS18B20_E_ROM | -8 | ROM имеет неверный family code либо отклонён адресной операцией |
| DS18B20_E_POWER | -9 | Parasite Copy запрошен без strong-pull-up callback |
## Инициализация и поиск
### `ds18b20_init`
```c
ds18b20_status_t ds18b20_init(
ds18b20_t *instance,
const ds18b20_onewire_ops_t *ops,
void *platform_context,
uint8_t (*rom_storage)[DS18B20_ROM_SIZE],
size_t rom_capacity);
```
Обнуляет состояние, сохраняет callback/context/storage и отпускает линию.
`rom_storage` — массив приложения, `rom_capacity` — число ROM, не число байт.
Core не выделяет и не освобождает память.
### `ds18b20_search`
```c
ds18b20_status_t ds18b20_search(ds18b20_t *instance);
```
Заново выполняет Search ROM и заменяет прежний список. Сохраняются только ROM с
family `0x28` и корректным CRC. Результаты поиска:
- `DS18B20_OK` — найден минимум один корректный DS18B20;
- `DS18B20_E_NO_DEVICE` — корректные DS18B20 не найдены;
- `DS18B20_E_CAPACITY` — storage заполнен; уже записанные ROM остаются доступны.
### `ds18b20_count` и `ds18b20_rom`
```c
size_t ds18b20_count(const ds18b20_t *instance);
const uint8_t *ds18b20_rom(const ds18b20_t *instance, size_t index);
```
`count` возвращает число сохранённых ROM либо 0 для неверного экземпляра.
`rom` возвращает указатель на 8 байт либо `NULL` для неверного индекса.
Указатель становится логически устаревшим после следующего поиска.
## Преобразование и чтение
### `ds18b20_start_all`
```c
ds18b20_status_t ds18b20_start_all(ds18b20_t *instance);
```
Посылает `Skip ROM + Convert T` всем устройствам шины. Возвращает
`DS18B20_E_NO_DEVICE`, если нет presence pulse.
### `ds18b20_start`
```c
ds18b20_status_t ds18b20_start(
ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE]);
```
Проверяет ROM и посылает `Match ROM + Convert T` одному датчику.
### `ds18b20_conversion_ready`
```c
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *instance);
```
Один раз читает 1-Wire ready bit: `DS18B20_OK` означает готовность,
`DS18B20_E_BUSY` — преобразование продолжается. Это предпочтительная
неблокирующая проверка для main loop/RTOS.
### `ds18b20_wait`
```c
ds18b20_status_t ds18b20_wait(
ds18b20_t *instance, uint32_t timeout_ms);
```
Блокирующе опрашивает ready bit до готовности или `DS18B20_E_TIMEOUT`. Требует
`tick_ms`. Функция не делает sleep/yield и не рекомендуется в основном цикле.
### `ds18b20_read_scratchpad`
```c
ds18b20_status_t ds18b20_read_scratchpad(
ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE],
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE]);
```
Проверяет ROM, посылает `Match ROM + Read Scratchpad`, читает 9 байт и
проверяет CRC. Функция не запускает Convert T и не проверяет, что преобразование
ранее завершилось.
### `ds18b20_decode_temperature`
```c
ds18b20_status_t ds18b20_decode_temperature(
const uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE],
float *temperature_c);
```
Проверяет CRC, маскирует неопределённые младшие биты согласно разрешению 9–12
бит и возвращает градусы Цельсия. Неизвестная комбинация configuration bits
даёт `DS18B20_E_IO`.
## Конфигурация и User Bytes
### `ds18b20_set_resolution`
```c
ds18b20_status_t ds18b20_set_resolution(
ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE],
uint8_t bits);
```
`bits` принимает только `9`, `10`, `11` или `12`. Функция читает
scratchpad, сохраняет TH/TL, записывает новый configuration byte и посылает
`Copy Scratchpad`.
### `ds18b20_write_user_bytes`
```c
ds18b20_status_t ds18b20_write_user_bytes(
ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE],
int16_t bytes12,
int16_t bytes34,
uint8_t mask);
```
Текущий контракт отражает физические writable bytes DS18B20:
- `mask & 0x01` записывает младшие 8 бит `bytes12` в TH, scratchpad[2];
- `mask & 0x02` записывает старшие 8 бит `bytes12` в TL, scratchpad[3];
- `bytes34` зарезервирован и не используется: scratchpad[6]/[7] read-only;
- остальные биты `mask` игнорируются.
Перед записью функция читает scratchpad, поэтому невыбранный TH/TL и
configuration byte сохраняются. Затем выполняются `Write Scratchpad` и
`Copy Scratchpad`. Функция не выполняет no-op detection, Recall E2 и readback.
Application adapter обязан добавить эти шаги, если операция используется как
подтверждённая пользовательская запись.
## CRC и ROM
```c
uint8_t ds18b20_crc8(const void *data, size_t length);
ds18b20_status_t ds18b20_validate_rom(
const uint8_t rom[DS18B20_ROM_SIZE]);
```
`crc8` вычисляет Dallas/Maxim CRC-8. `validate_rom` отдельно проверяет family
`0x28` и CRC первых семи байтов против восьмого.
## Неблокирующий пример опроса
```c
enum poll_state { POLL_START, POLL_WAIT };
static enum poll_state state = POLL_START;
static uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE];
void poll_ds18b20(void)
{
if (state == POLL_START) {
if (ds18b20_start_all(&bus) == DS18B20_OK) {
state = POLL_WAIT;
}
return;
}
if (ds18b20_conversion_ready(&bus) != DS18B20_OK) {
return;
}
for (size_t i = 0; i < ds18b20_count(&bus); ++i) {
const uint8_t *rom = ds18b20_rom(&bus, i);
float temperature_c;
if (ds18b20_read_scratchpad(&bus, rom, scratchpad) == DS18B20_OK &&
ds18b20_decode_temperature(scratchpad, &temperature_c) ==
DS18B20_OK) {
publish_temperature(rom, temperature_c);
}
}
state = POLL_START;
}
```
Production adapter должен дополнительно иметь deadline: постоянный
`DS18B20_E_BUSY` не должен навсегда удерживать state machine.
## Диагностика
| Симптом | Проверка |
| --- | --- |
| `DS18B20_E_NO_DEVICE` | Питание, общий GND, pull-up, pin, presence pulse |
| Постоянный `DS18B20_E_BUSY` | Deadline, питание датчика, конфликт операций |
| `DS18B20_E_CRC` | Длина/топология шины, помехи, timing, pull-up |
| `DS18B20_E_CAPACITY` | Увеличить ROM storage или ограничить число устройств |
| Неверная температура | Не читать до ready; проверить CRC и resolution bits |
| Зависание в delay | Таймер должен быть запущен и считать непрерывно |
## Ограничения
- Вызовы одной шины не reentrant и не ISR-safe.
- Микросекундные 1-Wire-слоты синхронные; длительное преобразование должно быть
вынесено в state machine.
- Callback `strong_pullup` используется модулем UserByte для parasite-powered
`Copy Scratchpad`; аппаратная схема и timing должны быть проверены на плате.
- Low-level `Copy Scratchpad` намеренно разделён на `start/finish`; выдержку
10 ms, Recall E2, CRC/readback и освобождение шины обеспечивает state machine
`Modules/UserByte`, а не одиночный low-level вызов.
- Поиск синхронный и не имеет cancel callback; для большой шины или жёстких
realtime-требований нужен пошаговый автомат поиска.
## Тесты
Из корня репозитория:
```powershell
python -m unittest Libraries.PortableTests.test_portable_models
```
Модель проверяет CRC, независимость экземпляров и неблокирующую интеграцию.
`Libraries/PortableTests/test_portable_libraries.c` дополнительно проверяет C
API CRC, декодирование температуры и независимость двух шин. Для полной задачи
также обязательны `git diff --check` и целевая Keil-сборка `0/0`.
# User Byte и EEPROM
Для новой логики приложения используйте внутренний модуль `Modules/UserByte`
с явным selector `TH/TL`, а не legacy `ds18b20_write_user_bytes`. Low-level API `write_scratchpad`, `copy_*`,
`recall_e2`, `recall_ready` и `recover_bus` предназначен для его state machine.
`strong_pullup` обязателен только для parasite-powered Copy Scratchpad.
`ds18b20_search_retry_due` помогает приложению повторять поиск при `count=0`,
не вмешиваясь в активную температурную конверсию; период и tick задаёт adapter.
## Версия Modbus-контракта User Byte
### Явный неблокирующий поиск
```c
ds18b20_status_t status = ds18b20_search_begin(&bus);
while (status == DS18B20_E_BUSY ||
status == DS18B20_E_CRC ||
status == DS18B20_E_ROM) {
status = ds18b20_search_step(&bus);
/* Между шагами основной цикл продолжает обслуживать Modbus/RTC/SD. */
}
```
Не вызывайте `ds18b20_search_retry_due()` для GUI-команды: reconnect и polling
не должны автоматически менять таблицу ROM. В проектном адаптере команда
захватывается только по `apply=1`, а `sequence` защищает от старого ответа.
Legacy API `ds18b20_user_byte_submit()` остаётся TH-only. Для явного TL
используется selector `DS18B20_USER_BYTE_TL`; STM32 adapter принимает его только
с `contractVersion=2`, записанным атомарно с selector до APPLY. Это защищает GUI
от старого bridge/firmware, которое всегда маршрутизировало запрос как TH.
### Известные ROM и ошибка поиска detail 2
В F407 сохранённые SensorBindings восстанавливаются в список опроса после
AppStorage_Init. Если список непустой, стартовый SEARCH ROM не запускается.
Температура читается адресно через MATCH ROM; запуск преобразования общий.
Даже при отсутствии датчика во время старта его адрес остаётся доступен для
повторного чтения. Подключение подтверждается успешным чтением температуры.
Явный поиск добавляет новые ID. При успехе, ошибке, тайм-ауте и отмене
Dallas_FinishSearch объединяет результат с прежними ID, включая ещё не
сохранённые во Flash. Ёмкость списка — 32 ID; найденные ID занимают места
первыми. Сохранение использует существующий SensorBindings_SyncFound и
повтор при BUSY. Отключать питание следует после завершения сохранения.
MCU detail 2 = DS18B20_E_IO: поиск ROM получил недопустимую комбинацию
битов либо неполный ROM. Это не доказательство конкретной причины на кабеле.
Ошибка поиска остаётся видна, но известные ID продолжают опрашиваться.
Число в каталоге включает известные адреса; наличие определяется connected.
API ds18b20_add_known_rom проверяет family/CRC, возвращает E_ARGUMENT,
E_ROM, E_CRC или E_CAPACITY; дубликат возвращает OK. Функция не делает
I/O и не подтверждает наличие датчика. Вызывать вне поиска и конверсии.
Проверки: `Libraries/DS18B20/Tests/run_host_tests.ps1` и
`python -m unittest discover -s tests -p test_known_rom_host.py`.
Для аппаратной проверки: найти датчики по одному, дождаться сохранения,
подключить все на 20 м, перезапустить МК без команды поиска и проверить
обновление температур; затем повторить при ошибке/отмене поиска.
### Поиск с восстановлением после ошибок
Настройки в `Inc/ds18b20_config.h`: три прохода дерева, четыре дополнительных
повтора каждой неудачной ветки (пять попыток суммарно). Один вызов step делает
не более одной попытки ROM: Modbus и остальные сервисы работают между ними.
Перед попыткой сохраняются ROM-путь и discrepancy; после IO, отсутствия presence
или CRC они восстанавливаются. После пяти ошибок начинается следующий проход.
Результаты всех проходов объединяются; дубликаты не занимают ёмкость. CRC
проверяется до принятия пути. Чужое семейство с корректным CRC пропускается.
Успех означает, что хотя бы один проход полностью обошёл дерево и найден хотя
бы один DS18B20. Ранее восстановленные ошибки сохраняются в диагностике, но
не превращают успешный поиск в CRC_ERROR. Это не гарантия обнаружения каждого
физического датчика на нестабильной линии. Если ни один проход не завершён,
возвращается последняя ошибка; проверенные частичные результаты сохраняются.
Пустая линия ограничена 15 попытками reset. Общий предел 512 попыток защищает
и блокирующий API; приложение дополнительно ограничивает поиск 12000 мс.
Во время поиска после каждого слота добавляется 20 мкс высокого уровня:
после записи нуля получается минимум 30 мкс вместо 10 мкс. Импульсы 6/60 мкс,
read-init 3 мкс и выборка через дополнительные 10 мкс остаются прежними.
Добавочная пауза выполняется с разрешёнными прерываниями. Обычное чтение,
конверсия и Copy Scratchpad не получают эту добавку. Подбор паузы для кабеля
требует измерений; программная модель не подтверждает аналоговый фронт.
`bus.search_diagnostics` содержит attempts, retries, crc_errors, io_errors,
passes_finished, complete_passes и последнее место ошибки. Номер бита 1..64;
0 означает reset/общий лимит, pair=0xFF — пары нет. Диагностика сохраняется
после последующего успеха и обнуляется новым search_begin.
FC04: 1210–1217 совместимы, 1218 и 1219 используют прежний резерв:
- 1218: биты 0..6 — номер ROM-бита, бит 8 — complement, бит 9 — id,
биты 12..15 — положительный код последней ошибки; 0 — ошибок не было.
- 1219: биты 0..7 — число повторов с насыщением 255, биты 8..15 — число
законченных проходов, включая прерванные после исчерпания повторов.
Bridge читает 10 регистров и возвращает `diagnostics` в ответе поиска.
Для ошибки шины сообщение дополнено битом, id/complement, повторами и проходами.
Старые восемь полей сохранены. При bit=0 пара не интерпретируется.
Проверки: `run_host_tests.ps1`, `test_known_rom_host.py`,
`test_sensor_search_diagnostics_host.py`, `test_remote_ds18b20_host.py`.
На плате проверить 20 последовательных поисков трёх датчиков на 20 м,
отключение/подключение датчика при поиске, отмену и восстановление опроса.

View File

@@ -0,0 +1,126 @@
#ifndef PORTABLE_DS18B20_H
#define PORTABLE_DS18B20_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define DS18B20_ROM_SIZE 8U
#define DS18B20_SCRATCHPAD_SIZE 9U
typedef enum {
DS18B20_OK = 0,
DS18B20_E_ARGUMENT = -1,
DS18B20_E_IO = -2,
DS18B20_E_NO_DEVICE = -3,
DS18B20_E_CRC = -4,
DS18B20_E_TIMEOUT = -5,
DS18B20_E_BUSY = -6,
DS18B20_E_CAPACITY = -7,
DS18B20_E_ROM = -8,
DS18B20_E_POWER = -9
} ds18b20_status_t;
typedef struct {
void (*drive_low)(void *context);
void (*release)(void *context);
uint8_t (*read)(void *context);
void (*delay_us)(void *context, uint32_t us);
uint32_t (*tick_ms)(void *context);
void (*critical_enter)(void *context);
void (*critical_exit)(void *context);
/* Optional callback used only while parasite-powered EEPROM is copied. */
void (*strong_pullup)(void *context, uint8_t enable);
} ds18b20_onewire_ops_t;
typedef struct {
uint16_t attempts;
uint16_t retries;
uint16_t crc_errors;
uint16_t io_errors;
uint8_t passes_finished;
uint8_t complete_passes;
uint8_t last_error_bit; /* 1..64; 0 = reset or overall attempt limit. */
uint8_t last_error_pair; /* bit 1 = id, bit 0 = complement; 0xFF = no pair. */
ds18b20_status_t last_error;
} ds18b20_search_diagnostics_t;
typedef struct {
const ds18b20_onewire_ops_t *ops;
void *platform_context;
uint8_t (*roms)[DS18B20_ROM_SIZE];
size_t rom_capacity;
size_t rom_count;
uint8_t search_rom[DS18B20_ROM_SIZE];
uint8_t last_discrepancy;
uint8_t last_family_discrepancy;
uint8_t last_device;
uint8_t strong_pullup_active;
uint8_t search_active;
uint8_t search_retries;
ds18b20_status_t search_result;
ds18b20_search_diagnostics_t search_diagnostics;
} ds18b20_t;
ds18b20_status_t ds18b20_init(ds18b20_t *instance,
const ds18b20_onewire_ops_t *ops,
void *platform_context,
uint8_t (*rom_storage)[DS18B20_ROM_SIZE],
size_t rom_capacity);
ds18b20_status_t ds18b20_search(ds18b20_t *instance);
/* Incremental search keeps application services responsive by processing one
* physical ROM attempt per call. Begin clears the previous result table.
* BUSY includes internal retries/passes. All other results are terminal;
* a failed search may still contain validated partial results. */
ds18b20_status_t ds18b20_search_begin(ds18b20_t *instance);
ds18b20_status_t ds18b20_search_step(ds18b20_t *instance);
/* Add a validated known ROM without bus traffic; duplicates are idempotent.
* Call only outside an active search/conversion. Presence is checked by reads. */
ds18b20_status_t ds18b20_add_known_rom(ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE]);
size_t ds18b20_count(const ds18b20_t *instance);
const uint8_t *ds18b20_rom(const ds18b20_t *instance, size_t index);
uint8_t ds18b20_crc8(const void *data, size_t length);
ds18b20_status_t ds18b20_validate_rom(const uint8_t rom[DS18B20_ROM_SIZE]);
ds18b20_status_t ds18b20_start_all(ds18b20_t *instance);
ds18b20_status_t ds18b20_start(ds18b20_t *instance,
const uint8_t rom[DS18B20_ROM_SIZE]);
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *instance);
ds18b20_status_t ds18b20_wait(ds18b20_t *instance, uint32_t timeout_ms);
ds18b20_status_t ds18b20_read_scratchpad(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE]);
ds18b20_status_t ds18b20_decode_temperature(
const uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE], float *temperature_c);
ds18b20_status_t ds18b20_set_resolution(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE], uint8_t bits);
ds18b20_status_t ds18b20_write_user_bytes(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
int16_t bytes12, int16_t bytes34, uint8_t mask);
ds18b20_status_t ds18b20_write_scratchpad(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
uint8_t th, uint8_t tl, uint8_t configuration);
ds18b20_status_t ds18b20_copy_scratchpad_start(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE],
uint8_t parasite_power);
ds18b20_status_t ds18b20_copy_scratchpad_finish(
ds18b20_t *instance, uint8_t parasite_power);
ds18b20_status_t ds18b20_recall_e2(
ds18b20_t *instance, const uint8_t rom[DS18B20_ROM_SIZE]);
ds18b20_status_t ds18b20_recall_ready(ds18b20_t *instance);
void ds18b20_recover_bus(ds18b20_t *instance);
uint8_t ds18b20_search_retry_due(const ds18b20_t *instance,
uint8_t conversion_active,
uint32_t now_ms, uint32_t last_attempt_ms,
uint32_t retry_period_ms);
/* Экземпляр не синхронизируется внутри: один вызов на одной шине должен быть
* завершён до следующего. Из ISR API вызывать нельзя из-за задержек до 750 ms. */
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,17 @@
#ifndef PORTABLE_DS18B20_CONFIG_H
#define PORTABLE_DS18B20_CONFIG_H
#define DS18B20_DEFAULT_TIMEOUT_MS 750U
#define DS18B20_FAMILY_CODE 0x28U
/* Initial attempt + four retries for each tree branch, three full passes. */
#define DS18B20_SEARCH_RETRIES 4U
#define DS18B20_SEARCH_PASSES 3U
/* Bounds the blocking API too, even if noisy devices keep changing the tree. */
#define DS18B20_SEARCH_MAX_ATTEMPTS 512U
/* Extra released-high time AFTER each search slot; sampling is unchanged. */
#define DS18B20_SLOT_RECOVERY_EXTRA_US 20U
#if DS18B20_SEARCH_PASSES < 1U || DS18B20_SEARCH_PASSES > 255U || \
DS18B20_SEARCH_RETRIES > 254U || DS18B20_SEARCH_MAX_ATTEMPTS < 1U || \
DS18B20_SEARCH_MAX_ATTEMPTS > 65535U
#error Invalid DS18B20 search limits
#endif
#endif

View File

@@ -0,0 +1,235 @@
# DS18B20: перенос на другую платформу
## Что переносится без изменений
`Inc/ds18b20.h`, `Inc/ds18b20_config.h` и `Src/ds18b20.c` — переносимое
C-ядро. Оно использует `stdint.h`, `stddef.h`, `string.h` и аппаратные
callback. Не добавляйте в core HAL, RTOS, Modbus или глобальные дескрипторы
конкретного проекта.
## Шаг 1. Добавить файлы в сборку
Добавьте `Libraries/DS18B20/Inc` в include path и
`Libraries/DS18B20/Src/ds18b20.c` в список исходников. Создайте отдельные
каталоги `Port/<PLATFORM>/Inc` и `Port/<PLATFORM>/Src`.
Application adapter держите вне библиотеки либо в отдельном `Adapter`:
он планирует операции, переводит ошибки и связывает результат с остальным
проектом.
## Шаг 2. Создать platform context
Context содержит только ресурсы одного физического 1-Wire master:
```c
typedef struct {
gpio_handle_t gpio;
timer_handle_t timer;
uint32_t timer_ticks_per_us;
irq_state_t saved_irq_state;
} ds18b20_my_mcu_t;
```
Не используйте скрытый изменяемый singleton. Для двух шин создаются два context,
два `ds18b20_t` и два массива ROM.
## Шаг 3. Реализовать callbacks
```c
static void drive_low(void *context);
static void release_line(void *context);
static uint8_t read_line(void *context);
static void delay_us(void *context, uint32_t us);
static uint32_t tick_ms(void *context);
static void critical_enter(void *context);
static void critical_exit(void *context);
const ds18b20_onewire_ops_t ds18b20_my_mcu_ops = {
drive_low, release_line, read_line, delay_us, tick_ms,
critical_enter, critical_exit
};
```
### GPIO
- Линия 1-Wire работает только как open-drain: порт либо тянет её к 0, либо
переходит в высокоимпедансное состояние.
- Запрещён push-pull высокий уровень.
- `release_line` не должна ждать; `read_line` читает реальный pin level.
- Номинал внешнего pull-up и допустимая длина/ёмкость шины выбираются по
электрическим условиям конкретной платы.
STM32F4 port оставляет pin в `GPIO_MODE_OUTPUT_OD`, отпускает линию записью
единицы в BSRR и читает IDR без переключения MODER.
### Микросекундная задержка
`delay_us` обязана быть монотонной и достаточно точной для 1-Wire standard
speed. Не используйте scheduler sleep с миллисекундной гранулярностью. Если
задержка основана на hardware timer:
- таймер запускается до инициализации библиотеки;
- он считает непрерывно во всех вызывающих контекстах;
- учитывается переполнение счётчика;
- произведение `us * timer_ticks_per_us` не должно переполняться в диапазоне
используемых библиотекой задержек;
- частота таймера и `timer_ticks_per_us` должны совпадать.
### Tick и критическая секция
`tick_ms` нужен только для `ds18b20_wait()`; state-machine adapter может не
использовать блокирующий wait. Вычитание tick выполняется как `uint32_t` и
допускает wrap.
Critical callbacks должны сохранять и восстанавливать предыдущее состояние
прерываний, а не безусловно включать их. Если платформа гарантирует timing иначе,
оба callback можно оставить `NULL`.
## Шаг 4. Инициализировать экземпляр
```c
#define DS_CAPACITY 8U
static ds18b20_my_mcu_t port_context;
static ds18b20_t bus;
static uint8_t rom_storage[DS_CAPACITY][DS18B20_ROM_SIZE];
platform_gpio_timer_init(&port_context);
ds18b20_status_t status =
ds18b20_init(&bus, &ds18b20_my_mcu_ops, &port_context,
rom_storage, DS_CAPACITY);
```
Все три объекта должны жить столько же, сколько используется `bus`. Стековый
context или ROM storage нельзя передавать экземпляру, переживающему функцию.
## Шаг 5. Создать application adapter
Adapter должен:
1. выполнить явный поиск или восстановить/проверить известные ROM;
2. сериализовать Search ROM, Convert T, scratchpad и EEPROM-команды;
3. запустить Convert T и вернуть управление;
4. опрашивать ready с общим deadline;
5. читать каждый ROM и публиковать значение только после CRC;
6. восстановить state machine после disconnect, CRC error и timeout;
7. не запускать поиск/EEPROM-запись из обычного temperature polling;
8. синхронизировать доступ задач RTOS mutex-ом на уровне экземпляра.
Для циклического приложения вызывайте `ds18b20_search_step()` только когда
шина не занята преобразованием температуры или User Byte. Храните timeout,
отмену и sequence в адаптере приложения: portable core не зависит от HAL,
Modbus, GUI и глобального `hdallas`.
Не используйте critical callbacks библиотеки как mutex: они защищают короткий
1-Wire slot и могут запрещать прерывания.
## Память и выравнивание
- Core не использует heap.
- На каждую шину требуется `sizeof(ds18b20_t)` плюс
`rom_capacity * DS18B20_ROM_SIZE` байт ROM storage.
- Scratchpad — 9 байт у вызывающей стороны.
- Специального DMA-выравнивания core не требует; соблюдайте обычное выравнивание
C-типов для `ds18b20_t` и context.
- `rom_capacity` измеряется в элементах `uint8_t[8]`.
## Timing и питание
DS18B20 с внешним питанием может сигнализировать готовность через read slot.
Для parasite power требуется strong pull-up на всё время Convert T и
Copy Scratchpad. Контракт предоставляет optional callback
`strong_pullup(context, enable)`. Для поддержки режима порт обязан:
1. включить активный high не позднее 10 мкс после команды Copy Scratchpad;
2. удерживать его не менее 10 ms без другой активности 1-Wire;
3. безопасно отключать strong pull-up при success, timeout и error;
4. возвращать линию в open-drain idle перед Recall/readback;
5. проверить обычное и parasite-powered подключение на реальной плате.
## Перенос STM32F4 HAL port
Перед `ds18b20_stm32f4_hal_init()` заполните:
| Поле | Требование |
| --- | --- |
| `port` | Валидный `GPIO_TypeDef *` с включённым clock |
| `pin` | Одна ненулевая GPIO mask |
| `timer` | Запущенный свободно работающий `TIM_TypeDef *` |
| `timer_ticks_per_us` | Ненулевое число timer ticks за 1 мкс |
Порт использует `HAL_GetTick()` и CMSIS PRIMASK. При переносе на другую STM32
семью проверьте HAL-заголовок, разрядность/частоту timer, GPIO BSRR/IDR и способ
сохранения interrupt state.
## Проверки
Host/mock-проверки выполняются без платы:
```powershell
python -m unittest Libraries.PortableTests.test_portable_models
git diff --check
```
Для нового порта добавьте тесты reset/presence, write/read slots, timing bounds,
CRC error, нескольких экземпляров, timeout и восстановления после ошибки.
Затем выполните целевую сборку без ошибок/предупреждений.
## Перенос User Byte mailbox adapter
Core-модуль не зависит от Modbus. При переносе STM32 adapter сохраните
`selector + contractVersion=2` как одну транзакцию и APPLY как отдельную
последнюю запись. Input обязан возвращать selector/version echo. Legacy version
`0` можно принимать только для TH; TL без version `2` должен завершаться
`invalid`, а не значением TH.
Host-проверки adapter:
```powershell
powershell -ExecutionPolicy Bypass -File Modules/UserByte/Adapter/STM32_Modbus/Tests/run_host_tests.ps1
```
## Checklist
- [ ] Core собирается без HAL/RTOS/Modbus include.
- [ ] У каждой шины отдельные instance, context и ROM storage.
- [ ] GPIO физически open-drain и никогда не выдаёт push-pull high.
- [ ] Есть корректный внешний pull-up и общий GND.
- [ ] Таймер запущен, частота и overflow проверены.
- [ ] Critical section восстанавливает предыдущее состояние.
- [ ] Поиск, conversion, scratchpad и EEPROM сериализованы.
- [ ] У state machine есть deadline и восстановление после ошибок.
- [ ] CRC ROM и scratchpad проверяется до публикации данных.
- [ ] Host/mock-тесты и `git diff --check` проходят.
- [ ] Целевая сборка даёт 0 ошибок и 0 предупреждений.
- [ ] Работа проверена на реальной шине с 0, 1 и несколькими датчиками.
- [ ] Parasite power отмечен неподдерживаемым либо проверен со strong pull-up.
# Strong pull-up
Если порт поддерживает parasite power, добавьте callback
`strong_pullup(context, enable)`: включение должно немедленно активно держать
high после Copy Scratchpad, выключение — вернуть open-drain idle. Без безопасной
аппаратной реализации не разрешайте parasite mode вызывающему приложению.
### Импорт известных адресов
После ds18b20_init передайте каждый сохранённый ROM в
ds18b20_add_known_rom. Проверяйте возвращаемый статус и лимит ёмкости.
Хранилище ROM принадлежит вызывающему коду и живёт всё время работы шины.
Импорт не требует GPIO-транзакций и не заменяет проверку CRC температуры.
Сохраняйте список перед search_begin (он очищает результаты); по завершении
или отмене добавляйте прежние ID обратно вне активной конверсии.
Для нескольких шин храните снимки и происхождение ID отдельно для каждой.
### Перенос поиска с повторами
Пересоберите всех потребителей: ds18b20_t расширен состоянием поиска и
диагностикой. Рабочие буферы остаются caller-owned, HAL и Flash в ядре не нужны.
Только BUSY означает продолжение; остальные результаты терминальные.
Держите одного владельца шины до завершения/отмены поиска. Адаптер при отмене
сбрасывает search_active перед адресным опросом. Search begin нельзя запускать
во время strong pull-up. Пауза recovery действует только при search_active.
Новые callbacks не требуются; delay_us должен поддерживать добавочные 20 мкс.
Проверьте пределы config и внешний deadline. Для Modbus переносите два поля
диагностики лишь в свободные регистры, не сдвигая последующие окна.

View File

@@ -0,0 +1,97 @@
# Portable DS18B20
Переносимое ядро для поиска DS18B20 на шине 1-Wire, запуска преобразования,
чтения температуры, настройки разрешения и записи alarm/user bytes. Ядро не
зависит от STM32 HAL, не выделяет память динамически и поддерживает несколько
независимых экземпляров шин.
Подтверждённые неблокирующие операции `TH`/`TL` с Copy/Recall/readback находятся
во внутреннем модуле `Libraries/DS18B20/Modules/UserByte`. Основной core предоставляет ему
низкоуровневые операции scratchpad и optional callback `strong_pullup`.
## Подтверждённые EEPROM-байты
Официальный [datasheet Analog Devices/Maxim DS18B20](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf)
определяет `TH=scratchpad[2]` и `TL=scratchpad[3]` как два независимо
программируемых alarm-регистра. `Write Scratchpad` принимает TH, TL и
configuration (`scratchpad[4]`), `Copy Scratchpad` сохраняет все три байта в
EEPROM, а `Recall E2` возвращает их в scratchpad. Заводские значения после
сброса: TH `+75` (`0x4B`), TL `+70` (`0x46`), configuration `0x7F`.
Scratchpad[5..7] зарезервированы/read-only и пользовательскими не считаются.
На реальном датчике пользователь отдельно прочитал TH `14`, TL `128` и config
`31`, подтвердив, что поля GUI должны оставаться независимыми. Проверка
сохранения после полного power-cycle и parasite-power всё ещё требует отдельной
аппаратной приёмки.
## Документация
- [HELP.md](HELP.md) — публичный API, инициализация, примеры, коды ошибок,
диагностика, ограничения и тесты.
- [PORTING.md](PORTING.md) — перенос на другой MCU/проект и checklist порта.
- [PROJECT_RELATIONS.md](PROJECT_RELATIONS.md) — слои, зависимости, владение
памятью и связи с текущей прошивкой.
## Структура
```text
DS18B20/
├── Inc/ публичный API и конфигурация
├── Src/ переносимое ядро 1-Wire/DS18B20
├── Port/STM32F4_HAL/Inc/ публичный API STM32F4-порта
├── Port/STM32F4_HAL/Src/ реализация GPIO/таймера STM32F4
├── README.md точка входа
├── HELP.md справочник API
├── PORTING.md руководство по переносу
└── PROJECT_RELATIONS.md место библиотеки в проекте
```
Адаптер текущего приложения находится отдельно:
`climate_control_f407vet6_f4/Core/Src/dallas_tools.c`.
## Минимальное подключение
```c
#include ds18b20.h
#include ds18b20_stm32f4_hal.h
#define DS_CAPACITY 8U
static ds18b20_t bus;
static uint8_t roms[DS_CAPACITY][DS18B20_ROM_SIZE];
static ds18b20_stm32f4_hal_t port = {
.port = GPIOE, .pin = GPIO_PIN_2,
.timer = TIM2, .timer_ticks_per_us = 72U
};
if (ds18b20_stm32f4_hal_init(&port) == DS18B20_OK &&
ds18b20_init(&bus, &ds18b20_stm32f4_hal_ops, &port,
roms, DS_CAPACITY) == DS18B20_OK) {
(void)ds18b20_search(&bus);
}
```
### Пошаговый поиск для GUI и Modbus
Для приложения с постоянно работающими сервисами используйте
`ds18b20_search_begin()` и `ds18b20_search_step()`. Один вызов `step`
обрабатывает не более одного кандидата ROM. `DS18B20_E_BUSY` означает, что
нужно вызвать функцию в следующем проходе цикла; `DS18B20_OK` завершает поиск.
`DS18B20_E_BUSY` также включает внутренние повторы и переходы между проходами.
Все остальные статусы терминальные; CRC_ERROR после исчерпания повторов
нельзя продолжать вызывать в цикле. Подробности — в [HELP.md](HELP.md).
Таймер должен быть заранее запущен и считать непрерывно. Значение
`timer_ticks_per_us` задаётся частотой счёта таймера, а не частотой ядра.
Преобразование температуры выполняйте неблокирующей парой
`ds18b20_start_all()` / `ds18b20_conversion_ready()`; полный сценарий приведён
в [HELP.md](HELP.md).
### Опрос по сохранённым ID
`ds18b20_add_known_rom(bus, rom)` добавляет проверенный ROM без обращения к
линии. Повторное добавление не создаёт дубликат. См. [HELP.md](HELP.md).
## Shared source
Canonical source: `templates/c/ds18b20/instance`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.

View File

@@ -0,0 +1,353 @@
#include "ds18b20.h"
#include "ds18b20_config.h"
#include <string.h>
#define OW_SEARCH_ROM 0xF0U
#define OW_MATCH_ROM 0x55U
#define OW_SKIP_ROM 0xCCU
#define DS_CONVERT 0x44U
#define DS_READ_SCRATCHPAD 0xBEU
#define DS_WRITE_SCRATCHPAD 0x4EU
#define DS_COPY_SCRATCHPAD 0x48U
#define DS_RECALL_E2 0xB8U
static int valid_instance(const ds18b20_t *d)
{
return d && d->ops && d->ops->drive_low && d->ops->release &&
d->ops->read && d->ops->delay_us && d->roms && d->rom_capacity;
}
static void critical(ds18b20_t *d, int enter)
{
if (enter && d->ops->critical_enter) d->ops->critical_enter(d->platform_context);
if (!enter && d->ops->critical_exit) d->ops->critical_exit(d->platform_context);
}
static void write_bit(ds18b20_t *d, uint8_t bit)
{
critical(d, 1);
d->ops->drive_low(d->platform_context);
d->ops->delay_us(d->platform_context, bit ? 6U : 60U);
d->ops->release(d->platform_context);
d->ops->delay_us(d->platform_context, bit ? 64U : 10U);
critical(d, 0);
if (d->search_active)
d->ops->delay_us(d->platform_context, DS18B20_SLOT_RECOVERY_EXTRA_US);
}
static uint8_t read_bit(ds18b20_t *d)
{
uint8_t bit;
critical(d, 1);
d->ops->drive_low(d->platform_context);
d->ops->delay_us(d->platform_context, 3U);
d->ops->release(d->platform_context);
d->ops->delay_us(d->platform_context, 10U);
bit = d->ops->read(d->platform_context) ? 1U : 0U;
d->ops->delay_us(d->platform_context, 57U);
critical(d, 0);
if (d->search_active)
d->ops->delay_us(d->platform_context, DS18B20_SLOT_RECOVERY_EXTRA_US);
return bit;
}
static void write_byte(ds18b20_t *d, uint8_t value)
{
uint8_t i;
for (i = 0; i < 8U; ++i) { write_bit(d, value & 1U); value >>= 1U; }
}
static uint8_t read_byte(ds18b20_t *d)
{
uint8_t i, value = 0U;
for (i = 0; i < 8U; ++i) value |= (uint8_t)(read_bit(d) << i);
return value;
}
static ds18b20_status_t reset(ds18b20_t *d)
{
uint8_t level;
d->ops->drive_low(d->platform_context);
d->ops->delay_us(d->platform_context, 480U);
/* Protect release-to-presence sampling from interrupt latency. */
critical(d, 1);
d->ops->release(d->platform_context);
d->ops->delay_us(d->platform_context, 70U);
level = d->ops->read(d->platform_context);
critical(d, 0);
d->ops->delay_us(d->platform_context, 410U);
return level ? DS18B20_E_NO_DEVICE : DS18B20_OK;
}
static void match(ds18b20_t *d, const uint8_t *rom)
{
uint8_t i; write_byte(d, OW_MATCH_ROM);
for (i = 0U; i < 8U; ++i) write_byte(d, rom[i]);
}
uint8_t ds18b20_crc8(const void *data, size_t length)
{
const uint8_t *p = (const uint8_t *)data; uint8_t crc = 0U;
while (length--) { uint8_t in = *p++, i; for (i = 0U; i < 8U; ++i) {
uint8_t mix = (uint8_t)((crc ^ in) & 1U); crc >>= 1U;
if (mix) crc ^= 0x8CU; in >>= 1U; } }
return crc;
}
ds18b20_status_t ds18b20_validate_rom(const uint8_t rom[8])
{
if (!rom) return DS18B20_E_ARGUMENT;
if (rom[0] != DS18B20_FAMILY_CODE) return DS18B20_E_ROM;
return ds18b20_crc8(rom, 7U) == rom[7] ? DS18B20_OK : DS18B20_E_CRC;
}
ds18b20_status_t ds18b20_init(ds18b20_t *d, const ds18b20_onewire_ops_t *ops,
void *ctx, uint8_t (*roms)[8], size_t capacity)
{
if (!d || !ops || !roms || !capacity || !ops->drive_low || !ops->release ||
!ops->read || !ops->delay_us) return DS18B20_E_ARGUMENT;
memset(d, 0, sizeof(*d)); d->ops = ops; d->platform_context = ctx;
d->roms = roms; d->rom_capacity = capacity; ops->release(ctx);
return DS18B20_OK;
}
static ds18b20_status_t search_next(ds18b20_t *d)
{
uint8_t bit_no=1U,last_zero=0U,byte_no=0U,mask=1U;
ds18b20_status_t reset_status;
if (d->last_device) return DS18B20_OK;
d->search_diagnostics.last_error_bit = 0U;
d->search_diagnostics.last_error_pair = 0xFFU;
reset_status=reset(d);
if (reset_status != DS18B20_OK) return reset_status;
write_byte(d, OW_SEARCH_ROM);
while (byte_no < 8U) {
uint8_t id=read_bit(d), cmp=read_bit(d), dir;
d->search_diagnostics.last_error_bit = bit_no;
d->search_diagnostics.last_error_pair = (uint8_t)((id << 1U) | cmp);
if (id && cmp) break;
if (id != cmp) dir=id; else { dir=(bit_no<d->last_discrepancy) ?
((d->search_rom[byte_no]&mask)!=0U) : (bit_no==d->last_discrepancy);
if (!dir) { last_zero=bit_no; if (last_zero<9U) d->last_family_discrepancy=last_zero; } }
if (dir) d->search_rom[byte_no]|=mask; else d->search_rom[byte_no]&=(uint8_t)~mask;
write_bit(d,dir); ++bit_no; mask<<=1U; if (!mask) { ++byte_no; mask=1U; }
}
if (bit_no < 65U || !d->search_rom[0]) {
d->last_discrepancy=0; d->last_device=0; return DS18B20_E_IO;
}
d->last_discrepancy=last_zero;
if (!last_zero) d->last_device=1U;
return DS18B20_E_BUSY;
}
/* Reset only traversal state: the union of validated ROMs survives passes. */
static void search_tree_reset(ds18b20_t *d)
{
d->last_discrepancy = 0U;
d->last_family_discrepancy = 0U;
d->last_device = 0U;
d->search_retries = 0U;
memset(d->search_rom, 0, sizeof(d->search_rom));
}
static ds18b20_status_t search_stop(ds18b20_t *d, ds18b20_status_t result)
{
d->search_active = 0U;
d->search_result = result;
return result;
}
static ds18b20_status_t search_pass_finish(ds18b20_t *d, uint8_t complete)
{
ds18b20_search_diagnostics_t *diag = &d->search_diagnostics;
++diag->passes_finished;
if (complete) ++diag->complete_passes;
if (diag->passes_finished < DS18B20_SEARCH_PASSES) {
search_tree_reset(d);
return DS18B20_E_BUSY;
}
/* A completed traversal can recover earlier faults. Without one, retain
* the partial catalog but report the failure instead of claiming success. */
if (diag->complete_passes != 0U)
return search_stop(d, d->rom_count ? DS18B20_OK : DS18B20_E_NO_DEVICE);
return search_stop(d, diag->last_error);
}
ds18b20_status_t ds18b20_search_begin(ds18b20_t *d)
{
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
if (d->strong_pullup_active) return DS18B20_E_BUSY;
d->rom_count = 0U;
search_tree_reset(d);
memset(&d->search_diagnostics, 0, sizeof(d->search_diagnostics));
d->search_diagnostics.last_error_pair = 0xFFU;
d->search_active = 1U;
d->search_result = DS18B20_E_BUSY;
return DS18B20_E_BUSY;
}
ds18b20_status_t ds18b20_search_step(ds18b20_t *d)
{
uint8_t previous_rom[8], previous_discrepancy, previous_family;
ds18b20_search_diagnostics_t previous_diag;
ds18b20_status_t status;
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
if (!d->search_active) return d->search_result;
if (d->search_diagnostics.attempts >= DS18B20_SEARCH_MAX_ATTEMPTS) {
d->search_diagnostics.last_error = DS18B20_E_TIMEOUT;
d->search_diagnostics.last_error_bit = 0U;
d->search_diagnostics.last_error_pair = 0xFFU;
return search_stop(d, DS18B20_E_TIMEOUT);
}
/* Snapshot the preceding validated tree path BEFORE touching the wire.
* A corrupt ROM must never become the path for the following attempt. */
memcpy(previous_rom, d->search_rom, 8U);
previous_discrepancy = d->last_discrepancy;
previous_family = d->last_family_discrepancy;
previous_diag = d->search_diagnostics;
++d->search_diagnostics.attempts;
status = search_next(d);
if (status == DS18B20_E_BUSY) {
/* CRC first: a valid non-DS18B20 family is skipped without corrupting
* the traversal; a bad family caused by noise still gets retried. */
status = ds18b20_crc8(d->search_rom, 7U) == d->search_rom[7] ?
DS18B20_OK : DS18B20_E_CRC;
if (status == DS18B20_OK && d->search_rom[0] == DS18B20_FAMILY_CODE)
status = ds18b20_add_known_rom(d, d->search_rom);
}
if (status == DS18B20_OK) {
/* Keep the last FAILURE location, even when a later retry succeeds. */
d->search_diagnostics.last_error_bit = previous_diag.last_error_bit;
d->search_diagnostics.last_error_pair = previous_diag.last_error_pair;
d->search_retries = 0U;
return d->last_device ? search_pass_finish(d, 1U) : DS18B20_E_BUSY;
}
d->search_diagnostics.last_error = status;
if (status == DS18B20_E_CAPACITY) return search_stop(d, status);
if (status == DS18B20_E_CRC) ++d->search_diagnostics.crc_errors;
else ++d->search_diagnostics.io_errors;
memcpy(d->search_rom, previous_rom, 8U);
d->last_discrepancy = previous_discrepancy;
d->last_family_discrepancy = previous_family;
d->last_device = 0U;
if (d->search_retries < DS18B20_SEARCH_RETRIES) {
++d->search_retries;
++d->search_diagnostics.retries;
return DS18B20_E_BUSY;
}
return search_pass_finish(d, 0U);
}
ds18b20_status_t ds18b20_search(ds18b20_t *d)
{
ds18b20_status_t status;
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
if (d->strong_pullup_active) return DS18B20_E_BUSY;
status = ds18b20_search_begin(d);
while (status == DS18B20_E_BUSY) status = ds18b20_search_step(d);
return status;
}
ds18b20_status_t ds18b20_add_known_rom(ds18b20_t *d, const uint8_t rom[8])
{
size_t i;
ds18b20_status_t status;
if (!valid_instance(d)) return DS18B20_E_ARGUMENT;
status = ds18b20_validate_rom(rom);
if (status != DS18B20_OK) return status;
for (i = 0U; i < d->rom_count; ++i)
if (memcmp(d->roms[i], rom, 8U) == 0) return DS18B20_OK;
if (d->rom_count >= d->rom_capacity) return DS18B20_E_CAPACITY;
memcpy(d->roms[d->rom_count++], rom, 8U);
return DS18B20_OK;
}
size_t ds18b20_count(const ds18b20_t *d) { return valid_instance(d) ? d->rom_count : 0U; }
const uint8_t *ds18b20_rom(const ds18b20_t *d,size_t i) { return valid_instance(d)&&i<d->rom_count?d->roms[i]:NULL; }
ds18b20_status_t ds18b20_start_all(ds18b20_t *d)
{ if(!valid_instance(d))return DS18B20_E_ARGUMENT; if(reset(d))return DS18B20_E_NO_DEVICE; write_byte(d,OW_SKIP_ROM);write_byte(d,DS_CONVERT);return DS18B20_OK; }
ds18b20_status_t ds18b20_start(ds18b20_t *d,const uint8_t *rom)
{ ds18b20_status_t s;if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;if(ds18b20_validate_rom(rom))return DS18B20_E_ROM;s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_CONVERT);return DS18B20_OK; }
ds18b20_status_t ds18b20_conversion_ready(ds18b20_t *d)
{ if(!valid_instance(d))return DS18B20_E_ARGUMENT;return read_bit(d)?DS18B20_OK:DS18B20_E_BUSY; }
ds18b20_status_t ds18b20_wait(ds18b20_t *d,uint32_t timeout)
{ uint32_t start;if(!valid_instance(d)||!d->ops->tick_ms)return DS18B20_E_ARGUMENT;start=d->ops->tick_ms(d->platform_context);while(!read_bit(d))if((uint32_t)(d->ops->tick_ms(d->platform_context)-start)>timeout)return DS18B20_E_TIMEOUT;return DS18B20_OK; }
ds18b20_status_t ds18b20_read_scratchpad(ds18b20_t *d,const uint8_t *rom,uint8_t *sp)
{ uint8_t i;ds18b20_status_t s;if(!valid_instance(d)||!rom||!sp)return DS18B20_E_ARGUMENT;if(ds18b20_validate_rom(rom))return DS18B20_E_ROM;s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_READ_SCRATCHPAD);for(i=0;i<9U;++i)sp[i]=read_byte(d);return ds18b20_crc8(sp,8U)==sp[8]?DS18B20_OK:DS18B20_E_CRC; }
ds18b20_status_t ds18b20_decode_temperature(const uint8_t *sp,float *out)
{ int16_t raw;uint8_t cfg;if(!sp||!out)return DS18B20_E_ARGUMENT;if(ds18b20_crc8(sp,8U)!=sp[8])return DS18B20_E_CRC;cfg=sp[4]&0x60U;raw=(int16_t)((uint16_t)sp[0]|((uint16_t)sp[1]<<8));if(cfg==0)raw&=(int16_t)~7;else if(cfg==0x20)raw&=(int16_t)~3;else if(cfg==0x40)raw&=(int16_t)~1;else if(cfg!=0x60)return DS18B20_E_IO;*out=(float)raw/16.0f;return DS18B20_OK; }
static ds18b20_status_t write_config(ds18b20_t *d,const uint8_t *rom,uint8_t th,uint8_t tl,uint8_t cfg)
{
uint8_t chunk;
ds18b20_status_t s=ds18b20_write_scratchpad(d,rom,th,tl,cfg);
if(s)return s;s=ds18b20_copy_scratchpad_start(d,rom,0U);if(s)return s;
/* TIM1 counter is 16-bit: at 72 ticks/us a single 10 ms wait can never
* satisfy the port comparison. Short chunks preserve wrap-safe timing. */
for(chunk=0U;chunk<100U;++chunk)d->ops->delay_us(d->platform_context,100U);
return ds18b20_copy_scratchpad_finish(d,0U);
}
ds18b20_status_t ds18b20_set_resolution(ds18b20_t *d,const uint8_t *rom,uint8_t bits)
{ uint8_t sp[9],cfg;ds18b20_status_t s;if(bits<9U||bits>12U)return DS18B20_E_ARGUMENT;s=ds18b20_read_scratchpad(d,rom,sp);if(s)return s;cfg=(uint8_t)(0x1FU|((bits-9U)<<5));return write_config(d,rom,sp[2],sp[3],cfg); }
ds18b20_status_t ds18b20_write_user_bytes(ds18b20_t *d,const uint8_t *rom,int16_t b12,int16_t b34,uint8_t mask)
{ uint8_t sp[9];ds18b20_status_t s=ds18b20_read_scratchpad(d,rom,sp);(void)b34;if(s)return s;if(mask&1U)sp[2]=(uint8_t)b12;if(mask&2U)sp[3]=(uint8_t)(b12>>8);/* DS18B20 физически позволяет записать только TH/TL/config; байты 6/7 read-only. */return write_config(d,rom,sp[2],sp[3],sp[4]); }
ds18b20_status_t ds18b20_write_scratchpad(ds18b20_t *d,const uint8_t *rom,
uint8_t th,uint8_t tl,uint8_t cfg)
{
ds18b20_status_t s;
if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
s=ds18b20_validate_rom(rom);if(s)return s;
s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_WRITE_SCRATCHPAD);
write_byte(d,th);write_byte(d,tl);write_byte(d,cfg);return DS18B20_OK;
}
ds18b20_status_t ds18b20_copy_scratchpad_start(ds18b20_t *d,const uint8_t *rom,
uint8_t parasite)
{
ds18b20_status_t s;
if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
if(parasite && !d->ops->strong_pullup)return DS18B20_E_POWER;
s=ds18b20_validate_rom(rom);if(s)return s;
s=reset(d);if(s)return s;match(d,rom);write_byte(d,DS_COPY_SCRATCHPAD);
/* The pull-up must be asserted immediately after the command slot. */
if(parasite){d->ops->strong_pullup(d->platform_context,1U);
d->strong_pullup_active=1U;}
return DS18B20_OK;
}
ds18b20_status_t ds18b20_copy_scratchpad_finish(ds18b20_t *d,uint8_t parasite)
{
if(!valid_instance(d))return DS18B20_E_ARGUMENT;
if(parasite){if(!d->ops->strong_pullup)return DS18B20_E_POWER;
if(d->strong_pullup_active){d->ops->strong_pullup(d->platform_context,0U);
d->strong_pullup_active=0U;}}
d->ops->release(d->platform_context);return DS18B20_OK;
}
ds18b20_status_t ds18b20_recall_e2(ds18b20_t *d,const uint8_t *rom)
{
ds18b20_status_t s;if(!valid_instance(d)||!rom)return DS18B20_E_ARGUMENT;
s=ds18b20_validate_rom(rom);if(s)return s;s=reset(d);if(s)return s;
match(d,rom);write_byte(d,DS_RECALL_E2);return DS18B20_OK;
}
ds18b20_status_t ds18b20_recall_ready(ds18b20_t *d)
{if(!valid_instance(d))return DS18B20_E_ARGUMENT;return read_bit(d)?DS18B20_OK:DS18B20_E_BUSY;}
void ds18b20_recover_bus(ds18b20_t *d)
{
if(!valid_instance(d))return;
if(d->strong_pullup_active&&d->ops->strong_pullup){
d->ops->strong_pullup(d->platform_context,0U);d->strong_pullup_active=0U;}
d->ops->release(d->platform_context);
}
uint8_t ds18b20_search_retry_due(const ds18b20_t *d,uint8_t conversion_active,
uint32_t now,uint32_t last,uint32_t period)
{
if(!valid_instance(d)||d->rom_count!=0U||conversion_active||period==0U)return 0U;
/* Unsigned subtraction keeps the retry correct across HAL tick rollover. */
return ((uint32_t)(now-last)>=period)?1U:0U;
}

View File

@@ -0,0 +1,31 @@
$ErrorActionPreference = "Stop"
$out = Join-Path $env:TEMP "test_ds18b20_copy_delay.exe"
$test = Join-Path $PSScriptRoot "test_ds18b20_copy_delay.c"
$core = Join-Path $PSScriptRoot "..\Src\ds18b20.c"
$inc = Join-Path $PSScriptRoot "..\Inc"
$gcc = Get-Command gcc -ErrorAction SilentlyContinue
if ($gcc) {
& $gcc.Source -std=c99 -Wall -Wextra -Werror -I $inc $test $core -o $out
} else {
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
if (-not (Test-Path $vcvars)) { throw "Neither gcc nor MSVC was found" }
$command = "call `"$vcvars`" >nul && pushd `"$env:TEMP`" && cl /nologo /std:c11 /W4 /WX /I `"$inc`" `"$test`" `"$core`" /Fe:`"$out`""
& cmd.exe /d /c $command
}
if ($LASTEXITCODE -ne 0) { throw "DS18B20 regression compilation failed" }
& $out
if ($LASTEXITCODE -ne 0) { throw "DS18B20 regression failed" }
Remove-Item -LiteralPath $out -Force
$searchOut = Join-Path $env:TEMP "test_ds18b20_incremental_search.exe"
$searchTest = Join-Path $PSScriptRoot "test_ds18b20_incremental_search.c"
if ($gcc) {
& $gcc.Source -std=c99 -Wall -Wextra -Werror -I $inc $searchTest $core -o $searchOut
} else {
$command = "call `"$vcvars`" >nul && pushd `"$env:TEMP`" && cl /nologo /std:c11 /W4 /WX /I `"$inc`" `"$searchTest`" `"$core`" /Fe:`"$searchOut`""
& cmd.exe /d /c $command
}
if ($LASTEXITCODE -ne 0) { throw "DS18B20 incremental search compilation failed" }
& $searchOut
if ($LASTEXITCODE -ne 0) { throw "DS18B20 incremental search failed" }
Remove-Item -LiteralPath $searchOut -Force

View File

@@ -0,0 +1,92 @@
#include "ds18b20.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
typedef struct {
uint8_t scratchpad[DS18B20_SCRATCHPAD_SIZE];
uint16_t read_call;
uint32_t max_delay_us;
uint16_t copy_delay_chunks;
uint8_t critical_active;
} mock_port_t;
static void drive_low(void *context) { (void)context; }
static void release_line(void *context) { (void)context; }
static uint8_t read_line(void *context)
{
mock_port_t *port = (mock_port_t *)context;
uint16_t call = port->read_call++;
assert(port->critical_active); /* Presence and data sampling must be protected. */
if (call == 0U) return 0U; /* Read Scratchpad reset presence. */
if (call <= 72U) {
uint16_t bit = (uint16_t)(call - 1U);
return (uint8_t)((port->scratchpad[bit / 8U] >> (bit % 8U)) & 1U);
}
/* Write Scratchpad and Copy Scratchpad reset presence pulses. */
return 0U;
}
static void delay_us(void *context, uint32_t us)
{
mock_port_t *port = (mock_port_t *)context;
if (us > port->max_delay_us) port->max_delay_us = us;
if (us == 100U) port->copy_delay_chunks++;
}
static void critical_enter(void *context)
{
mock_port_t *port = (mock_port_t *)context;
assert(!port->critical_active);
port->critical_active = 1U;
}
static void critical_exit(void *context)
{
mock_port_t *port = (mock_port_t *)context;
assert(port->critical_active);
port->critical_active = 0U;
}
int main(void)
{
static const ds18b20_onewire_ops_t ops = {
drive_low, release_line, read_line, delay_us, NULL, critical_enter, critical_exit, NULL
};
ds18b20_t bus;
mock_port_t port;
uint8_t roms[1][DS18B20_ROM_SIZE] = {{0}};
memset(&port, 0, sizeof(port));
roms[0][0] = 0x28U;
roms[0][1] = 0x11U;
roms[0][7] = ds18b20_crc8(roms[0], 7U);
port.scratchpad[0] = 0x50U;
port.scratchpad[1] = 0x05U;
port.scratchpad[2] = 0x4BU;
port.scratchpad[3] = 0x46U;
port.scratchpad[4] = 0x7FU;
port.scratchpad[5] = 0xFFU;
port.scratchpad[6] = 0x0CU;
port.scratchpad[7] = 0x10U;
port.scratchpad[8] = ds18b20_crc8(port.scratchpad, 8U);
assert(ds18b20_init(&bus, &ops, &port, roms, 1U) == DS18B20_OK);
bus.rom_count = 1U;
assert(ds18b20_set_resolution(&bus, roms[0], 9U) == DS18B20_OK);
/* Regression: a 10 ms callback overflows the 16-bit 72 MHz timer port. */
assert(port.max_delay_us <= 480U);
assert(port.copy_delay_chunks >= 100U);
bus.rom_count = 0U;
assert(ds18b20_search_retry_due(&bus, 0U, 1999U, 0U, 2000U) == 0U);
assert(ds18b20_search_retry_due(&bus, 0U, 2000U, 0U, 2000U) == 1U);
assert(ds18b20_search_retry_due(&bus, 1U, 4000U, 0U, 2000U) == 0U);
/* Wrap-safe deadline: 0x20 - 0xFFFFFF00 = 0x120 ms. */
assert(ds18b20_search_retry_due(&bus, 0U, 0x20U, 0xFFFFFF00U, 0x120U) == 1U);
bus.rom_count = 1U;
assert(ds18b20_search_retry_due(&bus, 0U, 4000U, 0U, 2000U) == 0U);
puts("DS18B20 copy delay regression: OK");
return 0;
}

View File

@@ -0,0 +1,152 @@
/* Model real SEARCH ROM participation: each branch filters the active slaves.
* Fault injection tests transport recovery, not analog cable characteristics. */
#include "ds18b20.h"
#include "ds18b20_config.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
typedef struct {
ds18b20_t *bus;
uint8_t roms[3][8];
uint8_t masks[3];
unsigned active, command_bits, bit, pair, presence, attempts;
unsigned low_time, elapsed, low, critical, recovery;
unsigned fault_attempt, fault_bit, persistent_fault, crc_fault;
} port_t;
static void low(void *ctx) {
port_t *p = ctx; p->low = 1U; p->low_time = 0U; p->elapsed = 0U;
}
static unsigned rom_bit(port_t *p, unsigned i) {
return (p->roms[i][p->bit / 8U] >> (p->bit % 8U)) & 1U;
}
static unsigned fault(port_t *p) {
return p->attempts == p->fault_attempt ||
(p->persistent_fault && p->attempts >= p->fault_attempt);
}
static void release_line(void *ctx) {
port_t *p = ctx; unsigned i, direction;
if (!p->low) return;
p->low = 0U;
if (p->low_time == 480U) {
++p->attempts;
p->active = p->masks[p->bus->search_diagnostics.passes_finished];
p->presence = 1U; p->command_bits = 0U; p->bit = 0U; p->pair = 0U;
} else if (p->low_time != 3U) {
assert(p->low_time == 6U || p->low_time == 60U);
if (p->command_bits < 8U) { ++p->command_bits; return; }
direction = p->low_time == 6U;
for (i = 0U; i < 3U; ++i)
if (rom_bit(p, i) != direction) p->active &= ~(1U << i);
++p->bit; p->pair = 0U;
}
}
static uint8_t read_line(void *ctx) {
port_t *p = ctx; unsigned i, zeros = 0U, ones = 0U, value;
assert(p->critical);
if (p->presence) {
assert(p->elapsed == 550U); p->presence = 0U;
return p->active ? 0U : 1U;
}
assert(p->elapsed == 13U); /* The read sample did not move. */
assert(p->bit < 64U);
for (i = 0U; i < 3U; ++i) if (p->active & (1U << i)) {
if (rom_bit(p, i)) ++ones; else ++zeros;
}
value = p->pair++ == 0U ? !zeros : !ones;
if (fault(p) && p->bit + 1U == p->fault_bit)
value = p->crc_fault ? !value : 1U;
return (uint8_t)value;
}
static void delay(void *ctx, uint32_t us) {
port_t *p = ctx; p->elapsed += us;
if (p->low) p->low_time += us;
if (us == DS18B20_SLOT_RECOVERY_EXTRA_US) {
assert(!p->critical && !p->low); ++p->recovery;
}
}
static void enter(void *ctx) { port_t *p = ctx; assert(!p->critical); p->critical = 1U; }
static void leave(void *ctx) { port_t *p = ctx; assert(p->critical); p->critical = 0U; }
static const ds18b20_onewire_ops_t ops = {low, release_line, read_line, delay, NULL, enter, leave, NULL};
static void setup(ds18b20_t *bus, port_t *p, uint8_t storage[][8], size_t cap) {
unsigned i;
memset(p, 0, sizeof(*p)); p->bus = bus;
for (i = 0U; i < 3U; ++i) {
p->roms[i][0] = 0x28U; p->roms[i][1] = (uint8_t)(i + 1U);
p->roms[i][7] = ds18b20_crc8(p->roms[i], 7U);
p->masks[i] = 7U;
}
assert(ds18b20_init(bus, &ops, p, storage, cap) == DS18B20_OK);
}
static ds18b20_status_t finish(ds18b20_t *bus, port_t *p) {
ds18b20_status_t s; unsigned calls = 0U, before;
do {
before = p->attempts; s = ds18b20_search_step(bus);
assert(p->attempts <= before + 1U); /* Never retries in a tight loop. */
assert(++calls <= DS18B20_SEARCH_MAX_ATTEMPTS + 1U);
} while (s == DS18B20_E_BUSY);
before = p->attempts;
assert(ds18b20_search_step(bus) == s && p->attempts == before);
return s;
}
int main(void) {
ds18b20_t bus; port_t p; uint8_t storage[3][8], saved[8], discrepancy, family;
setup(&bus, &p, storage, 3U);
assert(ds18b20_search(&bus) == DS18B20_OK);
assert(bus.rom_count == 3U && p.attempts == 9U);
assert(bus.search_diagnostics.complete_passes == 3U && p.recovery != 0U);
assert(bus.search_diagnostics.last_error == DS18B20_OK);
/* A fault on the second branch must restore the PREVIOUS tree path. */
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 17U;
ds18b20_search_begin(&bus);
assert(ds18b20_search_step(&bus) == DS18B20_E_BUSY);
memcpy(saved, bus.search_rom, 8U); discrepancy = bus.last_discrepancy;
family = bus.last_family_discrepancy;
assert(ds18b20_search_step(&bus) == DS18B20_E_BUSY);
assert(memcmp(saved, bus.search_rom, 8U) == 0);
assert(bus.last_discrepancy == discrepancy && bus.last_family_discrepancy == family);
assert(finish(&bus, &p) == DS18B20_OK && bus.rom_count == 3U);
assert(bus.search_diagnostics.retries == 1U);
assert(bus.search_diagnostics.last_error_bit == 17U && bus.search_diagnostics.last_error_pair == 3U);
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 64U; p.crc_fault = 1U;
assert(ds18b20_search(&bus) == DS18B20_OK && bus.rom_count == 3U);
assert(bus.search_diagnostics.crc_errors == 1U && bus.search_diagnostics.retries == 1U);
assert(bus.search_diagnostics.last_error_bit == 64U);
/* Each pass sees a different device; the validated union retains all three. */
setup(&bus, &p, storage, 3U); p.masks[0] = 1U; p.masks[1] = 2U; p.masks[2] = 4U;
assert(ds18b20_search(&bus) == DS18B20_OK && bus.rom_count == 3U);
assert(p.attempts == 3U);
setup(&bus, &p, storage, 3U); memset(p.masks, 0, sizeof(p.masks));
assert(ds18b20_search(&bus) == DS18B20_E_NO_DEVICE);
assert(p.attempts == 15U && bus.search_diagnostics.retries == 12U);
assert(bus.search_diagnostics.last_error_bit == 0U);
assert(ds18b20_add_known_rom(&bus, p.roms[0]) == DS18B20_OK);
assert(ds18b20_add_known_rom(&bus, p.roms[0]) == DS18B20_OK && bus.rom_count == 1U);
assert(ds18b20_add_known_rom(&bus, NULL) == DS18B20_E_ARGUMENT);
p.roms[1][7] ^= 1U;
assert(ds18b20_add_known_rom(&bus, p.roms[1]) == DS18B20_E_CRC);
setup(&bus, &p, storage, 3U); p.fault_attempt = 2U; p.fault_bit = 17U; p.persistent_fault = 1U;
assert(ds18b20_search(&bus) == DS18B20_E_IO && bus.rom_count == 1U);
assert(p.attempts == 16U && bus.search_diagnostics.retries == 12U);
assert(bus.search_diagnostics.complete_passes == 0U);
setup(&bus, &p, storage, 3U); p.fault_attempt = 1U; p.fault_bit = 64U;
p.persistent_fault = 1U; p.crc_fault = 1U;
assert(ds18b20_search(&bus) == DS18B20_E_CRC && bus.rom_count == 0U);
assert(bus.search_diagnostics.crc_errors == 15U);
setup(&bus, &p, storage, 1U);
assert(ds18b20_search(&bus) == DS18B20_E_CAPACITY && bus.rom_count == 1U);
setup(&bus, &p, storage, 3U); ds18b20_search_begin(&bus);
bus.search_diagnostics.attempts = DS18B20_SEARCH_MAX_ATTEMPTS;
assert(finish(&bus, &p) == DS18B20_E_TIMEOUT && p.attempts == 0U);
puts("DS18B20 robust search, retries, CRC, union, limits and timing: OK");
return 0;
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
cmake_minimum_required(VERSION 3.13)
project(flash_storage C)
add_library(flash_storage STATIC Src/flash_storage.c)
target_include_directories(flash_storage PUBLIC Inc)
set_target_properties(flash_storage PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
add_executable(test_flash_storage Tests/test_flash_storage.c ../ds18b20/instance/Src/ds18b20.c)
target_link_libraries(test_flash_storage PRIVATE flash_storage)
target_include_directories(test_flash_storage PRIVATE ../ds18b20/instance/Inc)
add_test(NAME test_flash_storage COMMAND test_flash_storage)

View File

@@ -0,0 +1,104 @@
#ifndef PORTABLE_FLASH_STORAGE_H
#define PORTABLE_FLASH_STORAGE_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define FLASH_STORAGE_IMAGE_SIZE 512U
#define FLASH_STORAGE_RECORD_SIZE 544U
#define FLASH_STORAGE_V1_RECORD_SIZE 288U
typedef enum {
FLASH_STORAGE_OK = 0,
FLASH_STORAGE_E_ARGUMENT = -1,
FLASH_STORAGE_E_LAYOUT = -2,
FLASH_STORAGE_E_IO = -3,
FLASH_STORAGE_E_NOT_FOUND = -4,
FLASH_STORAGE_E_CORRUPT = -5,
FLASH_STORAGE_E_BUSY = -6,
FLASH_STORAGE_E_BOUNDS = -7
} flash_storage_status_t;
typedef struct {
flash_storage_status_t (*read)(void *context, uint32_t address,
void *data, size_t size);
flash_storage_status_t (*program)(void *context, uint32_t address,
const void *data, size_t size);
flash_storage_status_t (*erase)(void *context, uint32_t address,
size_t size);
uint32_t (*tick_ms)(void *context);
} flash_storage_ops_t;
typedef struct {
uint32_t bank_address[2];
uint32_t bank_size;
uint32_t program_alignment;
uint32_t erase_alignment;
uint32_t minimum_write_ms;
uint8_t reserve_percent;
} flash_storage_layout_t;
typedef struct {
const flash_storage_ops_t *ops;
void *platform_context;
flash_storage_layout_t layout;
uint32_t latest_address;
uint32_t latest_sequence;
uint32_t next_address;
uint32_t last_write_tick;
uint32_t valid_records;
uint32_t programmed_slots;
uint16_t latest_version;
uint8_t initialized;
uint8_t busy;
uint8_t wrote_this_boot;
/* Рабочая запись принадлежит экземпляру: Flash API не расходует малый
* embedded-стек на 544-байтные автоматические структуры. */
uint32_t workspace[FLASH_STORAGE_RECORD_SIZE / sizeof(uint32_t)];
} flash_storage_t;
typedef struct {
flash_storage_t journal;
uint8_t image[FLASH_STORAGE_IMAGE_SIZE];
uint8_t next_image[FLASH_STORAGE_IMAGE_SIZE];
uint8_t initialized;
} eeprom_store_t;
typedef struct {
uint32_t sequence;
uint32_t valid_records;
uint32_t programmed_slots;
uint32_t allocated_bytes;
uint32_t reserved_bytes;
} flash_storage_info_t;
flash_storage_status_t flash_storage_init(flash_storage_t *instance,
const flash_storage_ops_t *ops, void *platform_context,
const flash_storage_layout_t *layout);
flash_storage_status_t flash_storage_read_latest(flash_storage_t *instance,
void *data, size_t capacity, size_t *length, uint32_t *sequence);
flash_storage_status_t flash_storage_write(flash_storage_t *instance,
const void *data, size_t length);
flash_storage_status_t flash_storage_format(flash_storage_t *instance);
flash_storage_status_t flash_storage_get_info(flash_storage_t *instance,
flash_storage_info_t *info);
flash_storage_status_t eeprom_store_init(eeprom_store_t *instance,
const flash_storage_ops_t *ops, void *platform_context,
const flash_storage_layout_t *layout);
flash_storage_status_t eeprom_store_read(eeprom_store_t *instance,
size_t offset, void *data, size_t size);
flash_storage_status_t eeprom_store_write(eeprom_store_t *instance,
size_t offset, const void *data, size_t size);
flash_storage_status_t eeprom_store_format(eeprom_store_t *instance);
/* API не ISR-safe: erase/program могут надолго блокировать CPU. Один экземпляр
* обслуживается последовательно; разные экземпляры допустимы на независимых
* областях, а общий HAL Flash должен сериализовать платформенный адаптер. */
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,8 @@
#ifndef PORTABLE_FLASH_STORAGE_CONFIG_H
#define PORTABLE_FLASH_STORAGE_CONFIG_H
#define FLASH_STORAGE_MAGIC 0x474E4952UL
#define FLASH_STORAGE_COMMIT 0x54494D43UL
#define FLASH_STORAGE_VERSION 2U
#define FLASH_STORAGE_VERSION_V1 1U
#define FLASH_STORAGE_MIN_RESERVE_PERCENT 10U
#endif

43
c/flash-storage/README.md Normal file
View File

@@ -0,0 +1,43 @@
# FlashStorage
Переносимый журнал двух банков Flash с подтверждёнными записями и восстановлением
после прерванной записи. Предоставляет также EEPROM-подобный образ 512 байт.
Не содержит HAL, файловой системы, адресов конкретной платы или malloc.
Приложение → `flash_storage` / `eeprom_store` → callbacks → Flash-порт платы.
| Файл | Назначение |
|---|---|
| `Inc/flash_storage.h` | Контекст, раскладка банков, read/program/erase/tick callbacks |
| `Inc/flash_storage_config.h` | Версия формата, magic, commit marker |
| `Src/flash_storage.c` | Восстановление, запись, ротация банков; только стандартный C |
| `Tests/test_flash_storage.c` | Прерывание записи, восстановление, несколько экземпляров |
Порт получает контекст, адрес и длину. `read`/`program`/`erase` возвращают
`flash_storage_status_t`; `tick_ms` возвращает uint32_t. Буферы принадлежат
экземпляру. Стирание и программирование могут блокировать процессор: API не
предназначен для ISR. Раскладку и резерв места задаёт `flash_storage_layout_t`.
```c
/* board_ops, board_context и layout определяет порт конкретной платы. */
flash_storage_t journal;
uint8_t payload[512] = {0}, restored[512];
size_t length = 0;
flash_storage_status_t result;
result = flash_storage_init(&journal, &board_ops, board_context, &layout);
if (result == FLASH_STORAGE_OK) {
result = flash_storage_write(&journal, payload, sizeof(payload));
}
if (result == FLASH_STORAGE_OK) {
result = flash_storage_read_latest(&journal, restored,
sizeof(restored), &length, 0);
}
```
Существующий потребитель — climate. Порты STM32F4 и SPI NOR остаются там.
Проверка: `python tools/test_shared_libraries.py` из корня templates.
## Shared source
Canonical source: `templates/c/flash-storage`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9
c/modbus/CMakeLists.txt Normal file
View File

@@ -0,0 +1,9 @@
cmake_minimum_required(VERSION 3.13)
project(set_modbus C)
add_library(set_modbus STATIC src/modbus_data.c)
target_include_directories(set_modbus PUBLIC include)
set_target_properties(set_modbus PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
add_executable(test_modbus_data tests/test_modbus_data.c)
target_link_libraries(test_modbus_data PRIVATE set_modbus)
add_test(NAME modbus_data COMMAND test_modbus_data)

41
c/modbus/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Общее ядро команд Modbus
Обработка coils и регистров функций 01/03/04/05/06/0F/10 без HAL, UART,
таймеров, глобальной карты регистров и выделения памяти.
Приложение → декодированный запрос → проверка и отображение адреса портом →
`mb_data_transfer` → банк регистров/битов приложения.
| Файл | Назначение и зависимости |
|---|---|
| `include/modbus_data.h` | Контракт, только stdint/stddef |
| `src/modbus_data.c` | Общие ограничения количества, обработка coils и регистров |
| `ports/stm32-legacy/modbus.c` | Совместимый адаптер старого John; зависит от проектного `rs_message.h` |
| `tests/test_modbus_data.c` | Границы и проход через все смещения coils 0..15 |
Порт сначала вызывает `mb_data_validate`, затем отображает **весь** диапазон
адресов в память, и только после успешного отображения вызывает transfer.
Коды возврата соответствуют исключениям Modbus: 0 — успех, 1 — функция,
3 — значение. Ёмкость данных передаётся в 16-битных словах. Адресная карта,
права записи, RTU framing, CRC и таймеры остаются обязанностью транспорта.
```c
#include "modbus_data.h"
uint16_t registers[2] = {123, 456};
uint16_t response[2];
uint16_t response_bytes = 0;
uint8_t status = mb_data_validate(3, 2, 0, 2);
/* Пример банка уже отображён; реальный порт проверяет адрес до вызова. */
if (status == 0) {
status = mb_data_transfer(3, 2, 0, registers, 0,
response, 2, &response_bytes);
}
/* status == 0, response_bytes == 4; сериализацию выполняет транспорт. */
```
Используется в `home/climate/core/STM32_Modbus`, `john103C6T6` и
`ds18b20-c8t6test`. Два последних подключают один совместимый адаптер;
климат сохраняет свой master/slave транспорт и диагностические функции.
Это не замена `pcan_modbus_server`, у которого другой транспортный контракт.
Проверка: `python tools/test_shared_libraries.py` из корня templates.

View File

@@ -0,0 +1,15 @@
#ifndef SET_MODBUS_DATA_H
#define SET_MODBUS_DATA_H
#include <stddef.h>
#include <stdint.h>
/* Decoded Modbus values, independent of UART/HAL and register addresses.
* Coils in bank use bit 0 first in 16-bit words. Message words contain two
* wire bytes, first byte in bits 15..8 (same convention as register messages).
* The port validates/maps the complete address range before calling transfer.
* Return standard Modbus exceptions: 0 success, 1 function, 3 value. */
uint8_t mb_data_validate(uint8_t function, uint16_t quantity, uint16_t byte_count,
size_t data_capacity);
uint8_t mb_data_transfer(uint8_t function, uint16_t quantity, uint16_t byte_count,
uint16_t *bank, uint16_t bit_offset, uint16_t *data,
size_t data_capacity, uint16_t *response_bytes);
#endif

View File

@@ -0,0 +1,836 @@
/**
**************************************************************************
* @file modbus.c
* @brief Модуль для реализации MODBUS.
**************************************************************************
* @details Файл содержит реализацию функций работы с Modbus, включая:
* - доступ к coils и registers;
* - обработку команд протокола;
* - взаимодействие с RS (UART);
* - инициализацию.
*
* @section Функции и макросы
*
* ### Доступ к coils:
* - MB_Set_Coil_Local() — Установить coil по локальному адресу.
* - MB_Reset_Coil_Local() — Сбросить coil по локальному адресу.
* - MB_Toogle_Coil_Local() — Инвертировать coil по локальному адресу.
* - MB_Read_Coil_Local() — Прочитать coil по локальному адресу.
* - MB_Write_Coil_Global() — Установить/сбросить coil по глобальному адресу.
* - MB_Read_Coil_Global() — Прочитать coil по глобальному адресу.
*
* ### Обработка команд Modbus:
* - MB_DefineRegistersAddress() — Определить начальный адрес регистра.
* - MB_DefineCoilsAddress() — Определить начальный адрес coils.
* - MB_Check_Address_For_Arr() — Проверить, принадлежит ли адрес массиву.
* - Основные команды Modbus:
* - MB_Read_Coils()
* - MB_Read_Hold_Regs()
* - MB_Write_Single_Coil()
* - MB_Write_Miltuple_Coils()
* - MB_Write_Miltuple_Regs()
*
* ### Функции для работы с RS (UART):
* - RS_Parse_Message() / RS_Collect_Message() — Парсинг и сборка сообщения.
* - RS_Response() — Отправка ответа.
* - RS_Define_Size_of_RX_Message() — Определение размера принимаемого сообщения.
* - RS_Init() — Инициализация UART.
*
* ### Инициализация:
* - MODBUS_FirstInit() — Инициализация модуля Modbus.
*
* @section Структура данных Modbus
*
* #### Holding/Input Registers:
* - Регистры — 16-битные слова. Доступ к регистрам осуществляется через указатель.
* Таким образом, сами регистры могут представлять собой как массив так и структуру.
*
* #### Coils:
* - Coils — это биты, упакованные в 16-битные слова. Доступ к коилам осуществляется через указатель.
* Таким образом, сами коилы могут представлять собой как массив так и структуру.
*
* @section Инструкция по подключению
* Для корректной работы надо подключить обработчики RS_UART_Handler(), RS_TIM_Handler(),
* в соответствубщие низкоуровневые прерывания UART_IRQHandler, TIM_IRQHandler. После HAL'овского обработчика
*
* Также необходимо в modbus_config.h настроить дефайны для нужной работы UART
* После для запуска Modbus:
* @verbatim
//----------------Прием модбас----------------//
#include "rs_message.h"
#include "../../src/modbus_data.c"
MODBUS_FirstInit();
RS_Receive_IT(&hmodbus1, &MODBUS_MSG);
* @endverbatim
*
******************************************************************************/
#include "rs_message.h"
#include "../../src/modbus_data.c"
uint32_t dbg_temp, dbg_temp2, dbg_temp3; // for debug
/* MODBUS HANDLES */
extern UART_HandleTypeDef rs_huart;
extern TIM_HandleTypeDef rs_htim;
RS_HandleTypeDef hmodbus1;
/* DEFINE REGISTERS/COILS */
MB_DeviceIdentificationTypeDef MB_INFO;
MB_DataStructureTypeDef MB_DATA;
RS_MsgTypeDef MODBUS_MSG;
//-------------------------------------------------------------------
//-----------------------------FOR USER------------------------------
/**
* @brief First set up of MODBUS.
* @details Первый инит модбас. Заполняет структуры и инициализирует таймер и юарт для общения по модбас.
* @note This called from main
*/
void MODBUS_FirstInit(void)
{
MB_DevoceInentificationInit();
//-----------SETUP MODBUS-------------
// set up modbus: MB_RX_Size_NotConst and Timeout enable
hmodbus1.ID = MODBUS_DEVICE_ID;
hmodbus1.sRS_Timeout = MODBUS_TIMEOUT;
hmodbus1.sRS_Mode = SLAVE_ALWAYS_WAIT;
hmodbus1.sRS_RX_Size_Mode = RS_RX_Size_NotConst;
// INIT
hmodbus1.RS_STATUS = RS_Init(&hmodbus1, &rs_huart, &rs_htim, 0);
RS_EnableReceive();
}
/**
* @brief Set or Reset Coil at its global address.
* @param Addr - адрес коила.
* @param WriteVal - Что записать в коил: 0 или 1.
* @return ExceptionCode - Код исключения если коила по адресу не существует, и NO_ERRORS если все ок.
*
* @details Позволяет обратиться к любому коилу по его глобальному адрессу.
Вне зависимости от того как коилы размещены в памяти.
*/
MB_ExceptionTypeDef MB_Write_Coil_Global(uint16_t Addr, MB_CoilsOpTypeDef WriteVal)
{
//---------CHECK FOR ERRORS----------
MB_ExceptionTypeDef Exception = NO_ERRORS;
uint16_t *coils;
uint16_t start_shift = 0; // shift in coils register
//------------WRITE COIL-------------
Exception = MB_DefineCoilsAddress(&coils, Addr, 1, &start_shift, 1);
if(Exception == NO_ERRORS)
{
switch(WriteVal)
{
case SET_COIL:
*coils |= (1<<start_shift);
break;
case RESET_COIL:
*coils &= ~(1<<start_shift);
break;
case TOOGLE_COIL:
*coils ^= (1<<start_shift);
break;
}
}
return Exception;
}
/**
* @brief Read Coil at its global address.
* @param Addr - адрес коила.
* @param Exception - Указатель на переменную для кода исключения, в случа неудачи при чтении.
* @return uint16_t - Возвращает весь регистр с маской на запрошенном коиле.
*
* @details Позволяет обратиться к любому коилу по его глобальному адрессу.
Вне зависимости от того как коилы размещены в памяти.
*/
uint16_t MB_Read_Coil_Global(uint16_t Addr, MB_ExceptionTypeDef *Exception)
{
//---------CHECK FOR ERRORS----------
MB_ExceptionTypeDef Exception_tmp;
if(Exception == NULL) // if exception is not given to func fill it
Exception = &Exception_tmp;
uint16_t *coils;
uint16_t start_shift = 0; // shift in coils register
//------------READ COIL--------------
*Exception = MB_DefineCoilsAddress(&coils, Addr, 1, &start_shift, 0);
if(*Exception == NO_ERRORS)
{
return ((*coils)&(1<<start_shift));
}
else
{
return 0;
}
}
//-------------------------------------------------------------------
//----------------FUNCTIONS FOR PROCESSING MESSAGE-------------------
/**
* @brief Check is address valid for certain array.
* @param Addr - начальный адресс.
* @param Qnt - количество запрашиваемых элементов.
* @param R_ARR_ADDR - начальный адресс массива R_ARR.
* @param R_ARR_NUMB - количество элементов в массиве R_ARR.
* @return ExceptionCode - ILLEGAL DATA ADRESS если адресс недействителен, и NO_ERRORS если все ок.
*
* @details Позволяет определить, принадлежит ли адресс Addr массиву R_ARR:
* Если адресс Addr находится в диапазоне адрессов массива R_ARR, то возвращаем NO_ERROR.
* Если адресс Addr находится за пределами адрессов массива R_ARR - ILLEGAL_DATA_ADDRESSю.
*/
MB_ExceptionTypeDef MB_Check_Address_For_Arr(uint16_t Addr, uint16_t Qnt, uint16_t R_ARR_ADDR, uint16_t R_ARR_NUMB)
{
// if address from this array
if(Addr >= R_ARR_ADDR)
{
// if quantity too big return error
if ((Addr - R_ARR_ADDR) + Qnt > R_ARR_NUMB)
{
return ILLEGAL_DATA_ADDRESS; // return exception code
}
// if all ok - return no errors
return NO_ERRORS;
}
// if address isnt from this array return error
else
return ILLEGAL_DATA_ADDRESS; // return exception code
}
/**
* @brief Define Address Origin for Input/Holding Registers
* @param pRegs - указатель на указатель регистров.
* @param Addr - адрес начального регистра.
* @param Qnt - количество запрашиваемых регистров.
* @param WriteFlag - флаг регистр нужны для чтения или записи.
* @return ExceptionCode - Код исключения если есть, и NO_ERRORS если нет.
*
* @details Определение адреса начального регистра.
* @note WriteFlag пока не используется.
*/
MB_ExceptionTypeDef MB_DefineRegistersAddress(uint16_t **pRegs, uint16_t Addr, uint16_t Qnt, uint8_t RegisterType)
{
/* check quantity error */
if (Qnt > 125)
{
return ILLEGAL_DATA_VALUE; // return exception code
}
if(RegisterType == RegisterType_Holding)
{
// Default holding registers
if(MB_Check_Address_For_Arr(Addr, Qnt, R_HOLDING_ADDR, R_HOLDING_QNT) == NO_ERRORS)
{
*pRegs = MB_Set_Register_Ptr(&MB_DATA.HoldRegs, Addr); // указатель на выбранный по Addr регистр
}
// if address doesnt match any array - return illegal data address response
else
{
return ILLEGAL_DATA_ADDRESS;
}
}
else if(RegisterType == RegisterType_Input)
{
// Default input registers
if(MB_Check_Address_For_Arr(Addr, Qnt, R_INPUT_ADDR, R_INPUT_QNT) == NO_ERRORS)
{
*pRegs = MB_Set_Register_Ptr(&MB_DATA.InRegs, Addr); // указатель на выбранный по Addr регистр
}
// if address doesnt match any array - return illegal data address response
else
{
return ILLEGAL_DATA_ADDRESS;
}
}
else
{
return ILLEGAL_FUNCTION;
}
// if found requeried array return no err
return NO_ERRORS; // return no errors
}
/**
* @brief Define Address Origin for coils
* @param pCoils - указатель на указатель коилов.
* @param Addr - адресс начального коила.
* @param Qnt - количество запрашиваемых коилов.
* @param start_shift - указатель на переменную содержащую сдвиг внутри регистра для начального коила.
* @param WriteFlag - флаг коилы нужны для чтения или записи.
* @return ExceptionCode - Код исключения если есть, и NO_ERRORS если нет.
*
* @details Определение адреса начального регистра запрашиваемых коилов.
* @note WriteFlag используется для определния регистров GPIO: ODR или IDR.
*/
MB_ExceptionTypeDef MB_DefineCoilsAddress(uint16_t **pCoils, uint16_t Addr, uint16_t Qnt, uint16_t *start_shift, uint8_t WriteFlag)
{
/* check quantity error */
if (Qnt > 2000)
{
return ILLEGAL_DATA_VALUE; // return exception code
}
// Default coils
if(MB_Check_Address_For_Arr(Addr, Qnt, C_CONTROL_ADDR, C_CONTROL_QNT) == NO_ERRORS)
{
*pCoils = MB_Set_Coil_Reg_Ptr(&MB_DATA.Coils, Addr); // указатель на выбранный по Addr массив коилов
}
// if address doesnt match any array - return illegal data address response
else
{
return ILLEGAL_DATA_ADDRESS;
}
*start_shift = Addr % 16; // set shift to requested coil
// if found requeried array return no err
return NO_ERRORS; // return no errors
}
/**
* @brief Proccess command Read Coils (01 - 0x01).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Read Coils.
*/
uint8_t MB_Read_Coils(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(1, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, &shift, 0);
if (!status) status = mb_data_transfer(1, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Read Holding Registers (03 - 0x03).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Read Holding Registers.
*/
uint8_t MB_Read_Hold_Regs(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(3, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Holding);
if (!status) status = mb_data_transfer(3, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Read Input Registers (04 - 0x04).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Read Input Registers.
*/
uint8_t MB_Read_Input_Regs(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(4, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Input);
if (!status) status = mb_data_transfer(4, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Write Single Coils (05 - 0x05).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Write Single Coils.
*/
uint8_t MB_Write_Single_Coil(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(5, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, 1, &shift, 1);
if (!status) status = mb_data_transfer(5, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Write Single Register (06 - 0x06).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Write Single Register.
*/
uint8_t MB_Write_Single_Reg(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(6, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, 1, RegisterType_Holding);
if (!status) status = mb_data_transfer(6, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Write Multiple Coils (15 - 0x0F).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Write Multiple Coils.
*/
uint8_t MB_Write_Miltuple_Coils(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(15, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineCoilsAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, &shift, 1);
if (!status) status = mb_data_transfer(15, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
/**
* @brief Proccess command Write Multiple Registers (16 - 0x10).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Write Multiple Registers.
*/
uint8_t MB_Write_Miltuple_Regs(RS_MsgTypeDef *modbus_msg)
{
uint16_t *bank, shift = 0, response_bytes = modbus_msg->ByteCnt;
uint8_t status = mb_data_validate(16, modbus_msg->Qnt, modbus_msg->ByteCnt,
sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]));
if (!status) status = (uint8_t)MB_DefineRegistersAddress(&bank, modbus_msg->Addr, modbus_msg->Qnt, RegisterType_Holding);
if (!status) status = mb_data_transfer(16, modbus_msg->Qnt, modbus_msg->ByteCnt,
bank, shift, modbus_msg->DATA, sizeof(modbus_msg->DATA) / sizeof(modbus_msg->DATA[0]), &response_bytes);
modbus_msg->Except_Code = (MB_ExceptionTypeDef)status;
if (status) return 0;
modbus_msg->ByteCnt = response_bytes;
return 1;
}
void MB_WriteObjectToMessage(char *mbdata, unsigned *ind, MB_DeviceObjectTypeDef *obj)
{
mbdata[(*ind)++] = obj->length;
for (int i = 0; i < obj->length; i++)
{
mbdata[(*ind)++] = obj->name[i];
}
}
/**
* @brief Proccess command Read Device Identification (43/14 - 0x2B/0E).
* @param modbus_msg - указатель на структуру собщения modbus.
* @return fMessageHandled - статус о результате обработки комманды.
* @details Обработка команды Write Single Register.
*/
uint8_t MB_Read_Device_Identification(RS_MsgTypeDef *modbus_msg)
{
char *mbdata = (char *)modbus_msg->DATA;
unsigned ind = 0;
switch(modbus_msg->DevId.ReadDevId)
{
case MB_BASIC_IDENTIFICATION:
mbdata[ind++] = 0x00;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.VendorName);
mbdata[ind++] = 0x01;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ProductCode);
mbdata[ind++] = 0x02;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.Revision);
modbus_msg->DevId.NumbOfObj = 3;
break;
case MB_REGULAR_IDENTIFICATION:
mbdata[ind++] = 0x03;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.VendorUrl);
mbdata[ind++] = 0x04;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ProductName);
mbdata[ind++] = 0x05;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.ModelName);
mbdata[ind++] = 0x06;
MB_WriteObjectToMessage(mbdata, &ind, &MB_INFO.UserApplicationName);
modbus_msg->DevId.NumbOfObj = 4;
break;
default:
return 0;
}
modbus_msg->ByteCnt = ind;
return 1;
}
/**
* @brief Respond accord to received message.
* @param hRS - указатель на хендлер RS.
* @param RS_msg - указатель на структуру сообщения.
* @return RS_RES - статус о результате ответа на комманду.
* @details Обработка принятой комманды и ответ на неё.
*/
RS_StatusTypeDef RS_Response(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg)
{
RS_StatusTypeDef MB_RES = 0;
hmodbus->f.MessageHandled = 0;
hmodbus->f.EchoResponse = 0;
RS_Reset_TX_Flags(hmodbus); // reset flag for correct transmit
if(modbus_msg->Func_Code < ERR_VALUES_START)// if no errors after parsing
{
switch (modbus_msg->Func_Code)
{
// Read Coils
case MB_R_COILS:
hmodbus->f.MessageHandled = MB_Read_Coils(hmodbus->pMessagePtr);
break;
// Read Hodling Registers
case MB_R_HOLD_REGS:
hmodbus->f.MessageHandled = MB_Read_Hold_Regs(hmodbus->pMessagePtr);
break;
case MB_R_IN_REGS:
hmodbus->f.MessageHandled = MB_Read_Input_Regs(hmodbus->pMessagePtr);
break;
// Write Single Coils
case MB_W_COIL:
hmodbus->f.MessageHandled = MB_Write_Single_Coil(hmodbus->pMessagePtr);
if(hmodbus->f.MessageHandled)
{
hmodbus->f.EchoResponse = 1;
hmodbus->RS_Message_Size -= 2; // echo response if write ok (minus 2 cause of two CRC bytes)
}
break;
case MB_W_HOLD_REG:
hmodbus->f.MessageHandled = MB_Write_Single_Reg(hmodbus->pMessagePtr);
if(hmodbus->f.MessageHandled)
{
hmodbus->f.EchoResponse = 1;
hmodbus->RS_Message_Size -= 2; // echo response if write ok (minus 2 cause of two CRC bytes)
}
break;
// Write Multiple Coils
case MB_W_COILS:
hmodbus->f.MessageHandled = MB_Write_Miltuple_Coils(hmodbus->pMessagePtr);
if(hmodbus->f.MessageHandled)
{
hmodbus->f.EchoResponse = 1;
hmodbus->RS_Message_Size = 6; // echo response if write ok (withous data bytes)
}
break;
// Write Multiple Registers
case MB_W_HOLD_REGS:
hmodbus->f.MessageHandled = MB_Write_Miltuple_Regs(hmodbus->pMessagePtr);
if(hmodbus->f.MessageHandled)
{
hmodbus->f.EchoResponse = 1;
hmodbus->RS_Message_Size = 6; // echo response if write ok (withous data bytes)
}
break;
case MB_R_DEVICE_INFO:
hmodbus->f.MessageHandled = MB_Read_Device_Identification(hmodbus->pMessagePtr);
break;
/* unknown func code */
default: modbus_msg->Except_Code = 0x01; /* set exception code: illegal function */
}
if(hmodbus->f.MessageHandled == 0)
{
modbus_msg->Func_Code += ERR_VALUES_START;
}
else
{
}
}
// if we need response - check that transmit isnt busy
if( RS_Is_TX_Busy(hmodbus) )
RS_Abort(hmodbus, ABORT_TX); // if tx busy - set it free
// Transmit right there, or sets (fDeferredResponse) to transmit response in main code
MB_RES = RS_Handle_Transmit_Start(hmodbus, modbus_msg);
hmodbus->RS_STATUS = MB_RES;
return MB_RES;
}
/**
* @brief Collect message in buffer to transmit it.
* @param hRS - указатель на хендлер RS.
* @param RS_msg - указатель на структуру сообщения.
* @param msg_uart_buff - указатель на буффер UART.
* @return RS_RES - статус о результате заполнения буфера.
* @details Заполнение буффера UART из структуры сообщения.
*/
RS_StatusTypeDef RS_Collect_Message(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg, uint8_t *modbus_uart_buff)
{
int ind = 0; // ind for modbus-uart buffer
if(hmodbus->f.EchoResponse && hmodbus->f.MessageHandled) // if echo response need
ind = hmodbus->RS_Message_Size;
else
{
//------INFO ABOUT DATA/MESSAGE------
//-----------[first bytes]-----------
// set ID of message/user
modbus_uart_buff[ind++] = modbus_msg->MbAddr;
// set dat or err response
modbus_uart_buff[ind++] = modbus_msg->Func_Code;
if (modbus_msg->Func_Code < ERR_VALUES_START) // if no error occur
{
// fill modbus header
if(modbus_msg->Func_Code == MB_R_DEVICE_INFO) // devide identification header
{
modbus_uart_buff[ind++] = modbus_msg->DevId.MEI_Type;
modbus_uart_buff[ind++] = modbus_msg->DevId.ReadDevId;
modbus_uart_buff[ind++] = modbus_msg->DevId.Conformity;
modbus_uart_buff[ind++] = modbus_msg->DevId.MoreFollows;
modbus_uart_buff[ind++] = modbus_msg->DevId.NextObjId;
modbus_uart_buff[ind++] = modbus_msg->DevId.NumbOfObj;
if (modbus_msg->ByteCnt > DATA_SIZE*2) // if ByteCnt less than DATA_SIZE
{
return RS_COLLECT_MSG_ERR;
}
//---------------DATA----------------
//-----------[data bytes]------------
uint8_t *tmp_data_addr = (uint8_t *)modbus_msg->DATA;
for(int i = 0; i < modbus_msg->ByteCnt; i++) // filling buffer with data
{ // set data
modbus_uart_buff[ind++] = *tmp_data_addr;
tmp_data_addr++;
}
}
else // modbus data header
{
// set size of received data
if (modbus_msg->ByteCnt <= DATA_SIZE*2) // if ByteCnt less than DATA_SIZE
modbus_uart_buff[ind++] = modbus_msg->ByteCnt;
else // otherwise return data_size err
{
return RS_COLLECT_MSG_ERR;
}
//---------------DATA----------------
//-----------[data bytes]------------
uint16_t *tmp_data_addr = (uint16_t *)modbus_msg->DATA;
for(int i = 0; i < modbus_msg->ByteCnt; i++) // filling buffer with data
{ // set data
if (i%2 == 0) // HI byte
modbus_uart_buff[ind++] = (*tmp_data_addr)>>8;
else // LO byte
{
modbus_uart_buff[ind++] = *tmp_data_addr;
tmp_data_addr++;
}
}
}
}
else // if some error occur
{ // send expection code
modbus_uart_buff[ind++] = modbus_msg->Except_Code;
}
}
//---------------CRC----------------
//---------[last 16 bytes]----------
// calc crc of received data
uint16_t CRC_VALUE = crc16(modbus_uart_buff, ind);
// write crc to message structure and modbus-uart buffer
modbus_msg->MB_CRC = CRC_VALUE;
modbus_uart_buff[ind++] = CRC_VALUE;
modbus_uart_buff[ind++] = CRC_VALUE >> 8;
hmodbus->RS_Message_Size = ind;
return RS_OK; // returns ok
}
/**
* @brief Parse message from buffer to process it.
* @param hRS - указатель на хендлер RS.
* @param RS_msg - указатель на структуру сообщения.
* @param msg_uart_buff - указатель на буффер UART.
* @return RS_RES - статус о результате заполнения структуры.
* @details Заполнение структуры сообщения из буффера UART.
*/
RS_StatusTypeDef RS_Parse_Message(RS_HandleTypeDef *hmodbus, RS_MsgTypeDef *modbus_msg, uint8_t *modbus_uart_buff)
{
uint32_t check_empty_buff;
int ind = 0; // ind for modbus-uart buffer
//-----INFO ABOUT DATA/MESSAGE-------
//-----------[first bits]------------
// get ID of message/user
modbus_msg->MbAddr = modbus_uart_buff[ind++];
if(modbus_msg->MbAddr != hmodbus->ID)
return RS_SKIP;
// get func code
modbus_msg->Func_Code = modbus_uart_buff[ind++];
if(modbus_msg->Func_Code == MB_R_DEVICE_INFO) // if it device identification request
{
modbus_msg->DevId.MEI_Type = modbus_uart_buff[ind++];
modbus_msg->DevId.ReadDevId = modbus_uart_buff[ind++];
modbus_msg->DevId.NextObjId = modbus_uart_buff[ind++];
modbus_msg->ByteCnt = 0;
}
else // if its classic modbus request
{
// get address from CMD
modbus_msg->Addr = modbus_uart_buff[ind++] << 8;
modbus_msg->Addr |= modbus_uart_buff[ind++];
// get address from CMD
modbus_msg->Qnt = modbus_uart_buff[ind++] << 8;
modbus_msg->Qnt |= modbus_uart_buff[ind++];
}
if(hmodbus->f.RX_Half == 0) // if all message received
{
//---------------DATA----------------
// (optional)
if (modbus_msg->ByteCnt != 0)
{
ind++; // increment ind for data_size byte
//check that data size is correct
if (modbus_msg->ByteCnt > DATA_SIZE*2)
{
modbus_msg->Func_Code += ERR_VALUES_START;
return RS_PARSE_MSG_ERR;
}
uint16_t *tmp_data_addr = (uint16_t *)modbus_msg->DATA;
for(int i = 0; i < modbus_msg->ByteCnt; i++) // /2 because we transmit 8 bits, not 16 bits
{ // set data
if (i%2 == 0)
*tmp_data_addr = ((uint16_t)modbus_uart_buff[ind++] << 8);
else
{
*tmp_data_addr |= modbus_uart_buff[ind++];
tmp_data_addr++;
}
}
}
//---------------CRC----------------
//----------[last 16 bits]----------
// calc crc of received data
uint16_t CRC_VALUE = crc16(modbus_uart_buff, ind);
// get crc of received data
modbus_msg->MB_CRC = modbus_uart_buff[ind++];
modbus_msg->MB_CRC |= modbus_uart_buff[ind++] << 8;
// compare crc
if (modbus_msg->MB_CRC != CRC_VALUE)
{
modbus_msg->Func_Code += ERR_VALUES_START;
}
// hmodbus->MB_RESPONSE = MB_CRC_ERR; // set func code - error about wrong crc
// check is buffer empty
check_empty_buff = 0;
for(int i=0; i<ind;i++)
check_empty_buff += modbus_uart_buff[i];
// if(check_empty_buff == 0)
// hmodbus->MB_RESPONSE = MB_EMPTY_MSG; //
}
return RS_OK;
}
/**
* @brief Define size of RX Message that need to be received.
* @param hRS - указатель на хендлер RS.
* @param rx_data_size - указатель на переменную для записи кол-ва байт для принятия.
* @return RS_RES - статус о корректности рассчета кол-ва байт для принятия.
* @details Определение сколько байтов надо принять по протоколу.
*/
RS_StatusTypeDef RS_Define_Size_of_RX_Message(RS_HandleTypeDef *hmodbus, uint32_t *rx_data_size)
{
RS_StatusTypeDef MB_RES = 0;
MB_RES = RS_Parse_Message(hmodbus, hmodbus->pMessagePtr, hmodbus->pBufferPtr);
if(MB_RES == RS_SKIP) // if message not for us
return MB_RES; // return
if ((hmodbus->pMessagePtr->Func_Code & ~ERR_VALUES_START) < 0x0F)
{
hmodbus->pMessagePtr->ByteCnt = 0;
*rx_data_size = 1;
}
else
{
hmodbus->pMessagePtr->ByteCnt = hmodbus->pBufferPtr[RX_FIRST_PART_SIZE-1]; // get numb of data in command
// +1 because that defines is size, not ind.
*rx_data_size = hmodbus->pMessagePtr->ByteCnt + 2;
}
if(hmodbus->pMessagePtr->Func_Code == MB_R_DEVICE_INFO)
{
*rx_data_size = 0;
}
hmodbus->RS_Message_Size = RX_FIRST_PART_SIZE + *rx_data_size; // size of whole message
return RS_OK;
}
//-----------------------------FOR USER------------------------------
//-------------------------------------------------------------------
void MB_DevoceInentificationInit(void)
{
MB_INFO.VendorName.name = MODBUS_VENDOR_NAME;
MB_INFO.ProductCode.name = MODBUS_PRODUCT_CODE;
MB_INFO.Revision.name = MODBUS_REVISION;
MB_INFO.VendorUrl.name = MODBUS_VENDOR_URL;
MB_INFO.ProductName.name = MODBUS_PRODUCT_NAME;
MB_INFO.ModelName.name = MODBUS_MODEL_NAME;
MB_INFO.UserApplicationName.name = MODBUS_USER_APPLICATION_NAME;
MB_INFO.VendorName.length = sizeof(MODBUS_VENDOR_NAME);
MB_INFO.ProductCode.length = sizeof(MODBUS_PRODUCT_CODE);
MB_INFO.Revision.length = sizeof(MODBUS_REVISION);
MB_INFO.VendorUrl.length = sizeof(MODBUS_VENDOR_URL);
MB_INFO.ProductName.length = sizeof(MODBUS_PRODUCT_NAME);
MB_INFO.ModelName.length = sizeof(MODBUS_MODEL_NAME);
MB_INFO.UserApplicationName.length = sizeof(MODBUS_USER_APPLICATION_NAME);
}

View File

@@ -0,0 +1,57 @@
#include "../include/modbus_data.h"
uint8_t mb_data_validate(uint8_t function, uint16_t quantity, uint16_t bytes, size_t capacity)
{
size_t needed;
switch (function) {
case 1: case 15:
if (!quantity || quantity > (function == 1 ? 2000U : 1968U)) return 3;
needed = ((size_t)quantity+15U)/16U;
if (function == 15 && bytes != (quantity+7U)/8U) return 3;
break;
case 3: case 4: case 16:
if (!quantity || quantity > (function == 16 ? 123U : 125U)) return 3;
needed = quantity;
if (function == 16 && bytes != quantity*2U) return 3;
break;
case 5: return (quantity == 0 || quantity == 0xff00U) ? 0 : 3;
case 6: return 0;
default: return 1;
}
return needed <= capacity ? 0 : 3;
}
uint8_t mb_data_transfer(uint8_t function, uint16_t quantity, uint16_t bytes,
uint16_t *bank, uint16_t offset, uint16_t *data, size_t capacity, uint16_t *response_bytes)
{
uint16_t i;
uint8_t status = mb_data_validate(function, quantity, bytes, capacity);
if (status) return status;
if (bank == NULL || data == NULL || response_bytes == NULL || offset > 15U) return 3;
if (function == 1) {
for (i = 0; i < (quantity+15U)/16U; ++i) data[i] = 0;
}
if (function == 1 || function == 15) {
for (i = 0; i < quantity; ++i) {
uint16_t position = (uint16_t)(i+offset);
uint16_t mask = (uint16_t)(1U << (position%16U));
uint16_t wire_mask = (uint16_t)(1U << ((i%16U)^8U));
if (function == 1) {
if (bank[position/16U] & mask) data[i/16U] |= wire_mask;
} else if (data[i/16U] & wire_mask) bank[position/16U] |= mask;
else bank[position/16U] &= (uint16_t)~mask;
}
if (function == 1) *response_bytes = (uint16_t)((quantity+7U)/8U);
} else if (function == 5) {
if (quantity) *bank |= (uint16_t)(1U << offset);
else *bank &= (uint16_t)~(1U << offset);
} else if (function == 6) *bank = quantity;
else {
for (i = 0; i < quantity; ++i) {
if (function == 16) bank[i] = data[i];
else data[i] = bank[i];
}
if (function != 16) *response_bytes = (uint16_t)(quantity*2U);
}
return 0;
}

View File

@@ -0,0 +1,29 @@
#include "modbus_data.h"
#include <assert.h>
#include <string.h>
int main(void)
{
uint16_t bank[4]={0xa5a5,0x5a5a,0x1234,0xbeef}, original[4], data[130], out;
unsigned offset, quantity, bit;
assert(mb_data_transfer(3,4,0,bank,0,data,130,&out)==0 && out==8 && data[3]==0xbeef);
assert(mb_data_transfer(16,4,7,bank,0,data,130,&out)==3);
assert(mb_data_validate(1,0,0,130)==3);
assert(mb_data_validate(3,126,0,130)==3);
assert(mb_data_validate(16,124,248,130)==3);
assert(mb_data_validate(15,17,3,1)==3);
assert(mb_data_validate(5,1,0,0)==3);
assert(mb_data_validate(2,1,0,130)==1);
for(offset=0;offset<16;++offset) for(quantity=1;quantity<=32;++quantity) {
bank[0]=0xa5a5; bank[1]=0x5a5a; bank[2]=0x1234; bank[3]=0xbeef;
memcpy(original,bank,sizeof(bank));
memset(data,0xcc,sizeof(data));
assert(mb_data_transfer(1,(uint16_t)quantity,0,bank,(uint16_t)offset,data,130,&out)==0);
for(bit=0;bit<quantity;++bit)
assert(((data[bit/16]>>((bit%16)^8))&1)==((bank[(bit+offset)/16]>>((bit+offset)%16))&1));
for(bit=0;bit<quantity;++bit) bank[(bit+offset)/16]^=(uint16_t)(1U<<((bit+offset)%16));
assert(mb_data_transfer(15,(uint16_t)quantity,out,bank,(uint16_t)offset,data,130,&out)==0);
assert(memcmp(bank,original,sizeof(bank))==0);
assert(data[(quantity+15)/16]==0xcccc); /* no write past response */
}
return 0;
}

View File

@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.13)
project(sd_file_browser C)
add_library(sd_file_browser STATIC Src/sd_file_browser.c)
target_include_directories(sd_file_browser PUBLIC Inc)
set_target_properties(sd_file_browser PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
add_executable(test_sd_file_browser Tests/test_sd_file_browser.c)
target_link_libraries(test_sd_file_browser PRIVATE sd_file_browser)
add_test(NAME test_sd_file_browser COMMAND test_sd_file_browser)

View File

@@ -0,0 +1,82 @@
#ifndef SD_FILE_BROWSER_H
#define SD_FILE_BROWSER_H
#include <stdint.h>
/* Ограничения одинаковы для portable core, Modbus mailbox и GUI. */
#define SD_FILE_BROWSER_MAX_PATH 96U
#define SD_FILE_BROWSER_MAX_NAME 48U
#define SD_FILE_BROWSER_MAX_PAGE_ITEMS 3U
#define SD_FILE_BROWSER_MAX_CHUNK_BYTES 64U
typedef enum {
/* Успех означает полностью сформированную страницу, а не только open. */
SD_FILE_BROWSER_OK = 0,
/* BUSY оставляет retry политике приложения и ничего не пишет на носитель. */
SD_FILE_BROWSER_BUSY,
SD_FILE_BROWSER_NOT_READY,
SD_FILE_BROWSER_IO_ERROR,
SD_FILE_BROWSER_INVALID_PATH,
SD_FILE_BROWSER_INVALID_CURSOR,
SD_FILE_BROWSER_NAME_TOO_LONG,
SD_FILE_BROWSER_FILE_CHANGED,
SD_FILE_BROWSER_NOT_LOG_FILE
} SdFileBrowserStatus;
typedef struct {
/* Имя всегда завершается нулём и не содержит родительского пути. */
char name[SD_FILE_BROWSER_MAX_NAME + 1U];
uint32_t size;
uint16_t modified_date;
uint16_t modified_time;
uint8_t is_directory;
} SdFileBrowserEntry;
typedef struct {
/* Ответ имеет фиксированную RAM-ёмкость и никогда не выделяет heap. */
SdFileBrowserEntry items[SD_FILE_BROWSER_MAX_PAGE_ITEMS];
uint16_t next_offset;
uint8_t count;
uint8_t has_more;
} SdFileBrowserPage;
typedef struct {
/* Metadata повторяется в каждом chunk и защищает bridge от склейки разных версий файла. */
uint32_t total_size;
uint32_t offset;
uint16_t modified_date;
uint16_t modified_time;
uint8_t length;
uint8_t end_of_file;
uint8_t data[SD_FILE_BROWSER_MAX_CHUNK_BYTES];
} SdFileBrowserChunk;
typedef SdFileBrowserStatus (*SdFileBrowserListPageFn)(
void *context, const char *path, uint16_t offset, uint8_t limit,
SdFileBrowserPage *page);
typedef struct {
/* Context принадлежит платформе; portable core его не освобождает. */
void *context;
SdFileBrowserListPageFn list_page;
} SdFileBrowser;
/* Принимается только логический путь от корня карты: "/" или "/dir/file".
* Проверка не исправляет вход: неканонические строки отклоняются fail-closed. */
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path);
/* Разрешает только legacy TEMPLOG.MD или суточный temperature_*.md внутри
* штатного корня niceOne/mounth; служебные JSON/прошивки сюда не попадают. */
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path);
/* Разрешает только сырой образ .bin/.fw внутри каталога niceOne/firmware.
* Каталог отделён от журналов, чтобы прошивка и логи не смешивались. */
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path);
/* Вызов делегирует порту ровно одну ограниченную страницу каталога.
* Output предварительно очищается, поэтому ошибка не публикует старые имена. */
SdFileBrowserStatus SdFileBrowser_ListPage(
const SdFileBrowser *browser, const char *path, uint16_t offset,
uint8_t limit, SdFileBrowserPage *page);
#endif /* SD_FILE_BROWSER_H */

View File

@@ -0,0 +1,30 @@
# SD File Browser
Небольшое переносимое read-only ядро для постраничного просмотра каталогов.
Оно не зависит от STM32 HAL, FatFs, Modbus или GUI. Платформа передаёт callback
`list_page`; ядро проверяет путь, предел страницы и очищает ответ перед вызовом.
Логический путь всегда начинается с `/`. Запрещены `..`, `.`, пустые части,
обратная косая черта, двоеточие, управляющие символы, путь от ОС и компоненты
длиннее 48 ASCII-байт. Максимум — три элемента на страницу и 95 байт пути.
FatFs-порт сканирует позднюю страницу пошагово: один вызов service выполняет не
более шестнадцати `readdir`. Поэтому любой `uint16` offset доступен без длинного
блокирующего вызова main loop. Между страницами `DIR` закрыт. Если логгер или
backup получает работу во время сканирования, приложение отменяет viewer,
освобождает `DIR` и возвращает `logger busy`; writer всегда имеет приоритет.
Для переноса реализуйте `SdFileBrowserListPageFn`, обеспечьте read-only открытие
каталога, конечные тайм-ауты носителя и закройте cursor при извлечении карты.
Порт также предоставляет `SdFileBrowserFatFs_ReadLogChunk`: он разрешает только
legacy `/TEMPLOG.MD` и `temperature_*.md` внутри `/niceOne/mounth`, читает не
более 64 байт и закрывает `FIL` до возврата. Первый ответ публикует размер и FAT
date/time; каждый следующий запрос обязан повторить их. Замена, обрезание или
изменение файла возвращает `SD_FILE_BROWSER_FILE_CHANGED`. В API нет функций
записи, удаления, rename, выполнения Markdown или чтения служебных каталогов.
## Shared source
Canonical source: `templates/c/sd-file-browser`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.

View File

@@ -0,0 +1,177 @@
#include "sd_file_browser.h"
#include <stddef.h>
#include <string.h>
static uint8_t has_temperature_name(const char *name)
{
const size_t length = strlen(name);
const char prefix[] = "temperature_";
return (uint8_t)((length > (sizeof(prefix) - 1U + 3U)) &&
(strncmp(name, prefix, sizeof(prefix) - 1U) == 0) &&
(strcmp(name + length - 3U, ".md") == 0));
}
/* Сравнение расширения без учёта регистра для короткого суффикса ASCII. */
static uint8_t has_suffix_ci(const char *name, const char *suffix)
{
const size_t name_length = strlen(name);
const size_t suffix_length = strlen(suffix);
size_t index;
if (name_length <= suffix_length) {
return 0U;
}
for (index = 0U; index < suffix_length; ++index) {
char left = name[name_length - suffix_length + index];
char right = suffix[index];
if ((left >= 'A') && (left <= 'Z')) {
left = (char)(left - 'A' + 'a');
}
if ((right >= 'A') && (right <= 'Z')) {
right = (char)(right - 'A' + 'a');
}
if (left != right) {
return 0U;
}
}
return 1U;
}
/* Сырой образ прошивки: .bin или .fw. Intel HEX здесь не принимается — его
* адреса лежат внутри файла и требуют разбора, недоступного на этом этапе. */
static uint8_t has_firmware_name(const char *name)
{
return (uint8_t)((has_suffix_ci(name, ".bin") != 0U) ||
(has_suffix_ci(name, ".fw") != 0U));
}
/* Разрешены печатные ASCII-имена без разделителей ОС и управляющих байтов. */
static uint8_t path_character_is_safe(char character)
{
unsigned char value = (unsigned char)character;
return (uint8_t)((value >= 0x20U) && (value <= 0x7EU) &&
(character != '\\') && (character != ':'));
}
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path)
{
size_t length;
size_t component_start;
size_t index;
/* strlen вызывается только после явной проверки внешнего указателя. */
if (path == NULL) {
return SD_FILE_BROWSER_INVALID_PATH;
}
length = strlen(path);
if ((length == 0U) || (length >= SD_FILE_BROWSER_MAX_PATH) ||
(path[0] != '/')) {
/* Абсолютный путь накопителя и усечённый путь fail-closed отклоняются. */
return SD_FILE_BROWSER_INVALID_PATH;
}
if (length == 1U) {
return SD_FILE_BROWSER_OK;
}
/* Компоненты анализируются за один проход, включая завершающий ноль. */
component_start = 1U;
for (index = 1U; index <= length; ++index) {
char character = path[index];
if ((character == '/') || (character == '\0')) {
size_t component_length = index - component_start;
if ((component_length == 0U) ||
(component_length > SD_FILE_BROWSER_MAX_NAME) ||
((component_length == 1U) &&
(path[component_start] == '.')) ||
((component_length == 2U) &&
(path[component_start] == '.') &&
(path[component_start + 1U] == '.'))) {
/* Пустые, точечные и слишком длинные компоненты не нормализуются. */
return SD_FILE_BROWSER_INVALID_PATH;
}
component_start = index + 1U;
} else if (path_character_is_safe(character) == 0U) {
/* Кодировка текущего FatFs-порта ASCII; байты UTF-8 не угадываются. */
return SD_FILE_BROWSER_INVALID_PATH;
}
}
return SD_FILE_BROWSER_OK;
}
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path)
{
const char root[] = "/niceOne/mounth/";
const char *relative;
const char *separator;
const char *name;
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
if (status != SD_FILE_BROWSER_OK) {
return status;
}
/* TEMPLOG.MD сохранён только для старых карт, где журнал лежал в корне. */
if (strcmp(path, "/TEMPLOG.MD") == 0) {
return SD_FILE_BROWSER_OK;
}
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
relative = path + sizeof(root) - 1U;
separator = strchr(relative, '/');
/* Штатное дерево содержит ровно месяц и имя; обход дополнительных уровней запрещён. */
if ((separator == NULL) || (separator == relative) ||
(strchr(separator + 1U, '/') != NULL)) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
name = strrchr(path, '/');
if ((name == NULL) || (has_temperature_name(name + 1U) == 0U)) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
return SD_FILE_BROWSER_OK;
}
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path)
{
const char root[] = "/niceOne/firmware/";
const char *name;
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
if (status != SD_FILE_BROWSER_OK) {
return status;
}
/* Образы лежат ровно в одном каталоге; вложенные уровни не обходятся. */
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
name = path + sizeof(root) - 1U;
if ((name[0] == '\0') || (strchr(name, '/') != NULL)) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
if (has_firmware_name(name) == 0U) {
return SD_FILE_BROWSER_NOT_LOG_FILE;
}
return SD_FILE_BROWSER_OK;
}
SdFileBrowserStatus SdFileBrowser_ListPage(
const SdFileBrowser *browser, const char *path, uint16_t offset,
uint8_t limit, SdFileBrowserPage *page)
{
SdFileBrowserStatus status;
/* limit проверяется до очистки, чтобы invalid call не трогал чужую память. */
if ((browser == NULL) || (browser->list_page == NULL) || (page == NULL) ||
(limit == 0U) || (limit > SD_FILE_BROWSER_MAX_PAGE_ITEMS)) {
return SD_FILE_BROWSER_INVALID_CURSOR;
}
status = SdFileBrowser_ValidatePath(path);
if (status != SD_FILE_BROWSER_OK) {
return status;
}
/* Нулевой tail исключает утечку содержимого предыдущего Modbus-ответа. */
memset(page, 0, sizeof(*page));
/* Portable слой не знает FatFs/HAL и передаёт только проверенный запрос. */
return browser->list_page(browser->context, path, offset, limit, page);
}

View File

@@ -0,0 +1,67 @@
#include "sd_file_browser.h"
#include <stdio.h>
#include <string.h>
static unsigned checks;
static unsigned failures;
static unsigned calls;
#define CHECK(condition) do { ++checks; if (!(condition)) ++failures; } while (0)
/* Mock подтверждает, что core передаёт только проверенный bounded запрос. */
static SdFileBrowserStatus mock_list(
void *context, const char *path, uint16_t offset, uint8_t limit,
SdFileBrowserPage *page)
{
(void)context;
++calls;
CHECK(strcmp(path, "/niceOne") == 0);
CHECK(offset == 3U);
CHECK(limit == 2U);
memcpy(page->items[0].name, "July", sizeof("July"));
page->items[0].is_directory = 1U;
page->count = 1U;
page->next_offset = 4U;
return SD_FILE_BROWSER_OK;
}
int main(void)
{
static const char *const invalid[] = {
"", "niceOne", "/../secret", "/./logs", "/a//b", "/a\\b",
"/a:b", "/trailing/"
};
SdFileBrowser browser = { NULL, mock_list };
SdFileBrowserPage page;
unsigned index;
CHECK(SdFileBrowser_ValidatePath("/") == SD_FILE_BROWSER_OK);
CHECK(SdFileBrowser_ValidateLogFilePath("/TEMPLOG.MD") == SD_FILE_BROWSER_OK);
CHECK(SdFileBrowser_ValidateLogFilePath(
"/niceOne/mounth/2026-07_July/temperature_2026-07-18_10-00-00.md") ==
SD_FILE_BROWSER_OK);
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/settings/private.json") ==
SD_FILE_BROWSER_NOT_LOG_FILE);
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/fw.bin") ==
SD_FILE_BROWSER_NOT_LOG_FILE);
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/temperature_bad.md") ==
SD_FILE_BROWSER_NOT_LOG_FILE);
CHECK(SdFileBrowser_ValidatePath("/niceOne/2026-07_July") ==
SD_FILE_BROWSER_OK);
for (index = 0U; index < sizeof(invalid) / sizeof(invalid[0]); ++index) {
CHECK(SdFileBrowser_ValidatePath(invalid[index]) ==
SD_FILE_BROWSER_INVALID_PATH);
}
memset(&page, 0xA5, sizeof(page));
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 3U, 2U, &page) ==
SD_FILE_BROWSER_OK);
CHECK(calls == 1U);
CHECK(page.count == 1U);
CHECK(strcmp(page.items[0].name, "July") == 0);
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 0U, 4U, &page) ==
SD_FILE_BROWSER_INVALID_CURSOR);
CHECK(calls == 1U);
(void)printf("SD browser core: %u checks, %u failures\n", checks, failures);
return failures == 0U ? 0 : 1;
}

View File

@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.13)
project(settings_backup C)
add_library(settings_backup STATIC Src/settings_backup.c)
target_include_directories(settings_backup PUBLIC Inc)
set_target_properties(settings_backup PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
add_executable(test_settings_backup Tests/test_settings_backup.c)
target_link_libraries(test_settings_backup PRIVATE settings_backup)
add_test(NAME test_settings_backup COMMAND test_settings_backup)

View File

@@ -0,0 +1,125 @@
#ifndef SETTINGS_BACKUP_H
#define SETTINGS_BACKUP_H
/* Переносимое ядро резервирования не включает HAL, FatFs, AppStorage и Modbus. */
#include <stddef.h>
#include <stdint.h>
#define SETTINGS_BACKUP_SCHEMA_VERSION 1U
#define SETTINGS_BACKUP_MAX_ROOMS 32U
#define SETTINGS_BACKUP_MAX_SENSORS 32U
#define SETTINGS_BACKUP_MAX_PATH 160U
/* Календарь передаётся приложением вместе с признаком доверия к RTC. */
typedef struct {
uint16_t year;
uint8_t month;
uint8_t date;
uint8_t hours;
uint8_t minutes;
uint8_t seconds;
uint8_t valid;
} SettingsBackupDateTime;
/* Одна комната содержит только документированные инженерные параметры. */
typedef struct {
uint16_t setpoint_x10;
uint8_t hysteresis_x10;
uint8_t calibration_start_pct;
uint16_t full_open_time_100ms;
uint8_t target_position_pct;
uint8_t confirmed_position_pct;
} SettingsBackupRoom;
/* В JSON всегда попадает полный ROM, включая family и Dallas CRC. */
typedef struct {
uint8_t rom[8];
uint8_t room;
} SettingsBackupSensor;
/* Снимок создаётся адаптером только из committed-записи AppStorage. */
typedef struct {
uint32_t revision;
uint32_t content_crc32;
uint8_t requested_backend;
uint8_t active_backend;
uint8_t room_count;
uint8_t sensor_count;
SettingsBackupDateTime rtc;
SettingsBackupRoom rooms[SETTINGS_BACKUP_MAX_ROOMS];
SettingsBackupSensor sensors[SETTINGS_BACKUP_MAX_SENSORS];
} SettingsBackupSnapshot;
/* Короткий набор результатов позволяет порту отобразить SD absent/full/I/O. */
typedef enum {
SETTINGS_BACKUP_PORT_OK = 0,
SETTINGS_BACKUP_PORT_NOT_READY,
SETTINGS_BACKUP_PORT_NO_SPACE,
SETTINGS_BACKUP_PORT_IO_ERROR
} SettingsBackupPortResult;
/* Наблюдаемое состояние не скрывает retry и отказ носителя от Modbus/GUI. */
typedef enum {
SETTINGS_BACKUP_IDLE = 0,
SETTINGS_BACKUP_PENDING,
SETTINGS_BACKUP_LAST_OK,
SETTINGS_BACKUP_LAST_NOT_READY,
SETTINGS_BACKUP_LAST_NO_SPACE,
SETTINGS_BACKUP_LAST_IO_ERROR,
SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT
} SettingsBackupStatus;
/* Все операции платформы ограничены одной файловой транзакцией за вызов. */
typedef struct {
uint32_t (*tick_ms)(void *context);
SettingsBackupPortResult (*mount)(void *context);
void (*unmount)(void *context);
SettingsBackupPortResult (*find_identical)(void *context,
uint32_t revision, uint32_t crc32, uint8_t *found);
SettingsBackupPortResult (*resolve_paths)(void *context,
const SettingsBackupSnapshot *snapshot, char *final_path,
uint32_t final_capacity, char *temporary_path,
uint32_t temporary_capacity);
SettingsBackupPortResult (*atomic_write)(void *context,
const char *temporary_path, const char *final_path,
const char *utf8_json, uint32_t length);
SettingsBackupPortResult (*apply_retention)(void *context,
uint16_t maximum_copies);
} SettingsBackupPort;
/* Контекст хранит pending-снимок: вызывающий может освобождать свой буфер. */
typedef struct {
SettingsBackupPort port;
void *port_context;
SettingsBackupSnapshot pending_snapshot;
char *json_buffer;
uint32_t json_capacity;
char final_path[SETTINGS_BACKUP_MAX_PATH];
char temporary_path[SETTINGS_BACKUP_MAX_PATH];
uint32_t next_retry_ms;
uint16_t maximum_copies;
uint8_t phase;
uint8_t retry_count;
uint8_t pending;
SettingsBackupStatus status;
} SettingsBackup;
/* Инициализация не обращается к SD и потому безопасна во время старта МК. */
uint8_t SettingsBackup_Init(SettingsBackup *instance,
const SettingsBackupPort *port, void *port_context,
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies);
/* Новая committed-ревизия заменяет только ещё не записанный pending-снимок. */
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
const SettingsBackupSnapshot *snapshot);
/* Service делает не более одного mount/scan/write/retention шага за проход. */
void SettingsBackup_Service(SettingsBackup *instance);
/* Форматирование и CRC открыты для host-тестов без файловой системы. */
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot);
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
char *output, uint32_t capacity);
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance);
#endif /* SETTINGS_BACKUP_H */

View File

@@ -0,0 +1,11 @@
#ifndef SETTINGS_BACKUP_CONFIG_H
#define SETTINGS_BACKUP_CONFIG_H
/* Политика продукта отделена от переносимого автомата и FatFs-порта. */
#define SETTINGS_BACKUP_JSON_BUFFER_SIZE 12288U
#define SETTINGS_BACKUP_MAXIMUM_COPIES 24U
#define SETTINGS_BACKUP_RETRY_MS 30000UL
#define SETTINGS_BACKUP_RETRY_LIMIT 3U
#define SETTINGS_BACKUP_COOLDOWN_MS 300000UL
#endif /* SETTINGS_BACKUP_CONFIG_H */

View File

@@ -0,0 +1,89 @@
# SettingsBackup
`SettingsBackup` сохраняет только подтверждённые конфигурации в JSON на
SD-карту. Ядро `Src/settings_backup.c` не включает STM32 HAL, FatFs, Modbus,
RTC или `AppStorage`: календарь и нормализованный снимок передаёт приложение,
а файловые операции задаются callbacks структуры `SettingsBackupPort`.
## Размещение
При достоверном RTC используется путь:
```text
niceOne/settings/YYYY-MM_EnglishMonth/settings_YYYY-MM-DD_HH-MM-SS.json
```
Например:
```text
niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json
```
Названия месяцев фиксированы на английском и состоят только из FAT-безопасных
ASCII-символов. При недостоверном RTC файл помещается в `invalid_rtc` и получает
имя `settings_invalid_rtc_rev_N.json`; это имя не выдаётся за календарную дату.
Коллизии разрешаются суффиксами `_1`, `_2` и далее. Проверяются одновременно
итоговое имя и `.tmp`, поэтому незавершённая транзакция после перезапуска не
перезаписывается.
## Schema v1
Корневые поля: `schema`, `schema_version`, `snapshot_revision`,
`snapshot_crc32`, `rtc`, `storage`, `units`, `rooms`, `sensors` и
`restore_policy`. Все 32 комнаты содержат уставку и гистерезис в десятых долях
градуса, старт калибровки и время полного открытия в 100 мс, целевое и
подтверждённое положение в процентах. Датчик содержит полный 16-символьный HEX
ROM: family, шесть serial-байтов и Dallas CRC, а также номер комнаты.
В `RoomSettings` v1 во Flash фиксируется достигнутый безопасный checkpoint.
Поэтому `target_position_pct` резервной копии равен
`confirmed_position_pct`; ещё не подтверждённая цель Modbus/GUI принципиально
не попадает в файл.
## Когда создаётся копия
`settings_backup_app.c` сравнивает номер committed-записи. Только при его
изменении он физически перечитывает 512 байт через
`AppStorage_GetConfirmedSnapshot`, где кольцевой журнал повторно проверяет
version, длину, commit marker и CRC. На старте та же ревизия ищется во всех
месячных каталогах по content CRC; revision остаётся в файле для аудита, но
повторный commit тех же настроек не создаёт дубль. Десятиминутный температурный
журнал не является trigger backup.
Запись атомарна: уникальный `.tmp` создаётся с `FA_CREATE_NEW`, полностью
записывается, синхронизируется, закрывается и только затем переименовывается в
`.json`. Старые JSON не удаляются при ошибке open/write/sync/close/rename.
Ошибки absent/full/removal переводят автомат в диагностическое состояние и
назначают ограниченный retry без задержек и busy wait в main loop.
Retention задаётся `SETTINGS_BACKUP_MAXIMUM_COPIES` (по умолчанию 24). При
превышении лимита сначала удаляется самый старый подтверждённый duplicate hash,
затем самый старый уникальный файл; последняя корректная копия не удаляется.
## Восстановление
Автоматического восстановления нет. Наличие JSON никогда не вызывает запись в
`AppStorage`. Будущая отдельная команда должна прочитать выбранный файл только
для проверки, подтвердить schema/version, UTF-8/JSON, диапазоны 32 комнат,
полные ROM и Dallas CRC, content CRC, совместимость backend и получить явное
подтверждение оператора. Лишь после этого отдельная реализация может собрать
новый AppStorage image и выполнить обычный committed write с обратным чтением.
## Перенос на другую платформу
Скопируйте `Inc`, `Src` и конфигурацию. Реализуйте callbacks mount, global
dedupe scan, collision-safe path resolve, atomic write и retention. Каждая
callback-функция должна иметь ограниченный timeout и не выполняться из ISR.
FatFs-порт данного проекта находится в `Port/FatFs`; это единственный модуль
библиотеки, который включает `ff.h` и использует общий том SD logger.
Модельные тесты запускаются так:
```text
python -m unittest discover -s Libraries/SettingsBackup/Tests -p "test_*.py"
```
## Shared source
Canonical source: `templates/c/settings-backup`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.

View File

@@ -0,0 +1,362 @@
#include "../Inc/settings_backup.h"
#include "../Inc/settings_backup_config.h"
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
/*
* Архитектура переносимого автомата
* ---------------------------------
* 1. Приложение передаёт только подтверждённый нормализованный snapshot.
* 2. Notify копирует его, поэтому lifetime исходного указателя не важен.
* 3. Canonical CRC охватывает только пользовательскую конфигурацию.
* 4. RTC и backend остаются metadata и не создают ложный content duplicate.
* 5. Mount, поиск, resolve, write и retention разнесены по main-loop проходам.
* 6. Ни один переход не содержит delay, busy wait или прямого доступа к HAL.
* 7. Ошибка файловой callback закрывает логическую попытку и ставит retry.
* 8. Первые три retry короткие; дальнейшие выполняются после cooldown.
* 9. Новая committed revision немедленно заменяет устаревший pending snapshot.
* 10. JSON строится перед write целиком и не передаётся порту усечённым.
* 11. Все числовые единицы зафиксированы отдельным объектом schema.
* 12. ROM выводится как полный uppercase HEX без locale и разделителей.
* 13. Никакие строки проекта не попадают в JSON, поэтому секреты исключены.
* 14. Автомат никогда не читает JSON и принципиально не восстанавливает Flash.
* 15. Диагностический getter не меняет phase и пригоден для Modbus polling.
*/
/* Фазы разделяют потенциально медленные FAT-операции между main-loop проходами. */
enum {
BACKUP_PHASE_MOUNT = 0,
BACKUP_PHASE_DEDUPE,
BACKUP_PHASE_RESOLVE,
BACKUP_PHASE_WRITE,
BACKUP_PHASE_RETENTION
};
/* Добавляет форматированный фрагмент и никогда не оставляет усечённый JSON. */
static uint8_t append_text(char *output, uint32_t capacity, uint32_t *used,
const char *format, ...)
{
va_list arguments;
int length;
if ((*used >= capacity) || (format == NULL)) {
return 0U;
}
va_start(arguments, format);
length = vsnprintf(output + *used, capacity - *used, format, arguments);
va_end(arguments);
if ((length < 0) || ((uint32_t)length >= (capacity - *used))) {
return 0U;
}
*used += (uint32_t)length;
return 1U;
}
/* CRC32 IEEE считается по стабильному бинарному представлению настроек. */
static uint32_t crc32_add(uint32_t crc, uint8_t value)
{
uint8_t bit;
crc ^= value;
for (bit = 0U; bit < 8U; ++bit) {
crc = ((crc & 1U) != 0U) ? (crc >> 1U) ^ 0xEDB88320UL : crc >> 1U;
}
return crc;
}
/* Время и backend исключены: hash обозначает именно пользовательские настройки. */
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot)
{
uint32_t crc = 0xFFFFFFFFUL;
uint8_t index;
uint8_t byte;
if ((snapshot == NULL) || (snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
return 0U;
}
crc = crc32_add(crc, snapshot->room_count);
crc = crc32_add(crc, snapshot->sensor_count);
for (index = 0U; index < snapshot->room_count; ++index) {
const SettingsBackupRoom *room = &snapshot->rooms[index];
crc = crc32_add(crc, (uint8_t)room->setpoint_x10);
crc = crc32_add(crc, (uint8_t)(room->setpoint_x10 >> 8U));
crc = crc32_add(crc, room->hysteresis_x10);
crc = crc32_add(crc, room->calibration_start_pct);
crc = crc32_add(crc, (uint8_t)room->full_open_time_100ms);
crc = crc32_add(crc, (uint8_t)(room->full_open_time_100ms >> 8U));
crc = crc32_add(crc, room->target_position_pct);
crc = crc32_add(crc, room->confirmed_position_pct);
}
for (index = 0U; index < snapshot->sensor_count; ++index) {
for (byte = 0U; byte < 8U; ++byte) {
crc = crc32_add(crc, snapshot->sensors[index].rom[byte]);
}
crc = crc32_add(crc, snapshot->sensors[index].room);
}
return ~crc;
}
/* Формирует schema v1: все строки ASCII/UTF-8 и не требуют locale/float. */
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
char *output, uint32_t capacity)
{
uint32_t used = 0U;
uint8_t index;
uint8_t byte;
if ((snapshot == NULL) || (output == NULL) || (capacity == 0U) ||
(snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
return -1;
}
output[0] = '\0';
if (!append_text(output, capacity, &used,
"{\n \"schema\":\"niceOne.settings\",\n \"schema_version\":1,\n"
" \"snapshot_revision\":%lu,\n \"snapshot_crc32\":\"%08lX\",\n",
(unsigned long)snapshot->revision,
(unsigned long)snapshot->content_crc32)) {
return -1;
}
if (snapshot->rtc.valid != 0U) {
if (!append_text(output, capacity, &used,
" \"rtc\":{\"valid\":true,\"datetime\":\"%04u-%02u-%02uT%02u:%02u:%02u\"},\n",
snapshot->rtc.year, snapshot->rtc.month, snapshot->rtc.date,
snapshot->rtc.hours, snapshot->rtc.minutes, snapshot->rtc.seconds)) {
return -1;
}
} else if (!append_text(output, capacity, &used,
" \"rtc\":{\"valid\":false,\"datetime\":null},\n")) {
return -1;
}
if (!append_text(output, capacity, &used,
" \"storage\":{\"requested_backend\":\"%s\",\"active_backend\":\"%s\"},\n"
" \"units\":{\"temperature\":\"0.1_degC\",\"hysteresis\":\"0.1_degC\","
"\"position\":\"percent\",\"full_open_time\":\"100_ms\"},\n \"rooms\":[\n",
snapshot->requested_backend ? "external_spi_nor" : "internal_flash",
snapshot->active_backend ? "external_spi_nor" : "internal_flash")) {
return -1;
}
for (index = 0U; index < snapshot->room_count; ++index) {
const SettingsBackupRoom *room = &snapshot->rooms[index];
if (!append_text(output, capacity, &used,
" {\"room\":%u,\"setpoint_x10\":%u,\"hysteresis_x10\":%u,"
"\"calibration\":{\"start_position_pct\":%u,\"full_open_time_100ms\":%u},"
"\"valve\":{\"target_position_pct\":%u,\"confirmed_position_pct\":%u}}%s\n",
(unsigned)(index + 1U), room->setpoint_x10, room->hysteresis_x10,
room->calibration_start_pct, room->full_open_time_100ms,
room->target_position_pct, room->confirmed_position_pct,
(index + 1U < snapshot->room_count) ? "," : "")) {
return -1;
}
}
if (!append_text(output, capacity, &used, " ],\n \"sensors\":[\n")) {
return -1;
}
for (index = 0U; index < snapshot->sensor_count; ++index) {
if (!append_text(output, capacity, &used, " {\"rom\":\"")) {
return -1;
}
for (byte = 0U; byte < 8U; ++byte) {
if (!append_text(output, capacity, &used, "%02X",
snapshot->sensors[index].rom[byte])) {
return -1;
}
}
if (!append_text(output, capacity, &used, "\",\"room\":%u}%s\n",
snapshot->sensors[index].room,
(index + 1U < snapshot->sensor_count) ? "," : "")) {
return -1;
}
}
if (!append_text(output, capacity, &used,
" ],\n \"restore_policy\":\"manual_validation_only\"\n}\n")) {
return -1;
}
return (int)used;
}
/* Проверяет инженерные диапазоны до помещения снимка в очередь. */
static uint8_t snapshot_is_valid(const SettingsBackupSnapshot *snapshot)
{
uint8_t index;
if ((snapshot == NULL) || (snapshot->revision == 0U) ||
(snapshot->room_count != SETTINGS_BACKUP_MAX_ROOMS) ||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
return 0U;
}
for (index = 0U; index < snapshot->room_count; ++index) {
const SettingsBackupRoom *room = &snapshot->rooms[index];
if ((room->setpoint_x10 < 50U) || (room->setpoint_x10 > 400U) ||
(room->hysteresis_x10 < 1U) || (room->hysteresis_x10 > 100U) ||
(room->calibration_start_pct > 100U) ||
(room->target_position_pct > 100U) ||
(room->confirmed_position_pct > 100U) ||
(room->full_open_time_100ms < 10U) ||
(room->full_open_time_100ms > 36000U)) {
return 0U;
}
}
return 1U;
}
/* Инициализация валидирует полный набор callback, нужный атомарному протоколу. */
uint8_t SettingsBackup_Init(SettingsBackup *instance,
const SettingsBackupPort *port, void *port_context,
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies)
{
if ((instance == NULL) || (port == NULL) || (json_buffer == NULL) ||
(json_capacity < 1024U) || (port->tick_ms == NULL) ||
(port->mount == NULL) || (port->unmount == NULL) ||
(port->find_identical == NULL) || (port->resolve_paths == NULL) ||
(port->atomic_write == NULL) || (port->apply_retention == NULL)) {
return 0U;
}
memset(instance, 0, sizeof(*instance));
instance->port = *port;
instance->port_context = port_context;
instance->json_buffer = json_buffer;
instance->json_capacity = json_capacity;
instance->maximum_copies = (maximum_copies == 0U) ? 1U : maximum_copies;
instance->status = SETTINGS_BACKUP_IDLE;
return 1U;
}
/* Pending копируется целиком и получает вычисленный canonical CRC. */
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
const SettingsBackupSnapshot *snapshot)
{
if ((instance == NULL) || (snapshot_is_valid(snapshot) == 0U)) {
if (instance != NULL) {
instance->status = SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT;
}
return 0U;
}
instance->pending_snapshot = *snapshot;
instance->pending_snapshot.content_crc32 =
SettingsBackup_CalculateContentCrc(&instance->pending_snapshot);
instance->phase = BACKUP_PHASE_MOUNT;
instance->retry_count = 0U;
instance->pending = 1U;
instance->status = SETTINGS_BACKUP_PENDING;
return 1U;
}
/* Ошибка закрывает том и назначает ограниченный retry/cooldown без busy wait. */
static void schedule_retry(SettingsBackup *instance,
SettingsBackupPortResult result)
{
uint32_t delay_ms;
/* Порт завершает владение попыткой; общий физический том может оставить
* смонтированным, если его разделяет с независимым temperature logger. */
instance->port.unmount(instance->port_context);
instance->status = (result == SETTINGS_BACKUP_PORT_NOT_READY) ?
SETTINGS_BACKUP_LAST_NOT_READY :
(result == SETTINGS_BACKUP_PORT_NO_SPACE) ?
SETTINGS_BACKUP_LAST_NO_SPACE : SETTINGS_BACKUP_LAST_IO_ERROR;
/* Счётчик насыщать не требуется: после 255 ошибок unsigned wrap только
* вернёт одну короткую попытку и не нарушит сохранность данных. */
++instance->retry_count;
delay_ms = (instance->retry_count <= SETTINGS_BACKUP_RETRY_LIMIT) ?
SETTINGS_BACKUP_RETRY_MS : SETTINGS_BACKUP_COOLDOWN_MS;
instance->next_retry_ms = instance->port.tick_ms(instance->port_context) + delay_ms;
instance->phase = BACKUP_PHASE_MOUNT;
}
/* Автомат намеренно выполняет только одну callback-операцию на каждом проходе. */
void SettingsBackup_Service(SettingsBackup *instance)
{
SettingsBackupPortResult result;
uint8_t found = 0U;
int json_length;
uint32_t now_ms;
if ((instance == NULL) || (instance->pending == 0U)) {
return;
}
now_ms = instance->port.tick_ms(instance->port_context);
if ((instance->next_retry_ms != 0U) &&
((int32_t)(now_ms - instance->next_retry_ms) < 0)) {
return;
}
instance->next_retry_ms = 0U;
if (instance->phase == BACKUP_PHASE_MOUNT) {
/* Mount — единственная операция этой итерации; scan начнётся позже. */
result = instance->port.mount(instance->port_context);
if (result != SETTINGS_BACKUP_PORT_OK) {
schedule_retry(instance, result);
return;
}
instance->phase = BACKUP_PHASE_DEDUPE;
return;
}
if (instance->phase == BACKUP_PHASE_DEDUPE) {
/* Restart dedupe обязан выполняться до выбора нового имени файла. */
result = instance->port.find_identical(instance->port_context,
instance->pending_snapshot.revision,
instance->pending_snapshot.content_crc32, &found);
if (result != SETTINGS_BACKUP_PORT_OK) {
schedule_retry(instance, result);
return;
}
if (found != 0U) {
instance->pending = 0U;
instance->status = SETTINGS_BACKUP_LAST_OK;
instance->port.unmount(instance->port_context);
return;
}
instance->phase = BACKUP_PHASE_RESOLVE;
return;
}
if (instance->phase == BACKUP_PHASE_RESOLVE) {
/* Resolve резервирует концептуальную пару final/temp без их создания. */
result = instance->port.resolve_paths(instance->port_context,
&instance->pending_snapshot, instance->final_path,
sizeof(instance->final_path), instance->temporary_path,
sizeof(instance->temporary_path));
if (result != SETTINGS_BACKUP_PORT_OK) {
schedule_retry(instance, result);
return;
}
instance->phase = BACKUP_PHASE_WRITE;
return;
}
if (instance->phase == BACKUP_PHASE_WRITE) {
/* Форматирование RAM не касается FAT; atomicity начинается в callback. */
json_length = SettingsBackup_FormatJson(&instance->pending_snapshot,
instance->json_buffer, instance->json_capacity);
if (json_length < 0) {
schedule_retry(instance, SETTINGS_BACKUP_PORT_IO_ERROR);
return;
}
result = instance->port.atomic_write(instance->port_context,
instance->temporary_path, instance->final_path,
instance->json_buffer, (uint32_t)json_length);
if (result != SETTINGS_BACKUP_PORT_OK) {
schedule_retry(instance, result);
return;
}
instance->phase = BACKUP_PHASE_RETENTION;
return;
}
/* Retention выполняется только после успешного rename новой good copy. */
result = instance->port.apply_retention(instance->port_context,
instance->maximum_copies);
if (result != SETTINGS_BACKUP_PORT_OK) {
schedule_retry(instance, result);
return;
}
instance->pending = 0U;
instance->status = SETTINGS_BACKUP_LAST_OK;
instance->port.unmount(instance->port_context);
}
/* Getter не меняет автомат и безопасен для диагностического регистра. */
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance)
{
return (instance == NULL) ? SETTINGS_BACKUP_LAST_IO_ERROR : instance->status;
}

View File

@@ -0,0 +1,43 @@
#include "settings_backup.h"
#include <assert.h>
#include <string.h>
typedef struct { uint32_t now; unsigned writes, unmounts; int fail; } mock_t;
static uint32_t tick(void *p) { return ((mock_t*)p)->now; }
static SettingsBackupPortResult mount(void *p) { (void)p; return SETTINGS_BACKUP_PORT_OK; }
static void unmount(void *p) { ++((mock_t*)p)->unmounts; }
static SettingsBackupPortResult find(void *p,uint32_t r,uint32_t c,uint8_t *found)
{ (void)p; (void)r; (void)c; *found=0; return SETTINGS_BACKUP_PORT_OK; }
static SettingsBackupPortResult paths(void *p,const SettingsBackupSnapshot *s,char *f,uint32_t fc,char *t,uint32_t tc)
{ (void)p; (void)s; assert(fc>8 && tc>8); memcpy(f,"ok.json",8); memcpy(t,"ok.tmp",7); return SETTINGS_BACKUP_PORT_OK; }
static SettingsBackupPortResult write_json(void *p,const char *t,const char *f,const char *json,uint32_t n)
{
mock_t *m=p; (void)t; (void)f;
assert(n==strlen(json) && n>10 && json[0]=='{');
if (m->fail) return SETTINGS_BACKUP_PORT_IO_ERROR;
++m->writes; return SETTINGS_BACKUP_PORT_OK;
}
static SettingsBackupPortResult retain(void *p,uint16_t n)
{ (void)p; assert(n==2); return SETTINGS_BACKUP_PORT_OK; }
int main(void)
{
SettingsBackup b; SettingsBackupSnapshot s={0}; mock_t m={0}; char json[16000];
SettingsBackupPort port={tick,mount,unmount,find,paths,write_json,retain};
unsigned i; uint32_t crc;
s.revision=1; s.room_count=SETTINGS_BACKUP_MAX_ROOMS;
for(i=0;i<s.room_count;++i) { s.rooms[i].setpoint_x10=200; s.rooms[i].hysteresis_x10=10; s.rooms[i].full_open_time_100ms=100; }
crc=SettingsBackup_CalculateContentCrc(&s);
s.rtc.year=2026; s.active_backend=1;
assert(SettingsBackup_CalculateContentCrc(&s)==crc);
assert(SettingsBackup_FormatJson(&s,json,5)<0);
assert(SettingsBackup_Init(&b,&port,&m,json,sizeof(json),2));
assert(SettingsBackup_Notify(&b,&s));
s.rooms[0].setpoint_x10=300;
assert(b.pending_snapshot.rooms[0].setpoint_x10==200);
m.fail=1;
for(i=0;i<4;++i) SettingsBackup_Service(&b);
assert(m.writes==0 && b.pending && b.status==SETTINGS_BACKUP_LAST_IO_ERROR);
m.fail=0; m.now=b.next_retry_ms;
for(i=0;i<5;++i) SettingsBackup_Service(&b);
assert(m.writes==1 && !b.pending && b.status==SETTINGS_BACKUP_LAST_OK);
return 0;
}

View File

@@ -0,0 +1,196 @@
"""Модельные проверки полного контракта JSON backup без реальной SD-карты."""
import json
import unittest
import zlib
from dataclasses import dataclass
from datetime import datetime
from pathlib import PurePosixPath
MONTHS = (
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
@dataclass(frozen=True)
class Snapshot:
"""Подтверждённый снимок модели соответствует metadata C-ядра."""
revision: int
payload: bytes
rtc: datetime | None
@property
def crc(self) -> int:
"""CRC моделирует стабильный content hash без календаря."""
return zlib.crc32(self.payload) & 0xFFFFFFFF
class FakeCard:
"""In-memory FAT-модель фиксирует атомарность, коллизии и отказы."""
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.ready = True
self.fail_step: str | None = None
def resolve(self, snapshot: Snapshot) -> tuple[str, str]:
"""Выбирает календарное либо честно помеченное fallback-имя."""
if snapshot.rtc:
month = f"{snapshot.rtc:%Y-%m}_{MONTHS[snapshot.rtc.month - 1]}"
stem = f"settings_{snapshot.rtc:%Y-%m-%d_%H-%M-%S}"
else:
month = "invalid_rtc"
stem = f"settings_invalid_rtc_rev_{snapshot.revision}"
root = PurePosixPath("niceOne/settings") / month
suffix = 0
while True:
marker = "" if suffix == 0 else f"_{suffix}"
final = str(root / f"{stem}{marker}.json")
temporary = str(root / f"{stem}{marker}.tmp")
if final not in self.files and temporary not in self.files:
return final, temporary
suffix += 1
def atomic_write(self, temporary: str, final: str, content: bytes) -> bool:
"""Сбой любого этапа никогда не изменяет старый final JSON."""
if not self.ready or self.fail_step == "open":
return False
self.files[temporary] = b""
if self.fail_step == "write":
return False
self.files[temporary] = content
if self.fail_step in {"sync", "close", "rename"}:
return False
self.files[final] = self.files.pop(temporary)
return True
def document(snapshot: Snapshot) -> bytes:
"""Минимальная schema-модель проверяет UTF-8 и обязательные разделы."""
rooms = [
{
"room": room,
"setpoint_x10": 200,
"hysteresis_x10": 10,
"calibration": {"start_position_pct": 0, "full_open_time_100ms": 300},
"valve": {"target_position_pct": 50, "confirmed_position_pct": 50},
}
for room in range(1, 33)
]
sensors = [{"rom": "280102030405069E", "room": 1}]
return (json.dumps(
{
"schema": "niceOne.settings",
"schema_version": 1,
"snapshot_revision": snapshot.revision,
"snapshot_crc32": f"{snapshot.crc:08X}",
"rtc": {"valid": snapshot.rtc is not None,
"datetime": snapshot.rtc.isoformat() if snapshot.rtc else None},
"storage": {"requested_backend": "internal_flash",
"active_backend": "internal_flash"},
"units": {"temperature": "0.1_degC", "position": "percent"},
"rooms": rooms,
"sensors": sensors,
"restore_policy": "manual_validation_only",
}, ensure_ascii=False, indent=2,
) + "\n").encode("utf-8")
class SettingsBackupModelTests(unittest.TestCase):
"""Каждый тест соответствует одному или нескольким пунктам task.MD."""
def test_calendar_tree_and_fat_safe_english_month(self) -> None:
card = FakeCard()
final, temporary = card.resolve(Snapshot(7, b"a", datetime(2026, 7, 17, 14, 30, 5)))
self.assertEqual(final, "niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json")
self.assertTrue(temporary.endswith(".tmp"))
self.assertNotIn(":", final)
def test_json_schema_contains_all_32_rooms_full_rom_and_units(self) -> None:
snapshot = Snapshot(9, b"confirmed", datetime(2026, 12, 31, 23, 59, 59))
parsed = json.loads(document(snapshot).decode("utf-8"))
self.assertEqual(parsed["schema_version"], 1)
self.assertEqual(len(parsed["rooms"]), 32)
self.assertEqual(len(parsed["sensors"][0]["rom"]), 16)
self.assertIn("target_position_pct", parsed["rooms"][0]["valve"])
self.assertIn("confirmed_position_pct", parsed["rooms"][0]["valve"])
self.assertEqual(parsed["restore_policy"], "manual_validation_only")
def test_hash_excludes_rtc_and_prevents_restart_duplicate(self) -> None:
first = Snapshot(12, b"same settings", datetime(2026, 7, 1))
restart = Snapshot(13, b"same settings", datetime(2026, 8, 1))
self.assertEqual(first.crc, restart.crc)
identities = {first.crc}
self.assertIn(restart.crc, identities)
def test_confirmed_revision_change_creates_copy_not_periodic_tick(self) -> None:
revisions = [4, 4, 4, 5, 5]
writes = []
previous = None
for revision in revisions:
if revision != previous:
writes.append(revision)
previous = revision
self.assertEqual(writes, [4, 5])
def test_collision_survives_restart_and_checks_temp_too(self) -> None:
card = FakeCard()
snapshot = Snapshot(3, b"x", datetime(2026, 1, 2, 3, 4, 5))
final, temporary = card.resolve(snapshot)
card.files[final] = b"old"
card.files[temporary] = b"interrupted"
next_final, next_temporary = card.resolve(snapshot)
self.assertTrue(next_final.endswith("_1.json"))
self.assertTrue(next_temporary.endswith("_1.tmp"))
def test_atomic_failures_preserve_last_good(self) -> None:
for failure in ("open", "write", "sync", "close", "rename"):
with self.subTest(failure=failure):
card = FakeCard()
card.fail_step = failure
card.files["last.json"] = b"valid"
final, temporary = card.resolve(Snapshot(2, b"new", None))
self.assertFalse(card.atomic_write(temporary, final, b"new"))
self.assertEqual(card.files["last.json"], b"valid")
self.assertNotIn(final, card.files)
def test_absent_removed_or_full_card_is_non_destructive(self) -> None:
for failure in (False, "open", "write"):
with self.subTest(failure=failure):
card = FakeCard()
card.ready = bool(failure)
card.fail_step = failure if isinstance(failure, str) else None
card.files["last.json"] = b"valid"
final, temporary = card.resolve(Snapshot(2, b"new", None))
self.assertFalse(card.atomic_write(temporary, final, b"new"))
self.assertEqual(card.files["last.json"], b"valid")
def test_invalid_rtc_uses_revision_and_never_fake_date(self) -> None:
final, _ = FakeCard().resolve(Snapshot(0x1234, b"x", None))
self.assertIn("invalid_rtc", final)
self.assertIn("rev_4660", final)
self.assertNotRegex(final, r"20\d\d-\d\d-\d\d")
def test_retention_prefers_old_duplicate_and_keeps_one_good(self) -> None:
entries = [
("2026-01/a.json", 1), ("2026-02/b.json", 1),
("2026-03/c.json", 2),
]
duplicates = [path for path, crc in entries if sum(v == crc for _, v in entries) > 1]
victim = min(duplicates)
remaining = [entry for entry in entries if entry[0] != victim]
self.assertEqual(victim, "2026-01/a.json")
self.assertGreaterEqual(len(remaining), 1)
def test_restore_is_never_automatic_and_secrets_are_absent(self) -> None:
text = document(Snapshot(1, b"x", None)).decode("utf-8")
self.assertIn('"restore_policy": "manual_validation_only"', text)
for forbidden in ("password", "secret", "unused_flash", "pending_gui"):
self.assertNotIn(forbidden, text)
if __name__ == "__main__":
unittest.main()

10
c/spi-nor/CMakeLists.txt Normal file
View File

@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.13)
project(spi_nor C)
add_library(spi_nor STATIC Src/spi_nor.c)
target_include_directories(spi_nor PUBLIC Inc)
set_target_properties(spi_nor PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
enable_testing()
add_executable(test_spi_nor Tests/test_spi_nor.c)
target_link_libraries(test_spi_nor PRIVATE spi_nor)
add_test(NAME test_spi_nor COMMAND test_spi_nor)

View File

@@ -0,0 +1,72 @@
#ifndef SPI_NOR_COMMAND_SERVICE_H
#define SPI_NOR_COMMAND_SERVICE_H
/*
* Переносимый read-only сервис коротких сервисных команд SPI NOR.
*
* Модуль не знает о HAL, Modbus, HTTP и GUI. Он принимает уже распознанный
* spi_nor_t, применяет собственный allowlist и выполняет ровно одну законченную
* транзакцию. Изменяющие команды намеренно отсутствуют в публичном API.
*/
#include "spi_nor.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Четырёх байт достаточно для opcode и 24-битного адреса READ 0x03. */
#define SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES 4U
#define SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES 64U
typedef enum
{
SPI_NOR_COMMAND_SERVICE_IDLE = 0,
SPI_NOR_COMMAND_SERVICE_BUSY,
SPI_NOR_COMMAND_SERVICE_OK,
SPI_NOR_COMMAND_SERVICE_INVALID,
SPI_NOR_COMMAND_SERVICE_BLOCKED,
SPI_NOR_COMMAND_SERVICE_UNAVAILABLE,
SPI_NOR_COMMAND_SERVICE_IO_ERROR,
SPI_NOR_COMMAND_SERVICE_TIMEOUT
} spi_nor_command_service_status_t;
typedef struct
{
uint16_t sequence;
uint8_t tx_length;
uint8_t rx_length;
uint8_t tx[SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES];
} spi_nor_command_service_request_t;
typedef struct
{
spi_nor_command_service_status_t status;
uint16_t sequence;
uint8_t tx_length;
uint8_t rx_length;
uint8_t tx_echo[SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES];
uint8_t rx[SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES];
} spi_nor_command_service_response_t;
/*
* Allowlist общий для W25Q64/W25Q128 и SST25VF016B:
* 0x9F JEDEC ID, 0x05 status register 1, 0x03 обычное чтение данных.
*/
spi_nor_command_service_status_t spi_nor_command_service_validate(
const spi_nor_t *device,
const spi_nor_command_service_request_t *request);
/* Process очищает старый ответ и выполняет не более одной SPI-транзакции. */
spi_nor_command_service_status_t spi_nor_command_service_process(
spi_nor_t *device,
const spi_nor_command_service_request_t *request,
spi_nor_command_service_response_t *response);
#ifdef __cplusplus
}
#endif
#endif /* SPI_NOR_COMMAND_SERVICE_H */

View File

@@ -0,0 +1,73 @@
#ifndef SPI_NOR_READ_SERVICE_H
#define SPI_NOR_READ_SERVICE_H
/*
* Ограниченный read-only сервис диагностики SPI NOR.
*
* Сервис отделён от HAL, Modbus и приложения. Владелец передаёт только ёмкость
* и callback чтения, поэтому portable-ядро SpiNor не получает зависимостей GUI.
*/
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Один маленький пакет ограничивает длительность SPI и Modbus main loop. */
#define SPI_NOR_READ_SERVICE_MAX_BYTES 64U
typedef enum
{
SPI_NOR_READ_SERVICE_IDLE = 0,
SPI_NOR_READ_SERVICE_BUSY,
SPI_NOR_READ_SERVICE_OK,
SPI_NOR_READ_SERVICE_INVALID,
SPI_NOR_READ_SERVICE_UNAVAILABLE,
SPI_NOR_READ_SERVICE_IO_ERROR
} spi_nor_read_service_status_t;
/* Callback адаптера не раскрывает драйверу контекст конкретной платформы. */
typedef spi_nor_read_service_status_t (*spi_nor_read_service_read_fn)(
void *context, uint32_t address, void *data, size_t size);
typedef struct
{
void *context;
spi_nor_read_service_read_fn read;
uint32_t capacity_bytes;
} spi_nor_read_service_t;
typedef struct
{
uint32_t address;
uint16_t length;
uint16_t sequence;
} spi_nor_read_service_request_t;
typedef struct
{
spi_nor_read_service_status_t status;
uint32_t address;
uint16_t length;
uint16_t sequence;
uint8_t data[SPI_NOR_READ_SERVICE_MAX_BYTES];
} spi_nor_read_service_response_t;
/* Init проверяет callback и ёмкость, но сам не обращается к SPI. */
spi_nor_read_service_status_t spi_nor_read_service_init(
spi_nor_read_service_t *service, void *context,
spi_nor_read_service_read_fn read, uint32_t capacity_bytes);
/* Process всегда очищает старые данные до проверки нового запроса. */
spi_nor_read_service_status_t spi_nor_read_service_process(
const spi_nor_read_service_t *service,
const spi_nor_read_service_request_t *request,
spi_nor_read_service_response_t *response);
#ifdef __cplusplus
}
#endif
#endif /* SPI_NOR_READ_SERVICE_H */

View File

@@ -0,0 +1,165 @@
#include "spi_nor_command_service.h"
#include <string.h>
/* Только эти три opcode документированы одинаково для всех поддержанных NOR. */
#define SPI_NOR_DIAG_READ_DATA 0x03U
#define SPI_NOR_DIAG_READ_STATUS_1 0x05U
#define SPI_NOR_DIAG_READ_JEDEC_ID 0x9FU
/* HAL-коды переводятся в стабильные статусы диагностического протокола. */
static spi_nor_command_service_status_t map_io_status(
spi_nor_io_status_t status)
{
if (status == SPI_NOR_IO_OK) {
return SPI_NOR_COMMAND_SERVICE_OK;
}
if (status == SPI_NOR_IO_TIMEOUT) {
return SPI_NOR_COMMAND_SERVICE_TIMEOUT;
}
return SPI_NOR_COMMAND_SERVICE_IO_ERROR;
}
/* READ использует 24-битный big-endian адрес, как основное ядро SpiNor. */
static uint32_t read_address(const uint8_t tx[4])
{
return ((uint32_t)tx[1] << 16U) |
((uint32_t)tx[2] << 8U) |
(uint32_t)tx[3];
}
spi_nor_command_service_status_t spi_nor_command_service_validate(
const spi_nor_t *device,
const spi_nor_command_service_request_t *request)
{
uint8_t opcode;
if ((device == NULL) || (request == NULL) ||
(device->initialized == 0U) || (device->detected == 0U) ||
(device->io.transfer == NULL) || (device->io.chip_select == NULL)) {
return SPI_NOR_COMMAND_SERVICE_UNAVAILABLE;
}
if ((request->tx_length == 0U) ||
(request->tx_length > SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES) ||
(request->rx_length == 0U) ||
(request->rx_length > SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES)) {
return SPI_NOR_COMMAND_SERVICE_INVALID;
}
opcode = request->tx[0];
/* JEDEC возвращает ровно manufacturer/type/capacity для определения модели. */
if (opcode == SPI_NOR_DIAG_READ_JEDEC_ID) {
return ((request->tx_length == 1U) && (request->rx_length == 3U)) ?
SPI_NOR_COMMAND_SERVICE_OK : SPI_NOR_COMMAND_SERVICE_INVALID;
}
/* SR1 — единственный общий и однозначно read-only status всех трёх NOR. */
if (opcode == SPI_NOR_DIAG_READ_STATUS_1) {
return ((request->tx_length == 1U) && (request->rx_length == 1U)) ?
SPI_NOR_COMMAND_SERVICE_OK : SPI_NOR_COMMAND_SERVICE_INVALID;
}
/* Обычный READ допускает произвольный bounded RX, но требует весь адрес. */
if (opcode == SPI_NOR_DIAG_READ_DATA) {
uint32_t address;
if (request->tx_length != 4U) {
return SPI_NOR_COMMAND_SERVICE_INVALID;
}
address = read_address(request->tx);
if ((address >= device->info.capacity_bytes) ||
((uint32_t)request->rx_length >
(device->info.capacity_bytes - address))) {
return SPI_NOR_COMMAND_SERVICE_INVALID;
}
return SPI_NOR_COMMAND_SERVICE_OK;
}
/*
* Fail-closed default блокирует WREN, program/erase, status/protection
* writes, power-down/wake, reset и любой неизвестный opcode. Будущий
* изменяющий режим должен быть отдельным API, а не расширением allowlist.
*/
return SPI_NOR_COMMAND_SERVICE_BLOCKED;
}
spi_nor_command_service_status_t spi_nor_command_service_process(
spi_nor_t *device,
const spi_nor_command_service_request_t *request,
spi_nor_command_service_response_t *response)
{
spi_nor_command_service_status_t status;
spi_nor_io_status_t io_status;
uint8_t locked = 0U;
if (response == NULL) {
return SPI_NOR_COMMAND_SERVICE_INVALID;
}
/* Очистка запрещает повторное использование старого RX после любого отказа. */
memset(response, 0, sizeof(*response));
if (request != NULL) {
response->sequence = request->sequence;
response->tx_length = request->tx_length;
if (request->tx_length <= SPI_NOR_COMMAND_SERVICE_MAX_TX_BYTES) {
memcpy(response->tx_echo, request->tx, request->tx_length);
}
}
status = spi_nor_command_service_validate(device, request);
if (status != SPI_NOR_COMMAND_SERVICE_OK) {
response->status = status;
return status;
}
if (device->busy != 0U) {
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
return response->status;
}
if (device->io.lock != NULL) {
/* Mutex относится ко всей CS-транзакции, а не к отдельному transfer. */
io_status = device->io.lock(device->io.context,
device->config.lock_timeout_ms);
if (io_status != SPI_NOR_IO_OK) {
response->status = (io_status == SPI_NOR_IO_TIMEOUT) ?
SPI_NOR_COMMAND_SERVICE_TIMEOUT :
SPI_NOR_COMMAND_SERVICE_BUSY;
return response->status;
}
locked = 1U;
}
if (device->busy != 0U) {
if ((locked != 0U) && (device->io.unlock != NULL)) {
device->io.unlock(device->io.context);
}
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
return response->status;
}
/* Busy устанавливается после mutex и снимается до unlock основного устройства. */
device->busy = 1U;
response->status = SPI_NOR_COMMAND_SERVICE_BUSY;
/* CS освобождается безусловно после TX либо RX ошибки/тайм-аута. */
device->io.chip_select(device->io.context, 1U);
io_status = device->io.transfer(device->io.context, request->tx, NULL,
request->tx_length,
device->config.io_timeout_ms);
if (io_status == SPI_NOR_IO_OK) {
io_status = device->io.transfer(device->io.context, NULL, response->rx,
request->rx_length,
device->config.io_timeout_ms);
}
device->io.chip_select(device->io.context, 0U);
device->busy = 0U;
if ((locked != 0U) && (device->io.unlock != NULL)) {
device->io.unlock(device->io.context);
}
status = map_io_status(io_status);
if (status != SPI_NOR_COMMAND_SERVICE_OK) {
/* Частичный RX не должен выглядеть достоверным после физической ошибки. */
memset(response->rx, 0, sizeof(response->rx));
response->rx_length = 0U;
response->status = status;
return status;
}
/* RX length подтверждается только когда оба transfer завершились успешно. */
response->rx_length = request->rx_length;
response->status = SPI_NOR_COMMAND_SERVICE_OK;
return response->status;
}

View File

@@ -0,0 +1,59 @@
#include "spi_nor_read_service.h"
#include <string.h>
/* Диагностический слой намеренно не содержит program/erase callbacks. */
spi_nor_read_service_status_t spi_nor_read_service_init(
spi_nor_read_service_t *service, void *context,
spi_nor_read_service_read_fn read, uint32_t capacity_bytes)
{
if ((service == NULL) || (read == NULL) || (capacity_bytes == 0U)) {
return SPI_NOR_READ_SERVICE_INVALID;
}
service->context = context;
service->read = read;
service->capacity_bytes = capacity_bytes;
return SPI_NOR_READ_SERVICE_OK;
}
/* Вычитание после проверки address исключает uint32 overflow address+length. */
spi_nor_read_service_status_t spi_nor_read_service_process(
const spi_nor_read_service_t *service,
const spi_nor_read_service_request_t *request,
spi_nor_read_service_response_t *response)
{
spi_nor_read_service_status_t status;
if (response == NULL) {
return SPI_NOR_READ_SERVICE_INVALID;
}
memset(response, 0, sizeof(*response));
if ((service == NULL) || (request == NULL) || (service->read == NULL) ||
(service->capacity_bytes == 0U)) {
response->status = SPI_NOR_READ_SERVICE_UNAVAILABLE;
return response->status;
}
response->sequence = request->sequence;
response->address = request->address;
if ((request->length == 0U) ||
(request->length > SPI_NOR_READ_SERVICE_MAX_BYTES) ||
(request->address >= service->capacity_bytes) ||
((uint32_t)request->length >
(service->capacity_bytes - request->address))) {
response->status = SPI_NOR_READ_SERVICE_INVALID;
return response->status;
}
response->status = SPI_NOR_READ_SERVICE_BUSY;
/* Callback остаётся единственной точкой физического чтения платформы. */
status = service->read(service->context, request->address,
response->data, request->length);
if (status != SPI_NOR_READ_SERVICE_OK) {
/* После SPI-ошибки вызывающий не получает частично заполненный буфер. */
memset(response->data, 0, sizeof(response->data));
response->status = status;
return response->status;
}
response->length = request->length;
response->status = SPI_NOR_READ_SERVICE_OK;
return response->status;
}

140
c/spi-nor/Inc/spi_nor.h Normal file
View File

@@ -0,0 +1,140 @@
#ifndef PORTABLE_SPI_NOR_H
#define PORTABLE_SPI_NOR_H
/*
* Переносимое ядро SPI NOR.
*
* Ядро знает только стандартные SPI-команды и callbacks платформы. Оно не
* включает HAL, регистры MCU, GPIO проекта, Modbus или код приложения. Каждый
* экземпляр хранит собственное состояние и поэтому не использует глобальные
* изменяемые переменные.
*/
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Поддерживаемые микросхемы ограничены устройствами с 24-битной адресацией. */
typedef enum
{
SPI_NOR_MODEL_NONE = 0,
SPI_NOR_MODEL_W25Q64,
SPI_NOR_MODEL_W25Q128,
SPI_NOR_MODEL_SST25VF016B
} spi_nor_model_t;
/* Ошибки протокола не зависят от кодов конкретного HAL. */
typedef enum
{
SPI_NOR_OK = 0,
SPI_NOR_E_ARGUMENT = -1,
SPI_NOR_E_IO = -2,
SPI_NOR_E_TIMEOUT = -3,
SPI_NOR_E_NOT_FOUND = -4,
SPI_NOR_E_UNSUPPORTED = -5,
SPI_NOR_E_BOUNDS = -6,
SPI_NOR_E_ALIGNMENT = -7,
SPI_NOR_E_VERIFY = -8,
SPI_NOR_E_BUSY = -9
} spi_nor_status_t;
/* Callback SPI возвращает только переносимый результат физического обмена. */
typedef enum
{
SPI_NOR_IO_OK = 0,
SPI_NOR_IO_ERROR = -1,
SPI_NOR_IO_TIMEOUT = -2
} spi_nor_io_status_t;
/* Геометрия и JEDEC доступны приложению только как копируемая диагностика. */
typedef struct
{
spi_nor_model_t model;
uint8_t manufacturer_id;
uint8_t memory_type;
uint8_t capacity_code;
uint32_t capacity_bytes;
uint32_t sector_size;
uint16_t page_size;
} spi_nor_info_t;
/*
* Контракт платформы описывает одну SPI-шину и один сигнал CS.
*
* transfer выполняется при уже активном CS. NULL в tx означает передачу
* dummy-байтов, NULL в rx — игнорирование принятого потока. lock/unlock
* необязательны; RTOS-порт может ими сериализовать полную read/program/erase
* операцию, а не отдельный SPI пакет.
*/
typedef struct
{
void *context;
spi_nor_io_status_t (*transfer)(void *context, const uint8_t *tx,
uint8_t *rx, size_t size,
uint32_t timeout_ms);
void (*chip_select)(void *context, uint8_t active);
uint32_t (*tick_ms)(void *context);
void (*delay_ms)(void *context, uint32_t delay_ms);
spi_nor_io_status_t (*lock)(void *context, uint32_t timeout_ms);
void (*unlock)(void *context);
} spi_nor_io_t;
/* Тайм-ауты вынесены в конфигурацию для медленных шин и разных RTOS. */
typedef struct
{
uint32_t io_timeout_ms;
uint32_t lock_timeout_ms;
uint32_t program_timeout_ms;
uint32_t erase_timeout_ms;
uint32_t ready_poll_delay_ms;
} spi_nor_config_t;
/* Контекст полностью принадлежит вызывающему коду и не требует malloc. */
typedef struct
{
spi_nor_io_t io;
spi_nor_config_t config;
spi_nor_info_t info;
uint8_t initialized;
uint8_t detected;
uint8_t busy;
} spi_nor_t;
/* Возвращает документированные безопасные тайм-ауты библиотеки. */
spi_nor_config_t spi_nor_default_config(void);
/* Инициализация только копирует callbacks и не обращается к физической шине. */
spi_nor_status_t spi_nor_init(spi_nor_t *device, const spi_nor_io_t *io,
const spi_nor_config_t *config);
/* Probe будит память, читает JEDEC и разрешает работу известной геометрии. */
spi_nor_status_t spi_nor_probe(spi_nor_t *device);
/* Операции используют физические смещения микросхемы, начиная с нуля. */
spi_nor_status_t spi_nor_read(spi_nor_t *device, uint32_t address,
void *data, size_t size);
spi_nor_status_t spi_nor_program(spi_nor_t *device, uint32_t address,
const void *data, size_t size);
spi_nor_status_t spi_nor_erase(spi_nor_t *device, uint32_t address,
size_t size);
/* Информация выдаётся только после успешного распознавания поддержанной модели. */
spi_nor_status_t spi_nor_get_info(const spi_nor_t *device,
spi_nor_info_t *info);
/* Последний JEDEC доступен и для различения пустой шины и неизвестной модели. */
spi_nor_status_t spi_nor_get_last_jedec(const spi_nor_t *device,
uint8_t jedec[3]);
/* Read-only диагностика возвращает status register 1 под общей блокировкой. */
spi_nor_status_t spi_nor_read_status_register(spi_nor_t *device,
uint8_t *status);
#ifdef __cplusplus
}
#endif
#endif /* PORTABLE_SPI_NOR_H */

123
c/spi-nor/PORTING.md Normal file
View File

@@ -0,0 +1,123 @@
# Портирование SPI NOR на другую платформу
## 1. Добавить переносимое ядро
В сборку новой цели включите `Src/spi_nor.c`, а `Inc` добавьте в include path.
Это единственные обязательные файлы. Они используют только C99-заголовки
`stddef.h`, `stdint.h` и `string.h`.
## 2. Реализовать platform callbacks
Создайте собственный контекст шины и заполните `spi_nor_io_t`:
- `transfer` — полный синхронный SPI-обмен при уже активном CS;
- `chip_select` — `active=1` опускает CS, `active=0` поднимает его;
- `tick_ms` — монотонный 32-битный счётчик миллисекунд;
- `delay_ms` — задержка выхода из power-down и пауза BUSY polling;
- `lock/unlock` — необязательная пара для RTOS, оба либо заданы, либо `NULL`.
`transfer` должен поддерживать следующие варианты:
| `tx` | `rx` | Действие |
|---|---|---|
| не `NULL` | `NULL` | передать command или payload |
| `NULL` | не `NULL` | передавать dummy и сохранить входящие байты |
| не `NULL` | не `NULL` | full-duplex обмен, допустим для порта |
Callback не должен самостоятельно переключать CS. Ядро может вызвать transfer
дважды под одним CS: сначала command, затем data. Ошибка должна возвращаться как
`SPI_NOR_IO_ERROR` или `SPI_NOR_IO_TIMEOUT`.
## 3. Настроить SPI
Для W25Q/SST25 используйте master, 8 бит, MSB first и режим, разрешённый
datasheet. Частота не должна превышать предел микросхемы и возможности разводки.
CS перед `spi_nor_init()` должен быть настроен output push-pull и находиться в 1.
Проверьте питание, общую землю и отсутствие конфликта MISO с другими CS.
## 4. Инициализировать и выполнить probe
```c
spi_nor_t device;
spi_nor_io_t io = MakeMyPlatformIo();
spi_nor_config_t config = spi_nor_default_config();
config.erase_timeout_ms = 10000U; /* Пример для медленной микросхемы. */
if (spi_nor_init(&device, &io, &config) != SPI_NOR_OK) {
HandleConfigurationError();
}
if (spi_nor_probe(&device) != SPI_NOR_OK) {
HandleAbsentOrUnsupportedFlash();
}
```
До успешного probe read/program/erase отклоняются. Для диагностики неизвестной
памяти вызовите `spi_nor_get_last_jedec()` после неуспешного probe.
## 5. Подключить FlashStorage при необходимости
Добавьте в сборку:
- `Libraries/FlashStorage/Src/flash_storage.c`;
- `Libraries/FlashStorage/Adapter/SPI_NOR/Src/flash_storage_spi_nor_adapter.c`;
- include paths `FlashStorage/Inc` и `FlashStorage/Adapter/SPI_NOR/Inc`.
После probe создайте `flash_storage_spi_nor_adapter_t`, вызовите
`flash_storage_spi_nor_adapter_init()` и передайте `flash_storage_spi_nor_ops`
в `flash_storage_init()` или `eeprom_store_init()`. Layout должен лежать внутри
фактической `capacity_bytes` и быть выровнен по 4096 байтам.
## 6. STM32 HAL
Для другого STM32 можно использовать существующий порт, если семейство имеет
совместимый HAL API. Подключите `spi_nor_stm32f4_hal.c`, замените HAL include на
заголовок нужного семейства и переименуйте порт. Имена конкретных `hspi`, GPIO
и pin передавайте из приложения; не добавляйте их как `extern` в библиотеку.
Если используется DMA или RTOS, простой STM32F4 порт следует заменить:
- transfer обязан дождаться завершения DMA до возврата;
- lock/unlock должны использовать mutex, не semaphore из ISR;
- mutex должен быть общим для всех устройств на одной SPI-шине;
- callback delay должен освобождать CPU через RTOS delay, если это допустимо.
## 7. Приёмочные проверки
1. На пустой шине получить `SPI_NOR_E_NOT_FOUND` и CS=1.
2. Прочитать ожидаемый JEDEC.
3. Проверить чтение первого и последнего адреса.
4. В тестовом секторе выполнить erase и полный verify `0xFF`.
5. Записать данные через границу страницы и сравнить readback.
6. Имитировать SPI error/timeout и убедиться, что CS поднят.
7. Проверить отказ для диапазона за концом памяти и невыровненного erase.
8. При RTOS параллельно запустить два клиента и проверить mutex-анализатором.
Для текущего репозитория host mock запускается через
`python -m unittest tests.test_spi_nor_host` и не требует STM32 HAL.
## 8. Перенос read-only command service
Если нужен сервисный просмотр команд, добавьте в сборку
`Diagnostics/Src/spi_nor_command_service.c` и include path `Diagnostics/Inc`.
Передавайте ему уже успешно probed `spi_nor_t`; отдельный HAL-порт не нужен:
```c
spi_nor_command_service_request_t request = {0};
spi_nor_command_service_response_t response;
request.sequence = 1U;
request.tx_length = 1U;
request.rx_length = 3U;
request.tx[0] = 0x9FU;
(void)spi_nor_command_service_process(&device, &request, &response);
```
Не расширяйте существующий allowlist ради записи. Если продукту когда-нибудь
потребуются изменяющие операции, они должны иметь отдельный API, threat model,
защиту диапазонов и аппаратные тесты. Текущий сервис не содержит compile-time
или runtime переключателя, снимающего блокировку.
Проверьте mock-тестом своей платформы: TX/RX порядок под одним CS, освобождение
CS при timeout каждого transfer, общий mutex SPI bus, строгие максимумы 4/64 и
отказ для `06`, `02`, всех erase, status/protection writes, power/reset и
неизвестных opcode.

113
c/spi-nor/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Переносимая библиотека SPI NOR
`SpiNor` — самостоятельный синхронный драйвер последовательной NOR Flash. Он
не зависит от STM32, HAL, `main.c`, GPIO проекта, Modbus, GUI и FlashStorage.
Память для контекста предоставляет приложение; `malloc` и глобальное изменяемое
состояние не используются.
## Поддержанные микросхемы
| Модель | JEDEC | Ёмкость | Program | Erase |
|---|---:|---:|---:|---:|
| W25Q64 | `EF 40 17` | 8 МиБ | страницы до 256 байт | 4 КиБ |
| W25Q128 | `EF 40 18` | 16 МиБ | страницы до 256 байт | 4 КиБ |
| SST25VF016B | `BF 25 41` | 2 МиБ | безопасный Byte Program | 4 КиБ |
Ядро использует команды с 24-битным адресом и намеренно не принимает
микросхемы больше 16 МиБ. Неизвестный JEDEC возвращает
`SPI_NOR_E_UNSUPPORTED`; пустая шина `00 00 00` или `FF FF FF` —
`SPI_NOR_E_NOT_FOUND`.
## Структура
- `Inc/spi_nor.h` — публичные типы, callbacks, конфигурация и API;
- `Src/spi_nor.c` — переносимый протокол, JEDEC, bounds, тайм-ауты и verify;
- `Port/STM32F4_HAL` — единственное место с зависимостью от STM32F4 HAL;
- `Examples/portable_init.c` — минимальная интеграция без имён текущей платы;
- `PORTING.md` — пошаговый перенос на другой STM32 или другой MCU;
- `Tests` — mock-шина и сценарии без HAL и реального оборудования.
- `Diagnostics` — два bounded read-only сервиса без HAL/Modbus/GUI;
`spi_nor_read_service` читает физический диапазон через callback, а
`spi_nor_command_service` выполняет одну разрешённую сервисную транзакцию.
Адаптер журнала находится отдельно в
`Libraries/FlashStorage/Adapter/SPI_NOR`: FlashStorage не проникает в ядро
SPI NOR, а SPI NOR не знает формат журнала.
Диагностический сервис принимает только capacity и callback чтения, ограничивает
один пакет 64 байт и не имеет `program`/`erase` API. Приложение может связать
его с Modbus, не добавляя GUI или прикладные зависимости в ядро `SpiNor`.
### Сервис коротких SPI-команд
`spi_nor_command_service` принимает `spi_nor_t`, request с `sequence`, TX до
4 байт и RX до 64 байт, а response возвращает status, echo TX и RX. Владельцем
контекста остаётся приложение; сервис не выделяет память и не хранит историю.
Allowlist одинаков для всех трёх поддержанных моделей:
| Opcode | Строгая форма | Причина допуска |
|---:|---|---|
| `9F` | TX 1, RX 3 | JEDEC ID |
| `05` | TX 1, RX 1 | status register 1 |
| `03` | TX 4 (`03 A2 A1 A0`), RX 1–64 | обычное 24-битное чтение с bounds |
Любой другой opcode возвращает `BLOCKED`. В частности, запрещены WREN/WRDI,
program, все erase, status/protection writes, power-down/release, reset и
неизвестные команды. Это fail-closed read-only API, а не универсальный SPI
terminal. Изменяющий сервисный режим не требуется и в библиотеку не включён.
Одна операция имеет порядок `CS active -> TX -> dummy RX -> CS inactive`.
После ошибки или тайм-аута TX/RX сервис безусловно освобождает CS и очищает
частичный RX. Он использует тот же `busy` и необязательные `lock/unlock`, что
основной экземпляр `spi_nor_t`.
## Публичный API
```c
spi_nor_io_t io;
spi_nor_t flash;
/* Порт приложения заполняет callbacks и собственный io.context. */
FillPlatformCallbacks(&io);
if (spi_nor_init(&flash, &io, NULL) == SPI_NOR_OK &&
spi_nor_probe(&flash) == SPI_NOR_OK) {
uint8_t bytes[16];
(void)spi_nor_read(&flash, 0U, bytes, sizeof(bytes));
}
```
`spi_nor_program()` автоматически делит поток по страницам и проверяет каждый
chunk обратным чтением. `spi_nor_erase()` принимает только полные выровненные
4-КиБ секторы и проверяет каждый стёртый байт на `0xFF`.
## Электрическое подключение
Нужны `SCK`, `MOSI`, `MISO`, отдельный active-low `CS`, питание и земля.
Питание и допустимую частоту следует брать из datasheet конкретной модели.
Для текущей платы используются SPI2: `PB10/SCK`, `PC3/MOSI`, `PC2/MISO` и
`PE3/CS`, но эти имена находятся только в `app_storage.c`.
## Ограничения выполнения
- API синхронный и не предназначен для ISR;
- erase может блокировать вызывающий поток до заданного тайм-аута;
- один `spi_nor_t` не допускает рекурсивных операций;
- при RTOS callbacks `lock/unlock` должны защищать всю составную операцию;
- общий SPI bus требует согласованного mutex и корректного управления CS всех
подключённых устройств;
- DMA callback нельзя считать завершённым до фактического окончания обмена;
- библиотека не меняет SPI mode и clock: это обязанность платформенного порта.
## Гарантии ошибок
При любой ошибке обмена CS возвращается в неактивное состояние. Ошибка program
или erase не маскируется: результат считается успешным только после ожидания
BUSY и полного readback verify. Повторять изменяющую операцию автоматически
библиотека не будет, потому что политика повторов принадлежит приложению.
## Shared source
Canonical source: `templates/c/spi-nor`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.

472
c/spi-nor/Src/spi_nor.c Normal file
View File

@@ -0,0 +1,472 @@
#include "spi_nor.h"
#include <string.h>
/* Стандартные команды одинаковы для проверенных W25Q и SST25. */
#define SPI_NOR_COMMAND_WRITE_ENABLE 0x06U
#define SPI_NOR_COMMAND_READ_STATUS 0x05U
#define SPI_NOR_COMMAND_READ_DATA 0x03U
#define SPI_NOR_COMMAND_PAGE_PROGRAM 0x02U
#define SPI_NOR_COMMAND_SECTOR_ERASE 0x20U
#define SPI_NOR_COMMAND_READ_JEDEC_ID 0x9FU
#define SPI_NOR_COMMAND_RELEASE_POWER_DOWN 0xABU
#define SPI_NOR_STATUS_BUSY 0x01U
#define SPI_NOR_SECTOR_SIZE 4096UL
#define SPI_NOR_MAX_PAGE_SIZE 256U
#define SPI_NOR_VERIFY_CHUNK_SIZE 32U
#define SPI_NOR_24BIT_CAPACITY_LIMIT 0x01000000UL
/* Преобразует результат физического обмена в ошибку протокольного слоя. */
static spi_nor_status_t map_io_status(spi_nor_io_status_t status)
{
if (status == SPI_NOR_IO_OK) {
return SPI_NOR_OK;
}
if (status == SPI_NOR_IO_TIMEOUT) {
return SPI_NOR_E_TIMEOUT;
}
return SPI_NOR_E_IO;
}
/* Захватывает весь составной вызов, чтобы другой поток не вклинился после WREN. */
static spi_nor_status_t begin_operation(spi_nor_t *device)
{
spi_nor_io_status_t lock_status;
if ((device == NULL) || (device->initialized == 0U)) {
return SPI_NOR_E_ARGUMENT;
}
/* Рекурсивный вызов отклоняется до mutex, чтобы не ждать самого себя. */
if (device->busy != 0U) {
return SPI_NOR_E_BUSY;
}
if (device->io.lock != NULL) {
lock_status = device->io.lock(device->io.context,
device->config.lock_timeout_ms);
if (lock_status != SPI_NOR_IO_OK) {
return (lock_status == SPI_NOR_IO_TIMEOUT) ?
SPI_NOR_E_TIMEOUT : SPI_NOR_E_BUSY;
}
}
if (device->busy != 0U) {
if (device->io.unlock != NULL) {
device->io.unlock(device->io.context);
}
return SPI_NOR_E_BUSY;
}
device->busy = 1U;
return SPI_NOR_OK;
}
/* Всегда освобождает как локальный флаг, так и необязательный mutex платформы. */
static void end_operation(spi_nor_t *device)
{
device->busy = 0U;
if (device->io.unlock != NULL) {
device->io.unlock(device->io.context);
}
}
/* Обмен выполняется только при активном CS, а ошибка не оставляет CS в нуле. */
static spi_nor_status_t transaction(spi_nor_t *device,
const uint8_t *command,
size_t command_size,
const uint8_t *tx_data,
uint8_t *rx_data,
size_t data_size)
{
spi_nor_io_status_t io_status;
device->io.chip_select(device->io.context, 1U);
io_status = device->io.transfer(device->io.context, command, NULL,
command_size,
device->config.io_timeout_ms);
if ((io_status == SPI_NOR_IO_OK) && (data_size != 0U)) {
io_status = device->io.transfer(device->io.context, tx_data, rx_data,
data_size,
device->config.io_timeout_ms);
}
device->io.chip_select(device->io.context, 0U);
return map_io_status(io_status);
}
/* Формирует opcode и 24-битный big-endian адрес независимо от endian CPU. */
static void make_address_command(uint8_t command[4], uint8_t opcode,
uint32_t address)
{
command[0] = opcode;
command[1] = (uint8_t)(address >> 16U);
command[2] = (uint8_t)(address >> 8U);
command[3] = (uint8_t)address;
}
/* Проверяет весь полуоткрытый диапазон без переполнения address + size. */
static spi_nor_status_t validate_range(const spi_nor_t *device,
uint32_t address,
size_t size)
{
if ((device->detected == 0U) || (size == 0U)) {
return SPI_NOR_E_ARGUMENT;
}
if ((address >= device->info.capacity_bytes) ||
(size > (size_t)(device->info.capacity_bytes - address)) ||
(device->info.capacity_bytes > SPI_NOR_24BIT_CAPACITY_LIMIT)) {
return SPI_NOR_E_BOUNDS;
}
return SPI_NOR_OK;
}
/* Читает регистр состояния в отдельной законченной SPI-транзакции. */
static spi_nor_status_t read_status(spi_nor_t *device, uint8_t *status)
{
uint8_t command = SPI_NOR_COMMAND_READ_STATUS;
return transaction(device, &command, 1U, NULL, status, 1U);
}
/* Публичное чтение статуса не допускает пересечения с program/erase/read. */
spi_nor_status_t spi_nor_read_status_register(spi_nor_t *device,
uint8_t *status)
{
spi_nor_status_t result;
if ((device == NULL) || (status == NULL) || (device->detected == 0U)) {
return SPI_NOR_E_ARGUMENT;
}
result = begin_operation(device);
if (result != SPI_NOR_OK) {
return result;
}
result = read_status(device, status);
end_operation(device);
return result;
}
/* Ожидает снятия BUSY с wrap-safe арифметикой tick и конечным тайм-аутом. */
static spi_nor_status_t wait_ready(spi_nor_t *device, uint32_t timeout_ms)
{
uint32_t started_ms = device->io.tick_ms(device->io.context);
uint8_t status = SPI_NOR_STATUS_BUSY;
spi_nor_status_t result;
while ((status & SPI_NOR_STATUS_BUSY) != 0U) {
result = read_status(device, &status);
if (result != SPI_NOR_OK) {
return result;
}
if ((status & SPI_NOR_STATUS_BUSY) == 0U) {
return SPI_NOR_OK;
}
if ((uint32_t)(device->io.tick_ms(device->io.context) - started_ms) >=
timeout_ms) {
return SPI_NOR_E_TIMEOUT;
}
if (device->config.ready_poll_delay_ms != 0U) {
device->io.delay_ms(device->io.context,
device->config.ready_poll_delay_ms);
}
}
return SPI_NOR_OK;
}
/* Перед каждой изменяющей командой устанавливает volatile Write Enable Latch. */
static spi_nor_status_t write_enable(spi_nor_t *device)
{
uint8_t command = SPI_NOR_COMMAND_WRITE_ENABLE;
return transaction(device, &command, 1U, NULL, NULL, 0U);
}
/* Внутреннее чтение не захватывает mutex повторно и используется verify-кодом. */
static spi_nor_status_t raw_read(spi_nor_t *device, uint32_t address,
void *data, size_t size)
{
uint8_t command[4];
make_address_command(command, SPI_NOR_COMMAND_READ_DATA, address);
return transaction(device, command, sizeof(command), NULL, data, size);
}
/* Программирует один page/byte chunk и немедленно проверяет его чтением. */
static spi_nor_status_t program_chunk(spi_nor_t *device,
uint32_t address,
const uint8_t *data,
size_t size)
{
uint8_t command[4];
uint8_t verify[SPI_NOR_MAX_PAGE_SIZE];
spi_nor_status_t result;
result = write_enable(device);
if (result != SPI_NOR_OK) {
return result;
}
make_address_command(command, SPI_NOR_COMMAND_PAGE_PROGRAM, address);
result = transaction(device, command, sizeof(command), data, NULL, size);
if (result != SPI_NOR_OK) {
return result;
}
result = wait_ready(device, device->config.program_timeout_ms);
if (result != SPI_NOR_OK) {
return result;
}
result = raw_read(device, address, verify, size);
if (result != SPI_NOR_OK) {
return result;
}
return (memcmp(verify, data, size) == 0) ?
SPI_NOR_OK : SPI_NOR_E_VERIFY;
}
/* Проверяет каждый байт сектора после erase, а не только первый word. */
static spi_nor_status_t verify_erased_sector(spi_nor_t *device,
uint32_t address)
{
uint8_t verify[SPI_NOR_VERIFY_CHUNK_SIZE];
uint32_t offset;
size_t index;
spi_nor_status_t result;
for (offset = 0U; offset < SPI_NOR_SECTOR_SIZE;
offset += sizeof(verify)) {
result = raw_read(device, address + offset, verify, sizeof(verify));
if (result != SPI_NOR_OK) {
return result;
}
for (index = 0U; index < sizeof(verify); ++index) {
if (verify[index] != 0xFFU) {
return SPI_NOR_E_VERIFY;
}
}
}
return SPI_NOR_OK;
}
/* Сопоставляет только проверенные JEDEC; неизвестную ёмкость не угадывает. */
static spi_nor_status_t identify_device(spi_nor_t *device)
{
const uint8_t manufacturer = device->info.manufacturer_id;
const uint8_t memory_type = device->info.memory_type;
const uint8_t capacity = device->info.capacity_code;
device->info.sector_size = SPI_NOR_SECTOR_SIZE;
if ((manufacturer == 0xEFU) && (memory_type == 0x40U) &&
(capacity == 0x17U)) {
device->info.model = SPI_NOR_MODEL_W25Q64;
device->info.capacity_bytes = 8UL * 1024UL * 1024UL;
device->info.page_size = 256U;
return SPI_NOR_OK;
}
if ((manufacturer == 0xEFU) && (memory_type == 0x40U) &&
(capacity == 0x18U)) {
device->info.model = SPI_NOR_MODEL_W25Q128;
device->info.capacity_bytes = 16UL * 1024UL * 1024UL;
device->info.page_size = 256U;
return SPI_NOR_OK;
}
if ((manufacturer == 0xBFU) && (memory_type == 0x25U) &&
(capacity == 0x41U)) {
device->info.model = SPI_NOR_MODEL_SST25VF016B;
device->info.capacity_bytes = 2UL * 1024UL * 1024UL;
/* Byte Program 0x02 не требует SST AAI sequencing. */
device->info.page_size = 1U;
return SPI_NOR_OK;
}
if (((manufacturer == 0x00U) && (memory_type == 0x00U) &&
(capacity == 0x00U)) ||
((manufacturer == 0xFFU) && (memory_type == 0xFFU) &&
(capacity == 0xFFU))) {
return SPI_NOR_E_NOT_FOUND;
}
return SPI_NOR_E_UNSUPPORTED;
}
/* Публичные defaults сохраняют тайм-ауты исходного HAL-драйвера. */
spi_nor_config_t spi_nor_default_config(void)
{
spi_nor_config_t config;
config.io_timeout_ms = 100U;
config.lock_timeout_ms = 100U;
config.program_timeout_ms = 1000U;
config.erase_timeout_ms = 5000U;
config.ready_poll_delay_ms = 1U;
return config;
}
/* Проверяет обязательные callbacks и сохраняет независимую копию контракта. */
spi_nor_status_t spi_nor_init(spi_nor_t *device, const spi_nor_io_t *io,
const spi_nor_config_t *config)
{
if ((device == NULL) || (io == NULL) || (io->transfer == NULL) ||
(io->chip_select == NULL) || (io->tick_ms == NULL) ||
(io->delay_ms == NULL) ||
((io->lock == NULL) != (io->unlock == NULL))) {
return SPI_NOR_E_ARGUMENT;
}
memset(device, 0, sizeof(*device));
device->io = *io;
device->config = (config != NULL) ? *config : spi_nor_default_config();
if ((device->config.io_timeout_ms == 0U) ||
(device->config.program_timeout_ms == 0U) ||
(device->config.erase_timeout_ms == 0U)) {
memset(device, 0, sizeof(*device));
return SPI_NOR_E_ARGUMENT;
}
device->io.chip_select(device->io.context, 0U);
device->initialized = 1U;
return SPI_NOR_OK;
}
/* Будит устройство и сохраняет raw JEDEC даже при неизвестной модели. */
spi_nor_status_t spi_nor_probe(spi_nor_t *device)
{
uint8_t command = SPI_NOR_COMMAND_RELEASE_POWER_DOWN;
uint8_t jedec[3] = {0U, 0U, 0U};
spi_nor_status_t result;
result = begin_operation(device);
if (result != SPI_NOR_OK) {
return result;
}
device->detected = 0U;
memset(&device->info, 0, sizeof(device->info));
result = transaction(device, &command, 1U, NULL, NULL, 0U);
if (result == SPI_NOR_OK) {
device->io.delay_ms(device->io.context, 1U);
command = SPI_NOR_COMMAND_READ_JEDEC_ID;
result = transaction(device, &command, 1U, NULL, jedec,
sizeof(jedec));
}
if (result == SPI_NOR_OK) {
device->info.manufacturer_id = jedec[0];
device->info.memory_type = jedec[1];
device->info.capacity_code = jedec[2];
result = identify_device(device);
if (result == SPI_NOR_OK) {
device->detected = 1U;
}
}
end_operation(device);
return result;
}
/* Чтение проверяет адрес до активации CS и не использует внутренний RAM-кэш. */
spi_nor_status_t spi_nor_read(spi_nor_t *device, uint32_t address,
void *data, size_t size)
{
spi_nor_status_t result;
if (data == NULL) {
return SPI_NOR_E_ARGUMENT;
}
result = begin_operation(device);
if (result != SPI_NOR_OK) {
return result;
}
result = validate_range(device, address, size);
if (result == SPI_NOR_OK) {
result = raw_read(device, address, data, size);
}
end_operation(device);
return result;
}
/* Запись разбивается по границам страниц; SST получает byte-program chunks. */
spi_nor_status_t spi_nor_program(spi_nor_t *device, uint32_t address,
const void *data, size_t size)
{
const uint8_t *source = data;
size_t remaining = size;
spi_nor_status_t result;
if (data == NULL) {
return SPI_NOR_E_ARGUMENT;
}
result = begin_operation(device);
if (result != SPI_NOR_OK) {
return result;
}
result = validate_range(device, address, size);
while ((result == SPI_NOR_OK) && (remaining != 0U)) {
size_t page_remaining = device->info.page_size -
(address % device->info.page_size);
size_t chunk = (remaining < page_remaining) ?
remaining : page_remaining;
result = program_chunk(device, address, source, chunk);
address += (uint32_t)chunk;
source += chunk;
remaining -= chunk;
}
end_operation(device);
return result;
}
/* Erase принимает только целые выровненные 4-КиБ секторы в пределах памяти. */
spi_nor_status_t spi_nor_erase(spi_nor_t *device, uint32_t address,
size_t size)
{
size_t remaining = size;
spi_nor_status_t result;
result = begin_operation(device);
if (result != SPI_NOR_OK) {
return result;
}
if (((address % SPI_NOR_SECTOR_SIZE) != 0U) ||
((size % SPI_NOR_SECTOR_SIZE) != 0U)) {
result = SPI_NOR_E_ALIGNMENT;
} else {
result = validate_range(device, address, size);
}
while ((result == SPI_NOR_OK) && (remaining != 0U)) {
uint8_t command[4];
result = write_enable(device);
if (result == SPI_NOR_OK) {
make_address_command(command, SPI_NOR_COMMAND_SECTOR_ERASE,
address);
result = transaction(device, command, sizeof(command), NULL,
NULL, 0U);
}
if (result == SPI_NOR_OK) {
result = wait_ready(device, device->config.erase_timeout_ms);
}
if (result == SPI_NOR_OK) {
result = verify_erased_sector(device, address);
}
address += SPI_NOR_SECTOR_SIZE;
remaining -= SPI_NOR_SECTOR_SIZE;
}
end_operation(device);
return result;
}
/* Копия info не позволяет внешнему коду менять геометрию активного контекста. */
spi_nor_status_t spi_nor_get_info(const spi_nor_t *device,
spi_nor_info_t *info)
{
if ((device == NULL) || (info == NULL)) {
return SPI_NOR_E_ARGUMENT;
}
if (device->detected == 0U) {
return SPI_NOR_E_NOT_FOUND;
}
*info = device->info;
return SPI_NOR_OK;
}
/* Raw JEDEC остаётся диагностически доступен после NOT_FOUND/UNSUPPORTED. */
spi_nor_status_t spi_nor_get_last_jedec(const spi_nor_t *device,
uint8_t jedec[3])
{
if ((device == NULL) || (jedec == NULL) ||
(device->initialized == 0U)) {
return SPI_NOR_E_ARGUMENT;
}
jedec[0] = device->info.manufacturer_id;
jedec[1] = device->info.memory_type;
jedec[2] = device->info.capacity_code;
return SPI_NOR_OK;
}

View File

@@ -0,0 +1,363 @@
#include "spi_nor.h"
#include <stdio.h>
#include <string.h>
/* Host mock хранит максимальную W25Q128 целиком и не использует STM32 HAL. */
#define MOCK_CAPACITY (16UL * 1024UL * 1024UL)
#define MOCK_SECTOR_SIZE 4096UL
#define MOCK_COMMAND_READ 0x03U
#define MOCK_COMMAND_WRITE 0x02U
#define MOCK_COMMAND_ERASE 0x20U
#define MOCK_COMMAND_STATUS 0x05U
#define MOCK_COMMAND_WREN 0x06U
#define MOCK_COMMAND_JEDEC 0x9FU
/* Состояние fake SPI моделирует команды, CS, ошибки и readback-порчу. */
typedef struct
{
uint8_t memory[MOCK_CAPACITY];
uint8_t jedec[3];
uint8_t opcode;
uint8_t chip_selected;
uint8_t write_enabled;
uint8_t fail_io;
uint8_t busy_forever;
uint8_t corrupt_program;
uint8_t corrupt_erase;
uint32_t address;
uint32_t tick_ms;
uint32_t page_program_count;
uint32_t erase_count;
} mock_spi_t;
/* Счётчики формируют короткий самостоятельный test runner без framework. */
static unsigned int tests_run;
static unsigned int tests_failed;
/* CHECK сохраняет имя строки и позволяет выполнить остальные сценарии. */
#define CHECK(condition) TestCheck((condition), #condition, __LINE__)
/* Регистрирует одно утверждение и печатает только диагностическую ошибку. */
static void TestCheck(int condition, const char *expression, int line)
{
++tests_run;
if (!condition) {
++tests_failed;
(void)printf("FAIL line %d: %s\n", line, expression);
}
}
/* Инициализация задаёт erased-состояние и JEDEC W25Q128 по умолчанию. */
static void MockInit(mock_spi_t *mock)
{
memset(mock, 0, sizeof(*mock));
memset(mock->memory, 0xFF, sizeof(mock->memory));
mock->jedec[0] = 0xEFU;
mock->jedec[1] = 0x40U;
mock->jedec[2] = 0x18U;
}
/* Декодирует три адресных байта команды в физическое 24-битное смещение. */
static uint32_t MockAddress(const uint8_t *tx)
{
return ((uint32_t)tx[1] << 16U) |
((uint32_t)tx[2] << 8U) |
(uint32_t)tx[3];
}
/* Fake transfer исполняет ровно тот callback-контракт, который видит ядро. */
static spi_nor_io_status_t MockTransfer(void *context, const uint8_t *tx,
uint8_t *rx, size_t size,
uint32_t timeout_ms)
{
mock_spi_t *mock = context;
size_t index;
(void)timeout_ms;
if ((mock->fail_io != 0U) || (mock->chip_selected == 0U)) {
return SPI_NOR_IO_ERROR;
}
if (tx != NULL) {
if ((mock->opcode == MOCK_COMMAND_WRITE) && (size != 4U)) {
if (mock->write_enabled == 0U) {
return SPI_NOR_IO_ERROR;
}
for (index = 0U; index < size; ++index) {
mock->memory[mock->address + index] &= tx[index];
}
if ((mock->corrupt_program != 0U) && (size != 0U)) {
mock->memory[mock->address] ^= 0x01U;
}
mock->address += (uint32_t)size;
mock->write_enabled = 0U;
++mock->page_program_count;
return SPI_NOR_IO_OK;
}
mock->opcode = tx[0];
if ((size == 4U) && ((mock->opcode == MOCK_COMMAND_READ) ||
(mock->opcode == MOCK_COMMAND_WRITE) ||
(mock->opcode == MOCK_COMMAND_ERASE))) {
mock->address = MockAddress(tx);
}
if (mock->opcode == MOCK_COMMAND_WREN) {
mock->write_enabled = 1U;
} else if (mock->opcode == MOCK_COMMAND_ERASE) {
if (mock->write_enabled == 0U) {
return SPI_NOR_IO_ERROR;
}
memset(&mock->memory[mock->address], 0xFF, MOCK_SECTOR_SIZE);
if (mock->corrupt_erase != 0U) {
mock->memory[mock->address + 7U] = 0x00U;
}
mock->write_enabled = 0U;
++mock->erase_count;
}
return SPI_NOR_IO_OK;
}
if (rx == NULL) {
return SPI_NOR_IO_ERROR;
}
if (mock->opcode == MOCK_COMMAND_JEDEC) {
memcpy(rx, mock->jedec, size);
} else if (mock->opcode == MOCK_COMMAND_STATUS) {
memset(rx, (mock->busy_forever != 0U) ? 1 : 0, size);
} else if (mock->opcode == MOCK_COMMAND_READ) {
memcpy(rx, &mock->memory[mock->address], size);
mock->address += (uint32_t)size;
} else {
memset(rx, 0xFF, size);
}
return SPI_NOR_IO_OK;
}
/* Mock CS фиксирует активность и сбрасывает opcode на границе транзакции. */
static void MockChipSelect(void *context, uint8_t active)
{
mock_spi_t *mock = context;
mock->chip_selected = active;
if (active != 0U) {
mock->opcode = 0U;
}
}
/* Mock tick управляется delay callback и делает timeout детерминированным. */
static uint32_t MockTick(void *context)
{
mock_spi_t *mock = context;
return mock->tick_ms;
}
/* Mock delay не ждёт реальное время, а продвигает виртуальные миллисекунды. */
static void MockDelay(void *context, uint32_t delay_ms)
{
mock_spi_t *mock = context;
mock->tick_ms += delay_ms;
}
/* Создаёт IO contract без HAL, GPIO-регистров и глобальных handles. */
static spi_nor_io_t MockIo(mock_spi_t *mock)
{
spi_nor_io_t io;
memset(&io, 0, sizeof(io));
io.context = mock;
io.transfer = MockTransfer;
io.chip_select = MockChipSelect;
io.tick_ms = MockTick;
io.delay_ms = MockDelay;
return io;
}
/* Успешный probe должен определить каждую заявленную модель и геометрию. */
static void TestSupportedJedec(void)
{
const uint8_t ids[3][3] = {
{0xEFU, 0x40U, 0x17U},
{0xEFU, 0x40U, 0x18U},
{0xBFU, 0x25U, 0x41U}
};
const uint32_t capacities[3] = {
8UL * 1024UL * 1024UL,
16UL * 1024UL * 1024UL,
2UL * 1024UL * 1024UL
};
unsigned int index;
for (index = 0U; index < 3U; ++index) {
static mock_spi_t mock;
spi_nor_t device;
spi_nor_info_t info;
spi_nor_io_t io;
MockInit(&mock);
memcpy(mock.jedec, ids[index], sizeof(mock.jedec));
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
CHECK(spi_nor_get_info(&device, &info) == SPI_NOR_OK);
CHECK(info.capacity_bytes == capacities[index]);
CHECK(mock.chip_selected == 0U);
}
}
/* Unknown и пустая шина должны различаться, сохраняя raw JEDEC. */
static void TestProbeFailures(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
uint8_t raw[3];
MockInit(&mock);
mock.jedec[0] = 0x12U;
mock.jedec[1] = 0x34U;
mock.jedec[2] = 0x56U;
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_E_UNSUPPORTED);
CHECK(spi_nor_get_last_jedec(&device, raw) == SPI_NOR_OK);
CHECK(memcmp(raw, mock.jedec, sizeof(raw)) == 0);
memset(mock.jedec, 0xFF, sizeof(mock.jedec));
CHECK(spi_nor_probe(&device) == SPI_NOR_E_NOT_FOUND);
}
/* Program через границу страницы обязан создать два chunks и точный readback. */
static void TestProgramAndRead(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
uint8_t source[20];
uint8_t result[20];
size_t index;
MockInit(&mock);
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
for (index = 0U; index < sizeof(source); ++index) {
source[index] = (uint8_t)(index + 1U);
}
CHECK(spi_nor_program(&device, 250U, source, sizeof(source)) == SPI_NOR_OK);
CHECK(mock.page_program_count == 2U);
memset(result, 0, sizeof(result));
CHECK(spi_nor_read(&device, 250U, result, sizeof(result)) == SPI_NOR_OK);
CHECK(memcmp(source, result, sizeof(source)) == 0);
CHECK(spi_nor_read(&device, MOCK_CAPACITY - 2U, result, 4U) ==
SPI_NOR_E_BOUNDS);
}
/* SST25 использует безопасную последовательность из отдельных Byte Program. */
static void TestSstByteProgram(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
const uint8_t source[3] = {0x11U, 0x22U, 0x33U};
MockInit(&mock);
mock.jedec[0] = 0xBFU;
mock.jedec[1] = 0x25U;
mock.jedec[2] = 0x41U;
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
CHECK(spi_nor_program(&device, 0U, source, sizeof(source)) == SPI_NOR_OK);
CHECK(mock.page_program_count == 3U);
}
/* Readback-порча program должна возвращаться отдельной verify-ошибкой. */
static void TestProgramVerifyFailure(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
const uint8_t source[2] = {0x12U, 0x34U};
MockInit(&mock);
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
mock.corrupt_program = 1U;
CHECK(spi_nor_program(&device, 0U, source, sizeof(source)) ==
SPI_NOR_E_VERIFY);
CHECK(mock.chip_selected == 0U);
}
/* Erase проверяет alignment, весь сектор и readback-порчу. */
static void TestErase(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
MockInit(&mock);
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
mock.memory[9U] = 0x00U;
CHECK(spi_nor_erase(&device, 1U, MOCK_SECTOR_SIZE) ==
SPI_NOR_E_ALIGNMENT);
CHECK(spi_nor_erase(&device, 0U, MOCK_SECTOR_SIZE) == SPI_NOR_OK);
CHECK(mock.memory[9U] == 0xFFU);
mock.corrupt_erase = 1U;
CHECK(spi_nor_erase(&device, 0U, MOCK_SECTOR_SIZE) == SPI_NOR_E_VERIFY);
}
/* Вечный BUSY использует виртуальный tick и заканчивается timeout, не зависая. */
static void TestTimeout(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
spi_nor_config_t config = spi_nor_default_config();
const uint8_t value = 0x55U;
MockInit(&mock);
io = MockIo(&mock);
config.program_timeout_ms = 3U;
CHECK(spi_nor_init(&device, &io, &config) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
mock.busy_forever = 1U;
CHECK(spi_nor_program(&device, 0U, &value, 1U) == SPI_NOR_E_TIMEOUT);
CHECK(mock.tick_ms >= 3U);
}
/* Ошибка обмена обязана поднять CS и не оставлять контекст permanently busy. */
static void TestIoFailureReleasesState(void)
{
static mock_spi_t mock;
spi_nor_t device;
spi_nor_io_t io;
uint8_t value;
MockInit(&mock);
io = MockIo(&mock);
CHECK(spi_nor_init(&device, &io, NULL) == SPI_NOR_OK);
CHECK(spi_nor_probe(&device) == SPI_NOR_OK);
mock.fail_io = 1U;
CHECK(spi_nor_read(&device, 0U, &value, 1U) == SPI_NOR_E_IO);
CHECK(mock.chip_selected == 0U);
mock.fail_io = 0U;
CHECK(spi_nor_read(&device, 0U, &value, 1U) == SPI_NOR_OK);
}
/* Main выполняет все host-сценарии и возвращает ненулевой код при сбое. */
int main(void)
{
TestSupportedJedec();
TestProbeFailures();
TestProgramAndRead();
TestSstByteProgram();
TestProgramVerifyFailure();
TestErase();
TestTimeout();
TestIoFailureReleasesState();
(void)printf("SPI NOR host mock: %u checks, %u failures\n",
tests_run, tests_failed);
return (tests_failed == 0U) ? 0 : 1;
}

View File

@@ -0,0 +1,166 @@
#include "spi_nor_command_service.h"
#include <stdio.h>
#include <string.h>
typedef struct
{
/* Счётчики доказывают единственную пару assert/release для каждого вызова. */
uint8_t cs_active;
uint8_t cs_asserts;
uint8_t cs_releases;
uint8_t transfers;
spi_nor_io_status_t fail_on_transfer;
uint8_t fail_transfer_number;
} mock_bus_t;
static unsigned checks;
static unsigned failures;
/* Каждый CHECK увеличивает общий счётчик, чтобы тест не мог пройти пустым. */
#define CHECK(value) do { checks++; if (!(value)) { failures++; } } while (0)
/* Mock различает TX и dummy RX, сохраняя реальный порядок одной транзакции. */
static spi_nor_io_status_t mock_transfer(void *context, const uint8_t *tx,
uint8_t *rx, size_t size,
uint32_t timeout_ms)
{
mock_bus_t *bus = context;
size_t index;
(void)timeout_ms;
/* Номер transfer позволяет отдельно сломать TX и последующий dummy RX. */
bus->transfers++;
if ((bus->fail_transfer_number != 0U) &&
(bus->transfers == bus->fail_transfer_number)) {
return bus->fail_on_transfer;
}
if ((tx == NULL) && (rx != NULL)) {
for (index = 0U; index < size; index++) {
rx[index] = (uint8_t)(0xA0U + index);
}
}
return SPI_NOR_IO_OK;
}
static void mock_cs(void *context, uint8_t active)
{
mock_bus_t *bus = context;
/* Последнее состояние проверяется после каждого error/timeout сценария. */
bus->cs_active = active;
if (active != 0U) {
bus->cs_asserts++;
} else {
bus->cs_releases++;
}
}
static void reset_bus(mock_bus_t *bus)
{
/* Сценарии не наследуют счётчики и forced error предыдущего вызова. */
memset(bus, 0, sizeof(*bus));
}
int main(void)
{
mock_bus_t bus;
spi_nor_t device;
spi_nor_command_service_request_t request;
spi_nor_command_service_response_t response;
static const uint8_t blocked[] = {
0x06U, 0x02U, 0x20U, 0x52U, 0xD8U, 0xC7U, 0x60U,
0x01U, 0x31U, 0x11U, 0x36U, 0x39U, 0xB9U, 0xABU,
0x66U, 0x99U, 0x04U, 0xFFU
};
size_t index;
/* Mock device считается уже успешно probed поддержанной SPI NOR. */
memset(&device, 0, sizeof(device));
reset_bus(&bus);
device.io.context = &bus;
device.io.transfer = mock_transfer;
device.io.chip_select = mock_cs;
device.config.io_timeout_ms = 10U;
device.config.lock_timeout_ms = 10U;
device.info.capacity_bytes = 256U;
device.initialized = 1U;
device.detected = 1U;
memset(&request, 0, sizeof(request));
request.sequence = 7U;
request.tx_length = 1U;
request.rx_length = 3U;
request.tx[0] = 0x9FU;
/* JEDEC проверяет данные, sequence, echo и физический порядок transfers. */
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_OK);
CHECK(response.sequence == 7U && response.tx_echo[0] == 0x9FU);
CHECK(response.rx_length == 3U && response.rx[0] == 0xA0U &&
response.rx[2] == 0xA2U);
CHECK(bus.cs_asserts == 1U && bus.cs_releases == 1U &&
bus.cs_active == 0U && bus.transfers == 2U);
/* Status и последний физический byte разрешены строгими shape/bounds. */
reset_bus(&bus);
request.tx[0] = 0x05U;
request.rx_length = 1U;
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_OK);
request.tx[0] = 0x03U;
request.tx[1] = 0U;
request.tx[2] = 0U;
request.tx[3] = 0xFFU;
request.tx_length = 4U;
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_OK);
request.rx_length = 2U;
/* Последний адрес плюс два байта обязан отклониться до активации CS. */
CHECK(spi_nor_command_service_validate(&device, &request) ==
SPI_NOR_COMMAND_SERVICE_INVALID);
/* Весь mutating/state-changing набор и неизвестный opcode fail-closed. */
request.tx_length = 1U;
request.rx_length = 1U;
/* Список включает все опасные семейства opcode и произвольный unknown FF. */
for (index = 0U; index < sizeof(blocked); index++) {
request.tx[0] = blocked[index];
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_BLOCKED);
CHECK(bus.cs_active == 0U);
}
/* Неверные длины не обращаются к шине. */
reset_bus(&bus);
request.tx[0] = 0x9FU;
request.tx_length = 1U;
request.rx_length = SPI_NOR_COMMAND_SERVICE_MAX_RX_BYTES + 1U;
/* Проверяем не только статус, но и полное отсутствие физического I/O. */
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_INVALID);
CHECK(bus.transfers == 0U && bus.cs_asserts == 0U);
/* CS обязан стать inactive при ошибке TX, ошибке RX и timeout RX. */
request.rx_length = 3U;
/* Одинаковая гарантия CS применяется к первому и второму transfer. */
for (index = 1U; index <= 2U; index++) {
reset_bus(&bus);
bus.fail_transfer_number = (uint8_t)index;
bus.fail_on_transfer = SPI_NOR_IO_ERROR;
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_IO_ERROR);
CHECK(bus.cs_releases == 1U && bus.cs_active == 0U &&
response.rx_length == 0U);
}
reset_bus(&bus);
bus.fail_transfer_number = 2U;
bus.fail_on_transfer = SPI_NOR_IO_TIMEOUT;
/* Timeout имеет отдельный статус, но ту же очистку RX и освобождение CS. */
CHECK(spi_nor_command_service_process(&device, &request, &response) ==
SPI_NOR_COMMAND_SERVICE_TIMEOUT);
CHECK(bus.cs_releases == 1U && bus.cs_active == 0U);
(void)printf("SPI NOR command service: %u checks, %u failures\n",
checks, failures);
return failures == 0U ? 0 : 1;
}

View File

@@ -0,0 +1,74 @@
#include "spi_nor_read_service.h"
#include <stdio.h>
#include <string.h>
static unsigned checks;
static unsigned failures;
static uint8_t image[256];
static spi_nor_read_service_status_t forced_status = SPI_NOR_READ_SERVICE_OK;
#define CHECK(value) do { checks++; if (!(value)) { failures++; } } while (0)
/* Mock копирует детерминированный образ и умеет вернуть физическую ошибку. */
static spi_nor_read_service_status_t mock_read(void *context, uint32_t address,
void *data, size_t size)
{
(void)context;
if (forced_status != SPI_NOR_READ_SERVICE_OK) {
memset(data, 0xA5, size);
return forced_status;
}
memcpy(data, &image[address], size);
return SPI_NOR_READ_SERVICE_OK;
}
int main(void)
{
spi_nor_read_service_t service;
spi_nor_read_service_request_t request;
spi_nor_read_service_response_t response;
unsigned index;
for (index = 0U; index < sizeof(image); index++) {
image[index] = (uint8_t)index;
}
CHECK(spi_nor_read_service_init(&service, NULL, mock_read,
sizeof(image)) == SPI_NOR_READ_SERVICE_OK);
request.address = 0U;
request.length = 1U;
request.sequence = 7U;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_OK);
CHECK(response.data[0] == 0U && response.sequence == 7U);
/* Последний физический байт является допустимой включительной границей. */
request.address = sizeof(image) - 1U;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_OK);
CHECK(response.data[0] == 0xFFU);
request.address = sizeof(image);
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_INVALID);
request.address = sizeof(image) - 32U;
request.length = 33U;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_INVALID);
request.address = 0U;
request.length = 0U;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_INVALID);
request.length = SPI_NOR_READ_SERVICE_MAX_BYTES + 1U;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_INVALID);
request.length = 8U;
forced_status = SPI_NOR_READ_SERVICE_IO_ERROR;
CHECK(spi_nor_read_service_process(&service, &request, &response) ==
SPI_NOR_READ_SERVICE_IO_ERROR);
CHECK(response.length == 0U && response.data[0] == 0U);
(void)printf("SPI NOR read service: %u checks, %u failures\n", checks, failures);
return failures == 0U ? 0 : 1;
}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More