# Протокол синхронизации файлов Device ↔ Server ## Принцип > Девайс сваливает всё содержимое директории в один запрос. Сервер делает то же самое в ответе. Каждый берёт что нужно. **"Удаление только явное; отсутствие файла в пакете не означает удаление."** Оба конца применяют изменения и приводят состояние к более новым версиям файлов. Отсутствие файла в запросе означает **«сервер не удаляет этот файл»**. Удаление — **активное**: файл удаляется только если пришёл блок файла с пустым содержимым. - **Транспорт:** HTTPS (только HTTPS; 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. Получить ответный пакет, накопить полностью в PSRAM-буфере (heap_caps_malloc MALLOC_CAP_SPIRAM), чтобы не использовать внутренний RAM 5. Для каждого файла в ответе: - если content пустой → удалить локальный файл - иначе записать на SD (create or overwrite) ``` Минимум логики на девайсе: нет сравнения 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. Как запрос, так и ответный пакет накапливаются в PSRAM-буфере (до 32 KB), а не во внутреннем RAM ESP32.