13 KiB
Протокол синхронизации файлов Device ↔ Server
Принцип
Девайс сваливает всё содержимое директории в один запрос. Сервер делает то же самое в ответе. Каждый берёт что нужно.
"Удаление только явное; отсутствие файла в пакете не означает удаление."
Оба конца применяют изменения и приводят состояние к более новым версиям файлов. Отсутствие файла в запросе означает «сервер не удаляет этот файл». Удаление — активное: файл удаляется только если пришёл блок файла с пустым содержимым.
- Транспорт: HTTP
- Инициатор синхронизации: устройство (девайс)
- Количество HTTP roundtrip'ов: 1
- Файлы: до 5 KB, типично 300–500 байт, ~10–20 файлов
- Дата модификации: хранится внутри файла как gcode-команда
P177 Updated at <human_date> U<unix_timestamp>
Формат пакета
Одинаков в обе стороны (запрос и ответ). Обычный текст (text/plain; charset=utf-8).
---FILE: <filename>---
<content>
---END---
---FILE: <filename>---
<content>
---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: <filename>---
---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/<ESP_ID>
Требования к ответу сервера
- Успешный синк: HTTP
2xx. - Формат тела ответа: тот же пакетный текстовый формат (
---FILE: ...---+---END---). - Разрешён пустой успешный ответ:
HTTP 200Content-Length: 0- тело отсутствует
Это означает: у сервера нет файлов для отправки девайсу (или итоговое состояние уже совпадает).
Для девайса это не ошибка HTTP и не таймаут, а валидный сценарий:
- считать синк успешным;
- интерпретировать ответ как пустой список файлов;
- применить изменения из ответа (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
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'ов, нет решений — только применять изменения.
Логика сервера
# Псевдокод
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 <human_date>— читаемая дата в форматеDD.MM.YYYY, HH:MM:SSU<unix_timestamp>— unix timestamp (секунды с 1970-01-01 UTC), по которому сервер и сравнивает версии- Если строка
P177отсутствует или не содержитU<digits>— файл считается самым старым (timestamp = 0) - При создании/изменении файла — обновить значение
P177
Парсинг: регулярное выражение ^P177\b.*\bU(\d+).*$ (MULTILINE) — захватывает число после U.
Реализация на стороне девайса (C, ESP32)
Парсинг ответного пакета
// Пример парсинга одного блока
// Ищем "---FILE: <name>---\n" ...содержимое... "---END---\n"
// Если содержимое пустое (сразу ---END---) → удалить файл
Алгоритм чтения файлов и формирования пакета
// 1. sd_list_dir("/programs", ...)
// 2. Для каждого файла: sd_read_file(path, buffer, ...)
// 3. Записать в HTTP-тело: "---FILE: <name>---\n<content>\n---END---\n"
// 4. Использовать chunked transfer или накопить в буфере
Для 10–20 файлов по 500 байт пакет ~10–15 KB — умещается в буфер RAM ESP32.