Refactor LoRa protocol to batch+ack with ACK-based time bootstrap

This commit is contained in:
2026-02-04 11:57:49 +01:00
parent f08d9a34d3
commit f0503af8c7
20 changed files with 326 additions and 1048 deletions

View File

@@ -1,6 +0,0 @@
#pragma once
#include <Arduino.h>
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);

View File

@@ -7,21 +7,11 @@ enum class DeviceRole : uint8_t {
Receiver = 1 Receiver = 1
}; };
enum class PayloadType : uint8_t {
MeterData = 0,
TestCode = 1,
TimeSync = 2,
MeterBatch = 3,
Ack = 4
};
enum class BatchRetryPolicy : uint8_t { enum class BatchRetryPolicy : uint8_t {
Keep = 0, Keep = 0,
Drop = 1 Drop = 1
}; };
constexpr uint8_t PROTOCOL_VERSION = 1;
// Pin definitions // Pin definitions
constexpr uint8_t PIN_LORA_SCK = 5; constexpr uint8_t PIN_LORA_SCK = 5;
constexpr uint8_t PIN_LORA_MISO = 19; constexpr uint8_t PIN_LORA_MISO = 19;
@@ -57,17 +47,7 @@ constexpr uint8_t LORA_PREAMBLE_LEN = 8;
// Timing // Timing
constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30; constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30;
constexpr uint32_t TIME_SYNC_INTERVAL_SEC = 60; constexpr uint32_t SYNC_REQUEST_INTERVAL_MS = 15000;
constexpr uint32_t TIME_SYNC_SLOW_INTERVAL_SEC = 3600;
constexpr uint32_t TIME_SYNC_FAST_WINDOW_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t SENDER_TIMESYNC_WINDOW_MS = 300;
constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_FAST = 60;
constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_SLOW = 3600;
constexpr uint32_t TIME_SYNC_DRIFT_THRESHOLD_SEC = 10;
constexpr uint32_t TIME_SYNC_BURST_INTERVAL_MS = 10000;
constexpr uint32_t TIME_SYNC_BURST_DURATION_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t TIME_SYNC_ERROR_TIMEOUT_MS = 2UL * 24UL * 60UL * 60UL * 1000UL;
constexpr bool ENABLE_DS3231 = true;
constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000; constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000;
constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL; constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t SENDER_OLED_READ_MS = 10000; constexpr uint32_t SENDER_OLED_READ_MS = 10000;
@@ -107,6 +87,7 @@ constexpr const char *WEB_AUTH_DEFAULT_USER = "admin";
constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin"; constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin";
constexpr uint8_t NUM_SENDERS = 1; constexpr uint8_t NUM_SENDERS = 1;
constexpr uint32_t MIN_ACCEPTED_EPOCH_UTC = 1769904000UL; // 2026-02-01 00:00:00 UTC
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
0xF19C //433mhz sender 0xF19C //433mhz sender
//0x7EB4 //868mhz sender //0x7EB4 //868mhz sender

View File

@@ -6,19 +6,16 @@ enum class FaultType : uint8_t {
None = 0, None = 0,
MeterRead = 1, MeterRead = 1,
Decode = 2, Decode = 2,
LoraTx = 3, LoraTx = 3
TimeSync = 4
}; };
enum class RxRejectReason : uint8_t { enum class RxRejectReason : uint8_t {
None = 0, None = 0,
CrcFail = 1, CrcFail = 1,
BadProtocol = 2, InvalidMsgKind = 2,
WrongRole = 3, LengthMismatch = 3,
WrongPayloadType = 4, DeviceIdMismatch = 4,
LengthMismatch = 5, BatchIdMismatch = 5
DeviceIdMismatch = 6,
BatchIdMismatch = 7
}; };
struct FaultCounters { struct FaultCounters {

View File

@@ -4,7 +4,3 @@
#include "data_model.h" #include "data_model.h"
bool meterDataToJson(const MeterData &data, String &out_json); bool meterDataToJson(const MeterData &data, String &out_json);
bool jsonToMeterData(const String &json, MeterData &data);
bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, 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);

View File

@@ -6,11 +6,14 @@
constexpr size_t LORA_MAX_PAYLOAD = 230; constexpr size_t LORA_MAX_PAYLOAD = 230;
enum class LoraMsgKind : uint8_t {
BatchUp = 0,
AckDown = 1
};
struct LoraPacket { struct LoraPacket {
uint8_t protocol_version; LoraMsgKind msg_kind;
DeviceRole role;
uint16_t device_id_short; uint16_t device_id_short;
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; int16_t rssi_dbm;

View File

@@ -1,8 +0,0 @@
#pragma once
#include <Arduino.h>
bool rtc_ds3231_init();
bool rtc_ds3231_is_present();
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc);
bool rtc_ds3231_set_epoch(uint32_t epoch_utc);

View File

@@ -1,17 +1,11 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include "lora_transport.h"
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);
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);
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_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_utc();
uint32_t time_get_last_sync_age_sec(); uint32_t time_get_last_sync_age_sec();

View File

@@ -1,71 +0,0 @@
#include "compressor.h"
static constexpr uint8_t RLE_MARKER = 0xFF;
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
out_len = 0;
if (!in || !out) {
return false;
}
size_t i = 0;
while (i < in_len) {
uint8_t value = in[i];
size_t run = 1;
while (i + run < in_len && in[i + run] == value && run < 255) {
run++;
}
if (value == RLE_MARKER || run >= 4) {
if (out_len + 3 > out_max) {
return false;
}
out[out_len++] = RLE_MARKER;
out[out_len++] = static_cast<uint8_t>(run);
out[out_len++] = value;
} else {
if (out_len + run > out_max) {
return false;
}
for (size_t j = 0; j < run; ++j) {
out[out_len++] = value;
}
}
i += run;
}
return true;
}
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
out_len = 0;
if (!in || !out) {
return false;
}
size_t i = 0;
while (i < in_len) {
uint8_t value = in[i++];
if (value == RLE_MARKER) {
if (i + 1 >= in_len) {
return false;
}
uint8_t run = in[i++];
uint8_t data = in[i++];
if (out_len + run > out_max) {
return false;
}
for (uint8_t j = 0; j < run; ++j) {
out[out_len++] = data;
}
} else {
if (out_len + 1 > out_max) {
return false;
}
out[out_len++] = value;
}
}
return true;
}

View File

@@ -13,12 +13,8 @@ const char *rx_reject_reason_text(RxRejectReason reason) {
switch (reason) { switch (reason) {
case RxRejectReason::CrcFail: case RxRejectReason::CrcFail:
return "crc_fail"; return "crc_fail";
case RxRejectReason::BadProtocol: case RxRejectReason::InvalidMsgKind:
return "bad_protocol_version"; return "invalid_msg_kind";
case RxRejectReason::WrongRole:
return "wrong_role";
case RxRejectReason::WrongPayloadType:
return "wrong_payload_type";
case RxRejectReason::LengthMismatch: case RxRejectReason::LengthMismatch:
return "length_mismatch"; return "length_mismatch";
case RxRejectReason::DeviceIdMismatch: case RxRejectReason::DeviceIdMismatch:

View File

@@ -188,8 +188,6 @@ static bool render_last_error_line(uint8_t y) {
label = "decode"; label = "decode";
} else if (g_last_error == FaultType::LoraTx) { } else if (g_last_error == FaultType::LoraTx) {
label = "lora"; label = "lora";
} else if (g_last_error == FaultType::TimeSync) {
label = "timesync";
} }
display.setCursor(0, y); display.setCursor(0, y);
display.printf("Err: %s %lus", label, static_cast<unsigned long>(age_seconds(g_last_error_ts, g_last_error_ms))); display.printf("Err: %s %lus", label, static_cast<unsigned long>(age_seconds(g_last_error_ts, g_last_error_ms)));

View File

@@ -2,8 +2,6 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <limits.h> #include <limits.h>
#include <math.h> #include <math.h>
#include "config.h"
#include "power_manager.h"
static float round2(float value) { static float round2(float value) {
if (isnan(value)) { if (isnan(value)) {
@@ -12,20 +10,6 @@ static float round2(float value) {
return roundf(value * 100.0f) / 100.0f; return roundf(value * 100.0f) / 100.0f;
} }
static uint32_t kwh_to_wh(float value) {
if (isnan(value)) {
return 0;
}
double wh = static_cast<double>(value) * 1000.0;
if (wh < 0.0) {
wh = 0.0;
}
if (wh > static_cast<double>(UINT32_MAX)) {
wh = static_cast<double>(UINT32_MAX);
}
return static_cast<uint32_t>(llround(wh));
}
static int32_t round_to_i32(float value) { static int32_t round_to_i32(float value) {
if (isnan(value)) { if (isnan(value)) {
return 0; return 0;
@@ -51,31 +35,6 @@ static const char *short_id_from_device_id(const char *device_id) {
return device_id; return device_id;
} }
static void sender_label_from_short_id(uint16_t short_id, char *out, size_t out_len) {
if (!out || out_len == 0) {
return;
}
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) {
snprintf(out, out_len, "s%02u", static_cast<unsigned>(i + 1));
return;
}
}
snprintf(out, out_len, "s00");
}
static uint16_t short_id_from_sender_label(const char *sender_label) {
if (!sender_label || strlen(sender_label) < 2 || sender_label[0] != 's') {
return 0;
}
char *end = nullptr;
long idx = strtol(sender_label + 1, &end, 10);
if (end == sender_label + 1 || idx <= 0 || idx > NUM_SENDERS) {
return 0;
}
return EXPECTED_SENDER_IDS[idx - 1];
}
static void format_float_2(char *buf, size_t buf_len, float value) { static void format_float_2(char *buf, size_t buf_len, float value) {
if (!buf || buf_len == 0) { if (!buf || buf_len == 0) {
return; return;
@@ -133,193 +92,3 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
size_t len = serializeJson(doc, out_json); size_t len = serializeJson(doc, out_json);
return len > 0 && len < 256; return len > 0 && len < 256;
} }
bool jsonToMeterData(const String &json, MeterData &data) {
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, json);
if (err) {
return false;
}
const char *id = doc["id"] | "";
if (strlen(id) == 4) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%s", id);
} else {
strncpy(data.device_id, id, sizeof(data.device_id));
}
data.device_id[sizeof(data.device_id) - 1] = '\0';
data.ts_utc = doc["ts"] | 0;
data.energy_total_kwh = doc["e_kwh"] | NAN;
data.total_power_w = doc["p_w"] | NAN;
data.phase_power_w[0] = doc["p1_w"] | NAN;
data.phase_power_w[1] = doc["p2_w"] | NAN;
data.phase_power_w[2] = doc["p3_w"] | NAN;
data.battery_voltage_v = doc["bat_v"] | NAN;
if (doc["bat_pct"].isNull() && !isnan(data.battery_voltage_v)) {
data.battery_percent = battery_percent_from_voltage(data.battery_voltage_v);
} else {
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<FaultType>(doc["err_last"] | 0);
data.rx_reject_reason = static_cast<uint8_t>(doc["rx_reject"] | 0);
if (strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4;
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
}
return true;
}
bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, String &out_json, const FaultCounters *faults, FaultType last_error) {
if (!samples || count == 0) {
return false;
}
DynamicJsonDocument doc(8192);
doc["schema"] = 1;
char sender_label[8] = {};
sender_label_from_short_id(samples[count - 1].short_id, sender_label, sizeof(sender_label));
doc["sender"] = sender_label;
doc["batch_id"] = batch_id;
doc["t0"] = samples[0].ts_utc;
doc["t_first"] = samples[0].ts_utc;
doc["t_last"] = samples[count - 1].ts_utc;
uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000;
doc["dt_s"] = dt_s > 0 ? dt_s : 1;
doc["n"] = static_cast<uint32_t>(count);
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;
}
}
doc["err_last"] = static_cast<uint8_t>(last_error);
if (!isnan(samples[count - 1].battery_voltage_v)) {
char bat_buf[16];
format_float_2(bat_buf, sizeof(bat_buf), samples[count - 1].battery_voltage_v);
doc["bat_v"] = serialized(bat_buf);
}
JsonArray energy = doc.createNestedArray("e_wh");
JsonArray p_w = doc.createNestedArray("p_w");
JsonArray p1_w = doc.createNestedArray("p1_w");
JsonArray p2_w = doc.createNestedArray("p2_w");
JsonArray p3_w = doc.createNestedArray("p3_w");
for (size_t i = 0; i < count; ++i) {
energy.add(kwh_to_wh(samples[i].energy_total_kwh));
p_w.add(round_to_i32(samples[i].total_power_w));
p1_w.add(round_to_i32(samples[i].phase_power_w[0]));
p2_w.add(round_to_i32(samples[i].phase_power_w[1]));
p3_w.add(round_to_i32(samples[i].phase_power_w[2]));
}
out_json = "";
size_t len = serializeJson(doc, out_json);
return len > 0;
}
bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count) {
out_count = 0;
if (!out_samples || max_count == 0) {
return false;
}
DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, json);
if (err) {
return false;
}
const char *id = doc["id"] | "";
const char *sender = doc["sender"] | "";
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);
float bat_v = doc["bat_v"] | NAN;
if (!doc["schema"].isNull()) {
if ((doc["schema"] | 0) != 1) {
return false;
}
size_t count = doc["n"] | 0;
if (count == 0) {
return false;
}
if (count > max_count) {
count = max_count;
}
uint32_t t0 = doc["t0"] | 0;
uint32_t t_first = doc["t_first"] | t0;
uint32_t t_last = doc["t_last"] | t_first;
uint32_t dt_s = doc["dt_s"] | 1;
JsonArray energy = doc["e_wh"].as<JsonArray>();
JsonArray p_w = doc["p_w"].as<JsonArray>();
JsonArray p1_w = doc["p1_w"].as<JsonArray>();
JsonArray p2_w = doc["p2_w"].as<JsonArray>();
JsonArray p3_w = doc["p3_w"].as<JsonArray>();
for (size_t idx = 0; idx < count; ++idx) {
MeterData &data = out_samples[idx];
data = {};
uint16_t short_id = short_id_from_sender_label(sender);
if (short_id != 0) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id);
data.short_id = short_id;
} else if (id[0] != '\0') {
strncpy(data.device_id, id, sizeof(data.device_id));
data.device_id[sizeof(data.device_id) - 1] = '\0';
} else {
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
}
if (count > 1 && t_last >= t_first) {
uint32_t span = t_last - t_first;
uint32_t step = span / static_cast<uint32_t>(count - 1);
data.ts_utc = t_first + static_cast<uint32_t>(idx) * step;
} else {
data.ts_utc = t0 + static_cast<uint32_t>(idx) * dt_s;
}
data.energy_total_kwh = static_cast<float>((energy[idx] | 0)) / 1000.0f;
data.total_power_w = static_cast<float>(p_w[idx] | 0);
data.phase_power_w[0] = static_cast<float>(p1_w[idx] | 0);
data.phase_power_w[1] = static_cast<float>(p2_w[idx] | 0);
data.phase_power_w[2] = static_cast<float>(p3_w[idx] | 0);
data.battery_voltage_v = bat_v;
if (!isnan(bat_v)) {
data.battery_percent = battery_percent_from_voltage(bat_v);
} else {
data.battery_percent = 0;
}
data.valid = true;
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 (data.short_id == 0 && strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4;
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
}
}
out_count = count;
return count > 0;
}
return false;
}

View File

@@ -65,13 +65,11 @@ bool lora_send(const LoraPacket &pkt) {
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
t1 = millis(); t1 = millis();
} }
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t idx = 0; size_t idx = 0;
buffer[idx++] = pkt.protocol_version; buffer[idx++] = static_cast<uint8_t>(pkt.msg_kind);
buffer[idx++] = static_cast<uint8_t>(pkt.role);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8); buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF); buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF);
buffer[idx++] = static_cast<uint8_t>(pkt.payload_type);
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
return false; return false;
@@ -111,7 +109,7 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
while (true) { while (true) {
int packet_size = LoRa.parsePacket(); int packet_size = LoRa.parsePacket();
if (packet_size > 0) { if (packet_size > 0) {
if (packet_size < 7) { if (packet_size < 5) {
while (LoRa.available()) { while (LoRa.available()) {
LoRa.read(); LoRa.read();
} }
@@ -119,13 +117,13 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
return false; return false;
} }
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t len = 0; size_t len = 0;
while (LoRa.available() && len < sizeof(buffer)) { while (LoRa.available() && len < sizeof(buffer)) {
buffer[len++] = LoRa.read(); buffer[len++] = LoRa.read();
} }
if (len < 7) { if (len < 5) {
note_reject(RxRejectReason::LengthMismatch); note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
@@ -136,21 +134,20 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
note_reject(RxRejectReason::CrcFail); note_reject(RxRejectReason::CrcFail);
return false; return false;
} }
if (buffer[0] != PROTOCOL_VERSION) { uint8_t msg_kind = buffer[0];
note_reject(RxRejectReason::BadProtocol); if (msg_kind > static_cast<uint8_t>(LoraMsgKind::AckDown)) {
note_reject(RxRejectReason::InvalidMsgKind);
return false; return false;
} }
pkt.protocol_version = buffer[0]; pkt.msg_kind = static_cast<LoraMsgKind>(msg_kind);
pkt.role = static_cast<DeviceRole>(buffer[1]); pkt.device_id_short = static_cast<uint16_t>(buffer[1] << 8) | buffer[2];
pkt.device_id_short = static_cast<uint16_t>(buffer[2] << 8) | buffer[3]; pkt.payload_len = len - 5;
pkt.payload_type = static_cast<PayloadType>(buffer[4]);
pkt.payload_len = len - 7;
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
note_reject(RxRejectReason::LengthMismatch); note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
memcpy(pkt.payload, &buffer[5], pkt.payload_len); memcpy(pkt.payload, &buffer[3], pkt.payload_len);
pkt.rssi_dbm = static_cast<int16_t>(LoRa.packetRssi()); pkt.rssi_dbm = static_cast<int16_t>(LoRa.packetRssi());
pkt.snr_db = LoRa.packetSnr(); pkt.snr_db = LoRa.packetSnr();
return true; return true;

View File

@@ -1,9 +1,7 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "data_model.h" #include "data_model.h"
#include "json_codec.h"
#include "payload_codec.h" #include "payload_codec.h"
#include "compressor.h"
#include "lora_transport.h" #include "lora_transport.h"
#include "meter_driver.h" #include "meter_driver.h"
#include "power_manager.h" #include "power_manager.h"
@@ -28,8 +26,6 @@ static char g_device_id[16] = "";
static SenderStatus g_sender_statuses[NUM_SENDERS]; static SenderStatus g_sender_statuses[NUM_SENDERS];
static bool g_ap_mode = false; static bool g_ap_mode = false;
static WifiMqttConfig g_cfg; 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 uint32_t g_boot_ms = 0;
static FaultCounters g_sender_faults = {}; static FaultCounters g_sender_faults = {};
static FaultCounters g_receiver_faults = {}; static FaultCounters g_receiver_faults = {};
@@ -50,18 +46,6 @@ static uint32_t g_sender_last_error_remote_ms[NUM_SENDERS] = {};
static bool g_sender_discovery_sent[NUM_SENDERS] = {}; static bool g_sender_discovery_sent[NUM_SENDERS] = {};
static bool g_receiver_discovery_sent = false; static bool g_receiver_discovery_sent = false;
struct TimeSyncBurstState {
bool active;
uint32_t start_ms;
uint32_t last_send_ms;
uint32_t last_drift_check_ms;
bool last_drift_ok;
};
static TimeSyncBurstState g_timesync_burst[NUM_SENDERS] = {};
static uint32_t g_sender_last_timesync_rx_ms = 0;
static bool g_sender_timesync_error = 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;
static constexpr size_t BATCH_MAX_COMPRESSED = 4096; static constexpr size_t BATCH_MAX_COMPRESSED = 4096;
@@ -99,21 +83,19 @@ static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES];
static uint8_t g_inflight_count = 0; static uint8_t g_inflight_count = 0;
static uint16_t g_inflight_batch_id = 0; static uint16_t g_inflight_batch_id = 0;
static bool g_inflight_active = false; static bool g_inflight_active = false;
static bool g_inflight_sync_request = false;
static uint32_t g_last_debug_log_ms = 0; static uint32_t g_last_debug_log_ms = 0;
static uint32_t g_sender_last_timesync_check_ms = 0;
static uint32_t g_sender_rx_window_ms = 0; static uint32_t g_sender_rx_window_ms = 0;
static uint32_t g_sender_sleep_ms = 0; static uint32_t g_sender_sleep_ms = 0;
static uint32_t g_sender_power_log_ms = 0; static uint32_t g_sender_power_log_ms = 0;
static uint8_t g_sender_timesync_mode = 0;
static RxRejectReason g_sender_rx_reject_reason = RxRejectReason::None; static RxRejectReason g_sender_rx_reject_reason = RxRejectReason::None;
static uint32_t g_sender_rx_reject_log_ms = 0; static uint32_t g_sender_rx_reject_log_ms = 0;
static MeterData g_last_meter_data = {}; static MeterData g_last_meter_data = {};
static bool g_last_meter_valid = false; static bool g_last_meter_valid = false;
static uint32_t g_last_meter_rx_ms = 0; static uint32_t g_last_meter_rx_ms = 0;
static uint32_t g_meter_stale_seconds = 0; static uint32_t g_meter_stale_seconds = 0;
static constexpr uint32_t SENDER_TIMESYNC_ACQUIRE_MS = 10UL * 60UL * 1000UL; static bool g_time_acquired = false;
static constexpr uint32_t SENDER_TIMESYNC_ACQUIRE_INTERVAL_SEC = 20; static uint32_t g_last_sync_request_ms = 0;
static constexpr uint32_t SENDER_TIMESYNC_ACQUIRE_WINDOW_MS = 3000;
static void watchdog_kick(); static void watchdog_kick();
@@ -129,17 +111,6 @@ static void serial_debug_printf(const char *fmt, ...) {
Serial.println(buf); Serial.println(buf);
} }
static void sender_set_timesync_mode(uint8_t mode) {
if (g_sender_timesync_mode == mode) {
return;
}
g_sender_timesync_mode = mode;
if (SERIAL_DEBUG_MODE) {
const char *label = mode == 2 ? "acquire" : (mode == 1 ? "slow" : "fast");
serial_debug_printf("timesync: mode=%s", label);
}
}
static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {}; static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {};
struct BatchRxState { struct BatchRxState {
@@ -185,37 +156,6 @@ static bool battery_sample_due(uint32_t now_ms) {
return g_last_battery_ms == 0 || now_ms - g_last_battery_ms >= BATTERY_SAMPLE_INTERVAL_MS; return g_last_battery_ms == 0 || now_ms - g_last_battery_ms >= BATTERY_SAMPLE_INTERVAL_MS;
} }
static bool sender_timesync_window_due() {
uint32_t interval_sec = SENDER_TIMESYNC_CHECK_SEC_FAST;
bool in_acquire = (g_sender_last_timesync_rx_ms == 0) && (millis() - g_boot_ms < SENDER_TIMESYNC_ACQUIRE_MS);
bool allow_slow = (millis() - g_boot_ms >= 60000UL) && time_is_synced() && time_rtc_present() &&
(g_sender_last_timesync_rx_ms > 0);
// RTC boot time is not evidence of receiving network TimeSync.
if (in_acquire) {
interval_sec = SENDER_TIMESYNC_ACQUIRE_INTERVAL_SEC;
sender_set_timesync_mode(2);
} else if (allow_slow) {
interval_sec = SENDER_TIMESYNC_CHECK_SEC_SLOW;
sender_set_timesync_mode(1);
} else {
sender_set_timesync_mode(0);
}
static uint32_t last_interval_sec = 0;
if (last_interval_sec != interval_sec) {
last_interval_sec = interval_sec;
g_sender_last_timesync_check_ms = millis();
}
if (g_sender_last_timesync_check_ms == 0) {
g_sender_last_timesync_check_ms = millis() - interval_sec * 1000UL;
}
uint32_t now_ms = millis();
if (now_ms - g_sender_last_timesync_check_ms >= interval_sec * 1000UL) {
g_sender_last_timesync_check_ms = now_ms;
return true;
}
return false;
}
static bool batch_queue_drop_oldest() { static bool batch_queue_drop_oldest() {
if (g_batch_count == 0) { if (g_batch_count == 0) {
return false; return false;
@@ -228,33 +168,13 @@ static bool batch_queue_drop_oldest() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
} }
g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH; g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH;
g_batch_count--; g_batch_count--;
return dropped_inflight; return dropped_inflight;
} }
static void receiver_note_timesync_drift(uint8_t sender_idx, uint32_t sender_ts_utc) {
if (sender_idx >= NUM_SENDERS) {
return;
}
if (!time_is_synced() || sender_ts_utc == 0) {
return;
}
uint32_t now_utc = time_get_utc();
uint32_t diff = now_utc > sender_ts_utc ? now_utc - sender_ts_utc : sender_ts_utc - now_utc;
TimeSyncBurstState &state = g_timesync_burst[sender_idx];
state.last_drift_check_ms = millis();
state.last_drift_ok = diff <= TIME_SYNC_DRIFT_THRESHOLD_SEC;
if (!state.last_drift_ok) {
if (!state.active) {
state.active = true;
state.start_ms = millis();
state.last_send_ms = 0;
}
}
}
static void sender_note_rx_reject(RxRejectReason reason, const char *context) { static void sender_note_rx_reject(RxRejectReason reason, const char *context) {
if (reason == RxRejectReason::None) { if (reason == RxRejectReason::None) {
return; return;
@@ -370,6 +290,29 @@ static uint16_t read_u16_le(const uint8_t *src) {
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8); return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
} }
static void write_u16_be(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[1] = static_cast<uint8_t>(value & 0xFF);
}
static uint16_t read_u16_be(const uint8_t *src) {
return static_cast<uint16_t>(src[0] << 8) | static_cast<uint16_t>(src[1]);
}
static void write_u32_be(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>((value >> 24) & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[3] = static_cast<uint8_t>(value & 0xFF);
}
static uint32_t read_u32_be(const uint8_t *src) {
return (static_cast<uint32_t>(src[0]) << 24) |
(static_cast<uint32_t>(src[1]) << 16) |
(static_cast<uint32_t>(src[2]) << 8) |
static_cast<uint32_t>(src[3]);
}
static uint16_t sender_id_from_short_id(uint16_t short_id) { static uint16_t sender_id_from_short_id(uint16_t short_id) {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) { if (EXPECTED_SENDER_IDS[i] == short_id) {
@@ -433,7 +376,7 @@ static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_co
} }
size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len; size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len;
size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload; size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload;
size_t packet_len = 5 + payload_len + 2; size_t packet_len = 3 + payload_len + 2;
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
return timeout_ms < 10000 ? 10000 : timeout_ms; return timeout_ms < 10000 ? 10000 : timeout_ms;
@@ -444,7 +387,7 @@ static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) {
return 10000; return 10000;
} }
uint8_t chunk_count = static_cast<uint8_t>((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); uint8_t chunk_count = static_cast<uint8_t>((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD);
size_t packet_len = 5 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2; size_t packet_len = 3 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2;
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
return timeout_ms < 10000 ? 10000 : timeout_ms; return timeout_ms < 10000 ? 10000 : timeout_ms;
@@ -467,10 +410,8 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_
chunk_len = BATCH_CHUNK_PAYLOAD; chunk_len = BATCH_CHUNK_PAYLOAD;
} }
LoraPacket pkt = {}; LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION; pkt.msg_kind = LoraMsgKind::BatchUp;
pkt.role = DeviceRole::Sender;
pkt.device_id_short = g_short_id; pkt.device_id_short = g_short_id;
pkt.payload_type = PayloadType::MeterBatch;
pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; pkt.payload_len = chunk_len + BATCH_HEADER_SIZE;
uint8_t *payload = pkt.payload; uint8_t *payload = pkt.payload;
@@ -501,27 +442,31 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_
return all_ok; return all_ok;
} }
static void send_batch_ack(uint16_t batch_id, uint16_t sender_id) { static void send_batch_ack(uint16_t batch_id, uint8_t sample_count) {
uint32_t epoch = time_get_utc();
uint8_t time_valid = (time_is_synced() && epoch >= MIN_ACCEPTED_EPOCH_UTC) ? 1 : 0;
if (!time_valid) {
epoch = 0;
}
LoraPacket ack = {}; LoraPacket ack = {};
ack.protocol_version = PROTOCOL_VERSION; ack.msg_kind = LoraMsgKind::AckDown;
ack.role = DeviceRole::Receiver;
ack.device_id_short = g_short_id; ack.device_id_short = g_short_id;
ack.payload_type = PayloadType::Ack; ack.payload_len = 7;
ack.payload_len = 6; ack.payload[0] = time_valid;
write_u16_le(&ack.payload[0], batch_id); write_u16_be(&ack.payload[1], batch_id);
write_u16_le(&ack.payload[2], sender_id); write_u32_be(&ack.payload[3], epoch);
write_u16_le(&ack.payload[4], g_short_id);
uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT; uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT;
for (uint8_t i = 0; i < repeats; ++i) { for (uint8_t i = 0; i < repeats; ++i) {
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: tx repeat %u/%u batch_id=%u", static_cast<unsigned>(i + 1),
static_cast<unsigned>(repeats), batch_id);
}
lora_send(ack); lora_send(ack);
if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) { if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) {
delay(ACK_REPEAT_DELAY_MS); delay(ACK_REPEAT_DELAY_MS);
} }
} }
serial_debug_printf("ack: tx batch_id=%u time_valid=%u epoch=%lu samples=%u",
batch_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(epoch),
static_cast<unsigned>(sample_count));
lora_receive_continuous(); lora_receive_continuous();
} }
@@ -547,23 +492,25 @@ static bool prepare_inflight_from_queue() {
} }
static bool send_inflight_batch(uint32_t ts_for_display) { static bool send_inflight_batch(uint32_t ts_for_display) {
if (!g_inflight_active || g_inflight_count == 0) { if (!g_inflight_active) {
return false; return false;
} }
BatchInput input = {}; BatchInput input = {};
input.sender_id = sender_id_from_short_id(g_short_id); input.sender_id = sender_id_from_short_id(g_short_id);
input.batch_id = g_inflight_batch_id; input.batch_id = g_inflight_batch_id;
input.t_last = g_inflight_samples[g_inflight_count - 1].ts_utc; input.t_last = g_inflight_sync_request ? time_get_utc() :
g_inflight_samples[g_inflight_count - 1].ts_utc;
uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000; uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000;
input.dt_s = dt_s > 0 ? static_cast<uint8_t>(dt_s) : 1; input.dt_s = dt_s > 0 ? static_cast<uint8_t>(dt_s) : 1;
input.n = g_inflight_count; input.n = g_inflight_sync_request ? 0 : g_inflight_count;
input.battery_mV = battery_mv_from_voltage(g_inflight_samples[g_inflight_count - 1].battery_voltage_v); input.battery_mV = g_inflight_sync_request ? battery_mv_from_voltage(g_last_battery_voltage_v) :
battery_mv_from_voltage(g_inflight_samples[g_inflight_count - 1].battery_voltage_v);
input.err_m = g_sender_faults.meter_read_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.meter_read_fail); input.err_m = g_sender_faults.meter_read_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.meter_read_fail);
input.err_d = g_sender_faults.decode_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.decode_fail); input.err_d = g_sender_faults.decode_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.decode_fail);
input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.lora_tx_fail); input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.lora_tx_fail);
input.err_last = static_cast<uint8_t>(g_sender_last_error); input.err_last = static_cast<uint8_t>(g_sender_last_error);
input.err_rx_reject = static_cast<uint8_t>(g_sender_rx_reject_reason); input.err_rx_reject = static_cast<uint8_t>(g_sender_rx_reject_reason);
for (uint8_t i = 0; i < g_inflight_count; ++i) { for (uint8_t i = 0; i < input.n; ++i) {
input.energy_wh[i] = kwh_to_wh_from_float(g_inflight_samples[i].energy_total_kwh); input.energy_wh[i] = kwh_to_wh_from_float(g_inflight_samples[i].energy_total_kwh);
if (!float_to_i16_w(g_inflight_samples[i].phase_power_w[0], input.p1_w[i]) || if (!float_to_i16_w(g_inflight_samples[i].phase_power_w[0], input.p1_w[i]) ||
!float_to_i16_w(g_inflight_samples[i].phase_power_w[1], input.p2_w[i]) || !float_to_i16_w(g_inflight_samples[i].phase_power_w[1], input.p2_w[i]) ||
@@ -580,7 +527,7 @@ static bool send_inflight_batch(uint32_t ts_for_display) {
} }
uint32_t encode_ms = millis() - encode_start; uint32_t encode_ms = millis() - encode_start;
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
serial_debug_printf("tx: batch_id=%u count=%u bin_len=%u", g_inflight_batch_id, g_inflight_count, serial_debug_printf("tx: batch_id=%u count=%u bin_len=%u", g_inflight_batch_id, input.n,
static_cast<unsigned>(encoded_len)); static_cast<unsigned>(encoded_len));
if (encode_ms > 200) { if (encode_ms > 200) {
serial_debug_printf("tx: encode took %lums", static_cast<unsigned long>(encode_ms)); serial_debug_printf("tx: encode took %lums", static_cast<unsigned long>(encode_ms));
@@ -596,10 +543,18 @@ static bool send_inflight_batch(uint32_t ts_for_display) {
} }
if (ok) { if (ok) {
g_last_batch_send_ms = millis(); g_last_batch_send_ms = millis();
if (g_inflight_sync_request) {
serial_debug_printf("sync: request tx batch_id=%u", g_inflight_batch_id);
} else {
serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast<unsigned>(encoded_len)); serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast<unsigned>(encoded_len));
}
} else {
if (g_inflight_sync_request) {
serial_debug_printf("sync: request tx failed batch_id=%u", g_inflight_batch_id);
} else { } else {
serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id); serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id);
} }
}
return ok; return ok;
} }
@@ -607,6 +562,7 @@ static bool send_meter_batch(uint32_t ts_for_display) {
if (!prepare_inflight_from_queue()) { if (!prepare_inflight_from_queue()) {
return false; return false;
} }
g_inflight_sync_request = false;
bool ok = send_inflight_batch(ts_for_display); bool ok = send_inflight_batch(ts_for_display);
if (ok) { if (ok) {
g_last_sent_batch_id = g_inflight_batch_id; g_last_sent_batch_id = g_inflight_batch_id;
@@ -615,12 +571,36 @@ static bool send_meter_batch(uint32_t ts_for_display) {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
}
return ok;
}
static bool send_sync_request() {
if (g_batch_ack_pending) {
return false;
}
if (battery_sample_due(millis())) {
update_battery_cache();
}
g_inflight_active = true;
g_inflight_sync_request = true;
g_inflight_count = 0;
g_inflight_batch_id = g_batch_id;
bool ok = send_inflight_batch(time_get_utc());
if (ok) {
g_last_sent_batch_id = g_inflight_batch_id;
g_batch_ack_pending = true;
} else {
g_inflight_active = false;
g_inflight_sync_request = false;
g_inflight_batch_id = 0;
} }
return ok; return ok;
} }
static bool resend_inflight_batch(uint32_t ts_for_display) { static bool resend_inflight_batch(uint32_t ts_for_display) {
if (!g_batch_ack_pending || !g_inflight_active || g_inflight_count == 0) { if (!g_batch_ack_pending || !g_inflight_active || (!g_inflight_sync_request && g_inflight_count == 0)) {
return false; return false;
} }
return send_inflight_batch(ts_for_display); return send_inflight_batch(ts_for_display);
@@ -635,6 +615,7 @@ static void finish_inflight_batch() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
g_batch_id++; g_batch_id++;
} }
@@ -731,8 +712,6 @@ void setup() {
lora_init(); lora_init();
display_init(); display_init();
time_rtc_init();
time_try_load_from_rtc();
display_set_self_ids(g_short_id, g_device_id); display_set_self_ids(g_short_id, g_device_id);
if (g_role == DeviceRole::Sender) { if (g_role == DeviceRole::Sender) {
@@ -741,6 +720,8 @@ void setup() {
meter_init(); meter_init();
g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS;
g_last_send_ms = millis(); g_last_send_ms = millis();
g_last_sync_request_ms = millis() - SYNC_REQUEST_INTERVAL_MS;
g_time_acquired = false;
update_battery_cache(); update_battery_cache();
} else { } else {
power_receiver_init(); power_receiver_init();
@@ -794,6 +775,7 @@ static void sender_loop() {
g_batch_retry_count); g_batch_retry_count);
} }
if (g_time_acquired) {
const char *frame = nullptr; const char *frame = nullptr;
size_t frame_len = 0; size_t frame_len = 0;
if (meter_poll_frame(frame, frame_len)) { if (meter_poll_frame(frame, frame_len)) {
@@ -840,9 +822,7 @@ static void sender_loop() {
data.battery_voltage_v = g_last_battery_voltage_v; data.battery_voltage_v = g_last_battery_voltage_v;
data.battery_percent = g_last_battery_percent; data.battery_percent = g_last_battery_percent;
data.rx_reject_reason = static_cast<uint8_t>(g_sender_rx_reject_reason); data.rx_reject_reason = static_cast<uint8_t>(g_sender_rx_reject_reason);
data.ts_utc = time_get_utc();
uint32_t now_utc = time_get_utc();
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
data.valid = meter_ok; data.valid = meter_ok;
g_last_sample_ts_utc = data.ts_utc; g_last_sample_ts_utc = data.ts_utc;
@@ -859,10 +839,16 @@ static void sender_loop() {
g_last_send_ms = now_ms; g_last_send_ms = now_ms;
send_meter_batch(last_sample_ts()); send_meter_batch(last_sample_ts());
} }
} else {
if (!g_batch_ack_pending && now_ms - g_last_sync_request_ms >= SYNC_REQUEST_INTERVAL_MS) {
g_last_sync_request_ms = now_ms;
send_sync_request();
}
}
if (g_batch_ack_pending) { if (g_batch_ack_pending) {
LoraPacket ack_pkt = {}; LoraPacket ack_pkt = {};
const uint32_t ack_len = 5 + 6 + 2; const uint32_t ack_len = 3 + 7 + 2;
uint32_t ack_air_ms = lora_airtime_ms(ack_len); uint32_t ack_air_ms = lora_airtime_ms(ack_len);
uint32_t ack_window_ms = ack_air_ms + 300; uint32_t ack_window_ms = ack_air_ms + 300;
if (ack_window_ms < 1200) { if (ack_window_ms < 1200) {
@@ -886,74 +872,35 @@ static void sender_loop() {
} }
if (!got_ack) { if (!got_ack) {
sender_note_rx_reject(lora_get_last_rx_reject_reason(), "ack"); sender_note_rx_reject(lora_get_last_rx_reject_reason(), "ack");
} else if (ack_pkt.role != DeviceRole::Receiver) { } else if (ack_pkt.msg_kind != LoraMsgKind::AckDown) {
sender_note_rx_reject(RxRejectReason::WrongRole, "ack"); sender_note_rx_reject(RxRejectReason::InvalidMsgKind, "ack");
} else if (ack_pkt.payload_type != PayloadType::Ack) { } else if (ack_pkt.payload_len < 7) {
sender_note_rx_reject(RxRejectReason::WrongPayloadType, "ack");
} else if (ack_pkt.payload_len < 6) {
sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack"); sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack");
} else { } else {
uint16_t ack_id = read_u16_le(ack_pkt.payload); uint8_t time_valid = ack_pkt.payload[0] & 0x01;
uint16_t ack_sender = read_u16_le(&ack_pkt.payload[2]); uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]);
uint16_t ack_receiver = read_u16_le(&ack_pkt.payload[4]); uint32_t ack_epoch = read_u32_be(&ack_pkt.payload[3]);
if (ack_sender == g_short_id && ack_receiver == ack_pkt.device_id_short && bool set_time = false;
g_batch_ack_pending && ack_id == g_last_sent_batch_id) { if (g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) {
time_set_utc(ack_epoch);
g_time_acquired = true;
set_time = true;
}
g_last_acked_batch_id = ack_id; g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: rx ok batch_id=%u", ack_id); serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u",
ack_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(ack_epoch),
set_time ? 1 : 0);
finish_inflight_batch(); finish_inflight_batch();
} else { } else {
if (ack_sender != g_short_id || ack_receiver != ack_pkt.device_id_short) { if (ack_id != g_last_sent_batch_id) {
sender_note_rx_reject(RxRejectReason::DeviceIdMismatch, "ack");
} else if (ack_id != g_last_sent_batch_id) {
sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack"); sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack");
} }
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: reject batch_id=%u sender=%u receiver=%u exp_batch=%u exp_sender=%u",
ack_id, ack_sender, ack_receiver, g_last_sent_batch_id, g_short_id);
} }
} }
} }
}
bool timesync_due = (!g_batch_ack_pending && sender_timesync_window_due());
if (timesync_due) {
LoraPacket rx = {};
uint32_t rx_start = millis();
uint32_t window_ms = (g_sender_timesync_mode == 2) ? SENDER_TIMESYNC_ACQUIRE_WINDOW_MS : SENDER_TIMESYNC_WINDOW_MS;
bool got = lora_receive_window(rx, window_ms);
uint32_t rx_elapsed = millis() - rx_start;
if (SERIAL_DEBUG_MODE) {
g_sender_rx_window_ms += rx_elapsed;
}
if (!got) {
sender_note_rx_reject(lora_get_last_rx_reject_reason(), "timesync");
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("timesync: rx miss window_ms=%lu", static_cast<unsigned long>(window_ms));
}
} else if (rx.role != DeviceRole::Receiver) {
sender_note_rx_reject(RxRejectReason::WrongRole, "timesync");
} else if (rx.payload_type != PayloadType::TimeSync) {
sender_note_rx_reject(RxRejectReason::WrongPayloadType, "timesync");
} else if (time_handle_timesync_payload(rx.payload, rx.payload_len)) {
g_sender_last_timesync_rx_ms = now_ms;
if (g_sender_timesync_error) {
g_sender_timesync_error = false;
display_set_last_error(FaultType::None, 0, 0);
}
serial_debug_printf("timesync: rx ok window_ms=%lu", static_cast<unsigned long>(window_ms));
} else {
sender_note_rx_reject(RxRejectReason::LengthMismatch, "timesync");
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("timesync: rx miss window_ms=%lu", static_cast<unsigned long>(window_ms));
}
}
}
uint32_t timesync_age_ms = (g_sender_last_timesync_rx_ms > 0) ? (now_ms - g_sender_last_timesync_rx_ms)
: (now_ms - g_boot_ms);
if (!g_sender_timesync_error && timesync_age_ms > TIME_SYNC_ERROR_TIMEOUT_MS) {
g_sender_timesync_error = true;
display_set_last_error(FaultType::TimeSync, time_get_utc(), now_ms);
}
if (!g_batch_ack_pending) { if (!g_batch_ack_pending) {
lora_sleep(); lora_sleep();
} }
@@ -974,6 +921,7 @@ static void sender_loop() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
} }
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); 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); display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
@@ -982,9 +930,14 @@ static void sender_loop() {
display_tick(); display_tick();
uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS; uint32_t next_due = g_time_acquired ? (g_last_sample_ms + METER_SAMPLE_INTERVAL_MS) :
(g_last_sync_request_ms + SYNC_REQUEST_INTERVAL_MS);
if (g_time_acquired) {
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; if (next_send_due < next_due) {
next_due = next_send_due;
}
}
if (!g_batch_ack_pending && next_due > now_ms) { if (!g_batch_ack_pending && next_due > now_ms) {
watchdog_kick(); watchdog_kick();
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
@@ -1002,64 +955,13 @@ static void sender_loop() {
static void receiver_loop() { static void receiver_loop() {
watchdog_kick(); watchdog_kick();
if (g_last_timesync_ms == 0) {
g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TIME_SYNC_OFFSET_MS);
}
LoraPacket pkt = {}; LoraPacket pkt = {};
if (lora_receive(pkt, 0) && pkt.protocol_version == PROTOCOL_VERSION) { if (lora_receive(pkt, 0)) {
if (pkt.payload_type == PayloadType::MeterData) { if (pkt.msg_kind == LoraMsgKind::BatchUp) {
uint8_t decompressed[256];
size_t decompressed_len = 0;
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<const char *>(decompressed)), data)) {
data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db;
sd_logger_log_sample(data, data.last_error != FaultType::None);
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;
receiver_note_timesync_drift(i, data.ts_utc);
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) {
BatchInput batch = {}; BatchInput batch = {};
bool decode_error = false; bool decode_error = false;
uint16_t batch_id = 0; uint16_t batch_id = 0;
if (process_batch_packet(pkt, batch, decode_error, batch_id)) { if (process_batch_packet(pkt, batch, decode_error, batch_id)) {
MeterData samples[METER_BATCH_MAX_SAMPLES];
size_t count = 0;
int8_t sender_idx = -1; int8_t sender_idx = -1;
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]) {
@@ -1067,29 +969,43 @@ static void receiver_loop() {
break; break;
} }
} }
bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id; bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id;
send_batch_ack(batch_id, batch.n);
if (duplicate) { if (duplicate) {
send_batch_ack(batch_id, pkt.device_id_short); goto receiver_loop_done;
} else { }
if (sender_idx >= 0) {
g_last_batch_id_rx[sender_idx] = batch_id; g_last_batch_id_rx[sender_idx] = batch_id;
send_batch_ack(batch_id, pkt.device_id_short); }
count = batch.n; if (batch.n == 0) {
if (count == 0 || count > METER_BATCH_MAX_SAMPLES) { goto receiver_loop_done;
}
if (batch.n > METER_BATCH_MAX_SAMPLES) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); 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); display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
return; goto receiver_loop_done;
} }
size_t count = batch.n;
uint16_t short_id = pkt.device_id_short; uint16_t short_id = pkt.device_id_short;
if (short_id == 0) { if (short_id == 0) {
short_id = short_id_from_sender_id(batch.sender_id); short_id = short_id_from_sender_id(batch.sender_id);
} }
uint64_t span = static_cast<uint64_t>(batch.dt_s) * static_cast<uint64_t>(count - 1); uint64_t span = static_cast<uint64_t>(batch.dt_s) * static_cast<uint64_t>(count - 1);
if (batch.t_last < span) { if (batch.t_last < span || batch.t_last < MIN_ACCEPTED_EPOCH_UTC) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); 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); display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
return; goto receiver_loop_done;
} }
uint32_t t_first = batch.t_last - static_cast<uint32_t>(span); uint32_t t_first = batch.t_last - static_cast<uint32_t>(span);
if (t_first < MIN_ACCEPTED_EPOCH_UTC) {
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);
goto receiver_loop_done;
}
MeterData samples[METER_BATCH_MAX_SAMPLES];
float bat_v = batch.battery_mV > 0 ? static_cast<float>(batch.battery_mV) / 1000.0f : NAN; float bat_v = batch.battery_mV > 0 ? static_cast<float>(batch.battery_mV) / 1000.0f : NAN;
for (size_t s = 0; s < count; ++s) { for (size_t s = 0; s < count; ++s) {
MeterData &data = samples[s]; MeterData &data = samples[s];
@@ -1107,11 +1023,7 @@ static void receiver_loop() {
data.phase_power_w[2] = static_cast<float>(batch.p3_w[s]); data.phase_power_w[2] = static_cast<float>(batch.p3_w[s]);
data.total_power_w = data.phase_power_w[0] + data.phase_power_w[1] + data.phase_power_w[2]; data.total_power_w = data.phase_power_w[0] + data.phase_power_w[1] + data.phase_power_w[2];
data.battery_voltage_v = bat_v; data.battery_voltage_v = bat_v;
if (!isnan(bat_v)) { data.battery_percent = !isnan(bat_v) ? battery_percent_from_voltage(bat_v) : 0;
data.battery_percent = battery_percent_from_voltage(bat_v);
} else {
data.battery_percent = 0;
}
data.valid = true; data.valid = true;
data.link_valid = true; data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm; data.link_rssi_dbm = pkt.rssi_dbm;
@@ -1127,16 +1039,13 @@ static void receiver_loop() {
if (sender_idx >= 0) { if (sender_idx >= 0) {
web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count); web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count);
for (size_t s = 0; s < count; ++s) { for (size_t s = 0; s < count; ++s) {
samples[s].short_id = pkt.device_id_short;
mqtt_publish_state(samples[s]); mqtt_publish_state(samples[s]);
} }
if (count > 0) {
g_sender_statuses[sender_idx].last_data = samples[count - 1]; g_sender_statuses[sender_idx].last_data = samples[count - 1];
g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc; g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc;
g_sender_statuses[sender_idx].has_data = true; g_sender_statuses[sender_idx].has_data = true;
g_sender_faults_remote[sender_idx].meter_read_fail = samples[count - 1].err_meter_read; g_sender_faults_remote[sender_idx].meter_read_fail = samples[count - 1].err_meter_read;
g_sender_faults_remote[sender_idx].lora_tx_fail = samples[count - 1].err_lora_tx; g_sender_faults_remote[sender_idx].lora_tx_fail = samples[count - 1].err_lora_tx;
receiver_note_timesync_drift(static_cast<uint8_t>(sender_idx), samples[count - 1].ts_utc);
g_sender_last_error_remote[sender_idx] = samples[count - 1].last_error; g_sender_last_error_remote[sender_idx] = samples[count - 1].last_error;
g_sender_last_error_remote_utc[sender_idx] = time_get_utc(); g_sender_last_error_remote_utc[sender_idx] = time_get_utc();
g_sender_last_error_remote_ms[sender_idx] = millis(); g_sender_last_error_remote_ms[sender_idx] = millis();
@@ -1147,8 +1056,6 @@ static void receiver_loop() {
g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx], g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx],
g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]); g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]);
} }
}
}
} else if (decode_error) { } 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); 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); display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
@@ -1156,42 +1063,7 @@ static void receiver_loop() {
} }
} }
uint32_t interval_sec = TIME_SYNC_INTERVAL_SEC; receiver_loop_done:
uint32_t now_ms = millis();
if (!g_ap_mode) {
bool burst_sent = false;
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
TimeSyncBurstState &state = g_timesync_burst[i];
if (state.active) {
if (now_ms - state.start_ms >= TIME_SYNC_BURST_DURATION_MS) {
state.active = false;
} else if (state.last_send_ms == 0 || now_ms - state.last_send_ms >= TIME_SYNC_BURST_INTERVAL_MS) {
state.last_send_ms = now_ms;
burst_sent = true;
}
}
}
if (burst_sent) {
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);
}
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("timesync: tx burst");
}
g_last_timesync_ms = now_ms;
} else if (now_ms - g_last_timesync_ms > interval_sec * 1000UL) {
g_last_timesync_ms = now_ms;
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);
}
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("timesync: tx normal");
}
}
}
mqtt_loop(); mqtt_loop();
web_server_loop(); web_server_loop();
if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) { if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) {

View File

@@ -18,8 +18,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return "none"; return "none";
} }

View File

@@ -101,7 +101,7 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
if (!out || !out_len) { if (!out || !out_len) {
return false; return false;
} }
if (in.n == 0 || in.n > kMaxSamples) { if (in.n > kMaxSamples) {
return false; return false;
} }
if (in.dt_s == 0) { if (in.dt_s == 0) {
@@ -131,6 +131,11 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
out[pos++] = in.err_last; out[pos++] = in.err_last;
out[pos++] = in.err_rx_reject; out[pos++] = in.err_rx_reject;
if (in.n == 0) {
*out_len = pos;
return true;
}
if (!ensure_capacity(4, out_cap, pos)) { if (!ensure_capacity(4, out_cap, pos)) {
return false; return false;
} }
@@ -210,9 +215,18 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
out->err_last = buf[pos++]; out->err_last = buf[pos++];
out->err_rx_reject = buf[pos++]; out->err_rx_reject = buf[pos++];
if (out->n == 0 || out->n > kMaxSamples || out->dt_s == 0) { if (out->n > kMaxSamples || out->dt_s == 0) {
return false; return false;
} }
if (out->n == 0) {
for (uint8_t i = 0; i < kMaxSamples; ++i) {
out->energy_wh[i] = 0;
out->p1_w[i] = 0;
out->p2_w[i] = 0;
out->p3_w[i] = 0;
}
return pos == len;
}
if (pos + 4 > len) { if (pos + 4 > len) {
return false; return false;
} }

View File

@@ -1,125 +0,0 @@
#include "rtc_ds3231.h"
#include "config.h"
#include <Wire.h>
#include <string>
#include <time.h>
static constexpr uint8_t DS3231_ADDR = 0x68;
static uint8_t bcd_to_dec(uint8_t val) {
return static_cast<uint8_t>((val >> 4) * 10 + (val & 0x0F));
}
static uint8_t dec_to_bcd(uint8_t val) {
return static_cast<uint8_t>(((val / 10) << 4) | (val % 10));
}
static time_t timegm_fallback(struct tm *tm_utc) {
if (!tm_utc) {
return static_cast<time_t>(-1);
}
const char *old_tz = getenv("TZ");
// getenv() may return a pointer into mutable storage that becomes invalid after setenv().
std::string old_tz_copy = old_tz ? old_tz : "";
setenv("TZ", "UTC0", 1);
tzset();
time_t t = mktime(tm_utc);
if (!old_tz_copy.empty()) {
setenv("TZ", old_tz_copy.c_str(), 1);
} else {
unsetenv("TZ");
}
tzset();
return t;
}
static bool read_registers(uint8_t start_reg, uint8_t *out, size_t len) {
if (!out || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
size_t read = Wire.requestFrom(DS3231_ADDR, static_cast<uint8_t>(len));
if (read != len) {
return false;
}
for (size_t i = 0; i < len; ++i) {
out[i] = Wire.read();
}
return true;
}
static bool write_registers(uint8_t start_reg, const uint8_t *data, size_t len) {
if (!data || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
for (size_t i = 0; i < len; ++i) {
Wire.write(data[i]);
}
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_init() {
Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL);
Wire.setClock(100000);
return rtc_ds3231_is_present();
}
bool rtc_ds3231_is_present() {
Wire.beginTransmission(DS3231_ADDR);
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc) {
uint8_t regs[7] = {};
if (!read_registers(0x00, regs, sizeof(regs))) {
return false;
}
uint8_t sec = bcd_to_dec(regs[0] & 0x7F);
uint8_t min = bcd_to_dec(regs[1] & 0x7F);
uint8_t hour = bcd_to_dec(regs[2] & 0x3F);
uint8_t day = bcd_to_dec(regs[4] & 0x3F);
uint8_t month = bcd_to_dec(regs[5] & 0x1F);
uint16_t year = 2000 + bcd_to_dec(regs[6]);
struct tm tm_utc = {};
tm_utc.tm_sec = sec;
tm_utc.tm_min = min;
tm_utc.tm_hour = hour;
tm_utc.tm_mday = day;
tm_utc.tm_mon = month - 1;
tm_utc.tm_year = year - 1900;
tm_utc.tm_isdst = 0;
time_t t = timegm_fallback(&tm_utc);
if (t <= 0) {
return false;
}
epoch_utc = static_cast<uint32_t>(t);
return true;
}
bool rtc_ds3231_set_epoch(uint32_t epoch_utc) {
time_t t = static_cast<time_t>(epoch_utc);
struct tm tm_utc = {};
if (!gmtime_r(&t, &tm_utc)) {
return false;
}
uint8_t regs[7] = {};
regs[0] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_sec));
regs[1] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_min));
regs[2] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_hour));
regs[3] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_wday + 1));
regs[4] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mday));
regs[5] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mon + 1));
regs[6] = dec_to_bcd(static_cast<uint8_t>((tm_utc.tm_year + 1900) - 2000));
return write_registers(0x00, regs, sizeof(regs));
}

View File

@@ -15,8 +15,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return ""; return "";
} }

View File

@@ -2,9 +2,7 @@
#ifdef ENABLE_TEST_MODE #ifdef ENABLE_TEST_MODE
#include "config.h" #include "config.h"
#include "compressor.h"
#include "lora_transport.h" #include "lora_transport.h"
#include "json_codec.h"
#include "time_manager.h" #include "time_manager.h"
#include "display_ui.h" #include "display_ui.h"
#include "mqtt_client.h" #include "mqtt_client.h"
@@ -14,16 +12,9 @@
static uint32_t g_last_test_ms = 0; static uint32_t g_last_test_ms = 0;
static uint16_t g_test_code_counter = 1000; static uint16_t g_test_code_counter = 1000;
static uint32_t g_last_timesync_ms = 0;
static constexpr uint32_t TEST_SEND_INTERVAL_MS = 30000; static constexpr uint32_t TEST_SEND_INTERVAL_MS = 30000;
static constexpr uint32_t TEST_TIMESYNC_OFFSET_MS = 15000;
void test_sender_loop(uint16_t short_id, const char *device_id) { void test_sender_loop(uint16_t short_id, const char *device_id) {
LoraPacket rx = {};
if (lora_receive(rx, 0) && rx.payload_type == PayloadType::TimeSync) {
time_handle_timesync_payload(rx.payload, rx.payload_len);
}
if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) { if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) {
return; return;
} }
@@ -60,48 +51,34 @@ void test_sender_loop(uint16_t short_id, const char *device_id) {
data.ts_utc = ts; data.ts_utc = ts;
display_set_last_meter(data); display_set_last_meter(data);
uint8_t compressed[LORA_MAX_PAYLOAD]; if (json.length() > LORA_MAX_PAYLOAD) {
size_t compressed_len = 0;
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
return; return;
} }
LoraPacket pkt = {}; LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION; pkt.msg_kind = LoraMsgKind::BatchUp;
pkt.role = DeviceRole::Sender;
pkt.device_id_short = short_id; pkt.device_id_short = short_id;
pkt.payload_type = PayloadType::TestCode; pkt.payload_len = json.length();
pkt.payload_len = compressed_len; memcpy(pkt.payload, json.c_str(), pkt.payload_len);
memcpy(pkt.payload, compressed, compressed_len);
lora_send(pkt); lora_send(pkt);
} }
void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_short_id) { void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_short_id) {
if (g_last_timesync_ms == 0) { (void)self_short_id;
g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TEST_TIMESYNC_OFFSET_MS);
}
if (millis() - g_last_timesync_ms > TIME_SYNC_INTERVAL_SEC * 1000UL) {
g_last_timesync_ms = millis();
time_send_timesync(self_short_id);
}
LoraPacket pkt = {}; LoraPacket pkt = {};
if (!lora_receive(pkt, 0)) { if (!lora_receive(pkt, 0)) {
return; return;
} }
if (pkt.payload_type != PayloadType::TestCode) { if (pkt.msg_kind != LoraMsgKind::BatchUp) {
return; return;
} }
uint8_t decompressed[160]; uint8_t decompressed[160];
size_t decompressed_len = 0; if (pkt.payload_len >= sizeof(decompressed)) {
if (!decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) {
return; return;
} }
if (decompressed_len >= sizeof(decompressed)) { memcpy(decompressed, pkt.payload, pkt.payload_len);
return; decompressed[pkt.payload_len] = '\0';
}
decompressed[decompressed_len] = '\0';
StaticJsonDocument<128> doc; StaticJsonDocument<128> doc;
if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) { if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) {

View File

@@ -1,15 +1,9 @@
#include "time_manager.h" #include "time_manager.h"
#include "compressor.h"
#include "config.h"
#include "rtc_ds3231.h"
#include <time.h> #include <time.h>
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 uint32_t g_last_sync_utc = 0; static uint32_t g_last_sync_utc = 0;
static constexpr uint32_t kMinValidEpoch = 1672531200UL; // 2023-01-01
static constexpr uint32_t kMaxValidEpoch = 4102444800UL; // 2100-01-01
static void note_last_sync(uint32_t epoch) { static void note_last_sync(uint32_t epoch) {
if (epoch == 0) { if (epoch == 0) {
@@ -57,63 +51,6 @@ void time_set_utc(uint32_t epoch) {
settimeofday(&tv, nullptr); settimeofday(&tv, nullptr);
g_time_synced = true; g_time_synced = true;
note_last_sync(epoch); note_last_sync(epoch);
if (g_rtc_present) {
rtc_ds3231_set_epoch(epoch);
}
}
bool time_send_timesync(uint16_t device_id_short) {
uint32_t epoch = time_get_utc();
if (epoch == 0) {
return false;
}
char payload_str[32];
snprintf(payload_str, sizeof(payload_str), "T:%lu", static_cast<unsigned long>(epoch));
uint8_t compressed[LORA_MAX_PAYLOAD];
size_t compressed_len = 0;
if (!compressBuffer(reinterpret_cast<const uint8_t *>(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) {
return false;
}
LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION;
pkt.role = DeviceRole::Receiver;
pkt.device_id_short = device_id_short;
pkt.payload_type = PayloadType::TimeSync;
pkt.payload_len = compressed_len;
memcpy(pkt.payload, compressed, compressed_len);
bool ok = lora_send(pkt);
if (ok) {
lora_receive_continuous();
}
return ok;
}
bool time_handle_timesync_payload(const uint8_t *payload, size_t len) {
uint8_t decompressed[64];
size_t decompressed_len = 0;
if (!decompressBuffer(payload, len, decompressed, sizeof(decompressed), decompressed_len)) {
return false;
}
if (decompressed_len >= sizeof(decompressed)) {
return false;
}
decompressed[decompressed_len] = '\0';
if (decompressed_len < 3 || decompressed[0] != 'T' || decompressed[1] != ':') {
return false;
}
uint32_t epoch = static_cast<uint32_t>(strtoul(reinterpret_cast<const char *>(decompressed + 2), nullptr, 10));
if (epoch == 0) {
return false;
}
time_set_utc(epoch);
return true;
} }
void time_get_local_hhmm(char *out, size_t out_len) { void time_get_local_hhmm(char *out, size_t out_len) {
@@ -127,43 +64,6 @@ void time_get_local_hhmm(char *out, size_t out_len) {
snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
} }
void time_rtc_init() {
if (!ENABLE_DS3231) {
g_rtc_present = false;
return;
}
g_rtc_present = rtc_ds3231_init();
}
bool time_try_load_from_rtc() {
if (!g_rtc_present) {
return false;
}
if (time_is_synced()) {
return true;
}
uint32_t epoch = 0;
if (!rtc_ds3231_read_epoch(epoch) || epoch == 0) {
if (SERIAL_DEBUG_MODE) {
Serial.println("rtc: read failed");
}
return false;
}
bool valid = epoch >= kMinValidEpoch && epoch <= kMaxValidEpoch;
if (SERIAL_DEBUG_MODE) {
Serial.printf("rtc: epoch=%lu %s\n", static_cast<unsigned long>(epoch), valid ? "accepted" : "rejected");
}
if (!valid) {
return false;
}
time_set_utc(epoch);
return true;
}
bool time_rtc_present() {
return g_rtc_present;
}
uint32_t time_get_last_sync_utc() { uint32_t time_get_last_sync_utc() {
return g_last_sync_utc; return g_last_sync_utc;
} }

View File

@@ -83,8 +83,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return "none"; return "none";
} }
@@ -651,8 +649,8 @@ static void handle_manual() {
html += "<li>Battery: percent with voltage in V.</li>"; html += "<li>Battery: percent with voltage in V.</li>";
html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>"; html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
html += "<li>err_tx: sender-side LoRa TX error counter.</li>"; html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).</li>"; html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).</li>";
html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=bad_protocol_version, 3=wrong_role, 4=wrong_payload_type, 5=length_mismatch, 6=device_id_mismatch, 7=batch_id_mismatch).</li>"; html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=invalid_msg_kind, 3=length_mismatch, 4=device_id_mismatch, 5=batch_id_mismatch).</li>";
html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>"; html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>";
html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>"; html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
html += "</ul>"; html += "</ul>";