# Руководство по написанию Control Scripts

Control Scripts — это PHP-классы автоматизации. Базовый класс находится в `server/SHServ/Middleware/ControlScripts.php`, интерфейс — в `server/SHServ/Implements/ControlScriptsInterface.php`.

Scope-классы располагаются в `automation/Scopes/` (или другом месте, если настроено иначе). Все Scope-файлы загружаются автоматически при старте сервера.

---

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

Все Scope-классы наследуют `\SHServ\Middleware\ControlScripts` и реализуют `\SHServ\Implements\ControlScriptsInterface`:

```php
<?php

namespace ControlScripts\Scopes;

class MyScope extends \SHServ\Middleware\ControlScripts 
              implements \SHServ\Implements\ControlScriptsInterface {
    // ...
}
```

**Автоматическая загрузка:**  
При старте сервера каждый Scope-класс:
1. Проверяет состояние в БД (таблица `scripts`, тип `scope`, имя = название класса)
2. Если scope включён (или это первый запуск) — вызывает 4 регистрационных метода
3. Методы регистрируют обработчики в статические коллекции Fury Events

---

---

## Рекомендуемая структура Scope-классов

По умолчанию **один Scope = одна физическая комната или чёткая зона**. Это совпадает с тем, как человек мыслит свой дом: «включить свет на кухне», «автоматика в спальне».

### Что кладём в Scope комнаты

| Что | Пример |
|-----|--------|
| **Sync map** | Кнопки и реле, физически находящиеся в этой комнате |
| **Events** | `button@kitchen_buttons_1.press`, `motion@hall_sensor.online` |
| **Actions** | Ручные скрипты для устройств комнаты |
| **Regular** | Автоматика комнаты: автоотключение света в ванне через 10 мин |

### Исключения — функциональные скоупы

Когда логика явно не привязана к одной комнате, выделяйте отдельный Scope:

| Тип | Пример |
|-----|--------|
| **Уличное / общее** | `OutdoorScope` — прожекторы, кнопки у входных дверей |
| **Сценарии** | `ScenesScope` — «Спокойной ночи», «Ушёл из дома» |
| **Инфраструктура** | `SystemScope` — health-check'и, резервное копирование, watchdog |

### Cross-room связи

Кнопки из одной комнаты часто управляют светом в другой (например, выключатель у входной двери — холл и улица). **Sync map остаётся централизованным** в `\ControlScripts\Common::register_global_device_sync_map()` — так проще искать, с чем синхронизируется конкретное устройство, не перебирая все Scope.

### Trait `Common`

```php
use \ControlScripts\Common;

class KitchenScope extends ControlScripts implements ControlScriptsInterface {
    use Common; // только register_global_device_sync_map()

    public function register_sync_map(): void {
        $this->register_global_device_sync_map();
    }
}
```

**`Common` содержит только sync map.** Все хелперы (`auto_button_bindings`, `add_toggle_action`, `group_toggle` и т.д.) живут в базовом классе `ControlScripts`.

---

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

Каждый Scope-класс обязан реализовать 4 метода. Они вызываются при старте сервера (если scope включён в БД).

### 1. `register_sync_map(): void`

Регистрирует связи «реле ↔ кнопки» для синхронизации индикаторов.

```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],
    ]);
}
```

Sync map хранится в статической переменной `ControlScripts::$sync_map_storage`.

---

### 2. `register_events_handlers(): void`

Подписывается на события от устройств. Обработчик вызывается **асинхронно** после ответа устройству (`fastcgi_finish_request`).

```php
public function register_events_handlers(): void {
    // Нажатие кнопки (канал 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);
    });
}
```

**Паттерны имён событий:**

| Паттерн | Пример | Описание |
|---------|---------|---------|
| `{event_name}` | `press` | Любое устройство, любое событие |
| `{type}.{event_name}` | `button.press` | Все устройства типа `button` |
| `{type}@{alias}.{event_name}` | `button@kitchen_btns.press` | Конкретное устройство (все каналы) |
| `{type}({ch}).{event_name}` | `button(1).press` | Все устройства типа, канал 1 |
| `{type}@{alias}({ch}).{event_name}` | `button@kitchen_btns(1).press` | Конкретное устройство, канал 1 |

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

---

### 3. `register_actions_scripts(): void`

Регистрирует action-скрипты (ручной запуск через UI/API).

```php
public function register_actions_scripts(): void {
    $this->add_action_script([
        "alias"       => "kitchen_light_toggle",
        "name"        => "Свет на кухне",
        "icon"        => '<i class="ph ph-lightbulb"></i>',
        "description" => "Включить/выключить основной свет",
        "author"      => "Eugene Sukhodolskiy"
    ], function($params) {
        $relay = $this->devices()->by_alias("kitchen_relay");
        $relay->device_api()->toggle_channel(0);
        $this->helper()->sync_relay_to_btns($this->sync_map(), "kitchen_relay");
        return ["result" => true];
    });
}
```

**API:**
- `POST /api/v1/scripts/actions/run` — запустить скрипт
- `GET /api/v1/scripts/actions/alias/{alias}/enable` — включить
- `GET /api/v1/scripts/actions/alias/{alias}/disable` — выключить

---

### 4. `register_regular_scripts(): void`

Регистрирует regular-скрипты (периодический запуск по cron).

```php
public function register_regular_scripts(): void {
    $this->add_regular_script([
        "alias" => "check_door_sensor",
        "name"  => "Проверка датчика двери",
    ], function() {
        $sensor = $this->devices()->by_alias("door_sensor");
        // ...
    });
}
```

**API:**
- `GET /cron/regular-scripts` — запустить все enabled regular-скрипты (cron)
- `GET /api/v1/scripts/regular/alias/{alias}/enable` — включить
- `GET /api/v1/scripts/regular/alias/{alias}/disable` — выключить

---

## Доступные методы базового класса

| Метод | Возвращает | Описание |
|-------|------------|----------|
| `$this->devices()` | `\SHServ\Models\Devices` | Поиск устройств по alias, id, hard_id |
| `$this->helper()` | `\SHServ\Helpers\DeviceScriptsHelper` | Хелперы синхронизации реле/кнопок |
| `$this->sync_map()` | `array` | Текущий sync_map (статическое хранилище) |
| `$this->add_event_handler($name, $cb)` | `void` | Подписаться на событие |
| `$this->add_action_script($attrs, $cb)` | `bool` | Зарегистрировать action-скрипт |
| `$this->add_regular_script($attrs, $cb)` | `bool` | Зарегистрировать regular-скрипт |
| `$this->add_sync_connection($entries)` | `void` | Добавить связь в sync_map |

---

## DeviceScriptsHelper

Класс `\SHServ\Helpers\DeviceScriptsHelper` предоставляет методы для синхронизации индикаторов кнопок с состоянием реле.

### Методы

| Метод | Описание |
|-------|---------|
| `sync_relay_to_btn_channel($relay_api, $btn_api, $relay_ch, $btn_ch)` | Синхронизировать один канал реле с одним каналом кнопки |
| `sync_relay_to_btns($sync_map, $relay_alias)` | Синхронизировать все кнопки из sync_map с указанным реле |
| `sync_btn_channels($sync_map, $btn_alias)` | Синхронизировать индикаторы указанной кнопки с реле из sync_map |
| `get_sync_entries_by_type($sync_map, $type)` | Получить все записи указанного типа из sync_map |
| `prepare_sync_map_by_alias($sync_map, $alias)` | Подготовить sync_map для указанного устройства |
| `get_relay_state($alias, $channel=0)` | Получить состояние канала реле: `true`/`false`/`null` |
| `get_hatch_state($alias)` | Получить состояние люка: `"open"`/`"closed"`/`null` |
| `make_relay_indicator($alias, $channel=0)` | Сформировать `indicator` для реле |
| `make_group_indicators($targets)` | Сформировать массив `indicator`-ов для группы реле |
| `make_hatch_indicator($alias)` | Сформировать `indicator` для люка |

### Пример использования

```php
// В event-хендлере: переключить реле и синхронизировать кнопки
$relay = $this->devices()->by_alias("kitchen_relay");
$relay_api = $relay->device_api();
$relay_api->toggle_channel(0);

// Синхронизировать все кнопки, связанные с этим реле
$this->helper()->sync_relay_to_btns($this->sync_map(), "kitchen_relay");

// Или: синхронизировать конкретную кнопку с реле
$this->helper()->sync_btn_channels($this->sync_map(), "kitchen_btns");
```

---

## Управление Scope через API

| Endpoint | Описание |
|----------|---------|
| `GET /api/v1/scripts/actions/scope/{name}/enable` | Включить Scope (в БД) |
| `GET /api/v1/scripts/actions/scope/{name}/disable` | Выключить Scope (в БД) |
| `GET /api/v1/scripts/scopes/list` | Список всех Scope с состоянием |
| `GET /api/v1/scripts/scopes/name/{name}` | Исходный код PHP-файла Scope |
| `POST /api/v1/scripts/scopes/update` | Обновить код Scope |

**Важно:** Scope отключается через БД — при следующем запуске сервера его скрипты не зарегистрируются.

---

## Trait Common

Trait `\ControlScripts\Common` хранит **централизованную карту синхронизации** всех устройств дома:

```php
use \ControlScripts\Common;

class KitchenScope extends \SHServ\Middleware\ControlScripts {
    use Common;

    public function register_sync_map(): void {
        $this->register_global_device_sync_map();
    }
}
```

**Почему sync map централизован:** кнопки из одной комнаты управляют светом в другой, и искать связи по всем Scope неудобно. `Common` остаётся единственным источником правды для `sync_map`.

Все остальные хелперы (`auto_button_bindings`, `add_toggle_action`, `group_toggle` и т.д.) находятся в базовом классе `ControlScripts`.

Проверьте наличие trait в вашем проекте: `automation/Common.php`.

---

## Таймеры и задержки (Timers)

Scope-классы могут планировать отложенный запуск action-скриптов, regular-скриптов или событий.

### API внутри Scope

| Метод | Описание |
|-------|---------|
| `$this->delay_action($timer_alias, $action_alias, $params, $delay_seconds)` | Запланировать запуск action-скрипта через N секунд |
| `$this->delay_event($timer_alias, $event_name, $params, $delay_seconds)` | Запланировать вызов события через N секунд |
| `$this->delay_regular($timer_alias, $regular_alias, $delay_seconds)` | Запланировать запуск regular-скрипта через N секунд |
| `$this->cancel_timer($timer_alias)` | Отменить ожидающий таймер с указанным alias |

**Повторный `delay_*` с тем же `timer_alias` внутри одного Scope заменяет старый pending-таймер.**

### Пример

```php
public function register_events_handlers(): void {
    // Двойное нажатие кнопки включает свет на 5 минут
    $this->add_event_handler("button@kitchen_btns(1).double_press", function($device, $data) {
        $this->devices()->by_alias("kitchen_relay")->device_api()->toggle_channel(0);

        // Автовыключение через 5 минут
        $this->delay_action("kitchen_auto_off", "kitchen_light_toggle", [], 300);
    });

    // Одинарное нажатие отменяет таймер (свет остаётся включённым)
    $this->add_event_handler("button@kitchen_btns(1).press", function($device, $data) {
        $this->cancel_timer("kitchen_auto_off");
    });
}
```

### Как это работает

1. `delay_*` создаёт запись в таблице `shserv_timers` со статусом `pending`
2. Cron-задача `GET /cron/timers` (метод `CronController::run_timers()`) раз в минуту выбирает `pending` таймеры, у которых `execute_at <= NOW()`
3. Таймер выполняется inline с `try/catch`:
   - `action` → `ControlScripts::run_action_script()`
   - `event` → `events()->app_call()`
   - `regular` → `ControlScripts::run_regular_script()`
4. После выполнения статус меняется на `executed` или `failed` (с логом ошибки)

### REST API для таймеров

| Endpoint | Описание |
|----------|---------|
| `GET /api/v1/scripts/timers/list` | Список таймеров (все статусы) |
| `POST /api/v1/scripts/timers/cancel` | Отменить pending-таймер по `timer_alias` |

---

## Params Schema для action scripts

Action-скрипты могут декларировать схему входных параметров. Vue-клиент автоматически отображает форму в модалке при запуске скрипта. Сервер валидирует параметры по схеме перед вызовом closure.

### Объявление в Scope

```php
public function register_actions_scripts(): void {
    $this->add_action_script([
        "alias"         => "dim_lights",
        "name"          => "Диммер",
        "description"   => "Установить яркость в комнате",
        "params_schema" => [
            "level" => [
                "type"     => "range",
                "label"    => "Яркость, %",
                "min"      => 0,
                "max"      => 100,
                "step"     => 5,
                "default"  => 50,
                "required" => true,
            ],
            "room" => [
                "type"    => "select",
                "label"   => "Комната",
                "options" => [
                    "kitchen" => "Кухня",
                    "hall"    => "Зал",
                    "bedroom" => "Спальня",
                ],
                "default"  => "hall",
                "required" => true,
            ],
            "instant" => [
                "type"    => "toggle",
                "label"   => "Мгновенно (без плавного диммирования)",
                "default" => false,
            ],
        ],
    ], function($params) {
        $level   = $params['level']   ?? 50;
        $room    = $params['room']    ?? 'hall';
        $instant = $params['instant'] ?? false;
        // ...
    });
}
```

### Поддерживаемые типы полей

| Тип | Компонент Vue | Параметры schema |
|-----|---------------|------------------|
| `text` | `GnInput` | `label`, `placeholder`, `required`, `default` |
| `number` | `GnInput type="number"` | `label`, `placeholder`, `min`, `max`, `required`, `default` |
| `range` | `GnRange` | `label`, `min`, `max`, `step`, `required`, `default` |
| `select` | `GnSelect` | `label`, `options` (ассоциативный массив `value => label`), `required`, `default` |
| `toggle` | `GnSwitch` | `label`, `default` |
| `textarea` | `GnTextarea` | `label`, `placeholder`, `required`, `default` |

### Правила валидации

- **Валидация на сервере:** перед вызовом closure `POST /api/v1/scripts/actions/run` проверяет `params` по schema. При ошибке — `invalid_params` с массивом `failed_fields`.
- **Default values:** если поле не передано и не `required`, сервер подставляет `default` из schema.
- **Required:** поле обязано присутствовать в запросе (для `text`/`textarea` — не пустая строка).
- **Границы:** для `number` и `range` проверяется `min` и `max`.
- **Select:** значение должно быть ключом из `options`.

---

## Уровень опасности (Danger Level)

Action-скрипты могут быть помечены одним из трёх уровней опасности. Vue-клиент использует эту метку для визуальной индикации (цвет рамки карточки) и для запроса подтверждения перед запуском.

| Уровень | Поведение UI |
|---------|-------------|
| `safe` (default) | Запускается сразу, обычная рамка |
| `cautious` | Confirm dialog с `variant="warning"`, оранжевая рамка |
| `dangerous` | Confirm dialog с `variant="dangerous"`, красная рамка |

### Объявление в Scope

```php
$this->add_hatch_action("hatch_open", "Открыть люк", "hatch_motor", "open", 100, [
    "danger_level" => "dangerous",
]);
```

**Валидация:** неизвестное значение приводит к `safe`.

---

## Индикаторы состояния (State Callback)

Action-скрипт может декларировать `state_callback` — closure, который вызывается при формировании ответа API `GET /api/v1/scripts/actions/list`. Результат отображается в UI как набор `GnBadge`-ов рядом с карточкой скрипта.

### Формат возвращаемого значения

Callback должен вернуть массив элементов:

```php
[
    ["label" => "On",  "variant" => "success"],
    ["label" => "Off", "variant" => "secondary"],
    ["label" => "Open", "variant" => "warning"],
]
```

Поддерживаемые `variant` — те же, что у `GnBadge`: `primary`, `success`, `warning`, `danger`, `secondary`.

### Объявление в Scope

```php
$this->add_group_toggle_action("kitchen1_toggle", "Кухня 1", [
    "fisrt_floor_big_relay:0",
], [
    "state_callback" => function() {
        return $this->helper()->make_relay_indicator("fisrt_floor_big_relay", 0);
    },
]);
```

### Factory-методы в DeviceScriptsHelper

Для стандартных устройств хелпер предоставляет готовые callback-фабрики:

| Метод | Описание |
|-------|---------|
| `make_relay_indicator($alias, $channel=0)` | Состояние канала реле: `On`/`Off`/`?` |
| `make_group_indicators($targets)` | Массив индикаторов для группы реле |
| `make_hatch_indicator($alias)` | Состояние люка: `Open`/`Closed`/`?` |

**Ошибки callback игнорируются сервером** — если устройство недоступно, индикатор просто не попадёт в ответ API.

---

## Хелперы для упрощения написания скриптов

Base class `ControlScripts` предоставляет high-level обёртки над повторяющимися паттернами.

### Action script generators

| Метод | Описание |
|-------|----------|
| `$this->add_toggle_action($alias, $name, $relay_alias, $channel=0, $extra_attrs=[])` | Зарегистрировать action script toggle + auto-sync |
| `$this->add_on_action($alias, $name, $relay_alias, $channel=null, $extra_attrs=[])` | Включить реле (одно- или многоканальное) + auto-sync |
| `$this->add_off_action($alias, $name, $relay_alias, $channel=null, $extra_attrs=[])` | Выключить реле + auto-sync |
| `$this->add_hatch_action($alias, $name, $hatch_alias, $op, $percent=100, $extra_attrs=[])` | Открыть/закрыть люк |
| `$this->add_group_toggle_action($alias, $name, $pairs, $extra_attrs=[])` | Групповой toggle нескольких реле |

**`$extra_attrs`** поддерживает любые атрибуты из `add_action_script()`, включая `"danger_level"`, `"state_callback"`, `"description"`, `"author"`, `"icon"`, `"params_schema"`. Неизвестные ключи безопасно игнорируются. Все стандартные хелперы пробрасывают `$extra_attrs` в `add_action_script()` через `array_merge`. Для `group_toggle` и `group_set_state` targets также поддерживают строку `"alias:channel"`. |

Пример — **было / стало**:

```php
// Было: 9 строк boilerplate
$this->add_action_script([
    "alias" => "computer_table_lamp_switch",
    "name"  => "Комп. лампа",
    "icon"  => '<i class="ph ph-lamp"></i>',
], function($params) {
    return $this->lamp_switch("computer_table_lamp", 0);
});

// Стало: 1 строка
$this->add_toggle_action("computer_table_lamp_switch", "Комп. лампа", "computer_table_lamp", 0,
    ["icon" => '<i class="ph ph-lamp"></i>']
);
```

### Автобиндинг кнопок

```php
// Было: 16 вызовов в LightHubScope
$this->btn_on_online("master_room_btns", [2, 3]);
$this->set_btns_click_handlers("master_room_btns");
$this->btn_on_online("btns_hall2_1");
$this->set_btns_click_handlers("btns_hall2_1");
// ... ещё 6 раз

// Стало: 1 вызов
$this->auto_button_bindings([
    "master_room_btns"  => ["mute" => [2, 3]],
    "btns_hall2_1"      => [],
    "hall_secondary"    => [],
    "bed_btns_right_1"  => [],
    "kitchen_buttons_1" => [],
]);
```

### Групповые операции

```php
// Выключить всё разом (внутри action script или event handler)
$this->group_off([
    "hall_relay",
    ["light_hub_1", 0],
    ["light_hub_1", 1],
]);

// Групповой toggle
$this->group_toggle([
    "spotlight_main_front_1",
    "spotlight_main_back_1",
    "spotlight_main_back_2",
]);
```

Методы `group_set_state` и `group_toggle` автоматически синхронизируют индикаторы кнопок.

---

## Системные моды (System Modes)

Моды — это boolean-теги (не взаимоисключающие), которые задают текущий контекст системы: «Дома», «Не дома», «Ночь», «Сон», «Кино» и т.д. Scope-классы могут проверять активные моды в хендлерах и action-скриптах, меняя поведение в зависимости от ситуации.

### Реестр модов

Все моды декларируются централизованно в `automation/ModesRegistry.php`:

```php
class ModesRegistry {
    public static function definitions(): array {
        return [
            'home' => [
                'label'       => 'Дома',
                'description' => 'Основной режим присутствия. Все системы работают в штатном режиме.',
            ],
            'away' => [
                'label'       => 'Не дома',
                'description' => 'Никого нет дома. Активны охранные сценарии и энергосбережение.',
            ],
            'night' => [
                'label'       => 'Ночь',
                'description' => 'Пониженная яркость освещения, тихие уведомления.',
            ],
            'sleep' => [
                'label'       => 'Сон',
                'description' => 'Минимум света и звука. Датчики движения переведены в тихий режим.',
            ],
        ];
    }
}
```

> **Label и description живут в коде** — БД (`shserv_modes`) хранит только runtime-состояние (`is_active`). При первом обращении к `ModesContext` реестр автоматически синхронизируется с таблицей (`INSERT IGNORE`).

### API внутри Scope

| Метод | Описание |
|-------|---------|
| `$this->mode()->is('night')` | Проверить, активен ли конкретный мод |
| `$this->mode()->any(['night', 'sleep'])` | Хотя бы один из модов активен |
| `$this->mode()->all(['away', 'sleep'])` | Все перечисленные моды активны |
| `$this->mode()->active()` | Массив активных тегов |
| `$this->mode()->enable('away')` | Активировать мод |
| `$this->mode()->disable('away')` | Деактивировать мод |
| `$this->mode()->toggle('movie')` | Переключить мод |
| `$this->mode()->meta('away')` | Получить `['label' => ..., 'description' => ...]` |

### Пример

```php
public function register_events_handlers(): void {
    // При движении в коридоре — яркость зависит от времени суток
    $this->add_event_handler("motion.hall", function($device, $data) {
        if ($this->mode()->any(['night', 'sleep'])) {
            $this->run_action('dim_hall_lights', ['level' => 20]);
        } else {
            $this->run_action('dim_hall_lights', ['level' => 80]);
        }
    });
}

public function register_actions_scripts(): void {
    // Ручной скрипт «Уйти из дома»
    $this->add_action_script([
        "alias" => "set_mode_away",
        "name"  => "Не дома",
    ], function($params) {
        $this->mode()->enable('away');
        $this->mode()->disable('home');
        // ... выключить весь свет, включить охрану
    });
}
```

### REST API для модов

| Endpoint | Описание |
|----------|---------|
| `GET /api/v1/modes/list` | Список всех модов с label, description, is_active |
| `GET /api/v1/modes/active` | Массив активных тегов |
| `POST /api/v1/modes/{tag}/enable` | Активировать мод |
| `POST /api/v1/modes/{tag}/disable` | Деактивировать мод |

> Моды не взаимоисключающие — можно одновременно включить `away` + `sleep`. Если нужно эмулировать взаимоисключение (например, «Дома» vs «Не дома»), управляйте этим явно в action-скрипте: `$this->mode()->enable('away'); $this->mode()->disable('home');`.
