From eb80f4904681d46cdd54e21169685e27f65d9681 Mon Sep 17 00:00:00 2001 From: acidburns Date: Wed, 4 Feb 2026 20:12:02 +0100 Subject: [PATCH] test: align test mode with ACK/time flow and expose ack metrics --- include/data_model.h | 1 + src/main.cpp | 2 + src/test_mode.cpp | 148 +++++++++++++++++++++++++++++++++++++++++-- src/web_server.cpp | 1 + 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/include/data_model.h b/include/data_model.h index a2eac9e..1f47279 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -50,6 +50,7 @@ struct MeterData { struct SenderStatus { MeterData last_data; uint32_t last_update_ts_utc; + uint16_t last_acked_batch_id; bool has_data; }; diff --git a/src/main.cpp b/src/main.cpp index f808459..b737ee8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -133,6 +133,7 @@ static void init_sender_statuses() { g_sender_statuses[i] = {}; g_sender_statuses[i].has_data = false; g_sender_statuses[i].last_update_ts_utc = 0; + g_sender_statuses[i].last_acked_batch_id = 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] = {}; @@ -1056,6 +1057,7 @@ static void receiver_loop() { } if (sender_idx >= 0) { + g_sender_statuses[sender_idx].last_acked_batch_id = batch_id; web_server_set_last_batch(static_cast(sender_idx), samples, count); for (size_t s = 0; s < count; ++s) { mqtt_publish_state(samples[s]); diff --git a/src/test_mode.cpp b/src/test_mode.cpp index d9ce1c7..10c1084 100644 --- a/src/test_mode.cpp +++ b/src/test_mode.cpp @@ -12,8 +12,93 @@ static uint32_t g_last_test_ms = 0; static uint16_t g_test_code_counter = 1000; +static uint16_t g_test_batch_id = 1; +static uint16_t g_test_last_acked_batch_id = 0; static constexpr uint32_t TEST_SEND_INTERVAL_MS = 30000; +static void write_u16_be(uint8_t *dst, uint16_t value) { + dst[0] = static_cast((value >> 8) & 0xFF); + dst[1] = static_cast(value & 0xFF); +} + +static uint16_t read_u16_be(const uint8_t *src) { + return static_cast(src[0] << 8) | static_cast(src[1]); +} + +static void write_u32_be(uint8_t *dst, uint32_t value) { + dst[0] = static_cast((value >> 24) & 0xFF); + dst[1] = static_cast((value >> 16) & 0xFF); + dst[2] = static_cast((value >> 8) & 0xFF); + dst[3] = static_cast(value & 0xFF); +} + +static uint32_t read_u32_be(const uint8_t *src) { + return (static_cast(src[0]) << 24) | + (static_cast(src[1]) << 16) | + (static_cast(src[2]) << 8) | + static_cast(src[3]); +} + +static uint32_t ack_window_ms() { + uint32_t air_ms = lora_airtime_ms(lora_frame_size(LORA_ACK_DOWN_PAYLOAD_LEN)); + uint32_t window_ms = air_ms + 300; + if (window_ms < 1200) { + window_ms = 1200; + } + if (window_ms > 4000) { + window_ms = 4000; + } + return window_ms; +} + +static bool receive_ack_for_batch(uint16_t batch_id, uint8_t &time_valid, uint32_t &ack_epoch, int16_t &rssi_dbm, float &snr_db) { + LoraPacket ack_pkt = {}; + uint32_t window_ms = ack_window_ms(); + bool got_ack = lora_receive_window(ack_pkt, window_ms); + if (!got_ack) { + got_ack = lora_receive_window(ack_pkt, window_ms / 2); + } + if (!got_ack || ack_pkt.msg_kind != LoraMsgKind::AckDown || ack_pkt.payload_len < LORA_ACK_DOWN_PAYLOAD_LEN) { + return false; + } + + uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]); + if (ack_id != batch_id) { + return false; + } + + time_valid = ack_pkt.payload[0] & 0x01; + ack_epoch = read_u32_be(&ack_pkt.payload[3]); + rssi_dbm = ack_pkt.rssi_dbm; + snr_db = ack_pkt.snr_db; + return true; +} + +static void send_test_ack(uint16_t self_short_id, uint16_t batch_id, uint8_t &time_valid, uint32_t &ack_epoch) { + ack_epoch = time_get_utc(); + time_valid = (time_is_synced() && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) ? 1 : 0; + if (!time_valid) { + ack_epoch = 0; + } + + LoraPacket ack = {}; + ack.msg_kind = LoraMsgKind::AckDown; + ack.device_id_short = self_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], ack_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); + } + } + lora_receive_continuous(); +} + void test_sender_loop(uint16_t short_id, const char *device_id) { if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) { return; @@ -36,11 +121,13 @@ void test_sender_loop(uint16_t short_id, const char *device_id) { uint32_t now_utc = time_get_utc(); uint32_t ts = now_utc > 0 ? now_utc : millis() / 1000; - StaticJsonDocument<128> doc; + StaticJsonDocument<192> doc; doc["id"] = device_id; doc["role"] = "sender"; doc["test_code"] = code; doc["ts"] = ts; + doc["batch_id"] = g_test_batch_id; + doc["last_acked"] = g_test_last_acked_batch_id; char bat_buf[8]; snprintf(bat_buf, sizeof(bat_buf), "%.2f", data.battery_voltage_v); doc["bat_v"] = serialized(bat_buf); @@ -60,11 +147,32 @@ void test_sender_loop(uint16_t short_id, const char *device_id) { pkt.device_id_short = short_id; pkt.payload_len = json.length(); memcpy(pkt.payload, json.c_str(), pkt.payload_len); - lora_send(pkt); + if (!lora_send(pkt)) { + return; + } + + uint8_t time_valid = 0; + uint32_t ack_epoch = 0; + int16_t ack_rssi = 0; + float ack_snr = 0.0f; + if (receive_ack_for_batch(g_test_batch_id, time_valid, ack_epoch, ack_rssi, ack_snr)) { + if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) { + time_set_utc(ack_epoch); + } + g_test_last_acked_batch_id = g_test_batch_id; + g_test_batch_id++; + if (SERIAL_DEBUG_MODE) { + Serial.printf("test ack: batch=%u time_valid=%u epoch=%lu rssi=%d snr=%.1f\n", + static_cast(g_test_last_acked_batch_id), + static_cast(time_valid), + static_cast(ack_epoch), + static_cast(ack_rssi), + static_cast(ack_snr)); + } + } } void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_short_id) { - (void)self_short_id; LoraPacket pkt = {}; if (!lora_receive(pkt, 0)) { return; @@ -73,22 +181,28 @@ void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_sho return; } - uint8_t decompressed[160]; + uint8_t decompressed[192]; if (pkt.payload_len >= sizeof(decompressed)) { return; } memcpy(decompressed, pkt.payload, pkt.payload_len); decompressed[pkt.payload_len] = '\0'; - StaticJsonDocument<128> doc; + StaticJsonDocument<192> doc; if (deserializeJson(doc, reinterpret_cast(decompressed)) != DeserializationError::Ok) { return; } const char *id = doc["id"] | ""; const char *code = doc["test_code"] | ""; + uint16_t batch_id = static_cast(doc["batch_id"] | 0); + uint32_t ts = doc["ts"] | 0; float bat_v = doc["bat_v"] | NAN; + uint8_t time_valid = 0; + uint32_t ack_epoch = 0; + send_test_ack(self_short_id, batch_id, time_valid, ack_epoch); + for (uint8_t i = 0; i < count; ++i) { if (strncmp(statuses[i].last_data.device_id, id, sizeof(statuses[i].last_data.device_id)) == 0) { display_set_test_code_for_sender(i, code); @@ -96,12 +210,34 @@ void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_sho statuses[i].last_data.battery_voltage_v = bat_v; statuses[i].last_data.battery_percent = battery_percent_from_voltage(bat_v); } + statuses[i].last_data.link_valid = true; + statuses[i].last_data.link_rssi_dbm = pkt.rssi_dbm; + statuses[i].last_data.link_snr_db = pkt.snr_db; + statuses[i].last_data.ts_utc = ts; + statuses[i].last_acked_batch_id = batch_id; statuses[i].has_data = true; statuses[i].last_update_ts_utc = time_get_utc(); break; } } - mqtt_publish_test(id, String(reinterpret_cast(decompressed))); + StaticJsonDocument<256> mqtt_doc; + mqtt_doc["id"] = id; + mqtt_doc["role"] = "receiver"; + mqtt_doc["test_code"] = code; + mqtt_doc["ts"] = ts; + mqtt_doc["batch_id"] = batch_id; + mqtt_doc["acked_batch_id"] = batch_id; + if (!isnan(bat_v)) { + mqtt_doc["bat_v"] = bat_v; + } + mqtt_doc["rssi"] = pkt.rssi_dbm; + mqtt_doc["snr"] = pkt.snr_db; + mqtt_doc["time_valid"] = time_valid; + mqtt_doc["ack_epoch"] = ack_epoch; + + String mqtt_payload; + serializeJson(mqtt_doc, mqtt_payload); + mqtt_publish_test(id, mqtt_payload); } #endif diff --git a/src/web_server.cpp b/src/web_server.cpp index 346f33a..4eef61c 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -363,6 +363,7 @@ static String render_sender_block(const SenderStatus &status) { s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1); } if (status.has_data) { + s += " ack:" + String(status.last_acked_batch_id); 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)) + ")";