nanoFramework RS485 Light Intensity 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 Industrial Light Intensity Sensor (SKU314990739 or SKU 314990740)

I cut one of the cables of a spare Industrial IP68 Modbus RS485 1-to-4 Splitter/Hub to connect the sensor to the RS485 breakout board.

The sensor has an operating voltage of 3.6-30V but it is connected to the 12V supply pin. Initially, I had the sensor connected to the 5V output of the RS485 Breakout Board for Seeed Studio XIAO (SKU 113991354) so it didn’t work.

// Modbus Client
using (var _client = new ModbusClient("COM2"))
{
#if DEBUG_LOGGER
   _client.Logger = new DebugLogger("ModbusClient") 
   { 
      MinLogLevel = LogLevel.Debug 
   };
#endif

   while (true)
   {
      try
      {
         // regs[0] = High order byye of value.
         var regs = _client.ReadHoldingRegisters(SlaveAddress, regIllumminanceHigh, NumberOfRegistersToRead);
         short high = regs[regIllumminanceHigh];

         // regs[1] = low order byte of value.
         ushort low = unchecked((ushort)regs[regIlluminanceLow]);

         // regs[2] = status.
         short rawStatus = regs[regStatus];

         ushort illumminance = (ushort)((high << 16) | low);

         Console.WriteLine($"Illuminance: {illumminance} Lux, Status:{rawStatus}");
      }
      catch (Exception ex)
      {
         Console.WriteLine($"Read failed: {ex.Message}");
      }

      Thread.Sleep(60000);
   }
}

For the next version the “status” value will be mapped to an enumeration.

Message Transformation in code with HiveMQ Client

The first prototype of “transforming” telemetry data used C# code complied with the application. The HiveMQClient based application subscribes to topics (devices publishing environmental measurements) and then republishes them to multiple topics.

private static void OnMessageReceived(object? sender, HiveMQtt.Client.Events.OnMessageReceivedEventArgs e)
{
   HiveMQClient client = (HiveMQClient)sender!;

   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.receive start");
   Console.WriteLine($" Topic:{e.PublishMessage.Topic} QoS:{e.PublishMessage.QoS} Payload:{e.PublishMessage.PayloadAsString}");

   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Publish start");
   foreach (string topic in _applicationSettings.PublishTopics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
   {
      e.PublishMessage.Topic = string.Format(topic, _applicationSettings.ClientId);

      Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Topic:{e.PublishMessage.Topic} HiveMQ Publish start ");
      var resultPublish = client.PublishAsync(e.PublishMessage).GetAwaiter().GetResult();
      Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Published:{resultPublish.QoS1ReasonCode} {resultPublish.QoS2ReasonCode}");
   }
   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Receive finish");
}

The MQTTX application subscribed to topics that devices (XiaoTandHandCO2A, XiaoTandHandCO2B etc.) and the simulated bridge (DESKTOP-EN0QGL0) published.

The second prototype “transforms” the telemetry message payloads with C# code that is compiled (with CSScript) as the application starts. The application subscribes to the topics which devices publish (environmental measurements), transforms the payloads, and then republishes the transformed messages to “bridge” topics

private static void OnMessageReceived(object? sender, HiveMQtt.Client.Events.OnMessageReceivedEventArgs e)
{
   HiveMQClient client = (HiveMQClient)sender!;

   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.receive start");
   Console.WriteLine($" Topic:{e.PublishMessage.Topic} QoS:{e.PublishMessage.QoS} Payload:{e.PublishMessage.PayloadAsString}");

   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Publish start");
   foreach (string topic in _applicationSettings.PublishTopics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
   {
      e.PublishMessage.Topic = string.Format(topic, _applicationSettings.ClientId);

      foreach (MQTT5PublishMessage message in _evaluator.Transform(e.PublishMessage))
      {
         Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Topic:{e.PublishMessage.Topic} HiveMQ Publish start ");
         var resultPublish = client.PublishAsync(message).GetAwaiter().GetResult();
         Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Published:{resultPublish.QoS1ReasonCode} {resultPublish.QoS2ReasonCode}");
      }
   }
   Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Receive finish");
}

// This code is compiled as the application starts up, it implements the IMessageTransformer interface
const string sampleTransformCode = @"
using System.Text;
using HiveMQtt.MQTT5.Types;

public class messageTransformer : devMobile.IoT.MqttTransformer.CSScriptLoopback.IMessageTransformer
{
   public MQTT5PublishMessage[] Transform(MQTT5PublishMessage message)
   {
      // Example: echo the payload to a new topic
      var payload = Encoding.UTF8.GetString(message.Payload);


      // Simple transformations: convert to uppercase or lowercase
      var toLower = new MQTT5PublishMessage
      {
         Topic = message.Topic,
         Payload = Encoding.UTF8.GetBytes(payload.ToLower()),
         QoS = QualityOfService.AtLeastOnceDelivery
      };

      var toUpper = new MQTT5PublishMessage
      {
         Topic = message.Topic,
         Payload = Encoding.UTF8.GetBytes(payload.ToUpper()),
         QoS = QualityOfService.AtLeastOnceDelivery
      };

      return new[] { toLower, toUpper };
   }
}";

The sample C# code implements the IMessageTransformer interface and republishes lower and upper case versions of the message.

public interface IMessageTransformer
{
   public MQTT5PublishMessage[] Transform(MQTT5PublishMessage mqttPublishMessage);
}
...
  _evaluator = CSScript.Evaluator.LoadCode<IMessageTransformer>(sampleTransformCode);

The SwarmSpace, and Myriota gateways both use an interface based approach to process uplink and downlink messages. Future versions will include support for isolating processing so that a rogue script can’t crash the application or reference unapproved assemblies

nanoFramework RS485 Temperature, Humidity & Dewpoint Sensor

As part of this series of samples comparing Arduino to nanoFramework to .NET IoT Device “Proof of Concept (PoC) applications, several posts use an SenseCap Air Temperature and Humidity Sensor SKU 101990882

I cut one of the cables of 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 3.6-30V/DC 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.

public class Program
{
   // === Sensor Modbus params (from Seeed datasheet and label on cable) ===
   const byte SlaveAddress = 0x2A;   // default
   const ushort RegTemperature = 0x00; // int16 (twos-comp), value = °C * 100
   const ushort RegHumidity = 0x01; // uint16, value = %RH * 100
   const ushort RegDewPointTemperature = 0x02; // int16 (twos-comp), value = °C * 100

   public static void Main()
   {
      Console.WriteLine("Modbus Client for Seeedstudio Temperature Humidity and Dew point sensor SKU101990882");

#if ESP32_XIAO_ESP32_S3
      Configuration.SetPinFunction(Gpio.IO06, DeviceFunction.COM2_RX);
      Configuration.SetPinFunction(Gpio.IO05, DeviceFunction.COM2_TX);
      Configuration.SetPinFunction(Gpio.IO03, DeviceFunction.COM2_RTS);
#endif 
      var ports = SerialPort.GetPortNames();

      Console.WriteLine("Available ports: ");
      foreach (string port in ports)
      {
         Console.WriteLine($" {port}");
      }

      // Modbus Client
      using (var _client = new ModbusClient("COM2"))
      {
#if DEBUG_LOGGER
         _client.Logger = new DebugLogger("ModbusClient") 
         { 
            MinLogLevel = LogLevel.Debug 
         };
#endif

         while (true)
         {
            try
            {
               // regs[0] = Temperature.
               var regs = _client.ReadHoldingRegisters(SlaveAddress, RegTemperature, 3);
               short rawTemperature = regs[RegTemperature];
               double temperature = rawTemperature / 100.0; // Signed 16 - bit, value = °C * 100

               // regs[1] = Humidity.
               ushort rawRelativeHumidity = unchecked((ushort)regs[RegHumidity]);
               double relativeHumidity = rawRelativeHumidity / 100.0; // Humidity. Unsigned 16-bit, value = %RH * 100

               // regs[2] = Dewpoint.
               short rawDewPointTemperature = regs[RegDewPointTemperature];
               double dewPointTemperature = rawDewPointTemperature / 100.0; // Signed 16 - bit, value = °C * 100

               Console.WriteLine($"Temperature: {temperature:F1}°C, RH: {relativeHumidity:F0}%, Dewpoint:{dewPointTemperature:F1} °C");
            }
            catch (Exception ex)
            {
               Console.WriteLine($"Read failed: {ex.Message}");
            }

            Thread.Sleep(60000);
         }
      }
   }
}

The nanoFramework Modbus Library based application worked second attempt because initially I had the RegDewPointTemperature register value 0x021

Arduino RS485 Temperature, Dewpoint & Humidity Sensor

As part of this series of samples comparing Arduino to nanoFramework to .NET IoT Device “Proof of Concept (PoC) applications, and a couple of posts use a SenseCAP Temperature dewpoint, and humidity Sensor SKU101990882.

I cut up a spare Industrial IP68 Modbus RS485 1-to-4 Splitter/Hub to connect the sensor to the breakout board as I find this much easier than soldering connectors

This sensor has an operating voltage of 3.6-30V/DC 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 breakout and device with a 12V power supply so was tied back so it didn’t touch any of the other electronics.

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 0x2A
#define REG_TEMPERATURE 0x0000
#define REG_HUMIDITY 0x0001
#define REG_DEWPOINT 0x0002

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

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

  Serial.println("ModbusMaster: Seeed SKU101990882 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);
}
...
void loop() {
  float temperature;
  uint16_t humidity;
  uint16_t dewPoint;

  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;

    // --- Read DewPoint ---
    dewPoint = node.getResponseBuffer(REG_DEWPOINT);
    dewPoint = dewPoint / 100;

    Serial.printf("Temperature: %.1f°C Humidity: %u%%RH Dewpoint: %u°C\n", temperature, humidity, dewPoint);
  } else {
    Serial.printf("Modbus error: %d\n", result);
  }

  delay(60000);
}

The Arduino ModbusMaster based application worked first time I forgot to scale the dewpoint.

I have order an Industrial Soil Moisture & Temperature Sensor MODBUS-RS485 sensor from Mouser which will be my next project.

NanoMQ on Windows fail

After hours of fail trying to get nanoMQ TCP bridge running on my Windows11 development system it was time to walk away. I ran nanoMQ with different log levels but “nng_dialer_create failed 9” was the initial error message displayed.

The setup looked good…

bridges.mqtt.MyBridgeDeviceID {
    ## Azure Event Grid MQTT broker endpoint
  server = "tls+mqtt-tcp://xxxx.newzealandnorth-1.ts.eventgrid.azure.net:8883"
  proto_ver   = 5
  clientid    = "MyBridgeDeviceID"
  username    = "MyBridgeDeviceID"
  clean_start = true
  keepalive   = "60s"

  ## TLS client certificate authentication
  ssl = {
    # key_password = ""
    keyfile    = "certificates/MyBridgeDeviceID.key"
    certfile   = "certificates/MyBridgeDeviceID.crt"
    cacertfile = "certificates/xxxx.crt"
  }
  ## ------------------------------------------------------------
  ## Topic forwarding (NanoMQ → Azure Event Grid)
  ## ------------------------------------------------------------
  ## These are the topics your device publishes locally.
  ## They will be forwarded upstream to Event Grid.
  ##
  forwards = [xxxx]

  ## ------------------------------------------------------------
  ## Topic subscription (Azure Event Grid → NanoMQ)
  ## ------------------------------------------------------------
  ## This is the topic your device subscribes to from Event Grid.
  subscription = [xxxx]
}

Turns out the nanomq-windows-x86_64 version is not built with Transport Layer Security(TLS) or Dashboard support enabled and If I had started with my Seeedstudio EdgeBox 200 the configuration would most probably have worked.

The management API did work, though I don’t understand why they didn’t use a more RESTfull approach e.g. using HTTP Status codes.

NanoMQ with a HiveMQ Client

Most of my applications have focused on telemetry but I had been thinking about local control for solutions that have to run disconnected. In “real-world” deployments connectivity to Azure EventGrid MQTT Broker isn’t 100% reliable (also delay and jitter issues) which are an issue for control at the edge.

The approach to “transforming” telemetry data into “commands” has to be reliable, supportable, testable, scalable and portable (different processor architectures and operating systems). There are several Edge MQTT brokers which meet most, or all of these criteria and this series of posts will use NanoMQ a Linux Foundation Edge project which can run on my development system reComputer Industrial J3011- Fanless Edge AI, and Seeedstudio EdgeBox 200 devices.

The HiveMQClient application could publish and subscribe to topics

The MQTTX application could also publish and subscribe to topics

The HiveMQClient application has no way to “gracefully” shutdown which was visible in the NanoMQ console.

I have cut corners, the support for secure connections to nanoMQ is very limited and this setup should only be used for basic proof of concepts

Linux Foundation Edge NanoMQ Setup

Over Christmas I read an article about the Internet of Vehicles(IoV) which got me thinking about “edge brokers”. In “real-world” deployments connectivity to Azure EventGrid MQTT Broker would not 100% reliable so I have been looking at lightweight edge brokers.

A Message Queue Telemetry Transport(MQTT) broker with a small footprint (so it could run on a device like my Seeedstudio Edgebox 200), MQTT V5 support, local message persistence for disconnected operation, X509 certificate mutual authentication (so I could connect to Azure EventGrid MQTT Broker) were requirements. I initially looked at

I started by downloading and extracting the Windows X64 version of nanoMQ (started with the debug version).

The only change I had to make was the listener configuration.

Shortly after launching NanoMQ I could connect to it using MQTTX (from EMQX)

I then modified my nanoFramework Azure Event Grid MQTT broker client to connect to the NanoMQ instance running on my development environment.

The nanoFramework Azure Event Grid MQTT broker client could publish and subscribe to topics

The MQTTX application could also publish and subscribe to topics

Downloading nanoMQ, figuring out the configuration file modifications, and modifying my Azure Event Grid MQTT broker client took less than an hour.

BUT: I cut corners, the support for secure connections to nanoMQ is very limited and this setup should only be used for basic proof of concepts

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.

nanoFramework 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 one of the cables of 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.

public static void Main()
{
   Debug.WriteLine("Modbus Client for Seeedstudio Temperature Humidity and CO2 sensor SKU101991029");

   Configuration.SetPinFunction(Gpio.IO06, DeviceFunction.COM2_RX);
   Configuration.SetPinFunction(Gpio.IO05, DeviceFunction.COM2_TX);
   Configuration.SetPinFunction(Gpio.IO03, DeviceFunction.COM2_RTS);

   DateTime warmupCompleted = DateTime.UtcNow;

   // Modbus Client
   using (var client = new ModbusClient("COM2"))
   {
      try
      {
         Debug.WriteLine("Reading CO2 Sensor Warmup duration");

         // Read warm-up time (seconds) from 0x0021
         var warmupReg = client.ReadHoldingRegisters(SlaveAddress, RegWarmup, 1);
         ushort warmupSeconds = unchecked((ushort)warmupReg[0]);

         Debug.WriteLine($"Sensor warm-up:{warmupSeconds}sec");

         warmupCompleted += TimeSpan.FromSeconds(warmupSeconds);
      }
      catch (Exception ex)
      {
         Debug.WriteLine($"Warm-up read failed (continuing): {ex.Message}");
      }

      while (true)
      {
         try
         {
            var regs = client.ReadHoldingRegisters(SlaveAddress, RegCO2, 3);
            short rawTemp = regs[RegTemperature];
            double tempC = rawTemp / 100.0; // Signed 16 - bit, value = °C * 100

            // regs[2] = Humidity. Unsigned 16-bit, value = %RH * 100
            ushort rawRh = unchecked((ushort)regs[RegHumidity]);
            double rhPercent = rawRh / 100.0; // Humidity. Unsigned 16-bit, value = %RH * 100

            if (DateTime.UtcNow > warmupCompleted)
            {
               // regs[0] = CO2 (ppm)
               ushort rawCO2 = unchecked((ushort)regs[RegCO2]);

               int co2Ppm = rawCO2; // already ppm
               Debug.WriteLine($"T:{tempC:F2}°C, RH:{rhPercent:F2}%, CO2:{co2Ppm} ppm");
            }
            else
            {
               Debug.WriteLine($"T:{tempC:F2}°C, RH:{rhPercent:F2}%");
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine($"Read failed: {ex.Message}");
         }

         Thread.Sleep(60000);
      }
   }
}

The nanoFramework Modbus Library 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 (rather than via a breakout board) the VCC+, VCC-, universal asynchronous receiver-transmitter(UART) and transmit enable would have been difficult.

I tried Copilot to clean up the image but it didn’t go well