diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..247509d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +A distributed smart home system with three layers: +- **ESP8266/ESP32 firmware** (`devices/`) — IoT devices exposing a REST API +- **PHP server** (`server/`) — central backend that manages devices, events, and automation scripts +- **Web client** (`webclient/`) — JavaScript/SCSS frontend bundled with Gulp/esbuild + +--- + +## Build & Dev Commands + +### Web Client +```bash +cd webclient +npm install # install dependencies (esbuild, sass, gulp, browser-sync, etc.) +npm start # runs gulp: compiles SCSS → dist/css/main.css, bundles JS → dist/js/main.js, watches + live-reloads +``` + +### PHP Server +No build step. PHP is served directly from `server/`. Entry point is `server/index.php`. Configure in `server/SHServ/config.php` (DB credentials, device IP scan range, debug flag). + +### IoT Firmware +Compiled via Arduino IDE or PlatformIO. Each device's `.ino` file `#include`s `` from `devices/sh_core_esp8266/src/`. Pre-built binaries live in `devices/builds/`. + +--- + +## Architecture + +### Device Provisioning Flow +1. New device boots in **setup mode** — only `/about` is public (no auth). +2. Server scans `192.168.x.2–254` (range from config), finds devices via `/about`. +3. Server calls `POST /setup` on the device, pushing: auth token, server address, device name/alias, channel schema. +4. Device enters **normal mode**: all endpoints require `Authorization: Bearer `. + +### Device Runtime API +- `GET /about` — always public, returns device type/firmware info +- `GET /status` — current channel states (auth required) +- `POST /action` — control command (auth required) +- Device sends events to server via `POST /events/new` + +### Server API (all under `/api/v1/`) +- `/devices/scanning/*` — scan network for new devices +- `/devices/setup/new-device` — register a found device +- `/devices/id/{id}/*` — info, status, control per device +- `/devices/action` — execute a device action +- `/areas/*` — logical groupings of devices +- `/scripts/*` — automation scripts + +Routes are defined as **PHP traits** in `server/SHServ/Routes/` and mixed into the app via `server/SHServ/App.php`. The underlying framework is a custom MVC called **Fury** (`server/Fury/`). + +### Automation Scripts +PHP classes in `server/ControlScripts/Scopes/`, extending `ControlScripts` base class. Each Scope class implements four methods: `register_sync_map`, `register_events_handlers`, `register_actions_scripts`, `register_regular_scripts`. All Scopes are auto-loaded at startup. See `docs/control-scripts-guide.md`. + +### Web Client Structure +- `webclient/src/js/index.js` — app entry point +- `webclient/src/js/sh/SmartHomeApi.js` — all server API calls +- `webclient/src/js/components/` — UI components (hud, modals, toasts, etc.) +- `webclient/src/js/routes.js` — frontend routing +- `webclient/proxy.php` — CORS proxy for API calls in dev + +### sh_core Library (`devices/sh_core_esp8266/src/`) +Shared Arduino library used by all device firmware: +- `sh_core.h` — EEPROM memory layout, constants, channel type definitions +- `sh_core.cpp` — WiFi, OTA, EEPROM helpers +- `REST_API.cpp` — device HTTP server and endpoint implementations +- `WebHandlers.cpp` / `WebPages.cpp` — browser-accessible device config UI + +--- + +## Key Files + +| Path | Purpose | +|------|---------| +| `server/SHServ/config.php` | DB credentials, device IP scan range, debug mode | +| `server/SHServ/Routes/` | API route definitions (trait-based) | +| `server/SHServ/Controllers/` | Request handler logic | +| `server/SHServ/Models/` | DB query layer | +| `server/console.php` | CLI entry point for server-side scripts | +| `webclient/src/js/sh/SmartHomeApi.js` | JS API client (the contract between frontend and backend) | +| `devices/sh_core_esp8266/src/sh_core.h` | EEPROM layout and all device-side constants | +| `docs/device-spec.md` | Device REST API contract (endpoints on the device itself) | +| `docs/server-api.md` | **Server REST API** — full reference of all implemented endpoints | +| `docs/architecture.md` | Full architecture: firmware contract, events routing, sync map, Fury framework | +| `docs/firmware-dev-guide.md` | How to write firmware for a new device type | +| `docs/control-scripts-guide.md` | How to write automation scripts (Scope classes) | diff --git a/channels_schema_changer/app.py b/channels_schema_changer/app.py deleted file mode 100644 index f235c1e..0000000 --- a/channels_schema_changer/app.py +++ /dev/null @@ -1,479 +0,0 @@ -from flask import Flask, request, redirect, url_for, render_template_string -import requests -from typing import Any, Dict, List, Optional, Tuple - -app = Flask(__name__) -app.config["SECRET_KEY"] = "sh-schema-editor-dev" - -CHANNEL_COUNT = 8 -CHANNEL_BYTES = 4 -SCHEMA_LEN = CHANNEL_COUNT * CHANNEL_BYTES -SH_PIN_UNUSED = 255 - -FIELD_NAMES = ["pin", "indicator", "feedback", "flags"] -FIELD_LABELS = { - "pin": "Pin", - "indicator": "Indicator", - "feedback": "Feedback", - "flags": "Flags", -} - -HTML = """ - - - - - - SH Channels Schema Editor - - - -
-
-
-

SH Channels Schema Editor

-
Port 8001. Reads and writes /channels_schema and /set_channels_schema.
-
- {% if device_ip %} -
Device: {{ device_ip }}
- {% endif %} -
- - {% if message %} -
{{ message }}
- {% endif %} - -
-

Connection

-
-
-
- - -
-
- - -
-
-
- -
-
- -
- - {% if schema %} -
-

Schema editor

-
- - - - {% for channel in channels %} -
-
-
Channel {{ channel.index }}
-
Bytes {{ channel.base }}..{{ channel.base + 3 }}
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
- {% endfor %} - -
- - - -
-
-
- -
-

Raw schema

-
{{ schema | join(', ') }}
-
- {% endif %} -
- - -""" - - -def build_headers(token: str) -> Dict[str, str]: - headers: Dict[str, str] = {"Content-Type": "application/json"} - token = token.strip() - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -def normalize_ip(device_ip: str) -> str: - device_ip = device_ip.strip() - if device_ip.startswith("http://") or device_ip.startswith("https://"): - return device_ip.rstrip("/") - return f"http://{device_ip}" - - -def parse_device_schema(payload: Dict[str, Any]) -> List[int]: - schema = payload.get("schema") - if not isinstance(schema, list): - raise ValueError("Device response does not contain a schema array") - if len(schema) != SCHEMA_LEN: - raise ValueError(f"Schema length is {len(schema)}, expected {SCHEMA_LEN}") - - normalized: List[int] = [] - for value in schema: - if not isinstance(value, int): - raise ValueError("Schema contains a non-integer value") - if value < 0 or value > 255: - raise ValueError("Schema values must be in range 0..255") - normalized.append(value) - return normalized - - -def get_schema(device_ip: str, token: str) -> List[int]: - base_url = normalize_ip(device_ip) - response = requests.get( - f"{base_url}/channels_schema", - headers=build_headers(token), - timeout=5, - ) - response.raise_for_status() - data = response.json() - if data.get("status") == "error": - raise ValueError(data.get("message") or data.get("error") or "Device returned an error") - return parse_device_schema(data) - - -def set_schema(device_ip: str, token: str, schema: List[int]) -> Dict[str, Any]: - base_url = normalize_ip(device_ip) - response = requests.post( - f"{base_url}/set_channels_schema", - headers=build_headers(token), - json={"schema": schema}, - timeout=8, - ) - response.raise_for_status() - data = response.json() - if data.get("status") == "error": - raise ValueError(data.get("message") or data.get("error") or "Device returned an error") - return data - - -def split_schema(schema: List[int]) -> List[Dict[str, Any]]: - channels: List[Dict[str, Any]] = [] - for index in range(CHANNEL_COUNT): - base = index * CHANNEL_BYTES - flags = schema[base + 3] - channels.append( - { - "index": index, - "base": base, - "pin": schema[base + 0], - "indicator": schema[base + 1], - "feedback": schema[base + 2], - "flags": flags, - "invert": (flags & 0x01) != 0, - } - ) - return channels - - -def coerce_byte(value: Optional[str], field_name: str) -> int: - if value is None: - raise ValueError(f"Missing value for {field_name}") - value = value.strip() - if value == "": - raise ValueError(f"Empty value for {field_name}") - try: - parsed = int(value) - except ValueError as exc: - raise ValueError(f"Invalid integer for {field_name}") from exc - if parsed < 0 or parsed > 255: - raise ValueError(f"Value for {field_name} must be 0..255") - return parsed - - -def schema_from_form(form: Any) -> List[int]: - schema: List[int] = [] - for ch in range(CHANNEL_COUNT): - pin = coerce_byte(form.get(f"pin_{ch}"), f"pin_{ch}") - indicator = coerce_byte(form.get(f"indicator_{ch}"), f"indicator_{ch}") - feedback = coerce_byte(form.get(f"feedback_{ch}"), f"feedback_{ch}") - flags = coerce_byte(form.get(f"flags_{ch}"), f"flags_{ch}") - - if form.get(f"invert_{ch}"): - flags |= 0x01 - else: - flags &= 0xFE - - schema.extend([pin, indicator, feedback, flags]) - return schema - - -def render_page( - *, - device_ip: str = "", - token: str = "", - schema: Optional[List[int]] = None, - message: str = "", - message_type: str = "info", -) -> str: - return render_template_string( - HTML, - device_ip=device_ip, - token=token, - schema=schema, - channels=split_schema(schema) if schema else [], - message=message, - message_type=message_type, - ) - - -@app.get("/") -def index() -> str: - return render_page() - - -@app.post("/connect") -def connect() -> str: - device_ip = request.form.get("device_ip", "").strip() - token = request.form.get("token", "").strip() - - if not device_ip: - return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err") - - try: - schema = get_schema(device_ip, token) - except Exception as exc: - return render_page(device_ip=device_ip, token=token, message=f"Failed to load schema: {exc}", message_type="err") - - return render_page(device_ip=device_ip, token=token, schema=schema, message="Schema loaded successfully.", message_type="ok") - - -@app.post("/save") -def save_schema() -> str: - device_ip = request.form.get("device_ip", "").strip() - token = request.form.get("token", "").strip() - - if not device_ip: - return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err") - - try: - schema = schema_from_form(request.form) - - fill_unused_channel = request.form.get("fill_unused") - if fill_unused_channel is not None: - ch = int(fill_unused_channel) - base = ch * CHANNEL_BYTES - schema[base + 0] = SH_PIN_UNUSED - schema[base + 1] = SH_PIN_UNUSED - schema[base + 2] = SH_PIN_UNUSED - schema[base + 3] = 0 - return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Channel {ch} marked as unused. Not saved yet.", message_type="info") - - if request.form.get("fill_all_unused"): - schema = [] - for _ in range(CHANNEL_COUNT): - schema.extend([SH_PIN_UNUSED, SH_PIN_UNUSED, SH_PIN_UNUSED, 0]) - return render_page(device_ip=device_ip, token=token, schema=schema, message="All channels marked as unused. Not saved yet.", message_type="info") - - if request.form.get("normalize"): - return render_page(device_ip=device_ip, token=token, schema=schema, message="Flags normalized from checkboxes. Not saved yet.", message_type="info") - - result = set_schema(device_ip, token, schema) - return render_page(device_ip=device_ip, token=token, schema=schema, message=result.get("message", "Schema saved."), message_type="ok") - except Exception as exc: - try: - schema = schema_from_form(request.form) - except Exception: - schema = None - return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Failed to save schema: {exc}", message_type="err") - - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=8001, debug=True) diff --git a/channels_schema_changer/requirements.txt b/channels_schema_changer/requirements.txt deleted file mode 100644 index 6e49680..0000000 --- a/channels_schema_changer/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -blinker==1.9.0 -certifi==2026.2.25 -charset-normalizer==3.4.5 -click==8.3.1 -Flask==3.1.3 -idna==3.11 -itsdangerous==2.2.0 -Jinja2==3.1.6 -MarkupSafe==3.0.3 -requests==2.32.5 -urllib3==2.6.3 -Werkzeug==3.1.6 diff --git a/device_interface_dev/setup.html b/device_interface_dev/setup.html deleted file mode 100644 index b40d9a1..0000000 --- a/device_interface_dev/setup.html +++ /dev/null @@ -1,382 +0,0 @@ - - - - - WiFi setup - - - - - -
-
-
-

WiFi setup

-
- -
-
-
-

- Connect the device to your home WiFi network. -

-
-
-
- - -
-
- - -
-
- -
-
-
-
-
Last Wi-Fi SSID: (Empty)
- -
- -
-
-
-
-
Wi-Fi networks
Scanning...
Scanning ERROR
100%
90%
88%
60%
34%
-
-
-
-
-
-

ESP SmartHome Device

-
-
- - - - - \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c5f978b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,297 @@ +# Архитектура системы + +## Политика управления устройствами + +**Ни веб-клиент, ни сами умные устройства не имеют права напрямую управлять другими устройствами через внешний REST API.** + +Всё управление устройствами — только через скрипты (`ControlScripts`). Скрипты являются единственной точкой логики автоматизации. У них три механики взаимодействия с системой: + +| Механика | Описание | Как запускается | +|----------|---------|----------------| +| **Events** | Реакция на события — в том числе события от устройств (`button_press`, `presence_changed` и т.д.) | Автоматически, при получении события от устройства через `POST /events/new` | +| **Regular** | Периодические задачи | По cron через `GET /cron/regular-scripts` | +| **Actions** | Именованные операции, запускаемые явно | Через `POST /api/v1/scripts/actions/run` из клиента | + +Это намеренная политика: бизнес-логика управления устройствами сосредоточена исключительно в `ControlScripts/Scopes/`, а не размазана по клиентскому коду или прошивкам. + +--- + +## Три слоя + +``` +[ESP8266/ESP32 устройства] + ↕ HTTP REST (локальная сеть, только сервер инициирует) +[PHP-сервер SHServ] + ↕ HTTP REST (proxy.php) +[Веб-клиент (JS/SCSS)] +``` + +--- + +## Устройства (Firmware) + +Каждое устройство — это Arduino-прошивка, собранная поверх библиотеки `sh_core_esp8266`. + +### Роль sh_core + +`devices/sh_core_esp8266/src/` — разделяемая библиотека для всех устройств. Она отвечает за: +- WiFi подключение и AP-режим при первом запуске +- HTTP-сервер и регистрацию стандартных роутов (`/about`, `/status`, `/action`, `/setup`, `/set_token`, `/reboot`, `/reset`, `/set_device_name`, `/channels_schema`, `/set_channels_schema`) +- EEPROM: хранение SSID/пароля, токена, адреса сервера, имени устройства, схемы каналов +- OTA-обновление +- Универсальный POST-хелпер для отправки событий на сервер: `core_post_json_to_server()` + +### Контракт прошивки устройства + +Каждый `.ino`-файл обязан реализовать 4 функции (они вызываются из `sh_core`): + +```cpp +// Добавить поля в JSON-ответ /status +void appendStatusJsonFields(String &json); + +// Добавить поля в JSON-ответ /about +void appendAboutJsonFields(String &json); + +// Обработать входящее действие /action +bool deviceHandleAction(const String &action, const String ¶msJson, + String &errorCode, String &errorMessage); + +// Выполнить сброс к заводским настройкам +void deviceHandleReset(); +``` + +И задать три глобальных константы: +```cpp +const char* DEVICE_TYPE = "relay"; // тип устройства +const char* FW_VERSION = "1.0"; // версия прошивки +const uint8_t CHANNEL_NUM = 8; // число каналов (0 если не используются) +``` + +### EEPROM layout + +| Адрес | Длина | Содержимое | +|-------|-------|-----------| +| 0 | 32 | SSID | +| 32 | 64 | WiFi password | +| 96 | 1 | Device mode (0=setup, 1=normal, 2=error, 3=updating) | +| 97 | 64 | Device name | +| 161 | 64 | Auth token | +| 225 | 32 | Server base URL | +| 257 | 36 | Channel schema (4 meta + 32 data) | +| 512–1023 | 512 | Свободно для устройства (`DEVICE_EEPROM_START`) | + +### Каналы (channels schema) + +Схема — 8 каналов × 4 байта: +- байт 0: GPIO пин канала (`SH_CH_PIN`) +- байт 1: GPIO пин индикатора или номер LED (`SH_CH_INDICATOR`) +- байт 2: GPIO пин обратной связи / концевика (`SH_CH_FEEDBACK`) +- байт 3: флаги — бит 0 (`SH_CH_FLAG_INVERT`) = инверсия канала + +Значение `0xFF` (`SH_PIN_UNUSED`) означает «пин не используется». + +### Режимы устройства + +| Режим | Описание | +|-------|---------| +| `setup` | Не подключено к серверу. `/setup` и `/set_token` доступны без токена. | +| `normal` | Работает штатно. Все запросы требуют `Authorization: Bearer `. | +| `error` | Ошибка. | +| `updating` | OTA-обновление. | + +При переходе `setup → normal`: устройство запоминает IP сервера и принимает авторизованные запросы **только** с этого адреса. + +--- + +## Сервер (SHServ) + +PHP-приложение поверх собственного микрофреймворка **Fury**. + +### Fury framework + +`server/Fury/` содержит минималистичный MVC-фреймворк: +- **Bootstrap** — точка входа, инициализирует приложение через `events()` +- **Router** — маршрутизация через `.uri()`, `.get()`, `.post()` +- **ThinBuilder** — простой SQL query builder поверх PDO +- **Events** — простой pub/sub (`events()->handler(name, cb)`, `events()->app_call(name, data)`) +- **Template** — шаблонизатор (используется мало) + +### Жизненный цикл запроса + +1. `server/index.php` → `Fury\Kernel\Init` → `Fury\Kernel\Bootstrap` +2. Bootstrap генерирует событие `kernel:Bootstrap.ready_app` +3. `EventsHandlers` ловит событие → вызывает `routes->routes_init()` → `router->start_routing()` +4. Router находит совпадение и вызывает `Controller@method` + +### Структура SHServ + +``` +SHServ/ +├── App.php — точка входа (new App()), инициализация +├── config.php — DB, IP-диапазон устройств, devmode +├── Routes.php — объединяет все trait-роуты +├── Routes/ — trait DevicesRESTAPI_v1, ScriptsRESTAPI_v1, AreasRESTAPI_v1, DevMode +├── Controllers/ — по одному контроллеру на группу роутов +├── Models/ — DB-слой (Devices, Areas, Scripts, EventsModel, ...) +├── Entities/ — объекты предметной области (Device, Area, Script, ...) +├── Middleware/ — базовые классы Controller, Model, Entity, ControlScripts +├── Helpers/ — DeviceScriptsHelper, MetaImplementation, Validator, ... +├── Tools/DeviceAPI/ — HTTP-клиенты к устройствам (Base, Relay, Button, Sensor, Hatch) +├── Tools/DeviceScanner.php — параллельное сканирование сети через curl_multi +└── Logs/ — JSON-логи по дням +``` + +### Добавление устройства на сервер + +1. `GET /api/v1/devices/scanning/setup` — сервер параллельно опрашивает диапазон IP (из `config.php`: `device_ip_range`), возвращает устройства в режиме `setup` +2. `POST /api/v1/devices/setup/new-device` — сервер: + - Запрашивает `/about` у устройства + - Создаёт запись в таблице `devices` + - Генерирует токен, сохраняет в `device_auth` + - Отправляет токен на устройство через `POST /set_token` + - Устанавливает имя устройства через `POST /set_device_name` + +### DeviceAPI (Tools/DeviceAPI/) + +Серверная сторона HTTP-клиентов к устройствам: +- `Base` — базовые методы: `get_about()`, `get_status()`, `post_action()`, `remote_set_token()`, `reboot()`, `reset()`, `set_device_name()` +- `Relay extends Base` — `toggle_channel(ch)`, `set_state(bool)`, `set_channel_state(bool, ch)` +- `Button extends Base` — `get_indicators()`, `get_indicator_state(ch)`, `set_channel_state(mode, ch)` +- `Sensor extends Base` +- `Hatch extends Base` + +`Device::device_api()` создаёт нужный экземпляр по `device_type` и автоматически подставляет токен из `device_auth`. + +### Cron-маршруты + +Два endpoint'а для запуска по cron: + +| URL | Действие | +|-----|---------| +| `GET /cron/regular-scripts` | Запускает все зарегистрированные regular-скрипты (с проверкой флага enabled в БД) | +| `GET /cron/status-update-scanning` | Сканирует сеть, обновляет `connection_status` и `device_ip` устройств в БД | + +--- + +## Система событий (Events от устройств) + +Устройство отправляет событие на `POST /events/new` сервера: + +```json +{ + "device_id": "", + "event_name": "button_press", + "data": { "channel": 0 } +} +``` + +Сервер (`EventsController`) ищет устройство по `device_hard_id`, проверяет авторизацию, немедленно отвечает `200 OK`, а затем (после `fastcgi_finish_request`) вызывает `EventsModel`, который триггерит события через Fury Events в нескольких форматах: + +| Паттерн события | Пример | Описание | +|----------------|---------|---------| +| `{event_name}` | `button_press` | Глобальный — любое устройство | +| `{device_type}.{event_name}` | `button.button_press` | По типу устройства | +| `{device_type}@{alias}.{event_name}` | `button@kitchen_btns.button_press` | По alias устройства | +| `{device_type}({channel}).{event_name}` | `button(2).button_press` | По типу + каналу | +| `{device_type}@{alias}({channel}).{event_name}` | `button@kitchen_btns(2).button_press` | По alias + каналу (самый точный) | + +--- + +## Control Scripts (ControlScripts/Scopes/) + +Скрипты автоматизации — это PHP-классы в `server/ControlScripts/Scopes/`, наследующие `ControlScripts` и реализующие `ControlScriptsInterface`. Все Scope-файлы загружаются автоматически при старте приложения. + +### Структура Scope-класса + +```php +class MyScope extends \SHServ\Middleware\ControlScripts + implements \SHServ\Implements\ControlScriptsInterface { + + public function register_sync_map(): void { ... } + public function register_events_handlers(): void { ... } + public function register_actions_scripts(): void { ... } + public function register_regular_scripts(): void { ... } +} +``` + +### Типы скриптов + +**Action-скрипты** — вызываются вручную через API (`POST /api/v1/scripts/actions/run`): +```php +$this->add_action_script([ + "alias" => "my_action", + "name" => "Имя", + "icon" => '', + "author" => "Name" +], function($params) { + // логика + return ["result" => ...]; +}); +``` + +**Regular-скрипты** — запускаются по cron (`GET /cron/regular-scripts`): +```php +$this->add_regular_script([ + "alias" => "my_regular", + "name" => "Имя" +], function() { + // периодическая логика +}); +``` + +**Event-хендлеры** — реакция на события от устройств: +```php +$this->add_event_handler("button@{$alias}({$channel}).press", function(Device $device, array $data) { + // логика +}); +``` + +### Sync map + +Декларативное описание связей «реле-канал ↔ кнопки-каналы» для синхронизации индикаторов: +```php +$this->add_sync_connection([ + ["type" => "relay", "alias" => "kitchen_relay", "channel" => 0], + ["type" => "button", "alias" => "kitchen_btns", "channel" => 1], +]); +``` + +`DeviceScriptsHelper::sync_relay_to_btns()` читает sync map и обновляет индикаторы кнопок при изменении состояния реле. + +--- + +## Веб-клиент + +SPA на vanilla JS + esbuild. Роутинг через hash `#!/route`. + +``` +src/js/ +├── index.js — init: SmartHomeApi, Screens, routes() +├── routes.js — привязка роутов к экранам +├── DataProvider.js — простое key-value хранилище состояния (window.DataProvider) +├── sh/SmartHomeApi.js — HTTP-клиент (fetch + callback-style) +│ └── modules/ — DevicesApi, ScriptsApi, AreasApi +└── components/ + ├── Screens.js — переключение экранов по hash + ├── hud.js — верхний HUD с навигацией + ├── modals.js — модальные окна + ├── toasts.js — уведомления + └── screens/ — экраны devices, areas, scripts +``` + +### SmartHomeApi + +Callback-style клиент. Все запросы идут через `proxy.php` (опция `proxy_path`): + +```js +const api = new SmartHomeApi({ + base_url: API_BASEURL, + token: "YOUR_TOKEN", + proxy_path: "/proxy.php", +}); + +api.devices.list((err, data) => { ... }); +api.scripts.run({ alias: "my_action" }, (err, data) => { ... }); +``` + +Константа `API_BASEURL` инжектируется через esbuild при сборке (`webclient/gulpfile.js`). diff --git a/docs/control-scripts-guide.md b/docs/control-scripts-guide.md new file mode 100644 index 0000000..da2e370 --- /dev/null +++ b/docs/control-scripts-guide.md @@ -0,0 +1,192 @@ +# Руководство по написанию Control Scripts + +Control Scripts — это PHP-классы автоматизации, живущие в `server/ControlScripts/Scopes/`. Все файлы в этой папке загружаются автоматически при старте сервера. + +--- + +## Структура класса + +```php + button для синхронизации индикаторов + public function register_sync_map(): void { } + + // Подписаться на события от устройств + public function register_events_handlers(): void { } + + // Зарегистрировать action-скрипты (запуск вручную через UI/API) + public function register_actions_scripts(): void { } + + // Зарегистрировать regular-скрипты (запуск по cron) + public function register_regular_scripts(): void { } +} +``` + +--- + +## Action-скрипты + +Запускаются вручную через `POST /api/v1/scripts/actions/run` или из UI. + +```php +$this->add_action_script([ + "alias" => "kitchen_light_toggle", // уникальный alias + "name" => "Свет на кухне", + "icon" => '', // Phosphor Icons + "description" => "Включить/выключить основной свет", + "author" => "Eugene Sukhodolskiy" +], function($params) { + $relay_api = $this->devices()->by_alias("kitchen_relay")->device_api(); + + if ($relay_api instanceof \SHServ\Tools\DeviceAPI\Relay) { + $relay_api->toggle_channel(0); + } + + return ["result" => true]; +}); +``` + +Включение/выключение конкретного action-скрипта: +- `GET /api/v1/scripts/actions/alias/{alias}/enable` +- `GET /api/v1/scripts/actions/alias/{alias}/disable` + +--- + +## Regular-скрипты + +Запускаются периодически через cron: `GET /cron/regular-scripts`. + +```php +$this->add_regular_script([ + "alias" => "check_door_sensor", + "name" => "Проверка датчика двери", +], function() { + $sensor = $this->devices()->by_alias("door_sensor"); + // ... +}); +``` + +Включение/выключение: +- `GET /api/v1/scripts/actions/regular/{alias}/enable` +- `GET /api/v1/scripts/actions/regular/{alias}/disable` + +--- + +## Event-хендлеры + +Подписка на события от устройств. Обработчик вызывается **после** ответа устройству (через `fastcgi_finish_request`), поэтому может занимать время. + +```php +// Нажатие кнопки (канал 0) конкретного устройства +$this->add_event_handler("button@my_btns(0).press", function(Device $device, array $data) { + $relay = $this->devices()->by_alias("my_relay"); + $relay->device_api()->toggle_channel(0); +}); + +// Приход устройства онлайн +$this->add_event_handler("button@my_btns.online", function(Device $device, array $data) { + // синхронизировать индикаторы + $this->helper()->sync_btn_channels($this->sync_map(), $device->alias); +}); +``` + +### Паттерны имён событий + +| Паттерн | Пример | Когда срабатывает | +|---------|---------|------------------| +| `{event_name}` | `button_press` | Любое устройство, любое событие с таким именем | +| `{type}.{event_name}` | `button.button_press` | Все устройства типа `button` | +| `{type}@{alias}.{event_name}` | `button@kitchen_btns.online` | Конкретное устройство | +| `{type}({ch}).{event_name}` | `button(2).button_press` | Все устройства типа, канал 2 | +| `{type}@{alias}({ch}).{event_name}` | `button@kitchen_btns(2).button_press` | Конкретное устройство, канал 2 | + +### Известные event_name (от устройств) + +| Устройство | event_name | Описание | +|-----------|-----------|---------| +| button | `press` | Нажатие кнопки | +| button | `online` | Устройство вышло в сеть | +| relay | `limit_switch_activated` | Сработал концевик | +| hatch | `limit_switch_activated` | Сработал концевик закрытия | +| hatch | `calibration_failed` | Не удалась калибровка | +| sensor | `presence_changed` | Изменение присутствия в помещении | + +--- + +## Sync map + +Описывает связи «какой канал реле ↔ какие каналы кнопок». Используется для автоматической синхронизации индикаторов кнопок с состоянием реле. + +```php +public function register_sync_map(): void { + $this->add_sync_connection([ + ["type" => "relay", "alias" => "kitchen_relay", "channel" => 0], + ["type" => "button", "alias" => "kitchen_btns", "channel" => 1], + ["type" => "button", "alias" => "hall_btns", "channel" => 0], + ]); +} +``` + +Первый элемент в массиве — обычно реле (источник состояния). Остальные — кнопки, чьи индикаторы синхронизируются. + +### Хелперы синхронизации + +```php +// При нажатии кнопки — переключить реле и синхронизировать все кнопки +$relay_api->toggle_channel($relay_channel); +$this->helper()->sync_relay_to_btns($this->sync_map(), $relay_alias); + +// При появлении кнопки онлайн — синхронизировать её индикаторы с реле +$this->helper()->sync_btn_channels($this->sync_map(), $btn_alias); +``` + +--- + +## Доступные методы базового класса + +```php +$this->devices() // → Models\Devices (поиск по alias, id, hard_id) +$this->helper() // → DeviceScriptsHelper (синхронизация) +$this->sync_map() // → текущий sync_map_storage +$this->add_event_handler($name, $cb) +$this->add_action_script($attrs, $cb) +$this->add_regular_script($attrs, $cb) +$this->add_sync_connection($entries) +``` + +--- + +## Управление Scope через API + +Включение/выключение целого Scope (всех его скриптов): +- `GET /api/v1/scripts/actions/scope/{name}/enable` +- `GET /api/v1/scripts/actions/scope/{name}/disable` + +Scope отключается через БД — при следующем запуске сервера его скрипты не зарегистрируются. + +--- + +## Общий trait Common + +`server/ControlScripts/Common.php` — trait с готовыми хелперами для Scope-классов: + +```php +use \ControlScripts\Common; + +// Зарегистрировать глобальный sync_map (все реле и кнопки системы) +$this->register_global_device_sync_map(); + +// Установить индикаторы заглушённых каналов при появлении кнопки онлайн +$this->btn_on_online("my_btns", [/* muted channels */]); + +// Установить обработчики нажатий для кнопок из sync_map +$this->set_btns_click_handlers("my_btns"); +``` diff --git a/docs/firmware-dev-guide.md b/docs/firmware-dev-guide.md new file mode 100644 index 0000000..bf81e78 --- /dev/null +++ b/docs/firmware-dev-guide.md @@ -0,0 +1,156 @@ +# Руководство по разработке прошивки устройства + +## Минимальный шаблон нового устройства + +```cpp +#include + +// --- Обязательные константы ядра --- +const char* DEVICE_TYPE = "my_device"; // тип устройства +const char* FW_VERSION = "1.0 dev"; +const uint8_t CHANNEL_NUM = 2; // 0 если каналы не используются + +#include +#include "MyDeviceLogic.h" + +// --- Хук для /status --- +void appendStatusJsonFields(String &json) { + // json уже содержит "status":"ok" + // добавляем дополнительные поля + json += ",\"my_field\":42"; +} + +// --- Хук для /about --- +void appendAboutJsonFields(String &json) { + json += ",\"channels\":" + String(CHANNEL_NUM); +} + +// --- Обработчик /action --- +bool deviceHandleAction(const String &action, + const String ¶msJson, + String &errorCode, + String &errorMessage) +{ + if (action == "my_action") { + // ... + return true; + } + + errorCode = "IllegalActionOrParams"; + errorMessage = "Unknown action"; + return false; +} + +// --- Сброс к заводским настройкам --- +void deviceHandleReset() { + // очистить EEPROM устройства (диапазон DEVICE_EEPROM_START..EEPROM_SIZE) + EEPROM.begin(EEPROM_SIZE); + for (uint16_t addr = DEVICE_EEPROM_START; addr < EEPROM_SIZE; addr++) { + EEPROM.write(addr, 0xFF); + } + EEPROM.commit(); + EEPROM.end(); +} + +void setup() { + coreSetup(); + // инициализация GPIO, сенсоров и т.п. +} + +void loop() { + coreLoop(); + // своя логика +} +``` + +--- + +## Использование каналов (channels schema) + +Каналы читаются из EEPROM через API ядра: + +```cpp +uint8_t pin = sh_channel_pin(ch); // GPIO пин канала +uint8_t ind = sh_channel_indicator(ch); // GPIO пин индикатора +uint8_t fb_pin = sh_channel_feedback_pin(ch); // GPIO пин обратной связи +bool inverted = sh_channel_is_inverted(ch); // флаг инверсии +``` + +Если `pin == SH_PIN_UNUSED` (0xFF) — канал не задействован: +```cpp +if (pin == SH_PIN_UNUSED) continue; +``` + +--- + +## EEPROM устройства + +Ядро занимает адреса 0..~293. Для хранения данных устройства: + +```cpp +const uint16_t MY_EEPROM_BASE = getDeviceEepromStart(); // = 512 +const uint16_t MY_DATA_ADDR = MY_EEPROM_BASE; // float = 4 байта +const uint16_t MY_STATE_ADDR = MY_EEPROM_BASE + 4; +``` + +--- + +## Отправка событий на сервер + +```cpp +#include + +// Пример отправки события +String json = "{\"device_id\":\"" + getUniqueID() + "\"," + "\"event_name\":\"my_event\"," + "\"data\":{\"channel\":0}}"; + +int http_code = 0; +core_post_json_to_server("/events/new", json, 3000, http_code); +``` + +Путь для событий по умолчанию `/events/new`. Можно переопределить: +```cpp +const char* core_get_event_path() { return "/events/new"; } +``` + +--- + +## Типы устройств + +| `DEVICE_TYPE` | Описание | +|--------------|---------| +| `relay` | Реле (1–8 каналов), хранит состояние в EEPROM | +| `button` | Блок кнопок с RGB-индикаторами NeoPixel | +| `sensor` | Мультисенсор (radar, climate, light, mic) | +| `hatch` | Моторизованный люк (3 реле + концевик) | + +--- + +## Поддерживаемые платформы + +Библиотека `sh_core` поддерживает ESP8266 и ESP32 через платформенные заголовки: +- `devices/sh_core_esp8266/src/platform/esp8266/sh_platform_esp8266.h` +- `devices/sh_core_esp8266/src/platform/esp32/sh_platform_esp32.h` + +Активный платформенный заголовок выбирается через `sh_platform.h`. + +--- + +## Пины по умолчанию в примерах устройств + +### sensor.ino (ESP32) +| Компонент | Пины | +|-----------|------| +| BH1750 (свет) | SDA=16, SCL=17, I2C addr=0x5C | +| BME280 (климат) | SDA=18, SCL=19, I2C addr=0x76 | +| LD2420 (radar) | RX=4, TX=15, 115200 baud | +| MAX4466 (mic) | ADC=34 | + +### hatch.ino (ESP32) +| Компонент | Пин | +|-----------|-----| +| Силовое реле | GPIO18 (хардкод `HATCH_POWER_RELAY_PIN`) | +| Реле «открыть» (канал 0) | через channels schema | +| Реле «закрыть» (канал 1) | через channels schema | +| Концевик «закрыто» | feedback-пин канала 1 | diff --git a/docs/script-api.md b/docs/script-api.md index 08a8e79..6e97f6d 100644 --- a/docs/script-api.md +++ b/docs/script-api.md @@ -1,3 +1,8 @@ +> **Примечание.** Этот документ описывает концептуальный API событий. Актуальная реализация скриптов использует класс-ориентированный подход на базе Scope-классов. +> Подробнее: [`docs/control-scripts-guide.md`](./control-scripts-guide.md) + +--- + ## API для написания скриптов - Скрипт должен быть написан на php - Скрипт может находиться где угодно, при добавлении скрипта можно указать директорию где его нужно искать. diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 0000000..5343a22 --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,517 @@ +# REST API сервера — справочник + +Веб-клиент общается с сервером по HTTP+JSON. Все API-запросы идут через `proxy.php` (CORS). + +## Политика управления устройствами + +Клиент **не управляет устройствами напрямую**. Вся логика — только через скрипты (`ControlScripts`): +- управление устройствами — через **action-скрипты** (`POST /api/v1/scripts/actions/run`) +- реакция на физические события — через **event-хендлеры** в скриптах +- периодические задачи — через **regular-скрипты** (cron) + +Прямые вызовы устройств (`/api/v1/devices/action` и т.п.) — служебные endpoint'ы сервера для внутреннего использования, не предназначены для вызова из клиентского кода. + +--- + +## Формат ответов + +Все endpoint'ы используют единый формат из `Utils::response_success` / `Utils::response_error`. + +**Успех:** +```json +{ + "status": true, + "data": { ... } +} +``` + +**Ошибка:** +```json +{ + "status": false, + "error_alias": "device_not_found", + "failed_fields": ["device_id"], + "msg": "Устройство не найдено" +} +``` + +> **Примечание.** Авторизация (`Authorization: Bearer `) **пока не реализована** на уровне middleware — все endpoint'ы в данный момент открыты. Заголовок описан в спецификации, но проверка не подключена. + +--- + +## Устройства `/api/v1/devices` + +### `GET /api/v1/devices/list` +Список всех активных устройств. + +**Ответ:** +```json +{ + "status": true, + "data": { + "devices": [ + { + "id": 12, + "alias": "kitchen_relay", + "device_type": "relay", + "device_hard_id": "ecf0a1b5c9d7...", + "device_mac": "A4:CF:12:9B:3F:D2", + "device_ip": "192.168.2.42", + "firmware_version": "1.22 dev", + "name": "Реле кухни", + "status": "active", + "connection_status": "active", + "last_contact": "2026-04-22 18:35", + "create_at": "2025-12-01 10:00" + } + ], + "total": 1 + } +} +``` + +--- + +### `GET /api/v1/devices/id/{id}` +Данные одного устройства из БД (без запроса к самому устройству). + +**Ответ:** `data.device` — поля устройства (те же что в списке выше). + +--- + +### `GET /api/v1/devices/id/{id}/info` +Данные устройства + живой ответ `/about` с самого устройства. + +**Ответ:** +```json +{ + "status": true, + "data": { + "device": { + "id": 12, + "alias": "...", + "name": "...", + "description": "...", + "status": "active", + "connection_status": "active", + "last_contact": "...", + "create_at": "...", + "device": { /* ответ GET /about с устройства */ } + } + } +} +``` + +--- + +### `GET /api/v1/devices/id/{id}/status` +Живое состояние устройства — проксирует `GET /status` на само устройство. + +**Ответ:** +```json +{ + "status": true, + "data": { + "device": { + "id": 12, + "alias": "...", + "device_response": { /* ответ GET /status с устройства */ } + } + } +} +``` + +--- + +### `GET /api/v1/devices/scanning/setup` +Сканировать сеть и вернуть только устройства в режиме `setup` (ещё не добавленные). +Параллельный curl_multi по диапазону IP из конфига (`192.168.2.2–192.168.2.254`). + +**Ответ:** `data.devices` — массив ответов `/about` найденных устройств. + +--- + +### `GET /api/v1/devices/scanning/all` +Сканировать сеть и вернуть все найденные устройства (любой статус). + +--- + +### `POST /api/v1/devices/setup/new-device` +Добавить устройство. Сервер запрашивает `/about`, сохраняет в БД, генерирует токен, отправляет его устройству через `/set_token`, задаёт имя через `/set_device_name`. + +**Тело:** +```json +{ + "device_ip": "192.168.2.42", + "alias": "kitchen_relay", + "name": "Реле кухни", + "description": "..." +} +``` + +**Ответ (успех):** `data.device` — объект нового устройства. + +**Ответ (ошибка):** `error_alias` — `invalid_ip` | `empty_field` | `alias_already_exists` | `device_not_found` | `device_mode_error` | `db_error` + +--- + +### `POST /api/v1/devices/action` +Выполнить действие на устройстве — проксирует `POST /action` на само устройство. + +**Тело:** +```json +{ + "device_id": 12, + "action": "toggle_channel", + "params": { "channel": 0 } +} +``` + +**Ответ (успех):** +```json +{ + "status": true, + "data": { + "device": { + "id": 12, + "alias": "...", + "device_response": { /* ответ устройства */ } + } + } +} +``` + +--- + +### `POST /api/v1/devices/update-name` +Обновить имя устройства (в БД + на самом устройстве через `/set_device_name`). + +**Тело:** `{ "device_id": 12, "name": "Новое имя" }` + +--- + +### `POST /api/v1/devices/update-description` +Обновить описание устройства (только в БД). + +**Тело:** `{ "device_id": 12, "description": "..." }` + +--- + +### `POST /api/v1/devices/update-alias` +Обновить alias устройства (только в БД). **Осторожно:** сломает скрипты, использующие старый alias. + +**Тело:** `{ "device_id": 12, "new_alias": "new_alias_name" }` + +--- + +### `POST /api/v1/devices/place-in-area` +Поместить устройство в область. + +**Тело:** `{ "target_id": 12, "place_in_area_id": 3 }` + +--- + +### `GET /api/v1/devices/id/{id}/unassign-from-area` +Отвязать устройство от области (сделать «без области»). + +--- + +### `POST /api/v1/devices/resetup` +Переустановить токен устройства (перепривязка). + +**Тело:** `{ "device_id": 12 }` + +--- + +### `POST /api/v1/devices/reset` +Сбросить устройство к заводским настройкам (вызывает `POST /reset` на устройстве). + +**Тело:** `{ "device_id": 12 }` + +--- + +### `GET /api/v1/devices/id/{id}/reboot` +Перезагрузить устройство. + +--- + +### `GET /api/v1/devices/id/{id}/remove` +Удалить устройство: сбрасывает устройство (`POST /reset`), деактивирует токен, помечает запись как `removed` в БД. + +--- + +## Области `/api/v1/areas` + +Области — иерархическая структура физических пространств (комнаты, этажи, здания). Устройства и скрипты можно размещать в областях. + +### `GET /api/v1/areas/list` +Список всех областей. + +**Ответ:** `data.areas` — массив объектов AREA, `data.total`. + +```json +{ + "id": 2, + "type": "room", + "alias": "kitchen", + "display_name": "Кухня", + "parent_area_id": 0 +} +``` + +--- + +### `GET /api/v1/areas/id/{area_id}/list` +Список дочерних областей указанной области. + +--- + +### `POST /api/v1/areas/new-area` +Создать новую область. + +**Тело:** `{ "type": "room", "alias": "kitchen", "display_name": "Кухня" }` + +**Ответ:** `data.alias`, `data.area`. + +**Ошибки:** `alias_already_exists` | `empty_field` (поля `type`, `display_name`) + +--- + +### `GET /api/v1/areas/id/{area_id}/remove` +Удалить область. Все устройства и дочерние области внутри — отвязываются (но не удаляются). Нельзя удалить area_id ≤ 1. + +--- + +### `POST /api/v1/areas/place-in-area` +Вложить одну область в другую. + +**Тело:** `{ "target_id": 5, "place_in_area_id": 2 }` + +--- + +### `GET /api/v1/areas/id/{area_id}/unassign-from-area` +Отвязать область от родительской (поднять на верхний уровень). + +--- + +### `POST /api/v1/areas/update-display-name` +Переименовать область. + +**Тело:** `{ "area_id": 2, "display_name": "Кухня (1 этаж)" }` + +--- + +### `POST /api/v1/areas/update-alias` +Изменить alias области. **Осторожно:** сломает скрипты, использующие старый alias. + +**Тело:** `{ "area_id": 2, "new_alias": "kitchen_floor_1" }` + +--- + +### `GET /api/v1/areas/id/{area_id}/devices` +Список устройств в области (включая вложенные области). + +**Ответ:** `data.devices` — массив устройств, `data.total`. + +--- + +### `GET /api/v1/areas/id/{area_id}/scripts` +Список скриптов, размещённых в области (action + regular). + +**Ответ:** `data.scripts`, `data.total`. + +--- + +### `GET /api/v1/areas/id/{area_id}/reboot_devices` +### `GET /api/v1/areas/reboot_devices` +Перезагрузить все устройства в области (или все устройства системы). + +**Ответ:** +```json +{ + "status": true, + "data": { + "results": { + "kitchen:relay_1": { /* ответ от устройства */ } + }, + "total": 1 + } +} +``` + +--- + +### `GET /api/v1/areas/types/list` +Список всех существующих типов областей в БД (для подсказок при создании новой). + +**Ответ:** `data.types` — массив строк, например `["room", "floor", "building"]`. + +--- + +## Скрипты `/api/v1/scripts` + +Управление тремя типами скриптов: **action** (ручной запуск), **regular** (cron), **scope** (PHP-класс, контейнер для action + regular). + +### `GET /api/v1/scripts/actions/list` +Список всех зарегистрированных action-скриптов с их состоянием (enabled/disabled). + +**Ответ:** +```json +{ + "status": true, + "data": { + "scripts": [ + { + "alias": "kitchen_light_toggle", + "name": "Свет на кухне", + "icon": "", + "description": "...", + "author": "Eugene Sukhodolskiy", + "state": "enabled", + "filename": "LightHubScope.php", + "path": "/srv/http/smart-home-serv.local/server/ControlScripts" + } + ], + "total": 3 + } +} +``` + +--- + +### `GET /api/v1/scripts/regular/list` +Список всех зарегистрированных regular-скриптов. + +--- + +### `GET /api/v1/scripts/scopes/list` +Список всех Scope-классов с их состоянием. + +**Ответ:** `data.scopes` — массив `{ name, filename, state, path }`, `data.total`. + +--- + +### `GET /api/v1/scripts/scopes/name/{name}` +Получить исходный код PHP-файла Scope. Возвращает raw PHP-код (не JSON). + +--- + +### `POST /api/v1/scripts/actions/run` +Запустить action-скрипт вручную. + +**Тело:** +```json +{ + "alias": "kitchen_light_toggle", + "params": {} +} +``` + +**Ответ:** +```json +{ + "status": true, + "data": { + "return": { + "result": { /* возвращаемое скриптом */ }, + "exec_time": "0.042 seconds" + } + } +} +``` + +**Ошибка:** `action_script_not_found` (если alias не существует или скрипт disabled) + +--- + +### `GET /api/v1/scripts/actions/alias/{alias}/enable` +### `GET /api/v1/scripts/actions/alias/{alias}/disable` +Включить / выключить action-скрипт (записывает состояние в БД). + +--- + +### `GET /api/v1/scripts/regular/alias/{alias}/enable` +### `GET /api/v1/scripts/regular/alias/{alias}/disable` +Включить / выключить regular-скрипт. + +--- + +### `GET /api/v1/scripts/actions/scope/{name}/enable` +### `GET /api/v1/scripts/actions/scope/{name}/disable` +Включить / выключить весь Scope (все его скрипты перестают регистрироваться при следующем старте сервера). + +--- + +### `POST /api/v1/scripts/scopes/update` +Перезаписать содержимое PHP-файла Scope. + +**Тело:** `{ "name": "LightHubScope", "path": "/srv/.../ControlScripts", "file": "`. + +--- + +## Cron-маршруты + +Не для клиента — вызываются планировщиком на сервере. + +| Endpoint | Действие | +|----------|---------| +| `GET /cron/regular-scripts` | Запустить все enabled regular-скрипты | +| `GET /cron/status-update-scanning` | Сканировать сеть, обновить `connection_status` и `device_ip` в БД | + +--- + +## Запланировано, не реализовано + +Следующие разделы описаны в спецификациях (`docs/server-api-v1/`), но ещё не реализованы: + +| Раздел | Файл спеки | +|--------|-----------| +| Авторизация | `auth.md` | +| Пользователи | `users.md` | +| Группы пользователей и права | `groups.md` | +| Логи | `logs.md` | +| Уведомления | `notifications.md` | diff --git a/docs/server-spec.md b/docs/server-spec.md index e18884f..4ad9881 100644 --- a/docs/server-spec.md +++ b/docs/server-spec.md @@ -16,9 +16,33 @@ ```json { - "device_id": "ecf0a1b5c9d74f9a8e294c1f67b0a8b9", // Уникальный идентификатор устройства - "event_name": "button_press", // Название события (button_press, state_change, sensor_data и т.д.) - "data": { // Объект с данными события, структура зависит от event_name и device_type - // специфичные для события поля + "device_id": "ecf0a1b5c9d74f9a8e294c1f67b0a8b9", + "event_name": "button_press", + "data": { + "channel": 0 } } +``` + +Поле `data` — произвольный объект, структура зависит от `event_name` и `device_type`. +Если `data` содержит поле `channel`, сервер дополнительно генерирует события с привязкой к каналу. + +#### Пример успешного ответа + +```json +{ "status": "ok" } +``` + +#### Пример ответа при неизвестном устройстве + +```json +{ + "status": false, + "error_alias": "unknown_device", + "failed_fields": ["device_id"] +} +``` + +#### Обработка события на сервере + +Сервер отвечает `200 OK` немедленно, а затем (асинхронно, через `fastcgi_finish_request`) триггерит события через внутреннюю шину в нескольких форматах — см. `docs/architecture.md`, раздел «Система событий». diff --git a/rest_api_debug_tool/.gitignore b/rest_api_debug_tool/.gitignore deleted file mode 100644 index b694934..0000000 --- a/rest_api_debug_tool/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv \ No newline at end of file diff --git a/rest_api_debug_tool/app.py b/rest_api_debug_tool/app.py deleted file mode 100644 index ad7d041..0000000 --- a/rest_api_debug_tool/app.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import json -import re -from typing import Any, Dict, Tuple - -import requests -from flask import Flask, jsonify, request, send_from_directory - -app = Flask(__name__, static_folder="static") - -HOP_BY_HOP_HEADERS = { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -} - -PRIVATE_HOST_RE = re.compile(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0)$", re.I) - - -def _filter_response_headers(headers: Dict[str, str]) -> Dict[str, str]: - out: Dict[str, str] = {} - for k, v in headers.items(): - if k.lower() in HOP_BY_HOP_HEADERS: - continue - out[k] = v - return out - - -def _safe_target_url(url: str) -> Tuple[bool, str]: - # Базовая защита от совсем странного ввода. - # Для локальной разработки можно оставлять шире, но хотя бы не даём file:// и т.п. - if not url: - return False, "Empty url" - if not (url.startswith("http://") or url.startswith("https://")): - return False, "Only http/https URLs are allowed" - return True, "" - - -@app.get("/") -def index(): - return send_from_directory(app.static_folder, "index.html") - - -@app.post("/proxy") -def proxy(): - data: Dict[str, Any] = request.get_json(silent=True) or {} - - url = str(data.get("url", "")).strip() - method = str(data.get("method", "GET")).upper().strip() - body_type = str(data.get("body_type", "none")).strip() - headers_in = data.get("headers") or {} - body_in = data.get("body", "") - - ok, err = _safe_target_url(url) - if not ok: - return jsonify({"error": err}), 400 - - if not isinstance(headers_in, dict): - return jsonify({"error": "headers must be an object/dict"}), 400 - - # Вырезаем хоп-бай-хоп заголовки - headers: Dict[str, str] = {} - for k, v in headers_in.items(): - if not isinstance(k, str): - continue - if k.lower() in HOP_BY_HOP_HEADERS: - continue - headers[k] = str(v) - - timeout_s = 30 - - try: - req_kwargs: Dict[str, Any] = { - "headers": headers, - "timeout": timeout_s, - "allow_redirects": False, - } - - if method in ("GET", "HEAD"): - # body не отправляем - pass - else: - if body_type == "json": - # body_in может быть строкой JSON или объектом - if isinstance(body_in, (dict, list)): - req_kwargs["json"] = body_in - else: - # строка - text = str(body_in) - if text.strip(): - req_kwargs["data"] = text.encode("utf-8") - else: - req_kwargs["data"] = b"" - # если Content-Type не задан — проставим - if not any(k.lower() == "content-type" for k in headers.keys()): - req_kwargs["headers"]["Content-Type"] = "application/json; charset=utf-8" - - elif body_type == "form": - # body_in ожидаем как строку "a=1\nb=2" или dict - if isinstance(body_in, dict): - req_kwargs["data"] = body_in - else: - lines = [l.strip() for l in str(body_in).splitlines() if l.strip()] - form: Dict[str, str] = {} - for line in lines: - if "=" not in line: - continue - k, v = line.split("=", 1) - form[k.strip()] = v.strip() - req_kwargs["data"] = form - if not any(k.lower() == "content-type" for k in headers.keys()): - req_kwargs["headers"]["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" - - elif body_type == "raw": - req_kwargs["data"] = (str(body_in)).encode("utf-8") - if not any(k.lower() == "content-type" for k in headers.keys()): - req_kwargs["headers"]["Content-Type"] = "text/plain; charset=utf-8" - else: - # none - pass - - resp = requests.request(method, url, **req_kwargs) - - resp_headers = _filter_response_headers(dict(resp.headers)) - content_type = resp.headers.get("content-type", "") - - # Возвращаем тело как текст (включая JSON строкой) - # Если бинарь — лучше base64, но ты писал что в основном JSON/текст - try: - resp_text = resp.text - except Exception: - resp_text = resp.content.decode("utf-8", errors="replace") - - return jsonify( - { - "status": resp.status_code, - "headers": resp_headers, - "content_type": content_type, - "body": resp_text, - } - ), 200 - - except requests.RequestException as e: - return jsonify({"error": f"Upstream request failed: {str(e)}"}), 502 - - -if __name__ == "__main__": - # http://127.0.0.1:8000 - app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/rest_api_debug_tool/requirements.txt b/rest_api_debug_tool/requirements.txt deleted file mode 100644 index ebbdb01..0000000 --- a/rest_api_debug_tool/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -blinker==1.9.0 -certifi==2025.11.12 -charset-normalizer==3.4.4 -click==8.3.1 -Flask==3.1.2 -idna==3.11 -itsdangerous==2.2.0 -Jinja2==3.1.6 -MarkupSafe==3.0.3 -requests==2.32.5 -urllib3==2.6.2 -Werkzeug==3.1.4 diff --git a/rest_api_debug_tool/static/index.html b/rest_api_debug_tool/static/index.html deleted file mode 100644 index cac71af..0000000 --- a/rest_api_debug_tool/static/index.html +++ /dev/null @@ -1,454 +0,0 @@ - - - - - - REST API Tester - - - - - - - - - - -
-
-
-
-
-
-
-

REST API Tester

-
POST JSON / обычные URL запросы, заголовки, параметры, просмотр ответа.
-
-
- - -
-
- -
- -
-
- - -
- Примечание: если используешь локальные домены/HTTP, может упереться в CORS. В таком случае запускай это как расширение/через локальный прокси или делай запросы с backend-прокси. -
-
- -
- - -
- -
- - -
- -
-
-
- - -
-
- - -
-
-
- -
- - -
- - - -
-
- -
- -
-
-
- - -
-
-
-
-
Response
-
- - - - -
-
- -
- -
-
- -
-
- -
- - - -
- - -
- - -
-
-
- -
-
-
- -
-
- - - - - - - - - - - - diff --git a/tools/channels_schema_changer/app.py b/tools/channels_schema_changer/app.py new file mode 100644 index 0000000..f235c1e --- /dev/null +++ b/tools/channels_schema_changer/app.py @@ -0,0 +1,479 @@ +from flask import Flask, request, redirect, url_for, render_template_string +import requests +from typing import Any, Dict, List, Optional, Tuple + +app = Flask(__name__) +app.config["SECRET_KEY"] = "sh-schema-editor-dev" + +CHANNEL_COUNT = 8 +CHANNEL_BYTES = 4 +SCHEMA_LEN = CHANNEL_COUNT * CHANNEL_BYTES +SH_PIN_UNUSED = 255 + +FIELD_NAMES = ["pin", "indicator", "feedback", "flags"] +FIELD_LABELS = { + "pin": "Pin", + "indicator": "Indicator", + "feedback": "Feedback", + "flags": "Flags", +} + +HTML = """ + + + + + + SH Channels Schema Editor + + + +
+
+
+

SH Channels Schema Editor

+
Port 8001. Reads and writes /channels_schema and /set_channels_schema.
+
+ {% if device_ip %} +
Device: {{ device_ip }}
+ {% endif %} +
+ + {% if message %} +
{{ message }}
+ {% endif %} + +
+

Connection

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + {% if schema %} +
+

Schema editor

+
+ + + + {% for channel in channels %} +
+
+
Channel {{ channel.index }}
+
Bytes {{ channel.base }}..{{ channel.base + 3 }}
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ {% endfor %} + +
+ + + +
+
+
+ +
+

Raw schema

+
{{ schema | join(', ') }}
+
+ {% endif %} +
+ + +""" + + +def build_headers(token: str) -> Dict[str, str]: + headers: Dict[str, str] = {"Content-Type": "application/json"} + token = token.strip() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def normalize_ip(device_ip: str) -> str: + device_ip = device_ip.strip() + if device_ip.startswith("http://") or device_ip.startswith("https://"): + return device_ip.rstrip("/") + return f"http://{device_ip}" + + +def parse_device_schema(payload: Dict[str, Any]) -> List[int]: + schema = payload.get("schema") + if not isinstance(schema, list): + raise ValueError("Device response does not contain a schema array") + if len(schema) != SCHEMA_LEN: + raise ValueError(f"Schema length is {len(schema)}, expected {SCHEMA_LEN}") + + normalized: List[int] = [] + for value in schema: + if not isinstance(value, int): + raise ValueError("Schema contains a non-integer value") + if value < 0 or value > 255: + raise ValueError("Schema values must be in range 0..255") + normalized.append(value) + return normalized + + +def get_schema(device_ip: str, token: str) -> List[int]: + base_url = normalize_ip(device_ip) + response = requests.get( + f"{base_url}/channels_schema", + headers=build_headers(token), + timeout=5, + ) + response.raise_for_status() + data = response.json() + if data.get("status") == "error": + raise ValueError(data.get("message") or data.get("error") or "Device returned an error") + return parse_device_schema(data) + + +def set_schema(device_ip: str, token: str, schema: List[int]) -> Dict[str, Any]: + base_url = normalize_ip(device_ip) + response = requests.post( + f"{base_url}/set_channels_schema", + headers=build_headers(token), + json={"schema": schema}, + timeout=8, + ) + response.raise_for_status() + data = response.json() + if data.get("status") == "error": + raise ValueError(data.get("message") or data.get("error") or "Device returned an error") + return data + + +def split_schema(schema: List[int]) -> List[Dict[str, Any]]: + channels: List[Dict[str, Any]] = [] + for index in range(CHANNEL_COUNT): + base = index * CHANNEL_BYTES + flags = schema[base + 3] + channels.append( + { + "index": index, + "base": base, + "pin": schema[base + 0], + "indicator": schema[base + 1], + "feedback": schema[base + 2], + "flags": flags, + "invert": (flags & 0x01) != 0, + } + ) + return channels + + +def coerce_byte(value: Optional[str], field_name: str) -> int: + if value is None: + raise ValueError(f"Missing value for {field_name}") + value = value.strip() + if value == "": + raise ValueError(f"Empty value for {field_name}") + try: + parsed = int(value) + except ValueError as exc: + raise ValueError(f"Invalid integer for {field_name}") from exc + if parsed < 0 or parsed > 255: + raise ValueError(f"Value for {field_name} must be 0..255") + return parsed + + +def schema_from_form(form: Any) -> List[int]: + schema: List[int] = [] + for ch in range(CHANNEL_COUNT): + pin = coerce_byte(form.get(f"pin_{ch}"), f"pin_{ch}") + indicator = coerce_byte(form.get(f"indicator_{ch}"), f"indicator_{ch}") + feedback = coerce_byte(form.get(f"feedback_{ch}"), f"feedback_{ch}") + flags = coerce_byte(form.get(f"flags_{ch}"), f"flags_{ch}") + + if form.get(f"invert_{ch}"): + flags |= 0x01 + else: + flags &= 0xFE + + schema.extend([pin, indicator, feedback, flags]) + return schema + + +def render_page( + *, + device_ip: str = "", + token: str = "", + schema: Optional[List[int]] = None, + message: str = "", + message_type: str = "info", +) -> str: + return render_template_string( + HTML, + device_ip=device_ip, + token=token, + schema=schema, + channels=split_schema(schema) if schema else [], + message=message, + message_type=message_type, + ) + + +@app.get("/") +def index() -> str: + return render_page() + + +@app.post("/connect") +def connect() -> str: + device_ip = request.form.get("device_ip", "").strip() + token = request.form.get("token", "").strip() + + if not device_ip: + return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err") + + try: + schema = get_schema(device_ip, token) + except Exception as exc: + return render_page(device_ip=device_ip, token=token, message=f"Failed to load schema: {exc}", message_type="err") + + return render_page(device_ip=device_ip, token=token, schema=schema, message="Schema loaded successfully.", message_type="ok") + + +@app.post("/save") +def save_schema() -> str: + device_ip = request.form.get("device_ip", "").strip() + token = request.form.get("token", "").strip() + + if not device_ip: + return render_page(device_ip=device_ip, token=token, message="Device IP is required.", message_type="err") + + try: + schema = schema_from_form(request.form) + + fill_unused_channel = request.form.get("fill_unused") + if fill_unused_channel is not None: + ch = int(fill_unused_channel) + base = ch * CHANNEL_BYTES + schema[base + 0] = SH_PIN_UNUSED + schema[base + 1] = SH_PIN_UNUSED + schema[base + 2] = SH_PIN_UNUSED + schema[base + 3] = 0 + return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Channel {ch} marked as unused. Not saved yet.", message_type="info") + + if request.form.get("fill_all_unused"): + schema = [] + for _ in range(CHANNEL_COUNT): + schema.extend([SH_PIN_UNUSED, SH_PIN_UNUSED, SH_PIN_UNUSED, 0]) + return render_page(device_ip=device_ip, token=token, schema=schema, message="All channels marked as unused. Not saved yet.", message_type="info") + + if request.form.get("normalize"): + return render_page(device_ip=device_ip, token=token, schema=schema, message="Flags normalized from checkboxes. Not saved yet.", message_type="info") + + result = set_schema(device_ip, token, schema) + return render_page(device_ip=device_ip, token=token, schema=schema, message=result.get("message", "Schema saved."), message_type="ok") + except Exception as exc: + try: + schema = schema_from_form(request.form) + except Exception: + schema = None + return render_page(device_ip=device_ip, token=token, schema=schema, message=f"Failed to save schema: {exc}", message_type="err") + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8001, debug=True) diff --git a/tools/channels_schema_changer/requirements.txt b/tools/channels_schema_changer/requirements.txt new file mode 100644 index 0000000..6e49680 --- /dev/null +++ b/tools/channels_schema_changer/requirements.txt @@ -0,0 +1,12 @@ +blinker==1.9.0 +certifi==2026.2.25 +charset-normalizer==3.4.5 +click==8.3.1 +Flask==3.1.3 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +requests==2.32.5 +urllib3==2.6.3 +Werkzeug==3.1.6 diff --git a/tools/device_interface_dev/setup.html b/tools/device_interface_dev/setup.html new file mode 100644 index 0000000..b40d9a1 --- /dev/null +++ b/tools/device_interface_dev/setup.html @@ -0,0 +1,382 @@ + + + + + WiFi setup + + + + + +
+
+
+

WiFi setup

+
+ +
+
+
+

+ Connect the device to your home WiFi network. +

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
Last Wi-Fi SSID: (Empty)
+ +
+ +
+
+
+
+
Wi-Fi networks
Scanning...
Scanning ERROR
100%
90%
88%
60%
34%
+
+
+
+
+
+

ESP SmartHome Device

+
+
+ + + + + \ No newline at end of file diff --git a/tools/rest_api_debug_tool/.gitignore b/tools/rest_api_debug_tool/.gitignore new file mode 100644 index 0000000..b694934 --- /dev/null +++ b/tools/rest_api_debug_tool/.gitignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/tools/rest_api_debug_tool/app.py b/tools/rest_api_debug_tool/app.py new file mode 100644 index 0000000..ad7d041 --- /dev/null +++ b/tools/rest_api_debug_tool/app.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import re +from typing import Any, Dict, Tuple + +import requests +from flask import Flask, jsonify, request, send_from_directory + +app = Flask(__name__, static_folder="static") + +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + +PRIVATE_HOST_RE = re.compile(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0)$", re.I) + + +def _filter_response_headers(headers: Dict[str, str]) -> Dict[str, str]: + out: Dict[str, str] = {} + for k, v in headers.items(): + if k.lower() in HOP_BY_HOP_HEADERS: + continue + out[k] = v + return out + + +def _safe_target_url(url: str) -> Tuple[bool, str]: + # Базовая защита от совсем странного ввода. + # Для локальной разработки можно оставлять шире, но хотя бы не даём file:// и т.п. + if not url: + return False, "Empty url" + if not (url.startswith("http://") or url.startswith("https://")): + return False, "Only http/https URLs are allowed" + return True, "" + + +@app.get("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.post("/proxy") +def proxy(): + data: Dict[str, Any] = request.get_json(silent=True) or {} + + url = str(data.get("url", "")).strip() + method = str(data.get("method", "GET")).upper().strip() + body_type = str(data.get("body_type", "none")).strip() + headers_in = data.get("headers") or {} + body_in = data.get("body", "") + + ok, err = _safe_target_url(url) + if not ok: + return jsonify({"error": err}), 400 + + if not isinstance(headers_in, dict): + return jsonify({"error": "headers must be an object/dict"}), 400 + + # Вырезаем хоп-бай-хоп заголовки + headers: Dict[str, str] = {} + for k, v in headers_in.items(): + if not isinstance(k, str): + continue + if k.lower() in HOP_BY_HOP_HEADERS: + continue + headers[k] = str(v) + + timeout_s = 30 + + try: + req_kwargs: Dict[str, Any] = { + "headers": headers, + "timeout": timeout_s, + "allow_redirects": False, + } + + if method in ("GET", "HEAD"): + # body не отправляем + pass + else: + if body_type == "json": + # body_in может быть строкой JSON или объектом + if isinstance(body_in, (dict, list)): + req_kwargs["json"] = body_in + else: + # строка + text = str(body_in) + if text.strip(): + req_kwargs["data"] = text.encode("utf-8") + else: + req_kwargs["data"] = b"" + # если Content-Type не задан — проставим + if not any(k.lower() == "content-type" for k in headers.keys()): + req_kwargs["headers"]["Content-Type"] = "application/json; charset=utf-8" + + elif body_type == "form": + # body_in ожидаем как строку "a=1\nb=2" или dict + if isinstance(body_in, dict): + req_kwargs["data"] = body_in + else: + lines = [l.strip() for l in str(body_in).splitlines() if l.strip()] + form: Dict[str, str] = {} + for line in lines: + if "=" not in line: + continue + k, v = line.split("=", 1) + form[k.strip()] = v.strip() + req_kwargs["data"] = form + if not any(k.lower() == "content-type" for k in headers.keys()): + req_kwargs["headers"]["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" + + elif body_type == "raw": + req_kwargs["data"] = (str(body_in)).encode("utf-8") + if not any(k.lower() == "content-type" for k in headers.keys()): + req_kwargs["headers"]["Content-Type"] = "text/plain; charset=utf-8" + else: + # none + pass + + resp = requests.request(method, url, **req_kwargs) + + resp_headers = _filter_response_headers(dict(resp.headers)) + content_type = resp.headers.get("content-type", "") + + # Возвращаем тело как текст (включая JSON строкой) + # Если бинарь — лучше base64, но ты писал что в основном JSON/текст + try: + resp_text = resp.text + except Exception: + resp_text = resp.content.decode("utf-8", errors="replace") + + return jsonify( + { + "status": resp.status_code, + "headers": resp_headers, + "content_type": content_type, + "body": resp_text, + } + ), 200 + + except requests.RequestException as e: + return jsonify({"error": f"Upstream request failed: {str(e)}"}), 502 + + +if __name__ == "__main__": + # http://127.0.0.1:8000 + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/tools/rest_api_debug_tool/requirements.txt b/tools/rest_api_debug_tool/requirements.txt new file mode 100644 index 0000000..ebbdb01 --- /dev/null +++ b/tools/rest_api_debug_tool/requirements.txt @@ -0,0 +1,12 @@ +blinker==1.9.0 +certifi==2025.11.12 +charset-normalizer==3.4.4 +click==8.3.1 +Flask==3.1.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +requests==2.32.5 +urllib3==2.6.2 +Werkzeug==3.1.4 diff --git a/tools/rest_api_debug_tool/static/index.html b/tools/rest_api_debug_tool/static/index.html new file mode 100644 index 0000000..cac71af --- /dev/null +++ b/tools/rest_api_debug_tool/static/index.html @@ -0,0 +1,454 @@ + + + + + + REST API Tester + + + + + + + + + + +
+
+
+
+
+
+
+

REST API Tester

+
POST JSON / обычные URL запросы, заголовки, параметры, просмотр ответа.
+
+
+ + +
+
+ +
+ +
+
+ + +
+ Примечание: если используешь локальные домены/HTTP, может упереться в CORS. В таком случае запускай это как расширение/через локальный прокси или делай запросы с backend-прокси. +
+
+ +
+ + +
+ +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + + +
+
+ +
+ +
+
+
+ + +
+
+
+
+
Response
+
+ + + + +
+
+ +
+ +
+
+ +
+
+ +
+ + + +
+ + +
+ + +
+
+
+ +
+
+
+ +
+
+ + + + + + + + + + + +