diff --git a/.claude/plans/ota-phase1.md b/.claude/plans/ota-phase1.md new file mode 100644 index 0000000..ecd6aab --- /dev/null +++ b/.claude/plans/ota-phase1.md @@ -0,0 +1,116 @@ +# План реализации Phase 1: Серверная часть OTA обновлений прошивки + +## Цель +Создать серверный каталог прошивок, API для управления ими, и механизм push-OTA на устройства. + +## Изменения + +### 1. Конфигурация (`server/SHServ/config.php`) +- Добавить поле `firmwares_dir` — путь к директории с ZIP-архивами прошивок. +- Значение по умолчанию: `__DIR__ . '/../../firmwares'`. +- Директория должна автоматически создаваться при старте (в `FirmwareCatalog::__construct`). + +### 2. Каталог прошивок (`server/SHServ/Tools/FirmwareCatalog.php`) +Новый класс, отвечающий за сканирование, кэширование и поиск прошивок. + +**Формат манифеста (`manifest.json` внутри ZIP):** +```json +{ + "id": "relay-1.3.0-esp8266-x8", + "device_type": "relay", + "platform": "esp8266", + "channels": 8, + "version": "1.3.0", + "core_version": "1.5.0", + "bin_filename": "relay.ino.bin", + "description": "...", + "changelog": "..." +} +``` +- `id`, `device_type`, `version`, `bin_filename` — обязательные. +- `platform`, `channels` — опциональные; если указаны, участвуют в matching. + +**API класса:** +- `scan(): array` — сканирует `firmwares_dir/*.zip`, распаковывает каждый ZIP (временно), читает `manifest.json`, валидирует, сохраняет в статический кэш. Возвращает кэш. +- `getAll(): array` — возвращает все записи каталога (массив манифестов). +- `getById(string $id): ?array` — одна запись каталога по ID. +- `getBinPath(string $id): ?string` — распаковывает `.bin` из ZIP во временную директорию, возвращает абсолютный путь к файлу. Cleanup — по желанию вызывающего. +- `findCompatible(array $deviceAbout): array` — фильтрует кэш по `device_type`, `platform`, `channels`, и возвращает только те прошивки, чья `version` > текущей версии устройства. Сравнение версий — компонентное `X.Y.Z`. +- `clearCache(): void` — сброс кэша. + +**Распаковка ZIP:** +- Первичный метод: `ZipArchive` (PHP встроенный). +- Fallback: `exec('unzip -o ...')` во временную директорию (`sys_get_temp_dir() . '/shserv_firmwares/...'`). +- После чтения манифеста — временная директория удаляется. + +### 3. DeviceAPI (`server/SHServ/Tools/DeviceAPI/Base.php`) +Добавить метод: +```php +public function updateFirmware(string $binPath): array +``` +- Отправляет `POST /update` на устройство через multipart/form-data (`CURLFile`). +- Таймаут 60 секунд (OTA длительная операция). +- Отправляет Bearer-токен, если есть. +- Возвращает массив `['http_code' => int, 'raw' => string, 'error' => string|null]`. +- Не использует retry-логику (OTA не идемпотентна). + +### 4. Routes (`server/SHServ/Routes/FirmwareRESTAPI_v1.php`) +Новый trait с endpoint'ами: + +**URI routes:** +- `GET /api/v1/firmwares` → `FirmwareRESTAPIController@firmwares_list` +- `GET /api/v1/firmwares/id/$firmware_id` → `FirmwareRESTAPIController@firmware_detail` +- `GET /api/v1/firmwares/id/$firmware_id/download` → `FirmwareRESTAPIController@firmware_download` +- `GET /api/v1/devices/id/$device_id/firmware-compatibility` → `FirmwareRESTAPIController@device_firmware_compatibility` + +**POST routes:** +- `POST /api/v1/firmwares/refresh` (без body) → `FirmwareRESTAPIController@firmware_refresh` +- `POST /api/v1/devices/update-firmware` (`device_id`, `firmware_id`) → `FirmwareRESTAPIController@device_update_firmware` + +### 5. Controller (`server/SHServ/Controllers/FirmwareRESTAPIController.php`) +Новый контроллер, расширяет `\SHServ\Middleware\Controller`. + +**Методы:** +- `firmwares_list()` — возвращает массив всех манифестов. +- `firmware_detail($firmware_id)` — один манифест по ID. +- `firmware_download($firmware_id)` — распаковывает `.bin`, отдаёт `Content-Type: application/octet-stream` через `readfile()`, затем cleanup и `exit`. +- `firmware_refresh()` — вызывает `$catalog->scan()`, возвращает success. +- `device_firmware_compatibility($device_id)` — берёт устройство, запрашивает `/about`, передаёт в `findCompatible()`. +- `device_update_firmware($device_id, $firmware_id)` — валидация, проверка совместимости, вызов `updateFirmware()`, cleanup temp bin, возврат результата. + +**Обработка ошибок:** +- `firmware_not_found` — прошивка не найдена в каталоге. +- `firmware_not_compatible` — прошивка не подходит для устройства. +- `ota_failed` — устройство вернуло HTTP != 200 или cURL ошибка. + +### 6. Интеграция маршрутов (`server/SHServ/Routes.php`) +- Добавить `use \SHServ\Routes\FirmwareRESTAPI_v1;`. +- В `routes_init()` вызвать `$this->firmware_restapi_uri_routes()`, `$this->firmware_restapi_post_routes()`. + +### 7. Локализация (`server/SHServ/text-msgs.php`) +Добавить алиасы: +- `firmware_not_found` → "Прошивка не найдена" +- `firmware_not_compatible` → "Прошивка не совместима с устройством" +- `ota_failed` → "Не удалось обновить прошивку на устройстве" + +### 8. Autoload +- После создания новых PHP-классов запустить `composer dump-autoload` в `server/`. + +### 9. База данных +- **Изменений в схему БД не требуется.** Каталог полностью in-memory, на основе файловой системы. + +## Последовательность реализации +1. `config.php` — добавить `firmwares_dir`. +2. `FirmwareCatalog.php` — создать класс. +3. `Base.php` — добавить `updateFirmware()`. +4. `FirmwareRESTAPI_v1.php` — trait с маршрутами. +5. `FirmwareRESTAPIController.php` — контроллер. +6. `Routes.php` — подключить trait. +7. `text-msgs.php` — добавить сообщения об ошибках. +8. `composer dump-autoload`. +9. Тестовый ZIP-архив (вручную) для проверки. + +## Риски / Открытые вопросы +- **Таймаут OTA:** 60 секунд достаточно для ESP8266/ESP32 по Wi-Fi? Если нет, увеличим до 120. +- **Cleanup temp файлов:** при ошибках в `device_update_firmware` и `firmware_download` нужно не забыть удалить распакованный `.bin`. Будем использовать `finally` или inline cleanup. +- **Права доступа:** endpoint'ы пока без middleware-авторизации (как и остальные API), позже подключим. diff --git a/tools/virtual_devices/device/base.py b/tools/virtual_devices/device/base.py index a16ee90..38a0f18 100644 --- a/tools/virtual_devices/device/base.py +++ b/tools/virtual_devices/device/base.py @@ -47,13 +47,16 @@ return { "device_name": self.state.device_name, "device_type": self.state.device_type, + "platform": self.state.platform, "firmware_version": self.state.firmware_version, + "core_version": self.state.core_version, "device_id": self.state.device_id, "server": self.state.server_url, "status": self.state.status, "ip_address": ip_address, "mac_address": self.state.mac_address, "uptime": self._uptime(), + "channels": self.state.channel_count, } def set_token(self, token: str) -> Dict[str, Any]: diff --git a/tools/virtual_devices/emulator.py b/tools/virtual_devices/emulator.py index 60a4d7d..dceed41 100644 --- a/tools/virtual_devices/emulator.py +++ b/tools/virtual_devices/emulator.py @@ -166,6 +166,41 @@ return jsonify({"status": "ok", "message": "Wi-Fi configured. Connecting..."}) +# ── update (OTA) ─────────────────────────────────────────── + +_UPDATE_HTML = """ + + +OTA Update + +

Firmware Update

+
+ + +
+ + +""" + +@app.get("/update") +def update_get(): + return _UPDATE_HTML + + +@app.post("/update") +def update_post(): + if 'firmware' not in request.files: + return "No firmware file", 400 + file = request.files['firmware'] + if file.filename == '': + return "Empty filename", 400 + # emulate successful OTA — just save firmware_version to current state for testing + device_instance.state.firmware_version = file.filename.replace('.bin', '') + from state import save + save(device_instance.state) + return "Update OK! Rebooting..." + + # ── simulate-event (debug helper, no auth) ───────────────── @app.post("/simulate-event") diff --git a/tools/virtual_devices/state.py b/tools/virtual_devices/state.py index 62e2761..5cdf46a 100644 --- a/tools/virtual_devices/state.py +++ b/tools/virtual_devices/state.py @@ -30,6 +30,9 @@ server_url: str mac_address: str firmware_version: str = "virtual-1.0.0" + core_version: str = "1.5.0" + platform: str = "esp8266" + channel_count: int = 4 status: str = "setup" # "setup" | "normal" token: str | None = None channels: List[Dict[str, Any]] = field(default_factory=list)