/ firmware / src / services / data_logger.cpp
data_logger.cpp
  1  #include "data_logger.h"
  2  
  3  #include <config.h>
  4  #include <storage.h>
  5  #include "../networking/sntp.h"
  6  #include "rtc.h"
  7  #include <manager.h>
  8  
  9  #include <Arduino.h>
 10  #include <SD.h>
 11  #include <freertos/timers.h>
 12  
 13  namespace {
 14  
 15  bool initialized = false;
 16  bool header_written = false;
 17  uint32_t last_log_ms = 0;
 18  
 19  bool ensure_header() {
 20    if (!hardware::storage::ensureSD()) return false;
 21  
 22    if (SD.exists(config::data_logger::CSV_PATH)) {
 23      File existing = SD.open(config::data_logger::CSV_PATH, FILE_READ);
 24      if (existing && existing.size() > 0) {
 25        existing.close();
 26        header_written = true;
 27        return true;
 28      }
 29      if (existing) existing.close();
 30    }
 31  
 32    File file = SD.open(config::data_logger::CSV_PATH, FILE_WRITE);
 33    if (!file) return false;
 34    file.print("time");
 35    for (uint8_t i = 0; i < config::data_logger::TEMP_HUMIDITY_SENSOR_COUNT; i++)
 36      file.printf(",temperature_celsius_%u", i);
 37    for (uint8_t i = 0; i < config::data_logger::TEMP_HUMIDITY_SENSOR_COUNT; i++)
 38      file.printf(",relative_humidity_percent_%u", i);
 39    for (uint8_t i = 0; i < config::voltage::CHANNEL_COUNT; i++)
 40      file.printf(",voltage_channel_%u", i);
 41    file.print(",co2_ppm_0,co2_temperature_celsius_0,co2_relative_humidity_percent_0");
 42    file.print(",pressure_hpa_0,pressure_temperature_celsius_0");
 43    file.print(",wind_speed_kmh_0,wind_direction_degrees_0");
 44    file.println();
 45    file.close();
 46    header_written = true;
 47    return true;
 48  }
 49  
 50  void write_field(File &file, const char *value) {
 51    if (value) file.print(value);
 52  }
 53  
 54  void write_float_field(File &file, float value, uint8_t decimals = 2) {
 55    if (!isnan(value)) file.print(value, decimals);
 56  }
 57  
 58  void append_row() {
 59    if (!hardware::storage::ensureSD()) return;
 60  
 61    File file = SD.open(config::data_logger::CSV_PATH, FILE_APPEND);
 62    if (!file) return;
 63  
 64    constexpr uint8_t n_th = config::data_logger::TEMP_HUMIDITY_SENSOR_COUNT;
 65  
 66    TemperatureHumiditySensorData th[n_th] = {};
 67    bool th_ok[n_th] = {};
 68    for (uint8_t i = 0; i < n_th; i++)
 69      th_ok[i] = sensors::manager::accessTemperatureHumidity(i, &th[i]);
 70  
 71    VoltageSensorData voltage = {};
 72    bool voltage_ok = sensors::manager::accessVoltage(&voltage);
 73  
 74    CO2SensorData co2 = {};
 75    bool co2_ok = sensors::manager::accessCO2(&co2);
 76  
 77    WindSpeedSensorData wind_speed = {};
 78    bool wind_speed_ok = sensors::manager::accessWindSpeed(&wind_speed);
 79  
 80    WindDirectionSensorData wind_direction = {};
 81    bool wind_direction_ok = sensors::manager::accessWindDirection(&wind_direction);
 82  
 83    if (networking::sntp::isSynced()) {
 84      write_field(file, networking::sntp::accessLocalTimeString());
 85    } else {
 86      RTCSnapshot rtc = {};
 87      services::rtc::accessSnapshot(&rtc);
 88      if (rtc.valid) write_field(file, rtc.iso8601);
 89    }
 90  
 91    for (uint8_t i = 0; i < n_th; i++) {
 92      file.print(',');
 93      if (th_ok[i]) write_float_field(file, th[i].temperature_celsius);
 94    }
 95  
 96    for (uint8_t i = 0; i < n_th; i++) {
 97      file.print(',');
 98      if (th_ok[i]) write_float_field(file, th[i].relative_humidity_percent);
 99    }
100  
101    for (uint8_t i = 0; i < config::voltage::CHANNEL_COUNT; i++) {
102      file.print(',');
103      if (voltage_ok) write_float_field(file, voltage.channel_volts[i], 4);
104    }
105  
106    file.print(',');
107    if (co2_ok) write_float_field(file, co2.co2_ppm, 1);
108    file.print(',');
109    if (co2_ok) write_float_field(file, co2.temperature_celsius);
110    file.print(',');
111    if (co2_ok) write_float_field(file, co2.relative_humidity_percent);
112  
113    BarometricPressureSensorData pressure = {};
114    bool pressure_ok = sensors::manager::accessBarometricPressure(&pressure);
115    file.print(',');
116    if (pressure_ok) write_float_field(file, pressure.pressure_hpa);
117    file.print(',');
118    if (pressure_ok) write_float_field(file, pressure.temperature_celsius);
119  
120    file.print(',');
121    if (wind_speed_ok) write_float_field(file, wind_speed.kilometers_per_hour);
122    file.print(',');
123    if (wind_direction_ok) write_float_field(file, wind_direction.degrees, 1);
124  
125    file.println();
126    file.close();
127  }
128  
129  }
130  
131  namespace {
132  
133  TimerHandle_t log_timer = nullptr;
134  
135  void log_timer_callback(TimerHandle_t) {
136    if (!initialized) {
137      initialized = ensure_header();
138      if (!initialized) return;
139    }
140    append_row();
141  }
142  
143  }
144  
145  void services::data_logger::initialize() {
146    initialized = ensure_header();
147  
148    log_timer = xTimerCreate("data-log", pdMS_TO_TICKS(config::data_logger::LOG_INTERVAL_MS),
149                              pdTRUE, nullptr, log_timer_callback);
150    xTimerStart(log_timer, 0);
151  }
152  
153  void services::data_logger::flushNow() {
154    if (!initialized) {
155      initialized = ensure_header();
156      if (!initialized) return;
157    }
158    append_row();
159  }
160  
161  bool services::data_logger::accessStatus(DataLoggerStatusSnapshot *snapshot) {
162    if (!snapshot) return false;
163    snapshot->initialized = initialized;
164    snapshot->sd_ready = hardware::storage::isSDReady();
165    snapshot->header_written = header_written;
166    snapshot->interval_ms = config::data_logger::LOG_INTERVAL_MS;
167    snapshot->last_log_ms = last_log_ms;
168    snapshot->path = config::data_logger::CSV_PATH;
169    snapshot->ring_buf_used = 0;
170    snapshot->ring_buf_capacity = 0;
171    snapshot->ring_buf_overrun = false;
172    return true;
173  }
174  
175  #ifdef PIO_UNIT_TESTING
176  
177  #include <testing/utils.h>
178  #include <SD.h>
179  
180  namespace services::data_logger { void test(void); }
181  
182  static void test_csv_header_format(void) {
183    GIVEN("a fresh CSV file on SD");
184    WHEN("the header is written");
185    THEN("it matches the expected column layout");
186    if (!hardware::storage::ensureSD()) {
187      TEST_IGNORE_MESSAGE("skipped — no SD card");
188      return;
189    }
190  
191    const char *path = "/.test_csv_header.csv";
192    SD.remove(path);
193  
194    const char *saved_path = config::data_logger::CSV_PATH;
195    // Write header to a temp file by calling ensure_header indirectly
196    File file = SD.open(path, FILE_WRITE);
197    TEST_ASSERT_TRUE_MESSAGE((bool)file, "device: failed to open test CSV");
198  
199    file.print("time");
200    for (uint8_t i = 0; i < config::data_logger::TEMP_HUMIDITY_SENSOR_COUNT; i++)
201      file.printf(",temperature_celsius_%u", i);
202    for (uint8_t i = 0; i < config::data_logger::TEMP_HUMIDITY_SENSOR_COUNT; i++)
203      file.printf(",relative_humidity_percent_%u", i);
204    for (uint8_t i = 0; i < config::voltage::CHANNEL_COUNT; i++)
205      file.printf(",voltage_channel_%u", i);
206    file.print(",co2_ppm_0,co2_temperature_celsius_0,co2_relative_humidity_percent_0");
207    file.print(",wind_speed_kmh_0,wind_direction_degrees_0");
208    file.println();
209    file.close();
210  
211    File check = SD.open(path, FILE_READ);
212    TEST_ASSERT_TRUE_MESSAGE((bool)check, "device: failed to read test CSV");
213  
214    char buf[512] = {};
215    size_t len = check.readBytesUntil('\n', buf, sizeof(buf) - 1);
216    check.close();
217    SD.remove(path);
218  
219    TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)len, "device: header is empty");
220    TEST_ASSERT_TRUE_MESSAGE(strncmp(buf, "time,", 5) == 0,
221      "device: header must start with 'time,'");
222    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "temperature_celsius_0"),
223      "device: header must contain temperature_celsius_0");
224    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "relative_humidity_percent_0"),
225      "device: header must contain relative_humidity_percent_0");
226    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "voltage_channel_0"),
227      "device: header must contain voltage_channel_0");
228    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "co2_ppm_0"),
229      "device: header must contain co2_ppm_0");
230    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "wind_speed_kmh_0"),
231      "device: header must contain wind_speed_kmh_0");
232    TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "wind_direction_degrees_0"),
233      "device: header must contain wind_direction_degrees_0");
234    TEST_ASSERT_NULL_MESSAGE(strstr(buf, "epoch"),
235      "device: header must not contain legacy 'epoch' column");
236    TEST_ASSERT_NULL_MESSAGE(strstr(buf, "model"),
237      "device: header must not contain legacy 'model' column");
238  
239  }
240  
241  void services::data_logger::test(void) {
242    MODULE("CSV");
243    RUN_TEST(test_csv_header_format);
244  }
245  
246  #endif