Ir para o conteúdo

Cartão do dispositivo

Este é o capítulo principal da secção. Aqui o dispositivo obtém a interface no portal e na aplicação móvel — sem uma única linha de código neles.

Como funciona

O dispositivo publica um manifesto de cartão — descrição legível por máquina «o que mostrar e com o que controlar». Portal e aplicação leem o manifesto e constroem o cartão: sensores tornam-se células com valores ao vivo, controlos — botões, campos de entrada e listas. O layout também pode ser definido na firmware.

Você não precisa publicar nada manualmente: você declara entidades através de link.card(), e o núcleo recolhe o manifesto e envia-o automaticamente na ligação.

1. Declaramos entidades

Todas as declarações são feitas em setup(), após s_link.begin(). Nosso filtro tem três entidades: leitura VOC, lista de modos e campo de limiar. Vamos analisar cada uma separadamente, e no final reunir o bloco inteiro.

Princípio geral: id e label

Cada entidade tem dois nomes, não confunda:

  • id — interno, nome máquina ("voc", "mode"). Caracteres latinos, dígitos, underscore, sem espaços. Pelo id a entidade é reconhecida no layout, comandos e portal entre si. Escolhido uma vez — não muda;
  • label — inscrição para humanos ("VOC index", "Mode"). O que escrever é o que o utilizador vê no cartão. Pode mudar livremente.

Sensor: leitura VOC

s_link.card().sensor(
    "voc",              // id: nome interno da entidade
    "VOC index",        // label: inscrição no cartão
    "",                 // unit: unidade de medida à direita do número ("°C", "%", "g");
                        //   o índice VOC não tem unidades — string vazia
    "units[0].vocIndex" // path: onde obter o valor — caminho dentro do JSON de telemetria.
                        //   Este é o CAMPO que adicionámos no capítulo 5:
                        //   doc["units"][0]["vocIndex"]. Os nomes devem coincidir
                        //   letra a letra, senão no cartão será um traço.
);

Sensor — é uma célula «apenas leitura»: o portal pega o valor de telemetria por path e mostra. Sensor não tem comando.

Lista de escolha: modo de funcionamento

// Variantes da lista. O utilizador vê-los-á no menu dropdown como estão.
static const char* kModes[] = { "auto", "on", "off" };

s_link.card().select(
    "mode",                  // id: nome interno da entidade
    "Mode",                  // label: inscrição no cartão
    kModes,                  // options: array de variantes (declarado acima)
    3,                       // quantidade de variantes no array — auto, on, off = três.
                             //   C++ não conhece o comprimento sozinho, nós informamos
    [](const char* opt) {    // callback: função que o núcleo chamará quando
                             //   o utilizador seleccionar uma variante no portal.
                             //   opt — string seleccionada, por exemplo "on"
        onModeSelected(opt); //   passamos à nossa lógica (escreveremos no capítulo 7)
    }
);

Aqui aparece a segunda metade do mecanismo: controlo. Quando o utilizador escolhe uma variante no portal, o dispositivo recebe um comando, o núcleo recebe-o automaticamente, verifica (strings estranhas que não estão em options não passam) e chama o seu callback com o valor escolhido. Não precisa de desempacotar mensagens MQTT manualmente — a sua zona de responsabilidade começa dentro de onModeSelected.

Campo numérico: limiar de activação

s_link.card().number(
    "threshold",       // id: nome interno da entidade
    "VOC threshold",   // label: inscrição no cartão
    100,               // min: o portal não deixará escrever menos
    400,               // max: nem mais; o núcleo além disso
                       //   corta o valor dentro destes limites no seu lado
    10,                // step: incremento de mudança com setas do campo
    "",                // unit: unidade de medida; o índice não tem
    [](float v) {              // callback: chamado quando o utilizador envia
                               //   novo valor; v — número dentro de min..max
        onThresholdChanged(v); //   passamos à nossa lógica (capítulo 7)
    }
);

Reunimos tudo junto

O aspecto final do bloco em setup() — é o que deve ficar no seu código. As funções onModeSelected e onThresholdChanged escreveremos no capítulo 7; para o código compilar já agora, declare-as como stubs acima de setup():

// Stubs: corpos verdadeiros escreveremos no capítulo 7 (lógica de automatização).
static void onModeSelected(const char* opt) {}
static void onThresholdChanged(float v) {}

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

    // Telemetria: campo próprio vocIndex (capítulo 5).
    s_link.onTelemetryPublish([](JsonObject doc) {
        if (g_vocIndex >= 0) {
            doc["units"][0]["vocIndex"] = g_vocIndex;
        }
    });

    // Cartão: sensor + dois órgãos de controlo.
    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);
    });
}

E o ventilador? Não precisa declarar: a flag hasFan = true em Config já adicionou a célula «Ventilador» ao manifesto automaticamente — é uma capacidade de dicionário, o núcleo conhece tudo sobre ela.

Parênteses rectos em callbacks — sempre vazios

[](const char* opt) { ... } — é uma lambda, função sem nome; analisámos em detalhe na nota do capítulo 5. Lembrete da regra do núcleo: parênteses sempre vazios ([]), não levamos nada «consigo» para a lambda, guardamos o necessário em variáveis globais — como g_mode e g_threshold do próximo capítulo.

2. Layout automático do cartão

Você pode não definir layout. O portal construirá o cartão a partir das entidades declaradas — e construirá com cuidado: células de leitura agrupam-se em linhas (até três por linha, depois quebra), órgãos de controlo ficam abaixo, cada um em sua linha, tudo em estilo do portal. Para a maioria dos dispositivos isto é suficiente — a interface fica organizada sem uma única preocupação com layout.

A ordem das entidades no cartão — a ordem de sua declaração em setup().

3. Layout próprio do cartão (opcional)

Primeiro — como o cartão é organizado. O cartão é uma pilha vertical de linhas. Uma linha — faixa horizontal onde ficam de um a quatro entidades; a largura do cartão é partilhada igualmente entre elas: uma entidade numa linha ocupará toda a largura, duas — metade cada, três — um terço cada.

O layout automático da secção anterior distribui entidades por estas linhas sozinho. Se quiser decidir você o que fica junto com o quê — defina linhas manualmente com chamadas de layoutRow. Uma chamada = uma linha, ordem de chamadas = ordem de linhas de cima para baixo:

// Linha 1: duas células — índice VOC e ventilador, cada metade da largura.
s_link.card().layoutRow("voc", "fan");

// Linha 2: dois órgãos de controlo — modo e limiar, também metade cada.
s_link.card().layoutRow("mode", "threshold");

Para layoutRow passam-se id das entidades — aqueles nomes internos que deu na declaração (foi para isto que id era necessário). "fan" — id da entidade de dicionário do ventilador, criada pela flag hasFan.

No cartão isto dará a seguinte composição:

┌─ DIY Air Filter ────────────────┐
│  VOC index        │  Ventilador │   ← linha 1: voc, fan
│  103              │  Deslig     │
├───────────────────┼─────────────┤
│  Mode      [auto ▾] │ Threshold [150] │   ← linha 2: mode, threshold
└─────────────────────────────────┘

Entidades não mencionadas em nenhuma linha não desaparecem — o portal desenha-as abaixo automaticamente. Assim pode definir apenas o «principal», deixando o resto para a automatização.

4. O que vai para o ar

O núcleo publica no tópico 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"] ]
}

Não é necessário entender este JSON — o núcleo gera-o a partir das suas chamadas. Mas é útil saber: se escrever firmware não em idryer-core (Rust, MicroPython, o que quer que seja), basta publicar este JSON por si próprio — o portal é omnívoro, desde que o formato coincida.

5. Verificação

Carregue e abra o dispositivo no portal:

  • célula VOC index mostra o índice ao vivo (respire sobre o sensor — o número cresce na próxima atualização);
  • célula Ventilador — Lig/Deslig;
  • Mode — lista dropdown, VOC threshold — campo com botão de envio.

A escolha de modo e limiar ainda não faz nada — callbacks stubs. Vamos animá-los no próximo capítulo.

Este é o conceito em si

Veja o que aconteceu: descreveu a interface em cinco linhas na firmware — e ela apareceu no portal e na aplicação. A mesma técnica funciona para qualquer dispositivo seu: mudam apenas id, inscrições e callbacks.