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

@@ -2,8 +2,6 @@
#include <ArduinoJson.h>
#include <limits.h>
#include <math.h>
#include "config.h"
#include "power_manager.h"
static float round2(float value) {
if (isnan(value)) {
@@ -12,20 +10,6 @@ static float round2(float value) {
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) {
if (isnan(value)) {
return 0;
@@ -51,31 +35,6 @@ static const char *short_id_from_device_id(const char *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) {
if (!buf || buf_len == 0) {
return;
@@ -133,193 +92,3 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
size_t len = serializeJson(doc, out_json);
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;
}