Merge remote-tracking branch 'origin/lora-refactor' into lora-refactor

Resolve merge conflict in src/main.cpp by accepting the refactored version
from origin. The remote branch includes significant architectural improvements:

- New app context and state machine structures
- Refactored receiver and sender logic
- Library reorganization (dd3_legacy_core, dd3_transport_logic)
- Test framework enhancements
- Code quality improvements

The local WiFi reconnection feature (commit 32cd065) will be
re-integrated if needed in the new architecture.
This commit is contained in:
2026-03-11 17:05:39 +01:00
44 changed files with 4098 additions and 1828 deletions

28
src/app_context.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
#include "config.h"
#include "data_model.h"
struct ReceiverSharedState {
SenderStatus sender_statuses[NUM_SENDERS];
FaultCounters sender_faults_remote[NUM_SENDERS];
FaultCounters sender_faults_remote_published[NUM_SENDERS];
FaultType sender_last_error_remote[NUM_SENDERS];
FaultType sender_last_error_remote_published[NUM_SENDERS];
uint32_t sender_last_error_remote_utc[NUM_SENDERS];
uint32_t sender_last_error_remote_ms[NUM_SENDERS];
bool sender_discovery_sent[NUM_SENDERS];
uint16_t last_batch_id_rx[NUM_SENDERS];
FaultCounters receiver_faults;
FaultCounters receiver_faults_published;
FaultType receiver_last_error;
FaultType receiver_last_error_published;
uint32_t receiver_last_error_utc;
uint32_t receiver_last_error_ms;
bool receiver_discovery_sent;
bool ap_mode;
};

View File

@@ -1,27 +0,0 @@
#include "data_model.h"
#include <esp_mac.h>
void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len) {
uint8_t mac[6] = {0};
// Read base MAC without needing WiFi to be started.
esp_read_mac(mac, ESP_MAC_WIFI_STA);
short_id = (static_cast<uint16_t>(mac[4]) << 8) | mac[5];
snprintf(device_id, device_id_len, "dd3-%04X", short_id);
}
const char *rx_reject_reason_text(RxRejectReason reason) {
switch (reason) {
case RxRejectReason::CrcFail:
return "crc_fail";
case RxRejectReason::InvalidMsgKind:
return "invalid_msg_kind";
case RxRejectReason::LengthMismatch:
return "length_mismatch";
case RxRejectReason::DeviceIdMismatch:
return "device_id_mismatch";
case RxRejectReason::BatchIdMismatch:
return "batch_id_mismatch";
default:
return "none";
}
}

View File

@@ -1,98 +0,0 @@
#include "html_util.h"
String html_escape(const String &input) {
String out;
out.reserve(input.length() + 8);
for (size_t i = 0; i < input.length(); ++i) {
char c = input[i];
switch (c) {
case '&':
out += "&amp;";
break;
case '<':
out += "&lt;";
break;
case '>':
out += "&gt;";
break;
case '"':
out += "&quot;";
break;
case '\'':
out += "&#39;";
break;
default:
out += c;
break;
}
}
return out;
}
String url_encode_component(const String &input) {
String out;
out.reserve(input.length() * 3);
const char *hex = "0123456789ABCDEF";
for (size_t i = 0; i < input.length(); ++i) {
unsigned char c = static_cast<unsigned char>(input[i]);
bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
if (safe) {
out += static_cast<char>(c);
} else {
out += '%';
out += hex[(c >> 4) & 0x0F];
out += hex[c & 0x0F];
}
}
return out;
}
static bool is_hex_char(char c) {
return (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
}
static String to_upper_hex4(const String &input) {
String out = input;
out.toUpperCase();
return out;
}
bool sanitize_device_id(const String &input, String &out_device_id) {
String trimmed = input;
trimmed.trim();
if (trimmed.length() == 0) {
return false;
}
if (trimmed.indexOf('/') >= 0 || trimmed.indexOf('\\') >= 0 || trimmed.indexOf("..") >= 0) {
return false;
}
if (trimmed.indexOf('%') >= 0) {
return false;
}
if (trimmed.length() == 4) {
for (size_t i = 0; i < 4; ++i) {
if (!is_hex_char(trimmed[i])) {
return false;
}
}
out_device_id = String("dd3-") + to_upper_hex4(trimmed);
return true;
}
if (trimmed.length() == 8 && trimmed.startsWith("dd3-")) {
String hex = trimmed.substring(4);
for (size_t i = 0; i < 4; ++i) {
if (!is_hex_char(hex[i])) {
return false;
}
}
out_device_id = String("dd3-") + to_upper_hex4(hex);
return true;
}
return false;
}

View File

@@ -1,96 +0,0 @@
#include "json_codec.h"
#include <ArduinoJson.h>
#include <limits.h>
#include <math.h>
static constexpr size_t STATE_JSON_DOC_CAPACITY = 512;
static float round2(float value) {
if (isnan(value)) {
return value;
}
return roundf(value * 100.0f) / 100.0f;
}
static int32_t round_to_i32(float value) {
if (isnan(value)) {
return 0;
}
long rounded = lroundf(value);
if (rounded > INT32_MAX) {
return INT32_MAX;
}
if (rounded < INT32_MIN) {
return INT32_MIN;
}
return static_cast<int32_t>(rounded);
}
static const char *short_id_from_device_id(const char *device_id) {
if (!device_id) {
return "";
}
size_t len = strlen(device_id);
if (len >= 4) {
return device_id + (len - 4);
}
return device_id;
}
static void format_float_2(char *buf, size_t buf_len, float value) {
if (!buf || buf_len == 0) {
return;
}
if (isnan(value)) {
snprintf(buf, buf_len, "null");
return;
}
snprintf(buf, buf_len, "%.2f", round2(value));
}
static void set_int_or_null(JsonDocument &doc, const char *key, float value) {
if (!key || key[0] == '\0') {
return;
}
if (isnan(value)) {
doc[key] = nullptr;
return;
}
doc[key] = round_to_i32(value);
}
bool meterDataToJson(const MeterData &data, String &out_json) {
StaticJsonDocument<STATE_JSON_DOC_CAPACITY> doc;
doc["id"] = short_id_from_device_id(data.device_id);
doc["ts"] = data.ts_utc;
char buf[16];
format_float_2(buf, sizeof(buf), data.energy_total_kwh);
doc["e_kwh"] = serialized(buf);
set_int_or_null(doc, "p_w", data.total_power_w);
set_int_or_null(doc, "p1_w", data.phase_power_w[0]);
set_int_or_null(doc, "p2_w", data.phase_power_w[1]);
set_int_or_null(doc, "p3_w", data.phase_power_w[2]);
format_float_2(buf, sizeof(buf), data.battery_voltage_v);
doc["bat_v"] = serialized(buf);
doc["bat_pct"] = data.battery_percent;
if (data.link_valid) {
doc["rssi"] = data.link_rssi_dbm;
doc["snr"] = data.link_snr_db;
}
if (data.err_meter_read > 0) {
doc["err_m"] = data.err_meter_read;
}
if (data.err_decode > 0) {
doc["err_d"] = data.err_decode;
}
if (data.err_lora_tx > 0) {
doc["err_tx"] = data.err_lora_tx;
}
doc["err_last"] = static_cast<uint8_t>(data.last_error);
doc["rx_reject"] = data.rx_reject_reason;
doc["rx_reject_text"] = rx_reject_reason_text(static_cast<RxRejectReason>(data.rx_reject_reason));
out_json = "";
size_t len = serializeJson(doc, out_json);
return len > 0;
}

View File

@@ -1,4 +1,5 @@
#include "lora_transport.h"
#include "lora_frame_logic.h"
#include <LoRa.h>
#include <SPI.h>
#include <math.h>
@@ -35,21 +36,6 @@ bool lora_get_last_rx_signal(int16_t &rssi_dbm, float &snr_db) {
return true;
}
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= static_cast<uint16_t>(data[i]) << 8;
for (uint8_t b = 0; b < 8; ++b) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
void lora_init() {
SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS);
LoRa.setPins(PIN_LORA_NSS, PIN_LORA_RST, PIN_LORA_DIO0);
@@ -66,54 +52,35 @@ bool lora_send(const LoraPacket &pkt) {
return true;
}
uint32_t t0 = 0;
uint32_t t1 = 0;
uint32_t t2 = 0;
uint32_t t3 = 0;
uint32_t t4 = 0;
if (SERIAL_DEBUG_MODE) {
t0 = millis();
}
LoRa.idle();
if (SERIAL_DEBUG_MODE) {
t1 = millis();
}
uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t idx = 0;
buffer[idx++] = static_cast<uint8_t>(pkt.msg_kind);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF);
if (pkt.payload_len > LORA_MAX_PAYLOAD) {
return false;
}
memcpy(&buffer[idx], pkt.payload, pkt.payload_len);
idx += pkt.payload_len;
uint16_t crc = crc16_ccitt(buffer, idx);
buffer[idx++] = static_cast<uint8_t>(crc >> 8);
buffer[idx++] = static_cast<uint8_t>(crc & 0xFF);
uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t frame_len = 0;
if (!lora_build_frame(static_cast<uint8_t>(pkt.msg_kind), pkt.device_id_short, pkt.payload, pkt.payload_len,
buffer, sizeof(buffer), frame_len)) {
return false;
}
LoRa.beginPacket();
if (SERIAL_DEBUG_MODE) {
t2 = millis();
}
LoRa.write(buffer, idx);
if (SERIAL_DEBUG_MODE) {
t3 = millis();
}
LoRa.write(buffer, frame_len);
int result = LoRa.endPacket(false);
bool ok = result == 1;
if (SERIAL_DEBUG_MODE) {
t4 = millis();
Serial.printf("lora_tx: idle=%lums begin=%lums write=%lums end=%lums total=%lums len=%u\n",
static_cast<unsigned long>(t1 - t0),
static_cast<unsigned long>(t2 - t1),
static_cast<unsigned long>(t3 - t2),
static_cast<unsigned long>(t4 - t3),
static_cast<unsigned long>(t4 - t0),
static_cast<unsigned>(idx));
uint32_t tx_ms = millis() - t0;
if (!ok || tx_ms > 2000) {
Serial.printf("lora_tx: len=%u total=%lums ok=%u\n",
static_cast<unsigned>(frame_len),
static_cast<unsigned long>(tx_ms),
ok ? 1U : 0U);
}
}
return result == 1;
return ok;
}
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
@@ -154,26 +121,33 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
return false;
}
uint16_t crc_calc = crc16_ccitt(buffer, len - 2);
uint16_t crc_rx = static_cast<uint16_t>(buffer[len - 2] << 8) | buffer[len - 1];
if (crc_calc != crc_rx) {
uint8_t msg_kind = 0;
uint16_t device_id_short = 0;
size_t payload_len = 0;
LoraFrameDecodeStatus status = lora_parse_frame(
buffer, len, static_cast<uint8_t>(LoraMsgKind::AckDown), &msg_kind, &device_id_short,
pkt.payload, sizeof(pkt.payload), &payload_len);
if (status == LoraFrameDecodeStatus::CrcFail) {
note_reject(RxRejectReason::CrcFail);
return false;
}
uint8_t msg_kind = buffer[0];
if (msg_kind > static_cast<uint8_t>(LoraMsgKind::AckDown)) {
if (status == LoraFrameDecodeStatus::InvalidMsgKind) {
note_reject(RxRejectReason::InvalidMsgKind);
return false;
}
if (status == LoraFrameDecodeStatus::LengthMismatch) {
note_reject(RxRejectReason::LengthMismatch);
return false;
}
pkt.msg_kind = static_cast<LoraMsgKind>(msg_kind);
pkt.device_id_short = static_cast<uint16_t>(buffer[1] << 8) | buffer[2];
pkt.payload_len = len - 5;
pkt.device_id_short = device_id_short;
pkt.payload_len = payload_len;
if (pkt.payload_len > LORA_MAX_PAYLOAD) {
note_reject(RxRejectReason::LengthMismatch);
return false;
}
memcpy(pkt.payload, &buffer[3], pkt.payload_len);
pkt.rssi_dbm = g_last_rx_rssi_dbm;
pkt.snr_db = g_last_rx_snr_db;
return true;

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ static uint32_t g_frames_parse_fail = 0;
static uint32_t g_rx_overflow = 0;
static uint32_t g_rx_timeout = 0;
static uint32_t g_last_log_ms = 0;
static uint32_t g_last_good_frame_ms = 0;
static constexpr uint32_t METER_FIXED_FRAC_MAX_DIV = 10000;
void meter_init() {
#ifdef ARDUINO_ARCH_ESP32
@@ -33,47 +35,138 @@ void meter_init() {
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
}
static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) {
const char *p = strstr(line, obis);
if (!p) {
enum class ObisField : uint8_t {
None = 0,
Energy = 1,
TotalPower = 2,
Phase1 = 3,
Phase2 = 4,
Phase3 = 5,
MeterSeconds = 6
};
static ObisField detect_obis_field(const char *line) {
if (!line) {
return ObisField::None;
}
const char *p = line;
while (*p == ' ' || *p == '\t') {
++p;
}
if (strncmp(p, "1-0:1.8.0", 9) == 0) {
return ObisField::Energy;
}
if (strncmp(p, "1-0:16.7.0", 10) == 0) {
return ObisField::TotalPower;
}
if (strncmp(p, "1-0:36.7.0", 10) == 0) {
return ObisField::Phase1;
}
if (strncmp(p, "1-0:56.7.0", 10) == 0) {
return ObisField::Phase2;
}
if (strncmp(p, "1-0:76.7.0", 10) == 0) {
return ObisField::Phase3;
}
if (strncmp(p, "0-0:96.8.0*255", 14) == 0) {
return ObisField::MeterSeconds;
}
return ObisField::None;
}
static bool parse_decimal_fixed(const char *start, const char *end, float &out_value) {
if (!start || !end || end <= start) {
return false;
}
const char *lparen = strchr(p, '(');
if (!lparen) {
return false;
}
const char *cur = lparen + 1;
char num_buf[24];
size_t n = 0;
while (*cur && *cur != ')' && *cur != '*') {
const char *cur = start;
bool started = false;
bool negative = false;
bool in_fraction = false;
bool saw_digit = false;
uint64_t int_part = 0;
uint32_t frac_part = 0;
uint32_t frac_div = 1;
while (cur < end) {
char c = *cur++;
if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == ',') {
if (c == ',') {
c = '.';
if (!started) {
if (c == '+' || c == '-') {
started = true;
negative = (c == '-');
continue;
}
if (n + 1 < sizeof(num_buf)) {
num_buf[n++] = c;
if (c >= '0' && c <= '9') {
started = true;
saw_digit = true;
int_part = static_cast<uint64_t>(c - '0');
continue;
}
if (c == '.' || c == ',') {
started = true;
in_fraction = true;
continue;
}
} else if (n == 0) {
continue;
} else {
break;
}
if (c >= '0' && c <= '9') {
saw_digit = true;
uint32_t digit = static_cast<uint32_t>(c - '0');
if (!in_fraction) {
if (int_part <= (UINT64_MAX - digit) / 10ULL) {
int_part = int_part * 10ULL + digit;
}
} else if (frac_div < METER_FIXED_FRAC_MAX_DIV) {
frac_part = frac_part * 10U + digit;
frac_div *= 10U;
}
continue;
}
if ((c == '.' || c == ',') && !in_fraction) {
in_fraction = true;
continue;
}
break;
}
if (n == 0) {
if (!saw_digit) {
return false;
}
num_buf[n] = '\0';
out_value = static_cast<float>(atof(num_buf));
double value = static_cast<double>(int_part);
if (frac_div > 1U) {
value += static_cast<double>(frac_part) / static_cast<double>(frac_div);
}
if (negative) {
value = -value;
}
out_value = static_cast<float>(value);
return true;
}
static bool parse_obis_ascii_unit_scale(const char *line, const char *obis, float &value) {
const char *p = strstr(line, obis);
if (!p) {
static bool parse_obis_ascii_payload_value(const char *line, float &out_value) {
const char *lparen = strchr(line, '(');
if (!lparen) {
return false;
}
const char *asterisk = strchr(p, '*');
const char *end = lparen + 1;
while (*end && *end != ')' && *end != '*') {
++end;
}
if (end <= lparen + 1) {
return false;
}
return parse_decimal_fixed(lparen + 1, end, out_value);
}
static bool parse_obis_ascii_unit_scale(const char *line, float &value) {
const char *lparen = strchr(line, '(');
if (!lparen) {
return false;
}
const char *asterisk = strchr(lparen, '*');
if (!asterisk) {
return false;
}
@@ -113,12 +206,8 @@ static int8_t hex_nibble(char c) {
return -1;
}
static bool parse_obis_hex_u32(const char *line, const char *obis, uint32_t &out_value) {
const char *p = strstr(line, obis);
if (!p) {
return false;
}
const char *lparen = strchr(p, '(');
static bool parse_obis_hex_payload_u32(const char *line, uint32_t &out_value) {
const char *lparen = strchr(line, '(');
if (!lparen) {
return false;
}
@@ -163,6 +252,16 @@ static void meter_debug_log() {
static_cast<unsigned long>(g_bytes_rx));
}
void meter_get_stats(MeterDriverStats &out) {
out.frames_ok = g_frames_ok;
out.frames_parse_fail = g_frames_parse_fail;
out.rx_overflow = g_rx_overflow;
out.rx_timeout = g_rx_timeout;
out.bytes_rx = g_bytes_rx;
out.last_rx_ms = g_last_rx_ms;
out.last_good_frame_ms = g_last_good_frame_ms;
}
bool meter_poll_frame(const char *&frame, size_t &len) {
frame = nullptr;
len = 0;
@@ -244,6 +343,7 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) {
g_frames_ok++;
g_last_good_frame_ms = millis();
} else {
g_frames_parse_fail++;
}
@@ -255,43 +355,68 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) {
g_frames_ok++;
g_last_good_frame_ms = millis();
} else {
g_frames_parse_fail++;
}
return data.valid;
}
ObisField field = detect_obis_field(line);
float value = NAN;
if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) {
parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value);
data.energy_total_kwh = value;
energy_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:16.7.0", value)) {
data.total_power_w = value;
total_p_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:36.7.0", value)) {
data.phase_power_w[0] = value;
p1_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:56.7.0", value)) {
data.phase_power_w[1] = value;
p2_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:76.7.0", value)) {
data.phase_power_w[2] = value;
p3_ok = true;
got_any = true;
}
uint32_t meter_seconds = 0;
if (parse_obis_hex_u32(line, "0-0:96.8.0*255", meter_seconds)) {
data.meter_seconds = meter_seconds;
data.meter_seconds_valid = true;
switch (field) {
case ObisField::Energy:
if (parse_obis_ascii_payload_value(line, value)) {
parse_obis_ascii_unit_scale(line, value);
data.energy_total_kwh = value;
energy_ok = true;
got_any = true;
}
break;
case ObisField::TotalPower:
if (parse_obis_ascii_payload_value(line, value)) {
data.total_power_w = value;
total_p_ok = true;
got_any = true;
}
break;
case ObisField::Phase1:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[0] = value;
p1_ok = true;
got_any = true;
}
break;
case ObisField::Phase2:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[1] = value;
p2_ok = true;
got_any = true;
}
break;
case ObisField::Phase3:
if (parse_obis_ascii_payload_value(line, value)) {
data.phase_power_w[2] = value;
p3_ok = true;
got_any = true;
}
break;
case ObisField::MeterSeconds:
if (parse_obis_hex_payload_u32(line, meter_seconds)) {
data.meter_seconds = meter_seconds;
data.meter_seconds_valid = true;
}
break;
default:
break;
}
if (energy_ok && total_p_ok && p1_ok && p2_ok && p3_ok && data.meter_seconds_valid) {
data.valid = true;
g_frames_ok++;
g_last_good_frame_ms = millis();
return true;
}
line_len = 0;
@@ -305,6 +430,7 @@ bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
data.valid = got_any;
if (data.valid) {
g_frames_ok++;
g_last_good_frame_ms = millis();
} else {
g_frames_parse_fail++;
}

View File

@@ -2,6 +2,7 @@
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include "ha_discovery_json.h"
#include "config.h"
#include "json_codec.h"
@@ -10,6 +11,13 @@ static PubSubClient mqtt_client(wifi_client);
static WifiMqttConfig g_cfg;
static String g_client_id;
static const char *ha_manufacturer_anchor() {
StaticJsonDocument<32> doc;
JsonObject device = doc.createNestedObject("device");
device["manufacturer"] = HA_MANUFACTURER;
return HA_MANUFACTURER;
}
static const char *fault_text(FaultType fault) {
switch (fault) {
case FaultType::MeterRead:
@@ -94,31 +102,9 @@ bool mqtt_publish_faults(const char *device_id, const FaultCounters &counters, F
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) {
if (!ha_build_discovery_sensor_payload(device_id, key, name, unit, device_class, state_topic, value_template,
ha_manufacturer_anchor(), payload)) {
return false;
}

View File

@@ -1,384 +0,0 @@
#include "payload_codec.h"
#include <limits.h>
static constexpr uint16_t kMagic = 0xDDB3;
// Breaking change: schema v3 replaces fixed dt_s spacing with a 30-bit present_mask.
static constexpr uint8_t kSchema = 3;
static constexpr uint8_t kFlags = 0x01;
static constexpr size_t kMaxSamples = 30;
static constexpr uint32_t kPresentMaskValidBits = 0x3FFFFFFFUL;
static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
}
static void write_u32_le(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[3] = static_cast<uint8_t>((value >> 24) & 0xFF);
}
static uint16_t read_u16_le(const uint8_t *src) {
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
}
static uint32_t read_u32_le(const uint8_t *src) {
return static_cast<uint32_t>(src[0]) |
(static_cast<uint32_t>(src[1]) << 8) |
(static_cast<uint32_t>(src[2]) << 16) |
(static_cast<uint32_t>(src[3]) << 24);
}
size_t uleb128_encode(uint32_t v, uint8_t *out, size_t cap) {
size_t i = 0;
do {
if (i >= cap) {
return 0;
}
uint8_t byte = static_cast<uint8_t>(v & 0x7F);
v >>= 7;
if (v != 0) {
byte |= 0x80;
}
out[i++] = byte;
} while (v != 0);
return i;
}
bool uleb128_decode(const uint8_t *in, size_t len, size_t *pos, uint32_t *v) {
if (!in || !pos || !v) {
return false;
}
uint32_t result = 0;
uint8_t shift = 0;
size_t p = *pos;
for (uint8_t i = 0; i < 5; ++i) {
if (p >= len) {
return false;
}
uint8_t byte = in[p++];
if (i == 4 && (byte & 0xF0) != 0) {
return false;
}
result |= static_cast<uint32_t>(byte & 0x7F) << shift;
if ((byte & 0x80) == 0) {
*pos = p;
*v = result;
return true;
}
shift = static_cast<uint8_t>(shift + 7);
}
return false;
}
uint32_t zigzag32(int32_t x) {
return (static_cast<uint32_t>(x) << 1) ^ static_cast<uint32_t>(x >> 31);
}
int32_t unzigzag32(uint32_t u) {
return static_cast<int32_t>((u >> 1) ^ (static_cast<uint32_t>(-static_cast<int32_t>(u & 1))));
}
size_t svarint_encode(int32_t x, uint8_t *out, size_t cap) {
uint32_t zz = zigzag32(x);
return uleb128_encode(zz, out, cap);
}
bool svarint_decode(const uint8_t *in, size_t len, size_t *pos, int32_t *x) {
uint32_t u = 0;
if (!uleb128_decode(in, len, pos, &u)) {
return false;
}
*x = unzigzag32(u);
return true;
}
static bool ensure_capacity(size_t needed, size_t cap, size_t pos) {
return pos + needed <= cap;
}
static uint8_t bit_count32(uint32_t value) {
uint8_t count = 0;
while (value != 0) {
value &= (value - 1);
count++;
}
return count;
}
bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *out_len) {
if (!out || !out_len) {
return false;
}
if (in.n > kMaxSamples) {
return false;
}
if ((in.present_mask & ~kPresentMaskValidBits) != 0) {
return false;
}
if (bit_count32(in.present_mask) != in.n) {
return false;
}
if (in.n == 0 && in.present_mask != 0) {
return false;
}
size_t pos = 0;
if (!ensure_capacity(24, out_cap, pos)) {
return false;
}
write_u16_le(&out[pos], kMagic);
pos += 2;
out[pos++] = kSchema;
out[pos++] = kFlags;
write_u16_le(&out[pos], in.sender_id);
pos += 2;
write_u16_le(&out[pos], in.batch_id);
pos += 2;
write_u32_le(&out[pos], in.t_last);
pos += 4;
write_u32_le(&out[pos], in.present_mask);
pos += 4;
out[pos++] = in.n;
write_u16_le(&out[pos], in.battery_mV);
pos += 2;
out[pos++] = in.err_m;
out[pos++] = in.err_d;
out[pos++] = in.err_tx;
out[pos++] = in.err_last;
out[pos++] = in.err_rx_reject;
if (in.n == 0) {
*out_len = pos;
return true;
}
if (!ensure_capacity(4, out_cap, pos)) {
return false;
}
write_u32_le(&out[pos], in.energy_wh[0]);
pos += 4;
for (uint8_t i = 1; i < in.n; ++i) {
if (in.energy_wh[i] < in.energy_wh[i - 1]) {
return false;
}
uint32_t delta = in.energy_wh[i] - in.energy_wh[i - 1];
size_t wrote = uleb128_encode(delta, &out[pos], out_cap - pos);
if (wrote == 0) {
return false;
}
pos += wrote;
}
auto encode_phase = [&](const int16_t *phase) -> bool {
if (!ensure_capacity(2, out_cap, pos)) {
return false;
}
write_u16_le(&out[pos], static_cast<uint16_t>(phase[0]));
pos += 2;
for (uint8_t i = 1; i < in.n; ++i) {
int32_t delta = static_cast<int32_t>(phase[i]) - static_cast<int32_t>(phase[i - 1]);
size_t wrote = svarint_encode(delta, &out[pos], out_cap - pos);
if (wrote == 0) {
return false;
}
pos += wrote;
}
return true;
};
if (!encode_phase(in.p1_w)) {
return false;
}
if (!encode_phase(in.p2_w)) {
return false;
}
if (!encode_phase(in.p3_w)) {
return false;
}
*out_len = pos;
return true;
}
bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
if (!buf || !out) {
return false;
}
size_t pos = 0;
if (len < 24) {
return false;
}
uint16_t magic = read_u16_le(&buf[pos]);
pos += 2;
uint8_t schema = buf[pos++];
uint8_t flags = buf[pos++];
if (magic != kMagic || schema != kSchema || (flags & 0x01) == 0) {
return false;
}
out->sender_id = read_u16_le(&buf[pos]);
pos += 2;
out->batch_id = read_u16_le(&buf[pos]);
pos += 2;
out->t_last = read_u32_le(&buf[pos]);
pos += 4;
out->present_mask = read_u32_le(&buf[pos]);
pos += 4;
out->n = buf[pos++];
out->battery_mV = read_u16_le(&buf[pos]);
pos += 2;
out->err_m = buf[pos++];
out->err_d = buf[pos++];
out->err_tx = buf[pos++];
out->err_last = buf[pos++];
out->err_rx_reject = buf[pos++];
if (out->n > kMaxSamples) {
return false;
}
if ((out->present_mask & ~kPresentMaskValidBits) != 0) {
return false;
}
if (bit_count32(out->present_mask) != out->n) {
return false;
}
if (out->n == 0 && out->present_mask != 0) {
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) {
return false;
}
out->energy_wh[0] = read_u32_le(&buf[pos]);
pos += 4;
for (uint8_t i = 1; i < out->n; ++i) {
uint32_t delta = 0;
if (!uleb128_decode(buf, len, &pos, &delta)) {
return false;
}
uint64_t sum = static_cast<uint64_t>(out->energy_wh[i - 1]) + static_cast<uint64_t>(delta);
if (sum > UINT32_MAX) {
return false;
}
out->energy_wh[i] = static_cast<uint32_t>(sum);
}
auto decode_phase = [&](int16_t *phase) -> bool {
if (pos + 2 > len) {
return false;
}
phase[0] = static_cast<int16_t>(read_u16_le(&buf[pos]));
pos += 2;
int32_t prev = static_cast<int32_t>(phase[0]);
for (uint8_t i = 1; i < out->n; ++i) {
int32_t delta = 0;
if (!svarint_decode(buf, len, &pos, &delta)) {
return false;
}
int32_t value = prev + delta;
if (value < INT16_MIN || value > INT16_MAX) {
return false;
}
phase[i] = static_cast<int16_t>(value);
prev = value;
}
return true;
};
if (!decode_phase(out->p1_w)) {
return false;
}
if (!decode_phase(out->p2_w)) {
return false;
}
if (!decode_phase(out->p3_w)) {
return false;
}
for (uint8_t i = out->n; 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;
}
#ifdef PAYLOAD_CODEC_TEST
bool payload_codec_self_test() {
BatchInput in = {};
in.sender_id = 1;
in.batch_id = 42;
in.t_last = 1700000000;
in.present_mask = (1UL << 0) | (1UL << 2) | (1UL << 3) | (1UL << 10) | (1UL << 29);
in.n = 5;
in.battery_mV = 3750;
in.err_m = 2;
in.err_d = 1;
in.err_tx = 3;
in.err_last = 2;
in.err_rx_reject = 1;
in.energy_wh[0] = 100000;
in.energy_wh[1] = 100001;
in.energy_wh[2] = 100050;
in.energy_wh[3] = 100050;
in.energy_wh[4] = 100200;
in.p1_w[0] = -120;
in.p1_w[1] = -90;
in.p1_w[2] = 1910;
in.p1_w[3] = -90;
in.p1_w[4] = 500;
in.p2_w[0] = 50;
in.p2_w[1] = -1950;
in.p2_w[2] = 60;
in.p2_w[3] = 2060;
in.p2_w[4] = -10;
in.p3_w[0] = 0;
in.p3_w[1] = 10;
in.p3_w[2] = -1990;
in.p3_w[3] = 10;
in.p3_w[4] = 20;
uint8_t buf[256];
size_t len = 0;
if (!encode_batch(in, buf, sizeof(buf), &len)) {
Serial.println("payload_codec_self_test: encode failed");
return false;
}
BatchInput out = {};
if (!decode_batch(buf, len, &out)) {
Serial.println("payload_codec_self_test: decode failed");
return false;
}
if (out.sender_id != in.sender_id || out.batch_id != in.batch_id || out.t_last != in.t_last ||
out.present_mask != in.present_mask || out.n != in.n || out.battery_mV != in.battery_mV ||
out.err_m != in.err_m || out.err_d != in.err_d || out.err_tx != in.err_tx || out.err_last != in.err_last ||
out.err_rx_reject != in.err_rx_reject) {
Serial.println("payload_codec_self_test: header mismatch");
return false;
}
for (uint8_t i = 0; i < in.n; ++i) {
if (out.energy_wh[i] != in.energy_wh[i] || out.p1_w[i] != in.p1_w[i] || out.p2_w[i] != in.p2_w[i] ||
out.p3_w[i] != in.p3_w[i]) {
Serial.println("payload_codec_self_test: sample mismatch");
return false;
}
}
Serial.printf("payload_codec_self_test: ok len=%u\n", static_cast<unsigned>(len));
return true;
}
#endif

523
src/receiver_pipeline.cpp Normal file
View File

@@ -0,0 +1,523 @@
#include "receiver_pipeline.h"
#include <Arduino.h>
#include <math.h>
#include <stdarg.h>
#include "config.h"
#include "batch_reassembly_logic.h"
#include "display_ui.h"
#include "json_codec.h"
#include "lora_transport.h"
#include "mqtt_client.h"
#include "payload_codec.h"
#include "power_manager.h"
#include "sd_logger.h"
#include "time_manager.h"
#include "web_server.h"
#include "wifi_manager.h"
#ifdef ARDUINO_ARCH_ESP32
#include <esp_task_wdt.h>
#endif
namespace {
static uint16_t g_short_id = 0;
static char g_device_id[16] = "";
static ReceiverSharedState *g_shared = nullptr;
static RxRejectReason g_receiver_rx_reject_reason = RxRejectReason::None;
static uint32_t g_receiver_rx_reject_log_ms = 0;
#define g_sender_statuses (g_shared->sender_statuses)
#define g_sender_faults_remote (g_shared->sender_faults_remote)
#define g_sender_faults_remote_published (g_shared->sender_faults_remote_published)
#define g_sender_last_error_remote (g_shared->sender_last_error_remote)
#define g_sender_last_error_remote_published (g_shared->sender_last_error_remote_published)
#define g_sender_last_error_remote_utc (g_shared->sender_last_error_remote_utc)
#define g_sender_last_error_remote_ms (g_shared->sender_last_error_remote_ms)
#define g_sender_discovery_sent (g_shared->sender_discovery_sent)
#define g_last_batch_id_rx (g_shared->last_batch_id_rx)
#define g_receiver_faults (g_shared->receiver_faults)
#define g_receiver_faults_published (g_shared->receiver_faults_published)
#define g_receiver_last_error (g_shared->receiver_last_error)
#define g_receiver_last_error_published (g_shared->receiver_last_error_published)
#define g_receiver_last_error_utc (g_shared->receiver_last_error_utc)
#define g_receiver_last_error_ms (g_shared->receiver_last_error_ms)
#define g_receiver_discovery_sent (g_shared->receiver_discovery_sent)
#define g_ap_mode (g_shared->ap_mode)
static void watchdog_kick() {
#ifdef ARDUINO_ARCH_ESP32
esp_task_wdt_reset();
#endif
}
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_MAX_COMPRESSED = 4096;
static constexpr uint32_t BATCH_RX_MARGIN_MS = 800;
static void serial_debug_printf(const char *fmt, ...) {
if (!SERIAL_DEBUG_MODE) {
return;
}
char buf[256];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
Serial.println(buf);
}
static uint8_t bit_count32(uint32_t value) {
uint8_t count = 0;
while (value != 0) {
value &= (value - 1);
count++;
}
return count;
}
static bool mqtt_publish_sample(const MeterData &data) {
#ifdef ENABLE_TEST_MODE
String payload;
if (!meterDataToJson(data, payload)) {
return false;
}
return mqtt_publish_test(data.device_id, payload);
#else
return mqtt_publish_state(data);
#endif
}
static BatchReassemblyState g_batch_rx = {};
static uint8_t g_batch_rx_buffer[BATCH_MAX_COMPRESSED] = {};
static void init_sender_statuses() {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
g_sender_statuses[i] = {};
g_sender_statuses[i].has_data = false;
g_sender_statuses[i].last_update_ts_utc = 0;
g_sender_statuses[i].rx_batches_total = 0;
g_sender_statuses[i].rx_batches_duplicate = 0;
g_sender_statuses[i].rx_last_duplicate_ts_utc = 0;
g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i];
snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]);
g_sender_faults_remote[i] = {};
g_sender_faults_remote_published[i] = {};
g_sender_last_error_remote[i] = FaultType::None;
g_sender_last_error_remote_published[i] = FaultType::None;
g_sender_last_error_remote_utc[i] = 0;
g_sender_last_error_remote_ms[i] = 0;
g_sender_discovery_sent[i] = false;
}
}
static void receiver_note_rx_reject(RxRejectReason reason, const char *context) {
if (reason == RxRejectReason::None) {
return;
}
g_receiver_rx_reject_reason = reason;
uint32_t now_ms = millis();
if (SERIAL_DEBUG_MODE && now_ms - g_receiver_rx_reject_log_ms >= 1000) {
g_receiver_rx_reject_log_ms = now_ms;
serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason));
}
}
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 void clear_faults(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms) {
counters = {};
last_type = FaultType::None;
last_ts_utc = 0;
last_ts_ms = 0;
}
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;
}
}
static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
}
static uint16_t read_u16_le(const uint8_t *src) {
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) {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) {
return static_cast<uint16_t>(i + 1);
}
}
return 0;
}
static uint16_t short_id_from_sender_id(uint16_t sender_id) {
if (sender_id == 0 || sender_id > NUM_SENDERS) {
return 0;
}
return EXPECTED_SENDER_IDS[sender_id - 1];
}
static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_count) {
if (total_len == 0 || chunk_count == 0) {
return 10000;
}
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 packet_len = 3 + payload_len + 2;
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;
return timeout_ms < 10000 ? 10000 : timeout_ms;
}
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 = {};
ack.msg_kind = LoraMsgKind::AckDown;
ack.device_id_short = g_short_id;
ack.payload_len = LORA_ACK_DOWN_PAYLOAD_LEN;
ack.payload[0] = time_valid;
write_u16_be(&ack.payload[1], batch_id);
write_u32_be(&ack.payload[3], epoch);
uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT;
for (uint8_t i = 0; i < repeats; ++i) {
lora_send(ack);
if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) {
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();
}
static void reset_batch_rx() {
batch_reassembly_reset(g_batch_rx);
}
static bool process_batch_packet(const LoraPacket &pkt, BatchInput &out_batch, bool &decode_error, uint16_t &out_batch_id) {
decode_error = false;
if (pkt.payload_len < BATCH_HEADER_SIZE) {
return false;
}
uint16_t batch_id = read_u16_le(&pkt.payload[0]);
uint8_t chunk_index = pkt.payload[2];
uint8_t chunk_count = pkt.payload[3];
uint16_t total_len = read_u16_le(&pkt.payload[4]);
const uint8_t *chunk_data = &pkt.payload[BATCH_HEADER_SIZE];
size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE;
uint32_t now_ms = millis();
uint16_t complete_len = 0;
BatchReassemblyStatus reassembly_status = batch_reassembly_push(
g_batch_rx, batch_id, chunk_index, chunk_count, total_len, chunk_data, chunk_len, now_ms,
compute_batch_rx_timeout_ms(total_len, chunk_count), BATCH_MAX_COMPRESSED, g_batch_rx_buffer,
sizeof(g_batch_rx_buffer), complete_len);
if (reassembly_status == BatchReassemblyStatus::ErrorReset) {
return false;
}
if (reassembly_status == BatchReassemblyStatus::InProgress) {
return false;
}
if (reassembly_status == BatchReassemblyStatus::Complete) {
if (!decode_batch(g_batch_rx_buffer, complete_len, &out_batch)) {
decode_error = true;
return false;
}
out_batch_id = batch_id;
return true;
}
return false;
}
static void receiver_loop() {
watchdog_kick();
LoraPacket pkt = {};
if (lora_receive(pkt, 0)) {
if (pkt.msg_kind == LoraMsgKind::BatchUp) {
BatchInput batch = {};
bool decode_error = false;
uint16_t batch_id = 0;
if (process_batch_packet(pkt, batch, decode_error, batch_id)) {
int8_t sender_idx = -1;
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
sender_idx = static_cast<int8_t>(i);
break;
}
}
if (sender_idx < 0) {
receiver_note_rx_reject(RxRejectReason::UnknownSender, "batch");
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);
serial_debug_printf("batch: reject unknown_sender short_id=%04X sender_id=%u batch_id=%u",
pkt.device_id_short,
static_cast<unsigned>(batch.sender_id),
static_cast<unsigned>(batch_id));
goto receiver_loop_done;
}
uint16_t expected_sender_id = static_cast<uint16_t>(sender_idx + 1);
if (batch.sender_id != expected_sender_id) {
receiver_note_rx_reject(RxRejectReason::DeviceIdMismatch, "batch");
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);
serial_debug_printf("batch: reject device_id_mismatch short_id=%04X sender_id=%u expected=%u batch_id=%u",
pkt.device_id_short,
static_cast<unsigned>(batch.sender_id),
static_cast<unsigned>(expected_sender_id),
static_cast<unsigned>(batch_id));
goto receiver_loop_done;
}
bool duplicate = g_last_batch_id_rx[sender_idx] == batch_id;
SenderStatus &status = g_sender_statuses[sender_idx];
if (status.rx_batches_total < UINT32_MAX) {
status.rx_batches_total++;
}
if (duplicate) {
if (status.rx_batches_duplicate < UINT32_MAX) {
status.rx_batches_duplicate++;
}
uint32_t duplicate_ts = time_get_utc();
if (duplicate_ts == 0) {
duplicate_ts = batch.t_last;
}
status.rx_last_duplicate_ts_utc = duplicate_ts;
}
send_batch_ack(batch_id, batch.n);
if (duplicate) {
goto receiver_loop_done;
}
g_last_batch_id_rx[sender_idx] = batch_id;
if (batch.n == 0) {
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);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
if (bit_count32(batch.present_mask) != batch.n) {
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;
}
size_t count = batch.n;
uint16_t short_id = pkt.device_id_short;
if (short_id == 0) {
short_id = short_id_from_sender_id(batch.sender_id);
}
if (batch.t_last < static_cast<uint32_t>(METER_BATCH_MAX_SAMPLES - 1) || 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);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
const uint32_t window_start = batch.t_last - static_cast<uint32_t>(METER_BATCH_MAX_SAMPLES - 1);
MeterData samples[METER_BATCH_MAX_SAMPLES];
float bat_v = batch.battery_mV > 0 ? static_cast<float>(batch.battery_mV) / 1000.0f : NAN;
size_t s = 0;
for (uint8_t slot = 0; slot < METER_BATCH_MAX_SAMPLES; ++slot) {
if ((batch.present_mask & (1UL << slot)) == 0) {
continue;
}
if (s >= count) {
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 &data = samples[s];
data = {};
data.short_id = short_id;
if (short_id != 0) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id);
} else {
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
}
data.ts_utc = window_start + static_cast<uint32_t>(slot);
if (data.ts_utc < 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;
}
data.energy_total_kwh = static_cast<float>(batch.energy_wh[s]) / 1000.0f;
data.phase_power_w[0] = static_cast<float>(batch.p1_w[s]);
data.phase_power_w[1] = static_cast<float>(batch.p2_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.battery_voltage_v = bat_v;
data.battery_percent = !isnan(bat_v) ? battery_percent_from_voltage(bat_v) : 0;
data.valid = true;
data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db;
data.err_meter_read = batch.err_m;
data.err_decode = batch.err_d;
data.err_lora_tx = batch.err_tx;
data.last_error = static_cast<FaultType>(batch.err_last);
data.rx_reject_reason = batch.err_rx_reject;
sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None);
s++;
}
if (s != count) {
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;
}
web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count);
for (size_t s = 0; s < count; ++s) {
mqtt_publish_sample(samples[s]);
}
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].has_data = true;
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_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_ms[sender_idx] = millis();
if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[sender_idx]) {
g_sender_discovery_sent[sender_idx] = mqtt_publish_discovery(samples[count - 1].device_id);
}
publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[sender_idx], g_sender_faults_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]);
} 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);
}
}
}
receiver_loop_done:
mqtt_loop();
web_server_loop();
if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) {
g_receiver_discovery_sent = mqtt_publish_discovery(g_device_id);
}
publish_faults_if_needed(g_device_id, g_receiver_faults, g_receiver_faults_published,
g_receiver_last_error, g_receiver_last_error_published, g_receiver_last_error_utc, g_receiver_last_error_ms);
display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
display_tick();
watchdog_kick();
}
} // namespace
bool ReceiverPipeline::begin(const ReceiverPipelineConfig &config) {
if (!config.shared) {
return false;
}
g_shared = config.shared;
*g_shared = {};
g_short_id = config.short_id;
if (config.device_id) {
strncpy(g_device_id, config.device_id, sizeof(g_device_id));
g_device_id[sizeof(g_device_id) - 1] = '\0';
} else {
g_device_id[0] = '\0';
}
init_sender_statuses();
reset_batch_rx();
g_receiver_rx_reject_reason = RxRejectReason::None;
g_receiver_rx_reject_log_ms = 0;
return true;
}
void ReceiverPipeline::loop() {
if (!g_shared) {
return;
}
receiver_loop();
}
ReceiverStats ReceiverPipeline::stats() const {
ReceiverStats stats = {};
if (!g_shared) {
return stats;
}
stats.receiver_decode_fail = g_receiver_faults.decode_fail;
stats.receiver_lora_tx_fail = g_receiver_faults.lora_tx_fail;
stats.last_rx_reject = g_receiver_rx_reject_reason;
stats.receiver_discovery_sent = g_receiver_discovery_sent;
return stats;
}

27
src/receiver_pipeline.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include "app_context.h"
#include "data_model.h"
struct ReceiverPipelineConfig {
uint16_t short_id;
const char *device_id;
ReceiverSharedState *shared;
};
struct ReceiverStats {
uint32_t receiver_decode_fail;
uint32_t receiver_lora_tx_fail;
RxRejectReason last_rx_reject;
bool receiver_discovery_sent;
};
class ReceiverPipeline {
public:
bool begin(const ReceiverPipelineConfig &config);
void loop();
ReceiverStats stats() const;
};

View File

@@ -27,30 +27,30 @@ static bool ensure_dir(const String &path) {
return SD.mkdir(path);
}
static String format_date_utc(uint32_t ts_utc) {
static String format_date_local(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc;
gmtime_r(&t, &tm_utc);
struct tm tm_local;
localtime_r(&t, &tm_local);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
tm_utc.tm_year + 1900,
tm_utc.tm_mon + 1,
tm_utc.tm_mday);
tm_local.tm_year + 1900,
tm_local.tm_mon + 1,
tm_local.tm_mday);
return String(buf);
}
static String format_hms_utc(uint32_t ts_utc) {
static String format_hms_local(uint32_t ts_utc) {
if (ts_utc == 0) {
return "";
}
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc;
gmtime_r(&t, &tm_utc);
struct tm tm_local;
localtime_r(&t, &tm_local);
char buf[16];
snprintf(buf, sizeof(buf), "%02d:%02d:%02d",
tm_utc.tm_hour,
tm_utc.tm_min,
tm_utc.tm_sec);
tm_local.tm_hour,
tm_local.tm_min,
tm_local.tm_sec);
return String(buf);
}
@@ -94,7 +94,7 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) {
return;
}
String filename = sender_dir + "/" + format_date_utc(data.ts_utc) + ".csv";
String filename = sender_dir + "/" + format_date_local(data.ts_utc) + ".csv";
bool new_file = !SD.exists(filename);
File f = SD.open(filename, FILE_APPEND);
if (!f) {
@@ -102,13 +102,13 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) {
}
if (new_file) {
f.println("ts_utc,ts_hms_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last");
f.println("ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last");
}
String ts_hms_utc = format_hms_utc(data.ts_utc);
String ts_hms_local = format_hms_local(data.ts_utc);
f.print(data.ts_utc);
f.print(',');
f.print(ts_hms_utc);
f.print(ts_hms_local);
f.print(',');
f.print(data.total_power_w, 1);
f.print(',');

1604
src/sender_state_machine.cpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
struct SenderStateMachineConfig {
uint16_t short_id;
const char *device_id;
};
struct SenderStats {
uint8_t queue_depth;
uint8_t build_count;
uint16_t inflight_batch_id;
uint16_t last_sent_batch_id;
uint16_t last_acked_batch_id;
uint8_t retry_count;
bool ack_pending;
uint32_t ack_timeout_total;
uint32_t ack_retry_total;
uint32_t ack_miss_streak;
uint32_t rx_window_ms;
uint32_t sleep_ms;
};
class SenderStateMachine {
public:
bool begin(const SenderStateMachineConfig &config);
void loop();
SenderStats stats() const;
private:
enum class State : uint8_t {
Syncing = 0,
Normal = 1,
Catchup = 2,
WaitAck = 3
};
void handleMeterRead(uint32_t now_ms);
void maybeSendBatch(uint32_t now_ms);
void handleAckWindow(uint32_t now_ms);
bool applyTimeFromAck(uint8_t time_valid, uint32_t ack_epoch);
void validateInvariants();
};

View File

@@ -1,10 +1,15 @@
#include "time_manager.h"
#include "config.h"
#include <time.h>
#ifdef ARDUINO_ARCH_ESP32
#include <esp_sntp.h>
#endif
static bool g_time_synced = false;
static bool g_clock_plausible = false;
static bool g_tz_set = false;
static uint32_t g_last_sync_utc = 0;
static constexpr uint32_t MIN_PLAUSIBLE_EPOCH_UTC = 1672531200UL; // 2023-01-01 00:00:00 UTC
static void note_last_sync(uint32_t epoch) {
if (epoch == 0) {
@@ -13,6 +18,32 @@ static void note_last_sync(uint32_t epoch) {
g_last_sync_utc = epoch;
}
static bool epoch_is_plausible(time_t epoch) {
return epoch >= static_cast<time_t>(MIN_PLAUSIBLE_EPOCH_UTC);
}
static void mark_synced(uint32_t epoch) {
if (epoch == 0) {
return;
}
g_time_synced = true;
g_clock_plausible = true;
note_last_sync(epoch);
}
#ifdef ARDUINO_ARCH_ESP32
static void ntp_sync_notification_cb(struct timeval *tv) {
time_t epoch = tv ? tv->tv_sec : time(nullptr);
if (!epoch_is_plausible(epoch)) {
return;
}
if (epoch > static_cast<time_t>(UINT32_MAX)) {
return;
}
mark_synced(static_cast<uint32_t>(epoch));
}
#endif
static void ensure_timezone_set() {
if (g_tz_set) {
return;
@@ -25,24 +56,31 @@ static void ensure_timezone_set() {
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 *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov";
#ifdef ARDUINO_ARCH_ESP32
sntp_set_time_sync_notification_cb(ntp_sync_notification_cb);
#endif
configTime(0, 0, server1, server2);
ensure_timezone_set();
}
uint32_t time_get_utc() {
time_t now = time(nullptr);
if (now < 1672531200) {
if (!epoch_is_plausible(now)) {
g_clock_plausible = false;
return 0;
}
if (!g_time_synced) {
g_time_synced = true;
note_last_sync(static_cast<uint32_t>(now));
g_clock_plausible = true;
#ifdef ARDUINO_ARCH_ESP32
if (!g_time_synced && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
mark_synced(static_cast<uint32_t>(now));
}
#endif
return static_cast<uint32_t>(now);
}
bool time_is_synced() {
return g_time_synced || time_get_utc() > 0;
(void)time_get_utc();
return g_time_synced && g_clock_plausible;
}
void time_set_utc(uint32_t epoch) {
@@ -51,8 +89,12 @@ void time_set_utc(uint32_t epoch) {
tv.tv_sec = epoch;
tv.tv_usec = 0;
settimeofday(&tv, nullptr);
g_time_synced = true;
note_last_sync(epoch);
if (epoch_is_plausible(static_cast<time_t>(epoch))) {
mark_synced(epoch);
} else {
g_clock_plausible = false;
g_time_synced = false;
}
}
void time_get_local_hhmm(char *out, size_t out_len) {

View File

@@ -243,7 +243,16 @@ static void history_reset() {
g_history = {};
}
static String history_date_from_epoch(uint32_t ts_utc) {
static String history_date_from_epoch_local(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_local;
localtime_r(&t, &tm_local);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d", tm_local.tm_year + 1900, tm_local.tm_mon + 1, tm_local.tm_mday);
return String(buf);
}
static String history_date_from_epoch_utc(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc;
gmtime_r(&t, &tm_utc);
@@ -252,6 +261,40 @@ static String history_date_from_epoch(uint32_t ts_utc) {
return String(buf);
}
static bool history_parse_u32_field(const char *start, size_t len, uint32_t &out) {
if (!start || len == 0 || len >= 16) {
return false;
}
char buf[16];
memcpy(buf, start, len);
buf[len] = '\0';
char *end = nullptr;
unsigned long value = strtoul(buf, &end, 10);
if (end == buf || *end != '\0' || value > static_cast<unsigned long>(UINT32_MAX)) {
return false;
}
out = static_cast<uint32_t>(value);
return true;
}
static bool history_parse_float_field(const char *start, size_t len, float &out) {
if (!start || len == 0 || len >= 24) {
return false;
}
char buf[24];
memcpy(buf, start, len);
buf[len] = '\0';
char *end = nullptr;
float value = strtof(buf, &end);
if (end == buf || *end != '\0') {
return false;
}
out = value;
return true;
}
static bool history_open_next_file() {
if (!g_history.active || g_history.done || g_history.error) {
return false;
@@ -264,8 +307,17 @@ static bool history_open_next_file() {
g_history.done = true;
return false;
}
String path = String("/dd3/") + g_history.device_id + "/" + history_date_from_epoch(day_ts) + ".csv";
String local_date = history_date_from_epoch_local(day_ts);
String path = String("/dd3/") + g_history.device_id + "/" + local_date + ".csv";
g_history.file = SD.open(path.c_str(), FILE_READ);
if (!g_history.file) {
// Compatibility fallback for files written before local-date partitioning.
String utc_date = history_date_from_epoch_utc(day_ts);
if (utc_date != local_date) {
String legacy_path = String("/dd3/") + g_history.device_id + "/" + utc_date + ".csv";
g_history.file = SD.open(legacy_path.c_str(), FILE_READ);
}
}
g_history.day_index++;
return true;
}
@@ -274,40 +326,32 @@ static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out)
if (!line || line[0] < '0' || line[0] > '9') {
return false;
}
const char *comma1 = strchr(line, ',');
if (!comma1) {
return false;
}
char ts_buf[16];
size_t ts_len = static_cast<size_t>(comma1 - line);
if (ts_len >= sizeof(ts_buf)) {
return false;
}
memcpy(ts_buf, line, ts_len);
ts_buf[ts_len] = '\0';
char *end = nullptr;
uint32_t ts = static_cast<uint32_t>(strtoul(ts_buf, &end, 10));
if (end == ts_buf) {
uint32_t ts = 0;
if (!history_parse_u32_field(line, static_cast<size_t>(comma1 - line), ts)) {
return false;
}
const char *comma2 = strchr(comma1 + 1, ',');
if (!comma2) {
return false;
}
const char *p_start = comma2 + 1;
const char *p_end = strchr(p_start, ',');
char p_buf[16];
size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start);
if (p_len == 0 || p_len >= sizeof(p_buf)) {
return false;
}
memcpy(p_buf, p_start, p_len);
p_buf[p_len] = '\0';
char *endp = nullptr;
float p = strtof(p_buf, &endp);
if (endp == p_buf) {
return false;
float p = 0.0f;
if (!history_parse_float_field(comma1 + 1, static_cast<size_t>(comma2 - (comma1 + 1)), p)) {
const char *p_start = comma2 + 1;
const char *p_end = strchr(p_start, ',');
size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start);
if (!history_parse_float_field(p_start, p_len, p)) {
return false;
}
}
ts_out = ts;
p_out = p;
return true;
@@ -567,10 +611,21 @@ static void handle_wifi_post() {
cfg.ntp_server_2 = server.arg("ntp2");
}
cfg.valid = true;
if (!wifi_save_config(cfg)) {
if (SERIAL_DEBUG_MODE) {
Serial.println("wifi_cfg: save failed, reboot cancelled");
}
String html = html_header("WiFi/MQTT Config");
html += "<p style='color:#b00020;'>Save failed. Configuration was not persisted and reboot was cancelled.</p>";
html += "<p><a href='/wifi'>Back to config</a></p>";
html += html_footer();
server.send(500, "text/html", html);
return;
}
g_config = cfg;
g_web_user = cfg.web_user;
g_web_pass = cfg.web_pass;
wifi_save_config(cfg);
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
delay(1000);
ESP.restart();
@@ -638,10 +693,11 @@ static void handle_sender() {
html += "if(min===max){min=0;}";
html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();";
html += "let first=true;";
html += "const xDen=series.length>1?(series.length-1):1;";
html += "for(let i=0;i<series.length;i++){";
html += "const v=series[i][1];";
html += "if(v===null)continue;";
html += "const x=(i/(series.length-1))* (w-2) + 1;";
html += "const x=series.length>1?((i/xDen)*(w-2)+1):(w/2);";
html += "const y=h-2-((v-min)/(max-min))*(h-4);";
html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }";
html += "ctx.stroke();";
@@ -698,7 +754,7 @@ static void handle_manual() {
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_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=invalid_msg_kind, 3=length_mismatch, 4=device_id_mismatch, 5=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, 6=unknown_sender).</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 += "</ul>";

View File

@@ -5,6 +5,59 @@
static Preferences prefs;
static bool wifi_log_save_failure(const char *key, const char *reason) {
if (SERIAL_DEBUG_MODE) {
Serial.printf("wifi_cfg: save failed key=%s reason=%s\n", key, reason);
}
return false;
}
static bool wifi_write_string_pref(const char *key, const String &value) {
size_t written = prefs.putString(key, value);
if (written != value.length()) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
String readback = prefs.getString(key, "");
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
static bool wifi_write_bool_pref(const char *key, bool value) {
size_t written = prefs.putBool(key, value);
if (written != sizeof(uint8_t)) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
bool readback = prefs.getBool(key, !value);
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
static bool wifi_write_ushort_pref(const char *key, uint16_t value) {
size_t written = prefs.putUShort(key, value);
if (written != sizeof(uint16_t)) {
return wifi_log_save_failure(key, "write_short");
}
if (!prefs.isKey(key)) {
return wifi_log_save_failure(key, "missing_key");
}
uint16_t fallback = value == static_cast<uint16_t>(0xFFFF) ? 0 : static_cast<uint16_t>(0xFFFF);
uint16_t readback = prefs.getUShort(key, fallback);
if (readback != value) {
return wifi_log_save_failure(key, "verify_mismatch");
}
return true;
}
void wifi_manager_init() {
prefs.begin("dd3cfg", false);
}
@@ -28,17 +81,39 @@ bool wifi_load_config(WifiMqttConfig &config) {
}
bool wifi_save_config(const WifiMqttConfig &config) {
prefs.putBool("valid", true);
prefs.putString("ssid", config.ssid);
prefs.putString("pass", config.password);
prefs.putString("mqhost", config.mqtt_host);
prefs.putUShort("mqport", config.mqtt_port);
prefs.putString("mquser", config.mqtt_user);
prefs.putString("mqpass", config.mqtt_pass);
prefs.putString("ntp1", config.ntp_server_1);
prefs.putString("ntp2", config.ntp_server_2);
prefs.putString("webuser", config.web_user);
prefs.putString("webpass", config.web_pass);
if (!wifi_write_bool_pref("valid", true)) {
return false;
}
if (!wifi_write_string_pref("ssid", config.ssid)) {
return false;
}
if (!wifi_write_string_pref("pass", config.password)) {
return false;
}
if (!wifi_write_string_pref("mqhost", config.mqtt_host)) {
return false;
}
if (!wifi_write_ushort_pref("mqport", config.mqtt_port)) {
return false;
}
if (!wifi_write_string_pref("mquser", config.mqtt_user)) {
return false;
}
if (!wifi_write_string_pref("mqpass", config.mqtt_pass)) {
return false;
}
if (!wifi_write_string_pref("ntp1", config.ntp_server_1)) {
return false;
}
if (!wifi_write_string_pref("ntp2", config.ntp_server_2)) {
return false;
}
if (!wifi_write_string_pref("webuser", config.web_user)) {
return false;
}
if (!wifi_write_string_pref("webpass", config.web_pass)) {
return false;
}
return true;
}