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