/ firmware / src / sensors / soil.cpp
soil.cpp
  1  #include "soil.h"
  2  #include "registry.h"
  3  
  4  #include <config.h>
  5  #include "../hardware/rs485.h"
  6  #include "../networking/modbus.h"
  7  
  8  #include <Arduino.h>
  9  #include <string.h>
 10  
 11  namespace {
 12  
 13  struct SoilProbeSlot {
 14    uint8_t slave_id;
 15    hardware::rs485::Channel channel;
 16    bool responsive;
 17  };
 18  
 19  SoilProbeSlot slots[8] = {};
 20  uint8_t probe_count = 0;
 21  bool available = false;
 22  
 23  void discover_probes() {
 24    probe_count = 0;
 25    memset(slots, 0, sizeof(slots));
 26  
 27    for (size_t index = 0; index < config::modbus::DEVICE_COUNT; index++) {
 28      const config::ModbusSensorConfig &sensor_config = config::modbus::DEVICES[index];
 29      if (sensor_config.kind != config::ModbusSensorKind::SoilProbe) continue;
 30      if (probe_count >= sizeof(slots) / sizeof(slots[0])) break;
 31  
 32      hardware::rs485::Channel channel = sensor_config.channel == 0
 33          ? hardware::rs485::Channel::Bus0
 34          : hardware::rs485::Channel::Bus1;
 35  
 36      uint16_t output_words[5] = {};
 37      ReadHoldingRegistersCommand command = {
 38        .channel = channel,
 39        .slave_id = sensor_config.slave_id,
 40        .start_register = sensor_config.register_address,
 41        .register_count = 5,
 42        .output_words = output_words,
 43        .error = ModbusError::NotInitialized,
 44      };
 45  
 46      bool ok = networking::modbus::readHoldingRegisters(&command);
 47      slots[probe_count] = {
 48        .slave_id = sensor_config.slave_id,
 49        .channel = channel,
 50        .responsive = ok,
 51      };
 52  
 53      if (ok) {
 54        Serial.printf("[soil] probe slave %d responsive\n", sensor_config.slave_id);
 55      }
 56      probe_count++;
 57    }
 58  }
 59  
 60  }
 61  
 62  bool sensors::soil::access(uint8_t index, SoilSensorData *sensor_data) {
 63    if (!sensor_data) return false;
 64    memset(sensor_data, 0, sizeof(*sensor_data));
 65    sensor_data->ok = false;
 66    if (index >= probe_count) return false;
 67  
 68    const SoilProbeSlot &slot = slots[index];
 69  
 70    const config::ModbusSensorConfig *sensor_config = nullptr;
 71    for (size_t i = 0; i < config::modbus::DEVICE_COUNT; i++) {
 72      if (config::modbus::DEVICES[i].kind == config::ModbusSensorKind::SoilProbe &&
 73          config::modbus::DEVICES[i].slave_id == slot.slave_id) {
 74        sensor_config = &config::modbus::DEVICES[i];
 75        break;
 76      }
 77    }
 78    if (!sensor_config) return false;
 79  
 80    uint16_t output_words[5] = {};
 81    ReadHoldingRegistersCommand command = {
 82      .channel = slot.channel,
 83      .slave_id = slot.slave_id,
 84      .start_register = sensor_config->register_address,
 85      .register_count = 5,
 86      .output_words = output_words,
 87      .error = ModbusError::NotInitialized,
 88    };
 89  
 90    if (!networking::modbus::readHoldingRegisters(&command)) return false;
 91  
 92    sensor_data->moisture_percent = output_words[0] / 10.0f;
 93    sensor_data->temperature_celsius = static_cast<int16_t>(output_words[1]) / 10.0f;
 94    sensor_data->conductivity = output_words[2];
 95    sensor_data->salinity = output_words[3];
 96    sensor_data->tds = output_words[4];
 97    sensor_data->slave_id = slot.slave_id;
 98    sensor_data->ok = true;
 99    return true;
100  }
101  
102  bool sensors::soil::initialize() {
103    discover_probes();
104    available = false;
105    for (uint8_t i = 0; i < probe_count; i++) {
106      if (slots[i].responsive) {
107        available = true;
108        break;
109      }
110    }
111    if (available) {
112      sensors::registry::add({
113          .kind = SensorKind::Soil,
114          .name = "Soil",
115          .isAvailable = sensors::soil::isAvailable,
116          .instanceCount = sensors::soil::probeCount,
117          .poll = [](uint8_t index, void *out, size_t cap) -> bool {
118              if (cap < sizeof(SoilSensorData)) return false;
119              return sensors::soil::access(
120                  index, static_cast<SoilSensorData *>(out));
121          },
122          .data_size = sizeof(SoilSensorData),
123      });
124    }
125    return available;
126  }
127  
128  bool sensors::soil::isAvailable() {
129    return available;
130  }
131  
132  uint8_t sensors::soil::probeCount() {
133    return probe_count;
134  }
135  
136  #ifdef PIO_UNIT_TESTING
137  
138  #include <testing/utils.h>
139  
140  static void test_soil_config_lookup(void) {
141    WHEN("the modbus topology is checked for soil probes");
142  
143    uint8_t count = 0;
144    for (size_t i = 0; i < config::modbus::DEVICE_COUNT; i++) {
145      if (config::modbus::DEVICES[i].kind == config::ModbusSensorKind::SoilProbe)
146        count++;
147    }
148  
149    if (count == 0) {
150      TEST_IGNORE_MESSAGE("no soil probes configured — skipping");
151      return;
152    }
153  
154    TEST_PRINTF("%d soil probe(s) in topology", count);
155  }
156  
157  static void test_soil_rejects_null(void) {
158    WHEN("a null buffer is passed to access");
159    THEN("it returns false");
160    TEST_ASSERT_FALSE_MESSAGE(sensors::soil::access(0, nullptr),
161        "device: access should fail with null pointer");
162  }
163  
164  static void test_soil_rejects_out_of_range(void) {
165    WHEN("an out-of-range probe index is requested");
166    THEN("it returns false");
167    SoilSensorData data = {};
168    TEST_ASSERT_FALSE_MESSAGE(sensors::soil::access(255, &data),
169        "device: access should fail for invalid index");
170  }
171  
172  void sensors::soil::test() {
173    MODULE("Soil");
174    RUN_TEST(test_soil_config_lookup);
175    RUN_TEST(test_soil_rejects_null);
176    RUN_TEST(test_soil_rejects_out_of_range);
177  }
178  
179  #endif