Saltar a contenido

Tarjeta del dispositivo

Este es el capítulo principal de la sección. Aquí el dispositivo obtiene interfaz en el portal y en la aplicación móvil — sin una sola línea de código en su lado.

Cómo funciona

El dispositivo publica un card manifest — descripción legible por máquina de "qué mostrar y cómo controlar". El portal y la aplicación leen el manifest y construyen la tarjeta: los sensores se convierten en celdas con valores en vivo, los controles en botones, campos de entrada y listas. El diseño también se puede especificar desde el firmware.

No necesitas publicar nada manualmente: declaras entidades a través de link.card(), y el núcleo automáticamente reúne el manifest y lo envía cuando se conecta.

1. Declaramos entidades

Todas las declaraciones se hacen en setup(), después de s_link.begin(). Nuestro filtro tiene tres entidades: lectura de VOC, lista de modos y campo de umbral. Desglosemos cada una por separado, y al final reuniremos el bloque completo.

Principio general: id y label

Cada entidad tiene dos nombres, no los confundas:

  • id — interno, nombre de máquina ("voc", "mode"). Latín, dígitos, guión bajo, sin espacios. Por id la entidad se reconoce en el diseño, comandos y portal entre sí. Lo inventas una vez — no lo cambias;
  • label — firma para humanos ("VOC index", "Mode"). Lo que escribas, eso es lo que verá el usuario en la tarjeta. Puedes cambiarlo libremente.

Sensor: lectura de VOC

s_link.card().sensor(
    "voc",              // id: nombre interno de la entidad
    "VOC index",        // label: firma en la tarjeta
    "",                 // unit: unidad de medida a la derecha del número ("°C", "%", "g");
                        //   el índice VOC no tiene unidades — cadena vacía
    "units[0].vocIndex" // path: de dónde tomar el valor — ruta en el JSON de telemetría.
                        //   Este es ESE MISMO campo que agregamos en el capítulo 5:
                        //   doc["units"][0]["vocIndex"]. Los nombres deben coincidir
                        //   carácter por carácter, de lo contrario habrá un guión en la tarjeta.
);

Un sensor es una celda de "solo lectura": el portal toma el valor del JSON de telemetría por path y lo muestra. El sensor no tiene comando.

Lista de selección: modo de trabajo

// Variantes de la lista. El usuario las verá en el menú desplegable tal como están.
static const char* kModes[] = { "auto", "on", "off" };

s_link.card().select(
    "mode",                  // id: nombre interno de la entidad
    "Mode",                  // label: firma en la tarjeta
    kModes,                  // options: array de variantes (declarado arriba)
    3,                       // número de variantes en el array — auto, on, off = tres.
                             //   C++ no sabe la longitud del array por sí mismo, la decimos nosotros
    [](const char* opt) {    // callback: función que el núcleo llamará cuando
                             //   el usuario seleccione una variante en el portal.
                             //   opt — la cadena seleccionada, por ejemplo "on"
        onModeSelected(opt); //   la pasamos a nuestra lógica (escribimos en el capítulo 7)
    }
);

Aquí aparece la segunda mitad del mecanismo: control. Cuando el usuario elige una variante en el portal, el dispositivo recibe un comando, el núcleo mismo lo acepta, lo verifica (las cadenas ajenas que no están en options no llegará a ti) y llama a tu callback con el valor seleccionado. No necesitas analizar mensajes MQTT manualmente — tu área de responsabilidad comienza dentro de onModeSelected.

Campo numérico: umbral de activación

s_link.card().number(
    "threshold",       // id: nombre interno de la entidad
    "VOC threshold",   // label: firma en la tarjeta
    100,               // min: el portal no permitirá escribir menos que esto
    400,               // max: más que esto tampoco; el núcleo además
                       //   recorta el valor por estos límites en su lado
    10,                // step: paso de cambio de valor con las flechas del campo
    "",                // unit: unidad de medida; el índice no la tiene
    [](float v) {              // callback: se llama cuando el usuario envía
                               //   un nuevo valor; v — número dentro de min..max
        onThresholdChanged(v); //   lo pasamos a nuestra lógica (escribimos en el capítulo 7)
    }
);

Lo reunimos todo

Vista final del bloque en setup() — lo que debe quedarse en tu código. Las funciones onModeSelected y onThresholdChanged las escribimos en el capítulo 7; para que el código compile ahora, declara sus versiones de prueba arriba de setup():

// Versiones de prueba: los cuerpos verdaderos los escribimos en el capítulo 7 (lógica de automatización).
static void onModeSelected(const char* opt) {}
static void onThresholdChanged(float v) {}

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

    // Telemetría: campo vocIndex propio (capítulo 5).
    s_link.onTelemetryPublish([](JsonObject doc) {
        if (g_vocIndex >= 0) {
            doc["units"][0]["vocIndex"] = g_vocIndex;
        }
    });

    // Tarjeta: sensor + dos órganos de control.
    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);
    });
}

¿Y el ventilador? No necesitas declararlo: la bandera hasFan = true en Config ya agregó la celda "Ventilador" al manifest automáticamente — es una habilidad del diccionario, el núcleo lo sabe todo sobre él.

Los corchetes en los callbacks — siempre vacíos

[](const char* opt) { ... } — esto es una lambda, una función anónima; la explicamos en detalle en la nota del capítulo 5. Recordamos la regla del núcleo: los corchetes de captura siempre están vacíos ([]), no llevamos nada "contigo" a la lambda, todo lo necesario lo guardamos en variables globales — como g_mode y g_threshold del siguiente capítulo.

2. Diseño automático de la tarjeta

No tienes que especificar el diseño en absoluto. El portal reunirá la tarjeta a partir de las entidades declaradas — y la reunirá pulcramente: las celdas de lectura se agrupan en filas (hasta tres en una fila, luego salto de línea), los órganos de control van debajo, cada uno en su línea, todo en el diseño de marca del portal. Para la mayoría de dispositivos esto es suficiente — la interfaz queda pulida sin una sola reflexión sobre la maquetación.

El orden de entidades en la tarjeta es el orden de su declaración en setup().

3. Tu propio diseño de la tarjeta (opcional)

Primero, cómo está estructurada la tarjeta. La tarjeta es una pila vertical de filas. Una fila es una tira horizontal en la que están de una a cuatro entidades; la comparten el ancho de la tarjeta equitativamente: una entidad en una fila ocupará todo el ancho, dos — la mitad cada una, tres — un tercio cada una.

El diseño automático del párrafo anterior distribuye las entidades en estas filas por sí solo. Si quieres decidir tú qué va junto a qué, — especifica las filas manualmente con llamadas layoutRow. Una llamada = una fila, el orden de llamadas = el orden de filas de arriba abajo:

// Fila 1: dos celdas — índice VOC y ventilador, cada una por la mitad del ancho.
s_link.card().layoutRow("voc", "fan");

// Fila 2: dos órganos de control — modo y umbral, también por mitades.
s_link.card().layoutRow("mode", "threshold");

En layoutRow pasas los id de las entidades — esos nombres internos que les diste cuando las declaraste (por eso el id era necesario). "fan" — id de la entidad del ventilador del diccionario, la creó la bandera hasFan.

En la tarjeta esto dará esta composición:

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

Las entidades que no menciones en ninguna fila no desaparecerán — el portal las dibujará abajo con lista automática. Así puedes diseñar solo lo "principal" y dejar el resto en la automática.

4. Qué va por el aire

El núcleo publicará en el 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"] ]
}

No necesitas entender este JSON — el núcleo lo genera a partir de tus llamadas. Pero es útil saber: si escribes firmware no en idryer-core (Rust, MicroPython, lo que sea), es suficiente publicar ese JSON manualmente — el portal lo come todo, solo que el formato coincida.

5. Verificación

Carga el firmware y abre el dispositivo en el portal:

  • la celda VOC index muestra el índice en vivo (sopla en el sensor — el número crece en la siguiente actualización);
  • la celda Ventilador — Encendido/Apagado;
  • Mode — lista desplegable, VOC threshold — campo con botón de envío.

La selección de modo y umbral aún no hace nada — callbacks de prueba. Los animaremos en el siguiente capítulo.

Este es ese concepto mismo

Nota qué pasó: describiste la interfaz en cinco líneas en el firmware — y apareció en el portal y en la aplicación. El mismo truco funciona para cualquier dispositivo: solo cambian id, firmas y callbacks.