From 8ba7675a1cb4bd434a24724cacdcc601bbe5042e Mon Sep 17 00:00:00 2001 From: acidburns Date: Fri, 30 Jan 2026 13:00:16 +0100 Subject: [PATCH] Add LoRa telemetry, fault counters, and time sync status --- include/config.h | 2 + include/data_model.h | 20 +++++ include/display_ui.h | 1 + include/json_codec.h | 2 +- include/lora_transport.h | 2 + include/mqtt_client.h | 2 + include/time_manager.h | 4 +- src/display_ui.cpp | 78 +++++++++++++++++-- src/json_codec.cpp | 51 ++++++++++++- src/lora_transport.cpp | 6 +- src/main.cpp | 161 +++++++++++++++++++++++++++++++++++++-- src/mqtt_client.cpp | 93 ++++++++++++++++++++++ src/time_manager.cpp | 36 ++++++++- 13 files changed, 437 insertions(+), 21 deletions(-) diff --git a/include/config.h b/include/config.h index 6436321..6018d03 100644 --- a/include/config.h +++ b/include/config.h @@ -60,6 +60,8 @@ constexpr uint32_t SENDER_OLED_READ_MS = 10000; constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000; constexpr uint32_t METER_SEND_INTERVAL_MS = 30000; constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; +constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; +constexpr bool ENABLE_HA_DISCOVERY = true; constexpr uint8_t NUM_SENDERS = 1; inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { diff --git a/include/data_model.h b/include/data_model.h index 6b62321..027eb44 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -2,6 +2,19 @@ #include +enum class FaultType : uint8_t { + None = 0, + MeterRead = 1, + Decode = 2, + LoraTx = 3 +}; + +struct FaultCounters { + uint32_t meter_read_fail; + uint32_t decode_fail; + uint32_t lora_tx_fail; +}; + struct MeterData { uint32_t ts_utc; uint16_t short_id; @@ -13,6 +26,13 @@ struct MeterData { float battery_voltage_v; uint8_t battery_percent; bool valid; + int16_t link_rssi_dbm; + float link_snr_db; + bool link_valid; + uint32_t err_meter_read; + uint32_t err_decode; + uint32_t err_lora_tx; + FaultType last_error; }; struct SenderStatus { diff --git a/include/display_ui.h b/include/display_ui.h index 713e49f..fb9427d 100644 --- a/include/display_ui.h +++ b/include/display_ui.h @@ -11,6 +11,7 @@ void display_set_sender_statuses(const SenderStatus *statuses, uint8_t count); void display_set_last_meter(const MeterData &data); void display_set_last_read(bool ok, uint32_t ts_utc); void display_set_last_tx(bool ok, uint32_t ts_utc); +void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms); void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok); void display_power_down(); void display_tick(); diff --git a/include/json_codec.h b/include/json_codec.h index bf448e9..48397be 100644 --- a/include/json_codec.h +++ b/include/json_codec.h @@ -5,5 +5,5 @@ bool meterDataToJson(const MeterData &data, String &out_json); bool jsonToMeterData(const String &json, MeterData &data); -bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json); +bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults = nullptr, FaultType last_error = FaultType::None); bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count); diff --git a/include/lora_transport.h b/include/lora_transport.h index fdd84c7..ec0fa00 100644 --- a/include/lora_transport.h +++ b/include/lora_transport.h @@ -12,6 +12,8 @@ struct LoraPacket { PayloadType payload_type; uint8_t payload[LORA_MAX_PAYLOAD]; size_t payload_len; + int16_t rssi_dbm; + float snr_db; }; void lora_init(); diff --git a/include/mqtt_client.h b/include/mqtt_client.h index e0bee61..7cc95bb 100644 --- a/include/mqtt_client.h +++ b/include/mqtt_client.h @@ -8,6 +8,8 @@ void mqtt_init(const WifiMqttConfig &config, const char *device_id); void mqtt_loop(); bool mqtt_is_connected(); bool mqtt_publish_state(const MeterData &data); +bool mqtt_publish_faults(const char *device_id, const FaultCounters &counters, FaultType last_error, uint32_t last_error_age_sec); +bool mqtt_publish_discovery(const char *device_id); #ifdef ENABLE_TEST_MODE bool mqtt_publish_test(const char *device_id, const String &payload); #endif diff --git a/include/time_manager.h b/include/time_manager.h index ab7222f..26892f6 100644 --- a/include/time_manager.h +++ b/include/time_manager.h @@ -7,9 +7,11 @@ void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2); uint32_t time_get_utc(); bool time_is_synced(); void time_set_utc(uint32_t epoch); -void time_send_timesync(uint16_t device_id_short); +bool time_send_timesync(uint16_t device_id_short); bool time_handle_timesync_payload(const uint8_t *payload, size_t len); void time_get_local_hhmm(char *out, size_t out_len); void time_rtc_init(); bool time_try_load_from_rtc(); bool time_rtc_present(); +uint32_t time_get_last_sync_utc(); +uint32_t time_get_last_sync_age_sec(); diff --git a/src/display_ui.cpp b/src/display_ui.cpp index c012e8e..7cde378 100644 --- a/src/display_ui.cpp +++ b/src/display_ui.cpp @@ -19,6 +19,9 @@ static uint32_t g_last_read_ts = 0; static uint32_t g_last_tx_ts = 0; static uint32_t g_last_read_ms = 0; static uint32_t g_last_tx_ms = 0; +static FaultType g_last_error = FaultType::None; +static uint32_t g_last_error_ts = 0; +static uint32_t g_last_error_ms = 0; static const SenderStatus *g_statuses = nullptr; static uint8_t g_status_count = 0; @@ -108,6 +111,12 @@ void display_set_last_tx(bool ok, uint32_t ts_utc) { g_last_tx_ms = millis(); } +void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms) { + g_last_error = type; + g_last_error_ts = ts_utc; + g_last_error_ms = ts_ms; +} + void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok) { g_ap_mode = ap_mode; g_wifi_ssid = ssid ? ssid : ""; @@ -137,6 +146,41 @@ static uint32_t age_seconds(uint32_t ts_utc, uint32_t ts_ms) { return (millis() - ts_ms) / 1000; } +static bool render_last_error_line(uint8_t y) { + if (g_last_error == FaultType::None) { + return false; + } + const char *label = "unk"; + if (g_last_error == FaultType::MeterRead) { + label = "meter"; + } else if (g_last_error == FaultType::Decode) { + label = "decode"; + } else if (g_last_error == FaultType::LoraTx) { + label = "lora"; + } + display.setCursor(0, y); + display.printf("Err: %s %lus", label, static_cast(age_seconds(g_last_error_ts, g_last_error_ms))); + return true; +} + +static void render_last_sync_line(uint8_t y, bool include_time) { + display.setCursor(0, y); + uint32_t last_sync = time_get_last_sync_utc(); + if (last_sync == 0 || !time_is_synced()) { + display.print("Sync: --"); + return; + } + uint32_t age = time_get_last_sync_age_sec(); + if (include_time) { + time_t t = last_sync; + struct tm timeinfo; + localtime_r(&t, &timeinfo); + display.printf("Sync: %lus %02d:%02d", static_cast(age), timeinfo.tm_hour, timeinfo.tm_min); + } else { + display.printf("Sync: %lus ago", static_cast(age)); + } +} + static void render_sender_status() { display.clearDisplay(); display.setCursor(0, 0); @@ -157,8 +201,13 @@ static void render_sender_status() { if (strlen(g_test_code) > 0) { display.setCursor(0, 48); display.printf("Test %s", g_test_code); - } + } else #endif + { + if (!render_last_error_line(48)) { + render_last_sync_line(48, true); + } + } display.display(); } @@ -193,25 +242,40 @@ static void render_receiver_status() { display.setCursor(0, 24); display.printf("MQTT: %s", g_mqtt_ok ? "OK" : "RETRY"); + char time_buf[8]; + time_get_local_hhmm(time_buf, sizeof(time_buf)); + display.setCursor(0, 36); + display.printf("Time: %s", time_buf); + uint32_t latest = 0; + bool link_valid = false; + int16_t link_rssi = 0; + float link_snr = 0.0f; if (g_statuses) { for (uint8_t i = 0; i < g_status_count; ++i) { if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) { latest = g_statuses[i].last_update_ts_utc; + link_valid = g_statuses[i].last_data.link_valid; + link_rssi = g_statuses[i].last_data.link_rssi_dbm; + link_snr = g_statuses[i].last_data.link_snr_db; } } } - display.setCursor(0, 36); + display.setCursor(0, 48); if (latest == 0 || !time_is_synced()) { - display.print("Last upd: --:--"); + display.print("Upd --:--"); } else { time_t t = latest; struct tm timeinfo; localtime_r(&t, &timeinfo); - display.printf("Last upd: %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); + display.printf("Upd %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); + } + if (link_valid) { + display.printf(" R:%d S:%.1f", link_rssi, link_snr); } + render_last_error_line(56); display.display(); } @@ -258,7 +322,11 @@ static void render_receiver_sender(uint8_t index) { display.setCursor(0, 48); display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]); display.setCursor(0, 56); - display.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]); + if (status.last_data.link_valid) { + display.printf("R:%d S:%.1f", status.last_data.link_rssi_dbm, status.last_data.link_snr_db); + } else { + display.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]); + } display.display(); } diff --git a/src/json_codec.cpp b/src/json_codec.cpp index af7b1db..cc4371e 100644 --- a/src/json_codec.cpp +++ b/src/json_codec.cpp @@ -33,7 +33,7 @@ static void format_float_2(char *buf, size_t buf_len, float value) { } bool meterDataToJson(const MeterData &data, String &out_json) { - StaticJsonDocument<192> doc; + StaticJsonDocument<256> doc; doc["id"] = short_id_from_device_id(data.device_id); doc["ts"] = data.ts_utc; char buf[16]; @@ -55,6 +55,23 @@ bool meterDataToJson(const MeterData &data, String &out_json) { doc["v3_v"] = serialized(buf); format_float_2(buf, sizeof(buf), data.battery_voltage_v); doc["bat_v"] = serialized(buf); + doc["bat_pct"] = data.battery_percent; + if (data.link_valid) { + doc["rssi"] = data.link_rssi_dbm; + doc["snr"] = data.link_snr_db; + } + if (data.err_meter_read > 0) { + doc["err_m"] = data.err_meter_read; + } + if (data.err_decode > 0) { + doc["err_d"] = data.err_decode; + } + if (data.err_lora_tx > 0) { + doc["err_tx"] = data.err_lora_tx; + } + if (data.last_error != FaultType::None) { + doc["err_last"] = static_cast(data.last_error); + } out_json = ""; size_t len = serializeJson(doc, out_json); @@ -69,7 +86,7 @@ static float read_float_or_legacy(JsonDocument &doc, const char *key, const char } bool jsonToMeterData(const String &json, MeterData &data) { - StaticJsonDocument<192> doc; + StaticJsonDocument<256> doc; DeserializationError err = deserializeJson(doc, json); if (err) { return false; @@ -99,6 +116,13 @@ bool jsonToMeterData(const String &json, MeterData &data) { data.battery_percent = doc["bat_pct"] | 0; } data.valid = true; + data.link_valid = false; + data.link_rssi_dbm = 0; + data.link_snr_db = NAN; + data.err_meter_read = doc["err_m"] | 0; + data.err_decode = doc["err_d"] | 0; + data.err_lora_tx = doc["err_tx"] | 0; + data.last_error = static_cast(doc["err_last"] | 0); if (strlen(data.device_id) >= 8) { const char *suffix = data.device_id + strlen(data.device_id) - 4; @@ -108,7 +132,7 @@ bool jsonToMeterData(const String &json, MeterData &data) { return true; } -bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json) { +bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults, FaultType last_error) { if (!samples || count == 0) { return false; } @@ -117,6 +141,17 @@ bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json) doc["id"] = short_id_from_device_id(samples[count - 1].device_id); doc["bat_v"] = round2(samples[count - 1].battery_voltage_v); doc["bat_pct"] = samples[count - 1].battery_percent; + if (faults) { + if (faults->meter_read_fail > 0) { + doc["err_m"] = faults->meter_read_fail; + } + if (faults->lora_tx_fail > 0) { + doc["err_tx"] = faults->lora_tx_fail; + } + } + if (last_error != FaultType::None) { + doc["err_last"] = static_cast(last_error); + } JsonArray arr = doc.createNestedArray("s"); for (size_t i = 0; i < count; ++i) { JsonArray row = arr.createNestedArray(); @@ -157,6 +192,9 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou const char *id = doc["id"] | ""; float bat_v = doc["bat_v"] | NAN; uint8_t bat_pct = doc["bat_pct"] | 0; + uint32_t err_m = doc["err_m"] | 0; + uint32_t err_tx = doc["err_tx"] | 0; + FaultType last_error = static_cast(doc["err_last"] | 0); size_t idx = 0; for (JsonArray row : arr) { @@ -187,6 +225,13 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou } else { data.battery_percent = bat_pct; } + data.link_valid = false; + data.link_rssi_dbm = 0; + data.link_snr_db = NAN; + data.err_meter_read = err_m; + data.err_decode = 0; + data.err_lora_tx = err_tx; + data.last_error = last_error; if (strlen(data.device_id) >= 8) { const char *suffix = data.device_id + strlen(data.device_id) - 4; diff --git a/src/lora_transport.cpp b/src/lora_transport.cpp index f2ff1c1..2bd0170 100644 --- a/src/lora_transport.cpp +++ b/src/lora_transport.cpp @@ -50,8 +50,8 @@ bool lora_send(const LoraPacket &pkt) { LoRa.beginPacket(); LoRa.write(buffer, idx); - LoRa.endPacket(); - return true; + int result = LoRa.endPacket(); + return result == 1; } bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { @@ -91,6 +91,8 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { return false; } memcpy(pkt.payload, &buffer[5], pkt.payload_len); + pkt.rssi_dbm = static_cast(LoRa.packetRssi()); + pkt.snr_db = LoRa.packetSnr(); return true; } diff --git a/src/main.cpp b/src/main.cpp index 3325644..c3b87f3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,9 @@ #include "web_server.h" #include "display_ui.h" #include "test_mode.h" +#ifdef ARDUINO_ARCH_ESP32 +#include +#endif static DeviceRole g_role = DeviceRole::Sender; static uint16_t g_short_id = 0; @@ -23,6 +26,24 @@ static WifiMqttConfig g_cfg; static uint32_t g_last_timesync_ms = 0; static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000; static uint32_t g_boot_ms = 0; +static FaultCounters g_sender_faults = {}; +static FaultCounters g_receiver_faults = {}; +static FaultCounters g_receiver_faults_published = {}; +static FaultCounters g_sender_faults_remote[NUM_SENDERS] = {}; +static FaultCounters g_sender_faults_remote_published[NUM_SENDERS] = {}; +static FaultType g_sender_last_error = FaultType::None; +static FaultType g_receiver_last_error = FaultType::None; +static FaultType g_sender_last_error_remote[NUM_SENDERS] = {}; +static FaultType g_sender_last_error_remote_published[NUM_SENDERS] = {}; +static FaultType g_receiver_last_error_published = FaultType::None; +static uint32_t g_sender_last_error_utc = 0; +static uint32_t g_sender_last_error_ms = 0; +static uint32_t g_receiver_last_error_utc = 0; +static uint32_t g_receiver_last_error_ms = 0; +static uint32_t g_sender_last_error_remote_utc[NUM_SENDERS] = {}; +static uint32_t g_sender_last_error_remote_ms[NUM_SENDERS] = {}; +static bool g_sender_discovery_sent[NUM_SENDERS] = {}; +static bool g_receiver_discovery_sent = false; static constexpr size_t BATCH_HEADER_SIZE = 6; static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE; @@ -57,6 +78,13 @@ static void init_sender_statuses() { g_sender_statuses[i].last_update_ts_utc = 0; g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i]; snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]); + g_sender_faults_remote[i] = {}; + g_sender_faults_remote_published[i] = {}; + g_sender_last_error_remote[i] = FaultType::None; + g_sender_last_error_remote_published[i] = FaultType::None; + g_sender_last_error_remote_utc[i] = 0; + g_sender_last_error_remote_ms[i] = 0; + g_sender_discovery_sent[i] = false; } } @@ -89,6 +117,60 @@ static uint32_t last_sample_ts() { return g_meter_samples[idx].ts_utc; } +static void note_fault(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) { + if (type == FaultType::MeterRead) { + counters.meter_read_fail++; + } else if (type == FaultType::Decode) { + counters.decode_fail++; + } else if (type == FaultType::LoraTx) { + counters.lora_tx_fail++; + } + last_type = type; + last_ts_utc = time_get_utc(); + last_ts_ms = millis(); +} + +static uint32_t age_seconds(uint32_t ts_utc, uint32_t ts_ms) { + if (time_is_synced() && ts_utc > 0) { + uint32_t now = time_get_utc(); + return now > ts_utc ? now - ts_utc : 0; + } + return (millis() - ts_ms) / 1000; +} + +static bool counters_changed(const FaultCounters &a, const FaultCounters &b) { + return a.meter_read_fail != b.meter_read_fail || a.decode_fail != b.decode_fail || a.lora_tx_fail != b.lora_tx_fail; +} + +static void publish_faults_if_needed(const char *device_id, const FaultCounters &counters, FaultCounters &last_published, + FaultType last_error, FaultType &last_error_published, uint32_t last_error_utc, uint32_t last_error_ms) { + if (!mqtt_is_connected()) { + return; + } + if (!counters_changed(counters, last_published) && last_error == last_error_published) { + return; + } + uint32_t age = last_error != FaultType::None ? age_seconds(last_error_utc, last_error_ms) : 0; + if (mqtt_publish_faults(device_id, counters, last_error, age)) { + last_published = counters; + last_error_published = last_error; + } +} + +#ifdef ARDUINO_ARCH_ESP32 +static void watchdog_init() { + esp_task_wdt_init(WATCHDOG_TIMEOUT_SEC, true); + esp_task_wdt_add(nullptr); +} + +static void watchdog_kick() { + esp_task_wdt_reset(); +} +#else +static void watchdog_init() {} +static void watchdog_kick() {} +#endif + static void write_u16_le(uint8_t *dst, uint16_t value) { dst[0] = static_cast(value & 0xFF); dst[1] = static_cast((value >> 8) & 0xFF); @@ -130,6 +212,10 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_ bool ok = lora_send(pkt); all_ok = all_ok && ok; + if (!ok) { + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } offset += chunk_len; delay(10); } @@ -149,7 +235,7 @@ static bool send_meter_batch(uint32_t ts_for_display) { } String json; - if (!meterBatchToJson(ordered, count, json)) { + if (!meterBatchToJson(ordered, count, json, &g_sender_faults, g_sender_last_error)) { return false; } @@ -177,7 +263,8 @@ static void reset_batch_rx() { g_batch_rx.last_rx_ms = 0; } -static bool process_batch_packet(const LoraPacket &pkt, String &out_json) { +static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error) { + decode_error = false; if (pkt.payload_len < BATCH_HEADER_SIZE) { return false; } @@ -225,10 +312,12 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json) { static uint8_t decompressed[BATCH_MAX_DECOMPRESSED]; size_t decompressed_len = 0; if (!decompressBuffer(g_batch_rx.buffer, g_batch_rx.received_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { + decode_error = true; reset_batch_rx(); return false; } if (decompressed_len >= sizeof(decompressed)) { + decode_error = true; reset_batch_rx(); return false; } @@ -245,6 +334,7 @@ void setup() { Serial.begin(115200); delay(200); + watchdog_init(); g_boot_ms = millis(); g_role = detect_role(); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); @@ -292,6 +382,7 @@ void setup() { } static void sender_loop() { + watchdog_kick(); uint32_t now_ms = millis(); if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { @@ -301,6 +392,10 @@ static void sender_loop() { strncpy(data.device_id, g_device_id, sizeof(data.device_id)); bool meter_ok = meter_read(data); + if (!meter_ok) { + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } read_battery(data); uint32_t now_utc = time_get_utc(); @@ -328,11 +423,13 @@ static void sender_loop() { uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due; if (next_due > now_ms) { + watchdog_kick(); light_sleep_ms(next_due - now_ms); } } static void receiver_loop() { + watchdog_kick(); if (g_last_timesync_ms == 0) { g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TIME_SYNC_OFFSET_MS); } @@ -341,34 +438,60 @@ static void receiver_loop() { if (pkt.payload_type == PayloadType::MeterData) { uint8_t decompressed[256]; size_t decompressed_len = 0; - if (decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { + if (!decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); + } else { if (decompressed_len >= sizeof(decompressed)) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); return; } decompressed[decompressed_len] = '\0'; MeterData data = {}; if (jsonToMeterData(String(reinterpret_cast(decompressed)), data)) { + data.link_valid = true; + data.link_rssi_dbm = pkt.rssi_dbm; + data.link_snr_db = pkt.snr_db; for (uint8_t i = 0; i < NUM_SENDERS; ++i) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { data.short_id = pkt.device_id_short; g_sender_statuses[i].last_data = data; g_sender_statuses[i].last_update_ts_utc = data.ts_utc; g_sender_statuses[i].has_data = true; + g_sender_faults_remote[i].meter_read_fail = data.err_meter_read; + g_sender_faults_remote[i].lora_tx_fail = data.err_lora_tx; + g_sender_last_error_remote[i] = data.last_error; + g_sender_last_error_remote_utc[i] = time_get_utc(); + g_sender_last_error_remote_ms[i] = millis(); mqtt_publish_state(data); + if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[i]) { + g_sender_discovery_sent[i] = mqtt_publish_discovery(data.device_id); + } + publish_faults_if_needed(data.device_id, g_sender_faults_remote[i], g_sender_faults_remote_published[i], + g_sender_last_error_remote[i], g_sender_last_error_remote_published[i], + g_sender_last_error_remote_utc[i], g_sender_last_error_remote_ms[i]); break; } } + } else { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); } } } else if (pkt.payload_type == PayloadType::MeterBatch) { String json; - if (process_batch_packet(pkt, json)) { + bool decode_error = false; + if (process_batch_packet(pkt, json, decode_error)) { MeterData samples[METER_BATCH_MAX_SAMPLES]; size_t count = 0; if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { for (size_t s = 0; s < count; ++s) { + samples[s].link_valid = true; + samples[s].link_rssi_dbm = pkt.rssi_dbm; + samples[s].link_snr_db = pkt.snr_db; samples[s].short_id = pkt.device_id_short; mqtt_publish_state(samples[s]); } @@ -376,11 +499,28 @@ static void receiver_loop() { g_sender_statuses[i].last_data = samples[count - 1]; g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc; g_sender_statuses[i].has_data = true; + g_sender_faults_remote[i].meter_read_fail = samples[count - 1].err_meter_read; + g_sender_faults_remote[i].lora_tx_fail = samples[count - 1].err_lora_tx; + g_sender_last_error_remote[i] = samples[count - 1].last_error; + g_sender_last_error_remote_utc[i] = time_get_utc(); + g_sender_last_error_remote_ms[i] = millis(); + if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[i]) { + g_sender_discovery_sent[i] = mqtt_publish_discovery(samples[count - 1].device_id); + } + publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[i], g_sender_faults_remote_published[i], + g_sender_last_error_remote[i], g_sender_last_error_remote_published[i], + g_sender_last_error_remote_utc[i], g_sender_last_error_remote_ms[i]); } break; } } + } else { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); } + } else if (decode_error) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); } } } @@ -391,13 +531,22 @@ static void receiver_loop() { } if (!g_ap_mode && millis() - g_last_timesync_ms > interval_sec * 1000UL) { g_last_timesync_ms = millis(); - time_send_timesync(g_short_id); + if (!time_send_timesync(g_short_id)) { + note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); + } } mqtt_loop(); web_server_loop(); + if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) { + g_receiver_discovery_sent = mqtt_publish_discovery(g_device_id); + } + publish_faults_if_needed(g_device_id, g_receiver_faults, g_receiver_faults_published, + g_receiver_last_error, g_receiver_last_error_published, g_receiver_last_error_utc, g_receiver_last_error_ms); display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected()); display_tick(); + watchdog_kick(); } void loop() { @@ -405,6 +554,7 @@ void loop() { if (g_role == DeviceRole::Sender) { test_sender_loop(g_short_id, g_device_id); display_tick(); + watchdog_kick(); delay(50); } else { test_receiver_loop(g_sender_statuses, NUM_SENDERS, g_short_id); @@ -412,6 +562,7 @@ void loop() { web_server_loop(); display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected()); display_tick(); + watchdog_kick(); delay(50); } return; diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index 1917393..cf81cf6 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -1,6 +1,8 @@ #include "mqtt_client.h" #include #include +#include +#include "config.h" #include "json_codec.h" static WiFiClient wifi_client; @@ -52,6 +54,97 @@ bool mqtt_publish_state(const MeterData &data) { return mqtt_client.publish(topic.c_str(), payload.c_str()); } +bool mqtt_publish_faults(const char *device_id, const FaultCounters &counters, FaultType last_error, uint32_t last_error_age_sec) { + if (!device_id || device_id[0] == '\0') { + return false; + } + if (!mqtt_connect()) { + return false; + } + + StaticJsonDocument<192> doc; + doc["err_m"] = counters.meter_read_fail; + doc["err_d"] = counters.decode_fail; + doc["err_tx"] = counters.lora_tx_fail; + if (last_error != FaultType::None) { + doc["err_last"] = static_cast(last_error); + doc["err_last_age"] = last_error_age_sec; + } + + String payload; + size_t len = serializeJson(doc, payload); + if (len == 0) { + return false; + } + String topic = String("smartmeter/") + device_id + "/faults"; + return mqtt_client.publish(topic.c_str(), payload.c_str(), true); +} + +static bool publish_discovery_sensor(const char *device_id, const char *key, const char *name, const char *unit, const char *device_class, + const char *state_topic, const char *value_template) { + StaticJsonDocument<256> doc; + String unique_id = String("dd3_") + device_id + "_" + key; + String sensor_name = String(device_id) + " " + name; + + doc["name"] = sensor_name; + doc["state_topic"] = state_topic; + doc["unique_id"] = unique_id; + if (unit && unit[0] != '\0') { + doc["unit_of_measurement"] = unit; + } + if (device_class && device_class[0] != '\0') { + doc["device_class"] = device_class; + } + doc["value_template"] = value_template; + + JsonObject device = doc.createNestedObject("device"); + JsonArray identifiers = device.createNestedArray("identifiers"); + identifiers.add(String("dd3-") + device_id); + device["name"] = String("DD3 ") + device_id; + device["model"] = "DD3-LoRa-Bridge"; + device["manufacturer"] = "DD3"; + + String payload; + size_t len = serializeJson(doc, payload); + if (len == 0) { + return false; + } + + String topic = String("homeassistant/sensor/") + device_id + "/" + key + "/config"; + return mqtt_client.publish(topic.c_str(), payload.c_str(), true); +} + +bool mqtt_publish_discovery(const char *device_id) { + if (!device_id || device_id[0] == '\0') { + return false; + } + if (!mqtt_connect()) { + return false; + } + + String state_topic = String("smartmeter/") + device_id + "/state"; + bool ok = true; + ok = ok && publish_discovery_sensor(device_id, "energy", "Energy", "kWh", "energy", state_topic.c_str(), "{{ value_json.e_kwh }}"); + ok = ok && publish_discovery_sensor(device_id, "power", "Power", "W", "power", state_topic.c_str(), "{{ value_json.p_w }}"); + ok = ok && publish_discovery_sensor(device_id, "v1", "Voltage L1", "V", "voltage", state_topic.c_str(), "{{ value_json.v1_v }}"); + ok = ok && publish_discovery_sensor(device_id, "v2", "Voltage L2", "V", "voltage", state_topic.c_str(), "{{ value_json.v2_v }}"); + ok = ok && publish_discovery_sensor(device_id, "v3", "Voltage L3", "V", "voltage", state_topic.c_str(), "{{ value_json.v3_v }}"); + ok = ok && publish_discovery_sensor(device_id, "p1", "Power L1", "W", "power", state_topic.c_str(), "{{ value_json.p1_w }}"); + ok = ok && publish_discovery_sensor(device_id, "p2", "Power L2", "W", "power", state_topic.c_str(), "{{ value_json.p2_w }}"); + ok = ok && publish_discovery_sensor(device_id, "p3", "Power L3", "W", "power", state_topic.c_str(), "{{ value_json.p3_w }}"); + ok = ok && publish_discovery_sensor(device_id, "bat_v", "Battery Voltage", "V", "voltage", state_topic.c_str(), "{{ value_json.bat_v }}"); + ok = ok && publish_discovery_sensor(device_id, "bat_pct", "Battery", "%", "battery", state_topic.c_str(), "{{ value_json.bat_pct }}"); + ok = ok && publish_discovery_sensor(device_id, "rssi", "LoRa RSSI", "dBm", "signal_strength", state_topic.c_str(), "{{ value_json.rssi }}"); + ok = ok && publish_discovery_sensor(device_id, "snr", "LoRa SNR", "dB", "", state_topic.c_str(), "{{ value_json.snr }}"); + + String faults_topic = String("smartmeter/") + device_id + "/faults"; + ok = ok && publish_discovery_sensor(device_id, "err_m", "Meter Read Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_m }}"); + ok = ok && publish_discovery_sensor(device_id, "err_d", "Decode Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_d }}"); + ok = ok && publish_discovery_sensor(device_id, "err_tx", "LoRa TX Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_tx }}"); + ok = ok && publish_discovery_sensor(device_id, "err_last_age", "Last Error Age", "s", "", faults_topic.c_str(), "{{ value_json.err_last_age }}"); + return ok; +} + #ifdef ENABLE_TEST_MODE bool mqtt_publish_test(const char *device_id, const String &payload) { if (!mqtt_connect()) { diff --git a/src/time_manager.cpp b/src/time_manager.cpp index 96ae190..9d46f0b 100644 --- a/src/time_manager.cpp +++ b/src/time_manager.cpp @@ -7,6 +7,14 @@ static bool g_time_synced = false; static bool g_tz_set = false; static bool g_rtc_present = false; +static uint32_t g_last_sync_utc = 0; + +static void note_last_sync(uint32_t epoch) { + if (epoch == 0) { + return; + } + g_last_sync_utc = epoch; +} void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2) { const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org"; @@ -24,6 +32,10 @@ uint32_t time_get_utc() { if (now < 1672531200) { return 0; } + if (!g_time_synced) { + g_time_synced = true; + note_last_sync(static_cast(now)); + } return static_cast(now); } @@ -42,16 +54,17 @@ void time_set_utc(uint32_t epoch) { tv.tv_usec = 0; settimeofday(&tv, nullptr); g_time_synced = true; + note_last_sync(epoch); if (g_rtc_present) { rtc_ds3231_set_epoch(epoch); } } -void time_send_timesync(uint16_t device_id_short) { +bool time_send_timesync(uint16_t device_id_short) { uint32_t epoch = time_get_utc(); if (epoch == 0) { - return; + return false; } char payload_str[32]; @@ -60,7 +73,7 @@ void time_send_timesync(uint16_t device_id_short) { uint8_t compressed[LORA_MAX_PAYLOAD]; size_t compressed_len = 0; if (!compressBuffer(reinterpret_cast(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) { - return; + return false; } LoraPacket pkt = {}; @@ -70,7 +83,7 @@ void time_send_timesync(uint16_t device_id_short) { pkt.payload_type = PayloadType::TimeSync; pkt.payload_len = compressed_len; memcpy(pkt.payload, compressed, compressed_len); - lora_send(pkt); + return lora_send(pkt); } bool time_handle_timesync_payload(const uint8_t *payload, size_t len) { @@ -134,3 +147,18 @@ bool time_try_load_from_rtc() { bool time_rtc_present() { return g_rtc_present; } + +uint32_t time_get_last_sync_utc() { + return g_last_sync_utc; +} + +uint32_t time_get_last_sync_age_sec() { + if (!time_is_synced()) { + return 0; + } + if (g_last_sync_utc == 0) { + return 0; + } + uint32_t now = time_get_utc(); + return now > g_last_sync_utc ? now - g_last_sync_utc : 0; +}