Skip to content

Sensor and telemetry

In this chapter, the filter starts measuring air and sending data to the cloud. The key technique — custom field in telemetry: the ecosystem vocabulary knows nothing about VOC, but the core allows you to add any field to telemetry.

1. Sensor library

In platformio.ini, add to lib_deps:

    adafruit/Adafruit SGP40 Sensor

2. Reading SGP40

In src/main.cpp (pins — from the diagram):

#include <Wire.h>
#include <Adafruit_SGP40.h>

static Adafruit_SGP40 s_sgp;
static int32_t g_vocIndex = -1;   // -1 = no data yet

static void initVocSensor() {
    Wire.begin(/*SDA=*/8, /*SCL=*/9);
    if (!s_sgp.begin()) {
        Serial.println("[VOC] SGP40 not found, check wiring");
    }
}

static void readVocSensor() {
    // measureVocIndex() maintains internal sensor compensation by itself.
    // Index: ~100 = normal air, higher = dirtier (max 500).
    g_vocIndex = s_sgp.measureVocIndex();
}

Add the initVocSensor() call to setup() after s_link.begin(), and readVocSensor() — in loop() once per second (using millis timer, not delay!):

static uint32_t s_lastReadMs = 0;

void loop() {
    s_link.loop();

    uint32_t now = millis();
    if (now - s_lastReadMs >= 1000) {
        s_lastReadMs = now;
        readVocSensor();
    }
}

No delay() in loop

s_link.loop() should be called constantly — Wi-Fi, MQTT, and portal commands depend on it. delay(1000) will freeze all of this. Only millis timers.

3. Custom field in telemetry

Every telemetryPeriodMs, the core itself assembles a JSON telemetry message and sends it to the cloud. For our device (one unit, only a fan from the vocabulary skills) the core assembles this message:

{
  "units": [ { "unitId": "U1", "fanStatus": false } ],
  "rssi": -52,
  "uptime": 120
}

Let us break down the structure:

  • units — array of device units (chambers). The serial iDryer dryer can have up to four independent chambers, so telemetry is always an array, even if there's only one chamber;
  • units[0] — first (and our only) unit: we specified unitsCount = 1 in Config;
  • fanStatus — vocabulary field, appeared because of hasFan = true;
  • rssi, uptime — Wi-Fi level and uptime, the core adds always.

There is nothing about VOC in this message — the core does not know about our sensor. But right before sending, the core gives your code a chance to add its own fields to the message. For this you register a callback — a function you give to the core, and the core calls it on every publication, passing the assembled JSON inside (the doc parameter — that's it).

In setup():

s_link.onTelemetryPublish([](JsonObject doc) {
    // doc — the telemetry message assembled by the core (see JSON above).
    // We add our vocIndex field to the first unit.
    if (g_vocIndex >= 0) {
        doc["units"][0]["vocIndex"] = g_vocIndex;
    }
});

The line doc["units"][0]["vocIndex"] = g_vocIndex; reads like: "in message doc take the array units, in it element 0 (our only unit) and write the vocIndex field there." You invent the field name yourself — in the next chapter you will reference it to show the value on the card.

If you encounter the word hook

In the core sources, this callback is called a PublishHook — "hook" means the same thing: a point where the library lets you "hook" your function. The terms are interchangeable; in this documentation we say "callback."

Lambda and why it's 'empty'

The construct [](JsonObject doc) { ... } is called a lambda — a function with no name, written right where it is used, so you don't have to extract it separately and invent a name.

Square brackets at the beginning — "capture list": variables from surrounding scope that the function takes with it. Core rule: brackets are always empty ([]) — the lambda captures nothing and carries no state with it (this is called stateless).

The reason is technical: lambdas with captures require dynamic memory, and frequent allocations on ESP32 fragment the heap and can crash Wi-Fi in the worst case. So the core accepts only simple functions.

Practical conclusion: everything the callback needs, store in global variables — like our g_vocIndex. This rule applies to all idryer-core callbacks.

The fan status is published the vocabulary way — just write it to the core field when you turn it on/off (logic — in chapter 7):

s_link.telemetry.fanOn[0] = fanIsOn;

4. Verification

After flashing, in the MQTT stream of the device (or in Serial log of publications), telemetry looks like this:

{
  "units": [ { "unitId": "U1", "fanStatus": false, "vocIndex": 103 } ],
  "rssi": -52,
  "uptime": 120
}

vocIndex — your own field, sent to the cloud alongside the vocabulary fanStatus. The portal already receives and stores it, but does not yet know what to do with it: show it to the portal in the next chapter.

Breathe on the sensor or bring a marker to it — the index should noticeably grow within seconds.