Add embedded storage drivers and extend firmware metadata
This commit is contained in:
10
c/sd-file-browser/CMakeLists.txt
Normal file
10
c/sd-file-browser/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(sd_file_browser C)
|
||||
add_library(sd_file_browser STATIC Src/sd_file_browser.c)
|
||||
target_include_directories(sd_file_browser PUBLIC Inc)
|
||||
set_target_properties(sd_file_browser PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||
|
||||
enable_testing()
|
||||
add_executable(test_sd_file_browser Tests/test_sd_file_browser.c)
|
||||
target_link_libraries(test_sd_file_browser PRIVATE sd_file_browser)
|
||||
add_test(NAME test_sd_file_browser COMMAND test_sd_file_browser)
|
||||
82
c/sd-file-browser/Inc/sd_file_browser.h
Normal file
82
c/sd-file-browser/Inc/sd_file_browser.h
Normal file
@@ -0,0 +1,82 @@
|
||||
#ifndef SD_FILE_BROWSER_H
|
||||
#define SD_FILE_BROWSER_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Ограничения одинаковы для portable core, Modbus mailbox и GUI. */
|
||||
#define SD_FILE_BROWSER_MAX_PATH 96U
|
||||
#define SD_FILE_BROWSER_MAX_NAME 48U
|
||||
#define SD_FILE_BROWSER_MAX_PAGE_ITEMS 3U
|
||||
#define SD_FILE_BROWSER_MAX_CHUNK_BYTES 64U
|
||||
|
||||
typedef enum {
|
||||
/* Успех означает полностью сформированную страницу, а не только open. */
|
||||
SD_FILE_BROWSER_OK = 0,
|
||||
/* BUSY оставляет retry политике приложения и ничего не пишет на носитель. */
|
||||
SD_FILE_BROWSER_BUSY,
|
||||
SD_FILE_BROWSER_NOT_READY,
|
||||
SD_FILE_BROWSER_IO_ERROR,
|
||||
SD_FILE_BROWSER_INVALID_PATH,
|
||||
SD_FILE_BROWSER_INVALID_CURSOR,
|
||||
SD_FILE_BROWSER_NAME_TOO_LONG,
|
||||
SD_FILE_BROWSER_FILE_CHANGED,
|
||||
SD_FILE_BROWSER_NOT_LOG_FILE
|
||||
} SdFileBrowserStatus;
|
||||
|
||||
typedef struct {
|
||||
/* Имя всегда завершается нулём и не содержит родительского пути. */
|
||||
char name[SD_FILE_BROWSER_MAX_NAME + 1U];
|
||||
uint32_t size;
|
||||
uint16_t modified_date;
|
||||
uint16_t modified_time;
|
||||
uint8_t is_directory;
|
||||
} SdFileBrowserEntry;
|
||||
|
||||
typedef struct {
|
||||
/* Ответ имеет фиксированную RAM-ёмкость и никогда не выделяет heap. */
|
||||
SdFileBrowserEntry items[SD_FILE_BROWSER_MAX_PAGE_ITEMS];
|
||||
uint16_t next_offset;
|
||||
uint8_t count;
|
||||
uint8_t has_more;
|
||||
} SdFileBrowserPage;
|
||||
|
||||
typedef struct {
|
||||
/* Metadata повторяется в каждом chunk и защищает bridge от склейки разных версий файла. */
|
||||
uint32_t total_size;
|
||||
uint32_t offset;
|
||||
uint16_t modified_date;
|
||||
uint16_t modified_time;
|
||||
uint8_t length;
|
||||
uint8_t end_of_file;
|
||||
uint8_t data[SD_FILE_BROWSER_MAX_CHUNK_BYTES];
|
||||
} SdFileBrowserChunk;
|
||||
|
||||
typedef SdFileBrowserStatus (*SdFileBrowserListPageFn)(
|
||||
void *context, const char *path, uint16_t offset, uint8_t limit,
|
||||
SdFileBrowserPage *page);
|
||||
|
||||
typedef struct {
|
||||
/* Context принадлежит платформе; portable core его не освобождает. */
|
||||
void *context;
|
||||
SdFileBrowserListPageFn list_page;
|
||||
} SdFileBrowser;
|
||||
|
||||
/* Принимается только логический путь от корня карты: "/" или "/dir/file".
|
||||
* Проверка не исправляет вход: неканонические строки отклоняются fail-closed. */
|
||||
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path);
|
||||
|
||||
/* Разрешает только legacy TEMPLOG.MD или суточный temperature_*.md внутри
|
||||
* штатного корня niceOne/mounth; служебные JSON/прошивки сюда не попадают. */
|
||||
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path);
|
||||
|
||||
/* Разрешает только сырой образ .bin/.fw внутри каталога niceOne/firmware.
|
||||
* Каталог отделён от журналов, чтобы прошивка и логи не смешивались. */
|
||||
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path);
|
||||
|
||||
/* Вызов делегирует порту ровно одну ограниченную страницу каталога.
|
||||
* Output предварительно очищается, поэтому ошибка не публикует старые имена. */
|
||||
SdFileBrowserStatus SdFileBrowser_ListPage(
|
||||
const SdFileBrowser *browser, const char *path, uint16_t offset,
|
||||
uint8_t limit, SdFileBrowserPage *page);
|
||||
|
||||
#endif /* SD_FILE_BROWSER_H */
|
||||
30
c/sd-file-browser/README.md
Normal file
30
c/sd-file-browser/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# SD File Browser
|
||||
|
||||
Небольшое переносимое read-only ядро для постраничного просмотра каталогов.
|
||||
Оно не зависит от STM32 HAL, FatFs, Modbus или GUI. Платформа передаёт callback
|
||||
`list_page`; ядро проверяет путь, предел страницы и очищает ответ перед вызовом.
|
||||
|
||||
Логический путь всегда начинается с `/`. Запрещены `..`, `.`, пустые части,
|
||||
обратная косая черта, двоеточие, управляющие символы, путь от ОС и компоненты
|
||||
длиннее 48 ASCII-байт. Максимум — три элемента на страницу и 95 байт пути.
|
||||
|
||||
FatFs-порт сканирует позднюю страницу пошагово: один вызов service выполняет не
|
||||
более шестнадцати `readdir`. Поэтому любой `uint16` offset доступен без длинного
|
||||
блокирующего вызова main loop. Между страницами `DIR` закрыт. Если логгер или
|
||||
backup получает работу во время сканирования, приложение отменяет viewer,
|
||||
освобождает `DIR` и возвращает `logger busy`; writer всегда имеет приоритет.
|
||||
|
||||
Для переноса реализуйте `SdFileBrowserListPageFn`, обеспечьте read-only открытие
|
||||
каталога, конечные тайм-ауты носителя и закройте cursor при извлечении карты.
|
||||
|
||||
Порт также предоставляет `SdFileBrowserFatFs_ReadLogChunk`: он разрешает только
|
||||
legacy `/TEMPLOG.MD` и `temperature_*.md` внутри `/niceOne/mounth`, читает не
|
||||
более 64 байт и закрывает `FIL` до возврата. Первый ответ публикует размер и FAT
|
||||
date/time; каждый следующий запрос обязан повторить их. Замена, обрезание или
|
||||
изменение файла возвращает `SD_FILE_BROWSER_FILE_CHANGED`. В API нет функций
|
||||
записи, удаления, rename, выполнения Markdown или чтения служебных каталогов.
|
||||
|
||||
|
||||
## Shared source
|
||||
|
||||
Canonical source: `templates/c/sd-file-browser`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||
177
c/sd-file-browser/Src/sd_file_browser.c
Normal file
177
c/sd-file-browser/Src/sd_file_browser.c
Normal file
@@ -0,0 +1,177 @@
|
||||
#include "sd_file_browser.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
static uint8_t has_temperature_name(const char *name)
|
||||
{
|
||||
const size_t length = strlen(name);
|
||||
const char prefix[] = "temperature_";
|
||||
|
||||
return (uint8_t)((length > (sizeof(prefix) - 1U + 3U)) &&
|
||||
(strncmp(name, prefix, sizeof(prefix) - 1U) == 0) &&
|
||||
(strcmp(name + length - 3U, ".md") == 0));
|
||||
}
|
||||
|
||||
/* Сравнение расширения без учёта регистра для короткого суффикса ASCII. */
|
||||
static uint8_t has_suffix_ci(const char *name, const char *suffix)
|
||||
{
|
||||
const size_t name_length = strlen(name);
|
||||
const size_t suffix_length = strlen(suffix);
|
||||
size_t index;
|
||||
|
||||
if (name_length <= suffix_length) {
|
||||
return 0U;
|
||||
}
|
||||
for (index = 0U; index < suffix_length; ++index) {
|
||||
char left = name[name_length - suffix_length + index];
|
||||
char right = suffix[index];
|
||||
|
||||
if ((left >= 'A') && (left <= 'Z')) {
|
||||
left = (char)(left - 'A' + 'a');
|
||||
}
|
||||
if ((right >= 'A') && (right <= 'Z')) {
|
||||
right = (char)(right - 'A' + 'a');
|
||||
}
|
||||
if (left != right) {
|
||||
return 0U;
|
||||
}
|
||||
}
|
||||
return 1U;
|
||||
}
|
||||
|
||||
/* Сырой образ прошивки: .bin или .fw. Intel HEX здесь не принимается — его
|
||||
* адреса лежат внутри файла и требуют разбора, недоступного на этом этапе. */
|
||||
static uint8_t has_firmware_name(const char *name)
|
||||
{
|
||||
return (uint8_t)((has_suffix_ci(name, ".bin") != 0U) ||
|
||||
(has_suffix_ci(name, ".fw") != 0U));
|
||||
}
|
||||
|
||||
/* Разрешены печатные ASCII-имена без разделителей ОС и управляющих байтов. */
|
||||
static uint8_t path_character_is_safe(char character)
|
||||
{
|
||||
unsigned char value = (unsigned char)character;
|
||||
return (uint8_t)((value >= 0x20U) && (value <= 0x7EU) &&
|
||||
(character != '\\') && (character != ':'));
|
||||
}
|
||||
|
||||
SdFileBrowserStatus SdFileBrowser_ValidatePath(const char *path)
|
||||
{
|
||||
size_t length;
|
||||
size_t component_start;
|
||||
size_t index;
|
||||
|
||||
/* strlen вызывается только после явной проверки внешнего указателя. */
|
||||
if (path == NULL) {
|
||||
return SD_FILE_BROWSER_INVALID_PATH;
|
||||
}
|
||||
length = strlen(path);
|
||||
if ((length == 0U) || (length >= SD_FILE_BROWSER_MAX_PATH) ||
|
||||
(path[0] != '/')) {
|
||||
/* Абсолютный путь накопителя и усечённый путь fail-closed отклоняются. */
|
||||
return SD_FILE_BROWSER_INVALID_PATH;
|
||||
}
|
||||
if (length == 1U) {
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
/* Компоненты анализируются за один проход, включая завершающий ноль. */
|
||||
component_start = 1U;
|
||||
for (index = 1U; index <= length; ++index) {
|
||||
char character = path[index];
|
||||
if ((character == '/') || (character == '\0')) {
|
||||
size_t component_length = index - component_start;
|
||||
if ((component_length == 0U) ||
|
||||
(component_length > SD_FILE_BROWSER_MAX_NAME) ||
|
||||
((component_length == 1U) &&
|
||||
(path[component_start] == '.')) ||
|
||||
((component_length == 2U) &&
|
||||
(path[component_start] == '.') &&
|
||||
(path[component_start + 1U] == '.'))) {
|
||||
/* Пустые, точечные и слишком длинные компоненты не нормализуются. */
|
||||
return SD_FILE_BROWSER_INVALID_PATH;
|
||||
}
|
||||
component_start = index + 1U;
|
||||
} else if (path_character_is_safe(character) == 0U) {
|
||||
/* Кодировка текущего FatFs-порта ASCII; байты UTF-8 не угадываются. */
|
||||
return SD_FILE_BROWSER_INVALID_PATH;
|
||||
}
|
||||
}
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
|
||||
SdFileBrowserStatus SdFileBrowser_ValidateLogFilePath(const char *path)
|
||||
{
|
||||
const char root[] = "/niceOne/mounth/";
|
||||
const char *relative;
|
||||
const char *separator;
|
||||
const char *name;
|
||||
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
|
||||
|
||||
if (status != SD_FILE_BROWSER_OK) {
|
||||
return status;
|
||||
}
|
||||
/* TEMPLOG.MD сохранён только для старых карт, где журнал лежал в корне. */
|
||||
if (strcmp(path, "/TEMPLOG.MD") == 0) {
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
relative = path + sizeof(root) - 1U;
|
||||
separator = strchr(relative, '/');
|
||||
/* Штатное дерево содержит ровно месяц и имя; обход дополнительных уровней запрещён. */
|
||||
if ((separator == NULL) || (separator == relative) ||
|
||||
(strchr(separator + 1U, '/') != NULL)) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
name = strrchr(path, '/');
|
||||
if ((name == NULL) || (has_temperature_name(name + 1U) == 0U)) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
|
||||
SdFileBrowserStatus SdFileBrowser_ValidateFirmwarePath(const char *path)
|
||||
{
|
||||
const char root[] = "/niceOne/firmware/";
|
||||
const char *name;
|
||||
SdFileBrowserStatus status = SdFileBrowser_ValidatePath(path);
|
||||
|
||||
if (status != SD_FILE_BROWSER_OK) {
|
||||
return status;
|
||||
}
|
||||
/* Образы лежат ровно в одном каталоге; вложенные уровни не обходятся. */
|
||||
if (strncmp(path, root, sizeof(root) - 1U) != 0) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
name = path + sizeof(root) - 1U;
|
||||
if ((name[0] == '\0') || (strchr(name, '/') != NULL)) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
if (has_firmware_name(name) == 0U) {
|
||||
return SD_FILE_BROWSER_NOT_LOG_FILE;
|
||||
}
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
|
||||
SdFileBrowserStatus SdFileBrowser_ListPage(
|
||||
const SdFileBrowser *browser, const char *path, uint16_t offset,
|
||||
uint8_t limit, SdFileBrowserPage *page)
|
||||
{
|
||||
SdFileBrowserStatus status;
|
||||
|
||||
/* limit проверяется до очистки, чтобы invalid call не трогал чужую память. */
|
||||
if ((browser == NULL) || (browser->list_page == NULL) || (page == NULL) ||
|
||||
(limit == 0U) || (limit > SD_FILE_BROWSER_MAX_PAGE_ITEMS)) {
|
||||
return SD_FILE_BROWSER_INVALID_CURSOR;
|
||||
}
|
||||
status = SdFileBrowser_ValidatePath(path);
|
||||
if (status != SD_FILE_BROWSER_OK) {
|
||||
return status;
|
||||
}
|
||||
/* Нулевой tail исключает утечку содержимого предыдущего Modbus-ответа. */
|
||||
memset(page, 0, sizeof(*page));
|
||||
/* Portable слой не знает FatFs/HAL и передаёт только проверенный запрос. */
|
||||
return browser->list_page(browser->context, path, offset, limit, page);
|
||||
}
|
||||
67
c/sd-file-browser/Tests/test_sd_file_browser.c
Normal file
67
c/sd-file-browser/Tests/test_sd_file_browser.c
Normal file
@@ -0,0 +1,67 @@
|
||||
#include "sd_file_browser.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static unsigned checks;
|
||||
static unsigned failures;
|
||||
static unsigned calls;
|
||||
|
||||
#define CHECK(condition) do { ++checks; if (!(condition)) ++failures; } while (0)
|
||||
|
||||
/* Mock подтверждает, что core передаёт только проверенный bounded запрос. */
|
||||
static SdFileBrowserStatus mock_list(
|
||||
void *context, const char *path, uint16_t offset, uint8_t limit,
|
||||
SdFileBrowserPage *page)
|
||||
{
|
||||
(void)context;
|
||||
++calls;
|
||||
CHECK(strcmp(path, "/niceOne") == 0);
|
||||
CHECK(offset == 3U);
|
||||
CHECK(limit == 2U);
|
||||
memcpy(page->items[0].name, "July", sizeof("July"));
|
||||
page->items[0].is_directory = 1U;
|
||||
page->count = 1U;
|
||||
page->next_offset = 4U;
|
||||
return SD_FILE_BROWSER_OK;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static const char *const invalid[] = {
|
||||
"", "niceOne", "/../secret", "/./logs", "/a//b", "/a\\b",
|
||||
"/a:b", "/trailing/"
|
||||
};
|
||||
SdFileBrowser browser = { NULL, mock_list };
|
||||
SdFileBrowserPage page;
|
||||
unsigned index;
|
||||
|
||||
CHECK(SdFileBrowser_ValidatePath("/") == SD_FILE_BROWSER_OK);
|
||||
CHECK(SdFileBrowser_ValidateLogFilePath("/TEMPLOG.MD") == SD_FILE_BROWSER_OK);
|
||||
CHECK(SdFileBrowser_ValidateLogFilePath(
|
||||
"/niceOne/mounth/2026-07_July/temperature_2026-07-18_10-00-00.md") ==
|
||||
SD_FILE_BROWSER_OK);
|
||||
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/settings/private.json") ==
|
||||
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/fw.bin") ==
|
||||
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||
CHECK(SdFileBrowser_ValidateLogFilePath("/niceOne/mounth/temperature_bad.md") ==
|
||||
SD_FILE_BROWSER_NOT_LOG_FILE);
|
||||
CHECK(SdFileBrowser_ValidatePath("/niceOne/2026-07_July") ==
|
||||
SD_FILE_BROWSER_OK);
|
||||
for (index = 0U; index < sizeof(invalid) / sizeof(invalid[0]); ++index) {
|
||||
CHECK(SdFileBrowser_ValidatePath(invalid[index]) ==
|
||||
SD_FILE_BROWSER_INVALID_PATH);
|
||||
}
|
||||
memset(&page, 0xA5, sizeof(page));
|
||||
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 3U, 2U, &page) ==
|
||||
SD_FILE_BROWSER_OK);
|
||||
CHECK(calls == 1U);
|
||||
CHECK(page.count == 1U);
|
||||
CHECK(strcmp(page.items[0].name, "July") == 0);
|
||||
CHECK(SdFileBrowser_ListPage(&browser, "/niceOne", 0U, 4U, &page) ==
|
||||
SD_FILE_BROWSER_INVALID_CURSOR);
|
||||
CHECK(calls == 1U);
|
||||
(void)printf("SD browser core: %u checks, %u failures\n", checks, failures);
|
||||
return failures == 0U ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user