- Change transport from HTTP to HTTPS only (code enforces https:// check) - Replace "write to temp file on SD" with PSRAM buffer in device logic - Update footer note about buffer: PSRAM, not internal RAM
299 lines
13 KiB
Markdown
299 lines
13 KiB
Markdown
# Протокол синхронизации файлов Device ↔ Server
|
|
|
|
## Принцип
|
|
|
|
> Девайс сваливает всё содержимое директории в один запрос. Сервер делает то же самое в ответе. Каждый берёт что нужно.
|
|
|
|
**"Удаление только явное; отсутствие файла в пакете не означает удаление."**
|
|
|
|
Оба конца применяют изменения и приводят состояние к более новым версиям файлов.
|
|
Отсутствие файла в запросе означает **«сервер не удаляет этот файл»**.
|
|
Удаление — **активное**: файл удаляется только если пришёл блок файла с пустым содержимым.
|
|
|
|
- **Транспорт:** HTTPS (только HTTPS; 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
|
|
|
|
```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 <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)
|
|
|
|
### Парсинг ответного пакета
|
|
|
|
```c
|
|
// Пример парсинга одного блока
|
|
// Ищем "---FILE: <name>---\n" ...содержимое... "---END---\n"
|
|
// Если содержимое пустое (сразу ---END---) → удалить файл
|
|
```
|
|
|
|
### Алгоритм чтения файлов и формирования пакета
|
|
|
|
```c
|
|
// 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. Как запрос, так и ответный пакет накапливаются в PSRAM-буфере (до 32 KB), а не во внутреннем RAM ESP32.
|