Newer
Older
smart-home-server / docs / architecture.md

Архитектура системы

Политика управления устройствами

Ни веб-клиент, ни сами умные устройства не имеют права напрямую управлять другими устройствами через внешний 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):

// Добавить поля в JSON-ответ /status
void appendStatusJsonFields(String &json);

// Добавить поля в JSON-ответ /about
void appendAboutJsonFields(String &json);

// Обработать входящее действие /action
bool deviceHandleAction(const String &action, const String &paramsJson,
                        String &errorCode, String &errorMessage);

// Выполнить сброс к заводским настройкам
void deviceHandleReset();

И задать три глобальных константы:

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

Виртуальный эмулятор (для разработки)

Для локальной разработки и тестирования без реального железа используется виртуальный эмулятор на Python + Flask (tools/virtual_devices/).

Эмулятор повторяет полный REST-контракт реальных устройств:

  • GET /about, GET /status, POST /action
  • POST /set_token, POST /reset, POST /reboot
  • GET /channels_schema, POST /set_channels_schema

Поддерживаемые типы:

  • relay — управление каналами (set_state: on/off)
  • button — генерация событий (POST /events/new на сервер)

Регистрация в сервере происходит через стандартный API POST /api/v1/devices/setup/new-device. Сервер устанавливает токен через /set_token так же, как и для реального устройства.

Детальное руководство: docs/virtual-device-emulator.md. | 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 <token>.
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.phpFury\Kernel\InitFury\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 Basetoggle_channel(ch), set_state(bool), set_channel_state(bool, ch)
  • Button extends Baseget_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:

{
  "device_id": "ecf0a1b5c9d74f9a8e294c1f67b0a8b9",
  "event_name": "press",
  "data": { "channel": 0 }
}

Требует Authorization: Bearer <device_token>.

Жизненный цикл на сервере

[EventsController::new_event()]
    1. Найти устройство по device_hard_id
    2. Проверить auth (device_auth)
    3. Залогировать событие
    4. Обновить last_contact и connection_status
    5. Ответить 200 OK немедленно
    6. fastcgi_finish_request() — асинхронное продолжение
        ↓
[EventsModel] — триггерит 5 вариантов через Fury Events

Почему 5 вариантов? Одно физическое событие триггерит 5 имён — это позволяет подписываться на разном уровне детализации.

Пять паттернов событий

Для button, alias kitchen_btns, канал 2, событие press:

Паттерн Имя события Когда использовать
1 {event_name} press Глобально — любое устройство
2 {device_type}.{event_name} button.press Все устройства типа
3 {device_type}@{alias}.{event_name} button@kitchen_btns.press Конкретное устройство
4 {device_type}({ch}).{event_name} button(2).press Все устройства, канал 2
5 {device_type}@{alias}({ch}).{event_name} button@kitchen_btns(2).press Точно: устройство + канал

EventsModel (server/SHServ/Models/EventsModel.php)

// 1. Канал + alias + channel
$events_model->channel_alias_device_event_call($device, $event_name, $channel, $data);
// → button@kitchen_btns(2).press

// 2. Канал + type + channel
$events_model->channel_device_event_call($device, $event_name, $channel, $data);
// → button(2).press

// 3. Alias (все каналы)
$events_model->alias_device_event_call($device, $event_name, $data);
// → button@kitchen_btns.press

// 4. Тип устройства
$events_model->global_device_event_call($device, $event_name, $data);
// → button.press

// 5. Глобально (любое устройство)
$events_model->global_any_device_event_call($device, $event_name, $data);
// → press

Полное описание: docs/events-from-devices.md.


Control Scripts (server/SHServ/Middleware/ControlScripts.php)

Скрипты автоматизации — это PHP-классы (Scope), расположенные в server/ControlScripts/Scopes/ (или другом месте, если настроено иначе).

Базовый класс

Все Scope-классы:

  • Наследуют \SHServ\Middleware\ControlScripts
  • Реализуют \SHServ\Implements\ControlScriptsInterface
  • Автоматически загружаются при старте сервера
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 { ... }
}

Четыре регистрационных метода

Метод Назначение Когда вызывается
register_sync_map() Связи реле ↔ кнопки для синхронизации индикаторов При старте сервера
register_events_handlers() Подписка на события от устройств При старте сервера
register_actions_scripts() Action-скрипты (ручной запуск) При старте сервера
register_regular_scripts() Regular-скрипты (периодические) При старте сервера

Типы скриптов

Action-скрипты — ручной запуск через POST /api/v1/scripts/actions/run:

$this->add_action_script([
    "alias"  => "kitchen_light_toggle",
    "name"   => "Свет на кухне",
    "icon"   => '<i class="ph ph-lightbulb"></i>',
], function($params) {
    $relay = $this->devices()->by_alias("kitchen_relay");
    $relay->device_api()->toggle_channel(0);
    return ["result" => true];
});

Regular-скрипты — периодический запуск через GET /cron/regular-scripts:

$this->add_regular_script([
    "alias" => "check_door_sensor",
    "name"  => "Проверка датчика двери",
], function() {
    // периодическая логика
});

Event-хендлеры — реакция на события от устройств:

// Конкретная кнопка, канал 1
$this->add_event_handler("button@kitchen_btns(1).press", function(Device $device, array $data) {
    $relay = $this->devices()->by_alias("kitchen_relay");
    $relay->device_api()->toggle_channel(0);
    $this->helper()->sync_relay_to_btns($this->sync_map(), "kitchen_relay");
});

// Устройство онлайн — синхронизировать индикаторы
$this->add_event_handler("button@kitchen_btns.online", function(Device $device, array $data) {
    $this->helper()->sync_btn_channels($this->sync_map(), $device->alias);
});

Sync map

Декларативное описание связей «реле ↔ кнопки» для синхронизации индикаторов:

public function register_sync_map(): void {
    $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, $relay_alias) — синхронизировать кнопки с реле
  • sync_btn_channels($sync_map, $btn_alias) — синхронизировать индикаторы кнопки с реле

Полное описание: docs/control-scripts-guide.md, docs/events-from-devices.md.


Веб-клиент

Vue Client (webclient/) — основной

Современный SPA на Vue 3 + Pinia + Vite + vue-router (hash mode).

webclient/
├── index.php                 — entry point (serve dist/index.html)
├── vite.config.js            — Vite конфиг (dev server + build)
├── src/
│   ├── app/main.js           — Vue app entry (createApp + Pinia + router)
│   ├── router/routes.js      — hash-роуты
│   ├── router/index.js       — настройки роутера + навигация логи
│   ├── api/
│   │   ├── client.js         — базовый API клиент
│   │   ├── http.js           — HTTP слой (fetch, логирование)
│   │   ├── mappers.js        — маппинг DTO → domain
│   │   └── modules/          — devices, areas, scripts, scanning
│   ├── stores/               — Pinia stores (Composition API)
│   │   ├── devices.js
│   │   ├── areas.js
│   │   ├── scripts.js
│   │   └── favorites.js
│   ├── features/             — feature-модули (pages, components)
│   │   ├── devices/pages/
│   │   ├── areas/pages/
│   │   └── scripts/pages/
│   ├── components/           — общие UI компоненты
│   └── utils/logger.js       — централизованное логирование
└── dist/                     — production build

Команды:

cd webclient
npm run dev       # dev server с прокси на PHP
npm run build     # production build → dist/
npm test          # Vitest тесты

Стилевые правила:

  • Использовать семантические классы gnexus-ui-kit (.text-success, .badge-warning, и т.д.)
  • Не использовать CSS custom properties (--color-*) напрямую

Legacy Client (webclient_legacy/) — архив

Vanilla JS + esbuild + SCSS. Поддерживается для обратной совместимости.

webclient_legacy/
├── src/js/
│   ├── index.js              — entry point
│   ├── sh/SmartHomeApi.js    — callback-style API клиент
│   ├── routes.js             — hash роутинг
│   └── components/           — UI компоненты (Screens, modals, toasts)
└── dist/                     — сборка через gulp

Команды:

cd webclient_legacy
npm start         # gulp: SCSS → CSS, JS bundle, live-reload