diff --git a/README.md b/README.md index 22afcb8..a9ea7dd 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/include/data_model.h b/include/data_model.h index 1871d67..cb30367 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -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); diff --git a/include/lora_transport.h b/include/lora_transport.h index c8c2646..97e3b75 100644 --- a/include/lora_transport.h +++ b/include/lora_transport.h @@ -2,6 +2,7 @@ #include #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(); diff --git a/src/data_model.cpp b/src/data_model.cpp index f1bc694..fbdc0e0 100644 --- a/src/data_model.cpp +++ b/src/data_model.cpp @@ -8,3 +8,24 @@ void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len) short_id = (static_cast(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"; + } +} diff --git a/src/json_codec.cpp b/src/json_codec.cpp index e3e88c6..765b53d 100644 --- a/src/json_codec.cpp +++ b/src/json_codec.cpp @@ -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(data.last_error); - } + doc["err_last"] = static_cast(data.last_error); + doc["rx_reject"] = data.rx_reject_reason; + doc["rx_reject_text"] = rx_reject_reason_text(static_cast(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(doc["err_last"] | 0); + data.rx_reject_reason = static_cast(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(last_error); - } + doc["err_last"] = static_cast(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); diff --git a/src/lora_transport.cpp b/src/lora_transport.cpp index d315176..80443be 100644 --- a/src/lora_transport.cpp +++ b/src/lora_transport.cpp @@ -3,6 +3,26 @@ #include #include +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(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(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); diff --git a/src/main.cpp b/src/main.cpp index 33d9a85..4b1e145 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(g_sender_faults.decode_fail); input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast(g_sender_faults.lora_tx_fail); input.err_last = static_cast(g_sender_last_error); + input.err_rx_reject = static_cast(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(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(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(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(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(window_ms)); } - } else if (SERIAL_DEBUG_MODE) { - serial_debug_printf("timesync: rx miss window_ms=%lu", static_cast(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(batch.err_last); + data.rx_reject_reason = batch.err_rx_reject; sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None); } diff --git a/src/payload_codec.cpp b/src/payload_codec.cpp index 7781f78..6403057 100644 --- a/src/payload_codec.cpp +++ b/src/payload_codec.cpp @@ -2,7 +2,7 @@ #include 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; } diff --git a/src/payload_codec.h b/src/payload_codec.h index 36f388e..8887bd1 100644 --- a/src/payload_codec.h +++ b/src/payload_codec.h @@ -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]; diff --git a/src/web_server.cpp b/src/web_server.cpp index 0d24b98..ae40b03 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -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(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(status.last_data.rx_reject_reason))) + ")"; } s += format_faults(idx); s += "
"; @@ -589,7 +591,7 @@ static void handle_sender() { html += "

Last batch (" + String(g_last_batch_count[i]) + " samples)

"; html += ""; html += ""; - html += ""; + html += ""; for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) { const MeterData &d = g_last_batch[i][r]; html += ""; @@ -606,6 +608,8 @@ static void handle_sender() { html += ""; html += ""; html += ""; + html += ""; html += ""; } html += "
#tse_kwhp_wp1_wp2_wp3_wbat_vbat_pctrssisnrerr_txerr_last
bat_vbat_pctrssisnrerr_txerr_lastrx_reject
" + String(d.link_snr_db, 1) + "" + String(d.err_lora_tx) + "" + String(static_cast(d.last_error)) + " (" + String(fault_text(d.last_error)) + ")" + String(d.rx_reject_reason) + " (" + + String(rx_reject_reason_text(static_cast(d.rx_reject_reason))) + ")
"; @@ -631,6 +635,7 @@ static void handle_manual() { html += "
  • RSSI/SNR: LoRa link quality from last packet.
  • "; html += "
  • err_tx: sender-side LoRa TX error counter.
  • "; html += "
  • err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).
  • "; + html += "
  • 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).
  • "; html += "
  • faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).
  • "; html += "
  • faults last: last receiver-side error code (same mapping as err_last).
  • "; html += "";