Arduino RS485 Temperature, Humidity & CO2 Sensor

As part of this series of samples comparing Arduino to nanoFramework to .NET IoT Device “Proof of Concept (PoC) applications, several posts use a SenseCAP CO2, Temperature and Humidity Sensor SKU101991029.

I cut up a spare Industrial IP68 Modbus RS485 1-to-4 Splitter/Hub to connect the sensor to the breakout board. This sensor has an operating voltage of 5V ~ 24V so it can be powered by the 5V output of a RS485 Breakout Board for Seeed Studio XIAO (SKU 113991354)

The red wire is for powering the sensor with a 12V power supply so was tied back so it didn’t touch any of the other electronics.

#include <HardwareSerial.h>
#include <ModbusMaster.h>

HardwareSerial RS485Serial(1);
ModbusMaster node;

// -----------------------------
// RS485 Pin Assignments (Corrected)
// -----------------------------
const int RS485_RX = 6;  // UART1 RX
const int RS485_TX = 5;  // UART1 TX
const int RS485_EN = D2;

// Sensor/Modbus parameters (from datasheet)
#define MODBUS_SLAVE_ID 0x2D
#define REG_CO2 0x0000
#define REG_TEMPERATURE 0x0001
#define REG_HUMIDITY 0x0002
#define REG_WARMUP_TIME 0x0021

uint32_t warmUp_Completed;

// Forward declarations for ModbusMaster callbacks
void preTransmission();
void postTransmission();

void setup() {
  Serial.begin(9600);
  delay(5000);

  Serial.println("ModbusMaster: Seeed SKU101991029 starting");

  // Wait for the hardware serial to be ready
  while (!Serial)
    ;
  Serial.println("Serial done");

  pinMode(RS485_EN, OUTPUT);
  digitalWrite(RS485_EN, LOW);  // Start in RX mode

  // Datasheet: 9600 baud, 8N1
  RS485Serial.begin(9600, SERIAL_8N1, RS485_RX, RS485_TX);
  while (!RS485Serial)
    ;
  Serial.println("RS485 done");

  // Tie ModbusMaster to the UART we just configured
  node.begin(MODBUS_SLAVE_ID, RS485Serial);

  // Register callbacks for half-duplex direction control
  node.preTransmission(preTransmission);
  node.postTransmission(postTransmission);

  // --- Read Startup time ---
  uint8_t result = node.readHoldingRegisters(REG_WARMUP_TIME, 1);
  if (result == node.ku8MBSuccess) {
    uint16_t warmUpTime = node.getResponseBuffer(0);
    warmUpTime += 3;
    Serial.printf("Start up time: %u sec\n", warmUpTime);
    warmUp_Completed = millis() + (warmUpTime * 1000);
  } else {
    Serial.printf("Read REG_WARMUP_TIME failed (err=%u)\n", result);
  }
}

// Toggle DE/RE around TX per ModbusMaster design
void preTransmission() {
  digitalWrite(RS485_EN, HIGH);  // enable driver (TX)
  delayMicroseconds(250);        // transceiver turn-around margin
}

void postTransmission() {
  delayMicroseconds(250);       // ensure last bit left the wire
  digitalWrite(RS485_EN, LOW);  // back to receive
}

void loop() {
  float temperature;
  uint16_t humidity;
  uint16_t co2;

  uint8_t result = node.readInputRegisters(0x0000, 3);

  if (result == node.ku8MBSuccess) {
    // --- Read Temperature ---
    uint16_t rawTemperature = node.getResponseBuffer(REG_TEMPERATURE);
    temperature = (int16_t)rawTemperature / 100.0;

    // --- Read Humidity ---
    humidity = node.getResponseBuffer(REG_HUMIDITY);
    humidity = humidity / 100;

    if (warmUp_Completed <= millis()) {
      // --- Read CO2  ---
      co2 = node.getResponseBuffer(REG_CO2);
      Serial.printf("Temperature: %.1f °C Humidity: %u %%RH CO2: %u ppm\n", temperature, humidity, co2);
    }
    else {
      Serial.printf("Temperature: %.1f °C Humidity: %u %%RH\n", temperature, humidity);
    }
  }
  else
  {
    Serial.printf("Modbus error: %d\n", result);      
  }

  delay(60000);
}

The Arduino ModbusMaster based application worked first time but implementing the CO2 Sensor warm-up time took a couple of attempts.

I did consider trying to fit the Seeed Studio XIAO ESP32-S3 inside the SenseCAP CO2, Temperature and Humidity Sensor but the electronics had been sprayed with a corrosion resistant coating.

Connecting directly (rather than via a breakout board) the VCC+, VCC-, universal asynchronous receiver-transmitter(UART) and transmit enable would have been difficult.

Arduino RS485 750cm Ultrasonic Level Sensor

The first couple of Proof of Concept(PoC) applications used a Seeedstudio SenseCAP RS485 500cm Ultrasonic Level Sensor (SKU 101991042). They also sell the SenseCAP RS485 750cm Ultrasonic Level Sensor which I found has exactly the same MODBUS interface setup.

Like the SenseCAP RS485 500cm Ultrasonic Level the SenseCAP RS485 750cm Ultrasonic Level Sensor has a Grove connector so the cable had to be “modified” before it would work with the RS485 Breakout Board for Seeed Studio XIAO (SKU 113991354).

The first step was to bend the crimp connector locks back using a very small screwdriver.

Then split the heat-shrink and cable outer, so the individual cables were longer.

The crimp connector at the of each wire had to be “modified” by trimming them with a pair of wire cutters (just in front of the crimped section) so that they could be inserted into the breakout board connectors.

The distance measurements were within a CM which was good. When I took the RS485 750cm Ultrasonic Level Sensor outside the distance returned was 0.0cm when the target was more 750cm away. I hadn’t noticed this with the Seeedstudio RS485 500cm Ultrasonic Level Sensor (SKU 101991042) as the ceiling in my office only 1.9M above the surface of my desk.

Azure Event Grid esp-mqtt-arduino Client – Transient fail

I couldn’t figure out why my code was failing so I gave up. Then the following day the application worked which was really odd. I then fired up my original Arduino PubSubClient library based application to do some testing and went to make a coffee. The application was failing to connect as XiaoTandHandCO2A and even when the bug was fixed (last will setup configuration commented out) it still wouldn’t connect.

The Azure EventGrid MQTT Broker client configuration looked fine

Accidentally the configuration was changed XiaoTandHandCO2B and the application worked which was unexpected. I still couldn’t figure out why my code was failing XiaoTandHandCO2A so I gave up.

The following morning the XiaoTandHandCO2A configuration worked which was a bit odd. My best guess is that after a number of failed attempts the device is “disabled” for a while (but this is not displayed in the client configuration) and the following morning enough time had passed for the device to be “re-enabled”.

For testing I usually split my constants & secrets into two files, constants has the certificate chains for the Azure Event Grid MQTT Broker and secrets has certificate chains for devices and the Azure EventGrid MQTT Broker. I did consider have a secrets.h file for each device or splitting the device intermediate certificates, but as I was debugging and testing, it was just easier to duplicate them.

// constants.h
#pragma once

const uint16_t MQTT_PORT = 8883;

// This certificate is used to authenticate the host
static const char CA_ROOT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
)PEM";

// Secrets.h
#pragma once

// Wi-Fi credentials
const char* WIFI_SSID = "SSID";
const char* WIFI_PASSWORD = "Password";

// MQTT settings
//const char* MQTT_SERVER_URI = "MyBroker.newzealandnorth-1.ts.eventgrid.azure.net";
const char* MQTT_SERVER_URL = "mqtts://MyBroker.newzealandnorth-1.ts.eventgrid.azure.net:8883";

/*
// This is A setup, below are B, C, D, & E
const char* MQTT_CLIENTID = "XiaoTandHandCO2A";
const char* MQTT_TOPIC_PUBLISH = "devices/XiaoTandHandCO2A/sequence"; 
const char* MQTT_TOPIC_SUBSCRIBE = "devices/XiaoTandHandCO2A/configuration"; 

static const char CLIENT_CERT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
Intermediate certificate
-----END CERTIFICATE-----
)PEM";

static const char CLIENT_KEY_PEM[] PROGMEM = R"PEM(
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
)PEM";
*/

const char* MQTT_CLIENTID = "XiaoTandHandCO2B"; 
const char* MQTT_TOPIC_PUBLISH = "devices/XiaoTandHandCO2B/sequence"; 
const char* MQTT_TOPIC_SUBSCRIBE = "devices/XiaoTandHandCO2V/configuration"; 

static const char CLIENT_CERT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
Intermediate certificate
-----END CERTIFICATE-----
)PEM";

static const char CLIENT_KEY_PEM[] PROGMEM = R"PEM(
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
)PEM";

/*
const char* MQTT_CLIENTID = "XiaoTandHandCO2C"; 
const char* MQTT_TOPIC_PUBLISH = "devices/XiaoTandHandCO2C/sequence"; 
const char* MQTT_TOPIC_SUBSCRIBE = "devices/XiaoTandHandCO2V/configuration"; 

static const char CLIENT_CERT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
Intermediate certificate
-----END CERTIFICATE-----
)PEM";

static const char CLIENT_KEY_PEM[] PROGMEM = R"PEM(
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
)PEM";
*/

/*
const char* MQTT_CLIENTID = "XiaoTandHandCO2D"; 
const char* MQTT_TOPIC_PUBLISH = "devices/XiaoTandHandCO2D/sequence"; 
const char* MQTT_TOPIC_SUBSCRIBE = "devices/XiaoTandHandCO2V/configuration"; 

static const char CLIENT_CERT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
Intermediate certificate
-----END CERTIFICATE-----
)PEM";

static const char CLIENT_KEY_PEM[] PROGMEM = R"PEM(
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
)PEM";
*/

/*
const char* MQTT_CLIENTID = "XiaoTandHandCO2E"; 
const char* MQTT_TOPIC_PUBLISH = "devices/XiaoTandHandCO2E/sequence"; 
const char* MQTT_TOPIC_SUBSCRIBE = "devices/XiaoTandHandCO2V/configuration"; 

static const char CLIENT_CERT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
Intermediate certificate
-----END CERTIFICATE-----
)PEM";

static const char CLIENT_KEY_PEM[] PROGMEM = R"PEM(
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
)PEM";
*/

To make deployments easier I have some software which stores the certificates in an optional “secure element” like the Microchip ATECC608C(The Seeedstudio Edgebox100 documentation refers to an optional Microchip ATECC680A but these are “not recommended for new designs”). This isn’t a great solution but deployments easier and is “better” than a string constant in the binary.

Arduino RS485 500cm Ultrasonic Level Sensor

As part of this series of samples comparing Arduino to nanoFramework to .NET IoT Device “Proof of Concept (PoC) applications, the next couple of posts use an RS485 500cm Ultrasonic Level Sensor (SKU 101991042). I started with this sensor because its uses MODBUS and has an operating voltage of 3.3~24 V so it can be powered by the 5V output of a RS485 Breakout Board for Seeed Studio XIAO (SKU 113991354)

Initially the ModBusMaster based application didn’t work but after correcting the pin assignments based on the Seeedstudio XIAO ESP32 S3 RS-485 test harness(Arduino) reading one sensor worked reliably.

#include <HardwareSerial.h>
#include <ModbusMaster.h>

HardwareSerial RS485Serial(1);
ModbusMaster node;

// Pin mapping for XIAO RS485 breakout
const int RS485_RX = 6;   // UART1 RX
const int RS485_TX = 5;   // UART1 TX
const int RS485_EN = D2;   // DE/RE control (single pin)

// Sensor/Modbus parameters (from datasheet)
const uint8_t  SLAVE_ADDR = 0x01;        // default address
const uint16_t REG_CALC_DISTANCE = 0x0100;
//const uint16_t REG_REAL_DISTANCE = 0x0101;
const uint16_t REG_TEMPERATURE   = 0x0102;
const uint16_t REG_SLAVE_ADDR    = 0x0200;

// Forward declarations for ModbusMaster callbacks
void preTransmission();
void postTransmission();

void setup() {
  Serial.begin(9600);
  delay(5000);
  Serial.println("ModbusMaster: Seeed SKU101991042 Starting");

 // Wait for the hardware serial to be ready
  while (!Serial)
    ;
  Serial.println("Serial done");

  // RS485 transceiver enable pin
  pinMode(RS485_EN, OUTPUT);
  digitalWrite(RS485_EN, LOW);           // start in RX mode

  // Datasheet: 9600 baud, 8N1
  RS485Serial.begin(9600, SERIAL_8N1, RS485_RX, RS485_TX);
  while (!RS485Serial)
    ;
  Serial.println("RS485 done");

  // Tie ModbusMaster to the UART we just configured
  node.begin(SLAVE_ADDR, RS485Serial);

  // Register callbacks for half-duplex direction control
  node.preTransmission(preTransmission);
  node.postTransmission(postTransmission);

  Serial.println("ModbusMaster: Seeed 101991042 distance & temperature reader ready.");
}

// Toggle DE/RE around TX per ModbusMaster design
void preTransmission() {
  digitalWrite(RS485_EN, HIGH);          // enable driver (TX)
  delayMicroseconds(250);                // transceiver turn-around margin
}

void postTransmission() {
  delayMicroseconds(250);                // ensure last bit left the wire
  digitalWrite(RS485_EN, LOW);           // back to receive
}

void loop() {
  static uint32_t last = 0;
  //if (millis() - last >= 1000) {         // poll at 1 Hz
  if (millis() - last >= 360000) {         // poll at 0.2 Hz
    last = millis();

    // --- 0x0100 Calculated distance (mm) ---
    uint8_t result = node.readHoldingRegisters(REG_CALC_DISTANCE, 1);
    if (result == node.ku8MBSuccess) {
      uint16_t dist_calc_mm = node.getResponseBuffer(0); // big-endian per Modbus
      float dist_calc_cm = dist_calc_mm / 10.0f;
      Serial.printf("Calculated distance: %u mm (%.1f cm)\n", dist_calc_mm, dist_calc_cm);
    } else {
      Serial.printf("Read 0x0100 failed (err=%u)\n", result);
    }
    delay(1000);
/*
    // --- 0x0101 Real-time distance (mm) ---
    result = node.readHoldingRegisters(REG_REAL_DISTANCE, 1);
    if (result == node.ku8MBSuccess) {
      uint16_t dist_real_mm = node.getResponseBuffer(0);
      float dist_real_cm = dist_real_mm / 10.0f;
      Serial.printf("Real-time distance:  %u mm (%.1f cm)\n", dist_real_mm, dist_real_cm);
    } else {
      Serial.printf("Read 0x0101 failed (err=%u)\n", result);
    }
*/
    // --- 0x0102 Temperature (INT16, 0.1°C) ---
    result = node.readHoldingRegisters(REG_TEMPERATURE, 1);
    if (result == node.ku8MBSuccess) {
      uint16_t raw = node.getResponseBuffer(0);
      int16_t temp_i16 = (int16_t)raw;
      float temp_c = temp_i16 / 10.0f;
      Serial.printf("Temperature: %.1f °C\n", temp_c);
    } else {
      Serial.printf("Read 0x0102 failed (err=%u)\n", result);
    }
    delay(1000);
  }
}

I had to add a short delay between each MODBUS sensor read to stop timeout errors.

Azure Event Grid esp-mqtt-arduino Client – Success

Still couldn’t figure out why my code was failing so I turned up logging to 11 and noticed a couple of messages which didn’t make sense. The device was connecting than disconnecting which indicated a another problem. As part of the Message Queue Telemetry Transport(MQTT) specification there is a “feature” Last Will and Testament(LWT) which a client can configure so that the MQTT broker sends a message to a topic if the device disconnects unexpectedly.

I was looking at the code and noticed that LWT was being used and that the topic didn’t exist in my Azure Event Grid MQTT Broker namespace. When the LWT configuration was commented out the application worked.

void Mqtt5ClientESP32::begin(const char* uri, const char* client_id, const char* user, const char* pass, bool use_v5) {
  connected_ = false;
  insecure_ = false;
  cfg_.broker.address.uri = uri;
  if (client_id) cfg_.credentials.client_id = client_id;
  if (user)      cfg_.credentials.username  = user;
  if (pass)      cfg_.credentials.authentication.password = pass;

  cfg_.broker.verification.use_global_ca_store = false;
  cfg_.broker.verification.certificate = nullptr;
  cfg_.broker.verification.certificate_len = 0;
  cfg_.broker.verification.skip_cert_common_name_check = false;
  
/*
  cfg_.session.last_will.topic  = "devices/esp32/lwt";
  cfg_.session.last_will.msg    = "offline";
  cfg_.session.last_will.qos    = 1;
  cfg_.session.last_will.retain = true;
*/

cfg_.session.protocol_ver = 
#if CONFIG_MQTT_PROTOCOL_5
      use_v5 ? MQTT_PROTOCOL_V_5 : MQTT_PROTOCOL_V_3_1_1;
#else
      MQTT_PROTOCOL_V_3_1_1;
  (void)use_v5;  // MQTT v5 support disabled at build time
#endif
}

Two methods were added so that the LWT could be configured if required

void SetLWT(const char *topic, const char *msg, int msg_len,int qos, int retain);
void Mqtt5ClientESP32::SetLWT(const char *topic, const char *msg, int msg_len,int qos, int retain){
   cfg_.session.last_will.topic  = topic;
   cfg_.session.last_will.msg    = msg;
   cfg_.session.last_will.msg_len= msg_len;
   cfg_.session.last_will.qos    = qos;
   cfg_.session.last_will.retain = retain;
}

Paying close attention to the logging I noticed the “Subscribing to ssl/mqtts” followed by “Subscribe request sent”

I checked the sample application and found that if the connect was successful the application would then try and subscribe to a topic that didn’t exist.

mqtt.onConnected([]{
  Serial.println("[MQTT] Connected event");

   mqttReady = true;
/*
Serial.println("[MQTT] Subscribing to ssl/mqtt5");
if (mqtt.subscribe("ssl/mqtt5", 1, true)) {
  Serial.println("[MQTT] Subscribe request sent");
} else {
  Serial.println("[MQTT] Subscribe request failed");
}
*/

I commented out that code and the application started without any messages

Just to make sure I checked that the message count in the Azure Storage Queue was increasing and the payload client ID matched my device

Yet again a couple of hours lost from my life which I can never get back

Azure Event Grid esp-mqtt-arduino Client – Finding fail

Still couldn’t figure out why my code was failing so I built a test harness which connected to the wifi, set the time with the Network Time Protocol(NTP), established a Transport Layer Security(TLS) connection with the Azure Event Grid MQTT Broker then finally Authenticated (using Client Certificate authentication). Basically, it was The joy of certs without the Arduino PubSubClient library and with authentication

/*
  Azure Event Grid MQTT Endpoint Probe with mTLS
  - Wi-Fi connect
  - SNTP time sync
  - DNS resolve
  - TCP reachability (port 8883)
  - TLS (server-only) handshake using CRT bundle (or custom CA)
  - TLS (mTLS) handshake with client certificate & private key

  Notes:
    - Client certificate must be PEM and match private key.
    - Private key must be PEM and UNENCRYPTED (no passphrase).
    - SNI uses HOSTNAME automatically; do NOT use raw IP.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>

#include <../constants.h>
#include <../secrets.h>

extern "C" {
  #include <lwip/netdb.h>
  #include <lwip/sockets.h>
  #include <lwip/inet.h>
  #include <lwip/errno.h>
  #include <time.h>
}
static const char* HOSTNAME  = "ThisIsNotTheMQTTBrokerYouAreLookingFor.newzealandnorth-1.ts.eventgrid.azure.net";
static const uint16_t PORT   = 8883;

// Time servers (for TLS validity window)
static const char* NTP_1 = "pool.ntp.org";
static const char* NTP_2 = "time.cloudflare.com";

static const char* errnoName(int e) {
  switch (e) {
    case 5:   return "EIO";
    case 101: return "ENETUNREACH";
    case 104: return "ECONNRESET";
    case 110: return "ETIMEDOUT";
    case 111: return "ECONNREFUSED";
    case 113: return "EHOSTUNREACH";
    default:  return "?";
  }
}


bool waitForWifi(uint32_t timeout_ms = 20000) {
  uint32_t start = millis();
  Serial.printf("[WiFi] Connecting to '%s'...\n", WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeout_ms) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  return WiFi.status() == WL_CONNECTED;
}


void syncTime() {
  configTime(0, 0, NTP_1, NTP_2);
  Serial.println("[NTP] Syncing time...");
  for (int i = 0; i < 20; ++i) {
    time_t now = time(nullptr);
    if (now > 1609459200) { // > Jan 1, 2021
      Serial.printf("[NTP] OK (unix=%ld)\n", (long)now);
      return;
    }
    delay(500);
  }
  Serial.println("[NTP] Time sync may have failed; continuing.");
}

bool probeDNS(const char* host, char outIp[16]) {
  struct addrinfo hints = {};
  hints.ai_family = AF_INET; // IPv4
  struct addrinfo* res = nullptr;

  Serial.printf("[DNS] Resolving %s...\n", host);
  int rc = getaddrinfo(host, NULL, &hints, &res);
  Serial.printf("[DNS] getaddrinfo rc=%d\n", rc);
  if (rc != 0 || !res) {
    Serial.println("[DNS] FAILED");
    return false;
  }
  struct sockaddr_in* sin = (struct sockaddr_in*)res->ai_addr;
  inet_ntop(AF_INET, &sin->sin_addr, outIp, 16);
  Serial.printf("[DNS] %s -> %s\n", host, outIp);
  freeaddrinfo(res);
  return true;
}


bool probeTCP(const char* host, uint16_t port, uint32_t timeout_ms = 5000) {
  WiFiClient cli;
  cli.setTimeout(timeout_ms);
  Serial.printf("[TCP] Connecting to %s:%u ...\n", host, port);
  if (!cli.connect(host, port)) {
    Serial.printf("[TCP] connect() FAILED\n");
    return false;
  }
  Serial.println("[TCP] Connected (no TLS). Closing (probe only).");
  cli.stop();
  return true;
}


bool probeTLS(const char* host, uint16_t port, uint32_t timeout_ms = 7000) {
  WiFiClientSecure tls;
  tls.setTimeout(timeout_ms);

  tls.setCACert(CA_ROOT_PEM);  

  Serial.printf("[TLS] Handshake to %s:%u ...\n", host, port);
  if (!tls.connect(host, port)) {
    int e = errno;
    Serial.printf("[TLS] connect() FAILED errno=%d (%s)\n", e, errnoName(e));
    return false;
  }
  Serial.println("[TLS] Handshake OK (server-only TLS)");
  tls.stop();
  return true;
}

bool probeMTLS(const char* host, uint16_t port, uint32_t timeout_ms = 8000) {
  WiFiClientSecure tls;
  tls.setTimeout(timeout_ms);

  tls.setCACert(CA_ROOT_PEM);
  tls.setCertificate(CLIENT_CERT_PEM);
  tls.setPrivateKey(CLIENT_KEY_PEM);

  Serial.printf("[mTLS] Handshake to %s:%u with client cert ...\n", host, port);
  if (!tls.connect(host, port)) {
    int e = errno;
    Serial.printf("[mTLS] connect() FAILED errno=%d (%s)\n", e, errnoName(e));
    Serial.println("[mTLS] If errno=ETIMEDOUT/ECONNRESET, server may be closing due to cert policy mismatch.");
    return false;
  }
  Serial.println("[mTLS] Handshake OK (client authenticated)");
  tls.stop();
  return true;
}

void setup() {
  Serial.begin(9600);
  delay(5000);
  Serial.println();
  Serial.println("==== Azure Event Grid MQTT Probe (mTLS) ====");

  WiFi.mode(WIFI_STA);

  if (!waitForWifi()) {
    Serial.println("[WiFi] FAILED to connect within timeout");
  } else {
    Serial.printf("[WiFi] Connected. IP=%s  RSSI=%d dBm\n",
                  WiFi.localIP().toString().c_str(), WiFi.RSSI());
  }

  // TLS sanity: time
  syncTime();

  // DNS
  char ip[16] = {0};
  bool dnsOk = probeDNS(HOSTNAME, ip);

  // TCP reachability
  bool tcpOk = probeTCP(HOSTNAME, PORT);

  // TLS (server-only)
  bool tlsOk = probeTLS(HOSTNAME, PORT);

  // TLS (mTLS with client cert/key)
  bool mtlsOk = probeMTLS(HOSTNAME, PORT);

  Serial.println("==== Summary ====");
  Serial.printf("DNS:  %s\n", dnsOk  ? "OK" : "FAILED");
  Serial.printf("TCP:  %s\n", tcpOk  ? "OK" : "FAILED");
  Serial.printf("TLS:  %s\n", tlsOk  ? "OK" : "FAILED");
  Serial.printf("mTLS: %s\n", mtlsOk ? "OK" : "FAILED");
  Serial.println("=================");

  Serial.println("If mTLS=FAILED, check: correct cert/key pair, chain/trust CA, and namespace mTLS policy.");
}

void loop() {
  delay(1000);
}

The test harness worked which meant the issue was with my “re-factoring” of the BasicMqtt5_cert example.

Azure Event Grid esp-mqtt-arduino Client – Hours of fail

I wanted to get other Arduino base clients (e.g. my SeeedStudio XiaoESP32S3) for Azure Event Grid MQTT Broker working (for MQTT 5 support) so installed the esp-mqtt-arduino library.

The library doesn’t support client authentication with certificates, so I added two methods setClientCert and setClientKey to the esp-mqtt-arduino.h and esp-mqtt-arduino.cpp files

class Mqtt5ClientESP32 {
   public:
   Mqtt5ClientESP32();
   ~Mqtt5ClientESP32();
//...
  void useCrtBundle(bool enable = true);
  void setCACert(const char* cert, size_t len = 0);
  void setClientCert(const char* cert, size_t len = 0);
  void setClientKey(const char* key, size_t len = 0);  
  void setInsecure(bool enable = true);
  void setKeepAlive(uint16_t seconds);
private:
void Mqtt5ClientESP32::setClientCert(const char* cert, size_t len)
{
  insecure_ = false;
  cfg_.credentials.authentication.certificate = cert;
  if (cert) {
    cfg_.credentials.authentication.certificate_len = len ? len : strlen(cert) + 1;
  } else {
    cfg_.credentials.authentication.certificate_len = 0;
  }  
  cfg_.broker.verification.skip_cert_common_name_check = false;  
}

void Mqtt5ClientESP32::setClientKey(const char* key, size_t len)
{
  insecure_ = false;
  cfg_.credentials.authentication.key = key;
  if (key) {
    cfg_.credentials.authentication.key_len = len ? len : strlen(key) + 1;
  } else {
    cfg_.credentials.authentication.key_len = 0;
  } 
  cfg_.broker.verification.skip_cert_common_name_check = false;  
}

I had started with the basic_mqtt5_cert example stripping it back to the bare minimum hacking out all the certificate bundle support et.c

#include <WiFi.h>
#include <esp-mqtt-arduino.h>
#include <esp_log.h>
#include "sdkconfig.h"
#include "../secrets.h"
#include "../constants.h"

Mqtt5ClientESP32 mqtt;

volatile bool mqttReady = false;
volatile bool mqttSubscribed = false;
void setup() {
  Serial.begin(9600);
  delay(5000);
  Serial.setDebugOutput(true);
  Serial.println("[BOOT] Starting MQTT5 demo");

  esp_log_level_set("*", ESP_LOG_INFO);
  esp_log_level_set("MQTT_CLIENT", ESP_LOG_VERBOSE);

  WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
    (void)info;
    Serial.printf("[WiFi event] id=%d\n", event);
  });

  Serial.printf("[WiFi] Connecting to %s\n", WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  uint8_t attempts = 0;
  while (WiFi.status() != WL_CONNECTED) {
    Serial.printf("[WiFi] status=%d attempt=%u\n", WiFi.status(), attempts++);
    delay(500);
  }
  Serial.print("[WiFi] Connected, IP: ");
  Serial.println(WiFi.localIP());

  // Sync time for TLS
  Serial.println("\[NTP] synchronising");
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  Serial.print("*");
  while (time(nullptr) < 100000) {
    delay(500);
    Serial.print("*");
  }
  Serial.println("\[NTP]  synchronised");

  Serial.printf("[MQTT] Init broker %s as %s\n", MQTT_SERVER_URL,MQTT_CLIENTID);
  mqtt.begin(MQTT_SERVER_URL, MQTT_CLIENTID);
  mqtt.setKeepAlive(45);

  mqtt.setCACert(CA_ROOT_PEM); 
  mqtt.setClientCert(CLIENT_CERT_PEM);
  mqtt.setClientKey(CLIENT_KEY_PEM);
  mqtt.setInsecure(false);

  mqtt.onMessage([](const char* topic, size_t topic_len, const uint8_t* data, size_t len){
    Serial.printf("[MSG] %.*s => %.*s\n", (int)topic_len, topic, (int)len, (const char*)data);
  });
  mqtt.onConnected([]{
    Serial.println("[MQTT] Connected event");
    mqttReady = true;
    Serial.println("[MQTT] Subscribing to ssl/mqtt5");
    if (mqtt.subscribe("ssl/mqtt5", 1, true)) {
      Serial.println("[MQTT] Subscribe request sent");
    } else {
      Serial.println("[MQTT] Subscribe request failed");
    }
  });

  mqtt.onDisconnected([]{
    Serial.println("[MQTT] Disconnected event");
    mqttReady = false;
  });

  Serial.println("[MQTT] Connecting...");
  if (!mqtt.connect()) {
    Serial.println("[MQTT] Connect start failed");
  }
}

void loop() {
  static unsigned long lastPublishMs = 0;
  const unsigned long now = millis();

  if (mqttReady && (now - lastPublishMs) >= 60000) {
    const char* msg = "Hello from Arduino MQTT5 ESP32!";
    Serial.println("[MQTT] Publishing demo message");
    if (mqtt.publish(MQTT_TOPIC_PUBLISH, (const uint8_t*)msg, strlen(msg))) {
      Serial.println("[MQTT] Publish queued (next in ~60s)");
    } else {
      Serial.println("[MQTT] Publish failed");
    }
    lastPublishMs = now;
  }

  delay(10);
}

It was important to put the setClientCert & setClient after the mqtt.begin because it resets the configuration

void Mqtt5ClientESP32::begin(const char* uri, const char* client_id,
                             const char* user, const char* pass, bool use_v5) {
  connected_ = false;
  insecure_ = false;
  cfg_.broker.address.uri = uri;
  if (client_id) cfg_.credentials.client_id = client_id;
  if (user)      cfg_.credentials.username  = user;
  if (pass)      cfg_.credentials.authentication.password = pass;

  cfg_.broker.verification.use_global_ca_store = false;
  cfg_.broker.verification.certificate = nullptr;
  cfg_.broker.verification.certificate_len = 0;
  cfg_.broker.verification.skip_cert_common_name_check = false;
  
  cfg_.session.last_will.topic  = "devices/esp32/lwt";
  cfg_.session.last_will.msg    = "offline";
  cfg_.session.last_will.qos    = 1;
  cfg_.session.last_will.retain = true;

cfg_.session.protocol_ver = 
#if CONFIG_MQTT_PROTOCOL_5
      use_v5 ? MQTT_PROTOCOL_V_5 : MQTT_PROTOCOL_V_3_1_1;
#else
      MQTT_PROTOCOL_V_3_1_1;
  (void)use_v5;  // MQTT v5 support disabled at build time
#endif
}

I tried increasing the log levels to get more debugging information, adding delays on startup to make it easier to see what was going on, trying different options of protocol support.

After hours of trying I gave up.

Arduino MQTT V5 Side quest – Discovery withWolfMQTT

Over the last week or so I have been trying to get an Arduino application working which supports Message Queue Telemetry Transport(MQTT) properties which were added in V5. This specification was released March 2019 so I figured it would be commonly supported. In the Arduino ecosystem I was wrong and there is going to be a series of pastes about my “epic fail”.

The post is not about WolfMQTT but what I learnt about configuring it (and other) libraries for Arduino.

I wanted to set the message properties (for reasons) and in the .NET nanoFramework (and other .NET libraries) it wasn’t a problem.

 while (true)
 {
    Console.WriteLine("MQTT publish message start...");


    var payload = new MessagePayload() { ClientID = Secrets.MQTT_CLIENTID, Sequence = sequenceNumber++ };

    string jsonPayload = JsonSerializer.SerializeObject(payload);

    var result = mqttClient.Publish(topicPublish, Encoding.UTF8.GetBytes(jsonPayload), "application/json; charset=utf-8", null);

    Debug.WriteLine($"MQTT published ({result}): {jsonPayload}");

    Thread.Sleep(60000);
 }

One of the options suggested by copilot was wolfMQTT(which uses an open/closed source model) but I found there was not an Arduino library for of this product. This was not unexpected due to the target audience of their products (but I did find there is a wolfSSL prebuilt library)

The first step was to grab a copy of the wolfMQTT library from GitHub and unpack it.

There were instructions on how to “re-organise” the source files into and Arduino friendly library

I don’t have an “easy” way of running bash on my dev box so I tried to do it manually (first of a series of mistakes).

#!/bin/sh

# this script will reformat the wolfSSL source code to be compatible with
# an Arduino project
# run as bash ./wolfssl-arduino.sh

DIR=${PWD##*/}

if [ "$DIR" = "ARDUINO" ]; then
    rm -rf wolfMQTT

    mkdir wolfMQTT
    cp ../../src/*.c ./wolfMQTT

    mkdir wolfMQTT/wolfmqtt
    cp ../../wolfmqtt/*.h ./wolfMQTT/wolfmqtt

    echo "/* Generated wolfMQTT header file for Arduino */" >> ./wolfMQTT/wolfMQTT.h
    echo "#include <wolfmqtt/mqtt_client.h>" >> ./wolfMQTT/wolfMQTT.h
else
    echo "ERROR: You must be in the IDE/ARDUINO directory to run this script"
fi

I copied the files into the specified folder tree and my compilation failed. I used a minimalist application to debug the compilation error.

I went back to the script file and realised that has missed creating a header file

    echo "/* Generated wolfMQTT header file for Arduino */" >> ./wolfMQTT/wolfMQTT.h
    echo "#include <wolfmqtt/mqtt_client.h>" >> ./wolfMQTT/wolfMQTT.h

I used a text editor to create the file which I saved into the src folder

I also checked that I had the same structure as wolfssl

The compilation was still failing so I turned on the Arduino verbose compiler output

After compilations I went back through the compiler output look for clues.

After a couple of failed compilations I paid attention to the error message.

I had the “case” of the header file name wrong, so I changed it to wolfMQTT.h

The compile then worked and the code executed.

After several hours of “fail” I now understand that case is important for header file names in Arduino(maybe due to Unix origins of some of the tools used). The naming of header file is also important so the library can be discovered.

In another post I will try and build an Azure Event Grid MQTT Broker client that uses wolfMQTT.

Azure Event Grid Server Certificate Validation

Over the last couple of weekends I had been trying to get a repeatable process for extracting the X509 certificate information in the correct structure so my Arduino application could connect to Azure Event Grid. The first step was to get the certificate chain for my Azure Event Grid MQTT Broker with openssl

openssl s_client -connect YourNameSpaceHere.newzealandnorth-1.ts.eventgrid.azure.net:443 -showcerts

The CN: CN=DigiCert Global Root G3 and the wildcard CN=*.eventgrid.azure.net certificates were “concatenated” in the constants header file which is included in the main program file. The format of the certificate chain is described in the comments. Avoid blank lines, “rogue” spaces or other formatting as these may cause the WiFiClientSecure Mbed TLS implementation to fail.

/*
Minimalist ESP32 + Azure Event Grid MQTT Event Grid broker namespace certificate validation
copyright (c) November 2025, devMobile Software
*/
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "secrets.h"
#include "constants.h"

// --- Wi-Fi ---
//const char* WIFI_SSID     = "";
//const char* WIFI_PASSWORD = "";

//const char* MQTT_SERVER = "YourNamespace.newzealandnorth-1.ts.eventgrid.azure.net";
const uint16_t MQTT_PORT = 8883;

/*
// The certificate that is used to authenticate the MQTT Broker
const char CA_ROOT_PEM[] PROGMEM = R"PEM(
-----BEGIN CERTIFICATE-----
MIIGdTCCBfugAwIBAgITMwAC8tqK8+gk3Ll5FwAAAALy2jAKBggqhkjOPQQDAzBd
....
      Thumbprint: 56D955C849887874AA1767810366D90ADF6C8536
      CN: CN=Microsoft Azure ECC TLS Issuing CA 03
      CN=*.eventgrid.azure.net      
....
4ZWZhnNydNZmt4H/7KAd5/UaIP/IUI/xBg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDXTCCAuOgAwIBAgIQAVKe6DaPC11yukM+LY6mLTAKBggqhkjOPQQDAzBhMQsw
....
      Thumbprint: 7E04DE896A3E666D00E687D33FFAD93BE83D349E
      CN: CN=DigiCert Global Root G3
....
MGHYkSqHik6yPbKi1OaJkVl9grldr+Y+z+jgUwWIaJ6ljXXj8cPXpyFgz3UEDnip
Eg==
-----END CERTIFICATE-----
)PEM";
*/

WiFiClientSecure secureClient;

void setup() {
  Serial.begin(9600);
  delay(2000);
  Serial.println("\nServerCertificateValidationClient starting");

  struct tm timeinfo;
  if (getLocalTime(&timeinfo)) {
    Serial.printf("Startup DateTime: %04d-%02d-%02d %02d:%02d:%02d\n", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
  }

  // Connect to WiFi
  Serial.println("WiFi connecting");
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("*");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print("*");
  }
  Serial.println("\nWiFi connected");

  if (getLocalTime(&timeinfo)) {
    Serial.printf("Wifi DateTime: %04d-%02d-%02d %02d:%02d:%02d\n", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
  }

  // Sync time for TLS
  Serial.println("\nTime synchronising");
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  Serial.print("*");
  while (time(nullptr) < 100000) {
    delay(500);
    Serial.print("*");
  }
  Serial.println("\nTime synchronised");

  if (getLocalTime(&timeinfo)) {
    Serial.printf("NTP DateTime: %04d-%02d-%02d %02d:%02d:%02d\n", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
  }

  Serial.println("\nValidating ServerFQDN-Certificate combination");
  secureClient.setCACert(CA_ROOT_PEM);
  Serial.print("*");
  while (!secureClient.connect(MQTT_SERVER, MQTT_PORT)) {
    delay(500);
    Serial.print("*");
  }
  Serial.println("\nTLS Connected");
}

void loop() {
  Serial.print("x");
  delay(5000);
}

After a hard reset the WiFiClientSecure connect failed because the device time had not been initialised so the device/server time offset was too large (see rfc9325)

After a “hard” reset the Network Time Protocol(NTP) client was used to set the device time.

After a “soft” reset the Network Time Protocol(NTP) client did not have to be called.

nanoFramework LoRa library Part4B

Transmit Basic Revisited

After finding some possible SPI library issues (April 2020) with my STM32F429 Discovery + Dragino LoRa shield for Arduino test rig I wanted to trial my code on another nanoFramework platform.

I had ordered a Sparkfun LoRa Gateway 1 Channel ESP32 for a LoRaWAN research project from a local supplier and an unexpected “bonus” was that the ESP32 WROOM platform is supported by the nanoFramework.

Sparkfun LoRa Gateway 1 Channel with wire antenna

I am using this in conjunction with my Armtronix IA005 SX1276 loRa node and my STM32F429 Discovery + Dragino LoRa shield for Arduino test rig.

STM32F429 Discovery+ Dragino LoRa shield with Armtronix device

The code now works on STM32F429 Discovery and ESP32 WROOM platforms. (manual update nanoFramework.Hardware.Esp32 NuGet reference required)

Sparkfun LoRa Gateway 1 Channel schematic

One disadvantage of the SparkFun device is that the reset pin on the SX127X doesn’t appear to be connected to the ESP32 so I can’t factory reset the device in code.

//#define ST_STM32F429I_DISCOVERY       //nanoff --target ST_STM32F429I_DISCOVERY --update
#define ESP32_WROOM_32_LORA_1_CHANNEL   //nanoff --target ESP32_WROOM_32 --serialport COM4 --update
namespace devMobile.IoT.Rfm9x.TransmitBasic
{
   using System;
   using System.Text;
   using System.Threading;

   using Windows.Devices.Gpio;
   using Windows.Devices.Spi;

#if ESP32_WROOM_32_LORA_1_CHANNEL
   using nanoFramework.Hardware.Esp32;
#endif

   public sealed class Rfm9XDevice
   {
      private SpiDevice rfm9XLoraModem;
      private const byte RegisterAddressReadMask = 0X7f;
      private const byte RegisterAddressWriteMask = 0x80;

      public Rfm9XDevice(string spiPort, int chipSelectPin, int resetPin)
      {
         var settings = new SpiConnectionSettings(chipSelectPin)
         {
            ClockFrequency = 1000000,
            //DataBitLength = 8,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared,
         };

         rfm9XLoraModem = SpiDevice.FromId(spiPort, settings);

         // Factory reset pin configuration
         GpioController gpioController = GpioController.GetDefault();
         GpioPin resetGpioPin = gpioController.OpenPin(resetPin);
         resetGpioPin.SetDriveMode(GpioPinDriveMode.Output);
         resetGpioPin.Write(GpioPinValue.Low);
         Thread.Sleep(10);
         resetGpioPin.Write(GpioPinValue.High);
         Thread.Sleep(10);
      }

      public Rfm9XDevice(string spiPort, int chipSelectPin)
      {
         var settings = new SpiConnectionSettings(chipSelectPin)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared,
         };

         rfm9XLoraModem = SpiDevice.FromId(spiPort, settings);
      }

      public Byte RegisterReadByte(byte registerAddress)
      {
         byte[] writeBuffer = new byte[] { registerAddress &= RegisterAddressReadMask, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         rfm9XLoraModem.TransferFullDuplex(writeBuffer, readBuffer);

         return readBuffer[1];
      }

      public ushort RegisterReadWord(byte address)
      {
         byte[] writeBuffer = new byte[] { address &= RegisterAddressReadMask, 0x0, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         rfm9XLoraModem.TransferFullDuplex(writeBuffer, readBuffer);

         return (ushort)(readBuffer[2] + (readBuffer[1] << 8));
      }

      public byte[] RegisterRead(byte address, int length)
      {
         byte[] writeBuffer = new byte[length + 1];
         byte[] readBuffer = new byte[writeBuffer.Length];
         byte[] repyBuffer = new byte[length];

         writeBuffer[0] = address &= RegisterAddressReadMask;

         rfm9XLoraModem.TransferFullDuplex(writeBuffer, readBuffer);

         Array.Copy(readBuffer, 1, repyBuffer, 0, length);

         return repyBuffer;
      }

      public void RegisterWriteByte(byte address, byte value)
      {
         byte[] writeBuffer = new byte[] { address |= RegisterAddressWriteMask, value };
         byte[] readBuffer = new byte[writeBuffer.Length];

         rfm9XLoraModem.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void RegisterWriteWord(byte address, ushort value)
      {
         byte[] valueBytes = BitConverter.GetBytes(value);
         byte[] writeBuffer = new byte[] { address |= RegisterAddressWriteMask, valueBytes[0], valueBytes[1] };
         byte[] readBuffer = new byte[writeBuffer.Length];

         rfm9XLoraModem.TransferFullDuplex(writeBuffer,readBuffer);
      }

      public void RegisterWrite(byte address, byte[] bytes)
      {
         byte[] writeBuffer = new byte[1 + bytes.Length];
         byte[] readBuffer = new byte[writeBuffer.Length];

         Array.Copy(bytes, 0, writeBuffer, 1, bytes.Length);
         writeBuffer[0] = address |= RegisterAddressWriteMask;

         rfm9XLoraModem.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void RegisterDump()
      {
         Console.WriteLine("Register dump");
         for (byte registerIndex = 0; registerIndex <= 0x42; registerIndex++)
         {
            byte registerValue = this.RegisterReadByte(registerIndex);

            Console.WriteLine($"Register 0x{registerIndex:x2} - Value 0X{registerValue:x2}");
         }
      }
   }

   class Program
   {
#if ST_STM32F429I_DISCOVERY
      private const string SpiBusId = "SPI5";
#endif
#if ESP32_WROOM_32_LORA_1_CHANNEL
      private const string SpiBusId = "SPI1";
#endif

      static void Main()
      {
         int SendCount = 0;
#if ST_STM32F429I_DISCOVERY
         int chipSelectPinNumber = PinNumber('C', 2);
         int resetPinNumber = PinNumber('C', 3);
#endif
#if ESP32_WROOM_32_LORA_1_CHANNEL
         int chipSelectPinNumber = Gpio.IO16;
#endif
         try
         {
#if ESP32_WROOM_32_LORA_1_CHANNEL
            Configuration.SetPinFunction(Gpio.IO12, DeviceFunction.SPI1_MISO);
            Configuration.SetPinFunction(Gpio.IO13, DeviceFunction.SPI1_MOSI);
            Configuration.SetPinFunction(Gpio.IO14, DeviceFunction.SPI1_CLOCK);
            Rfm9XDevice rfm9XDevice = new Rfm9XDevice(SpiBusId, chipSelectPinNumber);
#endif
#if ST_STM32F429I_DISCOVERY
            Rfm9XDevice rfm9XDevice = new Rfm9XDevice(SpiBusId, chipSelectPinNumber, resetPinNumber);
#endif
            Thread.Sleep(500);

            // Put device into LoRa + Standby mode
            rfm9XDevice.RegisterWriteByte(0x01, 0b10000001); // RegOpMode 

            // Set the frequency to 915MHz
            byte[] frequencyWriteBytes = { 0xE4, 0xC0, 0x00 }; // RegFrMsb, RegFrMid, RegFrLsb
            rfm9XDevice.RegisterWrite(0x06, frequencyWriteBytes);

            // More power PA Boost
            rfm9XDevice.RegisterWriteByte(0x09, 0b10000000); // RegPaConfig

            rfm9XDevice.RegisterDump();

            while (true)
            {
               rfm9XDevice.RegisterWriteByte(0x0E, 0x0); // RegFifoTxBaseAddress 

               // Set the Register Fifo address pointer
               rfm9XDevice.RegisterWriteByte(0x0D, 0x0); // RegFifoAddrPtr 

               string messageText = $"Hello LoRa {SendCount += 1}!";

               // load the message into the fifo
               byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);
               rfm9XDevice.RegisterWrite(0x0, messageBytes); // RegFifo

               // Set the length of the message in the fifo
               rfm9XDevice.RegisterWriteByte(0x22, (byte)messageBytes.Length); // RegPayloadLength

               Console.WriteLine($"Sending {messageBytes.Length} bytes message {messageText}");
               /// Set the mode to LoRa + Transmit
               rfm9XDevice.RegisterWriteByte(0x01, 0b10000011); // RegOpMode 

               // Wait until send done, no timeouts in PoC
               Console.WriteLine("Send-wait");
               byte IrqFlags = rfm9XDevice.RegisterReadByte(0x12); // RegIrqFlags
               while ((IrqFlags & 0b00001000) == 0)  // wait until TxDone cleared
               {
                  Thread.Sleep(10);
                  IrqFlags = rfm9XDevice.RegisterReadByte(0x12); // RegIrqFlags
                  Console.WriteLine(".");
               }
               Console.WriteLine("");
               rfm9XDevice.RegisterWriteByte(0x12, 0b00001000); // clear TxDone bit
               Console.WriteLine("Send-Done");

               Thread.Sleep(10000);
            }
         }
         catch (Exception ex)
         {
            Console.WriteLine(ex.Message);
         }
      }

#if ST_STM32F429I_DISCOVERY
      static int PinNumber(char port, byte pin)
      {
         if (port < 'A' || port > 'J')
            throw new ArgumentException();

         return ((port - 'A') * 16) + pin;
      }
#endif
   }
}

When I initially ran the application in Visual Studio 2019 the text below was displayed in the output window.

Register dump
Register 0x00 - Value 0X00
Register 0x01 - Value 0X80
Register 0x02 - Value 0X1A
Register 0x03 - Value 0X0B
Register 0x04 - Value 0X00
…
Register 0x3E - Value 0X00
Register 0x3F - Value 0X00
Register 0x40 - Value 0X00
Register 0x41 - Value 0X00
Register 0x42 - Value 0X12
Sending 13 bytes message Hello LoRa 1!
Send-wait
.
.
.
.
.
Send-Done
Sending 13 bytes message Hello LoRa 2!
Send-wait
.
.
.
.
.
Send-Done

I could the see the messages arriving at the Armtronix device in the Arduino monitor.

18:21:46.299 -> Sending HeLoRa World! 188
18:21:48.700 -> Message: p8V⸮⸮⸮⸮⸮Kg
18:21:48.700 -> Length: 13
18:21:48.734 -> FirstChar: 112
18:21:48.734 -> RSSI: -70
18:21:48.734 -> Snr: 9.50
18:21:48.769 -> 
18:21:50.193 -> Message: Hello LoRa 10!
18:21:50.193 -> Length: 14
18:21:50.226 -> FirstChar: 72
18:21:50.226 -> RSSI: -49
18:21:50.226 -> Snr: 10.00
18:21:50.260 -> 
18:21:56.652 -> Sending HeLoRa World! 190
18:21:58.765 -> Message: Hello LoRa 2!
18:21:58.765 -> Length: 13
18:21:58.798 -> FirstChar: 72
18:21:58.798 -> RSSI: -71
18:21:58.798 -> Snr: 9.75
18:21:58.832 -> 
18:22:00.268 -> Message: Hello LoRa 11!
18:22:00.268 -> Length: 14
18:22:00.302 -> FirstChar: 72
18:22:00.302 -> RSSI: -49
18:22:00.302 -> Snr: 10.00
18:22:00.336 -> 

The first message was getting corrupted (only when running in the debugger) which after some trial and error I think was most probably due to my RegOpMode register mode configuration.

SX127X RegOpMode details
// Put device into LoRa + Sleep mode
rfm9XDevice.RegisterWriteByte(0x01, 0b10000000);

// Put device into LoRa + Standby mode
rfm9XDevice.RegisterWriteByte(0x01, 0b10000001); 

After a couple of years and half a dozen platform ports still finding bugs in my samples…