SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
C3 — TESAIoT cloud: config file → MQTT task → broker

Learning goal

Three things a customer extending the cloud path must know:

  1. Configuration is a runtime key=value file on LittleFS, not Kconfig and not a #define — and one of its fields, port, is stored but never used for MQTT. The port comes from tls_mode.
  2. The MQTT task is created lazily, on the first connect request, not at boot.
  3. The topic surface you extend is a set of format macros plus a suffix router that runs on the MQTT event thread.

The real firmware sequence

Config: /.tesaiot_config

TESAIOT_CONFIG_FILE_PATH is "/.tesaiot_config" (tesaiot_config_defaults.h:83). Defaults: tls_mode = TESAIOT_MODE_SERVER_TLS, broker mqtt.tesaiot.dev, port 8884 (:18, :36-37). Two I/O backends, one parser — bento_storage_read_file() on mtb-only, a Python open() on mtb-mpy — selected by BENTO_HAS_MPY (tesaiot_config_store.c:38-54).

bool tesaiot_config_init(void)
{
if (s_initialized) return true;
s_mutex = xSemaphoreCreateMutexStatic(&s_mutex_buf);
config_set_defaults(&s_config);
s_initialized = true;
/* Try to load from LittleFS; if missing, create default file */
if (!tesaiot_config_load()) {
printf("[TESAIOT_CFG] no config file, creating defaults\r\n");
tesaiot_config_save();
}
return true;
}

Load-order note: on mtb-only tesaiot_config_init() runs from main() before ipc_tesaiot_handler_init(); on mtb-mpy it runs inside the VM task after it, which is why mpy_main.c follows it with ipc_tesaiot_refresh_status(). Chapter B1 covers the fork.

Three entry points, one function

All reach tesaiot_mqtt_connect() (tesaiot_mqtt.c:83-103): Python tesaiot.connect() (modtesaiot.c:692-695), the CM55 TESAIoT page's Connect button over IPC_CMD_TESAIOT_CONNECT (ipc_tesaiot_handler.c:303-307), and the HSM provisioning worker before it touches the chip (chapter D2). tesaiot_mqtt_connect() bridges TESAIOT_CONN_CONNECTING to the screen and calls mqtt_request_start().

Lazy task creation

bool mqtt_request_start(void)
{
/* Create MQTT task on first request (lazy — saves 5KB heap during boot) */
if (s_mqtt_task_handle == NULL) {
BaseType_t r = xTaskCreate(
mqtt_client_task, "MQTT",
/* Depth is in WORDS, and MQTT_CLIENT_TASK_STACK_SIZE already is —
* its comment says "4096 words", and the Subscriber and Publisher
* tasks below pass their own word counts straight through. Dividing
* by sizeof(StackType_t) here handed FreeRTOS 1024 words, so the
* task ran on 4 KB instead of the intended 16 KB.
*
* read_certificate_from_optiga() alone declares 4,096 bytes of
* locals (two 2 KB buffers), so it overflowed on entry — before
* reaching any of the OPTIGA wait loops, which is why adding
* timeouts to those never produced a message. The overflow also
* beat configCHECK_FOR_STACK_OVERFLOW, which only samples on a
* context switch: the frame was gone before one happened, so
* vApplicationStackOverflowHook never fired and the core just
* stopped. Diagnosed 2026-08-04 bringing up mTLS on the Dev Kit. */
MQTT_CLIENT_TASK_STACK_SIZE,
NULL, MQTT_CLIENT_TASK_PRIORITY, &s_mqtt_task_handle);
if (r != pdPASS) {
printf("[MQTT] Task creation failed\n");
return false;
}
/* Wait for task to create event group (poll — 200ms was racy) */
for (int i = 0; i < 20 && mqtt_control_events == NULL; i++) {
vTaskDelay(pdMS_TO_TICKS(50));
}
}
if (mqtt_control_events == NULL) return false;
if (mqtt_start_requested) return true;
mqtt_start_requested = true;
(void)xEventGroupSetBits(mqtt_control_events, MQTT_CTRL_START_BIT);
return true;
}

Boot-time creation was removed because the task's stack competed with cy_wcm_init() (mqtt_task.c:100-102). The task body waits for WiFi (up to 60 s, polling app_wifi_is_ready() — not cy_wcm_is_connected_to_ap(), which HardFaults before WCM init), then waits for the start bit, then mqtt_init() → mqtt_connect().

Where host, port and client-id are decided

/* ...context: inside mqtt_client_config_init() ... */
/* Port derived from mode (config stores only one port field):
* Mode 0/1 (mTLS): 8883
* Mode 2 (serverTLS+MQTTs): 8884
* Mode 3 (REST API): api_port */
switch (cfg.tls_mode) {
case TESAIOT_MODE_MTLS:
case TESAIOT_MODE_MTLS_SW:
broker_info.port = 8883;
break;
case TESAIOT_MODE_SERVER_TLS:
default:
broker_info.port = 8884;
break;
}

Read that switch twice. cfg.port is not consulted. Modes 0/1 (mTLS, mTLS software) → 8883; mode 2 (server TLS) and anything else → 8884. The code comment says so: config stores only one port field. The config header agrees — tesaiot_config_defaults.h:34-35: "Port stays mode-driven … this default only applies to the latter."

Also decided here: SNI falls back to the broker name; client-id is factory_uid (the Trust M hardware UID) in mTLS and device_id otherwise (mqtt_client_config.c:137-153); root CA is TESAIOT_ROOT_CA_CERTIFICATE; ALPN is explicitly unused. At the end, in mTLS mode only, the function hands off to mqtt_mtls_setup_optiga() — chapter C4.

Connect

/* ...context: inside the MQTT connect helper ... */
printf("[MQTT] Connecting to '%s:%u' as '%s'...\n",
broker_info.hostname, broker_info.port,
connection_info.client_id);
printf("[MQTT] root_ca=%p size=%u flags=0x%lX\n",
security_info ? security_info->root_ca : NULL,
security_info ? (unsigned)security_info->root_ca_size : 0,
(unsigned long)status_flag);
for (uint32_t retry = 0; retry < cfg.max_retries; retry++) {
/* Verify WiFi is still connected.
* MUST use app_wifi_is_ready() — NOT cy_wcm_is_connected_to_ap().
* BentoClaw uses lazy WiFi init: WCM is uninitialized until first
* wifi.scan()/connect(). Calling cy_wcm API before init = HardFault. */
if (!app_wifi_is_ready()) {
printf("[MQTT] WiFi disconnected — waiting for reconnect...\n");
for (int w = 0; w < 100 && !app_wifi_is_ready(); w++) {
vTaskDelay(pdMS_TO_TICKS(500));
}
if (!app_wifi_is_ready()) {
printf("[MQTT] WiFi reconnect timeout\n");
return ~CY_RSLT_SUCCESS;
}
}
printf("[MQTT] cy_mqtt_connect attempt %lu...\n", (unsigned long)(retry + 1));
result = cy_mqtt_connect(mqtt_connection, &connection_info);
printf("[MQTT] cy_mqtt_connect returned: 0x%08X\n", (unsigned int)result);
if (result == CY_RSLT_SUCCESS) {
printf("[MQTT] Connected to broker\n");

Retries up to cfg.max_retries, re-checking app_wifi_is_ready() each pass. On success the code calls tesaiot_bridge_mqtt_connected() directly rather than waiting for the config task to poll — that task runs at priority 1 below MQTT's 2 and may never get the CPU (mqtt_task.c:359-362).

The topic surface and the suffix router

Format macros (mqtt_client_config.h:84-91):

MQTT_TOPIC_FMT_TELEMETRY "device/%s/telemetry"
MQTT_TOPIC_FMT_TELEMETRY_SENSOR "device/%s/telemetry/sensor"
MQTT_TOPIC_FMT_COMMANDS "device/%s/commands"
MQTT_TOPIC_FMT_COMMANDS_WILDCARD "device/%s/commands/#"
MQTT_TOPIC_FMT_COMMAND_CSR "device/%s/commands/csr"
MQTT_TOPIC_FMT_COMMAND_REQUEST "device/%s/commands/request"
MQTT_TOPIC_FMT_COMMAND_STATUS "device/%s/commands/status"
MQTT_TOPIC_FMT_COMMAND_ACK "device/%s/commands/ack"

The subscriber subscribes once to device/<device_id>/commands/# and routes inbound messages by suffix, on the MQTT event thread:

/* ...context: inside the subscriber message callback ... */
static const char REQ_SUFFIX[] = "/commands/request";
static const char CSR_SUFFIX[] = "/commands/csr";
static const char PU_SUFFIX[] = "/commands/protected_update";
static const char ST_SUFFIX[] = "/commands/status";
static const char CT_SUFFIX[] = "/commands/certificate";
if (topic != NULL) {
if ((tlen >= sizeof(REQ_SUFFIX) - 1 &&
0 == strncmp(topic + tlen - (sizeof(REQ_SUFFIX) - 1),
REQ_SUFFIX, sizeof(REQ_SUFFIX) - 1)) ||
(tlen >= sizeof(CSR_SUFFIX) - 1 &&
0 == strncmp(topic + tlen - (sizeof(CSR_SUFFIX) - 1),
CSR_SUFFIX, sizeof(CSR_SUFFIX) - 1))) {
vPortFree(data_copy); /* our own echo — nothing to do */
return;
}
if (tlen >= sizeof(PU_SUFFIX) - 1 &&
0 == strncmp(topic + tlen - (sizeof(PU_SUFFIX) - 1),
PU_SUFFIX, sizeof(PU_SUFFIX) - 1)) {
cmd = HANDLE_PROTECTED_UPDATE_BUNDLE;
} else if (tlen >= sizeof(CT_SUFFIX) - 1 &&
0 == strncmp(topic + tlen - (sizeof(CT_SUFFIX) - 1),
CT_SUFFIX, sizeof(CT_SUFFIX) - 1)) {
cmd = HANDLE_DEVICE_CERTIFICATE;
} else if (tlen >= sizeof(ST_SUFFIX) - 1 &&
0 == strncmp(topic + tlen - (sizeof(ST_SUFFIX) - 1),
ST_SUFFIX, sizeof(ST_SUFFIX) - 1)) {
cmd = HANDLE_COMMAND_STATUS;

To add a command, add a suffix here and a case in the handler; the default handler is the (printf)("[Subscriber] %.*ss\n", …) line at subscriber_task.c:186 — note the parenthesised call form, which is the only reason that line prints (see Traps).

Publish

bool tesaiot_mqtt_connect(void)
{
if (mqtt_is_connected()) {
return true;
}
printf("[TESAIoT-MQTT] Requesting connection...\n");
tesaiot_bridge_mqtt(TESAIOT_CONN_CONNECTING);
if (!mqtt_request_start()) {
printf("[TESAIoT-MQTT] Failed to request start\n");
tesaiot_bridge_mqtt(TESAIOT_CONN_FAILED);
return false;
}
/* Non-blocking: MQTT task runs at priority 2 and calls
* tesaiot_bridge_mqtt_connected() directly when connected.
* The old 30-second polling loop blocked this cfg task (priority 1),
* preventing it from ever processing DISCONNECT commands. */
printf("[TESAIoT-MQTT] MQTT task started (async)\n");
return true;
}
bool tesaiot_mqtt_disconnect(void)
{
extern bool mqtt_request_stop(void);
if (!mqtt_is_connected()) {
return true;
}
printf("[TESAIoT-MQTT] Disconnecting...\n");
bool ok = mqtt_request_stop();
if (ok) {
tesaiot_bridge_mqtt(TESAIOT_CONN_OFF);
printf("[TESAIoT-MQTT] Disconnected\n");
} else {
printf("[TESAIoT-MQTT] Disconnect failed\n");
}
return ok;
}
bool tesaiot_mqtt_publish(const char *topic, const char *payload, size_t payload_len)
{
extern QueueHandle_t publisher_task_q;
if (!mqtt_is_connected()) {
printf("[TESAIoT-MQTT] Cannot publish: not connected\n");
return false;
}
if (publisher_task_q == NULL) {
printf("[TESAIoT-MQTT] Cannot publish: publisher queue not ready\n");
return false;
}
/* Build topic: caller-provided or default telemetry topic */
publisher_data_t msg;
memset(&msg, 0, sizeof(msg));
msg.cmd = PUBLISH_MQTT_MSG;
if (topic != NULL && topic[0] != '\0') {
size_t tlen = strlen(topic);
if (tlen >= sizeof(msg.topic)) {
printf("[TESAIoT-MQTT] Topic too long (%u >= %u)\n",
(unsigned)tlen, (unsigned)sizeof(msg.topic));
return false;
}
memcpy(msg.topic, topic, tlen + 1);
msg.topic_len = tlen;
} else {
/* Default: device/{device_id}/telemetry */
const tesaiot_config_t *cfg = tesaiot_config_get_ptr();
if (!cfg || cfg->device_id[0] == '\0') {
printf("[TESAIoT-MQTT] No device_id configured\n");
return false;
}
int n = snprintf(msg.topic, sizeof(msg.topic),
TESAIOT_TOPIC_FMT_TELEMETRY, cfg->device_id);
if (n < 0 || (size_t)n >= sizeof(msg.topic)) {
printf("[TESAIoT-MQTT] Topic build failed\n");
return false;
}
msg.topic_len = (size_t)n;
}
/* Allocate payload copy on C heap (freed by publisher_task after publish) */
if (payload != NULL && payload_len > 0) {
char *payload_copy = pvPortMalloc(payload_len);
if (payload_copy == NULL) {
printf("[TESAIoT-MQTT] Payload alloc failed (%u bytes)\n",
(unsigned)payload_len);
return false;
}
memcpy(payload_copy, payload, payload_len);
msg.data = payload_copy;
msg.payload_len = payload_len;
msg.free_after_publish = true;
} else {
msg.data = NULL;
msg.payload_len = 0;
msg.free_after_publish = false;
}
msg.max_attempts = 3;
msg.retry_delay_ticks = pdMS_TO_TICKS(1000);
/* Enqueue — non-blocking (0 timeout) to avoid deadlock from MicroPython task */
if (xQueueSend(publisher_task_q, &msg, 0) != pdTRUE) {

tesaiot_mqtt_publish(topic, payload, len) defaults the topic to device/<device_id>/telemetry and enqueues to publisher_task_q with a zero timeout — a full queue drops. The publisher task drains it:

void publisher_task(void *pvParameters)
{
publisher_data_t publisher_q_data;
(void)pvParameters;
/* Create the publisher command queue */
publisher_task_q = xQueueCreate(PUBLISHER_TASK_QUEUE_LENGTH,
sizeof(publisher_data_t));
printf("[Publisher] Task started\n");
while (true) {
/* See the note on PUBLISHER_TASK_STACK_SIZE. (printf) bypasses this file's
* printf mute, if it has one. */
(printf)("[PUB-TASK] stack_free=%u words\n",
(unsigned)uxTaskGetStackHighWaterMark(NULL));
if (pdTRUE == xQueueReceive(publisher_task_q, &publisher_q_data,
portMAX_DELAY)) {
switch (publisher_q_data.cmd) {
case PUBLISH_MQTT_MSG: {
/* Set topic and payload from queue data */
publish_info.topic = publisher_q_data.topic;
publish_info.topic_len = publisher_q_data.topic_len;
publish_info.payload = publisher_q_data.data;
publish_info.payload_len = publisher_q_data.payload_len;
/* Publish with retries */
cy_rslt_t result = ~CY_RSLT_SUCCESS;
uint8_t max_retries = publisher_q_data.max_attempts > 0 ?
publisher_q_data.max_attempts :
PUBLISH_RETRY_LIMIT;
for (uint8_t retry = 0; retry < max_retries; retry++) {
result = cy_mqtt_publish(mqtt_connection, &publish_info);
if (result == CY_RSLT_SUCCESS) {
#if TESAIOT_DEBUG_PUBLISHER_ENABLED
printf("[Publisher] Published to %.*s (%u bytes)\n",
(int)publish_info.topic_len,
publish_info.topic,
(unsigned)publish_info.payload_len);

From MicroPython: tesaiot.publish() (modtesaiot.c:772).

Step-by-step

Step 1 — Write the config

Create /.tesaiot_config with at least tls_mode=2, broker=…, device_id=… and the credentials your platform issued. On mtb-mpy the file is reachable from the REPL (open('/.tesaiot_config','w')); on mtb-only write it from the CM55 TESAIoT settings page or ship it in the image's filesystem.

What you should observe. Nothing on the UART. [TESAIOT_CFG] loaded: … exists in the source and is compiled to nothing (tesaiot_config_store.c:26). On mtb-only the absence of ERROR: tesaiot_config_init failed at boot is the positive signal (proj_cm33_ns/main.c:294-296).

Step 2 — Connect to WiFi

Chapter C1 or C2. The MQTT task will not proceed without it.

What you should observe. Topbar WiFi glyph, then clock.

Step 3 — Request the connection

tesaiot.connect() from the REPL, or tap Connect on the TESAIoT page.

What you should observe on the UART, in this order — all live (the mute in mqtt_task.c:39 is commented out; mqtt_client_config.c has no mute):

[MQTT] Waiting for WiFi...
[MQTT] WiFi connected
[MQTT] Waiting for start request...
[MQTT] Start request received
[MQTT-Config] Mode=%d, Broker=%s:%u, Client=%s, User=%s, PassLen=%u
[MQTT] Instance created
[MQTT] Connecting to '%s:%u' as '%s'...
[MQTT] Connected to broker

Read the Broker=s:u value in the [MQTT-Config] line: with tls_mode=2 it says :8884 regardless of what port= says in the file. That is the cfg.port-is-unused rule, visible.

On screen: the TESAIoT page's state goes to connected and shows the broker URL — tesaiot_bridge_mqtt_connected() sets mqtt_state and broker_url (tesaiot_mqtt.c:74-77; in mTLS also optiga_state and cert_state, :60-64).

Failure forms, also live: [MQTT] Connect failed: 0x%08X (retry lu/u), [MQTT] Exceeded max retries (u), [MQTT] WiFi not ready after 60s — aborting, [MQTT] Configuration failed — not attempting to connect.

Step 4 — Prove a publish end to end

From a machine that can reach the broker, subscribe before you publish:

mosquitto_sub -h <broker> -p 8884 --cafile <tesaiot-root-ca.pem> \
-u <user> -P <pass> -t 'device/+/telemetry' -v

Then tesaiot.publish('{"hello":1}') from the REPL, or from the page.

What you should observe. The payload arrives on device/<device_id>/telemetry in mosquitto_sub. That is the proof. Do not wait for [Publisher] Published to … on the UART — it never prints (Traps). The only live line in publisher_task.c is the stack diagnostic (printf)("[PUB-TASK] stack_free=u words\n", …) at :77.

Step 5 — Prove the subscription

Publish from the host to a command topic:

mosquitto_pub -h <broker> -p 8884 --cafile <tesaiot-root-ca.pem> \
-u <user> -P <pass> -t 'device/<device_id>/commands/anything' -m 'ping'

What you should observe. [Subscriber] %.*ss on the UART — the default handler at subscriber_task.c:186, which uses the (printf) form and is therefore live. If you route a new suffix to your own handler, print through (printf) too, or your line will vanish under the file's mute.

Traps

Trap 1 — Editing port= in /.tesaiot_config changes nothing for MQTT.
mqtt_client_config.c derives the port from tls_mode. If your broker listens on a non-standard port, change the switch — there is no configuration path. (Appendix X, item 15.)
Trap 2 — [Subscriber] Subscribing to: and [Subscriber] Subscribed (QoS…) are dead.
subscriber_task.c:46 mutes plain printf for the file; those two lines (:98, :104) are plain printf and compile to nothing. The lines that do print in that file are the parenthesised (printf)(…) calls at :169, :186, :205, :211, :217, :223, :229 — a function-like macro cannot match a parenthesised name. The same applies to [Publisher] Published to … (publisher_task.c:100, under the :28 mute). Any tutorial quoting those strings as milestones is wrong.
Trap 3 — [TESAIOT_IPC] connect requested is dead.
ipc_tesaiot_handler.c:26 mutes that file. The Connect button's only observable is the [MQTT] sequence it triggers.
Trap 4 — cy_wcm_is_connected_to_ap() before WCM init HardFaults.
The MQTT task uses app_wifi_is_ready() everywhere for this reason (mqtt_task.c:340-343). Use the same accessor in your extensions.
Trap 5 — The publish queue drops on full.
xQueueSend(publisher_task_q, &msg, 0) — zero wait. Burst publishing loses messages silently. Pace your telemetry or check the return.
Trap 6 — The router runs on the MQTT event thread.
Handlers that block, take the chip, or malloc large buffers belong on a queue to your own task — exactly what the shipped Protected Update handler does (chapter D2).

Variant

Variant
mtb-mpy and mtb-only

The whole tesaiot_mqtt/ module and the config store ship as source and compile on both variants. Differences: the config file's I/O backend (bento_storage_read_file vs a Python open()), the load order relative to ipc_tesaiot_handler_init() (B1), and the Python entry points tesaiot.connect() / tesaiot.publish(), which exist only on mtb-mpy. On mtb-only, drive the connection from the CM55 TESAIoT page or call tesaiot_mqtt_connect() from your own C task.