Ответы устройства

Эта страница — для разработчика драйвера. Она объясняет, как драйвер превращает то, что прислало устройство, в разобранный ответ: что описать в static responses, что вернуть из extract и parseResponse, как отправить несколько ответов сразу и как проверить, что ответ дошёл до выходов узла device - response - listener.

Если ответы описаны неверно, драйвер внешне «работает», но в потоке (рабочем листе программы в U-Logic) ничего не меняется: сообщения идут не на тот выход, связи между устройствами и вставки вида ${signal.Устройство.сигнал} остаются пустыми.

Что нужно знать до этой страницы:

Как ответ проходит через систему

Транспорт (способ связи с устройством: TCP, UDP, HTTP, Telnet, SSH, RS232 и другие) получил данные от устройства и передал их драйверу в виде объекта-обёртки (что внутри обёртки — в разделе «parseResponse: разбор кодом» ниже). Дальше:

  1. Система по очереди проверяет записи static responses, у которых задан matcher. У первой совпавшей записи вызывается extract. Если он вернул объект — разбор закончен.

  2. Только если ни одна запись не подошла (или extract ничего не вернул) — вызывается метод parseResponse.

  3. Результат (один объект или список объектов) система публикует как ответ. Узел device - response - listener читает у ответа поле type и отправляет сообщение на выход, назначенный этому типу. Если type нет или тип ни на какой выход не назначен, сообщение уходит на первый выход.

        flowchart TD
    A[Данные от устройства] --> B{Совпал matcher<br/>в static responses?}
    B -- да --> C[extract возвращает поля]
    C --> D[Система добавляет type = имя записи]
    B -- нет --> E[parseResponse]
    E --> F[Вы возвращаете объект или список с полем type]
    D --> G[Response Listener: выход по type]
    F --> G
    

Ошибка в одном обработчике не ломает остальные: если extract или parseResponse упал, система пропускает этот ответ и продолжает работу. Поэтому о сбое вы узнаете только по тому, что ответа нет. Как это увидеть, описано в разделе «Если ответ не приходит».

Откуда берётся type

Имя ответа type определяет, на какой выход он пойдёт, и под этим именем на ответ подписываются связи между устройствами.

  • Ответ описан в static responses (с matcher и extract): type добавляет система — равным ключу записи. Своё type из extract возвращать не нужно: оно всё равно будет заменено.

  • Ответ собирает parseResponse или отправляет publishResponse: система ничего не добавляет, поле type вы указываете сами.

Остальные разделы страницы ссылаются на это правило и не повторяют его.

Описание ответов: static responses

Ключ записи в static responses — это имя ответа. Его же редактор показывает в окне узла device - response - listener в списке «Типы ответов» (см. Типы ответов).

static responses = {
    power: {
        description: 'Состояние питания',
        matcher: { pattern: /PWR (ON|OFF)/ },
        extract: (match) => ({ power: match[1] === 'ON' })
    },
    volume: {
        description: 'Текущая громкость',
        matcher: { pattern: /VOL (\d+)/ },
        extract: (match) => ({ volume: parseInt(match[1], 10) })
    }
};

Поле записи

Зачем

description

Подпись ответа в списке «Типы ответов»

matcher.pattern

Регулярное выражение, по которому распознаётся ответ. Номера групп — match[1], match[2] …

matcher.check

Вместо выражения — функция (data) => true/false, если ответ не текстовый и выражением его не распознать. Тогда в extract первый аргумент — null, а данные приходят во втором

extract

Функция (match, data) => объект: вытаскивает значения из совпадения

fields, valueField, keyFields, category, recommendedOutput

Необязательное описание для редактора, см. «Описание полей ответа для интерфейса»

Если выражение в pattern записано неверно, система пропускает только эту запись и пишет предупреждение; остальные ответы работают.

Имя ответа: как не надо / как надо

Называйте ответы коротко и по смыслу: так значение проще использовать в логике, а в списках и связях имя не получается громоздким.

Как не надо — описательные имена с добавкой Response:

static responses = {
    muteResponse:   { /* ... */ },
    levelResponse:  { /* ... */ },
    routerResponse: { /* ... */ }
};

Как надо:

static responses = {
    mute:  { /* ... */ },
    level: { /* ... */ },
    route: { /* ... */ },
    power: { /* ... */ }
};

type в extract: как не надо / как надо

Как не надо — возвращать своё type из extract. Оно будет заменено на имя записи (mute), а код вводит в заблуждение:

mute: {
    matcher: { pattern: /(\d+) mute (true|false)/ },
    extract: (m) => ({ type: 'muteEvent', channel: +m[1], muted: m[2] === 'true' })
}

Как надо:

mute: {
    matcher: { pattern: /(\d+) mute (true|false)/ },
    extract: (m) => ({ channel: +m[1], muted: m[2] === 'true' })
}

Главное значение: как не надо / как надо

Главное значение — одно число, слово или состояние «включено/выключено», которое и есть смысл ответа (для громкости — сам уровень). Его система показывает в интерфейсе и подставляет в связи между устройствами и во вставки ${signal.Устройство.сигнал}.

Система ищет главное значение в двух местах по порядку: поле с тем же именем, что у ответа (у ответа level — поле level), затем поле value. Если нет ни того, ни другого, связи и вставки останутся пустыми, хотя в окне отладки ответ виден.

Как не надо:

route: {
    matcher: { pattern: /^x([1-9])AVx([1-9])$/ },
    extract: (match) => ({ input: Number(match[1]), output: Number(match[2]) })
    // нет ни поля route, ни поля value — значение сигнала не определено
}

Как надо — добавьте поле value с главным значением:

route: {
    matcher: { pattern: /^x([1-9])AVx([1-9])$/ },
    extract: (match) => ({
        input:  Number(match[1]),
        output: Number(match[2]),
        value:  Number(match[1])    // куда переключилось
    })
}

Если у ответа есть осмысленное поле с именем ответа, value можно не добавлять:

level: {
    matcher: { pattern: /level (-?\d+\.\d+)/ },
    extract: (match) => ({ level: parseFloat(match[1]) })
}

Примечание

Главное значение — одно простое значение, а не объект и не список. Если ответ сложный (например, набор диагностических данных), положите в value одно простое значение: слово 'ok' или числовой код.

parseResponse: разбор кодом

Метод parseResponse(data) нужен, когда разбор шаблоном не получается: драйверу нужно что-то помнить между ответами, ответов в одном сообщении несколько, данные двоичные или в формате JSON.

Что приходит в data

В parseResponse приходит не текст ответа, а объект-обёртка от транспорта. Где в нём ответ — зависит от транспорта:

Транспорт

Что в data

TCP, Telnet, SSH, RS232

{ data: <строка или байты>, ... } — ответ в data.data

UDP

{ data: <байты>, rinfo: {...}, ... } — ответ в data.data

HTTP

{ payload: <строка или байты>, statusCode, headers, responseCookies, ... } — ответ в data.payload

Local (loopback)

{ data: <строка>, ... } — ответ в data.data

В каком виде придёт ответ, задаёт окно транспорта: у TCP — поле «Возвращать» («Байты (Buffer)» или «Строка»), у HTTP — «Возвращать» («UTF-8», «Бинарные данные» или «JSON»), у UDP — «Формат данных» («Строка (UTF8)», «Base64» или «Hex»). У Telnet, SSH и RS232 такого поля нет. Подробнее — в разделе Транспорты: как связаться с устройством страницы «Узлы Dynamic Driver».

Предупреждение

Не работайте с data как со строкой: String(data), data.split(...), data.match(...) дают '[object Object]' или ошибку, а разбор молча ничего не находит. Сначала достаньте текст из обёртки.

Короткий способ достать текст ответа:

const text = String(data?.data ?? data?.payload ?? '');

Для двоичных протоколов не превращайте ответ в строку, а работайте с байтами: data.data — это Buffer, если в окне TCP выбрано «Байты (Buffer)».

HTTP-ответ несёт ещё код data.statusCode, заголовки data.headers и куки data.responseCookies. Читайте их прямо из data, а тело — из data.payload:

parseResponse(data) {
    if (data?.headers?.['x-csrf-token']) this._csrfToken = data.headers['x-csrf-token'];
    const body = String(data?.payload ?? '');
    if (!body) return null;
    // ... разбор тела
}

Подробно про HTTP-драйверы — в разделе «Расширенные возможности».

Что возвращать

Вернули

Что произойдёт

Объект

Один ответ

Массив объектов

Каждый объект — отдельный ответ

null или undefined

Ответ игнорируется (или вы уже отправили его через publishResponse)

Поле type в каждом объекте вы указываете сами (см. «Откуда берётся type»). Обёртку вокруг вашего объекта система добавит сама.

Как не надо — собирать обёртку вручную. Получится обёртка внутри обёртки. У ответа не будет поля type (имя ответа окажется глубже, в msg.payload.response.type). Узел не найдёт type и отправит сообщение на первый выход:

parseResponse(data) {
    return {
        category: 'response',
        response: { type: 'Gain', gain: -30 },
        msg: data
    };
}

Как надо — вернуть только { type, ...поля }:

parseResponse(data) {
    const text = String(data?.data ?? data?.payload ?? '');
    const m = text.match(/^GAIN (\d+) (-?\d+\.\d+)$/);
    if (!m) return null;               // не распознали — верните null, не бросайте ошибку
    return {
        type:        'Gain',           // имя ответа указываете вы
        channelCode: m[1],
        gain:        Number(m[2]),
        value:       Number(m[2])      // главное значение
    };
}

Несколько ответов в одном сообщении — список объектов:

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

Запись в static responses для parseResponse

Чтобы тип появился в списке «Типы ответов» узла device - response - listener, опишите его в static responses, но без matcher и extract: только описание для интерфейса. Весь разбор остаётся в parseResponse:

static responses = {
    mute: {
        description: 'Состояние приглушения по каналу',
        category: 'audio',
        fields: [{ name: 'output', type: 'number' }, { name: 'muted', type: 'boolean' }],
        valueField: 'muted',
        keyFields: ['output']
    }
};

Что выбрать: matcher или parseResponse

Одно и то же сообщение разбирается либо через matcher, либо через parseResponse, но не обоими: если matcher сработал и extract вернул объект, parseResponse не вызывается. Нельзя использовать matcher только для проверки, а допарсить данные в parseResponse.

Вариант A: весь разбор в static responses

Подходит для простого текстового протокола: один шаблон на один ответ, драйверу ничего не нужно помнить между ответами.

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' })
    }
};

Вариант B: весь разбор в parseResponse

Подходит, если нужно помнить состояние (например, для команды-переключателя), в одном сообщении приходит несколько ответов, данные двоичные или в формате JSON.

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` };
}

Вариант C, которого нет: this внутри extract

Может показаться, что если написать extract обычной функцией, то через this из неё доступно состояние драйвера. Это не так: extract вызывается как часть описания ответа, и this внутри указывает на запись из static responses, а не на драйвер. Если при разборе нужны данные драйвера, используйте вариант B: в parseResponse this — это драйвер.

Как выбрать

Ситуация

Вариант

Нужно помнить прошлое состояние (команда-переключатель)

B

В одном сообщении несколько событий

B

Данные в JSON или двоичные

B

Простые ответы без состояния

A или B

Простые ответы и переключатели в одном драйвере

B для всего

Не смешивайте A и B в одном драйвере: когда одни ответы разбирает matcher, а другие parseResponse, трудно понять, почему одно значение запоминается, а другое нет. Выберите один способ на весь драйвер.

Несколько ответов и publishResponse

Устройство иногда присылает несколько независимых сообщений в одном пакете. Есть три способа отдать их системе:

  1. Один ответ — верните объект из parseResponse().

  2. Несколько ответов сразу — верните массив объектов.

  3. Ответ готов не сразу (таймер, функция обратного вызова) или ответов много — вызывайте this.publishResponse() столько раз, сколько нужно, и в конце верните null.

parseResponse(data) {
    const parts = String(data?.data ?? data?.payload ?? '').split('\r\n').filter(Boolean);
    parts.forEach((part) => {
        const parsed = this._parseSingle(part);        // вернёт { type, ...поля }
        if (parsed) this.publishResponse(parsed, { raw: part });
    });
    return null;   // «ответы уже отправлены, делать ничего не нужно»
}

publishResponse(payload, originalMsg):

  • payload — чистый объект { type, ...поля, value }, как из parseResponse. Система его не меняет и type не добавляет, поэтому поле type обязательно.

  • originalMsg — необязательные сопроводительные данные (например, сырой пакет). Они придут из узла device - response - listener в msg.originalMsg. Если параметр не передан, в msg.originalMsg будет null.

Предупреждение

Если в payload нет поля type или тип не назначен ни на один выход, узел отправит сообщение на первый выход, а настроенная карта выходов будет проигнорирована. Если parseResponse отправил ответы сам, а потом вернул не null, ответ придёт дважды.

Неполные ответы и «Таймаут делимитера»

Если в окне транспорта в поле «Таймаут делимитера» (delimiterTimeout, окна транспортов — в разделе Транспорты: как связаться с устройством) задано значение больше нуля, транспорт по истечении таймаута отдаёт неполные данные: то, что успело прийти, даже если разделителя сообщения ещё нет. Это помогает со старыми устройствами без разделителя, но обработчик может принять обрывок за ответ: вместо Volume: 50 придёт Volume: 5, и драйвер выдаст неверное значение. Если «Таймаут делимитера» равен нулю (по умолчанию), обрывки по таймауту не передаются. Но обрывки приходят и тогда: при переполнении «Размера буфера» и при разрыве связи. Поэтому проверку лучше оставить.

Метод this.isCompleteResponse(data) возвращает false для обрывка. Вызывайте его в parseResponse:

parseResponse(data) {
    if (!this.isCompleteResponse(data)) return null;   // пропускаем обрывок
    const m = String(data?.data ?? '').match(/Volume: (\d+)/);
    return m ? { type: 'volume', volume: Number(m[1]), value: Number(m[1]) } : null;
}

Внутри extract вызвать this.isCompleteResponse нельзя (см. вариант C), поэтому проверяйте признак data.incomplete во втором аргументе: extract: (m, data) => data?.incomplete ? null : { volume: Number(m[1]) }.

Другой способ — строгие выражения с привязкой к началу и концу строки (/^Volume: (\d+)$/m): обрывок под них не подойдёт.

Описание полей ответа для интерфейса

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

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'],
        category: 'audio',
        recommendedOutput: true,

        extract: (m) => ({
            instanceTag: m[1],
            channel: +m[2],
            muted: m[3] === 'true',
            key: m[1] + '.' + m[2]
        })
    }
};

Поле описания

Что делает редактор

fields

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

valueField

Показывает, какое поле выбрать по умолчанию

keyFields

Показывает подсказку, как складывается ключ, например устройство.сигнал.КЛЮЧ

category

Ярлык для группировки ответов в списке «Типы ответов» (любая строка). Это не то поле category, которое система добавляет во внешнюю обёртку ответа

recommendedOutput

Помечает ответ как рекомендуемый выход

Предупреждение

keyFields — только подсказка для редактора. Ключ система сама не собирает: чтобы подписка на ответ с ключом (например, device.mute.MIC1.3) работала, extract должен вернуть готовое поле key, как в примере выше. Без key такая подписка молча не срабатывает.

Если описание (fields, valueField) и то, что реально возвращает extract, расходятся, работает extract. Описывайте то, что он действительно возвращает. Добавлять описание можно постепенно, по одному ответу.

Как проверить, что ответ дошёл

Проверьте перед сохранением драйвера:

  1. Каждый extract возвращает объект с полем value или с полем, названным как ответ.

  2. Главное значение — одно простое значение, а не объект.

  3. parseResponse возвращает { type, ...поля } или массив таких объектов, без полей category, response, msg.

  4. parseResponse возвращает null, когда ответ не распознан, и не бросает ошибку.

  5. Если parseResponse сам отправляет ответы через publishResponse, он заканчивается return null.

  6. type везде заполнен и совпадает с именем, назначенным на выход в узле device - response - listener.

Затем сохраните драйвер. В потоке подключите к выходу device - response - listener узел debug и отправьте команду. В msg.payload вы должны увидеть чистый ответ, например:

{ type: 'Gain', channelCode: '1', gain: -30, value: -30 }

Что видно и что это значит:

В окне отладки

Причина

msg.payload = { category: 'response', response: { ... } } — на верхнем уровне нет поля type

В parseResponse лишняя обёртка: уберите category, response, msg

Ответ есть, но связи и вставки ${signal.…} пустые

Нет ни value, ни поля с именем ответа

Ответ приходит только на первый выход

Нет type или тип не назначен на выход в окне узла

Ответа нет вообще

Не сработал ни один matcher, а parseResponse вернул null или упал

Если ответ не приходит

  1. Включите «Driver Debug» в окне Device Connection (подпись — «Логировать все команды и ответы», см. Device Connection: подключение драйвера к транспорту). Тогда в окне отладки U-Logic видны полученные данные и ошибки обработчиков ответов; без флага ошибки обработчиков не показываются.

  2. Сравните полученный текст с выражением: оно должно подходить к реальному тексту, с учётом пробелов и символов конца строки. Проверьте группы на примере:

    const regex = /Status: (.+), Power: (.+), Volume: (.+)/;
    const match = 'Status: READY, Power: ON, Volume: 50'.match(regex);
    // match[1] === 'READY', match[2] === 'ON', match[3] === '50'
    
  3. Проверьте вид данных в окне транспорта — «Возвращать» у TCP и HTTP, «Формат данных» у UDP (см. выше): от этого зависит, что придёт в data.data и data.payload.

  4. Проверьте, что в узле device - response - listener выбрано нужное соединение, а типы включены и назначены на выходы (окно узла — на странице «Узлы Dynamic Driver», раздел device - response - listener: принять ответы).