From 3e88fc380c13dd8bb52efea7770b0af859cc9e0d Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 12 Mar 2026 02:46:09 +0300 Subject: [PATCH] Add http_program_sync_protocol.md --- http_program_sync_protocol.md | 299 ++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 http_program_sync_protocol.md diff --git a/http_program_sync_protocol.md b/http_program_sync_protocol.md new file mode 100644 index 0000000..fe73fbb --- /dev/null +++ b/http_program_sync_protocol.md @@ -0,0 +1,299 @@ +# Протокол синхронизации файлов Device ↔ Server + +## Принцип + +> Девайс сваливает всё содержимое директории в один запрос. Сервер делает то же самое в ответе. Каждый берёт что нужно. + +**"Удаление только явное; отсутствие файла в пакете не означает удаление."** + +Оба конца применяют изменения и приводят состояние к более новым версиям файлов. +Отсутствие файла в запросе означает **«сервер не удаляет этот файл»**. +Удаление — **активное**: файл удаляется только если пришёл блок файла с пустым содержимым. + +- **Транспорт:** HTTP +- **Инициатор синхронизации:** устройство (девайс) +- **Количество HTTP roundtrip'ов:** 1 +- **Файлы:** до 5 KB, типично 300–500 байт, ~10–20 файлов +- **Дата модификации:** хранится внутри файла как gcode-команда `P177 Updated at U` + +--- + +## Формат пакета + +Одинаков в обе стороны (запрос и ответ). Обычный текст (`text/plain; charset=utf-8`). + +``` +---FILE: --- + +---END--- +---FILE: --- + +---END--- +``` + +### Пример пакета + +``` +---FILE: 076c3156-d339-41f7-abab-dbb78305adad.txt--- +P001 Высокий с сушкой 1ч +P177 Updated at 24.02.2025, 16:00:00 U1740412800 +K203 Move to S70 with speed R50 +K107 Hold on S70 for M60 minutes +---END--- +---FILE: 81483dba-698a-45e2-b65a-6801fa8daf27.txt--- +P001 Низкий с сушкой 1ч +P177 Updated at 27.01.2025, 17:46:40 U1738000000 +K203 Move to S80 with speed R70 +---END--- +``` + +Отдельных маркеров удаления нет. Удаление задаётся обычным блоком файла с пустым `content`: + +``` +---FILE: --- + +---END--- +``` + +--- + +## HTTP endpoint + +``` +POST /sync/programs/{panel_id} +Content-Type: text/plain; charset=utf-8 +``` + +Где `{panel_id}` — идентификатор панели (ESP ID), например: + +``` +POST /sync/programs/UP-2D1693358FEC +``` + +На девайсе endpoint формируется как: + +- base URL: `https://ucontroller.asakusa-lab.cc` +- panel ID: `esp_id.c_str()` +- итоговый URL: `https://ucontroller.asakusa-lab.cc/sync/programs/` + +### Требования к ответу сервера + +- Успешный синк: HTTP `2xx`. +- Формат тела ответа: тот же пакетный текстовый формат (`---FILE: ...---` + `---END---`). +- Разрешён **пустой успешный ответ**: + - `HTTP 200` + - `Content-Length: 0` + - тело отсутствует + +Это означает: **у сервера нет файлов для отправки девайсу** (или итоговое состояние уже совпадает). + +Для девайса это не ошибка HTTP и не таймаут, а валидный сценарий: + +1. считать синк успешным; +2. интерпретировать ответ как пустой список файлов; +3. применить изменения из ответа (create/update/delete по блокам файлов). + +### Обработка `Content-Length` на девайсе + +На стороне ESP32 перед чтением stream нужно проверять `Content-Length`: + +- если `Content-Length == 0` → **не входить в цикл чтения тела**; +- если `Content-Length != 0` или неизвестен (`-1`) → читать stream по обычным правилам и таймауту idle. + +Причина: при keep-alive соединении `client->connected()` может оставаться `true` даже при пустом теле, и без проверки `Content-Length` это приводит к ложному таймауту ожидания данных. + +### Быстрая проверка через curl + +```bash +curl -v -k \ + -H "Content-Type: text/plain; charset=utf-8" \ + --data '' \ + https://ucontroller.asakusa-lab.cc/sync/programs +``` + +Ожидаемый валидный результат для пустого ответа: + +- `HTTP/2 200` (или `HTTP/1.1 200`) +- `content-length: 0` + +--- + +--- + +## Логика девайса + +``` +1. Прочитать локальные изменения в /programs/ на SD-карте +2. Сформировать пакет изменений + 3. Отправить POST /sync/programs/{panel_id} +4. Получить ответный пакет, записать полностью во временный файл, чтобы не использовать буферы в рам + 5. Для каждого файла в ответе: + - если content пустой → удалить локальный файл + - иначе записать на SD (create or overwrite) +6. удалить временный файл +``` + +Минимум логики на девайсе: нет сравнения timestamp'ов, нет решений — только применять изменения. + +--- + +## Логика сервера + +```python +# Псевдокод + +device_files = parse_packet(request.body) # {filename: content} +server_files = read_directory("/programs/") # {filename: content} + +# 1. Применить изменения с девайса +for filename, content in device_files: + if content == "": + if filename in server_files: + delete(filename) + continue + + # Если на сервере tombstone (пустой файл — мягко удалён через API), + # не перезаписывать его: tombstone будет отправлен девайсу как инструкция удаления. + if filename in server_files and server_files[filename] == "": + continue + + if filename not in server_files: + save(filename, content) # новый файл + else: + device_ts = extract_p177(content) # или 0 если нет P177 + server_ts = extract_p177(server_files[filename]) + if device_ts >= server_ts: + save(filename, content) # девайс новее + +# 2. Собрать ответ для девайса +server_files = read_directory("/programs/") # перечитать после изменений + +response_files = {} +tombstones_to_purge = [] +for filename, server_content in server_files.items(): + if filename not in device_files: + response_files[filename] = server_content # отдать файл девайсу + continue + + device_content = device_files[filename] + if device_content == "": + continue + + # Tombstone на сервере → отправить девайсу пустой content (инструкция удалить), + # затем удалить tombstone в background. + if server_content == "": + response_files[filename] = "" + tombstones_to_purge.append(filename) + continue + + server_ts = extract_p177(server_content) + device_ts = extract_p177(device_content) + if server_ts > device_ts: + response_files[filename] = server_content # сервер новее — обновить девайс + +# После отправки ответа — физически удалить tombstone-файлы +background_purge(tombstones_to_purge) + +return build_packet(response_files) +``` + +--- + +## Tombstone-механизм мягкого удаления + +Когда файл удаляется через frontend API (`DELETE /api/programs/{panel_id}/{program_id}`), сервер **не удаляет файл физически**, а записывает пустое содержимое — tombstone. Это нужно, чтобы при следующем синке девайс получил инструкцию удалить файл у себя. + +Цикл удаления: + +``` +1. Frontend вызывает DELETE /api/programs/{panel_id}/{program_id} +2. Сервер записывает пустой файл (tombstone) на диск +3. Девайс при синке присылает этот файл со своим content +4. Сервер видит tombstone → включает файл в ответ с пустым content +5. После отправки ответа — физически удаляет tombstone (в background) +6. Девайс получает пустой content → удаляет файл у себя +``` + +Пока tombstone существует на сервере: +- файл **не виден** в frontend API (`GET /api/programs/{panel_id}`) — скрыт от листинга; +- если девайс пришлёт свой content этого файла — сервер **не перезапишет tombstone**; +- tombstone будет включён в ответ синка как пустой content → инструкция удаления для девайса. + +--- + +## Таблица сценариев + +| Сценарий | Действие сервера | Действие девайса | +|---|---|---| +| Файл только у девайса | Сохраняет, в ответ не включает | — | +| Файл только на сервере | Не трогает, включает в ответ | Сохраняет | +| Файл новее у девайса | Заменяет серверную версию, в ответ не включает | — | +| Файл новее на сервере | Включает в ответ | Обновляет | +| Одинаковые версии | В ответ не включает | — | +| Файл удалён на девайсе (пустой content) | Удаляет у себя | — | +| Tombstone на сервере (файл удалён через API) | Отправляет пустой content, удаляет tombstone в background | Удаляет | +| P177 отсутствует | timestamp = 0, считается самым старым | — | + +Ключевое правило: **удаление только явное (пустой content)**; отсутствие файла в запросе не удаляет его на сервере. + +--- + +## Диаграмма последовательности + +``` +Device Server + | | + |-- POST /sync/programs/{panel_id} -->| + | body: изменения по файлам | + | |-- парсит P177 из каждого файла + | |-- применяет upsert + | |-- удаляет только пустые content + | |-- tombstone блокирует запись от девайса + | |-- собирает ответ: серверные файлы для девайса + | |-- tombstone → пустой content в ответ + |<-- 200 OK ---------------------| + | body: изменения для девайса | + | |-- [background] физически удаляет tombstone-файлы + |-- применяет изменения из ответа| +``` + +--- + +## Формат P177 + +Дата хранится как строка в теле gcode-файла: + +``` +P177 Updated at 24.02.2025, 16:00:00 U1740412800 +``` + +- `P177` — код команды "дата последнего изменения" +- `Updated at ` — читаемая дата в формате `DD.MM.YYYY, HH:MM:SS` +- `U` — unix timestamp (секунды с 1970-01-01 UTC), по которому сервер и сравнивает версии +- Если строка `P177` отсутствует или не содержит `U` — файл считается самым старым (timestamp = 0) +- При создании/изменении файла — обновить значение `P177` + +Парсинг: регулярное выражение `^P177\b.*\bU(\d+).*$` (MULTILINE) — захватывает число после `U`. + +--- + +## Реализация на стороне девайса (C, ESP32) + +### Парсинг ответного пакета + +```c +// Пример парсинга одного блока +// Ищем "---FILE: ---\n" ...содержимое... "---END---\n" +// Если содержимое пустое (сразу ---END---) → удалить файл +``` + +### Алгоритм чтения файлов и формирования пакета + +```c +// 1. sd_list_dir("/programs", ...) +// 2. Для каждого файла: sd_read_file(path, buffer, ...) +// 3. Записать в HTTP-тело: "---FILE: ---\n\n---END---\n" +// 4. Использовать chunked transfer или накопить в буфере +``` + +Для 10–20 файлов по 500 байт пакет ~10–15 KB — умещается в буфер RAM ESP32.