Embedded HandbookApps
All sections › XXV. Mini-projects
STM32F407 · level intermediate · 22 min read
Open in the interactive handbook

198и. An MQTT client on the STM32: publish, subscribe and reconnect

In section 198з the F407 device was the server itself: the browser came to it for the page and the data. For a fleet of devices the reverse scheme is more convenient: the device connects to a broker on its own, publishes measurements and listens for commands (section 188.4). Here this is done with the MQTT client that ships with lwIP: src/apps/mqtt/mqtt.c, raw API, no OS.

What MQTT is, what a packet consists of, and why QoS and the last will exist has already been covered in sections 37.2, 87.5 and 188.4, and the ESP-IDF client in 156.6. This section is about something else: how lwIP specifically uses all this, what it actually puts on the wire, where its buffers run out, and how the device survives a pulled cable and a broker going down. Everything is tested against a real Mosquitto broker, with the timings taken from the run.

Sources: lwIP 2.1.2 from STM32CubeF4 1.28.0 (mqtt.c, mqtt.h, mqtt_priv.h, mqtt_opts.h), the Ethernet driver and network interface from sections 106а and 106в, the log of the eclipse-mosquitto:2 broker. Files are in _verify/mqtt198j/: mqtt198j_app.c (the device client, shared by the board and the PC), host198j.c (the PC run), mqtt_frame.c (our own encoder), sub198j.py (the subscriber), run198j.py (the scenario), build_f407.py (the armclang build). The check is _verify/mqtt198j_check.py.

Terms used in the documentation

Term Meaning
keep alive the "I am alive" interval: how many seconds the device may stay silent
Last Will and Testament (LWT) the last will: what the broker publishes if the device disappears
retained message a stored message: the broker hands it to every new subscriber
packet identifier the packet number: links a QoS 1 PUBLISH to its PUBACK
remaining length the length of the rest: 1 to 4 bytes of 7 bits each
clean session the broker keeps nothing between connections
output ring buffer the transmit ring of the lwIP client
exponential backoff retrying with a growing pause

198и.1 The device, the topics and the test bench

The device f407-01 does four things:

Topics go from general to specific: site, device, kind of data. Then a dispatcher subscribes to lab/# and sees everything, while the device subscribes only to its own commands through +. The device name in the topic matches its client identifier: the broker log shows at once who is who.

There is no board with Ethernet in this experiment, and QEMU does not model the F407 Ethernet. So the same file mqtt198j_app.c is built for Linux together with lwIP 2.1.2 and connected to the kernel through a TAP device, as in section 106в. The client container lives in the network namespace of the broker container: the kernel with the address 10.198.0.1 is the broker, lwIP is 10.198.0.2. The subscriber is a Python program on a bare socket, without MQTT libraries. Every frame that lwIP sent to TAP or received from it is written to the "wire" log with a time in milliseconds. The file /tmp/cut in the client container means "cable pulled": frames in both directions are lost, while the link stays up for lwIP, as with a real cable pulled out of a switch somewhere further down the line. On the PC the measurement step is 40 s instead of 30, so that a PINGREQ is visible between measurements.

Bench and run: the device on lwIP 2.1.2 with the address 10.198.0.2 behind the tap0 device publishes meas and state to Mosquitto 2.1.2 at 10.198.0.1:1883 and receives cmd/+ commands from it, a socket subscriber receives everything on lab/#; on a 240-second scale the link is up until 70 s, the cable is pulled from 70 to 140 s, the offline last will arrives at 91.0 s, lwIP notices the break at 95.0 s, four failed attempts, online again at 148.7 s; the broker is stopped from 175 to 199 s, four refusals, online at 209.7 s, a normal disconnect at 230.3 s gives offline


198и.2 The lwIP client: four calls and the callbacks

The whole client is four functions and three callbacks:

Call What it does
mqtt_client_connect(cli, ip, 1883, on_conn, arg, &ci) opens TCP and puts CONNECT into the transmit ring
mqtt_set_inpub_callback(cli, on_publish, on_data, arg) where to deliver incoming publications
mqtt_subscribe(cli, тема, qos, on_sub, arg) SUBSCRIBE; on_sub is called on SUBACK
mqtt_publish(cli, тема, данные, длина, qos, retain, cb, arg) PUBLISH; cb is called on PUBACK (QoS 1) or on sending (QoS 0)

on_conn is called both on success (MQTT_CONNECT_ACCEPTED) and on a break: 256 is MQTT_CONNECT_DISCONNECTED (TCP closed or refused), 257 is MQTT_CONNECT_TIMEOUT (the broker went silent). Both occurred in the run.

Three details are visible only in the source code:

The F407 build (build_f407.py, armclang -Os, our own files with -Wall -Wextra -Werror) has no warnings. The whole image is 29780 bytes of Flash and 28924 bytes of RAM. Of that, lwIP with the MQTT client is 25566 bytes of code and 17322 bytes of RAM (a 6 KB heap and the pools), mqtt.c itself is 3514 bytes of code, the device application is 1820 bytes of code and 589 bytes of RAM. Two build-time checks are in mqtt198j_app.c:

/* CONNECT с завещанием целиком помещается в кольцо передачи */
#define CONNECT_LEN (2u + 10u + 2u + sizeof(DEV_ID) - 1u + 2u + sizeof(T_STATE) - 1u + 2u + sizeof("offline") - 1u)
_Static_assert(CONNECT_LEN <= MQTT_OUTPUT_RINGBUF_SIZE, "CONNECT не влезает в MQTT_OUTPUT_RINGBUF_SIZE");
_Static_assert(sizeof(T_STATE) - 1u <= 255u, "lwIP хранит длину темы завещания в одном байте");

The second one reflects a real limitation of lwIP 2.1.2: it checks the lengths of the last-will topic and message against 255 bytes and stores them in a u8_t, although the protocol allows up to 65535.


198и.3 Packets byte by byte: our own encoder against the wire

An MQTT 3.1.1 encoder was already written in section 87.5. Here mqtt_frame.c is extended with what the device needs: CONNECT with a last will, PUBLISH with QoS 1 and a packet number, SUBSCRIBE, PUBACK, PINGREQ. The comparison works like this: the log parser assembles the TCP stream of each connection (retransmissions are dropped by sequence numbers), cuts it into MQTT packets and, for each outgoing packet, writes a request: "the device's CONNECT", "PUBLISH QoS 1 retain 0 number 3 to such-and-such topic with such-and-such body". Our encoder builds the packet from the request, and the bytes are compared.

During the run lwIP sent 23 MQTT packets, 15 of them distinct, and all 15 matched our encoder byte for byte. The check builds them once more with a Python encoder, also a match. The most telling ones:

Packet Bytes Start What it shows
CONNECT 49 10 2F 00 04 MQTT 04 2E 00 14 flags 0x2E: clean session, last will, QoS 1, retain; keep-alive 20 s
SUBSCRIBE 24 82 16 00 01 00 11 …/cmd/+ 01 number 1, QoS 1 requested
PUBLISH online 29 33 1B 00 11 …/state 00 02 0x33: QoS 1 and retain, number 2
PUBLISH of a measurement 32 32 1E 00 10 …/meas 00 03 n=1 t=23.1 QoS 1 without retain
PINGREQ 2 C0 00 no remaining part

The broker log repeated the same fields: p4, c1, k20 means protocol 4, clean session, keep-alive 20; Will message specified (7 bytes) (r1, q1).

lwIP acknowledges an incoming QoS 1 publication on its own: a PUBACK with the number assigned by the broker (40 02 00 01) goes out after the last chunk of data. The application has nothing to do here, and it cannot influence this either.


198и.4 The client buffers and an incoming publication in chunks

The client sizes are set in lwipopts.h, the defaults are in mqtt_opts.h:

Parameter Default What it limits
MQTT_OUTPUT_RINGBUF_SIZE 256 the transmit ring: an outgoing packet must fit entirely
MQTT_VAR_HEADER_BUFFER_LEN 128 the receive buffer: header, topic and number of an incoming packet
MQTT_REQ_MAX_IN_FLIGHT 4 requests waiting for a reply: QoS 1, subscriptions and even QoS 0 until TCP acknowledges them
MQTT_CYCLIC_TIMER_INTERVAL 5 the client timer step, s
MQTT_REQ_TIMEOUT 30 the deadline for a reply to a request, s
MQTT_CONNECT_TIMOUT 100 the CONNACK deadline, s (spelled exactly like that: TIMOUT)

Outgoing: fit into the ring. Right after connecting, the run asks mqtt_publish what the longest body for the topic lab/f407-01/resp (16 characters) it will accept with QoS 0 is. The answer is 235 bytes: a packet of 1 + 2 + 2 + 16 + 235 = 256 bytes, exactly the size of the ring. mqtt_publish returned ERR_OK, yet the subscriber never got this message, and it is not in the wire log. The next one, 234 bytes, arrived. The reason is the ring: when exactly 256 bytes are written, the write pointer catches up with the read pointer, and the ring looks empty (198и.7). The practical conclusion: the longest packet must be at least one byte smaller than MQTT_OUTPUT_RINGBUF_SIZE.

Incoming: in chunks of the receive buffer. The subscriber sent 3000 bytes to cmd/echo with QoS 1. The packet took 3027 bytes (remaining length 3024, two bytes, D0 17) and arrived in three TCP segments: 1460, 1460 and 107 bytes. But the chunks the application sees are cut not by TCP but by the MQTT_VAR_HEADER_BUFFER_LEN buffer: 25 chunks, 101 bytes, 23 of 125 and the last one 24. 128 − 3 header bytes = 125; the first chunk also holds the topic lab/f407-01/cmd/echo (2 + 20 bytes) and the number (2 bytes): 125 − 24 = 101. Hence the shape of the callback: on_publish gets the topic and the full length, on_data gets the chunks until the MQTT_DATA_FLAG_LAST flag arrives:

/* ── входящая публикация: сначала тема и полная длина, потом данные кусками ── */
static void on_publish(void *arg, const char *topic, u32_t tot_len)
{
    (void)arg;
    rx_cmd = CMD_NONE;
    if (strncmp(topic, T_CMD_PFX, sizeof(T_CMD_PFX) - 1u) == 0) {
        const char *name = topic + sizeof(T_CMD_PFX) - 1u;
        rx_cmd = strcmp(name, "led") == 0 ? CMD_LED : strcmp(name, "echo") == 0 ? CMD_ECHO : CMD_NONE;
    }
    rx_got = 0;
    rx_frag = 0;
    app_log("rx topic=%s len=%lu", topic, (unsigned long)tot_len);
}

static void on_data(void *arg, const u8_t *data, u16_t len, u8_t flags)
{
    (void)arg;
    uint32_t have = rx_got < sizeof(rx_buf) - 1u ? rx_got : sizeof(rx_buf) - 1u;
    uint32_t room = sizeof(rx_buf) - 1u - have;     /* лишнее не храним, но считаем */
    memcpy(rx_buf + have, data, len < room ? len : room);
    rx_got += len;
    rx_frag++;
    app_log("frag %lu len=%u last=%u", (unsigned long)rx_frag, (unsigned)len, (unsigned)(flags & MQTT_DATA_FLAG_LAST));
    if (!(flags & MQTT_DATA_FLAG_LAST))
        return;                                     /* ждём последний кусок */
    rx_buf[rx_got < sizeof(rx_buf) - 1u ? rx_got : sizeof(rx_buf) - 1u] = 0;
    if (rx_cmd == CMD_LED) {
        led = strcmp(rx_buf, "on") == 0;
        respond(led ? "led=on" : "led=off", led ? 6u : 7u);
    } else if (rx_cmd == CMD_ECHO) {
        char r[16];
        memcpy(r, "got=", 4);
        respond(r, (u16_t)(4u + put_dec(r + 4, rx_got)));
    }
}

The topic in on_publish is a pointer into the receive buffer, valid only for the duration of the call: remember the parsed command, not the pointer. The command body is not a string (section 37.2): on_data adds the terminating zero itself, and anything longer than 31 bytes is only counted. The reply got=3000 went out 4 ms after the command. You can reply directly from on_data: mqtt_publish puts the packet into the ring and hands it to TCP, it does not wait for a reply. On the F407 on_data is 276 bytes of code, on_publish is 108.


198и.5 QoS 1, keep-alive and detecting a break

When lwIP sends PINGREQ. The client timer ticks every 5 s. The tick counter is reset when TCP acknowledges the latest transmission; PINGREQ goes out when counter × 5 s reaches the keep-alive. With 20 s this happens 20–25 s after the last transmission, depending on where the tick falls. In the run, after 21.7 and 20.8 s. The broker waits one and a half keep-alives, 30 s, so there is a margin, but a small one: with a keep-alive of 10 s and a step of 5 s PINGREQ could go out only after 15 s, exactly when the broker is already disconnecting. That is why mqtt198j_app.c has a build-time check:

#define PING_PERIOD_S ((KEEPALIVE_S + MQTT_CYCLIC_TIMER_INTERVAL - 1u) / MQTT_CYCLIC_TIMER_INTERVAL * MQTT_CYCLIC_TIMER_INTERVAL \
                       + MQTT_CYCLIC_TIMER_INTERVAL)
_Static_assert(PING_PERIOD_S < KEEPALIVE_S * 3u / 2u, "брокер отключит прибор раньше, чем lwIP пошлёт PINGREQ");

A pulled cable. The cable is "pulled" at 70.2 s into the run. The last thing that reached the broker was a PINGREQ at 60.8 s. After that, silence on both sides:

Who When it noticed After the break After the last packet from the other side
broker: disconnected: exceeded timeout, last will offline to the subscriber 91.0 s 20.8 s 30.2 s
lwIP: on_conn with status 257 95.0 s 24.8 s 34.2 s

The broker reacts after one and a half keep-alives, as promised. lwIP reacts through its own "watchdog" check: if the broker is silent for longer than 1.5 keep-alive, the client closes the connection; with a 5 s step this happens on the seventh tick, after 30–35 s. TCP by itself would not notice the break for a long time (section 106.6).

What happened to QoS 1. At 80 s, already during the break, the device published measurement n=2, and the packet went into the void. When closing the connection, lwIP wiped all pending requests silently: the meas_done callback is not called, neither with an error nor in any other way. The application learns about it only in on_conn and puts such measurements back in the queue itself (lost_in_flight=1 in the log). After reconnecting, n=2 and the accumulated n=3 went out first, and the subscriber received all five measurements of the run exactly once each; n=2 arrived late by 68.7 s.

QoS 1 means "will arrive at least once" within one connection. Retrying after a reconnection is the device's job, because the session is clean. The price is a possible duplicate if the PUBACK was lost after the broker had already accepted the message: the subscriber must be able to discard the repeat, and for that the measurement body contains the number n.


198и.6 Reconnecting with a growing pause

The rule is simple: after a failure, wait, then try again, and each time wait twice as long: 1, 2, 4 … 32 s, plus up to a quarter of the pause on top, so that a hundred devices do not storm the broker in the same second after a failure. After a success the pause is 1 s again.

/* следующая пауза: 1, 2, 4 … 32 с плюс случайная добавка до четверти */
static uint32_t next_pause(void)
{
    uint32_t p = pause_ms;
    rnd ^= rnd << 13; rnd ^= rnd >> 17; rnd ^= rnd << 5;
    pause_ms = pause_ms * 2u > PAUSE_MAX_MS ? PAUSE_MAX_MS : pause_ms * 2u;
    return p + rnd % (p / 4u + 1u);
}

Two important points. The retry is not started directly from on_conn: lwIP calls it from inside its TCP handlers (through mqtt_close), and there is no reason to open a new connection from there. The retry is scheduled by the sys_timeout timer, the same one that measures the pause. And CONNACK is awaited with our own 5 s timer: lwIP's own deadline MQTT_CONNECT_TIMOUT is 100 s, and while the cable is out, the SYN is simply lost and TCP reports nothing. When its own timer fires, the device calls mqtt_disconnect; on_conn is not called after it, so the retry is scheduled immediately.

The two breaks in the run behave differently:

Cable pulled (70–140 s) Broker stopped (175–199 s)
how the device found out silence: after 24.8 s TCP closed by the broker: after 156 ms
failed attempts 4: the SYN is lost, 5 s waiting for CONNACK 4: the kernel answers with RST at once
pauses, s 1.2; 2.3; 5.0; 8.2; 17.0 1.0; 2.5; 4.2; 8.8; 17.8
connected after recovery after 8.5 s (fifth attempt) after 10.5 s (fifth attempt)
last will to the subscriber from the broker, at 91.0 s at the moment the broker stops

The delay after recovery is the price of the growing pause: the longer the broker was down, the longer the device thinks before the next attempt. The pause ceiling (32 s here) sets the worst case.

A normal disconnect. At 230.3 s the device called mqtt_disconnect, and 1 ms later the subscriber received offline, the last will. There is no DISCONNECT packet (E0 00) in the wire log: lwIP 2.1.2 simply closes TCP, and for the broker this is a break (connection closed by client). If the device goes to sleep and does not want to alarm the dispatcher, it publishes something like sleep with retain to state itself before disconnecting.


198и.7 Pitfalls

The device hung after receiving a long command. In lwIP 2.1.2, mqtt_message_received contains the check msg_idx < MQTT_VAR_HEADER_BUFFER_LEN, while msg_idx counts all received bytes of the packet. Any incoming publication longer than the receive buffer violates it. A run with the lwIP checks as they are: a 3000-byte command, and assert: client->msg_idx < MQTT_VAR_HEADER_BUFFER_LEN, the process is killed (code 134). On the board LWIP_PLATFORM_ASSERT from arch/cc.h is an endless loop, and the device simply freezes. The chunked parsing itself is correct (198и.4); only the check is wrong. The way out in the experiment is to build mqtt.c with -DLWIP_NOASSERT and the rest of the stack with the checks. Another way is not to accept publications longer than the buffer: topic plus body under 128 bytes.

mqtt_publish returned ERR_OK, but the message is missing. A packet exactly MQTT_OUTPUT_RINGBUF_SIZE long passes the space check (<=), but after it is written the ring looks empty, and the packet vanishes without a trace. In the run: a body of 235 bytes with a 16-character topic. Keep the longest packet at least one byte smaller than the ring, or enlarge the ring.

Commands stop arriving after a reconnection. mqtt_client_connect zeroes the client structure, and the incoming-publication callbacks are lost. mqtt_set_inpub_callback and the subscription belong in on_conn, on every connection.

A measurement was lost during a break, despite QoS 1. lwIP wipes pending requests when closing a connection without calling their callbacks. The queue of unacknowledged data lives in the application, and the "in flight" flag is cleared in on_conn on a break.

The device is "online" for another half minute after a break. That is how it is: both the broker and lwIP learn about the silence only after one and a half keep-alives (30.2 and 34.2 s in the experiment). Faster only with a smaller keep-alive, with the check from 198и.5.

Stuck connecting with the cable pulled. The lwIP CONNACK deadline is 100 s (MQTT_CONNECT_TIMOUT). You need your own timer: 5 s in the experiment.

The old connection sends packets after being closed. mqtt_close closes TCP, but unacknowledged data stay with the stack: 34 s after the cable was restored, lwIP retransmitted measurement n=2 and a PINGREQ over the old connection, and the broker answered with RST. No harm done, since the broker has already forgotten that connection, but do not be alarmed by an "extra" PUBLISH in a traffic capture.

All devices reconnect in the same second. Without a random addition to the pause, a fleet of devices knocks on the broker in sync after a broker restart. An addition of up to a quarter of the pause spreads them out.


198и.8 Summary

Full sources

mqtt198j_app.c — the device MQTT client on top of the lwIP raw API.

/* 198и: MQTT-клиент прибора поверх lwIP raw API (NO_SYS). Один и тот же файл
   собирается armclang для F407 и gcc для прогона на ПК с настоящим брокером. */
#include <stdint.h>
#include <string.h>
#include "lwip/apps/mqtt.h"
#include "lwip/apps/mqtt_priv.h"        /* описание mqtt_client_t: клиент статический, не из кучи */
#include "lwip/timeouts.h"
#include "mqtt198j_app.h"

/* PINGREQ у lwIP уходит, когда шаг таймера насчитал keep_alive без передачи:
   через keep_alive + шаг (округлённо вверх до шага). Брокер ждёт 1,5 keep_alive. */
#define PING_PERIOD_S ((KEEPALIVE_S + MQTT_CYCLIC_TIMER_INTERVAL - 1u) / MQTT_CYCLIC_TIMER_INTERVAL * MQTT_CYCLIC_TIMER_INTERVAL \
                       + MQTT_CYCLIC_TIMER_INTERVAL)
_Static_assert(PING_PERIOD_S < KEEPALIVE_S * 3u / 2u, "брокер отключит прибор раньше, чем lwIP пошлёт PINGREQ");

/* CONNECT с завещанием целиком помещается в кольцо передачи */
#define CONNECT_LEN (2u + 10u + 2u + sizeof(DEV_ID) - 1u + 2u + sizeof(T_STATE) - 1u + 2u + sizeof("offline") - 1u)
_Static_assert(CONNECT_LEN <= MQTT_OUTPUT_RINGBUF_SIZE, "CONNECT не влезает в MQTT_OUTPUT_RINGBUF_SIZE");
_Static_assert(sizeof(T_STATE) - 1u <= 255u, "lwIP хранит длину темы завещания в одном байте");

enum { CMD_NONE, CMD_LED, CMD_ECHO };

struct outbox {                          /* измерение, пока брокер не ответил PUBACK */
    uint32_t n;
    uint8_t used, in_flight;
};

static mqtt_client_t cli;
static ip_addr_t broker_ip;
static const struct mqtt_connect_client_info_t ci = {
    .client_id = DEV_ID, .keep_alive = KEEPALIVE_S,
    .will_topic = T_STATE, .will_msg = "offline", .will_qos = 1, .will_retain = 1,
};
static struct outbox box[OUTBOX_N];
static uint32_t meas_n, pause_ms = PAUSE_MIN_MS, tries, rnd = 0x198u;
static uint8_t rx_cmd, led;
static char rx_buf[32];
static uint32_t rx_got, rx_frag;

static void try_connect(void *arg);

static uint32_t put_dec(char *p, uint32_t v)
{
    char t[10];
    uint32_t k = 0, i = 0;
    do { t[k++] = (char)('0' + v % 10u); v /= 10u; } while (v);
    while (k) p[i++] = t[--k];
    return i;
}

/* следующая пауза: 1, 2, 4 … 32 с плюс случайная добавка до четверти */
static uint32_t next_pause(void)
{
    uint32_t p = pause_ms;
    rnd ^= rnd << 13; rnd ^= rnd >> 17; rnd ^= rnd << 5;
    pause_ms = pause_ms * 2u > PAUSE_MAX_MS ? PAUSE_MAX_MS : pause_ms * 2u;
    return p + rnd % (p / 4u + 1u);
}

static void schedule_retry(void)
{
    uint32_t p = next_pause();
    sys_untimeout(try_connect, NULL);
    sys_timeout(p, try_connect, NULL);
    app_log("retry pause=%lu", (unsigned long)p);
}

static void meas_done(void *arg, err_t err)
{
    struct outbox *b = arg;
    if (err == ERR_OK) {
        app_log("puback meas n=%lu", (unsigned long)b->n);
        b->used = 0;
    } else {
        app_log("timeout meas n=%lu err=%d", (unsigned long)b->n, err);
    }
    b->in_flight = 0;
}

static void send_outbox(void)
{
    char pl[24];
    for (uint32_t i = 0; i < OUTBOX_N; i++) {
        struct outbox *b = &box[i];
        if (!b->used || b->in_flight)
            continue;
        uint32_t k = 0;
        memcpy(pl, "n=", 2); k = 2u + put_dec(pl + 2, b->n);
        memcpy(pl + k, " t=23.", 6); k += 6u; k += put_dec(pl + k, b->n % 10u);
        err_t e = mqtt_publish(&cli, T_MEAS, pl, (u16_t)k, 1, 0, meas_done, b);
        app_log("pub meas n=%lu id=%u err=%d", (unsigned long)b->n, (unsigned)cli.pkt_id_seq, e);
        if (e == ERR_OK)
            b->in_flight = 1;
    }
}

static void meas_tick(void *arg)
{
    (void)arg;
    struct outbox *slot = NULL;
    meas_n++;
    for (uint32_t i = 0; i < OUTBOX_N && !slot; i++)
        if (!box[i].used) slot = &box[i];
    if (!slot) {                                    /* очередь полна: теряем самое старое */
        slot = &box[0];
        for (uint32_t i = 1; i < OUTBOX_N; i++)
            if (box[i].n < slot->n) slot = &box[i];
        app_log("outbox full, drop n=%lu", (unsigned long)slot->n);
    }
    slot->n = meas_n; slot->used = 1; slot->in_flight = 0;
    if (mqtt_client_is_connected(&cli))
        send_outbox();
    sys_timeout(MEAS_MS, meas_tick, NULL);
}

static void respond(const char *s, u16_t len)
{
    err_t e = mqtt_publish(&cli, T_RESP, s, len, 0, 0, NULL, NULL);
    app_log("pub resp len=%u err=%d", (unsigned)len, e);
}

/* ── входящая публикация: сначала тема и полная длина, потом данные кусками ── */
static void on_publish(void *arg, const char *topic, u32_t tot_len)
{
    (void)arg;
    rx_cmd = CMD_NONE;
    if (strncmp(topic, T_CMD_PFX, sizeof(T_CMD_PFX) - 1u) == 0) {
        const char *name = topic + sizeof(T_CMD_PFX) - 1u;
        rx_cmd = strcmp(name, "led") == 0 ? CMD_LED : strcmp(name, "echo") == 0 ? CMD_ECHO : CMD_NONE;
    }
    rx_got = 0;
    rx_frag = 0;
    app_log("rx topic=%s len=%lu", topic, (unsigned long)tot_len);
}

static void on_data(void *arg, const u8_t *data, u16_t len, u8_t flags)
{
    (void)arg;
    uint32_t have = rx_got < sizeof(rx_buf) - 1u ? rx_got : sizeof(rx_buf) - 1u;
    uint32_t room = sizeof(rx_buf) - 1u - have;     /* лишнее не храним, но считаем */
    memcpy(rx_buf + have, data, len < room ? len : room);
    rx_got += len;
    rx_frag++;
    app_log("frag %lu len=%u last=%u", (unsigned long)rx_frag, (unsigned)len, (unsigned)(flags & MQTT_DATA_FLAG_LAST));
    if (!(flags & MQTT_DATA_FLAG_LAST))
        return;                                     /* ждём последний кусок */
    rx_buf[rx_got < sizeof(rx_buf) - 1u ? rx_got : sizeof(rx_buf) - 1u] = 0;
    if (rx_cmd == CMD_LED) {
        led = strcmp(rx_buf, "on") == 0;
        respond(led ? "led=on" : "led=off", led ? 6u : 7u);
    } else if (rx_cmd == CMD_ECHO) {
        char r[16];
        memcpy(r, "got=", 4);
        respond(r, (u16_t)(4u + put_dec(r + 4, rx_got)));
    }
}

static void on_sub(void *arg, err_t err)
{
    (void)arg;
    app_log("suback err=%d", err);
}

static void on_state(void *arg, err_t err)
{
    (void)arg;
    app_log("puback state err=%d", err);
}

static void connack_wait_over(void *arg)
{
    (void)arg;
    app_log("no connack in %lu ms", (unsigned long)CONNACK_MS);
    mqtt_disconnect(&cli);                          /* обратного вызова не будет — повтор сами */
    schedule_retry();
}

static void on_conn(mqtt_client_t *c, void *arg, mqtt_connection_status_t st)
{
    (void)arg;
    sys_untimeout(connack_wait_over, NULL);
    if (st == MQTT_CONNECT_ACCEPTED) {
        app_log("connected try=%lu", (unsigned long)tries);
        tries = 0;
        pause_ms = PAUSE_MIN_MS;
        mqtt_set_inpub_callback(c, on_publish, on_data, NULL);
        mqtt_subscribe(c, T_CMD_SUB, 1, on_sub, NULL);
        mqtt_publish(c, T_STATE, "online", 6, 1, 1, on_state, NULL);
        send_outbox();                              /* то, что не дошло до обрыва */
        return;
    }
    uint32_t lost = 0;
    for (uint32_t i = 0; i < OUTBOX_N; i++) {       /* lwIP стёр запросы молча: вернуть их в очередь */
        lost += box[i].in_flight;
        box[i].in_flight = 0;
    }
    app_log("status %d lost_in_flight=%lu", (int)st, (unsigned long)lost);
    schedule_retry();                               /* не из обратного вызова и не сразу */
}

static void try_connect(void *arg)
{
    (void)arg;
    tries++;
    err_t e = mqtt_client_connect(&cli, &broker_ip, MQTT_PORT, on_conn, NULL, &ci);
    app_log("connect try=%lu err=%d", (unsigned long)tries, e);
    if (e != ERR_OK) {
        schedule_retry();
        return;
    }
    sys_timeout(CONNACK_MS, connack_wait_over, NULL);
}

mqtt_client_t *app_client(void)
{
    return &cli;
}

void app_start(const ip_addr_t *broker)
{
    ip_addr_copy(broker_ip, *broker);
    sys_timeout(MEAS_MS, meas_tick, NULL);
    try_connect(NULL);
}

Next: section 198к "Mini-project: an oscilloscope on the board — ADC, DMA, edge trigger and a display on the PC".

Open in the interactive handbook