Add RX reject reasons to telemetry and UI

BACKWARD-INCOMPATIBLE: MeterBatch schema bumped to v2 with err_rx_reject.
- Track and log RX reject reasons (CRC/protocol/role/payload/length/id/batch)
- Include rx_reject in sender telemetry JSON and receiver web UI
- Add lora_receive reject reason logging under SERIAL_DEBUG_MODE
This commit is contained in:
2026-02-04 01:01:49 +01:00
parent 0e7214d606
commit 1024aa3dd0
10 changed files with 143 additions and 27 deletions

View File

@@ -203,14 +203,16 @@ MeterData JSON (sender + MQTT):
"p2_w": 450.00,
"p3_w": 0.00,
"bat_v": 3.92,
"bat_pct": 78
"bat_pct": 78,
"rx_reject": 0,
"rx_reject_text": "none"
}
```
### Binary MeterBatch Payload (LoRa)
Fixed header (little-endian):
- `magic` u16 = 0xDDB3
- `schema` u8 = 1
- `schema` u8 = 2
- `flags` u8 = 0x01 (bit0 = signed phases)
- `sender_id` u16 (1..NUM_SENDERS, maps to `EXPECTED_SENDER_IDS`)
- `batch_id` u16
@@ -222,6 +224,8 @@ Fixed header (little-endian):
- `err_d` u8 (decode failures, sender-side counter)
- `err_tx` u8 (LoRa TX failures, sender-side counter)
- `err_last` u8 (last error code: 0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync)
- `err_rx_reject` u8 (last RX reject reason)
- `err_rx_reject` u8 (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)
- MQTT faults payload also includes `err_last_text` (string) and `err_last_age` (seconds).
Body:

View File

@@ -10,6 +10,17 @@ enum class FaultType : uint8_t {
TimeSync = 4
};
enum class RxRejectReason : uint8_t {
None = 0,
CrcFail = 1,
BadProtocol = 2,
WrongRole = 3,
WrongPayloadType = 4,
LengthMismatch = 5,
DeviceIdMismatch = 6,
BatchIdMismatch = 7
};
struct FaultCounters {
uint32_t meter_read_fail;
uint32_t decode_fail;
@@ -33,6 +44,7 @@ struct MeterData {
uint32_t err_decode;
uint32_t err_lora_tx;
FaultType last_error;
uint8_t rx_reject_reason;
};
struct SenderStatus {
@@ -42,3 +54,4 @@ struct SenderStatus {
};
void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len);
const char *rx_reject_reason_text(RxRejectReason reason);

View File

@@ -2,6 +2,7 @@
#include <Arduino.h>
#include "config.h"
#include "data_model.h"
constexpr size_t LORA_MAX_PAYLOAD = 230;
@@ -19,6 +20,7 @@ struct LoraPacket {
void lora_init();
bool lora_send(const LoraPacket &pkt);
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
RxRejectReason lora_get_last_rx_reject_reason();
void lora_idle();
void lora_sleep();
void lora_receive_continuous();

View File

@@ -8,3 +8,24 @@ void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len)
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::BadProtocol:
return "bad_protocol_version";
case RxRejectReason::WrongRole:
return "wrong_role";
case RxRejectReason::WrongPayloadType:
return "wrong_payload_type";
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

@@ -118,9 +118,9 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
if (data.err_lora_tx > 0) {
doc["err_tx"] = data.err_lora_tx;
}
if (data.last_error != FaultType::None) {
doc["err_last"] = static_cast<uint8_t>(data.last_error);
}
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);
@@ -162,6 +162,7 @@ bool jsonToMeterData(const String &json, MeterData &data) {
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;
@@ -196,9 +197,7 @@ bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id,
doc["err_tx"] = faults->lora_tx_fail;
}
}
if (last_error != FaultType::None) {
doc["err_last"] = static_cast<uint8_t>(last_error);
}
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);

View File

@@ -3,6 +3,26 @@
#include <SPI.h>
#include <math.h>
static RxRejectReason g_last_rx_reject_reason = RxRejectReason::None;
static uint32_t g_last_rx_reject_log_ms = 0;
static void note_reject(RxRejectReason reason) {
g_last_rx_reject_reason = reason;
if (SERIAL_DEBUG_MODE) {
uint32_t now_ms = millis();
if (now_ms - g_last_rx_reject_log_ms >= 1000) {
g_last_rx_reject_log_ms = now_ms;
Serial.printf("lora_rx: reject reason=%s\n", rx_reject_reason_text(reason));
}
}
}
RxRejectReason lora_get_last_rx_reject_reason() {
RxRejectReason reason = g_last_rx_reject_reason;
g_last_rx_reject_reason = RxRejectReason::None;
return reason;
}
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
@@ -95,6 +115,7 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
while (LoRa.available()) {
LoRa.read();
}
note_reject(RxRejectReason::LengthMismatch);
return false;
}
@@ -105,15 +126,18 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
}
if (len < 7) {
note_reject(RxRejectReason::LengthMismatch);
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) {
note_reject(RxRejectReason::CrcFail);
return false;
}
if (buffer[0] != PROTOCOL_VERSION) {
note_reject(RxRejectReason::BadProtocol);
return false;
}
@@ -123,6 +147,7 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
pkt.payload_type = static_cast<PayloadType>(buffer[4]);
pkt.payload_len = len - 7;
if (pkt.payload_len > LORA_MAX_PAYLOAD) {
note_reject(RxRejectReason::LengthMismatch);
return false;
}
memcpy(pkt.payload, &buffer[5], pkt.payload_len);

View File

@@ -105,6 +105,8 @@ static uint32_t g_sender_rx_window_ms = 0;
static uint32_t g_sender_sleep_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 uint32_t g_sender_rx_reject_log_ms = 0;
static MeterData g_last_meter_data = {};
static bool g_last_meter_valid = false;
static uint32_t g_last_meter_rx_ms = 0;
@@ -253,6 +255,18 @@ static void receiver_note_timesync_drift(uint8_t sender_idx, uint32_t sender_ts_
}
}
static void sender_note_rx_reject(RxRejectReason reason, const char *context) {
if (reason == RxRejectReason::None) {
return;
}
g_sender_rx_reject_reason = reason;
uint32_t now_ms = millis();
if (SERIAL_DEBUG_MODE && now_ms - g_sender_rx_reject_log_ms >= 1000) {
g_sender_rx_reject_log_ms = now_ms;
serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason));
}
}
static BatchBuffer *batch_queue_peek() {
if (g_batch_count == 0) {
return nullptr;
@@ -548,6 +562,7 @@ static bool send_inflight_batch(uint32_t ts_for_display) {
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_last = static_cast<uint8_t>(g_sender_last_error);
input.err_rx_reject = static_cast<uint8_t>(g_sender_rx_reject_reason);
for (uint8_t i = 0; i < g_inflight_count; ++i) {
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]) ||
@@ -824,6 +839,7 @@ static void sender_loop() {
}
data.battery_voltage_v = g_last_battery_voltage_v;
data.battery_percent = g_last_battery_percent;
data.rx_reject_reason = static_cast<uint8_t>(g_sender_rx_reject_reason);
uint32_t now_utc = time_get_utc();
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
@@ -852,7 +868,15 @@ static void sender_loop() {
if (SERIAL_DEBUG_MODE) {
g_sender_rx_window_ms += rx_elapsed;
}
if (got_ack && ack_pkt.payload_type == PayloadType::Ack && ack_pkt.payload_len >= 6 && ack_pkt.role == DeviceRole::Receiver) {
if (!got_ack) {
sender_note_rx_reject(lora_get_last_rx_reject_reason(), "ack");
} else if (ack_pkt.role != DeviceRole::Receiver) {
sender_note_rx_reject(RxRejectReason::WrongRole, "ack");
} else if (ack_pkt.payload_type != PayloadType::Ack) {
sender_note_rx_reject(RxRejectReason::WrongPayloadType, "ack");
} else if (ack_pkt.payload_len < 6) {
sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack");
} else {
uint16_t ack_id = read_u16_le(ack_pkt.payload);
uint16_t ack_sender = read_u16_le(&ack_pkt.payload[2]);
uint16_t ack_receiver = read_u16_le(&ack_pkt.payload[4]);
@@ -861,9 +885,16 @@ static void sender_loop() {
g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: rx ok batch_id=%u", ack_id);
finish_inflight_batch();
} else 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);
} else {
if (ack_sender != g_short_id || ack_receiver != ack_pkt.device_id_short) {
sender_note_rx_reject(RxRejectReason::DeviceIdMismatch, "ack");
} else if (ack_id != g_last_sent_batch_id) {
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);
}
}
}
}
@@ -878,17 +909,27 @@ static void sender_loop() {
if (SERIAL_DEBUG_MODE) {
g_sender_rx_window_ms += rx_elapsed;
}
if (got && rx.payload_type == PayloadType::TimeSync) {
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));
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));
}
} else 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)
@@ -1063,6 +1104,7 @@ static void receiver_loop() {
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);
}

View File

@@ -2,7 +2,7 @@
#include <limits.h>
static constexpr uint16_t kMagic = 0xDDB3;
static constexpr uint8_t kSchema = 1;
static constexpr uint8_t kSchema = 2;
static constexpr uint8_t kFlags = 0x01;
static constexpr size_t kMaxSamples = 30;
@@ -108,7 +108,7 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
return false;
}
size_t pos = 0;
if (!ensure_capacity(20, out_cap, pos)) {
if (!ensure_capacity(21, out_cap, pos)) {
return false;
}
write_u16_le(&out[pos], kMagic);
@@ -129,6 +129,7 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
out[pos++] = in.err_d;
out[pos++] = in.err_tx;
out[pos++] = in.err_last;
out[pos++] = in.err_rx_reject;
if (!ensure_capacity(4, out_cap, pos)) {
return false;
@@ -183,7 +184,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
return false;
}
size_t pos = 0;
if (len < 20) {
if (len < 21) {
return false;
}
uint16_t magic = read_u16_le(&buf[pos]);
@@ -207,6 +208,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
out->err_d = buf[pos++];
out->err_tx = buf[pos++];
out->err_last = buf[pos++];
out->err_rx_reject = buf[pos++];
if (out->n == 0 || out->n > kMaxSamples || out->dt_s == 0) {
return false;
@@ -283,6 +285,7 @@ bool payload_codec_self_test() {
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;
@@ -319,7 +322,8 @@ bool payload_codec_self_test() {
if (out.sender_id != in.sender_id || out.batch_id != in.batch_id || out.t_last != in.t_last ||
out.dt_s != in.dt_s || 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_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;
}

View File

@@ -13,6 +13,7 @@ struct BatchInput {
uint8_t err_d;
uint8_t err_tx;
uint8_t err_last;
uint8_t err_rx_reject;
uint32_t energy_wh[30];
int16_t p1_w[30];
int16_t p2_w[30];

View File

@@ -352,6 +352,8 @@ static String render_sender_block(const SenderStatus &status) {
s += " err_tx:" + String(status.last_data.err_lora_tx);
s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error));
s += " (" + String(fault_text(status.last_data.last_error)) + ")";
s += " rx_reject:" + String(status.last_data.rx_reject_reason);
s += " (" + String(rx_reject_reason_text(static_cast<RxRejectReason>(status.last_data.rx_reject_reason))) + ")";
}
s += format_faults(idx);
s += "<br>";
@@ -589,7 +591,7 @@ static void handle_sender() {
html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
html += "<table border='1' cellspacing='0' cellpadding='3'>";
html += "<tr><th>#</th><th>ts</th><th>e_kwh</th><th>p_w</th><th>p1_w</th><th>p2_w</th><th>p3_w</th>";
html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th></tr>";
html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th><th>rx_reject</th></tr>";
for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) {
const MeterData &d = g_last_batch[i][r];
html += "<tr>";
@@ -606,6 +608,8 @@ static void handle_sender() {
html += "<td>" + String(d.link_snr_db, 1) + "</td>";
html += "<td>" + String(d.err_lora_tx) + "</td>";
html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + " (" + String(fault_text(d.last_error)) + ")</td>";
html += "<td>" + String(d.rx_reject_reason) + " (" +
String(rx_reject_reason_text(static_cast<RxRejectReason>(d.rx_reject_reason))) + ")</td>";
html += "</tr>";
}
html += "</table>";
@@ -631,6 +635,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, 4=TimeSync).</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>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>";