Add LoRa telemetry, fault counters, and time sync status

This commit is contained in:
2026-01-30 13:00:16 +01:00
parent 7e3b537e49
commit 8ba7675a1c
13 changed files with 437 additions and 21 deletions

View File

@@ -60,6 +60,8 @@ constexpr uint32_t SENDER_OLED_READ_MS = 10000;
constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000; constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000;
constexpr uint32_t METER_SEND_INTERVAL_MS = 30000; constexpr uint32_t METER_SEND_INTERVAL_MS = 30000;
constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; 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; constexpr uint8_t NUM_SENDERS = 1;
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {

View File

@@ -2,6 +2,19 @@
#include <Arduino.h> #include <Arduino.h>
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 { struct MeterData {
uint32_t ts_utc; uint32_t ts_utc;
uint16_t short_id; uint16_t short_id;
@@ -13,6 +26,13 @@ struct MeterData {
float battery_voltage_v; float battery_voltage_v;
uint8_t battery_percent; uint8_t battery_percent;
bool valid; 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 { struct SenderStatus {

View File

@@ -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_meter(const MeterData &data);
void display_set_last_read(bool ok, uint32_t ts_utc); 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_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_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok);
void display_power_down(); void display_power_down();
void display_tick(); void display_tick();

View File

@@ -5,5 +5,5 @@
bool meterDataToJson(const MeterData &data, String &out_json); bool meterDataToJson(const MeterData &data, String &out_json);
bool jsonToMeterData(const String &json, MeterData &data); 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); bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count);

View File

@@ -12,6 +12,8 @@ struct LoraPacket {
PayloadType payload_type; PayloadType payload_type;
uint8_t payload[LORA_MAX_PAYLOAD]; uint8_t payload[LORA_MAX_PAYLOAD];
size_t payload_len; size_t payload_len;
int16_t rssi_dbm;
float snr_db;
}; };
void lora_init(); void lora_init();

View File

@@ -8,6 +8,8 @@ void mqtt_init(const WifiMqttConfig &config, const char *device_id);
void mqtt_loop(); void mqtt_loop();
bool mqtt_is_connected(); bool mqtt_is_connected();
bool mqtt_publish_state(const MeterData &data); 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 #ifdef ENABLE_TEST_MODE
bool mqtt_publish_test(const char *device_id, const String &payload); bool mqtt_publish_test(const char *device_id, const String &payload);
#endif #endif

View File

@@ -7,9 +7,11 @@ void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2);
uint32_t time_get_utc(); uint32_t time_get_utc();
bool time_is_synced(); bool time_is_synced();
void time_set_utc(uint32_t epoch); 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); 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_get_local_hhmm(char *out, size_t out_len);
void time_rtc_init(); void time_rtc_init();
bool time_try_load_from_rtc(); bool time_try_load_from_rtc();
bool time_rtc_present(); bool time_rtc_present();
uint32_t time_get_last_sync_utc();
uint32_t time_get_last_sync_age_sec();

View File

@@ -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_tx_ts = 0;
static uint32_t g_last_read_ms = 0; static uint32_t g_last_read_ms = 0;
static uint32_t g_last_tx_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 const SenderStatus *g_statuses = nullptr;
static uint8_t g_status_count = 0; 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(); 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) { void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok) {
g_ap_mode = ap_mode; g_ap_mode = ap_mode;
g_wifi_ssid = ssid ? ssid : ""; 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; 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<unsigned long>(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<unsigned long>(age), timeinfo.tm_hour, timeinfo.tm_min);
} else {
display.printf("Sync: %lus ago", static_cast<unsigned long>(age));
}
}
static void render_sender_status() { static void render_sender_status() {
display.clearDisplay(); display.clearDisplay();
display.setCursor(0, 0); display.setCursor(0, 0);
@@ -157,8 +201,13 @@ static void render_sender_status() {
if (strlen(g_test_code) > 0) { if (strlen(g_test_code) > 0) {
display.setCursor(0, 48); display.setCursor(0, 48);
display.printf("Test %s", g_test_code); display.printf("Test %s", g_test_code);
} } else
#endif #endif
{
if (!render_last_error_line(48)) {
render_last_sync_line(48, true);
}
}
display.display(); display.display();
} }
@@ -193,25 +242,40 @@ static void render_receiver_status() {
display.setCursor(0, 24); display.setCursor(0, 24);
display.printf("MQTT: %s", g_mqtt_ok ? "OK" : "RETRY"); 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; uint32_t latest = 0;
bool link_valid = false;
int16_t link_rssi = 0;
float link_snr = 0.0f;
if (g_statuses) { if (g_statuses) {
for (uint8_t i = 0; i < g_status_count; ++i) { for (uint8_t i = 0; i < g_status_count; ++i) {
if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) { if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) {
latest = g_statuses[i].last_update_ts_utc; 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()) { if (latest == 0 || !time_is_synced()) {
display.print("Last upd: --:--"); display.print("Upd --:--");
} else { } else {
time_t t = latest; time_t t = latest;
struct tm timeinfo; struct tm timeinfo;
localtime_r(&t, &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(); display.display();
} }
@@ -258,7 +322,11 @@ static void render_receiver_sender(uint8_t index) {
display.setCursor(0, 48); display.setCursor(0, 48);
display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]); display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]);
display.setCursor(0, 56); display.setCursor(0, 56);
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.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]);
}
display.display(); display.display();
} }

View File

@@ -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) { 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["id"] = short_id_from_device_id(data.device_id);
doc["ts"] = data.ts_utc; doc["ts"] = data.ts_utc;
char buf[16]; char buf[16];
@@ -55,6 +55,23 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
doc["v3_v"] = serialized(buf); doc["v3_v"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.battery_voltage_v); format_float_2(buf, sizeof(buf), data.battery_voltage_v);
doc["bat_v"] = serialized(buf); 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<uint8_t>(data.last_error);
}
out_json = ""; out_json = "";
size_t len = serializeJson(doc, 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) { bool jsonToMeterData(const String &json, MeterData &data) {
StaticJsonDocument<192> doc; StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, json); DeserializationError err = deserializeJson(doc, json);
if (err) { if (err) {
return false; return false;
@@ -99,6 +116,13 @@ bool jsonToMeterData(const String &json, MeterData &data) {
data.battery_percent = doc["bat_pct"] | 0; data.battery_percent = doc["bat_pct"] | 0;
} }
data.valid = true; 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<FaultType>(doc["err_last"] | 0);
if (strlen(data.device_id) >= 8) { if (strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4; const char *suffix = data.device_id + strlen(data.device_id) - 4;
@@ -108,7 +132,7 @@ bool jsonToMeterData(const String &json, MeterData &data) {
return true; 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) { if (!samples || count == 0) {
return false; 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["id"] = short_id_from_device_id(samples[count - 1].device_id);
doc["bat_v"] = round2(samples[count - 1].battery_voltage_v); doc["bat_v"] = round2(samples[count - 1].battery_voltage_v);
doc["bat_pct"] = samples[count - 1].battery_percent; 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<uint8_t>(last_error);
}
JsonArray arr = doc.createNestedArray("s"); JsonArray arr = doc.createNestedArray("s");
for (size_t i = 0; i < count; ++i) { for (size_t i = 0; i < count; ++i) {
JsonArray row = arr.createNestedArray(); 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"] | ""; const char *id = doc["id"] | "";
float bat_v = doc["bat_v"] | NAN; float bat_v = doc["bat_v"] | NAN;
uint8_t bat_pct = doc["bat_pct"] | 0; 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<FaultType>(doc["err_last"] | 0);
size_t idx = 0; size_t idx = 0;
for (JsonArray row : arr) { for (JsonArray row : arr) {
@@ -187,6 +225,13 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou
} else { } else {
data.battery_percent = bat_pct; 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) { if (strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4; const char *suffix = data.device_id + strlen(data.device_id) - 4;

View File

@@ -50,8 +50,8 @@ bool lora_send(const LoraPacket &pkt) {
LoRa.beginPacket(); LoRa.beginPacket();
LoRa.write(buffer, idx); LoRa.write(buffer, idx);
LoRa.endPacket(); int result = LoRa.endPacket();
return true; return result == 1;
} }
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
@@ -91,6 +91,8 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
return false; return false;
} }
memcpy(pkt.payload, &buffer[5], pkt.payload_len); memcpy(pkt.payload, &buffer[5], pkt.payload_len);
pkt.rssi_dbm = static_cast<int16_t>(LoRa.packetRssi());
pkt.snr_db = LoRa.packetSnr();
return true; return true;
} }

View File

@@ -12,6 +12,9 @@
#include "web_server.h" #include "web_server.h"
#include "display_ui.h" #include "display_ui.h"
#include "test_mode.h" #include "test_mode.h"
#ifdef ARDUINO_ARCH_ESP32
#include <esp_task_wdt.h>
#endif
static DeviceRole g_role = DeviceRole::Sender; static DeviceRole g_role = DeviceRole::Sender;
static uint16_t g_short_id = 0; static uint16_t g_short_id = 0;
@@ -23,6 +26,24 @@ static WifiMqttConfig g_cfg;
static uint32_t g_last_timesync_ms = 0; static uint32_t g_last_timesync_ms = 0;
static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000; static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000;
static uint32_t g_boot_ms = 0; 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_HEADER_SIZE = 6;
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE; 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_update_ts_utc = 0;
g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i]; 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]); 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; 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) { static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF); dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF); dst[1] = static_cast<uint8_t>((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); bool ok = lora_send(pkt);
all_ok = all_ok && ok; 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; offset += chunk_len;
delay(10); delay(10);
} }
@@ -149,7 +235,7 @@ static bool send_meter_batch(uint32_t ts_for_display) {
} }
String json; String json;
if (!meterBatchToJson(ordered, count, json)) { if (!meterBatchToJson(ordered, count, json, &g_sender_faults, g_sender_last_error)) {
return false; return false;
} }
@@ -177,7 +263,8 @@ static void reset_batch_rx() {
g_batch_rx.last_rx_ms = 0; 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) { if (pkt.payload_len < BATCH_HEADER_SIZE) {
return false; return false;
} }
@@ -225,10 +312,12 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json) {
static uint8_t decompressed[BATCH_MAX_DECOMPRESSED]; static uint8_t decompressed[BATCH_MAX_DECOMPRESSED];
size_t decompressed_len = 0; size_t decompressed_len = 0;
if (!decompressBuffer(g_batch_rx.buffer, g_batch_rx.received_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { if (!decompressBuffer(g_batch_rx.buffer, g_batch_rx.received_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) {
decode_error = true;
reset_batch_rx(); reset_batch_rx();
return false; return false;
} }
if (decompressed_len >= sizeof(decompressed)) { if (decompressed_len >= sizeof(decompressed)) {
decode_error = true;
reset_batch_rx(); reset_batch_rx();
return false; return false;
} }
@@ -245,6 +334,7 @@ void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(200); delay(200);
watchdog_init();
g_boot_ms = millis(); g_boot_ms = millis();
g_role = detect_role(); g_role = detect_role();
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
@@ -292,6 +382,7 @@ void setup() {
} }
static void sender_loop() { static void sender_loop() {
watchdog_kick();
uint32_t now_ms = millis(); uint32_t now_ms = millis();
if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { 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)); strncpy(data.device_id, g_device_id, sizeof(data.device_id));
bool meter_ok = meter_read(data); 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); read_battery(data);
uint32_t now_utc = time_get_utc(); 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_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; uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due;
if (next_due > now_ms) { if (next_due > now_ms) {
watchdog_kick();
light_sleep_ms(next_due - now_ms); light_sleep_ms(next_due - now_ms);
} }
} }
static void receiver_loop() { static void receiver_loop() {
watchdog_kick();
if (g_last_timesync_ms == 0) { if (g_last_timesync_ms == 0) {
g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TIME_SYNC_OFFSET_MS); 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) { if (pkt.payload_type == PayloadType::MeterData) {
uint8_t decompressed[256]; uint8_t decompressed[256];
size_t decompressed_len = 0; 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)) { 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; return;
} }
decompressed[decompressed_len] = '\0'; decompressed[decompressed_len] = '\0';
MeterData data = {}; MeterData data = {};
if (jsonToMeterData(String(reinterpret_cast<const char *>(decompressed)), data)) { if (jsonToMeterData(String(reinterpret_cast<const char *>(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) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
data.short_id = pkt.device_id_short; data.short_id = pkt.device_id_short;
g_sender_statuses[i].last_data = data; g_sender_statuses[i].last_data = data;
g_sender_statuses[i].last_update_ts_utc = data.ts_utc; g_sender_statuses[i].last_update_ts_utc = data.ts_utc;
g_sender_statuses[i].has_data = true; 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); 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; 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) { } else if (pkt.payload_type == PayloadType::MeterBatch) {
String json; 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]; MeterData samples[METER_BATCH_MAX_SAMPLES];
size_t count = 0; size_t count = 0;
if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
for (size_t s = 0; s < count; ++s) { 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; samples[s].short_id = pkt.device_id_short;
mqtt_publish_state(samples[s]); 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_data = samples[count - 1];
g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc; g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc;
g_sender_statuses[i].has_data = true; 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; 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) { if (!g_ap_mode && millis() - g_last_timesync_ms > interval_sec * 1000UL) {
g_last_timesync_ms = millis(); 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(); mqtt_loop();
web_server_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_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
display_tick(); display_tick();
watchdog_kick();
} }
void loop() { void loop() {
@@ -405,6 +554,7 @@ void loop() {
if (g_role == DeviceRole::Sender) { if (g_role == DeviceRole::Sender) {
test_sender_loop(g_short_id, g_device_id); test_sender_loop(g_short_id, g_device_id);
display_tick(); display_tick();
watchdog_kick();
delay(50); delay(50);
} else { } else {
test_receiver_loop(g_sender_statuses, NUM_SENDERS, g_short_id); test_receiver_loop(g_sender_statuses, NUM_SENDERS, g_short_id);
@@ -412,6 +562,7 @@ void loop() {
web_server_loop(); web_server_loop();
display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected()); display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
display_tick(); display_tick();
watchdog_kick();
delay(50); delay(50);
} }
return; return;

View File

@@ -1,6 +1,8 @@
#include "mqtt_client.h" #include "mqtt_client.h"
#include <WiFi.h> #include <WiFi.h>
#include <PubSubClient.h> #include <PubSubClient.h>
#include <ArduinoJson.h>
#include "config.h"
#include "json_codec.h" #include "json_codec.h"
static WiFiClient wifi_client; 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()); 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<uint8_t>(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 #ifdef ENABLE_TEST_MODE
bool mqtt_publish_test(const char *device_id, const String &payload) { bool mqtt_publish_test(const char *device_id, const String &payload) {
if (!mqtt_connect()) { if (!mqtt_connect()) {

View File

@@ -7,6 +7,14 @@
static bool g_time_synced = false; static bool g_time_synced = false;
static bool g_tz_set = false; static bool g_tz_set = false;
static bool g_rtc_present = 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) { 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"; 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) { if (now < 1672531200) {
return 0; return 0;
} }
if (!g_time_synced) {
g_time_synced = true;
note_last_sync(static_cast<uint32_t>(now));
}
return static_cast<uint32_t>(now); return static_cast<uint32_t>(now);
} }
@@ -42,16 +54,17 @@ void time_set_utc(uint32_t epoch) {
tv.tv_usec = 0; tv.tv_usec = 0;
settimeofday(&tv, nullptr); settimeofday(&tv, nullptr);
g_time_synced = true; g_time_synced = true;
note_last_sync(epoch);
if (g_rtc_present) { if (g_rtc_present) {
rtc_ds3231_set_epoch(epoch); 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(); uint32_t epoch = time_get_utc();
if (epoch == 0) { if (epoch == 0) {
return; return false;
} }
char payload_str[32]; char payload_str[32];
@@ -60,7 +73,7 @@ void time_send_timesync(uint16_t device_id_short) {
uint8_t compressed[LORA_MAX_PAYLOAD]; uint8_t compressed[LORA_MAX_PAYLOAD];
size_t compressed_len = 0; size_t compressed_len = 0;
if (!compressBuffer(reinterpret_cast<const uint8_t *>(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) { if (!compressBuffer(reinterpret_cast<const uint8_t *>(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) {
return; return false;
} }
LoraPacket pkt = {}; LoraPacket pkt = {};
@@ -70,7 +83,7 @@ void time_send_timesync(uint16_t device_id_short) {
pkt.payload_type = PayloadType::TimeSync; pkt.payload_type = PayloadType::TimeSync;
pkt.payload_len = compressed_len; pkt.payload_len = compressed_len;
memcpy(pkt.payload, compressed, 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) { 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() { bool time_rtc_present() {
return g_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;
}