cloudevents.cpp
1 #include "cloudevents.h" 2 #include <config.h> 3 #include <services/system.h> 4 #include "../sensors/registry.h" 5 #include <manager.h> 6 7 #include <Arduino.h> 8 #include <WiFi.h> 9 #include <ESPAsyncWebServer.h> 10 #include <ArduinoJson.h> 11 12 #include <time.h> 13 14 static const char *MIME_CLOUDEVENTS_BATCH = "application/cloudevents-batch+json"; 15 static const char *SPECVERSION = "1.0"; 16 17 // ───────────────────────────────────────────────────────────────────────────── 18 // Helpers 19 // ───────────────────────────────────────────────────────────────────────────── 20 21 static String cloudevents_source(void) { 22 return String("urn:apidae-systems:tenant:") + config::cloudevents::TENANT + 23 ":site:" + config::cloudevents::SITE; 24 } 25 26 static String cloudevents_event_id(const char *type_name, uint16_t sequence) { 27 return String(type_name) + "-" + 28 String(static_cast<unsigned long>(millis())) + "-" + 29 String(sequence); 30 } 31 32 static String cloudevents_now_iso8601(void) { 33 const time_t now = time(nullptr); 34 if (now <= 0) return ""; 35 36 struct tm utc_time; 37 gmtime_r(&now, &utc_time); 38 char buffer[32] = {0}; 39 strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc_time); 40 return String(buffer); 41 } 42 43 static JsonObject cloudevents_add_event(JsonArray events, uint16_t sequence, 44 const String &source, 45 const char *type_name, 46 const String &time_iso) { 47 JsonObject event = events.add<JsonObject>(); 48 event["specversion"] = SPECVERSION; 49 event["id"] = cloudevents_event_id(type_name, sequence); 50 event["source"] = source; 51 event["type"] = type_name; 52 event["datacontenttype"] = asyncsrv::T_application_json; 53 if (time_iso.length() > 0) { 54 event["time"] = time_iso; 55 } 56 return event; 57 } 58 59 // ───────────────────────────────────────────────────────────────────────────── 60 // Status event (system info, not a sensor) 61 // ───────────────────────────────────────────────────────────────────────────── 62 63 static void append_status_event(JsonArray events, uint16_t sequence, 64 const String &source, const String &time_iso) { 65 JsonObject event = cloudevents_add_event(events, sequence, source, 66 "status.v1", time_iso); 67 JsonObject data = event["data"].to<JsonObject>(); 68 SystemQuery query = { 69 .preferred_storage = StorageKind::LittleFS, 70 .snapshot = {}, 71 }; 72 services::system::accessSnapshot(&query); 73 data["memory_heap"] = query.snapshot.heap_free; 74 data["chip_model"] = query.snapshot.chip_model; 75 data["chip_cores"] = query.snapshot.chip_cores; 76 data["chip_revision"] = query.snapshot.chip_revision; 77 data["ipv4_address"] = query.snapshot.network.ip; 78 data["wifi_rssi"] = query.snapshot.network.rssi; 79 data["uptime_seconds"] = millis() / 1000UL; 80 } 81 82 // ───────────────────────────────────────────────────────────────────────────── 83 // Sensor serializers (SensorKind → JSON fields) 84 // ───────────────────────────────────────────────────────────────────────────── 85 86 static void serialize_temperature_humidity(const void *raw, JsonObject &out) { 87 auto *d = static_cast<const TemperatureHumiditySensorData *>(raw); 88 out["model"] = d->model ? d->model : "unknown"; 89 out["temperature_celsius"] = d->temperature_celsius; 90 out["relative_humidity_percent"] = d->relative_humidity_percent; 91 } 92 93 static void serialize_voltage(const void *raw, JsonObject &out) { 94 auto *d = static_cast<const VoltageSensorData *>(raw); 95 out["gain"] = sensors::voltage::accessGainLabel(); 96 JsonArray voltage = out["voltage"].to<JsonArray>(); 97 for (size_t ch = 0; ch < config::voltage::CHANNEL_COUNT; ch++) 98 voltage.add(d->channel_volts[ch]); 99 } 100 101 static void serialize_current(const void *raw, JsonObject &out) { 102 auto *d = static_cast<const CurrentSensorData *>(raw); 103 out["current_mA"] = d->current_mA; 104 out["bus_voltage_V"] = d->bus_voltage_V; 105 out["shunt_voltage_mV"] = d->shunt_voltage_mV; 106 out["power_mW"] = d->power_mW; 107 out["energy_J"] = d->energy_J; 108 out["charge_C"] = d->charge_C; 109 out["die_temperature_C"] = d->die_temperature_C; 110 } 111 112 static void serialize_co2(const void *raw, JsonObject &out) { 113 auto *d = static_cast<const CO2SensorData *>(raw); 114 out["model"] = d->model; 115 out["co2_ppm"] = d->co2_ppm; 116 out["temperature"] = d->temperature_celsius; 117 out["humidity"] = d->relative_humidity_percent; 118 } 119 120 static void serialize_barometric_pressure(const void *raw, JsonObject &out) { 121 auto *d = static_cast<const BarometricPressureSensorData *>(raw); 122 out["model"] = d->model; 123 out["pressure_hpa"] = d->pressure_hpa; 124 out["temperature_celsius"] = d->temperature_celsius; 125 } 126 127 static void serialize_wind_speed(const void *raw, JsonObject &out) { 128 auto *d = static_cast<const WindSpeedSensorData *>(raw); 129 out["wind_speed_kilometers_per_hour"] = d->kilometers_per_hour; 130 } 131 132 static void serialize_wind_direction(const void *raw, JsonObject &out) { 133 auto *d = static_cast<const WindDirectionSensorData *>(raw); 134 out["wind_direction_degrees"] = d->degrees; 135 out["wind_direction_angle_slice"] = d->slice; 136 } 137 138 static void serialize_solar_radiation(const void *raw, JsonObject &out) { 139 auto *d = static_cast<const SolarRadiationSensorData *>(raw); 140 out["watts_per_square_meter"] = d->watts_per_square_meter; 141 } 142 143 static void serialize_soil(const void *raw, JsonObject &out) { 144 auto *d = static_cast<const SoilSensorData *>(raw); 145 out["slave_id"] = d->slave_id; 146 out["temperature_celsius"] = d->temperature_celsius; 147 out["moisture_percent"] = d->moisture_percent; 148 out["conductivity"] = d->conductivity; 149 out["salinity"] = d->salinity; 150 out["tds"] = d->tds; 151 } 152 153 struct SensorSerializer { 154 SensorKind kind; 155 const char *event_type; 156 void (*serialize)(const void *data, JsonObject &out); 157 }; 158 159 static const SensorSerializer SERIALIZERS[] = { 160 {SensorKind::TemperatureHumidity, "sensors.temperature_and_humidity.v1", serialize_temperature_humidity}, 161 {SensorKind::Voltage, "sensors.power.v1", serialize_voltage}, 162 {SensorKind::Current, "sensors.current.v1", serialize_current}, 163 {SensorKind::CarbonDioxide, "sensors.carbon_dioxide.v1", serialize_co2}, 164 {SensorKind::BarometricPressure, "sensors.barometric_pressure.v1", serialize_barometric_pressure}, 165 {SensorKind::WindSpeed, "sensors.wind_speed.v1", serialize_wind_speed}, 166 {SensorKind::WindDirection, "sensors.wind_direction.v1", serialize_wind_direction}, 167 {SensorKind::SolarRadiation, "sensors.solar_radiation.v1", serialize_solar_radiation}, 168 {SensorKind::Soil, "sensors.soil.v1", serialize_soil}, 169 }; 170 171 static const SensorSerializer *find_serializer(SensorKind kind) { 172 for (const auto &s : SERIALIZERS) { 173 if (s.kind == kind) return &s; 174 } 175 return nullptr; 176 } 177 178 // ───────────────────────────────────────────────────────────────────────────── 179 // Registry-driven sensor event appender 180 // ───────────────────────────────────────────────────────────────────────────── 181 182 static void append_sensor_events(JsonArray events, uint16_t &sequence, 183 const String &source, const String &time_iso) { 184 for (uint8_t i = 0; i < sensors::registry::entryCount(); i++) { 185 const SensorEntry *entry = sensors::registry::entry(i); 186 if (!entry || !entry->isAvailable()) continue; 187 188 const SensorSerializer *ser = find_serializer(entry->kind); 189 if (!ser) continue; 190 191 uint8_t count = entry->instanceCount(); 192 if (count == 0) continue; 193 194 JsonObject event = cloudevents_add_event(events, sequence++, source, 195 ser->event_type, time_iso); 196 JsonObject data = event["data"].to<JsonObject>(); 197 198 if (count == 1) { 199 const void *snapshot = sensors::registry::latest(entry->kind, 0); 200 bool ok = sensors::registry::valid(entry->kind, 0); 201 data["read_ok"] = ok; 202 if (ok && snapshot) { 203 ser->serialize(snapshot, data); 204 } 205 } else { 206 data["sensor_count"] = count; 207 JsonArray instances = data["sensors"].to<JsonArray>(); 208 uint16_t successful = 0; 209 for (uint8_t j = 0; j < count; j++) { 210 const void *snapshot = sensors::registry::latest(entry->kind, j); 211 bool ok = sensors::registry::valid(entry->kind, j); 212 JsonObject inst = instances.add<JsonObject>(); 213 inst["index"] = j; 214 inst["read_ok"] = ok; 215 if (ok && snapshot) { 216 ser->serialize(snapshot, inst); 217 successful++; 218 } 219 } 220 data["successful_reads"] = successful; 221 } 222 } 223 } 224 225 // ───────────────────────────────────────────────────────────────────────────── 226 // Route handler 227 // ───────────────────────────────────────────────────────────────────────────── 228 229 static void handle_cloudevents_get(AsyncWebServerRequest *request) { 230 JsonDocument payload; 231 JsonArray events = payload.to<JsonArray>(); 232 233 const String source = cloudevents_source(); 234 const String time_iso = cloudevents_now_iso8601(); 235 236 uint16_t sequence = 0; 237 append_status_event(events, sequence++, source, time_iso); 238 append_sensor_events(events, sequence, source, time_iso); 239 240 AsyncResponseStream *response = 241 request->beginResponseStream(MIME_CLOUDEVENTS_BATCH); 242 serializeJson(payload, *response); 243 request->send(response); 244 } 245 246 void services::cloudevents::registerRoutes(AsyncWebServer *server) { 247 if (!server) return; 248 server->on("/api/cloudevents", HTTP_GET, handle_cloudevents_get); 249 } 250 251 // ───────────────────────────────────────────────────────────────────────────── 252 // Tests — describe("CloudEvents") 253 // ───────────────────────────────────────────────────────────────────────────── 254 #ifdef PIO_UNIT_TESTING 255 256 #include <testing/utils.h> 257 258 static void test_cloudevents_source_format(void) { 259 WHEN("the CloudEvents source string is generated"); 260 THEN("it contains tenant and site config"); 261 262 String source = cloudevents_source(); 263 String expected = String("urn:apidae-systems:tenant:") + 264 config::cloudevents::TENANT + ":site:" + 265 config::cloudevents::SITE; 266 TEST_ASSERT_EQUAL_STRING_MESSAGE( 267 expected.c_str(), 268 source.c_str(), 269 "device: source string should use tenant and site config"); 270 271 TEST_MESSAGE(source.c_str()); 272 } 273 274 static void test_cloudevents_event_id_includes_type(void) { 275 WHEN("an event ID is generated"); 276 THEN("it starts with the type prefix"); 277 278 String event_id = cloudevents_event_id("status.v1", 0); 279 TEST_ASSERT_TRUE_MESSAGE(event_id.startsWith("status.v1-"), 280 "device: event ID should start with type name"); 281 282 TEST_MESSAGE(event_id.c_str()); 283 } 284 285 void services::cloudevents::test(void) { 286 MODULE("CloudEvents"); 287 RUN_TEST(test_cloudevents_source_format); 288 RUN_TEST(test_cloudevents_event_id_includes_type); 289 } 290 291 #endif