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:

c
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:

text
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_ERROR

Transitions occur in response to events:

text
AT_OK
AT_ERROR
URC_QMTOPEN
URC_QMTCONN
URC_QMTSTAT
TIMEOUT
NETWORK_LOST
REQUEST_RECONNECT
POWERKEY_DONE
RESET_DONE

4. AT commands are not ordinary functions

An ordinary function returns a result immediately. An AT command may have several phases:

text
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:

text
AT+QMTOPEN=...
OK
+QMTOPEN: 0,0
AT+QMTCONN=...
OK
+QMTCONN: 0,0,0

OK often means only “command accepted”, not “operation completed successfully”.

5. The modem_service architecture

text
transport_task
  -> modem_request_queue
modem_task
  ├─ owns UART2
  ├─ owns AT parser
  ├─ owns modem state
  ├─ owns command timeout
  ├─ owns reconnect policy
  └─ produces modem events/status

Rule: only modem_task owns UART2. CLI, MQTT, SMS and transport must not write AT commands directly to UART2.

6. State structure

c
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

c
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:

c
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:

text
OK
ERROR
+CME ERROR: 10
+QMTOPEN: 0,0
+QMTOPEN: 0,5
+QMTCONN: 0,0,0
+QMTSTAT: 0,3

An event:

c
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

c
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:

text
any error -> reset ESP32

The correct recovery ladder:

text
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:

markdown
# 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:

text
transport_task
  -> modem_request_queue
  -> modem_task
      -> UART2
      -> AT parser
      -> modem state machine
      -> recovery ladder
AT+QMTOPEN returns OK, but the final +QMTOPEN URC has not arrived. What does OK establish in the lesson’s model?

Exercise

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.