# Руководство пользователя по созданию драйверов для Dynamic Driver

## Содержание

1. [Введение](#введение)
2. [Подготовка к разработке](#подготовка-к-разработке)
3. [Транспорты](#транспорты)
4. [Структура драйвера](#структура-драйвера)
5. [Пошаговая разработка драйвера](#пошаговая-разработка-драйвера)
6. [Тестирование драйвера](#тестирование-драйвера)
7. [Расширенные возможности](#расширенные-возможности)
8. [Устранение неполадок](#устранение-неполадок)

## Введение

Dynamic Driver позволяет создавать собственные драйверы для управления различными устройствами через Node-RED.

В этом руководстве мы рассмотрим процесс создания драйвера с нуля, шаг за шагом, и расскажем о лучших практиках разработки.

## Подготовка к разработке

### Необходимые инструменты

1. Базовые знания JavaScript и Node.js
2. Документация на протокол взаимодействия с вашим устройством


### Где создаётся драйвер

Драйверы пишутся во встроенном **редакторе драйверов** (Monaco) в админ-панели Node-RED — отдельные файлы вручную создавать не нужно, доступ к файловой системе не требуется. Имя, заданное драйверу в редакторе, — это его идентификатор (`driverName`), под которым он появится в выпадающем списке узла **Device Connection**. Подробнее — в разделе «Шаг 1».

## Транспорты

Dynamic Driver поддерживает несколько типов транспортов для связи с устройствами. Выбор транспорта зависит от протокола взаимодействия с вашим устройством.

### Обзор транспортов

| Транспорт | Описание | Типичное применение |
|-----------|----------|---------------------|
| **TCP** | TCP/IP соединение с поддержкой TLS | Сетевые устройства, AV-оборудование |
| **UDP** | UDP датаграммы | Broadcast, multicast, быстрые команды |
| **SSH** | Защищённое соединение через SSH | Серверы, Linux-устройства |
| **Telnet** | Telnet-протокол с авторизацией | Legacy-оборудование, CLI-интерфейсы |
| **HTTP** | REST API запросы | Веб-сервисы, современные API |
| **Local** | Loopback (без сети) | Логические узлы, виртуальные устройства |
| **RS232** | Последовательный порт через встроенный контроллер | Serial/COM, проекторы, legacy serial AV |
| **WebSocket** | WebSocket-соединение (ws/wss) | WebSocket API, real-time web-устройства |
| **IR** | Инфракрасные команды (send/learn) через встроенный контроллер | ИК ТВ, кондиционеры, проекторы |

### Контекстные переменные в транспортах

Сетевые транспорты TCP, UDP, SSH, Telnet и HTTP поддерживают использование **контекстных переменных** Node-RED (flow/global/env) для параметров подключения. RS232 и IR работают через встроенный контроллер, и контекстные переменные к ним неприменимы.

```javascript
// В настройках транспорта можно указать:
Host: global.deviceIP      // Из global контекста
Port: flow.devicePort      // Из flow контекста  
User: env.DEVICE_USER      // Из переменных окружения
```

Если контекстная переменная ещё не установлена при запуске, транспорт будет ожидать и повторять попытки подключения с интервалом, указанным в настройках `reconnectInterval` (при включенном `autoConnect`).

### Local (Loopback) транспорт

Local транспорт — это специальный "пустой" транспорт, который работает **без сетевого подключения**. Все отправленные данные немедленно возвращаются обратно как входящие.

#### Зачем нужен Local транспорт?

- **Логические узлы** — таймеры, агрегаторы, конвертеры данных
- **Виртуальные устройства** — драйверы без физического оборудования
- **Тестирование** — отладка драйверов без реального устройства
- **Интеграция** — драйверы, работающие с контекстом Node-RED

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

```
Device Command → Driver.sendCommand() → local-config.write(data)
                                              │
                                              ▼ (loopback)
Response Listener ← Driver.processResponse() ← data
```

1. **Device Command** отправляет команду
2. Драйвер форматирует её через `sendCommand()`
3. **local-config** получает данные и сразу возвращает их обратно
4. Драйвер обрабатывает в `processResponse()`
5. **Response Listener** получает результат

#### Пример драйвера для Local транспорта

```javascript
const BaseDriver = require('base-driver');

class CounterDriver extends BaseDriver {
    static metadata = {
        name: 'Counter Driver',
        manufacturer: 'Virtual',
        version: '1.0.0',
        description: 'Счётчик с хранением в global контексте',
        category: 'Virtual',
        parameters: [
            { name: 'counterKey', type: 'string', default: 'myCounter' }
        ]
    };
    
    static commands = {
        Increment: {
            description: 'Увеличить счётчик',
            parameters: [
                { name: 'step', type: 'number', default: 1 }
            ]
        },
        GetValue: {
            description: 'Получить текущее значение'
        },
        Reset: {
            description: 'Сбросить счётчик'
        }
    };
    
    // Конструктор переопределять не нужно — this.config инициализируется автоматически
    
    // Команды возвращают JSON, который вернётся в processResponse.
    // Примечание: `return JSON.stringify({...})` — это форма «голой строки»,
    // эквивалентная `return { payload: JSON.stringify({...}) }`.
    Increment(params) {
        const key = this.config.counterKey || 'myCounter';
        const step = Number(params.step) || 1;
        const current = global.get(key) || 0;
        const newValue = current + step;
        
        global.set(key, newValue);
        
        return JSON.stringify({
            command: 'Increment',
            previousValue: current,
            step: step,
            newValue: newValue
        });
    }
    
    GetValue(params) {
        const key = this.config.counterKey || 'myCounter';
        const value = global.get(key) || 0;
        
        return JSON.stringify({
            command: 'GetValue',
            value: value
        });
    }
    
    Reset(params) {
        const key = this.config.counterKey || 'myCounter';
        global.set(key, 0);
        
        return JSON.stringify({
            command: 'Reset',
            value: 0
        });
    }
    
    // Парсинг ответа от loopback
    parseResponse(data) {
        try {
            const payload = data.data || data.payload || data;
            return JSON.parse(payload);
        } catch (e) {
            return { error: 'Parse error', raw: data };
        }
    }
}

module.exports = CounterDriver;
```

#### Настройка Local транспорта

1. Создайте **Device Connection** с типом транспорта **Local (loopback)**
2. Создайте новый **Local Config** (настроек минимум — только имя и debug)
3. Выберите ваш драйвер
4. Добавьте **Device Command** и **Response Listener**

## Структура драйвера

Драйвер представляет собой модуль который включает в себя модуль `BaseDriver`. BaseDriver предоставляет необходимую функциональность для взаимодействия с системой и устройством.

### Основные элементы драйвера

- **Метаданные** - описательная информация о драйвере
- **Команды** - доступные для выполнения команды и их параметры
- **Обработчики ответов** - шаблоны для распознавания и обработки ответов устройства
- **Методы форматирования команд** - преобразование высокоуровневых команд в формат устройства
- **Метод инициализации** - настройка драйвера при подключении

## Пошаговая разработка драйвера

### Шаг 1: Создание драйвера в редакторе драйверов

Драйверы создаются и редактируются через встроенный **редактор драйверов** в админ-панели Node-RED — это код-редактор (Monaco) с автодополнением для `BaseDriver`, `static metadata`, `static commands`, `static responses`. **Доступ к файловой системе не нужен**: код драйвера сохраняет сам сервер, вручную создавать `.js`-файлы не требуется.

Порядок работы:

1. Откройте редактор драйверов и создайте новый драйвер.
2. Задайте драйверу **имя** — это его идентификатор (`driverName`), под которым он появится в выпадающем списке узла **Device Connection**. Имя экспортируемого класса и `metadata.name` на выбор драйвера **НЕ влияют** (`metadata.name` — лишь подпись для UI).
3. Напишите код драйвера (по шагам ниже) и **сохраните** в редакторе.

После сохранения драйвер доступен сразу — полный перезапуск Node-RED не нужен, изменения подхватываются автоматически. Чтобы новый драйвер появился в списке, переоткройте узел **Device Connection**.

### Шаг 2: Определение базовой структуры драйвера

```javascript
const BaseDriver = require('base-driver');

/**
 * Драйвер для устройства MyDevice
 */
class MyDeviceDriver extends BaseDriver {
  constructor(options) {
    super(options);
    // BaseDriver НЕ создаёт this.state — инициализируем сами,
    // иначе первая же команда, пишущая в this.state, бросит TypeError
    this.state = {};
  }
}

module.exports = MyDeviceDriver;
```

> **Допустимые модули в песочнице драйвера**
>
> Драйвер исполняется в песочнице с ограниченным `require`. Разрешены только:
> `base-driver`, `events`, `net`, `tls`, `http`, `https`, `dgram`, `dns`, `url`,
> `crypto`, `querystring`, `xml2js`, `buffer` (`Buffer`/`from`/`alloc`/`concat`),
> `util` (`inspect`/`format`/`promisify`), `path`
> (`basename`/`dirname`/`extname`/`join`/`normalize`/`sep`).
>
> Заблокированы: `fs`, `child_process`, `os`, `stream`, `zlib`, `vm`, `timers`,
> а также любые npm-пакеты (`axios`, `node-fetch` и т.п.). `eval` и `new Function` отключены.
>
> Таймеры доступны как глобалы `setTimeout` / `setInterval` / `setImmediate`
> (НЕ через `require('timers')`). Сетевой I/O выполняет транспорт, а не драйвер.

### Шаг 3: Добавление метаданных драйвера

Метаданные помогают системе идентифицировать драйвер и отображать информацию о нем в интерфейсе. Добавьте статическое свойство `metadata` в класс драйвера.

#### Структура metadata

```javascript
static metadata = {
    // === Обязательные поля ===
    name: 'MyDevice',              // Имя драйвера (отображается в списке)
    manufacturer: 'MyCompany',     // Производитель устройства
    version: '1.0.0',              // Версия драйвера (semver)
    
    // === Рекомендуемые поля ===
    description: 'Драйвер для устройства MyDevice от MyCompany',
    model: 'MD-1000',              // Модель устройства
    category: 'AV Equipment',      // Описательная категория (необязательно; UI её не группирует)
    
    // === Настройки транспорта по умолчанию ===
    transportDefaults: {
        tcp: { port: 23, delimiter: '\r\n' },
        telnet: { port: 23, shellPrompt: '[$#>]' }
    },
    
    // === Параметры драйвера (отображаются в UI) ===
    parameters: [
        { 
            name: 'apiKey', 
            type: 'string',
            label: 'API Key',
            description: 'Ключ для доступа к API устройства',
            default: '',
            required: true 
        },
        { 
            name: 'pollInterval', 
            type: 'number', 
            label: 'Интервал опроса (мс)',
            description: 'Как часто опрашивать устройство',
            default: 1000,
            min: 100,
            max: 60000
        }
    ]
};
```

#### Поля metadata

| Поле | Тип | Описание |
|------|-----|----------|
| `name` | string | **Обязательно.** Имя драйвера для отображения |
| `manufacturer` | string | Производитель устройства |
| `version` | string | Версия драйвера в формате semver |
| `description` | string | Описание назначения драйвера |
| `model` | string | Модель устройства |
| `category` | string | Описательная категория (необязательно; UI её не группирует) |
| `transportDefaults` | object | Настройки по умолчанию для транспортов |
| `parameters` | array | Массив параметров драйвера |

#### Параметры драйвера (parameters)

Параметры определяют настраиваемые значения, которые пользователь может задать в UI узла **Device Connection**. Значения автоматически разрешаются (включая `global`, `flow`, `env`) и передаются в конструктор драйвера.

##### Поля параметра

| Поле | Тип | Описание |
|------|-----|----------|
| `name` | string | **Обязательно.** Имя параметра (ключ в `options.customParams`) |
| `type` | string | Для параметров подключения фактический тип задаётся пользователем через typedInput (str/num/bool/global/flow/env); `password` НЕ маскирует ввод |
| `label` | string | Метка для отображения в UI |
| `description` | string | Подсказка для пользователя |
| `default` | any | Значение по умолчанию |
| `required` | boolean | Обязательный ли параметр |
| `min` | number | Минимальное значение (для `number`) |
| `max` | number | Максимальное значение (для `number`) |
| `enum` | array | Список допустимых значений |

> **Важно:** для параметров подключения редактор использует только `name` и `default`; поля `type`/`min`/`max`/`enum`/`required` **НЕ применяются** (они учитываются только для параметров команд — см. `static commands[].parameters`). В частности, нет маскирования пароля и нет dropdown'а для `enum`.

##### Примеры параметров

```javascript
parameters: [
    // Строковый параметр
    { 
        name: 'deviceId', 
        type: 'string',
        label: 'ID устройства',
        default: 'device-001',
        required: true
    },
    
    // Числовой параметр с ограничениями
    { 
        name: 'brightness', 
        type: 'number',
        label: 'Яркость по умолчанию',
        default: 50,
        min: 0,
        max: 100
    },
    
    // Булев параметр
    { 
        name: 'autoReconnect', 
        type: 'boolean',
        label: 'Авто-переподключение',
        default: true
    },
    
    // Секрет/токен — значение хранится и отображается в UI в открытом виде,
    // маскирования нет; для логина транспорта используйте поле Password самого транспорта
    { 
        name: 'apiSecret', 
        type: 'string',
        label: 'API Secret',
        required: true
    },
    
    // Подсказка о допустимых значениях; в UI подключения это обычное поле ввода, а не dropdown
    { 
        name: 'protocol', 
        type: 'string',
        label: 'Протокол',
        enum: ['v1', 'v2', 'legacy'],
        default: 'v2'
    }
]
```

#### Настройки транспорта по умолчанию (transportDefaults)

Драйвер может указать настройки, которые автоматически применятся к транспорту при его создании. Это полезно для указания порта по умолчанию, регулярных выражений для Telnet и других специфичных настроек.

##### Структура transportDefaults

```javascript
static metadata = {
    name: 'My Device',
    // ... другие поля ...
    
    transportDefaults: {
        // Настройки для TCP транспорта
        tcp: {
            port: 23,
            delimiter: '\r\n',
            autoConnect: true,
            reconnectInterval: 5000
        },
        
        // Настройки для Telnet транспорта
        telnet: {
            port: 23,
            usernamePrompt: 'login:',
            passwordPrompt: 'Password:',
            shellPrompt: '[\\S\\s]*[$#>]'
        },
        
        // Настройки для SSH транспорта
        ssh: {
            port: 22,
            delimiter: '\r\n'
        },
        
        // Настройки для UDP транспорта
        udp: {
            port: 5000,
            outip: '192.168.1.100'
        },
        
        // Настройки для HTTP транспорта
        http: {
            server: 'http://192.168.1.1',
            method: 'POST',
            ret: 'obj'  // JSON
        }
    }
};
```

##### Поддерживаемые поля по транспортам

| Транспорт | Поля |
|-----------|------|
| **TCP** | `host`, `port`, `delimiter`, `delimiterTimeout`, `keepDelimiter`, `bufferSize`, `reconnectInterval`, `ret`, `autoConnect`, `debug` |
| **Telnet** | `host`, `port`, `usernamePrompt`, `passwordPrompt`, `shellPrompt`, `delimiter`, `delimiterTimeout`, `keepDelimiter`, `bufferSize`, `reconnectInterval`, `autoConnect`, `debug` |
| **SSH** | `host`, `port`, `privateKey`, `passphrase`, `delimiter`, `delimiterTimeout`, `keepDelimiter`, `bufferSize`, `reconnectInterval`, `autoConnect`, `debug` |
| **UDP** | `port`, `outport`, `outip`, `ipv`, `datatype`, `delimiter`, `delimiterTimeout`, `keepDelimiter`, `autoConnect`, `debug` |
| **HTTP** | `server`, `method`, `ret`, `paytoqs`, `persist`, `senderr`, `insecureHTTPParser`, `rejectUnauthorized`, `followRedirects`, `requestTimeout`, `useAuth`, `authType` |

##### Описание полей

**Общие поля (TCP, Telnet, SSH, UDP):**
| Поле | Тип | Описание |
|------|-----|----------|
| `host` | string | IP-адрес или hostname устройства |
| `port` | number | Порт подключения |
| `delimiter` | string | Разделитель сообщений (`\r\n`, `\n`, hex:FF и т.д.) |
| `delimiterTimeout` | number | Таймаут ожидания делимитера (мс), 0 = отключено |
| `keepDelimiter` | boolean | Сохранять делимитер в полученном сообщении |
| `bufferSize` | number | Максимальный размер буфера в KB (по умолчанию 64) |
| `autoConnect` | boolean | Автоматическое управление соединением: подключение при старте + переподключение при разрыве |
| `debug` | boolean | Включить отладочные сообщения |

**TCP специфичные:**
| Поле | Тип | Описание |
|------|-----|----------|
| `reconnectInterval` | number | Интервал переподключения (мс), работает если `autoConnect: true` |
| `ret` | string | Тип возвращаемых данных: `buffer`, `string` |

**Telnet специфичные:**
| Поле | Тип | Описание |
|------|-----|----------|
| `usernamePrompt` | string | Regex для определения запроса логина |
| `passwordPrompt` | string | Regex для определения запроса пароля |
| `shellPrompt` | string | Regex для определения готовности shell |
| `reconnectInterval` | number | Интервал переподключения (мс), работает если `autoConnect: true` |

**SSH специфичные:**
| Поле | Тип | Описание |
|------|-----|----------|
| `privateKey` | string | Имя файла ключа в /var/admin/uploads/ |
| `passphrase` | string | Пароль для расшифровки ключа (если ключ защищён) |
| `reconnectInterval` | number | Интервал переподключения (мс), работает если `autoConnect: true` |

**UDP специфичные:**
| Поле | Тип | Описание |
|------|-----|----------|
| `outport` | number | Локальный порт для отправки |
| `outip` | string | IP-адрес назначения |
| `ipv` | string | Версия IP: `4` или `6` |
| `datatype` | string | Формат данных: `utf8`, `base64`, `hex` |

**HTTP специфичные:**
| Поле | Тип | Описание |
|------|-----|----------|
| `server` | string | URL сервера (http://...) |
| `method` | string | HTTP метод: `GET`, `POST`, `PUT`, `DELETE` |
| `ret` | string | Формат ответа: `txt`, `bin`, `obj` (JSON) |

> Поле `ret` определяет тип `data.payload` в `parseResponse`: `txt` → строка, `obj` → уже распарсенный объект (`JSON.parse` не нужен), `bin` → Buffer. Всегда проверяйте `typeof data.payload === 'string'` перед `JSON.parse`.
| `paytoqs` | string | Куда добавлять payload: `ignore`, `query`, `body` |
| `persist` | boolean | Использовать keep-alive соединение (повторное использование TCP-соединения) |
| `senderr` | boolean | При `true` ошибки отправляются только в Catch node (без выброса исключения) |
| `requestTimeout` | number | Таймаут запроса (мс) |
| `useAuth` | boolean | Включить аутентификацию; без `true` user/password/authType игнорируются |
| `authType` | string | Тип аутентификации: `basic`, `digest`, `bearer` |
| `rejectUnauthorized` | boolean | Проверять SSL сертификаты |
| `followRedirects` | boolean | Следовать переадресациям |

##### Логика применения

1. Defaults применяются **по нажатию кнопки "Apply driver defaults"** в редакторе транспорта
2. При нажатии кнопки **все указанные в драйвере поля перезаписываются** — текущие значения заменяются на defaults
3. Поля, не указанные в `transportDefaults`, остаются без изменений
4. Кнопка появляется только если для выбранного драйвера определены `transportDefaults` для данного типа транспорта

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

```javascript
class ExtronMatrixDriver extends BaseDriver {
    static metadata = {
        name: 'Extron Matrix',
        manufacturer: 'Extron',
        version: '1.0.0',
        
        transportDefaults: {
            tcp: {
                port: 23,
                delimiter: '\r\n'
            },
            telnet: {
                port: 23,
                usernamePrompt: '[\\S\\s]*Password[: ]*',  // Extron спрашивает только пароль
                passwordPrompt: '[\\S\\s]*Password[: ]*',
                shellPrompt: '[\\S\\s]*\\r\\n'
            }
        }
    };
}
```

При выборе этого драйвера в Device Connection, если пользователь создаст новый TCP или Telnet config, поля автоматически заполнятся указанными значениями.

##### Использование контекстных значений

В UI пользователь может выбрать тип значения параметра:

- **str** — статическая строка
- **num** — статическое число
- **bool** — true/false
- **global** — значение из `global.get('key')`
- **flow** — значение из `flow.get('key')`
- **env** — значение из переменных окружения

Система автоматически разрешит значения перед передачей в драйвер.

### Шаг 3.1: Использование параметров в драйвере

Если вы определили `parameters` в метаданных, их значения **автоматически** доступны через `this.config` сразу после создания экземпляра драйвера. Переопределять конструктор **не нужно**.

```javascript
// Параметры доступны в любом методе драйвера через this.config
initialize() {
    // this.config автоматически инициализирован из customParams
    if (this.config.autoQueryOnConnect) {
        this.publishCommand('getStatus');
    }
    
    // Использование параметров с дефолтными значениями
    this.pollInterval = this.config.pollInterval || 1000;
    this.apiKey = this.config.apiKey;
    
    console.log(`Драйвер инициализирован с интервалом: ${this.pollInterval}`);
}

// Пример использования в команде
setPower(params) {
    const timeout = this.config.commandTimeout || 5000;
    // ...
}
```

> **Примечание**: Начиная с версии 3.2.0, `this.config` инициализируется автоматически в `BaseDriver`. 
> Если вам нужна дополнительная обработка параметров, вы можете переопределить конструктор:

```javascript
constructor(options) {
    super(options);
    
    // this.config уже инициализирован в super()
    // Можно добавить дополнительную логику:
    this.effectiveTimeout = this.config.timeout || 5000;
    this.maxRetries = this.config.retries || 3;
}
```

### Шаг 4: Определение команд

Команды определяют действия, которые можно выполнить с устройством. Каждая команда описывается в объекте `static commands`. 
Для каждого параметра можно указать:
* `name`        — имя поля.
* `type`        — `string` | `number` | `boolean` | `enum` (типы `object`/`array` — только через `msg.parameters`, в UI не редактируются).
* `description` — пояснение.
* `required`    — если `true`, параметр обязателен. Узел «Device Command» проверит его наличие и подсветит поле в редакторе.
* Дополнительно: `min`/`max` (числа), `enum` (список допустимых значений).

> **Важно:** `min`/`max`/`enum`/`required` — подсказки ТОЛЬКО для редактора Device Command (статические поля). В runtime параметры могут прийти из `msg.parameters`/flow/global/JSONata и НЕ проверяются ни узлом, ни транспортом. Проверять и приводить значения ОБЯЗАН сам драйвер в теле метода.

```javascript
static commands = {
  setPower: {
    description: 'Включение/выключение устройства',
    parameters: [
      {
        name: 'value',
        type: 'boolean',
        description: 'Состояние питания (true=включено, false=выключено)',
        required: true
      }
    ]
  },
  setVolume: {
    description: 'Установка громкости',
    parameters: [
      {
        name: 'level',
        type: 'number',
        description: 'Уровень громкости (0-100)',
        required: true,
        min: 0,
        max: 100
      }
    ]
  },
  setInput: {
    description: 'Выбор входного источника',
    parameters: [
      {
        name: 'source',
        type: 'string',
        description: 'Имя источника (HDMI1, HDMI2, USB и т.д.)',
        required: true,
        enum: ['HDMI1', 'HDMI2', 'USB', 'Component', 'Composite']
      }
    ]
  },
  getStatus: {
    description: 'Запрос статуса устройства',
    parameters: []
  }
};
```

### Шаг 5: Определение обработчиков ответов

Обработчики ответов определяют, как драйвер будет интерпретировать данные, полученные от устройства. Используйте регулярные выражения для поиска шаблонов в ответах.

#### ⚠️ ВАЖНО: имя ключа = canonical имя сигнала

**Ключ записи в `static responses = { <ключ>: ... }` — это и есть имя сигнала**, под которым ответ ходит через всю систему:

- `data.response.type` (что эмитит runtime) ← `<ключ>` — base-driver автоматически выставляет это поле
- `device-response-listener.outputsMap[<ключ>]` — маршрутизация на выход узла
- `driven_by.signal_topic = "<device>.<ключ>"` — подписка room на сигнал
- UI редактора показывает `<ключ>` в dropdown'ах

Поэтому именуйте ключи **коротко и по смыслу** (то, под чем сигнал нужен в логике), а не описательно с суффиксом `Response`:

✅ ПРАВИЛЬНО:
```javascript
static responses = {
    mute:  { matcher: ..., extract: m => ({ channel: +m[1], muted: m[2] === 'true' }) },
    level: { matcher: ..., extract: m => ({ channel: +m[1], level: parseFloat(m[2]) }) },
    route: { matcher: ..., extract: m => ({ output: +m[1], input: +m[2] }) },
    power: { matcher: ..., extract: m => ({ state: m[1] === 'ON' }) }
};
// runtime эмитит data.response.type === 'mute' / 'level' / 'route' / 'power'
// outputsMap = {"mute": 0, "level": 1, "route": 2, "power": 3}
// driven_by.signal_topic = "myDsp.mute" / "myMatrix.route" / etc.
```

❌ НЕПРАВИЛЬНО:
```javascript
static responses = {
    muteResponse:  { ... },   // лишний 'Response' suffix во всех config
    levelResponse: { ... },   // outputsMap = {"muteResponse": 0} — уродливо
    routerResponse: { ... }   // driven_by.signal_topic = "myDsp.muteResponse"
};
```

#### `extract()` возвращает только данные, БЕЗ `type:`

`base-driver` автоматически выставляет `params.type = <ключ записи>`. Если `extract` вернёт свой `type:` — он будет затёрт. Не возвращайте `type:` из `extract` — это лишний код, который вводит в заблуждение и не работает.

✅ ПРАВИЛЬНО:
```javascript
mute: {
    matcher: { pattern: /(\d+) mute (true|false)/ },
    extract: m => ({ channel: +m[1], muted: m[2] === 'true' })
    // type будет авто = 'mute' (ключ записи)
}
```

❌ НЕПРАВИЛЬНО:
```javascript
mute: {
    matcher: { pattern: /(\d+) mute (true|false)/ },
    extract: m => ({ type: 'muteEvent', channel: +m[1], muted: m[2] === 'true' })
    //              ^^^^^^^^^^^^^^^^ затрётся — runtime получит type='mute'
}
```

#### Полный пример

```javascript
static responses = {
  status: {
    description: 'Статус устройства',
    matcher: {
      pattern: /Status: (.+), Power: (.+), Volume: (.+), Input: (.+)/
    },
    extract: function(match) {
      return {
        status: match[1],
        power: match[2] === 'ON',
        volume: parseInt(match[3], 10),
        input: match[4]
      };
    }
  },
  powerStatus: {
    description: 'Статус питания',
    matcher: {
      pattern: /Power: (.+)/
    },
    extract: function(match) {
      return {
        power: match[1] === 'ON'
      };
    }
  },
  volumeStatus: {
    description: 'Текущая громкость',
    matcher: {
      pattern: /Volume: (\d+)/
    },
    extract: function(match) {
      return {
        volume: parseInt(match[1], 10)
      };
    }
  },
  inputStatus: {
    description: 'Текущий вход',
    matcher: {
      pattern: /Input: (.+)/
    },
    extract: function(match) {
      return {
        input: match[1]
      };
    }
  },
  error: {
    description: 'Ошибка устройства',
    matcher: {
      pattern: /Error: (.+)/
    },
    extract: function(match) {
      return {
        message: match[1]
      };
    }
  }
};
```

### Шаг 6: Реализация методов команд

Для каждой команды создайте метод с тем же именем, который возвращает либо `{ payload: <string|Buffer> }`, либо непосредственно строку/Buffer — оба отправляются как есть. **ВАЖНО:** число или объект **БЕЗ** поля `payload` НЕ сериализуются (объект станет `'[object Object]'`), всегда возвращайте `{ payload }` или готовую строку/Buffer.

> **Важно:** BaseDriver НЕ создаёт `this.state` — драйвер обязан инициализировать его в конструкторе (`this.state = {}`), иначе первая же команда, пишущая в `this.state`, бросит `TypeError: Cannot set properties of undefined`.

```javascript
// Метод для команды setPower
setPower(params) {
  const { value } = params;
  // Обновляем состояние
  this.state.power = value;
  // Возвращаем объект с полем payload - форматированной командой
  return { payload: `PWR ${value ? 'ON' : 'OFF'}\r\n` };
}

// Метод для команды setVolume
setVolume(params) {
  const { level } = params;
  // Проверка диапазона
  const safeLevel = Math.max(0, Math.min(100, level));
  // Обновляем состояние
  this.state.volume = safeLevel;
  // Возвращаем объект с полем payload
  return { payload: `VOL ${safeLevel}\r\n` };
}

// Метод для команды setInput
setInput(params) {
  const { source } = params;
  // Проверка значения enum в runtime (узел/транспорт не проверяют параметры)
  if (!['HDMI1', 'HDMI2', 'USB', 'Component', 'Composite'].includes(source)) return null;
  // Обновляем состояние
  this.state.input = source;
  // Возвращаем объект с полем payload
  return { payload: `INPUT ${source}\r\n` };
}

// Метод для команды getStatus
getStatus() {
  // Команда без параметров
  return { payload: `STATUS\r\n` };
}
```

Форма возвращаемого payload зависит от транспорта:

* `{ payload: <string|Buffer> }` — для байт-стримовых транспортов (TCP / UDP / RS232 / SSH / Telnet / Local).
* `{ method, path, payload }` — для HTTP-транспорта (см. раздел «Работа с HTTP транспортом»).

### Шаг 7: Реализация метода инициализации

Метод `initialize` вызывается автоматически на КАЖДОЕ успешное подключение — при первом коннекте И при каждом переподключении; поэтому `initialize` ОБЯЗАН быть идемпотентным: снимайте прошлые таймеры/ресурсы перед повторным созданием. Используйте его для запроса начального состояния устройства или выполнения других действий при подключении.

```javascript
initialize() {
  // Запрос начального статуса устройства
  // Используйте publishCommand для отправки команд из драйвера
  this.publishCommand('getStatus');
  
  // Можно добавить дополнительные обработчики ответов динамически
  this.addResponseHandler(/Version: (.+)/, (match, data) => {
    // Обновляем информацию об устройстве
    this.deviceInfo.firmwareVersion = match[1];
    // Возвращаем обработанные данные
    return { 
      type: 'version',
      version: match[1]
    };
  });
  
  this.addResponseHandler(/Serial: (.+)/, (match, data) => {
    this.deviceInfo.serialNumber = match[1];
    return { 
      type: 'serial',
      serialNumber: match[1]
    };
  });
}
```

> **Примечание:** Метод `this.sendCommand()` форматирует команду через метод драйвера (например, `getStatus()`) и возвращает результат, но **не отправляет** данные в транспорт. Для инициирования отправки команды изнутри драйвера используйте `this.publishCommand()`.

#### Обработка обрывков (incomplete) при включённом `delimiterTimeout`

Если в UI транспорта (TCP/Telnet/SSH/RS232/UDP) задан **`delimiterTimeout > 0`**, transport будет публиковать **частичные** буферы по таймауту (когда delimiter не пришёл за указанное время). Это полезно для legacy-устройств которые шлют без терминатора. Но handler может ложно сматчить обрывок и эмитить **неверные данные**.

Пример проблемы:
```js
// УСТРОЙСТВО ШЛЁТ "Volume: 50\r\n", но обрывок пришёл как "Volume: 5"
this.addResponseHandler(/Volume: (\d+)/, (match) => ({ volume: Number(match[1]) }));
// ❌ handler сматчил "Volume: 5" → emit { volume: 5 }, хотя реально 50
```

**Решение** — использовать `this.isCompleteResponse(data)` (BaseDriver helper). Второй аргумент handler'а — `data` envelope содержит флаги `incomplete`, `timeout`, `overflow`, `flushed`:

```js
this.addResponseHandler(/Volume: (\d+)/, (match, data) => {
    if (!this.isCompleteResponse(data)) return null;  // skip обрывок
    return { volume: Number(match[1]) };
});
```

Если `delimiterTimeout = 0` (default) — transport вообще не публикует incomplete события, проверка работает as-is. Если включил timeout явно — добавляй guard в handlers где partial-match опасен.

Альтернатива: использовать **строгие regex** с anchor'ами (`/^Volume: (\d+)$/m` или `/Volume: (\d+)\r\n$/`) — обрывок не сматчится, partial защищена самим regex.

#### Lifecycle-хуки vs команды

Хуки `initialize`, `KeepAlive`, `onParamsUpdated`, `parseResponse`/`processResponse`, `onDestroy` — это **lifecycle-хуки**, а не команды. Runtime вызывает их НАПРЯМУЮ на инстансе драйвера, минуя `sendCommand` и whitelist из `static commands`.

Поэтому:

* Реализуйте их как обычные методы класса.
* НИКОГДА не объявляйте их в `static commands`.
* НИКОГДА не вызывайте их через `sendCommand` / `publishCommand`.

Whitelist `static commands` предназначен только для пользовательских команд устройства (`setPower`, `getStatus` и т.п.), которые приходят из узла **Device Command**.

### Шаг 8: Сборка полного драйвера

Объединим все части для создания полного драйвера:

```javascript
const BaseDriver = require('base-driver');

/**
 * Драйвер для устройства MyDevice
 */
class MyDeviceDriver extends BaseDriver {
  // Метаданные драйвера
  static metadata = {
    name: 'MyDevice',
    manufacturer: 'MyCompany',
    version: '1.0.0',
    description: 'Драйвер для устройства MyDevice от MyCompany'
  };
  
  // Определение команд
  static commands = {
    setPower: {
      description: 'Включение/выключение устройства',
      parameters: [
        {
          name: 'value',
          type: 'boolean',
          description: 'Состояние питания (true=включено, false=выключено)',
          required: true
        }
      ]
    },
    setVolume: {
      description: 'Установка громкости',
      parameters: [
        {
          name: 'level',
          type: 'number',
          description: 'Уровень громкости (0-100)',
          required: true,
          min: 0,
          max: 100
        }
      ]
    },
    setInput: {
      description: 'Выбор входного источника',
      parameters: [
        {
          name: 'source',
          type: 'string',
          description: 'Имя источника (HDMI1, HDMI2, USB и т.д.)',
          required: true,
          enum: ['HDMI1', 'HDMI2', 'USB', 'Component', 'Composite']
        }
      ]
    },
    getStatus: {
      description: 'Запрос статуса устройства',
      parameters: []
    }
  };
  
  // Определение обработчиков ответов
  static responses = {
    status: {
      description: 'Статус устройства',
      matcher: {
        pattern: /Status: (.+), Power: (.+), Volume: (.+), Input: (.+)/
      },
      extract: function(match) {
        return {
          status: match[1],
          power: match[2] === 'ON',
          volume: parseInt(match[3], 10),
          input: match[4]
        };
      }
    },
    powerStatus: {
      description: 'Статус питания',
      matcher: {
        pattern: /Power: (.+)/
      },
      extract: function(match) {
        return {
          power: match[1] === 'ON'
        };
      }
    },
    volumeStatus: {
      description: 'Текущая громкость',
      matcher: {
        pattern: /Volume: (\d+)/
      },
      extract: function(match) {
        return {
          volume: parseInt(match[1], 10)
        };
      }
    },
    inputStatus: {
      description: 'Текущий вход',
      matcher: {
        pattern: /Input: (.+)/
      },
      extract: function(match) {
        return {
          input: match[1]
        };
      }
    },
    error: {
      description: 'Ошибка устройства',
      matcher: {
        pattern: /Error: (.+)/
      },
      extract: function(match) {
        return {
          message: match[1]
        };
      }
    }
  };
    
  constructor(options) {
    super(options);
    this.state = {};
  }

  // Инициализация при подключении
  initialize() {
    console.log('Инициализация устройства MyDevice');
    
    // Запрос начального статуса при подключении
    this.publishCommand('getStatus');
    

  }
  
  // Методы команд
  setPower(params) {
    if (this.options.debug) {
      console.log('Выполнение команды setPower:', params);
    }
    
    const { value } = params;
    this.state.power = value;
    return { payload: `PWR ${value ? 'ON' : 'OFF'}\r\n` };
  }
  
  setVolume(params) {
    if (this.options.debug) {
      console.log('Выполнение команды setVolume:', params);
    }
    
    const { level } = params;
    // Проверка диапазона
    const safeLevel = Math.max(0, Math.min(100, level));
    this.state.volume = safeLevel;
    return { payload: `VOL ${safeLevel}\r\n` };
  }
  
  setInput(params) {
    if (this.options.debug) {
      console.log('Выполнение команды setInput:', params);
    }
    
    const { source } = params;
    // Проверка значения enum в runtime (узел/транспорт не проверяют параметры)
    if (!['HDMI1', 'HDMI2', 'USB', 'Component', 'Composite'].includes(source)) return null;
    this.state.input = source;
    return { payload: `INPUT ${source}\r\n` };
  }
  
  getStatus() {
    if (this.options.debug) {
      console.log('Выполнение команды getStatus');
    }
    
    return { payload: `STATUS\r\n` };
  }
  
  // Обработка нестандартных ответов
  parseResponse(data) {
    try {
      // Внимание: data содержит обертку, данные находятся в data.data или data.payload для http
      let payload = data.data || data.payload || data;
      
      // Примеры обработки нестандартных ответов
      if (typeof payload === 'string' && payload.includes('System Info:')) {
        const info = payload.replace('System Info:', '').trim();
        return {
          type: 'systemInfo',
          info: info
        };
      }
      
      return null; // Возвращаем null если ответ не распознан
    } catch (error) {
      console.error('Ошибка при обработке ответа:', error);
      return {
        type: 'error',
        message: error.message,
        raw: data
      };
    }
  }
}

module.exports = MyDeviceDriver;
```

### Важное примечание о Matcher vs parseResponse

Драйвер может использовать **ЛИБО** `matcher` (регулярные выражения в `static responses`), **ЛИБО** метод `parseResponse`. 

* Если найден подходящий `matcher`, метод `parseResponse` **НЕ БУДЕТ** вызван.
* Метод `parseResponse` вызывается только тогда, когда ни один из `matcher` не сработал.
* Нельзя использовать `matcher` только для валидации, а затем пытаться допарсить данные в `parseResponse`. Если `matcher` сработал, обработка считается завершенной.

#### Что может возвращать parseResponse

* **Object** — один распарсенный ответ, будет опубликован в Response Listener
* **Array** — массив объектов, каждый будет опубликован отдельно
* **null / undefined** — ответ проигнорирован (или драйвер уже опубликовал ответы через `this.publishResponse()`)

#### Что приходит в `parseResponse(data)` — структура аргумента

`parseResponse` получает **wrapper-объект от транспорта**, НЕ голую строку и НЕ Buffer напрямую. Транспорт всегда оборачивает фрейм:

| Транспорт | `data` content |
|---|---|
| TCP / Telnet / SSH / RS-232 | `{ data: <string|Buffer>, ... }` — payload в `data.data` |
| UDP | `{ data: <Buffer>, rinfo: {...}, ... }` |
| HTTP | `{ payload: <string|Buffer>, statusCode, headers, responseCookies, ... }` — body в `data.payload` |
| Local / loopback | `{ data: <string>, ... }` |

> **Правило: никогда не делайте `String(data)`, `data.split(...)`, `data.match(...)` напрямую.** Аргумент — объект; `String({...})` даст `'[object Object]'`, парсер тихо не сматчит ничего, в лог ничего не упадёт.

##### Канонический helper

В каждом драйвере, где есть `parseResponse`, определите локальный helper или используйте inline-выражение для извлечения текста:

```js
function rawText(data) {
    let p = data;
    if (p && typeof p === 'object' && !Buffer.isBuffer(p)) {
        if (Object.prototype.hasOwnProperty.call(p, 'data')) p = p.data;
        else if (Object.prototype.hasOwnProperty.call(p, 'payload')) p = p.payload;
    }
    if (Buffer.isBuffer(p)) return p.toString('utf8');
    return (p === undefined || p === null) ? '' : String(p);
}

// или inline:
const text = String(data?.data ?? data?.payload ?? '');
```

Для бинарных протоколов вместо `toString('utf8')` используйте `'latin1'` / `'binary'` / explicit Buffer работу.

##### HTTP / REST — особый случай

HTTP-транспорт кладёт в `data` дополнительные поля верхнего уровня: `data.statusCode`, `data.headers`, `data.responseCookies`. Если ваш драйвер работает с REST API (CSRF tokens, cookies, заголовки) — читайте их **напрямую** из `data`, не через helper:

```js
parseResponse(data) {
    // Auth-обновление из метаданных wrapper'а — корректно
    if (data?.responseCookies) this._auth.cookies = { ...data.responseCookies };
    if (data?.headers?.['x-csrf-token']) this._auth.csrfToken = data.headers['x-csrf-token'];
    
    // Body парсим через helper
    const body = String(data?.payload ?? '');
    if (!body) return null;
    // ... JSON.parse / regex / etc
}
```

#### Как это работает в `processResponse` (точная механика)

Реализация в [`lib/base-driver.js`](../lib/base-driver.js) (метод `processResponse`):

1. Цикл по всем `static responses[<key>]` где определён `matcher`. Для каждого: если regex сматчил → вызывается `extract(match, data)`. Если результат **non-null / non-undefined** → **немедленный `return`** из `processResponse`, дальше ничего не выполняется.
2. Если **ни один** static handler не вернул результат → **только тогда** вызывается `parseResponse(data)`.

Следствие: для одного и того же фрейма работает **либо** static handler **либо** parseResponse, но не оба. Это важно для трёх практических случаев ниже.

#### Pattern A — всё в `static responses` (matcher + extract)

Подходит когда:
- Простой ASCII protocol, один regex на один сигнал
- **Не нужен** кэш в драйвере (нет toggle V2, нет stateful-логики)
- Class A subscribe-фреймы которые только эмитят сигнал

```js
static responses = {
    power: {
        description: 'Состояние питания',
        category: 'power',
        fields: [{ name: 'power', type: 'boolean' }],
        valueField: 'power', keyFields: [],
        matcher: { pattern: /PWR (ON|OFF)/ },
        extract: (m) => ({ power: m[1] === 'ON', value: m[1] === 'ON' })
    }
};
```

#### Pattern B — всё в `parseResponse` (static БЕЗ matcher, только UI-декларация)

Подходит когда:
- **Нужен кэш** в драйвере (`this._cache`) для toggle V2 — без кэша `toggleX` не реализуем
- Multi-line response (несколько frame'ов в одном TCP пакете, нужно вернуть массив объектов)
- Бинарный protocol с checksum / композитный парсинг
- JSON protocol (CresNext, REST) — JSON-walker по дереву, не regex
- **Любой случай где нужен `this`** — `extract` это стрелочная функция, у неё нет `this` instance драйвера

`static responses` оставляешь как **UI-декларацию без `matcher`/`extract`** (только `description`/`fields`/`valueField`/`keyFields`/`category`). Парсинг переезжает целиком в `parseResponse`:

```js
static responses = {
    mute: {
        description: 'Состояние mute по каналу',
        category: 'audio',
        fields: [{ name: 'output', type: 'number' }, { name: 'muted', type: 'boolean' }],
        valueField: 'muted', keyFields: ['output']
        // НЕТ matcher / НЕТ extract — UI-декларация только
    }
};

parseResponse(data) {
    const raw = String(data?.data ?? data?.payload ?? '');
    const out = [];
    for (const m of raw.matchAll(/~\d{2}@MUTE-AUDIO (\d+),(\d+)(?: OK)?/g)) {
        const output = Number(m[1]);
        const muted  = m[2] === '1';
        this._cache = this._cache || {};
        this._cache[`mute:${output}`] = muted;     // кэш ЗДЕСЬ
        out.push({ type: 'mute', output, muted, value: muted });
    }
    return out.length === 1 ? out[0] : (out.length ? out : null);
}

toggleMute({ output }) {
    const cur = this._cache && this._cache[`mute:${output}`] === true;
    return { payload: `#MUTE-AUDIO ${output},${cur ? 0 : 1}\r` };
}
```

#### Pattern C — гибрид через `extract` как обычная функция (НЕ стрелочная)

**Внимание:** `extract` вызывается без привязки (`config.extract(...)`), поэтому даже в форме `function(m, data) { ... }` `this` — это статический объект-определение ответа, а НЕ инстанс драйвера. Доступ к `this._cache` инстанса из `extract` невозможен; для кэша/toggle используйте **Pattern B** (`parseResponse`).

#### Канонический выбор

| Случай | Pattern |
|---|---|
| Нужен V2 toggle (`this._cache`) | **B** — всё в parseResponse |
| Multi-line / multi-event фрейм | **B** |
| JSON / бинарный protocol | **B** |
| Только UI-сигналы, без toggle, без `this` | **A** или **B** — оба работают |
| Hybrid (часть простых signals + V2 toggle) | **B** для всего — consistency |

**Не смешивайте Pattern A и Pattern B на одном драйвере.** Если одна команда обрабатывается через matcher а другая через parseResponse, появляется wartime-путаница «почему один сигнал обновляет кэш, а другой нет». Выберите один pattern на драйвер.

### Канон заполнения ответов: extract и parseResponse

Этот раздел описывает **обязательную форму** объекта, который ваш драйвер выдаёт через `extract` (для matcher) или `parseResponse` (свободный парсинг). Без правильной формы получатели — Response Listener, signal-cache, reactive-биндинги (например, узел `room` с его `${signal.X.Y}` шаблонами и driven_by) — **не смогут извлечь значение и тихо отбросят ответ**. Драйвер при этом «работает», ответы видны в логах, но в UI ничего не происходит и связи не срабатывают.

#### Что делает BaseDriver под капотом

Когда драйвер распознал ответ устройства, BaseDriver автоматически:

1. Берёт ваш объект (из `extract` или `parseResponse`) и добавляет в него `type` — это ключ из `static responses` (для matcher) или то поле `type`, что вы сами указали в `parseResponse`.
2. Оборачивает в стандартный envelope:
   ```js
   {
     category: 'response',
     response: { type: '<имя>', ...ваши_поля },
     msg: <originalMsg>
   }
   ```
3. Публикует на шину. Response Listener маршрутизирует по `type` на выходы; reactive-получатели (room/signal-cache) ищут «primary value» внутри `response`.

Поэтому ваша задача — отдать **только содержимое поля `response`**, без обёртки и с правильным «главным значением» внутри.

#### Требование 1: extract обязан вернуть «primary value»

Получатели сигналов ищут primary value в полях ответа в таком порядке:

```
1. r[ключ_из_static_responses]   // r['Gain'] — если поле названо как ключ ответа
2. r.value                       // алиас «главного значения»
3. → undefined → сигнал тихо дропается
```

Если ни одно из двух не найдено — система не знает, что считать «значением сигнала», и `${signal.MyDevice.gain}`, reflections и driven_by возвращают `undefined`.

##### ❌ НЕПРАВИЛЬНО — нет primary value

```javascript
static responses = {
    route: {
        matcher: { pattern: /^x([1-9])AVx([1-9])$/ },
        extract: function(match) {
            return { input: Number(match[1]), output: Number(match[2]) };
        }
    }
}
// → r = { type: 'route', input: 1, output: 2 }
// → r['route'] = undefined, r.value = undefined
// → ${signal.Atlona.route} === undefined
// → reflections не срабатывают, driven_by не работает
```

##### ✅ ПРАВИЛЬНО — добавьте `value` алиас

```javascript
static responses = {
    route: {
        matcher: { pattern: /^x([1-9])AVx([1-9])$/ },
        extract: function(match) {
            return {
                input:  Number(match[1]),
                output: Number(match[2]),
                value:  Number(match[1])    // ← primary: «куда переключилось»
            };
        }
    }
}
// → r = { type: 'route', input: 1, output: 2, value: 1 }
// → ${signal.Atlona.route} === 1 ✓
```

##### ✅ ПРАВИЛЬНО — поле совпадает с именем ответа

Если у вас есть осмысленное «главное» поле, чьё имя совпадает с ключом из `static responses`, можно обойтись без `value`:

```javascript
static responses = {
    level: {
        matcher: { pattern: /level (-?\d+\.\d+)/ },
        extract: function(match) {
            return {
                level: parseFloat(match[1])    // ← поле 'level' = ключ 'level'
            };
        }
    }
}
// → r = { type: 'level', level: -12.5 }
// → r['level'] = -12.5 ✓
```

##### Совет: для составных сигналов указывайте оба

Когда «primary» не очевиден (несколько одинаково важных полей), безопаснее указывать **и** именованное поле, **и** алиас `value`:

```javascript
extract: function(match) {
    return {
        channelCode: match[1],
        gain:        Number(match[2]),
        value:       Number(match[2])    // = gain, для совместимости с подписчиками
    };
}
```

> **Важно:** primary value — это **скаляр** (число, строка, boolean). Не объект и не массив. Если ваш ответ структурно сложный (например, диагностический dump), всё равно укажите один скаляр в `value` — например, статус `'ok'` или числовой код.

#### Требование 2: parseResponse возвращает «чистый» объект

Самая частая ошибка — попытка вручную сделать ту же обёртку, которую BaseDriver и так добавит. Это приводит к **двойной обёртке** и тихому дропу сигналов.

##### ❌ НЕПРАВИЛЬНО — двойная обёртка

```javascript
parseResponse(data) {
    return {
        category: 'response',                    // ← НЕ нужно
        response: { type: 'Gain', gain: -30 },   // ← НЕ нужно
        msg: data
    };
}
```

В шине окажется:
```js
{
    category: 'response',
    response: {
        category: 'response',                    // ← вложенная обёртка!
        response: { type: 'Gain', gain: -30 },
        msg: ...
    },
    msg: ...
}
```

Получатели увидят `data.response.type === 'response'` (это строка `category`!), не найдут реального type — и **молча отбросят сигнал**. Драйвер «работает», ответы приходят, но в UI ничего не отражается.

##### ✅ ПРАВИЛЬНО — отдайте только `{ type, ...поля }`

```javascript
parseResponse(data) {
    // 1. Достаём сырую строку из транспорт-обёртки
    let raw = data;
    if (raw && typeof raw === 'object' && raw.payload) raw = raw.payload;
    if (Buffer.isBuffer(raw)) raw = raw.toString('utf8');
    if (typeof raw !== 'string') return null;

    // 2. Парсим
    const m = raw.match(/^GAIN (\d+) (-?\d+\.\d+)$/);
    if (m) {
        return {
            type:        'Gain',           // ← обязательно type
            channelCode: m[1],
            gain:        Number(m[2]),
            value:       Number(m[2])      // ← primary value, см. Требование 1
        };
    }
    return null;                            // ← не распознали — null, не throw
}
```

BaseDriver сам добавит `category: 'response'` и `msg: <originalMsg>` поверх вашего объекта.

##### ✅ ПРАВИЛЬНО — несколько ответов в одном пакете

Если устройство шлёт несколько событий в одном TCP-пакете (типичная ситуация при busy-bursts), верните массив **чистых** объектов:

```javascript
parseResponse(data) {
    const lines = String(data.data || data.payload || data).split('\r\n').filter(Boolean);
    return lines.map((line) => {
        const m = line.match(/^STATE (\w+) (\d+)$/);
        if (!m) return null;
        return {
            type:  'state',
            name:  m[1],
            value: Number(m[2])              // primary value
        };
    }).filter(Boolean);
}
```

Каждый элемент массива получит свою обёртку и будет опубликован отдельным сообщением.

##### ✅ ПРАВИЛЬНО — асинхронная публикация через `publishResponse`

Если ответы рождаются в callback'ах (timeouts, side-парсеры), опубликуйте их напрямую и верните `null`:

```javascript
parseResponse(data) {
    decodeAsync(data, (parsed) => {
        // BaseDriver сам обернёт в envelope. Передавайте чистый { type, ...поля, value }.
        this.publishResponse({
            type:  'decoded',
            value: parsed.primary,
            ...parsed
        });
    });
    return null;   // — «я опубликовал сам, system, не вмешивайся»
}
```

#### Чек-лист корректного ответа

Перед коммитом нового драйвера убедитесь:

- [ ] Каждый `extract` возвращает объект, в котором есть **либо** поле с именем ключа из `static responses`, **либо** поле `value`. Лучше — оба.
- [ ] `value` (или primary-поле) — это **скаляр** (число / строка / boolean), не объект.
- [ ] `parseResponse` возвращает голый `{ type, ...поля }` или массив таких объектов. Без `category`, без `response`, без `msg`.
- [ ] `parseResponse` возвращает `null`, если ответ не распознан (не падает с исключением).
- [ ] Если `parseResponse` сам публикует через `publishResponse`, метод возвращает `null`.
- [ ] Поле `type` всегда заполнено и совпадает с тем именем, по которому маршрутизирует Response Listener.

#### Как быстро проверить, что ответ дошёл правильно

Прицепите **Device Response Listener** + **Debug** node на выход. Что должно прийти:

```js
// msg.payload в Debug:
{ type: 'Gain', channelCode: '1', gain: -30, value: -30 }
```

Признаки проблем:

| Что видно | Что не так |
|---|---|
| `msg.payload = { category: 'response', response: { ... } }` | В `parseResponse` двойная обёртка (см. ❌ выше). Уберите `category`/`response`/`msg`. |
| `msg.payload.type === 'response'` (строка) | То же — двойная обёртка. Внутренний `type` потерялся. |
| Debug ОК, но `${signal.X.Y}` или driven_by → `undefined` | В `extract` нет ни `value`, ни поля с именем ключа ответа (см. Требование 1). |
| Ответ не доходит вообще | Не сработал ни один matcher и `parseResponse` вернул `null`. Проверьте regex / разбиение по delimiter. |

### Автоматическая авторизация — auth gate

#### Зачем

Многие TCP-устройства (Extron, Christie/PJLink, Cisco, APC, Bosch, ...) после открытия сокета сначала шлют `Password:` prompt и **отбрасывают** все команды до того, как driver ответит правильным паролем. Если в Node-RED поток сразу после CONNECTED отправит user-команду (`setRoute`, `setVolume`, ...), она уйдёт в транспорт до завершения логина — и устройство либо проигнорирует её, либо закроет коннект.

Без auth-gate пользователь вынужден лепить кастыли (`delay`-нода, `change`-условия по `data.response.authenticated`, ручные блокировки). С auth-gate driver **сам** блокирует user-команды до auth-complete и **flush'ит** их в правильном порядке через стандартный `publishCommand` pipeline.

#### Где живёт auth gate (архитектура)

Реализация — в `lib/base-driver.js` как **driver-side shared library**, доступная всем наследникам. Это **НЕ runtime hook**: `lib/connection/*`, `lib/transport/*`, `lib/mediator.js` ничего не знают про auth-state, не вызывают callback'и, не имеют дополнительных API. Gate полностью self-contained внутри инстанса драйвера.

Активация — **opt-in через статический флаг** `static requiresAuth = true`. Драйверы без флага работают как раньше — никакого wrap'а, никакой очереди, никаких изменений в API. На момент введения gate в библиотеке ~80 драйверов, ни один не задет.

Почему в `base-driver.js`, а не отдельным helper-файлом / inline в каждый driver: альтернатива — копировать ~80 LOC boilerplate в каждый auth-драйвер (15+ файлов), что даёт ~1200 LOC дублирования + риск дрейфа реализаций между авторами. `base-driver` как базовый класс — естественное место для общего helper'а, как и существующие `publishCommand`/`processResponse`/`destroy` методы.

#### Контракт API в BaseDriver

В классе-наследнике объявите статический флаг:

```js
class MyAuthDriver extends BaseDriver {
    static requiresAuth = true;            // включает gate
    static authCommands = ['sendPassword']; // whitelist: эти команды НЕ блокируются
    // ...
}
```

После этого в распоряжении драйвера три инстанс-метода (унаследованы из BaseDriver):

| Метод | Когда вызывать | Эффект |
|---|---|---|
| `this._authReset()` | в `initialize()` (runtime зовёт на каждый CONNECTED) | gate в состоянии `pending`, очередь чистая |
| `this._authMarkReady()` | в `parseResponse` при детекте «логин ОК» | gate → `ready`, pending команды flush через `publishCommand` |
| `this._authMarkFailed(reason)` | в `parseResponse` при детекте «логин failed» | gate → `failed`, pending дропается, новые user-команды дропаются до reset |
| `this._authGetState()` | в тестах / debug | возвращает `'pending' \| 'ready' \| 'failed'` |

Идемпотентность: повторный `_authMarkReady()` — no-op. После `_authMarkFailed()` повторный `_authMarkReady()` снимет failed-флаг и переведёт в ready.

#### Минимальный пример драйвера с auth gate

```js
class ExampleAuthDriver extends BaseDriver {
    static requiresAuth = true;
    static authCommands = ['sendPassword'];   // sendPassword проходит ВСЕГДА

    static commands = {
        sendPassword: { description: 'auto-step: ответить на Password:', parameters: [] },
        setRoute:     { description: 'route input→output', parameters: [
            { name: 'input',  type: 'number', required: true },
            { name: 'output', type: 'number', required: true }
        ]},
        getPower:     { description: 'query power', parameters: [] }
        // ... остальные команды auto-wrap'аются gate'ом
    };

    initialize() {
        this._authReset();
        if (!this.config.password) this._authMarkReady();   // graceful no-auth
        this.publishCommand('getPower');                    // queued до login OK
    }

    sendPassword() {
        return { payload: `${this.config.password}\r\n` };
    }

    setRoute({ input, output }) {
        return { payload: `MTX I${input} O${output}\r\n` };
    }

    getPower() {
        return { payload: 'PWR?\r\n' };
    }

    parseResponse(data) {
        const text = String(data?.data ?? data?.payload ?? '');
        if (/^Password:/.test(text)) {
            if (this.config.password) this.publishCommand('sendPassword');
            return { type: 'auth', authenticated: false };
        }
        if (/^Login OK/.test(text)) {
            this._authMarkReady();           // flush queued setRoute/getPower
            return { type: 'auth', authenticated: true };
        }
        if (/^Login failed/.test(text)) {
            this._authMarkFailed('Login failed');
            return { type: 'auth', authenticated: false, reason: 'failed' };
        }
        return null;
    }
}
```

#### Как auth-команда обходит gate

`static authCommands` — массив имён команд из `static commands`, **исключённых из auto-wrap**. Их типичный обитатель — `sendPassword`/`login`/`negotiate`, которые driver сам инициирует через `publishCommand` из `parseResponse` при детекте `Password:`-prompt'а. Они не блокируются, потому что от них зависит **выход** из pending-state.

Если auth-команды НЕ нужны как user-callable (т.е. их вообще не должно быть в `static commands`), всё равно объявите их там — иначе `publishCommand('sendPassword')` отвалится на whitelist-check в `sendCommand`.

#### Backward compatibility

Драйверы **без** `static requiresAuth = true` (или с `requiresAuth = false`) работают как раньше — никакого wrap'а, никакой очереди, никаких изменений в API. Это касается всех существующих ~80 драйверов из DriverLib/drivers — миграция инкрементальная, по одному драйверу за раз.

#### Лимит pending и overflow

Pending FIFO ограничена **64 командами**. При попытке добавить 65-ю команда дропается тихо (return `null`). На практике 64 хватает с запасом: типичный логин занимает 100-500ms, за это время даже агрессивный flow едва успеет накидать 5-10 команд. Лимит защищает от patalogical случая, когда driver застрял в pending (например, нет сети после `Password:`) и flow продолжает спамить — без лимита это была бы утечка памяти.

#### Reset на reconnect

Runtime вызывает `driver.initialize()` на **каждое** CONNECTED событие (re-connect, manual reconnect, transport restart). Поэтому ваш `initialize()` **обязан** вызывать `this._authReset()` первой строкой. Иначе после reconnect driver останется в `ready` или `failed` состоянии с прошлого сеанса.

#### Чек-лист

- [ ] `static requiresAuth = true` объявлен
- [ ] `static authCommands = ['sendPassword', ...]` объявлен (если используются auth-step команды)
- [ ] `initialize()` вызывает `this._authReset()` первой строкой
- [ ] `initialize()` вызывает `this._authMarkReady()` при пустом пароле (graceful no-auth)
- [ ] `parseResponse` детектит «логин OK» → `_authMarkReady()`
- [ ] `parseResponse` детектит «логин failed» → `_authMarkFailed(reason)`
- [ ] auth-команды (если есть) перечислены и в `static commands`, и в `static authCommands`

### Auth-gate механизм

Этот раздел углубляет тему **auth-gate** (см. выше «Автоматическая авторизация — auth gate») — мотивация, матрица применимости по транспортам, multi-transport pattern и real-world примеры устройств. Если предыдущий раздел отвечает на вопрос «**как** объявить gate», этот раздел отвечает на вопросы «**зачем** и **когда** он нужен», «как **не** наступить на грабли» и «как это выглядит на реальных протоколах».

#### A. Зачем нужен auth-gate (motivation)

**Проблема**. AV-устройства часто требуют login / handshake / discovery до приёма user-команд. Если в `initialize()` driver шлёт `login()`, а юзер в это время жмёт кнопку «play» в UI / приходит msg на input → user-команда уходит в транспорт **до** завершения login. Что происходит с точки зрения устройства:

- **Telnet codec** (Extron, Christie/PJLink) — команда до `Password:`-prompt silent drop'ается. Никакого ответа, никакой ошибки. Юзер не понимает почему `setRoute` не сработал.
- **HTTP REST** (Vinteo, Polycom Trio) — команда без `Authorization: Bearer ...` возвращает `401 Unauthorized`. Если ретраи включены — retry storm, нагрузка на устройство, забит лог.
- **Encrypted protocols** (LG webOS, Samsung MDC) — без shared key команда уходит в шифровщик и улетает мусором, устройство закрывает сокет. После reconnect повторение.

**Решение — driver-side FIFO буфер user-команд**. До явного `_authMarkReady()` все команды копятся в pending-очереди (лимит 64). После ready — replay через стандартный `publishCommand` pipeline в порядке поступления. Юзеру / flow ничего не нужно знать про auth — кнопка «play», нажатая через 50ms после reconnect, отработает корректно, просто с задержкой на login.

**Это не runtime hook**. Connection/transport/mediator ничего про gate не знают. Gate живёт целиком внутри инстанса драйвера (см. предыдущий раздел про архитектуру).

#### B. Когда нужен auth-gate (use cases)

Не каждому драйверу нужен auth-gate. У некоторых транспортов login делается на уровне самого транспорта (telnet с pexpect, SSH с PTY) — там driver-level gate был бы избыточен и приводил бы к double-auth. Полная таблица применимости:

| Транспорт | Auth механизм самого транспорта | Нужен ли auth-gate driver? |
|---|---|---|
| **HTTP / HTTPS** | нет (stateless) | **ДА** — для JWT / Bearer / OAuth / session token |
| **Raw TCP** | нет | **ДА** — для application-level login (binary handshake, password challenge) |
| **RS232** | нет | **ДА** — то же что raw TCP (если устройство требует login по serial) |
| **WebSocket** | нет (WS upgrade ≠ application login) | **ДА** — для token auth после connect |
| **Telnet** *(с заполненным password в transport config)* | **ЕСТЬ** — pexpect username/password/shell-prompt regex | **НЕТ** — transport handshake уже залогинил |
| **SSH** *(с заполненным password / key в transport config)* | **ЕСТЬ** — PTY pexpect | **НЕТ** — transport handshake залогинил |
| **Telnet** *(без password в transport — «skip-auth mode»)* | пропуск auth | **ДА** — driver делает auth сам |
| **SSH** *(без password в transport — например ключ-only)* | пропуск password auth | **ДА** если нужно application-auth поверх SSH |
| **UDP** | нет (нет сессии) | обычно не применимо (нет stateful auth) |
| **IR / Local** | нет | не применимо |

**Правило большого пальца**: если transport сам делает login через свой UI-конфиг (поле `Пароль` / `Password` заполнено) — НЕ объявляй `requiresAuth = true` в драйвере. Если transport stateless или login пропущен — gate в драйвере нужен.

#### C. Как объявить (declaration)

Шаблон полного driver'а с auth-gate (для напоминания, расширенный пример см. в предыдущем разделе):

```js
class MyDriver extends BaseDriver {
    static requiresAuth = true;
    static authCommands = ['login'];   // команды-исключения, проходят gate всегда

    static commands = {
        login:    { description: 'auth step', parameters: [] },
        setRoute: { description: 'route input→output', parameters: [
            { name: 'input',  type: 'number', required: true },
            { name: 'output', type: 'number', required: true }
        ]},
        // остальные команды auto-wrap'аются gate'ом
    };

    initialize() {
        this._authReset();             // открыть gate для нового connect
        this.publishCommand('login');  // авто-старт auth
    }

    login() {
        return { method: 'POST', path: '/auth', payload: { user: this.config.user, pass: this.config.password } };
    }

    parseResponse(data) {
        if (this._looksLikeLoginOk(data)) {
            this._authToken = extractToken(data);
            this._authMarkReady();           // gate открыт — pending команды flush'нутся
            return { type: 'authOk', value: 'ready' };
        }
        if (this._looksLikeLoginFail(data)) {
            this._authMarkFailed('bad credentials');
            return { type: 'authFailed', value: 'failed' };
        }
        // ... обычная response logic
    }
}
```

#### D. API base-driver

| Метод | Назначение |
|---|---|
| `_authReset()` | сбросить gate в `pending`, очистить FIFO. Зовётся в `initialize()` — runtime вызывает `initialize()` на каждый CONNECTED |
| `_authMarkReady()` | открыть gate, flush pending FIFO через `publishCommand` pipeline. Идемпотентно |
| `_authMarkFailed(reason)` | закрыть gate, дропнуть FIFO, warn в node (с reason). Последующие user-команды до `_authReset()` дропаются |
| `_authGetState()` | вернуть `'ready' \| 'failed' \| 'pending'` (для тестов и отладки) |

#### E. Что НЕ нужно делать (anti-patterns)

- **НЕ объявляй `requiresAuth = true` если driver использует только telnet/SSH со стандартным login flow через transport config** — transport handshake сделает работу, gate будет лишним.
- **НЕ заполняй password в transport config если driver сам делает auth через gate** — будет double-auth: transport залогинится, затем driver попытается залогиниться ещё раз. У ряда устройств это вызывает блокировку или сброс сессии. (Runtime теперь warn'ит про этот случай при init — см. предупреждение в Node-RED debug-консоли.)
- **НЕ забывай вызывать `_authReset()` в `initialize()`** — runtime зовёт `initialize()` на каждый CONNECTED, gate должен переоткрываться. Иначе после reconnect driver останется в прошлом state (`ready` или `failed`).
- **НЕ забывай `_authMarkReady()` при успехе login** — иначе все user-команды копятся навсегда, FIFO растёт, на 65-й команде начинается тихий drop.
- **НЕ забывай `_authMarkFailed(reason)` при провале** — иначе FIFO продолжает расти, caller не получит warn про auth failure, диагностика усложняется.
- **НЕ пытайся ставить gate поверх `KeepAlive()`** — KeepAlive runtime зовёт **напрямую**, минуя `sendCommand` whitelist (см. конвенцию lifecycle hooks). Gate его не покрывает. Если KeepAlive не должен идти до auth — добавь явный guard внутри `KeepAlive()`: `if (!this._authReady) return;`.

#### F. Multi-transport drivers (Polycom-pattern)

Если один driver поддерживает **несколько** транспортов (например, Polycom RealPresence Group работает по TCP + Telnet + RS232 одним кодом), auth-gate **унифицирует** auth-логику между ними. Driver «не знает» какой transport активен в данный момент, но универсально делает auth через gate.

Принцип:

- **При выборе Telnet в editor**: юзер должен ОСТАВИТЬ password пустым в transport config — driver auth-gate сделает auth сам через свою команду login. Иначе double-auth.
- **При выборе TCP / RS232 в editor**: у этих транспортов вообще нет auth механизма — driver auth-gate единственный путь.
- **Один и тот же `login()` / `parseResponse()` код** работает для всех трёх транспортов: driver просто шлёт `password\r\n` через `transport.write()`, и парсит `Login Successful` в ответе.

Этот паттерн позволяет иметь **один драйвер** вместо трёх (telnet-only, tcp-only, rs232-only) — кодовая база меньше, поддержка проще, баги одного исправляют сразу три варианта подключения.

#### G. Реальный диалог с устройством (примеры)

**Пример 1 — Polycom telnet / TCP login prompt**

1. CONNECTED → device greeting + `Password:` prompt в первом ответе.
2. `parseResponse` детектит `Password:` regex → `publishCommand('login')`.
3. `login()` шлёт `{password}\r\n`.
4. Device отвечает `Login Successful` или `password failed`.
5. `parseResponse` детектит ok → `_authMarkReady()`. Pending команды (например `getCallState`, добавленная UI'ем сразу после reconnect) flush'атся в порядке поступления.

**Пример 2 — Vinteo JWT REST (HTTPS)**

1. `initialize()` → `_authReset()` → `publishCommand('loginRequest')`.
2. `loginRequest()` шлёт `POST /api/v1/auth/jwt` с creds.
3. Response: `{ data: { token: 'JWT...' } }`, статус 201.
4. `parseResponse`: статус 201 + наличие token → сохранить `this._authToken`, вызвать `_authMarkReady()`.
5. Все pending команды flush'атся через `publishCommand`. Каждая команда через хелпер `_authHeaders()` добавляет `Authorization: Bearer ${this._authToken}` в headers.
6. На 401 в любом последующем ответе — `_authMarkFailed('token expired')`, либо повторный `_authReset()` + `publishCommand('loginRequest')` для re-auth.

**Пример 3 — LG xxup7670pux encrypted (PBKDF2 + AES)**

Здесь auth-gate работает **не как network handshake**, а как «гарантия что локальный криптоключ установлен до того как первая команда зашифруется».

1. `initialize()` → `_authReset()`.
2. Локально: derive key через `crypto.pbkdf2Sync(keycode, salt, iter, len, 'sha256')` → сохранить как `this._key`.
3. `_authMarkReady()` сразу же (никакого network roundtrip не нужно).
4. User-команды, которые могли прийти в ту же миллисекунду пока шёл `pbkdf2Sync`, теперь flush'атся через gate с уже готовым `this._key`. Шифрование работает корректно с первой команды.

**Пример 4 — Extron Matrix (Telnet skip-auth mode)**

1. В transport config telnet `password` оставлен пустым (skip-auth).
2. CONNECTED → device greeting + `Password:` (без аутентификации в transport).
3. `parseResponse` детектит prompt → `publishCommand('sendPassword')`.
4. `sendPassword()` шлёт `{this.config.password}\r\n` (driver-level config, НЕ transport-level).
5. Device → `Login Administrator` или `Login User` → `_authMarkReady()`.

В этом сценарии driver-level config содержит `password`, а transport-level config — нет. Это даёт driver'у контроль над moment'ом auth и форматом login-команды (некоторые устройства ждут `password\r`, некоторые `password\r\n`, некоторые `LOGIN password\r\n` — это знает только driver, не transport).

### Декларация полей в `static responses` (для UI room/routing)

#### Зачем

UI не может выполнить `extract()`, чтобы узнать какие поля придут в ответе. Без явной декларации UI:

- не показывает dropdown выбора поля (`driven_by.field`);
- не выводит подсказку по `key` для key-filtered подписок.

Декларация делает драйвер **self-describing**: редактор room/routing читает её один раз и отображает нужные подсказки без запуска кода устройства.

#### Convention имени сигнала

Имя сигнала, под которым runtime эмитит `data.response.type` — это **ключ записи** в `static responses = { <ключ>: ... }`. Под этим именем сигнал ходит в `outputsMap` device-response-listener, в `driven_by.signal_topic` room и т.д. Поэтому ключи именуйте коротко и по смыслу: `mute`, `level`, `route`, `power` — а не `muteResponse`, `levelResponse`.

```js
static responses = {
    mute: { ... },     // runtime эмитит data.response.type === 'mute'
    level: { ... },    // runtime эмитит data.response.type === 'level'
    route: { ... }     // runtime эмитит data.response.type === 'route'
};
```

`extract()` возвращает только параметры (без `type` — он навешивается автоматически из ключа).

#### Контракт — опциональные поля для UI

Каждая запись в `static responses` **может** (не обязана) содержать:

```js
{
    description: 'human readable',
    matcher: { pattern: /.../ },

    // ── Опциональные поля для UI ──
    fields: [                 // декларируемая форма результата extract()
        { name: 'instanceTag', type: 'string', description: 'optional' },
        { name: 'channel',     type: 'number' },
        { name: 'muted',       type: 'boolean' }
    ],
    valueField: 'muted',      // какое поле является значением сигнала
    keyFields: ['instanceTag', 'channel'],  // поля для построения `key`
                              // (для key-filtered driven_by)
    category: 'audio',        // ярлык группировки ответов в UI, любая строка, default 'other'
    recommendedOutput: true,  // подсветить ответ как рекомендуемый выход

    extract: function(match) { return { /* поля без type */ }; }
}
```

Все эти поля опциональны и влияют ТОЛЬКО на отображение в редакторе. `category` — произвольная строка-ярлык; не путать с envelope-полем `category: 'response'` из раздела `parseResponse` — это другое поле.

#### Примеры по типам устройств

##### Проектор — power on/off, нет сегментации

Проектор — одно устройство, одно значение. `keyFields: []` означает отсутствие сегментации: room подписывается на сигнал без `key`.

```js
static responses = {
    power: {
        description: 'Статус питания',
        matcher: { pattern: /^POWR=(0|1)$/ },
        fields: [{ name: 'state', type: 'boolean' }],
        valueField: 'state',
        keyFields: [],   // нет сегментации — одно устройство одно значение
        extract: m => ({ state: m[1] === '1' })
    }
};
```

##### AV-матрица — routing вход→выход, key = номер выхода

Матрица имеет N выходов. Каждый выход — отдельный сигнал. `keyFields: ['output']` позволяет подписаться на конкретный выход: `device.route.5` = выход 5.

```js
static responses = {
    route: {
        description: 'Маршрутизация вход-выход',
        matcher: { pattern: /OUT(\d+) IN(\d+)/ },
        fields: [
            { name: 'output', type: 'number' },
            { name: 'input',  type: 'number' }
        ],
        valueField: 'input',
        keyFields: ['output'],   // фильтрация по выходу: device.route.5 = выход 5
        extract: m => ({ output: +m[1], input: +m[2], key: String(m[1]) })
    }
};
```

##### Кондиционер — температура, нет сегментации

Один датчик температуры на устройство. Как и с проектором — `keyFields: []`.

```js
static responses = {
    temperature: {
        description: 'Текущая температура',
        matcher: { pattern: /TEMP (\d+\.\d)/ },
        fields: [{ name: 'celsius', type: 'number' }],
        valueField: 'celsius',
        keyFields: [],
        extract: m => ({ celsius: parseFloat(m[1]) })
    }
};
```

##### Audio DSP — mute по каналу, составной key

DSP может иметь несколько блоков (`instanceTag`) и несколько каналов в каждом. `keyFields` — ТОЛЬКО декларация для редактора. Runtime сам key НЕ собирает: чтобы `device.mute.MIC1.3` работала, `extract()` ОБЯЗАН вернуть готовое поле `key` (например `key: instanceTag + '.' + channel`). Без `key` keyed-подписка молча не срабатывает.

```js
static responses = {
    mute: {
        description: 'Статус mute канала',
        matcher: { pattern: /(\w+) mute (\d+) (true|false)/ },
        fields: [
            { name: 'instanceTag', type: 'string', description: 'Идентификатор блока DSP' },
            { name: 'channel',     type: 'number' },
            { name: 'muted',       type: 'boolean' }
        ],
        valueField: 'muted',
        keyFields: ['instanceTag', 'channel'],   // device.mute.MIC1.3
        extract: m => ({ instanceTag: m[1], channel: +m[2], muted: m[3] === 'true', key: m[1] + '.' + m[2] })
    }
};
```

#### Поведение в редакторе

| Поле декларации | Что делает редактор |
|-----------------|---------------------|
| `fields`        | Заполняет dropdown выбора поля для `driven_by.field` |
| `keyFields`     | Показывает подсказку как собирается `key` для key-filtered подписок (`driven_by.signal_topic = "device.signal.KEY"`). Это только декларация для редактора — runtime берёт `key` из поля `key`, возвращённого `extract()`, а не собирает его из `keyFields` |
| `valueField`    | Указывает, какое поле выбрать в dropdown поля по умолчанию |

#### Backwards-compat

Старые драйверы без декларации (`fields`, `valueField`, `keyFields`) продолжают работать без каких-либо изменений. UI просто не показывает field/key подсказки — это не ошибка, а ожидаемый режим.

Добавляйте декларацию постепенно, по одному ответу за раз — это безопасно. Миграция не требует изменений в runtime, транспортах или тестах.

#### Декларация не заменяет extract

`extract()` остаётся **источником истины** в runtime. Декларация используется только для UI. Если декларация (`fields`, `valueField`) и реальный результат `extract()` разойдутся — `extract()` выигрывает. Декларируйте то, что `extract()` реально возвращает.

---

### Использование драйвера 

1. После создания драйвера, нажмите кнопку сохранить
2. Добавьте узел "Device Command" в рабочую область
3. В настройках узла выберите драйвер из списка
4. Настройте параметры подключения (тип транспорта, адрес, порт)
6. В настройках узла "Device Command" выберите команду для выполнения
7. Добавьте узел "Device Response Listener" для получения ответов от устройства
8. Запустите поток и проверьте работу драйвера

## Расширенные возможности

### Работа с HTTP транспортом

HTTP транспорт в Dynamic Driver предоставляет широкие возможности для взаимодействия с веб-API и HTTP-сервисами. В отличие от других транспортов (TCP, SSH), HTTP работает в режиме запрос-ответ без постоянного соединения.

#### Настройка HTTP подключения

При создании узла **Device Connection**, выберите тип транспорта **HTTP** и настройте следующие параметры:

```javascript
// Основные настройки
{
  "server": "https://api.example.com",  // Базовый URL сервера
  "method": "GET",                      // HTTP метод по умолчанию
  "requestTimeout": 30000,               // Таймаут запроса в мс
  "followRedirects": true,               // Следовать переадресациям
  "rejectUnauthorized": false            // Проверка SSL сертификатов
}
```

#### Аутентификация HTTP

Поддерживаются три типа аутентификации:

> **ВАЖНО:** аутентификация отправляется ТОЛЬКО при `useAuth: true`. Без этого флага поля `user`/`password`/`authType` игнорируются и запрос уходит БЕЗ заголовка Authorization (без ошибки).

1. **Basic Authentication**:
   ```javascript
   {
     "useAuth": true,
     "authType": "basic",
     "user": "username",
     "password": "password"
   }
   ```

2. **Digest Authentication**:
   ```javascript
   {
     "useAuth": true,
     "authType": "digest",
     "user": "username",
     "password": "password"
   }
   ```

3. **Bearer Token**:
   ```javascript
   {
     "useAuth": true,
     "authType": "bearer",
     "password": "your-jwt-token"
   }
   ```

#### Создание HTTP драйвера

Для HTTP драйверов команды должны возвращать объект с HTTP-специфичными полями:

> **Важно:** параметры типа `object`/`array` НЕ редактируются в UI узла **Device Command** — текстовое поле сохранит их как строку. Передавайте их только через `msg.parameters` из предшествующего Function-узла (например `msg.parameters = { userData: { name: 'John' } }`). В редакторе поддерживаются только типы string / number / boolean / enum.

```javascript
class MyApiDriver extends BaseDriver {
  static commands = {
    getData: {
      description: 'Получить данные с API',
      parameters: [
        {
          name: 'id',
          type: 'string',
          description: 'Идентификатор ресурса',
          required: true
        },
        {
          name: 'format',
          type: 'string',
          description: 'Формат данных',
          enum: ['json', 'xml'],
          required: false
        }
      ]
    },
    createItem: {
      description: 'Создать новый элемент',
      parameters: [
        {
          name: 'data',
          type: 'object',
          description: 'Данные для создания',
          required: true
        }
      ]
    }
  };

  // GET запрос
  getData(params) {
    const { id, format = 'json' } = params;
    return {
      method: 'GET',
      path: `/api/items/${id}`,           // Путь добавляется к server
      headers: {
        'Accept': `application/${format}`,
        'User-Agent': 'MyDriver/1.0'
      }
    };
  }

  // POST запрос
  createItem(params) {
    const { data } = params;
    return {
      method: 'POST',
      path: '/api/items',
      headers: {
        'Content-Type': 'application/json'
      },
      payload: data
    };
  }

  // Полный URL (игнорирует server из настроек)
  getExternalData() {
    return {
      method: 'GET',
      url: 'https://external-api.com/data',  // Полный URL
      headers: {
        'Accept': 'application/json'
      }
    };
  }
}
```

#### Поля HTTP команды

| Поле | Описание | Пример |
|------|----------|--------|
| `method` | HTTP метод | `'GET'`, `'POST'`, `'PUT'`, `'DELETE'` |
| `path` | Путь к ресурсу (добавляется к базовому URL) | `'/api/users/123'` |
| `url` | Полный URL (игнорирует базовый URL) | `'https://api.example.com/data'` |
| `headers` | Заголовки запроса | `{ 'Content-Type': 'application/json' }` |
| `payload` | Тело запроса | `{ name: 'John', age: 30 }` |
| `cookies` | Куки для запроса | `{ sessionId: 'abc123' }` |

> `requestTimeout` / `followRedirects` / `rejectUnauthorized` можно вернуть из команды, чтобы переопределить настройку подключения для конкретного запроса.

#### Обработка HTTP ответов

HTTP ответы содержат дополнительные поля:

```javascript
parseResponse(data) {
  // data содержит:
  // - data.payload: тело ответа (Buffer или string)
  // - data.statusCode: HTTP статус код
  // - data.headers: заголовки ответа
  // - data.responseUrl: итоговый URL после переадресаций
  // - data.responseCookies: куки от сервера

  if (data.statusCode >= 400) {
    return {
      type: 'error',
      statusCode: data.statusCode,
      message: `HTTP Error: ${data.statusCode}`
    };
  }

  try {
    // При ret:'obj' data.payload уже распарсен — JSON.parse только для строки
    let body = data.payload;
    if (typeof body === 'string') body = JSON.parse(body);
    return {
      type: 'success',
      statusCode: data.statusCode,
      data: body,
      headers: data.headers
    };
  } catch (error) {
    return {
      type: 'parseError',
      message: 'Не удалось распарсить JSON ответ',
      raw: data.payload.toString()
    };
  }
}
```

#### Работа с query параметрами

```javascript
// Способ 1: добавление в path
getUsers(params) {
  const { page = 1, limit = 10 } = params;
  return {
    method: 'GET',
    path: `/api/users?page=${page}&limit=${limit}`
  };
}

// Способ 2: использование URLSearchParams
getUsers(params) {
  const { page = 1, limit = 10, filter } = params;
  const searchParams = new URLSearchParams({
    page: page.toString(),
    limit: limit.toString()
  });
  
  if (filter) {
    searchParams.append('filter', filter);
  }
  
  return {
    method: 'GET',
    path: `/api/users?${searchParams.toString()}`
  };
}
```

#### Загрузка файлов (multipart/form-data)

```javascript
uploadFile(params) {
  const { file, description } = params;
  
  return {
    method: 'POST',
    path: '/api/upload',
    headers: {
      'Content-Type': 'multipart/form-data'
    },
    payload: {
      file: {
        value: file,  // Buffer с содержимым файла
        options: { 
          filename: 'document.pdf',
          contentType: 'application/pdf'
        }
      },
      description: description
    }
  };
}
```

#### Работа с куками

```javascript
// Установка кук в запросе
loginUser(params) {
  const { username, password } = params;
  return {
    method: 'POST',
    path: '/api/login',
    payload: { username, password },
    cookies: {
      sessionId: 'existing-session'
    }
  };
}

// Обработка кук в ответе
parseResponse(data) {
  if (data.responseCookies) {
    // Сохраняем куки для последующих запросов
    this.state.cookies = data.responseCookies;
  }
  
  return {
    type: 'loginResponse',
    success: data.statusCode === 200,
    cookies: data.responseCookies
  };
}
```

#### Контекстные переменные

HTTP транспорт поддерживает использование контекстных переменных Node-RED. Каждое из полей **Сервер** / **Пользователь** / **Пароль** в UI узла **Device Connection** — это типизированный ввод (typedInput): выберите тип значения (flow / global / env) и введите **голый ключ** (без `{{...}}`).

Например:

* **Сервер** — тип `global`, значение `apiServer` (читается как `global.get('apiServer')`).
* **Пользователь** — тип `flow`, значение `apiUser`.
* **Пароль** — тип `env`, значение `API_TOKEN`.

> Шаблоны вида `{{...}}` применимы только к полю **Сервер/URL** (подстановка в строку URL), но не к остальным полям подключения.

#### Пример полного HTTP драйвера

```javascript
const BaseDriver = require('base-driver');
const { URLSearchParams } = require('url');

class RestApiDriver extends BaseDriver {
  static metadata = {
    name: 'REST API',
    manufacturer: 'Generic',
    version: '1.0.0',
    description: 'Универсальный драйвер для REST API'
  };

  static commands = {
    getUsers: {
      description: 'Получить список пользователей',
      parameters: [
        { name: 'page', type: 'number', description: 'Номер страницы', required: false },
        { name: 'limit', type: 'number', description: 'Количество на странице', required: false }
      ]
    },
    createUser: {
      description: 'Создать пользователя',
      parameters: [
        { name: 'userData', type: 'object', description: 'Данные пользователя', required: true }
      ]
    },
    updateUser: {
      description: 'Обновить пользователя',
      parameters: [
        { name: 'id', type: 'string', description: 'ID пользователя', required: true },
        { name: 'userData', type: 'object', description: 'Новые данные', required: true }
      ]
    },
    deleteUser: {
      description: 'Удалить пользователя',
      parameters: [
        { name: 'id', type: 'string', description: 'ID пользователя', required: true }
      ]
    }
  };

  getUsers(params) {
    const { page = 1, limit = 10 } = params;
    const query = new URLSearchParams({ page, limit }).toString();
    
    return {
      method: 'GET',
      path: `/api/users?${query}`,
      headers: {
        'Accept': 'application/json'
      }
    };
  }

  createUser(params) {
    const { userData } = params;
    
    return {
      method: 'POST',
      path: '/api/users',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      payload: userData
    };
  }

  updateUser(params) {
    const { id, userData } = params;
    
    return {
      method: 'PUT',
      path: `/api/users/${id}`,
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      payload: userData
    };
  }

  deleteUser(params) {
    const { id } = params;
    
    return {
      method: 'DELETE',
      path: `/api/users/${id}`,
      headers: {
        'Accept': 'application/json'
      }
    };
  }

  parseResponse(data) {
    // Обработка ошибок HTTP
    if (data.statusCode >= 400) {
      return {
        type: 'error',
        statusCode: data.statusCode,
        message: `HTTP ${data.statusCode}: ${data.payload || 'Unknown error'}`,
        headers: data.headers
      };
    }

    // Успешный ответ
    try {
      let responseData = data.payload;
      
      // Парсим JSON если это строка
      if (typeof responseData === 'string') {
        responseData = JSON.parse(responseData);
      }

      return {
        type: 'success',
        statusCode: data.statusCode,
        data: responseData,
        headers: data.headers,
        url: data.responseUrl
      };
    } catch (error) {
      return {
        type: 'parseError',
        message: 'Ошибка парсинга JSON ответа',
        statusCode: data.statusCode,
        raw: data.payload.toString(),
        error: error.message
      };
    }
  }

  initialize() {
    // Можно выполнить начальный запрос для проверки доступности API
    this.publishCommand('getUsers', { limit: 1 });
  }
}

module.exports = RestApiDriver;
```

#### Тестирование HTTP драйвера

1. Создайте узел **Device Connection** с типом транспорта **HTTP**
2. Укажите базовый URL в поле **Server**: `https://jsonplaceholder.typicode.com`
3. Создайте узел **Device Command** и выберите команду `getUsers`
4. Добавьте узел **Device Response Listener** для получения ответов
5. Запустите поток и проверьте получение данных

### Обработка сложных протоколов

Некоторые устройства используют сложные протоколы с двоичными данными или специальными форматами. 
В таких случаях можно использовать дополнительные методы обработки данных:

```javascript
// Для бинарных данных можно использовать буферы
binaryCommand(params) {
  const { data } = params;
  // Создаем буфер с командой
  const buffer = Buffer.from([0x02, data.charCodeAt(0), 0x03]);
  return { payload: buffer };
}

// Обработка бинарных ответов
static responses = {
  binaryResponse: {
    description: 'Бинарный ответ',
    matcher: {
      pattern: /\x02(.+)\x03/
    },
    extract: function(match) {
      // Преобразование бинарных данных
      const payload = match[1];
      const value = payload.charCodeAt(0);
      return {
        value: value
      };
    }
  }
};
```

### Управление несколькими устройствами

Для протоколов, поддерживающих управление несколькими устройствами, можно добавить адресацию (`this.state` должен быть инициализирован в конструкторе — `this.state = {}`):

```javascript
setPower(params) {
  const { value, address = 1 } = params;
  this.state.power = value;
  // Добавляем адрес устройства в команду
  return { payload: `${address}:PWR ${value ? 'ON' : 'OFF'}\r\n` };
}
```

### Множественные ответы и publishResponse

Иногда устройство присылает **несколько независимых сообщений в одном сетевом пакете**. Базовый класс драйвера предоставляет два вспомогательных метода, благодаря которым такая ситуация легко обрабатывается.

Когда использовать:

1. **Один ответ** – верните объект из `parseResponse()` с помощью `return`.
2. **Несколько ответов сразу** – верните `Array` объектов, каждый будет опубликован системой по очереди.
3. **Асинхронная логика** – внутри `parseResponse()` вызывайте `this.publishResponse()` столько раз, сколько нужно, и затем верните `return null`.

```javascript
// Пример: в буфере сразу два ответа разделены переводом строки
parseResponse(data) {
  const parts = data.data.split('\r\n').filter(Boolean);
  if (parts.length === 1) {
    // Простой случай – один ответ
    return this._parseSingle(parts[0]);
  }

  // Сложный случай: публикуем через publishResponse
  parts.forEach(part => {
    const parsed = this._parseSingle(part);
    // second parameter не обязателен, но полезен для дебага, в данном случае в raw записываем сырой ответ
    this.publishResponse(parsed, { raw: part });
  });
  return null;
}
```
* **`publishResponse(payload, originalMsg)`** – публикует один распарсенный ответ наружу.
  * `payload` – объект-результат вашего парсинга (то же, что обычно возвращает `parseResponse`).
  * `originalMsg` – необязательный объект-обёртка для сырого пакета/любой вспомогательной информации. Он попадёт в узел «Device Response Listener» как `msg.originalMsg`. Если не нужен — опустите параметр.

> **Важно:** если вернёте `null` или `undefined`, система считает, что драйвер уже опубликовал ответы. Если ответ передается не корректно, он всегда попадает на первый выход.

> **Важно:** если драйвер возвращает сообщение, которое не соответствует правилам маршрутизации (например, отсутствует поле type или его значение не указано в outputsMap), узел-слушатель направит такое сообщение на первый выход. В этом случае настроенная карта выходов игнорируется.

### Инициирование команд и `publishCommand`

Иногда драйверу нужно **самому** отправить команду устройству — например, при `initialize()` или при изменении внутренних таймеров.В `BaseDriver` имеется вспомогательный метод `publishCommand`:

```javascript
publishCommand(command, params = {})
```

* `command` - Имя команды
* `params` - Параметры команды

#### Когда использовать

* Запрос начальных статусов в `initialize()`.
* Реакция на внутренние события драйвера без участия внешних узлов.

#### Пример: запрос статуса при инициализации

```javascript
initialize() {
  // Вместо прямого вызова sendCommand → write используйте publishCommand
  this.publishCommand('getStatus');

  // Или несколько команд сразу
  this.publishCommand('getVolume');
  this.publishCommand('getInput');
}
```

#### Передача готового объекта

Если у вас уже есть сформированный объект (например, после вычислений), можно передать его напрямую:

```javascript
this.publishCommand({ command: 'setPower', parameters: { value: true } });
```

#### Примеры вызова `publishCommand`

```javascript
// 1) Булев параметр – включаем питание
this.publishCommand('setPower', { value: true });

// 2) То же, но сразу объектом
this.publishCommand({ command: 'setPower', parameters: { value: false } });

// 3) Числовой параметр – установить громкость
this.publishCommand('setVolume', { level: 25 });

// 4) Значение из enum – переключиться на HDMI2
this.publishCommand('setInput', { source: 'HDMI2' });

// 5) Несколько числовых параметров
this.publishCommand('multiSet', { brightness: 80, contrast: 60 });
```

### Автоматический Keep-Alive

Узел **Device Connection** может автоматически отправлять «пинг»-команды, чтобы поддерживать соединение и регулярно опрашивать устройство.

* В настройках узла есть параметры:
  * **Keep Alive** – включить/выключить (по умолчанию выключена).
  * **Interval** – период в секундах (поле *Keep Alive interval*).

* Для работы Keep-Alive драйвер должен реализовать метод `KeepAlive()`. **Метод ничего не возвращает** — для периодического опроса он вызывает `this.publishCommand(...)` (одну или несколько команд, объявленных в `static commands`):

```javascript
KeepAlive() {
    // Один запрос — проверить что устройство живо и получить state
    this.publishCommand('getPower');
}

// Или несколько запросов за один тик (batch poll):
KeepAlive() {
    this.publishCommand('getPower');
    this.publishCommand('getInput');
    this.publishCommand('getVolume');
}
```

> **Канон:** `KeepAlive()` не возвращает `{ payload }`. Он использует `publishCommand` и проходит через обычный command pipeline (whitelist, epoch, audit). Возврат formatted-payload (`return { payload: '...' }`) технически ещё поддерживается runtime'ом для совместимости, но в новых драйверах **не использовать** — это back-door, обходящий проверки.

> **Примечание:** Если драйвер не реализует метод `KeepAlive()`, keep-alive запросы будут молча проигнорированы.


### Управление ресурсами и `onDestroy()`

Если ваш драйвер создает собственные таймеры (`setInterval`), открывает дополнительные сокеты или подписывается на глобальные события, вы **обязаны** очистить эти ресурсы при остановке драйвера. В противном случае это приведет к утечкам памяти и некорректной работе при перезапуске потоков ("зомби" процессы).

Для этого реализуйте метод **`onDestroy()`**. Базовый класс сам вызовет его из своего `destroy()` (см. [base-driver.js:583](../lib/base-driver.js#L583)) — переопределять `destroy()` напрямую **не нужно** и **не рекомендуется**, иначе вы потеряете обнуление `handlers`/`_responseTopic`/`_commandTopic`, которое делает базовый метод.

```javascript
initialize() {
    // initialize вызывается на КАЖДОЕ переподключение — снимаем прошлый таймер
    if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
    // Запуск периодического опроса (если не используется встроенный Keep-Alive)
    this.pollTimer = setInterval(() => {
        this.publishCommand('getStatus');
    }, this.config.pollInterval || 5000);
}

// Вызывается базовым destroy() при остановке/удалении узла
onDestroy() {
    // Очистка таймера
    if (this.pollTimer) {
        clearInterval(this.pollTimer);
        this.pollTimer = null;
    }

    // Очистка других ресурсов
    if (this.myCustomSocket) {
        this.myCustomSocket.destroy();
    }

    // Если cleanup асинхронный — верните Promise, base-driver его await'нет
    // return this.myAsyncCleanup();
}
```

### Доступ к контексту Node-RED

Код драйвера имеет прямой доступ к глобальным объектам Node-RED. Это позволяет драйверу взаимодействовать с контекстом потока и глобальными настройками так же, как это делается в стандартном узле **Function**.

#### Доступные объекты контекста

> **Важно:** Объекты контекста (`global`, `flow`, `context`, `env`, `node`) доступны напрямую в коде драйвера **без префикса `this.`**, аналогично узлу Function в Node-RED.

| Объект | Описание | Методы |
|--------|----------|--------|
| `global` | Глобальный контекст (общий для всех потоков) | `get(key)`, `set(key, value)`, `keys()` |
| `flow` | Контекст потока (общий для узлов в одном flow) | `get(key)`, `set(key, value)`, `keys()` |
| `context` | Контекст узла (локальный для device-connection) | `get(key)`, `set(key, value)`, `keys()` |
| `env` | Переменные окружения | `get(name)` |
| `node` | Информация и методы узла | `id`, `name`, `log()`, `warn()`, `error()`, `debug()`, `trace()`, `status()` |
| `console` | Логирование (перенаправлено в Node-RED) | `log()`, `info()`, `warn()`, `error()`, `debug()` |

> **Примечание:** Объект `console` в драйвере перенаправлен в лог Node-RED с префиксом `[driver:имя_драйвера]`. Используйте `console.log()` для отладки — вывод будет виден в логе Node-RED, а не в системной консоли.

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

```javascript
// === global — глобальный контекст ===
// Чтение
const siteId = global.get('siteId');
const config = global.get('deviceConfig');

// Запись
global.set('lastActivity', Date.now());
global.set('statistics', { requests: 100, errors: 2 });

// Получение всех ключей
const allKeys = global.keys();


// === flow — контекст потока ===
// Чтение
const lastState = flow.get('lastDeviceState');

// Запись
flow.set('deviceStatus', { power: true, volume: 50 });

// Удаление (запись undefined)
flow.set('tempData', undefined);


// === context — контекст узла ===
// Локальные данные, привязанные к конкретному device-connection
context.set('requestCount', (context.get('requestCount') || 0) + 1);


// === env — переменные окружения ===
// Чтение переменных из settings.js или окружения системы
const apiToken = env.get('MY_DEVICE_TOKEN');
const serverUrl = env.get('API_SERVER');

// Поддерживаются также переменные, определённые в подпотоках (subflows)
const subflowParam = env.get('SUBFLOW_PARAM');


// === node — информация об узле ===
// ID и имя узла
console.log(`Узел: ${node.name} (${node.id})`);

// Логирование (выводится в лог Node-RED)
node.log('Информационное сообщение');
node.warn('Предупреждение');
node.error('Ошибка');
node.debug('Отладочная информация');  // Выводится в Debug панель, если включен driverDebug
node.trace('Трассировка');

// Установка статуса узла (цветной индикатор)
node.status({ fill: 'green', shape: 'dot', text: 'connected' });

// Примечание: node.send() недоступен — драйвер не отправляет сообщения напрямую,
// для этого используйте this.publishResponse() или this.publishCommand()
```

#### Пример: драйвер с сохранением состояния

```javascript
class StatefulDriver extends BaseDriver {
    initialize() {
        // Восстанавливаем состояние из flow контекста.
        // BaseDriver не создаёт this.state — инициализируем безусловно,
        // иначе первая же команда, пишущая в this.state, бросит TypeError.
        this.state = flow.get('deviceState') || {};
        console.log('Состояние восстановлено:', this.state);
        
        // Получаем настройки из global
        const globalConfig = global.get('deviceConfig') || {};
        this.timeout = globalConfig.timeout || 5000;
    }
    
    setPower(params) {
        const { value } = params;
        
        // Обновляем состояние
        this.state.power = value;
        
        // Сохраняем в flow для persistence
        flow.set('deviceState', this.state);
        
        // Логируем изменение
        node.log(`Power set to: ${value}`);
        
        return { payload: `PWR ${value ? 'ON' : 'OFF'}\r\n` };
    }
    
    getStatus() {
        // Читаем актуальные данные из разных контекстов
        const globalStats = global.get('statistics') || {};
        const flowState = flow.get('deviceState') || {};
        const localCount = context.get('requestCount') || 0;
        
        return {
            payload: 'STATUS\r\n',
            meta: { globalStats, flowState, localCount }
        };
    }
}
```

> **Примечание:** Доступ к `context` и `flow` привязан к узлу **Device Connection**, который использует данный драйвер. Если один и тот же драйвер используется несколькими узлами подключения, у каждого будет свой `context` и `flow` (в зависимости от расположения узла), но `global` — общий для всех.

### Динамическое обновление параметров

> **Важно:** Параметры драйвера (из `metadata.parameters`) разрешаются **один раз** при создании драйвера (deploy или старт Node-RED). Если параметр ссылается на `global` или `flow` переменную, её значение читается в этот момент. **Автоматического отслеживания изменений нет** — если вы измените `global.set('myToken', 'newValue')`, драйвер не узнает об этом автоматически.

Чтобы получить актуальные значения параметров, драйвер должен явно запросить обновление:

1.  Вызовите метод `this.requestParamsUpdate()`.
2.  Система заново вычислит значения параметров из настроек узла (re-evaluate `global`, `flow`, `env`).
3.  Система обновит `this.config` и вызовет метод `onParamsUpdated(params)` в вашем драйвере (если он определён).

#### Пример:

```javascript
constructor(options) {
    super(options);
    // Инициализация из начальных параметров
    this.apiKey = this.config.apiKey;
}

// Команда для проверки/обновления авторизации
checkAuth() {
    // Запрашиваем обновление параметров (вдруг токен в global обновился)
    this.requestParamsUpdate();
}

// Этот метод вызовется автоматически, когда придут новые данные
onParamsUpdated(params) {
    // params - это объект с актуальными значениями
    
    // Проверяем, изменился ли ключ
    if (this.apiKey !== params.apiKey) {
        console.log('API ключ был изменен!');
        this.apiKey = params.apiKey;
        
        // Выполняем действия с новым ключом (например, переподключение)
        this.doLogin();
    }
}
```

### Проверка регулярных выражений

Если обработчики ответов не срабатывают, проверьте корректность регулярных выражений:

```javascript
// Тестирование регулярного выражения
const regex = /Status: (.+), Power: (.+), Volume: (.+)/;
const testData = "Status: READY, Power: ON, Volume: 50";
const match = testData.match(regex);

if (match) {
  console.log('Совпадение найдено:');
  console.log('Статус:', match[1]);
  console.log('Питание:', match[2]);
  console.log('Громкость:', match[3]);
} else {
  console.log('Совпадение не найдено');
}
```

### Проблемы с форматированием команд

Если команды не отправляются корректно, убедитесь, что:

1. Метод (функция) имеет то же имя, что и команда в `static commands`
2. Команда корректно возвращает объект с полем `payload`

#### Подробно о параметрах команд

Ниже приведены типовые сценарии и то, как они описываются в массиве `parameters`.

| Сценарий | Как описать в `parameters` | Что увидит пользователь в узле **Device Command** |
|----------|---------------------------|-----------------------------------------------|
| Несколько числовых параметров | `multiSet` (см. пример ниже) | Два numeric-input поля. |
| Выпадающий список (enum) | `setInput` (см. пример ниже) | Dropdown-меню со значениями из `enum`. |
| Число с пределами | `setVolume` (см. пример ниже) | Numeric-input с подсказками `min`/`max` в редакторе (проверку выполняет драйвер). |
| Булев параметр | `setPower` (см. пример ниже) | Dropdown-меню `True/False`. |

##### Примеры описаний

```js
// Несколько параметров
multiSet: {
  description: 'Яркость и контраст',
  parameters: [
    { name: 'brightness', type: 'number', min: 0, max: 100, required: true },
    { name: 'contrast',   type: 'number', min: 0, max: 100, required: true }
  ]
}

// Выпадающий список
setInput: {
  parameters: [
    { name: 'source', type: 'string', enum: ['HDMI1', 'HDMI2', 'USB'] }
  ]
}

// Число с пределами
setVolume: {
  parameters: [
    { name: 'level', type: 'number', min: 0, max: 100 }
  ]
}

// Булево
setPower: {
  parameters: [
    { name: 'value', type: 'boolean', required: true }
  ]
}
```

> Если у параметра указано `required: true`, редактор подсветит незаполненное поле как обязательное. Это подсказка редактора для статических полей; в runtime значения из `msg.parameters`/flow/global/JSONata не проверяются — проверку выполняет драйвер.

##### Динамическое заполнение параметров из сообщения

Пример:

```javascript
// Function-node перед Device Command
msg.parameters = { brightness: 80, contrast: 55 };
return msg;
```

Если параметр содержит `enum`, убедитесь, что переданное значение входит в список.

> **Важно:** параметры типа `object`/`array` НЕ редактируются в UI узла **Device Command** (текстовое поле сохранит их как строку) — передавайте их только через `msg.parameters` из предшествующего Function-узла, например `msg.parameters = { userData: { name: 'John' } }`. В редакторе поддерживаются только типы string / number / boolean / enum.

## Примеры драйверов

### Тестовый драйвер для Local (Loopback) транспорта

Файл `drivers/loopback-test.js` — полный пример драйвера для работы без сетевого подключения:

```javascript
const BaseDriver = require('base-driver');

class LoopbackTestDriver extends BaseDriver {
    
    static metadata = {
        name: 'Loopback Test Driver',
        manufacturer: 'Dynamic Driver',
        model: 'Virtual',
        version: '1.0.0',
        description: 'Тестовый драйвер для Local (loopback) транспорта',
        category: 'Virtual',
        parameters: [
            {
                name: 'prefix',
                type: 'string',
                default: 'LOOPBACK',
                description: 'Префикс для ответов'
            },
            {
                name: 'counterKey',
                type: 'string', 
                default: 'loopbackCounter',
                description: 'Ключ для хранения счётчика в global контексте'
            }
        ]
    };
    
    static commands = {
        Echo: {
            description: 'Возвращает переданное сообщение',
            parameters: [
                { name: 'message', type: 'string', description: 'Сообщение' }
            ]
        },
        GetCounter: {
            description: 'Получить счётчик из global контекста'
        },
        IncrementCounter: {
            description: 'Увеличить счётчик',
            parameters: [
                { name: 'step', type: 'number', default: 1 }
            ]
        },
        SetFlowValue: {
            description: 'Записать значение в flow контекст',
            parameters: [
                { name: 'key', type: 'string', required: true },
                { name: 'value', type: 'string', required: true }
            ]
        },
        GetFlowValue: {
            description: 'Прочитать из flow контекста',
            parameters: [
                { name: 'key', type: 'string', required: true }
            ]
        },
        Calculate: {
            description: 'Математические операции',
            parameters: [
                { name: 'a', type: 'number', required: true },
                { name: 'b', type: 'number', required: true },
                { name: 'operation', type: 'string', enum: ['add', 'sub', 'mul', 'div'], default: 'add' }
            ]
        },
        GetEnv: {
            description: 'Получить переменную окружения',
            parameters: [
                { name: 'name', type: 'string', required: true }
            ]
        },
        GetStatus: {
            description: 'Статус драйвера'
        }
    };
    
    constructor(options) {
        super(options);
        // this.config уже инициализирован в super()
        this.startTime = Date.now();
    }
    
    initialize() {
        console.log('[LoopbackTest] Driver ready');
    }
    
    // === Команды ===
    // Примечание: `return JSON.stringify({...})` — это форма «голой строки»,
    // эквивалентная `return { payload: JSON.stringify({...}) }`.
    
    Echo(params) {
        const prefix = this.config.prefix || 'LOOPBACK';
        return JSON.stringify({
            command: 'Echo',
            prefix: prefix,
            message: params.message || 'Hello!',
            timestamp: new Date().toISOString()
        });
    }
    
    GetCounter(params) {
        const key = this.config.counterKey || 'loopbackCounter';
        return JSON.stringify({
            command: 'GetCounter',
            key: key,
            value: global.get(key) || 0
        });
    }
    
    IncrementCounter(params) {
        const key = this.config.counterKey || 'loopbackCounter';
        const step = Number(params.step) || 1;
        const current = global.get(key) || 0;
        const newValue = current + step;
        
        global.set(key, newValue);
        
        return JSON.stringify({
            command: 'IncrementCounter',
            previousValue: current,
            step: step,
            newValue: newValue
        });
    }
    
    SetFlowValue(params) {
        if (!params.key) {
            return JSON.stringify({ error: 'Key is required' });
        }
        
        flow.set(params.key, params.value);
        
        return JSON.stringify({
            command: 'SetFlowValue',
            key: params.key,
            value: params.value,
            success: true
        });
    }
    
    GetFlowValue(params) {
        if (!params.key) {
            return JSON.stringify({ error: 'Key is required' });
        }
        
        const value = flow.get(params.key);
        
        return JSON.stringify({
            command: 'GetFlowValue',
            key: params.key,
            value: value,
            exists: value !== undefined
        });
    }
    
    Calculate(params) {
        const a = Number(params.a) || 0;
        const b = Number(params.b) || 0;
        const op = params.operation || 'add';
        
        let result, error = null;
        
        switch (op) {
            case 'add': result = a + b; break;
            case 'sub': result = a - b; break;
            case 'mul': result = a * b; break;
            case 'div': 
                result = b !== 0 ? a / b : null;
                error = b === 0 ? 'Division by zero' : null;
                break;
            default: error = `Unknown operation: ${op}`;
        }
        
        return JSON.stringify({ command: 'Calculate', a, b, operation: op, result, error });
    }
    
    GetEnv(params) {
        if (!params.name) {
            return JSON.stringify({ error: 'Name is required' });
        }
        
        const value = env.get(params.name);
        
        return JSON.stringify({
            command: 'GetEnv',
            name: params.name,
            value: value,
            exists: value !== undefined
        });
    }
    
    GetStatus(params) {
        const uptime = Math.floor((Date.now() - this.startTime) / 1000);
        const counterKey = this.config.counterKey || 'loopbackCounter';
        
        return JSON.stringify({
            command: 'GetStatus',
            status: 'online',
            uptime: `${Math.floor(uptime / 60)}m ${uptime % 60}s`,
            counter: global.get(counterKey) || 0,
            config: this.config
        });
    }
    
    KeepAlive() {
        this.publishCommand('GetStatus');
    }
    
    // === Парсинг ответов от loopback ===
    
    parseResponse(data) {
        try {
            const payload = data.data || data.payload || data;
            return JSON.parse(payload);
        } catch (e) {
            return { error: 'Parse error', raw: String(payload) };
        }
    }
}

module.exports = LoopbackTestDriver;
```

#### Тестирование loopback драйвера

1. Скопируйте код в файл `drivers/loopback-test.js`
2. Перезапустите Node-RED
3. Создайте поток:

```
[Inject] → [Device Command] → [Response Listener] → [Debug]
```

4. В **Device Connection**:
   - Драйвер: `loopback-test`
   - Транспорт: `Local (loopback)`
   - Создайте новый Local Config

5. В **Device Command** выберите команду, например `Calculate` с параметрами:
   - a: 10
   - b: 5
   - operation: mul

6. Результат в Debug:
   ```json
   {
       "command": "Calculate",
       "a": 10,
       "b": 5,
       "operation": "mul",
       "result": 50,
       "error": null
   }
   ```

---
 
