Add LoRa telemetry, fault counters, and time sync status
This commit is contained in:
@@ -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] = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
src/main.cpp
161
src/main.cpp
@@ -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;
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user