Перейти к содержанию

Карточка устройства

Это главная глава раздела. Здесь устройство получает интерфейс на портале и в мобильном приложении — без единой строчки кода на их стороне.

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

Устройство публикует card-манифест — машиночитаемое описание «что показать и чем управлять». Портал и приложение читают манифест и строят карточку: сенсоры становятся ячейками с живыми значениями, контролы — кнопками, полями ввода и списками. Разметку тоже можно задать из прошивки.

Вам не нужно ничего публиковать вручную: вы объявляете сущности через link.card(), а ядро само собирает манифест и отправляет его при подключении.

1. Объявляем сущности

Все объявления делаются в setup(), после s_link.begin(). У нашего фильтра три сущности: показание VOC, список режимов и поле порога. Разберём каждую отдельно, а в конце соберём блок целиком.

Общий принцип: id и label

У каждой сущности есть два имени, не путайте их:

  • id — внутреннее, машинное имя ("voc", "mode"). Латиница, цифры, подчёркивание, без пробелов. По id сущность узнают разметка, команды и портал между собой. Придумали один раз — не меняете;
  • label — подпись для человека ("VOC index", "Mode"). Что напишете, то пользователь и увидит на карточке. Менять можно свободно.

Сенсор: показание VOC

s_link.card().sensor(
    "voc",              // id: внутреннее имя сущности
    "VOC index",        // label: подпись на карточке
    "",                 // unit: единица измерения справа от числа ("°C", "%", "g");
                        //   у VOC-индекса единицы нет — пустая строка
    "units[0].vocIndex" // path: откуда брать значение — путь внутри JSON телеметрии.
                        //   Это ТО САМОЕ поле, которое мы дописали в главе 5:
                        //   doc["units"][0]["vocIndex"]. Имена должны совпадать
                        //   буква в букву, иначе на карточке будет прочерк.
);

Сенсор — это ячейка «только чтение»: портал берёт значение из телеметрии по path и показывает. Никакой команды у сенсора нет.

Список выбора: режим работы

// Варианты списка. Пользователь увидит их в выпадающем меню как есть.
static const char* kModes[] = { "auto", "on", "off" };

s_link.card().select(
    "mode",                  // id: внутреннее имя сущности
    "Mode",                  // label: подпись на карточке
    kModes,                  // options: массив вариантов (объявлен строкой выше)
    3,                       // количество вариантов в массиве — auto, on, off = три.
                             //   C++ сам не знает длину массива, её сообщаем мы
    [](const char* opt) {    // колбэк: функция, которую ядро вызовет, когда
                             //   пользователь выберет вариант на портале.
                             //   opt — выбранная строка, например "on"
        onModeSelected(opt); //   передаём её в нашу логику (напишем в главе 7)
    }
);

Здесь появляется вторая половина механизма: управление. Когда пользователь выбирает вариант на портале, устройству прилетает команда, ядро само её принимает, проверяет (чужие строки, которых нет в options, до вас не дойдут) и вызывает ваш колбэк с выбранным значением. Разбирать MQTT-сообщения руками не нужно — ваша зона ответственности начинается внутри onModeSelected.

Числовое поле: порог срабатывания

s_link.card().number(
    "threshold",       // id: внутреннее имя сущности
    "VOC threshold",   // label: подпись на карточке
    100,               // min: меньше этого портал ввести не даст
    400,               // max: больше этого — тоже; ядро дополнительно
                       //   обрежет значение по этим границам на своей стороне
    10,                // step: шаг изменения значения стрелками поля
    "",                // unit: единица измерения; у индекса её нет
    [](float v) {              // колбэк: вызывается, когда пользователь отправил
                               //   новое значение; v — число в границах min..max
        onThresholdChanged(v); //   передаём в нашу логику (напишем в главе 7)
    }
);

Собираем вместе

Финальный вид блока в setup() — то, что должно остаться в вашем коде. Функции onModeSelected и onThresholdChanged мы напишем в главе 7; чтобы код компилировался уже сейчас, объявите их заглушками выше setup():

// Заглушки: настоящие тела напишем в главе 7 (логика автоматики).
static void onModeSelected(const char* opt) {}
static void onThresholdChanged(float v) {}

void setup() {
    Serial.begin(115200);
    s_link.begin();
    initVocSensor();

    // Телеметрия: своё поле vocIndex (глава 5).
    s_link.onTelemetryPublish([](JsonObject doc) {
        if (g_vocIndex >= 0) {
            doc["units"][0]["vocIndex"] = g_vocIndex;
        }
    });

    // Карточка: сенсор + два органа управления.
    s_link.card().sensor("voc", "VOC index", "", "units[0].vocIndex");

    static const char* kModes[] = { "auto", "on", "off" };
    s_link.card().select("mode", "Mode", kModes, 3, [](const char* opt) {
        onModeSelected(opt);
    });

    s_link.card().number("threshold", "VOC threshold", 100, 400, 10, "", [](float v) {
        onThresholdChanged(v);
    });
}

А вентилятор? Его объявлять не нужно: флаг hasFan = true в Config уже добавил ячейку «Вентилятор» в манифест автоматически — это словарный навык, ядро знает о нём всё само.

Квадратные скобки в колбэках — всегда пустые

[](const char* opt) { ... } — это лямбда, безымянная функция; подробно мы разобрали её во врезке главы 5. Напомним правило ядра: скобки захвата всегда пустые ([]), внутрь лямбды ничего «с собой» не берём, всё нужное храним в глобальных переменных — как g_mode и g_threshold из следующей главы.

2. Автоматическая разметка карточки

Разметку можно вообще не задавать. Портал сам соберёт карточку из объявленных сущностей — и соберёт аккуратно: показания-ячейки группируются в ряды (до трёх в ряд, дальше перенос), органы управления идут ниже, каждый на своей строке, всё в фирменном оформлении портала. Для большинства устройств этого достаточно — интерфейс получается опрятным без единой мысли о вёрстке.

Порядок сущностей на карточке — порядок их объявления в setup().

3. Своя разметка карточки (по желанию)

Сначала — как устроена карточка. Карточка — это вертикальная стопка рядов. Ряд — горизонтальная полоса, в которой стоят от одной до четырёх сущностей; ширину карточки они делят поровну: одна сущность в ряду займёт всю ширину, две — по половине, три — по трети.

Автоматическая разметка из предыдущего раздела раскладывает сущности по этим рядам сама. Если хотите решать самостоятельно, что с чем стоит рядом, — задайте ряды вручную вызовами layoutRow. Один вызов = один ряд, порядок вызовов = порядок рядов сверху вниз:

// Ряд 1: две ячейки — индекс VOC и вентилятор, каждая по половине ширины.
s_link.card().layoutRow("voc", "fan");

// Ряд 2: два органа управления — режим и порог, тоже пополам.
s_link.card().layoutRow("mode", "threshold");

В layoutRow передаются id сущностей — те самые внутренние имена, которые вы дали им при объявлении (вот зачем id был нужен). "fan" — id словарной сущности вентилятора, её создал флаг hasFan.

На карточке это даст такую компоновку:

┌─ DIY Air Filter ────────────────┐
│  VOC index        │  Вентилятор │   ← ряд 1: voc, fan
│  103              │  Выкл       │
├───────────────────┼─────────────┤
│  Mode      [auto ▾] │ Threshold [150] │   ← ряд 2: mode, threshold
└─────────────────────────────────┘

Сущности, которые вы не упомянули ни в одном ряду, не пропадут — портал дорисует их ниже автоматическим списком. Так можно разметить только «главное», а остальное оставить автоматике.

4. Что уходит в эфир

Ядро опубликует в топик idryer/{serial}/card (retained):

{
  "v": 1,
  "entities": [
    { "id": "fan",  "type": "binary_sensor", "device_class": "fan",
      "source": "telemetry", "path": "units[0].fanStatus" },
    { "id": "voc",  "type": "sensor", "label": "VOC index",
      "source": "telemetry", "path": "units[0].vocIndex" },
    { "id": "mode", "type": "select", "label": "Mode",
      "options": ["auto", "on", "off"], "action": "card.mode", "arg": "value" },
    { "id": "threshold", "type": "number", "label": "VOC threshold",
      "min": 100, "max": 400, "step": 10, "action": "card.threshold", "arg": "value" }
  ],
  "layout": [ ["voc", "fan"], ["mode", "threshold"] ]
}

Разбираться в этом JSON не обязательно — ядро генерирует его из ваших вызовов. Но знать о нём полезно: если вы пишете прошивку не на idryer-core (Rust, MicroPython, что угодно), достаточно опубликовать такой JSON самостоятельно — портал всеядный, лишь бы формат совпадал.

5. Проверка

Прошейте и откройте устройство на портале:

  • ячейка VOC index показывает живой индекс (дуньте на датчик — число растёт при следующем обновлении);
  • ячейка Вентилятор — Вкл/Выкл;
  • Mode — выпадающий список, VOC threshold — поле с кнопкой отправки.

Выбор режима и порога пока ничего не делает — колбэки-заглушки. Оживим их в следующей главе.

Это и есть та самая концепция

Заметьте, что произошло: вы описали интерфейс пятью строками в прошивке — и он появился на портале и в приложении. Тот же приём работает для любого вашего устройства: меняются только id, подписи и колбэки.