Files
ukuetis-docs/http_program_sync_protocol.md

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

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:SS
  • U<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.