/ firmware / src / sensors / temperature_and_humidity.cpp
temperature_and_humidity.cpp
  1  #include "temperature_and_humidity.h"
  2  #include "registry.h"
  3  #include <config.h>
  4  #include <i2c.h>
  5  
  6  #include <Arduino.h>
  7  #include <CHT832X.h>
  8  #include <SHT31.h>
  9  #include <math.h>
 10  
 11  namespace {
 12  
 13  enum class TemperatureHumidityBackend : uint8_t {
 14    CHT832X,
 15    SHT31,
 16  };
 17  
 18  struct TemperatureHumiditySlot {
 19    TemperatureHumidityBackend backend;
 20    config::I2CSensorConfig config;
 21    int8_t resolved_mux_channel;
 22    bool available;
 23  };
 24  
 25  TemperatureHumiditySlot slots[config::temperature_humidity::MAX_SENSORS] = {};
 26  uint8_t sensor_count = 0;
 27  
 28  hardware::i2c::Bus to_bus(uint8_t bus) {
 29    return bus == 0 ? hardware::i2c::Bus::Bus0 : hardware::i2c::Bus::Bus1;
 30  }
 31  
 32  bool access_i2c_device(const config::I2CSensorConfig &config,
 33                         int8_t resolved_mux_channel,
 34                         hardware::i2c::DeviceAccessCommand *command) {
 35    if (!command) return false;
 36    command->bus = to_bus(config.bus);
 37    command->mux_channel = resolved_mux_channel;
 38    command->wire = nullptr;
 39    command->ok = false;
 40    return hardware::i2c::accessDevice(command);
 41  }
 42  
 43  bool probe_cht832x(const config::I2CSensorConfig &config,
 44                     int8_t resolved_mux_channel) {
 45    hardware::i2c::DeviceAccessCommand command = {};
 46    if (!access_i2c_device(config, resolved_mux_channel, &command)) return false;
 47  
 48    CHT832X sensor(config.address, command.wire);
 49    int result = sensor.begin();
 50    if (result != CHT832X_OK) {
 51      hardware::i2c::clearSelection();
 52      return false;
 53    }
 54  
 55    uint16_t manufacturer = sensor.getManufacturer();
 56    hardware::i2c::clearSelection();
 57    return manufacturer == 0x5959;
 58  }
 59  
 60  bool probe_sht31(const config::I2CSensorConfig &config,
 61                   int8_t resolved_mux_channel) {
 62    hardware::i2c::DeviceAccessCommand command = {};
 63    if (!access_i2c_device(config, resolved_mux_channel, &command)) return false;
 64  
 65    SHT31 sensor(config.address, command.wire);
 66    bool ok = sensor.begin() && sensor.isConnected();
 67    hardware::i2c::clearSelection();
 68    return ok;
 69  }
 70  
 71  bool probe_descriptor(const config::I2CSensorConfig &config,
 72                        int8_t resolved_mux_channel) {
 73    switch (config.kind) {
 74      case config::I2CSensorKind::TemperatureHumidityCHT832X:
 75        return probe_cht832x(config, resolved_mux_channel);
 76      case config::I2CSensorKind::TemperatureHumiditySHT3X:
 77        return probe_sht31(config, resolved_mux_channel);
 78      default:
 79        return false;
 80    }
 81  }
 82  
 83  bool read_cht832x(const TemperatureHumiditySlot &slot,
 84                    TemperatureHumiditySensorData *sensor_data) {
 85    hardware::i2c::DeviceAccessCommand command = {};
 86    if (!access_i2c_device(slot.config, slot.resolved_mux_channel, &command)) return false;
 87  
 88    CHT832X sensor(slot.config.address, command.wire);
 89    int begin_result = sensor.begin();
 90    if (begin_result != CHT832X_OK) {
 91      hardware::i2c::clearSelection();
 92      return false;
 93    }
 94  
 95    sensor.setReadDelay(config::temperature_humidity::READ_DELAY_MS);
 96    int result = sensor.read();
 97    bool success = (result == CHT832X_OK || result == CHT832X_ERROR_CRC);
 98    if (success) {
 99      sensor_data->temperature_celsius = sensor.getTemperature();
100      sensor_data->relative_humidity_percent = sensor.getHumidity();
101      sensor_data->model = "CHT832X";
102      sensor_data->ok = true;
103    } else {
104      sensor_data->model = "CHT832X";
105      sensor_data->ok = false;
106      Serial.printf("[temperature_and_humidity] CHT832X read failed: %d\n", result);
107    }
108  
109    hardware::i2c::clearSelection();
110    return success;
111  }
112  
113  bool read_sht31(const TemperatureHumiditySlot &slot,
114                  TemperatureHumiditySensorData *sensor_data) {
115    hardware::i2c::DeviceAccessCommand command = {};
116    if (!access_i2c_device(slot.config, slot.resolved_mux_channel, &command)) return false;
117  
118    SHT31 sensor(slot.config.address, command.wire);
119    bool success = sensor.begin() && sensor.read(true);
120    if (success) {
121      sensor_data->temperature_celsius = sensor.getTemperature();
122      sensor_data->relative_humidity_percent = sensor.getHumidity();
123      sensor_data->model = "SHT31";
124      sensor_data->ok = true;
125    } else {
126      sensor_data->model = "SHT31";
127      sensor_data->ok = false;
128    }
129  
130    hardware::i2c::clearSelection();
131    return success;
132  }
133  
134  void append_slot(TemperatureHumidityBackend backend,
135                   const config::I2CSensorConfig &config,
136                   int8_t resolved_mux_channel) {
137    if (sensor_count >= config::temperature_humidity::MAX_SENSORS) return;
138    slots[sensor_count++] = {
139      .backend = backend,
140      .config = config,
141      .resolved_mux_channel = resolved_mux_channel,
142      .available = true,
143    };
144  }
145  
146  }
147  
148  bool sensors::temperature_and_humidity::initialize() {
149    sensor_count = 0;
150  
151    hardware::i2c::TopologySnapshot topology = {};
152    hardware::i2c::accessTopology(&topology);
153  
154    for (size_t index = 0; index < config::i2c_topology::DEVICE_COUNT; index++) {
155      const config::I2CSensorConfig &device = config::i2c_topology::DEVICES[index];
156      TemperatureHumidityBackend backend;
157  
158      switch (device.kind) {
159        case config::I2CSensorKind::TemperatureHumidityCHT832X:
160          backend = TemperatureHumidityBackend::CHT832X;
161          break;
162        case config::I2CSensorKind::TemperatureHumiditySHT3X:
163          backend = TemperatureHumidityBackend::SHT31;
164          break;
165        default:
166          continue;
167      }
168  
169      if (device.mux_channel == config::i2c::DIRECT_CHANNEL) {
170        if (probe_descriptor(device, config::i2c::DIRECT_CHANNEL)) {
171          append_slot(backend, device, config::i2c::DIRECT_CHANNEL);
172          Serial.printf("[temperature_and_humidity] found %s on bus %u addr 0x%02X\n",
173                        backend == TemperatureHumidityBackend::CHT832X ? "CHT832X" : "SHT31",
174                        device.bus, device.address);
175        }
176        continue;
177      }
178  
179      if (device.mux_channel == config::i2c::ANY_MUX_CHANNEL) {
180        if (!topology.mux_present || device.bus != 1) continue;
181        for (uint8_t channel = 0; channel < hardware::i2c::mux.channelCount(); channel++) {
182          if (probe_descriptor(device, channel)) {
183            append_slot(backend, device, channel);
184            Serial.printf("[temperature_and_humidity] found %s on mux channel %u addr 0x%02X\n",
185                          backend == TemperatureHumidityBackend::CHT832X ? "CHT832X" : "SHT31",
186                          channel, device.address);
187          }
188        }
189        continue;
190      }
191  
192      if (!topology.mux_present || device.bus != 1) continue;
193      if (probe_descriptor(device, device.mux_channel)) {
194        append_slot(backend, device, device.mux_channel);
195        Serial.printf("[temperature_and_humidity] found %s on mux channel %d addr 0x%02X\n",
196                      backend == TemperatureHumidityBackend::CHT832X ? "CHT832X" : "SHT31",
197                      device.mux_channel, device.address);
198      }
199    }
200  
201    hardware::i2c::clearSelection();
202    Serial.printf("[temperature_and_humidity] discovered %d sensor(s)\n",
203                  sensor_count);
204  
205    if (sensor_count > 0) {
206      sensors::registry::add({
207          .kind = SensorKind::TemperatureHumidity,
208          .name = "Temperature & Humidity",
209          .isAvailable = []() -> bool {
210              return sensors::temperature_and_humidity::sensorCount() > 0;
211          },
212          .instanceCount = sensors::temperature_and_humidity::sensorCount,
213          .poll = [](uint8_t index, void *out, size_t cap) -> bool {
214              if (cap < sizeof(TemperatureHumiditySensorData)) return false;
215              return sensors::temperature_and_humidity::access(
216                  index, static_cast<TemperatureHumiditySensorData *>(out));
217          },
218          .data_size = sizeof(TemperatureHumiditySensorData),
219      });
220    }
221    return sensor_count > 0;
222  }
223  
224  uint8_t sensors::temperature_and_humidity::sensorCount() {
225    return sensor_count;
226  }
227  
228  bool sensors::temperature_and_humidity::access(uint8_t index,
229                                                 TemperatureHumiditySensorData *sensor_data) {
230    if (index >= sensor_count) return false;
231    if (!sensor_data) return false;
232  
233    switch (slots[index].backend) {
234      case TemperatureHumidityBackend::CHT832X:
235        return read_cht832x(slots[index], sensor_data);
236      case TemperatureHumidityBackend::SHT31:
237        return read_sht31(slots[index], sensor_data);
238      default:
239        return false;
240    }
241  }
242  
243  uint8_t sensors::temperature_and_humidity::accessAll(TemperatureHumiditySensorData *sensor_data,
244                                                       bool *read_ok,
245                                                       uint8_t max_count) {
246    uint8_t count = (sensor_count < max_count) ? sensor_count : max_count;
247    uint8_t successful_reads = 0;
248  
249    for (uint8_t index = 0; index < count; index++) {
250      read_ok[index] = sensors::temperature_and_humidity::access(index, &sensor_data[index]);
251      if (read_ok[index]) {
252        successful_reads++;
253      } else {
254        sensor_data[index].temperature_celsius = NAN;
255        sensor_data[index].relative_humidity_percent = NAN;
256      }
257    }
258  
259    hardware::i2c::clearSelection();
260    return successful_reads;
261  }
262  
263  #ifdef PIO_UNIT_TESTING
264  
265  #include <testing/utils.h>
266  
267  
268  static void test_temp_humidity_discovers_sensors(void) {
269    GIVEN("Wire1 with power enabled");
270    WHEN("sensors are discovered from the I2C topology");
271    test_ensure_wire1_with_power();
272    hardware::i2c::initialize();
273  
274    uint8_t count = sensors::temperature_and_humidity::initialize();
275    char message[64];
276    snprintf(message, sizeof(message), "discovered %d sensor(s)", count);
277    TEST_MESSAGE(message);
278    if (count == 0) {
279      TEST_IGNORE_MESSAGE("no temperature/humidity sensors connected");
280      return;
281    }
282  }
283  
284  static void test_temp_humidity_reads_plausible_values(void) {
285    GIVEN("at least one discovered sensor");
286    WHEN("sensor 0 is read");
287  
288    if (sensors::temperature_and_humidity::sensorCount() == 0) {
289      TEST_IGNORE_MESSAGE("no sensors discovered, skipping");
290      return;
291    }
292  
293    TemperatureHumiditySensorData sensor_data = {};
294    bool success = sensors::temperature_and_humidity::access(0, &sensor_data);
295  
296    if (!success) {
297      TEST_IGNORE_MESSAGE("temperature/humidity read failed — sensor may be absent or busy");
298      return;
299    }
300  
301    char message[128];
302    snprintf(message, sizeof(message),
303             "sensor 0 (%s): %.2f C, %.2f %% RH", sensor_data.model ? sensor_data.model : "unknown",
304             sensor_data.temperature_celsius,
305             sensor_data.relative_humidity_percent);
306    TEST_MESSAGE(message);
307  
308    TEST_ASSERT_FLOAT_IS_DETERMINATE_MESSAGE(sensor_data.temperature_celsius,
309      "device: temperature reading is NaN or Inf");
310    TEST_ASSERT_FLOAT_IS_DETERMINATE_MESSAGE(sensor_data.relative_humidity_percent,
311      "device: humidity reading is NaN or Inf");
312    TEST_ASSERT_GREATER_OR_EQUAL_FLOAT_MESSAGE(-40.0f, sensor_data.temperature_celsius,
313      "device: temperature below SHT3x minimum (-40 C)");
314    TEST_ASSERT_LESS_OR_EQUAL_FLOAT_MESSAGE(85.0f, sensor_data.temperature_celsius,
315      "device: temperature above SHT3x maximum (85 C)");
316    TEST_ASSERT_GREATER_OR_EQUAL_FLOAT_MESSAGE(0.0f, sensor_data.relative_humidity_percent,
317      "device: humidity below 0 %");
318    TEST_ASSERT_LESS_OR_EQUAL_FLOAT_MESSAGE(100.0f, sensor_data.relative_humidity_percent,
319      "device: humidity above 100 %");
320  }
321  
322  static void test_temp_humidity_rejects_out_of_range_index(void) {
323    WHEN("an out-of-range sensor index is read");
324  
325    TemperatureHumiditySensorData sensor_data = {};
326    bool success = sensors::temperature_and_humidity::access(
327      sensors::temperature_and_humidity::sensorCount(), &sensor_data);
328  
329    TEST_ASSERT_FALSE_MESSAGE(success,
330      "device: read should fail for index >= sensor_count");
331  }
332  
333  static void test_temp_humidity_reads_all_sensors(void) {
334    GIVEN("discovered sensors");
335    WHEN("all sensors are read");
336  
337    if (sensors::temperature_and_humidity::sensorCount() == 0) {
338      TEST_IGNORE_MESSAGE("no temperature/humidity sensors discovered");
339      return;
340    }
341  
342    delay(1100);
343  
344    uint8_t count = sensors::temperature_and_humidity::sensorCount();
345    TemperatureHumiditySensorData sensor_data[config::temperature_humidity::MAX_SENSORS];
346    bool read_ok[config::temperature_humidity::MAX_SENSORS];
347  
348    uint8_t successful = sensors::temperature_and_humidity::accessAll(
349      sensor_data, read_ok, count);
350  
351    char message[128];
352    snprintf(message, sizeof(message),
353             "%d of %d sensors read successfully", successful, count);
354    TEST_MESSAGE(message);
355  
356    TEST_ASSERT_GREATER_THAN_MESSAGE(0, successful,
357      "device: at least one sensor should read successfully");
358  
359    for (uint8_t index = 0; index < count; index++) {
360      if (read_ok[index]) {
361        snprintf(message, sizeof(message),
362                 "sensor %d (%s): %.2f C, %.2f %% RH",
363                  index, sensor_data[index].model ? sensor_data[index].model : "unknown",
364                  sensor_data[index].temperature_celsius,
365                  sensor_data[index].relative_humidity_percent);
366        TEST_MESSAGE(message);
367      }
368    }
369  }
370  
371  static void test_temp_humidity_cht832x_manufacturer_id(void) {
372    GIVEN("a discovered CHT832X sensor");
373    WHEN("the manufacturer ID is read");
374  
375    if (sensors::temperature_and_humidity::sensorCount() == 0) {
376      test_ensure_wire1_with_power();
377      hardware::i2c::initialize();
378      sensors::temperature_and_humidity::initialize();
379    }
380  
381    size_t cht_index = SIZE_MAX;
382    for (uint8_t index = 0; index < sensors::temperature_and_humidity::sensorCount(); index++) {
383      if (slots[index].backend == TemperatureHumidityBackend::CHT832X) {
384        cht_index = index;
385        break;
386      }
387    }
388  
389    if (cht_index == SIZE_MAX) {
390      TEST_IGNORE_MESSAGE("no CHT832X sensors discovered");
391      return;
392    }
393  
394    hardware::i2c::DeviceAccessCommand command = {};
395    TEST_ASSERT_TRUE_MESSAGE(access_i2c_device(slots[cht_index].config,
396                                               slots[cht_index].resolved_mux_channel,
397                                               &command),
398      "device: failed to access resolved CHT832X device");
399    CHT832X sensor(slots[cht_index].config.address, command.wire);
400    TEST_ASSERT_EQUAL_INT_MESSAGE(CHT832X_OK, sensor.begin(),
401      "device: CHT832X begin failed during manufacturer check");
402    uint16_t manufacturer = sensor.getManufacturer();
403    hardware::i2c::clearSelection();
404  
405    char message[64];
406    snprintf(message, sizeof(message), "manufacturer ID: 0x%04X", manufacturer);
407    TEST_MESSAGE(message);
408  
409    TEST_ASSERT_EQUAL_HEX16_MESSAGE(0x5959, manufacturer,
410      "device: unexpected manufacturer ID (expected 0x5959)");
411  }
412  
413  void sensors::temperature_and_humidity::test() {
414    MODULE("Temperature & Humidity");
415    RUN_TEST(test_temp_humidity_discovers_sensors);
416    RUN_TEST(test_temp_humidity_reads_plausible_values);
417    RUN_TEST(test_temp_humidity_rejects_out_of_range_index);
418    RUN_TEST(test_temp_humidity_reads_all_sensors);
419    RUN_TEST(test_temp_humidity_cht832x_manufacturer_id);
420  }
421  
422  #endif