How to build a cellular IoT device with the Raspberry Pi Pico — part two, the code

In part one, I described an IoT demo setup based on the Raspberry Pi Pico and the Waveshare Pico SIM7080G Cat-M1/NB-IoT cellular add-on board, and wrote about some of the design goals. Now it’s time to implement that design with some C++ code: a host application, drivers for the modem, the HT16K3-based display and the MCP9808 temperature sensor, and some third-party libraries to decode incoming commands formatted as JSON and encoded in base64 for easy SMS transmission.

Pico + Waveshare cellular module = compact IoT development board

You can find all the code described below in my Pi-Pico GitHub repo — just look in the cellular-iot-demo directory, which you can drag onto Visual Studio Code to open it up as a project, or into any other text editor or IDE you prefer.

One general point: I’ve started adding the line add_definitions(-DDEBUG) to my projects’ CMakeLists.txt files. This sets a compiler-accessible token (called DEBUG; the -D bit is a flag for CMake) which I then use within #ifdef... #endif blocks that hold code that’s only relevant to debug work, such as using the Pico SDK’s stdio_init_all() function to route all printf() output through the USB connection to my computer. When I’m ready to make a release build, I comment out the add_definitions() line.

The code is spread across a number of files. The main one is cellular.cpp, and there are files for the three key classes, one for string utilities that get used pretty much everywhere, and one more for common I2C code.

Classes for devices

The classes provide drivers for the three peripherals: the Sitcom 7080G modem, the MCP9808 temperature sensor and the HT16K33-based display. To be honest, the first two could simply be implemented as namespaced code, which is how the two utilities files are organised, but I went with classes for consistency with the display. Each of these classes is instantiated only once, so a class is probably overkill. The HT16K33 should be a class because even though there’s only one instance right now, if you added a second one, to give yourself eight digits to play with, you‘d need a second instance to control it.

Back to cellular.cpp — it instantiates the classes:

Sim7080G modem = Sim7080G();
MCP9808 sensor = MCP9808();
HT16K33_Segment display = HT16K33_Segment();

and then sets up the Pico’s peripherals — GPIO, UART and I2C — and finally sits and waits incoming commands send via SMS:

void listen() {
  while (true) {
    // Check for a response from the modem
    string response = modem.listen(5000);
    if (response != "ERROR") {
      vector<string> lines = Utils::split_to_lines(response);
      for (uint32_t i = 0 ; i < lines.size() ; ++i) {
        string line = lines[i];
        if (line.length() == 0) continue;
        if (line.find("+CMTI:") != string::npos) {
          // We received an SMS, so get it...
          string num = Utils::get_sms_number(line);
          string msg = modem.send_at_response("AT+CMGR=" + num);

          // ...and process it for commands by
          // getting the message body...
          string data = Utils::split_msg(msg, 2);

          // ...decoding the base64 to a JSON string...
          string json = base64_decode(data);

          // ...and parsing the JSON
          DynamicJsonDocument doc(128);
          DeserializationError err = deserializeJson(doc,
          if (err == DeserializationError::Ok) {
            string cmd = doc["cmd"];
            uint32_t value = doc["val"];

            // Check for commands
            if (cmd == "LED" || cmd == "led")
            if (cmd == "NUM" || cmd == "num")
            if (cmd == "TMP" || cmd == "tmp")

          // Delete all SMSs now we're done with them
          modem.send_at("AT+CMGD=" + num + ",4");

The function spends five seconds listening for unsolicited messages — those not issued in direct response to an AT command — from the modem. If there’s a +CMTI: in there, that’s a signal that an SMS has been received, so the text is parsed to get the message index (num) and that’s used to assemble an AT command, CMGR, to read the received message.

The code uses René Nyffenegger’s c++ base64 library to decode the message back to a JSON string which is passed to Benoit Blanchon’s ArduinoJSON library for conversion to easily readable values access via the source JSON’s keys. Both are kept in their own files; a header only, in the case of ArduinoJSON.

Depending on the received command, the code jumps to a command-specific function, (process_command_xxx()) then comes back here and, finally, deletes the received message using another AT command, CMGD.

Talking to the modem

The two AT commands use two different methods, both part of the Sim7080G class. The first, send_at_response(), issues the specified AT command and returns whatever data was received back from the modem within the timeout period (the default is 2s). Sometimes, though, you don’t really need that response, only an indication that the operation triggered by the AT command was successful. Hence the final send_at() call, which returns true if the modem response was what you expected. By default, that’s the AT command set’s OK message — default parameter arguments is another advantage of using C++ over C. If that’s not what the modem issued, or the op timed out, send_at() returns false.

string Sim7080G::send_at_response(string cmd, uint32_t timeout) {
  // Write out the AT command, converting to
  // a C string for the Pico SDK
  #ifdef DEBUG
  printf("SEND_AT  CMD: %s\n", cmd.c_str());

  // Write out the AT command
  string data_out = cmd + "\r\n";
  uart_puts(MODEM_UART, data_out.c_str());

  // Read the buffer

  // Return response as string
  if (rx_ptr > &uart_buffer[0]) {
    string response = buffer_to_string();
    #ifdef DEBUG
    printf("SEND_AT RESP:\n%s", response.c_str());
    return response;

  return "ERROR";

send_at() makes direct use of send_at_response(): it just checks to see if the value of the string parameter back can be found in the response. That’s the meat of the class: the remaining code focuses on modem and network set-up. That’s mostly issuing configuration AT commands — ripe for refactoring: issue the commands as a single string rather than the separate send_at() calls in config_modem() — and a further AT command to check that the modem is attached to a network: when it is, AT+COPS? comes back with an operator name, not 0. That happens in check_network().

The function boot_modem() just fires off the ATE1 command until it gets response from the modem. The first time this fails, it’s taken as a sign that the modem is not powered, so the code turns it on by toggling its PWR_EN pin via toggle_module_power(). After approximately 30s, the modem’s UART is ready to use, and ATE1 receives a valid response.

The functions in utils.cpp are primarily used to handle responses from the modem. These are usually multi-line strings, with each line separated by the character sequence <CR><LF>. The split_to_lines() code uses that to segment the string into substrings stored in a vector for easy access. Sometimes we only need one line out of many, so a second function, split_msg(), uses split_to_lines() to break apart the response from the modem and then returns the specific line that’s required.

vector<string> split_to_lines(string ml_str, string separator) {   
  vector<string> result;
  while (ml_str.length()) {
    int index = ml_str.find(separator);
    if (index != string::npos){
      result.push_back(ml_str.substr(0, index));
      ml_str = ml_str.substr(index + separator.length());
    } else {

  return result;

split_to_lines() is generic: it splits on <CR><LF> by default, but it take any other separator string. For example, get_field_value(), uses it to separate responses by commas, which is the standard AT delimiter for items of data on a single line.

Sending commands

As I mentioned in part one, I use Twilio Super SIM for its API-driven connectivity. The call to send commands is:

curl -X POST \
  --data-urlencode "Sim=<YOUR_SIM_NAME_SID>" \
  --data-urlencode "Payload=..." \

The angle-bracketed values are those unique to the account holder and the SIM in use, but the other lines are generic. The key part is the value of the Payload parameter which takes the body of the SMS that will be sent to the device. Remember that the code expect a JSON string that has been base64-encoded. So, for example,

{ "cmd": "num", "val": 10 }



so the API request’s Payload line is

--data-urlencode "Payload=eyAiY21kIjogIm51bSIsICJ2YWwiOiAxMCB9Cg==" \

You generate the base64 string at the command line with:

echo '{ "cmd": "num", "val": 10 }' | base64

That’s for Super SIM, but you can use other SIMs, of course, and they will have their own way of receiving machine-to-machine messages. Adapt the code accordingly.

I’ve included a script,, which you can use to speed up sending test commands. Usage details in the README file.

Note Since writing this post, I’ve been tweaking the code. There’s much more there than described here so make sure you take a look.

More on the Raspberry Pi Pico