1. Today's topic
A state machine for Quectel EC25: AT commands, OK, ERROR, URCs, timeouts, MQTT/TCP/UDP reconnects and connectivity recovery. The main idea: a modem cannot be serviced as a sequence of send_at(); delay(); calls. It must be a separate service with its own state, request queue, deadlines and recovery.
2. Why this matters
Typical EC25 problems:
- AT sometimes responds immediately and sometimes does not;
- the modem may already be powered on when ESP32 starts;
- QMTOPEN/QMTCONN may hang or return errors;
- a URC may arrive at any moment;
- the network may disappear after a successful connection;
- MQTT may disconnect;
- the fast phase-detection path must not be blocked.
A poor approach:
send_at("AT+QMTOPEN=0,\"broker\",1883\r\n");
wait_until_response();
send_at("AT+QMTCONN=0,\"client\"\r\n");
wait_until_response();3. What a state machine is
The modem is always in one of these states:
MODEM_OFF
MODEM_POWERING_ON
MODEM_AT_SYNC
MODEM_SIM_CHECK
MODEM_REG_WAIT
MODEM_PDP_CONFIG
MODEM_PDP_ACTIVE
MODEM_MQTT_CONFIG
MODEM_MQTT_OPENING
MODEM_MQTT_CONNECTING
MODEM_READY
MODEM_DEGRADED
MODEM_RECOVERING
MODEM_ERRORTransitions occur in response to events:
AT_OK
AT_ERROR
URC_QMTOPEN
URC_QMTCONN
URC_QMTSTAT
TIMEOUT
NETWORK_LOST
REQUEST_RECONNECT
POWERKEY_DONE
RESET_DONE4. AT commands are not ordinary functions
An ordinary function returns a result immediately. An AT command may have several phases:
1. ESP32 sends an AT command line.
2. The modem immediately replies OK or ERROR.
3. A URC containing the operation result arrives later.For MQTT:
AT+QMTOPEN=...
OK
+QMTOPEN: 0,0
AT+QMTCONN=...
OK
+QMTCONN: 0,0,0OK often means only “command accepted”, not “operation completed successfully”.
5. The modem_service architecture
transport_task
-> modem_request_queue
modem_task
├─ owns UART2
├─ owns AT parser
├─ owns modem state
├─ owns command timeout
├─ owns reconnect policy
└─ produces modem events/statusRule: only modem_task owns UART2. CLI, MQTT, SMS and transport must not write AT commands directly to UART2.
6. State structure
typedef enum {
MODEM_STATE_OFF = 0,
MODEM_STATE_POWERING_ON,
MODEM_STATE_AT_SYNC,
MODEM_STATE_ECHO_OFF,
MODEM_STATE_SIM_CHECK,
MODEM_STATE_REG_WAIT,
MODEM_STATE_PDP_CONFIG,
MODEM_STATE_PDP_ACTIVATE,
MODEM_STATE_MQTT_CONFIG,
MODEM_STATE_MQTT_OPENING,
MODEM_STATE_MQTT_CONNECTING,
MODEM_STATE_READY,
MODEM_STATE_RECOVERING,
MODEM_STATE_ERROR,
} modem_state_t;
typedef struct {
modem_state_t state;
int64_t state_entered_us;
int64_t deadline_us;
uint8_t client_idx;
bool at_ready;
bool sim_ready;
bool registered_network;
bool pdp_active;
bool mqtt_open;
bool mqtt_connected;
uint32_t at_timeout_count;
uint32_t mqtt_open_fail_count;
uint32_t mqtt_connect_fail_count;
uint32_t recover_count;
} modem_ctx_t;The state must answer:
- where we are now;
- when we entered the state;
- what the deadline is;
- which command we are waiting for;
- what the last result was;
- how many recovery attempts have occurred.
7. Transitions without blocking waits
static void modem_enter_state(modem_ctx_t *m,
modem_state_t new_state,
int timeout_ms)
{
m->state = new_state;
m->state_entered_us = esp_timer_get_time();
m->deadline_us = m->state_entered_us + timeout_ms * 1000LL;
}The task:
void modem_task(void *arg)
{
modem_ctx_t m = {0};
modem_enter_state(&m, MODEM_STATE_AT_SYNC, 1000);
while (1) {
modem_poll_uart_events(&m);
modem_process_requests(&m);
modem_step(&m);
vTaskDelay(pdMS_TO_TICKS(10));
}
}8. Keep the parser separate from the state machine
The parser turns UART lines into events:
OK
ERROR
+CME ERROR: 10
+QMTOPEN: 0,0
+QMTOPEN: 0,5
+QMTCONN: 0,0,0
+QMTSTAT: 0,3An event:
typedef struct {
modem_event_type_t type;
int client_idx;
int result;
int ret_code;
int raw_error;
} modem_event_t;The parser does not decide to “restart the modem”. It only recognises the event.
9. The modem request queue
typedef enum {
MODEM_REQ_MQTT_PUBLISH,
MODEM_REQ_UDP_SEND,
MODEM_REQ_TCP_SEND,
MODEM_REQ_RECONNECT,
MODEM_REQ_GET_STATUS,
} modem_req_type_t;
typedef struct {
modem_req_type_t type;
uint8_t priority;
uint32_t seq;
int64_t created_at_us;
char topic[96];
uint8_t payload[256];
uint16_t payload_len;
} modem_request_t;External code sends a request, and modem_task takes it only in a suitable state.
10. Recovery ladder
A poor strategy:
any error -> reset ESP32The correct recovery ladder:
1. Retry the current AT command.
2. Close the MQTT session: QMTDISC/QMTCLOSE.
3. Reopen MQTT: QMTOPEN/QMTCONN.
4. Reactivate PDP.
5. Check network registration.
6. Restart the modem through PWRKEY/RESET.
7. Restart ESP32 only if the firmware itself is no longer healthy.11. Common mistakes
- Treating OK as the final result;
- using vTaskDelay(5000) instead of waiting for a specific event;
- having multiple UART2 owners;
- ignoring URCs;
- using one global modem_ready;
- not distinguishing telemetry from critical events.
12. Practical task
Create MODEM_STATE_MACHINE.md:
# EC25 modem state machine
| State | Entry action | Expected event | Timeout | On success | On error/timeout |
|---|---|---|---:|---|---|
| AT_SYNC | send `AT` | `OK` | 1000 ms | ECHO_OFF | POWERING_ON |
| ECHO_OFF | send `ATE0` | `OK` | 1000 ms | SIM_CHECK | AT_SYNC |
| SIM_CHECK | send `AT+CPIN?` | `READY` | 5000 ms | REG_WAIT | RECOVERING |
| REG_WAIT | query registration | registered | 60000 ms | PDP_CONFIG | RECOVERING |
| PDP_CONFIG | configure APN/PDP | `OK` | 5000 ms | PDP_ACTIVATE | RECOVERING |
| PDP_ACTIVATE | activate PDP | active | 60000 ms | MQTT_CONFIG | RECOVERING |
| MQTT_OPENING | `QMTOPEN` | `+QMTOPEN: 0,0` | 60000 ms | MQTT_CONNECTING | MQTT/PDP recovery |
| MQTT_CONNECTING | `QMTCONN` | `+QMTCONN: 0,0,...` | 30000 ms | READY | MQTT recovery |
| READY | process request queue | request / URC | - | READY | RECOVERING |13. A short recap
EC25 should be a separate service, rather than a collection of blocking send_at() calls:
transport_task
-> modem_request_queue
-> modem_task
-> UART2
-> AT parser
-> modem state machine
-> recovery ladderExercise
While MQTT is opening, a network-loss URC arrives. Describe which layers recognise the line and choose recovery, and why a CLI must not send its own AT command directly to UART2.
Self-check criteria: Separate parsing from state/recovery and retain one UART2 owner plus a request queue.
Show the supplied answer
The UART/parser layer recognises and emits the event. modem_task owns state, deadlines, request ordering and recovery, so it decides how to handle network loss. A CLI sends a request through the queue; direct UART2 writes would create a second owner and could interleave commands and responses.