diff --git a/README.md b/README.md index 3a72b2c..e6f59ee 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ Variants: - Energy total: 1-0:1.8.0*255 - Total power: 1-0:16.7.0*255 - Phase power: 36.7 / 56.7 / 76.7 - - Phase voltage: 32.7 / 52.7 / 72.7 - Reads battery voltage and estimates SoC. - Builds JSON payload, compresses, wraps in LoRa packet, transmits. - Light sleeps between meter reads; batches are sent every 30s. @@ -56,11 +55,11 @@ Variants: **Sender flow (pseudo-code)**: ```cpp void sender_loop() { - meter_read_every_second(); // SML/OBIS -> MeterData samples + meter_read_every_second(); // OBIS -> MeterData samples read_battery(data); // VBAT + SoC if (time_to_send_batch()) { - json = meterBatchToJson(samples); + json = meterBatchToJson(samples, batch_id); // bat_v per batch, t_first/t_last included compressed = compressBuffer(json); lora_send(packet(MeterBatch, compressed)); } @@ -77,7 +76,7 @@ void sender_loop() { **Key sender functions**: ```cpp -bool meter_read(MeterData &data); // parse SML frame, set OBIS fields +bool meter_read(MeterData &data); // parse OBIS fields void read_battery(MeterData &data); // ADC -> volts + percent bool meterDataToJson(const MeterData&, String&); bool compressBuffer(const uint8_t*, size_t, uint8_t*, size_t, size_t&); @@ -89,10 +88,11 @@ bool lora_send(const LoraPacket &pkt); // add header + CRC16 and transmit - NTP sync (UTC) and local display in Europe/Berlin. - Receives LoRa packets, verifies CRC16, decompresses, parses JSON. - Publishes meter JSON to MQTT. +- Sends ACKs for MeterBatch packets and de-duplicates by batch_id. - Web UI: - AP mode: status + WiFi/MQTT config. - STA mode: status + per-sender pages. -- OLED cycles through receiver status and per-sender pages. +- OLED cycles through receiver status and per-sender pages (receiver OLED never sleeps). **Receiver loop (pseudo-code)**: ```cpp @@ -106,7 +106,7 @@ void receiver_loop() { } } else if (pkt.type == MeterBatch) { json = reassemble_and_decompress_batch(pkt); - for (sample in jsonToMeterBatch(json)) { + for (sample in jsonToMeterBatch(json)) { // uses t_first/t_last for jittered timestamps update_sender_status(sample); mqtt_publish_state(sample); } @@ -169,14 +169,14 @@ Packet layout: [0] protocol_version (1) [1] role (0=sender, 1=receiver) [2..3] device_id_short (uint16) -[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch) +[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack) [5..N-3] compressed payload [N-2..N-1] CRC16 (bytes 0..N-3) ``` LoRa radio settings: - Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`) -- SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 +- SF10, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 ## Data Format JSON payload (sender + MQTT): @@ -190,14 +190,42 @@ JSON payload (sender + MQTT): "p1_w": 500.00, "p2_w": 450.00, "p3_w": 0.00, - "v1_v": 230.10, - "v2_v": 229.80, - "v3_v": 231.00, "bat_v": 3.92, "bat_pct": 78 } ``` +MeterBatch JSON (compressed over LoRa) uses per-field arrays with integer units for easier ingestion: + +```json +{ + "schema": 1, + "sender": "s01", + "batch_id": 1842, + "t0": 1738288000, + "t_first": 1738288000, + "t_last": 1738288030, + "dt_s": 1, + "n": 3, + "e_wh": [123456700, 123456701, 123456701], + "p_w": [930, 940, 950], + "p1_w": [480, 490, 500], + "p2_w": [450, 450, 450], + "p3_w": [0, 0, 0], + "bat_v": 3.92, + "meta": { + "rssi": -92, + "snr": 7.5, + "rx_ts": 1738288031 + } +} +``` + +Notes: +- `sender` maps to `EXPECTED_SENDER_IDS` order (`s01` = first sender). +- `meta` is injected by the receiver after batch reassembly. +- `bat_v` is a single batch-level value (percent is calculated locally). + ## Device IDs - Derived from WiFi STA MAC. - `short_id = (MAC[4] << 8) | MAC[5]` @@ -212,9 +240,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; ## OLED Behavior - Sender: OLED stays **ON for 10 seconds** on each wake, then powers down for sleep. -- Receiver: OLED follows the 10-minute auto-off behavior: - - GPIO14 HIGH: OLED forced ON. - - GPIO14 LOW: auto-off after 10 minutes. +- Receiver: OLED is always on (no auto-off). - Pages rotate every 4s. ## Power & Battery @@ -253,12 +279,24 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `lilygo-t3-v1-6-1-868`: production build for 868 MHz modules - `lilygo-t3-v1-6-1-868-test`: test build for 868 MHz modules +## Config Knobs +Key timing settings in `include/config.h`: +- `METER_SAMPLE_INTERVAL_MS` +- `METER_SEND_INTERVAL_MS` +- `BATCH_ACK_TIMEOUT_MS` +- `BATCH_MAX_RETRIES` + - `BATCH_QUEUE_DEPTH` + - `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion) + - `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON` + - `LORA_SEND_BYPASS` (debug only) + ## Limits & Known Constraints - **Compression**: uses lightweight RLE (good for JSON but not optimal). -- **OBIS parsing**: supports IEC 62056-21 ASCII (Mode D) and SML; may need tuning for some meters. +- **OBIS parsing**: supports IEC 62056-21 ASCII (Mode D); may need tuning for some meters. - **Payload size**: single JSON frames < 256 bytes (ArduinoJson static doc); batch frames are chunked and reassembled. - **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`. - **OLED**: no hardware reset line is used (matches working reference). +- **Batch ACKs**: sender waits for ACK after a batch and retries up to `BATCH_MAX_RETRIES` with `BATCH_ACK_TIMEOUT_MS` between attempts. ## Files & Modules - `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs @@ -266,7 +304,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode - `include/compressor.h`, `src/compressor.cpp`: RLE compression - `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC -- `include/meter_driver.h`, `src/meter_driver.cpp`: IEC 62056-21 ASCII + SML parse +- `include/meter_driver.h`, `src/meter_driver.cpp`: IEC 62056-21 ASCII parse - `include/power_manager.h`, `src/power_manager.cpp`: ADC + sleep - `include/time_manager.h`, `src/time_manager.cpp`: NTP + time sync - `include/wifi_manager.h`, `src/wifi_manager.cpp`: NVS config + WiFi diff --git a/include/config.h b/include/config.h index 6018d03..c9e8fc9 100644 --- a/include/config.h +++ b/include/config.h @@ -11,7 +11,13 @@ enum class PayloadType : uint8_t { MeterData = 0, TestCode = 1, TimeSync = 2, - MeterBatch = 3 + MeterBatch = 3, + Ack = 4 +}; + +enum class BatchRetryPolicy : uint8_t { + Keep = 0, + Drop = 1 }; constexpr uint8_t PROTOCOL_VERSION = 1; @@ -43,10 +49,11 @@ constexpr uint8_t PIN_METER_RX = 34; #define LORA_FREQUENCY_HZ 433E6 #endif constexpr long LORA_FREQUENCY = LORA_FREQUENCY_HZ; -constexpr uint8_t LORA_SPREADING_FACTOR = 12; +constexpr uint8_t LORA_SPREADING_FACTOR = 10; constexpr long LORA_BANDWIDTH = 125E3; constexpr uint8_t LORA_CODING_RATE = 5; constexpr uint8_t LORA_SYNC_WORD = 0x34; +constexpr uint8_t LORA_PREAMBLE_LEN = 8; // Timing constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30; @@ -59,9 +66,16 @@ constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL; constexpr uint32_t SENDER_OLED_READ_MS = 10000; constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000; constexpr uint32_t METER_SEND_INTERVAL_MS = 30000; +constexpr uint32_t BATCH_ACK_TIMEOUT_MS = 3000; +constexpr uint8_t BATCH_MAX_RETRIES = 2; constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; +constexpr uint8_t BATCH_QUEUE_DEPTH = 10; +constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep; constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; constexpr bool ENABLE_HA_DISCOVERY = true; +constexpr bool SERIAL_DEBUG_MODE = false; +constexpr bool SERIAL_DEBUG_DUMP_JSON = false; +constexpr bool LORA_SEND_BYPASS = false; constexpr uint8_t NUM_SENDERS = 1; inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { diff --git a/include/data_model.h b/include/data_model.h index 027eb44..94ed471 100644 --- a/include/data_model.h +++ b/include/data_model.h @@ -22,7 +22,6 @@ struct MeterData { float energy_total_kwh; float phase_power_w[3]; float total_power_w; - float phase_voltage_v[3]; float battery_voltage_v; uint8_t battery_percent; bool valid; diff --git a/include/display_ui.h b/include/display_ui.h index fb9427d..b71bc25 100644 --- a/include/display_ui.h +++ b/include/display_ui.h @@ -11,6 +11,8 @@ void display_set_sender_statuses(const SenderStatus *statuses, uint8_t count); void display_set_last_meter(const MeterData &data); void display_set_last_read(bool ok, uint32_t ts_utc); void display_set_last_tx(bool ok, uint32_t ts_utc); +void display_set_sender_queue(uint8_t depth, bool build_pending); +void display_set_sender_batches(uint16_t last_acked_batch_id, uint16_t current_batch_id); void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms); void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok); void display_power_down(); diff --git a/include/json_codec.h b/include/json_codec.h index 48397be..86a0fd7 100644 --- a/include/json_codec.h +++ b/include/json_codec.h @@ -5,5 +5,6 @@ bool meterDataToJson(const MeterData &data, String &out_json); bool jsonToMeterData(const String &json, MeterData &data); -bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults = nullptr, FaultType last_error = FaultType::None); +bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, String &out_json, + const FaultCounters *faults = nullptr, FaultType last_error = FaultType::None); bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count); diff --git a/include/lora_transport.h b/include/lora_transport.h index ec0fa00..bb445fb 100644 --- a/include/lora_transport.h +++ b/include/lora_transport.h @@ -3,7 +3,7 @@ #include #include "config.h" -constexpr size_t LORA_MAX_PAYLOAD = 200; +constexpr size_t LORA_MAX_PAYLOAD = 230; struct LoraPacket { uint8_t protocol_version; @@ -20,3 +20,4 @@ void lora_init(); bool lora_send(const LoraPacket &pkt); bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms); void lora_sleep(); +uint32_t lora_airtime_ms(size_t packet_len); diff --git a/include/web_server.h b/include/web_server.h index ee9a980..9acaec5 100644 --- a/include/web_server.h +++ b/include/web_server.h @@ -7,4 +7,6 @@ void web_server_begin_ap(const SenderStatus *statuses, uint8_t count); void web_server_begin_sta(const SenderStatus *statuses, uint8_t count); void web_server_set_config(const WifiMqttConfig &config); +void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors); +void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count); void web_server_loop(); diff --git a/platformio.ini b/platformio.ini index 52f1e2d..277abd6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ ; https://docs.platformio.org/page/projectconf.html [env:lilygo-t3-v1-6-1] -platform = espressif32 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip board = ttgo-lora32-v1 framework = arduino lib_deps = @@ -20,7 +20,7 @@ lib_deps = knolleary/PubSubClient@^2.8 [env:lilygo-t3-v1-6-1-test] -platform = espressif32 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip board = ttgo-lora32-v1 framework = arduino lib_deps = @@ -33,7 +33,7 @@ build_flags = -DENABLE_TEST_MODE [env:lilygo-t3-v1-6-1-868] -platform = espressif32 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip board = ttgo-lora32-v1 framework = arduino lib_deps = @@ -46,7 +46,7 @@ build_flags = -DLORA_FREQUENCY_HZ=868E6 [env:lilygo-t3-v1-6-1-868-test] -platform = espressif32 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip board = ttgo-lora32-v1 framework = arduino lib_deps = diff --git a/src/display_ui.cpp b/src/display_ui.cpp index 7cde378..ac5a013 100644 --- a/src/display_ui.cpp +++ b/src/display_ui.cpp @@ -19,6 +19,10 @@ static uint32_t g_last_read_ts = 0; static uint32_t g_last_tx_ts = 0; static uint32_t g_last_read_ms = 0; static uint32_t g_last_tx_ms = 0; +static uint8_t g_sender_queue_depth = 0; +static bool g_sender_build_pending = false; +static uint16_t g_sender_last_acked_batch_id = 0; +static uint16_t g_sender_current_batch_id = 0; static FaultType g_last_error = FaultType::None; static uint32_t g_last_error_ts = 0; static uint32_t g_last_error_ms = 0; @@ -111,6 +115,16 @@ void display_set_last_tx(bool ok, uint32_t ts_utc) { g_last_tx_ms = millis(); } +void display_set_sender_queue(uint8_t depth, bool build_pending) { + g_sender_queue_depth = depth; + g_sender_build_pending = build_pending; +} + +void display_set_sender_batches(uint16_t last_acked_batch_id, uint16_t current_batch_id) { + g_sender_last_acked_batch_id = last_acked_batch_id; + g_sender_current_batch_id = current_batch_id; +} + void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms) { g_last_error = type; g_last_error_ts = ts_utc; @@ -195,7 +209,13 @@ static void render_sender_status() { display.printf("Read %s %lus ago", g_last_read_ok ? "OK" : "ERR", static_cast(age_seconds(g_last_read_ts, g_last_read_ms))); display.setCursor(0, 36); - display.printf("TX %s %lus ago", g_last_tx_ok ? "OK" : "ERR", static_cast(age_seconds(g_last_tx_ts, g_last_tx_ms))); + display.printf("TX %s %lus Q%u%s A%u C%u", + g_last_tx_ok ? "OK" : "ERR", + static_cast(age_seconds(g_last_tx_ts, g_last_tx_ms)), + g_sender_queue_depth, + g_sender_build_pending ? "+" : "", + g_sender_last_acked_batch_id, + g_sender_current_batch_id); #ifdef ENABLE_TEST_MODE if (strlen(g_test_code) > 0) { @@ -219,11 +239,11 @@ static void render_sender_measurement() { display.setCursor(0, 12); display.printf("P %.0fW", g_last_meter.total_power_w); display.setCursor(0, 24); - display.printf("L1 %.0fV %.0fW", g_last_meter.phase_voltage_v[0], g_last_meter.phase_power_w[0]); + display.printf("L1 %.0fW", g_last_meter.phase_power_w[0]); display.setCursor(0, 36); - display.printf("L2 %.0fV %.0fW", g_last_meter.phase_voltage_v[1], g_last_meter.phase_power_w[1]); + display.printf("L2 %.0fW", g_last_meter.phase_power_w[1]); display.setCursor(0, 48); - display.printf("L3 %.0fV %.0fW", g_last_meter.phase_voltage_v[2], g_last_meter.phase_power_w[2]); + display.printf("L3 %.0fW", g_last_meter.phase_power_w[2]); display.display(); } @@ -292,7 +312,15 @@ static void render_receiver_sender(uint8_t index) { display.setCursor(0, 0); uint8_t bat = status.has_data ? status.last_data.battery_percent : 0; if (status.has_data) { - display.printf("%s B%u", status.last_data.device_id, bat); + const char *device_id = status.last_data.device_id; + if (strlen(device_id) >= 4 && strncmp(device_id, "dd3-", 4) == 0) { + device_id += 4; + } + if (status.last_data.link_valid) { + display.printf("%s R:%d S:%.1f", device_id, status.last_data.link_rssi_dbm, status.last_data.link_snr_db); + } else { + display.printf("%s B%u", device_id, bat); + } } else { display.printf("%s B--", status.last_data.device_id); } @@ -318,15 +346,11 @@ static void render_receiver_sender(uint8_t index) { display.setCursor(0, 24); display.printf("P %.0fW", status.last_data.total_power_w); display.setCursor(0, 36); - display.printf("L1 %.0fV %.0fW", status.last_data.phase_voltage_v[0], status.last_data.phase_power_w[0]); + display.printf("L1 %.0fW", status.last_data.phase_power_w[0]); display.setCursor(0, 48); - display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]); + display.printf("L2 %.0fW", status.last_data.phase_power_w[1]); display.setCursor(0, 56); - if (status.last_data.link_valid) { - display.printf("R:%d S:%.1f", status.last_data.link_rssi_dbm, status.last_data.link_snr_db); - } else { - display.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]); - } + display.printf("L3 %.0fW", status.last_data.phase_power_w[2]); display.display(); } @@ -348,7 +372,10 @@ void display_tick() { bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH; bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS; - if (in_boot_window) { + if (g_role == DeviceRole::Receiver) { + g_oled_on = true; + g_oled_off_start = 0; + } else if (in_boot_window) { g_oled_on = true; } else { if (ctrl_high) { diff --git a/src/json_codec.cpp b/src/json_codec.cpp index cc4371e..e3e88c6 100644 --- a/src/json_codec.cpp +++ b/src/json_codec.cpp @@ -1,6 +1,8 @@ #include "json_codec.h" #include +#include #include +#include "config.h" #include "power_manager.h" static float round2(float value) { @@ -10,6 +12,34 @@ static float round2(float value) { return roundf(value * 100.0f) / 100.0f; } +static uint32_t kwh_to_wh(float value) { + if (isnan(value)) { + return 0; + } + double wh = static_cast(value) * 1000.0; + if (wh < 0.0) { + wh = 0.0; + } + if (wh > static_cast(UINT32_MAX)) { + wh = static_cast(UINT32_MAX); + } + return static_cast(llround(wh)); +} + +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(rounded); +} + static const char *short_id_from_device_id(const char *device_id) { if (!device_id) { return ""; @@ -21,6 +51,31 @@ static const char *short_id_from_device_id(const char *device_id) { return device_id; } +static void sender_label_from_short_id(uint16_t short_id, char *out, size_t out_len) { + if (!out || out_len == 0) { + return; + } + for (uint8_t i = 0; i < NUM_SENDERS; ++i) { + if (EXPECTED_SENDER_IDS[i] == short_id) { + snprintf(out, out_len, "s%02u", static_cast(i + 1)); + return; + } + } + snprintf(out, out_len, "s00"); +} + +static uint16_t short_id_from_sender_label(const char *sender_label) { + if (!sender_label || strlen(sender_label) < 2 || sender_label[0] != 's') { + return 0; + } + char *end = nullptr; + long idx = strtol(sender_label + 1, &end, 10); + if (end == sender_label + 1 || idx <= 0 || idx > NUM_SENDERS) { + return 0; + } + return EXPECTED_SENDER_IDS[idx - 1]; +} + static void format_float_2(char *buf, size_t buf_len, float value) { if (!buf || buf_len == 0) { return; @@ -47,12 +102,6 @@ bool meterDataToJson(const MeterData &data, String &out_json) { doc["p2_w"] = serialized(buf); format_float_2(buf, sizeof(buf), data.phase_power_w[2]); doc["p3_w"] = serialized(buf); - format_float_2(buf, sizeof(buf), data.phase_voltage_v[0]); - doc["v1_v"] = serialized(buf); - format_float_2(buf, sizeof(buf), data.phase_voltage_v[1]); - doc["v2_v"] = serialized(buf); - format_float_2(buf, sizeof(buf), data.phase_voltage_v[2]); - doc["v3_v"] = serialized(buf); format_float_2(buf, sizeof(buf), data.battery_voltage_v); doc["bat_v"] = serialized(buf); doc["bat_pct"] = data.battery_percent; @@ -78,13 +127,6 @@ bool meterDataToJson(const MeterData &data, String &out_json) { return len > 0 && len < 256; } -static float read_float_or_legacy(JsonDocument &doc, const char *key, const char *legacy_key) { - if (doc[key].isNull()) { - return doc[legacy_key] | NAN; - } - return doc[key] | NAN; -} - bool jsonToMeterData(const String &json, MeterData &data) { StaticJsonDocument<256> doc; DeserializationError err = deserializeJson(doc, json); @@ -101,14 +143,11 @@ bool jsonToMeterData(const String &json, MeterData &data) { data.device_id[sizeof(data.device_id) - 1] = '\0'; data.ts_utc = doc["ts"] | 0; - data.energy_total_kwh = read_float_or_legacy(doc, "e_kwh", "energy_kwh"); - data.total_power_w = read_float_or_legacy(doc, "p_w", "p_total_w"); + data.energy_total_kwh = doc["e_kwh"] | NAN; + data.total_power_w = doc["p_w"] | NAN; data.phase_power_w[0] = doc["p1_w"] | NAN; data.phase_power_w[1] = doc["p2_w"] | NAN; data.phase_power_w[2] = doc["p3_w"] | NAN; - data.phase_voltage_v[0] = doc["v1_v"] | NAN; - data.phase_voltage_v[1] = doc["v2_v"] | NAN; - data.phase_voltage_v[2] = doc["v3_v"] | NAN; data.battery_voltage_v = doc["bat_v"] | NAN; if (doc["bat_pct"].isNull() && !isnan(data.battery_voltage_v)) { data.battery_percent = battery_percent_from_voltage(data.battery_voltage_v); @@ -132,15 +171,23 @@ bool jsonToMeterData(const String &json, MeterData &data) { return true; } -bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults, FaultType last_error) { +bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, String &out_json, const FaultCounters *faults, FaultType last_error) { if (!samples || count == 0) { return false; } DynamicJsonDocument doc(8192); - doc["id"] = short_id_from_device_id(samples[count - 1].device_id); - doc["bat_v"] = round2(samples[count - 1].battery_voltage_v); - doc["bat_pct"] = samples[count - 1].battery_percent; + doc["schema"] = 1; + char sender_label[8] = {}; + sender_label_from_short_id(samples[count - 1].short_id, sender_label, sizeof(sender_label)); + doc["sender"] = sender_label; + doc["batch_id"] = batch_id; + doc["t0"] = samples[0].ts_utc; + doc["t_first"] = samples[0].ts_utc; + doc["t_last"] = samples[count - 1].ts_utc; + uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000; + doc["dt_s"] = dt_s > 0 ? dt_s : 1; + doc["n"] = static_cast(count); if (faults) { if (faults->meter_read_fail > 0) { doc["err_m"] = faults->meter_read_fail; @@ -152,19 +199,23 @@ bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, if (last_error != FaultType::None) { doc["err_last"] = static_cast(last_error); } - JsonArray arr = doc.createNestedArray("s"); + if (!isnan(samples[count - 1].battery_voltage_v)) { + char bat_buf[16]; + format_float_2(bat_buf, sizeof(bat_buf), samples[count - 1].battery_voltage_v); + doc["bat_v"] = serialized(bat_buf); + } + + JsonArray energy = doc.createNestedArray("e_wh"); + JsonArray p_w = doc.createNestedArray("p_w"); + JsonArray p1_w = doc.createNestedArray("p1_w"); + JsonArray p2_w = doc.createNestedArray("p2_w"); + JsonArray p3_w = doc.createNestedArray("p3_w"); for (size_t i = 0; i < count; ++i) { - JsonArray row = arr.createNestedArray(); - row.add(samples[i].ts_utc); - row.add(round2(samples[i].energy_total_kwh)); - row.add(round2(samples[i].total_power_w)); - row.add(round2(samples[i].phase_power_w[0])); - row.add(round2(samples[i].phase_power_w[1])); - row.add(round2(samples[i].phase_power_w[2])); - row.add(round2(samples[i].phase_voltage_v[0])); - row.add(round2(samples[i].phase_voltage_v[1])); - row.add(round2(samples[i].phase_voltage_v[2])); - row.add(samples[i].valid ? 1 : 0); + energy.add(kwh_to_wh(samples[i].energy_total_kwh)); + p_w.add(round_to_i32(samples[i].total_power_w)); + p1_w.add(round_to_i32(samples[i].phase_power_w[0])); + p2_w.add(round_to_i32(samples[i].phase_power_w[1])); + p3_w.add(round_to_i32(samples[i].phase_power_w[2])); } out_json = ""; @@ -184,62 +235,85 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou return false; } - JsonArray arr = doc["s"].as(); - if (arr.isNull()) { - return false; - } - const char *id = doc["id"] | ""; - float bat_v = doc["bat_v"] | NAN; - uint8_t bat_pct = doc["bat_pct"] | 0; + const char *sender = doc["sender"] | ""; uint32_t err_m = doc["err_m"] | 0; uint32_t err_tx = doc["err_tx"] | 0; FaultType last_error = static_cast(doc["err_last"] | 0); + float bat_v = doc["bat_v"] | NAN; - size_t idx = 0; - for (JsonArray row : arr) { - if (idx >= max_count) { - break; + if (!doc["schema"].isNull()) { + if ((doc["schema"] | 0) != 1) { + return false; } - MeterData &data = out_samples[idx]; - data = {}; - if (strlen(id) == 4) { - snprintf(data.device_id, sizeof(data.device_id), "dd3-%s", id); - } else { - strncpy(data.device_id, id, sizeof(data.device_id)); + size_t count = doc["n"] | 0; + if (count == 0) { + return false; } - data.device_id[sizeof(data.device_id) - 1] = '\0'; - data.ts_utc = row[0] | 0; - data.energy_total_kwh = row[1] | NAN; - data.total_power_w = row[2] | NAN; - data.phase_power_w[0] = row[3] | NAN; - data.phase_power_w[1] = row[4] | NAN; - data.phase_power_w[2] = row[5] | NAN; - data.phase_voltage_v[0] = row[6] | NAN; - data.phase_voltage_v[1] = row[7] | NAN; - data.phase_voltage_v[2] = row[8] | NAN; - data.valid = (row[9] | 1) != 0; - data.battery_voltage_v = bat_v; - if (doc["bat_pct"].isNull() && !isnan(bat_v)) { - data.battery_percent = battery_percent_from_voltage(bat_v); - } else { - data.battery_percent = bat_pct; + if (count > max_count) { + count = max_count; } - data.link_valid = false; - data.link_rssi_dbm = 0; - data.link_snr_db = NAN; - data.err_meter_read = err_m; - data.err_decode = 0; - data.err_lora_tx = err_tx; - data.last_error = last_error; - if (strlen(data.device_id) >= 8) { - const char *suffix = data.device_id + strlen(data.device_id) - 4; - data.short_id = static_cast(strtoul(suffix, nullptr, 16)); + uint32_t t0 = doc["t0"] | 0; + uint32_t t_first = doc["t_first"] | t0; + uint32_t t_last = doc["t_last"] | t_first; + uint32_t dt_s = doc["dt_s"] | 1; + JsonArray energy = doc["e_wh"].as(); + JsonArray p_w = doc["p_w"].as(); + JsonArray p1_w = doc["p1_w"].as(); + JsonArray p2_w = doc["p2_w"].as(); + JsonArray p3_w = doc["p3_w"].as(); + + for (size_t idx = 0; idx < count; ++idx) { + MeterData &data = out_samples[idx]; + data = {}; + uint16_t short_id = short_id_from_sender_label(sender); + if (short_id != 0) { + snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id); + data.short_id = short_id; + } else if (id[0] != '\0') { + strncpy(data.device_id, id, sizeof(data.device_id)); + data.device_id[sizeof(data.device_id) - 1] = '\0'; + } else { + snprintf(data.device_id, sizeof(data.device_id), "dd3-0000"); + } + + if (count > 1 && t_last >= t_first) { + uint32_t span = t_last - t_first; + uint32_t step = span / static_cast(count - 1); + data.ts_utc = t_first + static_cast(idx) * step; + } else { + data.ts_utc = t0 + static_cast(idx) * dt_s; + } + data.energy_total_kwh = static_cast((energy[idx] | 0)) / 1000.0f; + data.total_power_w = static_cast(p_w[idx] | 0); + data.phase_power_w[0] = static_cast(p1_w[idx] | 0); + data.phase_power_w[1] = static_cast(p2_w[idx] | 0); + data.phase_power_w[2] = static_cast(p3_w[idx] | 0); + data.battery_voltage_v = bat_v; + if (!isnan(bat_v)) { + data.battery_percent = battery_percent_from_voltage(bat_v); + } else { + data.battery_percent = 0; + } + data.valid = true; + data.link_valid = false; + data.link_rssi_dbm = 0; + data.link_snr_db = NAN; + data.err_meter_read = err_m; + data.err_decode = 0; + data.err_lora_tx = err_tx; + data.last_error = last_error; + + if (data.short_id == 0 && strlen(data.device_id) >= 8) { + const char *suffix = data.device_id + strlen(data.device_id) - 4; + data.short_id = static_cast(strtoul(suffix, nullptr, 16)); + } } - idx++; + + out_count = count; + return count > 0; } - out_count = idx; - return idx > 0; + return false; } diff --git a/src/lora_transport.cpp b/src/lora_transport.cpp index 2bd0170..7ea0612 100644 --- a/src/lora_transport.cpp +++ b/src/lora_transport.cpp @@ -1,6 +1,7 @@ #include "lora_transport.h" #include #include +#include static uint16_t crc16_ccitt(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; @@ -29,6 +30,9 @@ void lora_init() { } bool lora_send(const LoraPacket &pkt) { + if (LORA_SEND_BYPASS) { + return true; + } uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; size_t idx = 0; buffer[idx++] = pkt.protocol_version; @@ -106,3 +110,28 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) { void lora_sleep() { LoRa.sleep(); } + +uint32_t lora_airtime_ms(size_t packet_len) { + if (packet_len == 0) { + return 0; + } + + const double bw = static_cast(LORA_BANDWIDTH); + const double sf = static_cast(LORA_SPREADING_FACTOR); + const double cr = static_cast(LORA_CODING_RATE - 4); // coding rate denominator: 4/(4+cr) + const double tsym = (1 << LORA_SPREADING_FACTOR) / bw; + const double t_preamble = (static_cast(LORA_PREAMBLE_LEN) + 4.25) * tsym; + + const bool low_data_rate_opt = (LORA_SPREADING_FACTOR >= 11) && (LORA_BANDWIDTH <= 125000); + const double de = low_data_rate_opt ? 1.0 : 0.0; + const double ih = 0.0; + const double crc = 1.0; + + const double payload_symb_nb = 8.0 + max( + ceil((8.0 * packet_len - 4.0 * sf + 28.0 + 16.0 * crc - 20.0 * ih) / (4.0 * (sf - 2.0 * de))) * (cr + 4.0), + 0.0); + const double t_payload = payload_symb_nb * tsym; + const double t_packet = t_preamble + t_payload; + + return static_cast(ceil(t_packet * 1000.0)); +} diff --git a/src/main.cpp b/src/main.cpp index c3b87f3..c795fe3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,8 +12,11 @@ #include "web_server.h" #include "display_ui.h" #include "test_mode.h" +#include +#include #ifdef ARDUINO_ARCH_ESP32 #include +#include #endif static DeviceRole g_role = DeviceRole::Sender; @@ -49,14 +52,76 @@ 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 size_t BATCH_MAX_DECOMPRESSED = 8192; -static constexpr uint32_t BATCH_RX_TIMEOUT_MS = 2000; +static constexpr uint32_t BATCH_RX_MARGIN_MS = 800; + +struct BatchBuffer { + uint16_t batch_id; + bool batch_id_valid; + uint8_t count; + MeterData samples[METER_BATCH_MAX_SAMPLES]; +}; + +static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH]; +static uint8_t g_batch_head = 0; +static uint8_t g_batch_tail = 0; +static uint8_t g_batch_count = 0; + +static MeterData g_build_samples[METER_BATCH_MAX_SAMPLES]; +static uint8_t g_build_count = 0; -static MeterData g_meter_samples[METER_BATCH_MAX_SAMPLES]; -static uint8_t g_meter_sample_count = 0; -static uint8_t g_meter_sample_head = 0; static uint32_t g_last_sample_ms = 0; +static uint32_t g_last_sample_ts_utc = 0; static uint32_t g_last_send_ms = 0; +static uint32_t g_last_batch_send_ms = 0; +static float g_last_battery_voltage_v = NAN; +static uint8_t g_last_battery_percent = 0; +static uint32_t g_last_battery_ms = 0; static uint16_t g_batch_id = 1; +static uint16_t g_last_sent_batch_id = 0; +static uint16_t g_last_acked_batch_id = 0; +static uint8_t g_batch_retry_count = 0; +static bool g_batch_ack_pending = false; +static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS; +static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES]; +static uint8_t g_inflight_count = 0; +static uint16_t g_inflight_batch_id = 0; +static bool g_inflight_active = false; +static uint32_t g_last_debug_log_ms = 0; + +static void watchdog_kick(); + +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 void serial_debug_print_json(const String &json) { + if (!SERIAL_DEBUG_MODE || !SERIAL_DEBUG_DUMP_JSON) { + return; + } + const char *data = json.c_str(); + size_t len = json.length(); + const size_t chunk = 128; + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i; + if (n > chunk) { + n = chunk; + } + Serial.write(reinterpret_cast(data + i), n); + watchdog_kick(); + delay(0); + } + Serial.write('\n'); +} + +static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {}; struct BatchRxState { bool active; @@ -66,6 +131,7 @@ struct BatchRxState { uint16_t total_len; uint16_t received_len; uint32_t last_rx_ms; + uint32_t timeout_ms; uint8_t buffer[BATCH_MAX_COMPRESSED]; }; @@ -88,33 +154,65 @@ static void init_sender_statuses() { } } -static void push_meter_sample(const MeterData &data) { - g_meter_samples[g_meter_sample_head] = data; - g_meter_sample_head = (g_meter_sample_head + 1) % METER_BATCH_MAX_SAMPLES; - if (g_meter_sample_count < METER_BATCH_MAX_SAMPLES) { - g_meter_sample_count++; - } +static void update_battery_cache() { + MeterData tmp = {}; + read_battery(tmp); + g_last_battery_voltage_v = tmp.battery_voltage_v; + g_last_battery_percent = tmp.battery_percent; + g_last_battery_ms = millis(); } -static size_t copy_meter_samples(MeterData *out, size_t max_count) { - if (!out || max_count == 0 || g_meter_sample_count == 0) { - return 0; +static bool batch_queue_drop_oldest() { + if (g_batch_count == 0) { + return false; } - size_t count = g_meter_sample_count < max_count ? g_meter_sample_count : max_count; - size_t start = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - count) % METER_BATCH_MAX_SAMPLES; - for (size_t i = 0; i < count; ++i) { - out[i] = g_meter_samples[(start + i) % METER_BATCH_MAX_SAMPLES]; + bool dropped_inflight = g_inflight_active && g_batch_queue[g_batch_tail].batch_id_valid && + g_inflight_batch_id == g_batch_queue[g_batch_tail].batch_id; + if (dropped_inflight) { + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; } - return count; + g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH; + g_batch_count--; + return dropped_inflight; +} + +static BatchBuffer *batch_queue_peek() { + if (g_batch_count == 0) { + return nullptr; + } + return &g_batch_queue[g_batch_tail]; +} + +static void batch_queue_enqueue(const MeterData *samples, uint8_t count) { + if (!samples || count == 0) { + return; + } + if (g_batch_count >= BATCH_QUEUE_DEPTH) { + if (batch_queue_drop_oldest()) { + g_batch_id++; + } + } + BatchBuffer &slot = g_batch_queue[g_batch_head]; + slot.batch_id = 0; + slot.batch_id_valid = false; + slot.count = count; + for (uint8_t i = 0; i < count; ++i) { + slot.samples[i] = samples[i]; + } + g_batch_head = (g_batch_head + 1) % BATCH_QUEUE_DEPTH; + g_batch_count++; } static uint32_t last_sample_ts() { - if (g_meter_sample_count == 0) { + if (g_last_sample_ts_utc == 0) { uint32_t now_utc = time_get_utc(); return now_utc > 0 ? now_utc : millis() / 1000; } - size_t idx = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - 1) % METER_BATCH_MAX_SAMPLES; - return g_meter_samples[idx].ts_utc; + return g_last_sample_ts_utc; } static void note_fault(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) { @@ -159,7 +257,12 @@ static void publish_faults_if_needed(const char *device_id, const FaultCounters #ifdef ARDUINO_ARCH_ESP32 static void watchdog_init() { - esp_task_wdt_init(WATCHDOG_TIMEOUT_SEC, true); + esp_task_wdt_deinit(); + esp_task_wdt_config_t config = {}; + config.timeout_ms = WATCHDOG_TIMEOUT_SEC * 1000; + config.idle_core_mask = 0; + config.trigger_panic = true; + esp_task_wdt_init(&config); esp_task_wdt_add(nullptr); } @@ -180,7 +283,46 @@ static uint16_t read_u16_le(const uint8_t *src) { return static_cast(src[0]) | (static_cast(src[1]) << 8); } -static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display) { +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 = 5 + payload_len + 2; + uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); + uint32_t timeout_ms = static_cast(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; + return timeout_ms < 10000 ? 10000 : timeout_ms; +} + +static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) { + if (payload_len == 0) { + return 10000; + } + uint8_t chunk_count = static_cast((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); + size_t packet_len = 5 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2; + uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); + uint32_t timeout_ms = static_cast(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; + return timeout_ms < 10000 ? 10000 : timeout_ms; +} + +static bool inject_batch_meta(String &json, int16_t rssi_dbm, float snr_db, uint32_t rx_ts_utc) { + DynamicJsonDocument doc(8192); + DeserializationError err = deserializeJson(doc, json); + if (err) { + return false; + } + + JsonObject meta = doc.createNestedObject("meta"); + meta["rssi"] = rssi_dbm; + meta["snr"] = snr_db; + meta["rx_ts"] = rx_ts_utc; + + json = ""; + return serializeJson(doc, json) > 0; +} + +static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) { if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) { return false; } @@ -204,55 +346,147 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_ pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; uint8_t *payload = pkt.payload; - write_u16_le(&payload[0], g_batch_id); + write_u16_le(&payload[0], batch_id); payload[2] = i; payload[3] = chunk_count; write_u16_le(&payload[4], static_cast(len)); memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len); + watchdog_kick(); + uint32_t tx_start = millis(); bool ok = lora_send(pkt); + uint32_t tx_ms = millis() - tx_start; all_ok = all_ok && ok; if (!ok) { note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); } + if (SERIAL_DEBUG_MODE && tx_ms > 500) { + serial_debug_printf("tx: chunk %u/%u took %lums ok=%u", static_cast(i + 1), + static_cast(chunk_count), static_cast(tx_ms), ok ? 1 : 0); + } offset += chunk_len; delay(10); } - if (all_ok) { - g_batch_id++; - } display_set_last_tx(all_ok, ts_for_display); return all_ok; } -static bool send_meter_batch(uint32_t ts_for_display) { - MeterData ordered[METER_BATCH_MAX_SAMPLES]; - size_t count = copy_meter_samples(ordered, METER_BATCH_MAX_SAMPLES); - if (count == 0) { +static void send_batch_ack(uint16_t batch_id, uint16_t sender_id) { + LoraPacket ack = {}; + ack.protocol_version = PROTOCOL_VERSION; + ack.role = DeviceRole::Receiver; + ack.device_id_short = g_short_id; + ack.payload_type = PayloadType::Ack; + ack.payload_len = 6; + write_u16_le(&ack.payload[0], batch_id); + write_u16_le(&ack.payload[2], sender_id); + write_u16_le(&ack.payload[4], g_short_id); + lora_send(ack); +} + +static bool prepare_inflight_from_queue() { + if (g_inflight_active) { + return true; + } + BatchBuffer *batch = batch_queue_peek(); + if (!batch || batch->count == 0) { return false; } + if (!batch->batch_id_valid) { + batch->batch_id = g_batch_id; + batch->batch_id_valid = true; + } + g_inflight_count = batch->count; + g_inflight_batch_id = batch->batch_id; + for (uint8_t i = 0; i < g_inflight_count; ++i) { + g_inflight_samples[i] = batch->samples[i]; + } + g_inflight_active = true; + return true; +} - String json; - if (!meterBatchToJson(ordered, count, json, &g_sender_faults, g_sender_last_error)) { +static bool send_inflight_batch(uint32_t ts_for_display) { + if (!g_inflight_active || g_inflight_count == 0) { return false; } + uint32_t json_start = millis(); + String json; + if (!meterBatchToJson(g_inflight_samples, g_inflight_count, g_inflight_batch_id, json, &g_sender_faults, g_sender_last_error)) { + return false; + } + uint32_t json_ms = millis() - json_start; + if (SERIAL_DEBUG_MODE) { + serial_debug_printf("tx: batch_id=%u count=%u json_len=%u", g_inflight_batch_id, g_inflight_count, static_cast(json.length())); + if (json_ms > 200) { + serial_debug_printf("tx: json encode took %lums", static_cast(json_ms)); + } + serial_debug_print_json(json); + } static uint8_t compressed[BATCH_MAX_COMPRESSED]; size_t compressed_len = 0; + uint32_t compress_start = millis(); if (!compressBuffer(reinterpret_cast(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) { return false; } + uint32_t compress_ms = millis() - compress_start; + if (SERIAL_DEBUG_MODE && compress_ms > 200) { + serial_debug_printf("tx: compress took %lums", static_cast(compress_ms)); + } + g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(compressed_len); - bool ok = send_batch_payload(compressed, compressed_len, ts_for_display); + uint32_t send_start = millis(); + bool ok = send_batch_payload(compressed, compressed_len, ts_for_display, g_inflight_batch_id); + uint32_t send_ms = millis() - send_start; + if (SERIAL_DEBUG_MODE && send_ms > 1000) { + serial_debug_printf("tx: send batch took %lums", static_cast(send_ms)); + } if (ok) { - g_meter_sample_count = 0; - g_meter_sample_head = 0; + g_last_batch_send_ms = millis(); + serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast(compressed_len)); + } else { + serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id); } return ok; } +static bool send_meter_batch(uint32_t ts_for_display) { + if (!prepare_inflight_from_queue()) { + return false; + } + bool ok = send_inflight_batch(ts_for_display); + if (ok) { + g_last_sent_batch_id = g_inflight_batch_id; + g_batch_ack_pending = true; + } else { + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + } + return ok; +} + +static bool resend_inflight_batch(uint32_t ts_for_display) { + if (!g_batch_ack_pending || !g_inflight_active || g_inflight_count == 0) { + return false; + } + return send_inflight_batch(ts_for_display); +} + +static void finish_inflight_batch() { + if (g_batch_count > 0) { + batch_queue_drop_oldest(); + } + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + g_batch_id++; +} + static void reset_batch_rx() { g_batch_rx.active = false; g_batch_rx.batch_id = 0; @@ -261,9 +495,10 @@ static void reset_batch_rx() { g_batch_rx.total_len = 0; g_batch_rx.received_len = 0; g_batch_rx.last_rx_ms = 0; + g_batch_rx.timeout_ms = 0; } -static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error) { +static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error, uint16_t &out_batch_id) { decode_error = false; if (pkt.payload_len < BATCH_HEADER_SIZE) { return false; @@ -276,7 +511,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool & size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE; uint32_t now_ms = millis(); - if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > BATCH_RX_TIMEOUT_MS)) { + if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > g_batch_rx.timeout_ms)) { if (chunk_index != 0) { reset_batch_rx(); return false; @@ -291,6 +526,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool & g_batch_rx.total_len = total_len; g_batch_rx.received_len = 0; g_batch_rx.next_index = 0; + g_batch_rx.timeout_ms = compute_batch_rx_timeout_ms(total_len, chunk_count); } if (!g_batch_rx.active || chunk_index != g_batch_rx.next_index || chunk_count != g_batch_rx.expected_chunks) { @@ -323,6 +559,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool & } decompressed[decompressed_len] = '\0'; out_json = String(reinterpret_cast(decompressed)); + out_batch_id = batch_id; reset_batch_rx(); return true; } @@ -338,6 +575,13 @@ void setup() { g_boot_ms = millis(); g_role = detect_role(); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); + if (SERIAL_DEBUG_MODE) { +#ifdef ARDUINO_ARCH_ESP32 + serial_debug_printf("boot: reset_reason=%d", static_cast(esp_reset_reason())); +#endif + serial_debug_printf("boot: role=%s short_id=%04X dev=%s", g_role == DeviceRole::Sender ? "sender" : "receiver", + g_short_id, g_device_id); + } lora_init(); display_init(); @@ -351,6 +595,7 @@ void setup() { meter_init(); g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; g_last_send_ms = millis(); + update_battery_cache(); } else { power_receiver_init(); wifi_manager_init(); @@ -363,6 +608,7 @@ void setup() { time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str()); mqtt_init(g_cfg, g_device_id); web_server_set_config(g_cfg); + web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote); web_server_begin_sta(g_sender_statuses, NUM_SENDERS); } else { g_ap_mode = true; @@ -376,6 +622,7 @@ void setup() { g_cfg.ntp_server_2 = "time.nist.gov"; } web_server_set_config(g_cfg); + web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote); web_server_begin_ap(g_sender_statuses, NUM_SENDERS); } } @@ -384,6 +631,19 @@ void setup() { static void sender_loop() { watchdog_kick(); uint32_t now_ms = millis(); + display_set_sender_queue(g_batch_count, g_build_count > 0); + display_set_sender_batches(g_last_acked_batch_id, g_batch_id); + if (SERIAL_DEBUG_MODE && now_ms - g_last_debug_log_ms >= 5000) { + g_last_debug_log_ms = now_ms; + serial_debug_printf("state: Q=%u%s A=%u C=%u inflight=%u ack_pending=%u retries=%u", + g_batch_count, + g_build_count > 0 ? "+" : "", + g_last_acked_batch_id, + g_batch_id, + g_inflight_count, + g_batch_ack_pending ? 1 : 0, + g_batch_retry_count); + } if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { g_last_sample_ms = now_ms; @@ -396,25 +656,91 @@ static void sender_loop() { note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); } - read_battery(data); + if (g_build_count == 0) { + update_battery_cache(); + } + data.battery_voltage_v = g_last_battery_voltage_v; + data.battery_percent = g_last_battery_percent; uint32_t now_utc = time_get_utc(); data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000; data.valid = meter_ok; - push_meter_sample(data); + g_last_sample_ts_utc = data.ts_utc; + g_build_samples[g_build_count++] = data; + if (g_build_count >= METER_BATCH_MAX_SAMPLES) { + batch_queue_enqueue(g_build_samples, g_build_count); + g_build_count = 0; + } display_set_last_meter(data); display_set_last_read(meter_ok, data.ts_utc); } - if (now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { + if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { g_last_send_ms = now_ms; send_meter_batch(last_sample_ts()); } + if (g_batch_ack_pending) { + uint32_t end_ms = millis() + 400; + while (millis() < end_ms) { + LoraPacket ack_pkt = {}; + if (!lora_receive(ack_pkt, 0) || ack_pkt.protocol_version != PROTOCOL_VERSION) { + delay(5); + continue; + } + if (ack_pkt.payload_type == PayloadType::Ack && ack_pkt.payload_len >= 6 && ack_pkt.role == DeviceRole::Receiver) { + 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]); + if (ack_sender == g_short_id && ack_receiver == ack_pkt.device_id_short && + g_batch_ack_pending && ack_id == g_last_sent_batch_id) { + g_last_acked_batch_id = ack_id; + serial_debug_printf("ack: ok batch_id=%u", ack_id); + finish_inflight_batch(); + break; + } + } + } + } + LoraPacket rx = {}; - if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION && rx.payload_type == PayloadType::TimeSync) { - time_handle_timesync_payload(rx.payload, rx.payload_len); + if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION) { + if (rx.payload_type == PayloadType::TimeSync) { + time_handle_timesync_payload(rx.payload, rx.payload_len); + } else if (rx.payload_type == PayloadType::Ack && rx.payload_len >= 6 && rx.role == DeviceRole::Receiver) { + uint16_t ack_id = read_u16_le(rx.payload); + uint16_t ack_sender = read_u16_le(&rx.payload[2]); + uint16_t ack_receiver = read_u16_le(&rx.payload[4]); + if (ack_sender == g_short_id && ack_receiver == rx.device_id_short && + g_batch_ack_pending && ack_id == g_last_sent_batch_id) { + g_last_acked_batch_id = ack_id; + serial_debug_printf("ack: ok batch_id=%u", ack_id); + finish_inflight_batch(); + } + } + } + + if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) { + if (g_batch_retry_count < BATCH_MAX_RETRIES) { + g_batch_retry_count++; + serial_debug_printf("ack: timeout batch_id=%u retry=%u", g_inflight_batch_id, g_batch_retry_count); + resend_inflight_batch(last_sample_ts()); + } else { + serial_debug_printf("ack: failed batch_id=%u policy=%s", g_inflight_batch_id, + BATCH_RETRY_POLICY == BatchRetryPolicy::Drop ? "drop" : "keep"); + if (BATCH_RETRY_POLICY == BatchRetryPolicy::Drop) { + finish_inflight_batch(); + } else { + g_batch_ack_pending = false; + g_batch_retry_count = 0; + g_inflight_active = false; + g_inflight_count = 0; + g_inflight_batch_id = 0; + } + note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); + display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); + } } display_tick(); @@ -422,7 +748,7 @@ static void sender_loop() { uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS; uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due; - if (next_due > now_ms) { + if (!g_batch_ack_pending && next_due > now_ms) { watchdog_kick(); light_sleep_ms(next_due - now_ms); } @@ -482,37 +808,53 @@ static void receiver_loop() { } else if (pkt.payload_type == PayloadType::MeterBatch) { String json; bool decode_error = false; - if (process_batch_packet(pkt, json, decode_error)) { + uint16_t batch_id = 0; + if (process_batch_packet(pkt, json, decode_error, batch_id)) { + uint32_t rx_ts_utc = time_get_utc(); + if (rx_ts_utc == 0) { + rx_ts_utc = millis() / 1000; + } + inject_batch_meta(json, pkt.rssi_dbm, pkt.snr_db, rx_ts_utc); MeterData samples[METER_BATCH_MAX_SAMPLES]; size_t count = 0; - if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { - for (uint8_t i = 0; i < NUM_SENDERS; ++i) { - if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { - for (size_t s = 0; s < count; ++s) { - samples[s].link_valid = true; - samples[s].link_rssi_dbm = pkt.rssi_dbm; - samples[s].link_snr_db = pkt.snr_db; - samples[s].short_id = pkt.device_id_short; - mqtt_publish_state(samples[s]); - } - if (count > 0) { - g_sender_statuses[i].last_data = samples[count - 1]; - g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc; - g_sender_statuses[i].has_data = true; - g_sender_faults_remote[i].meter_read_fail = samples[count - 1].err_meter_read; - g_sender_faults_remote[i].lora_tx_fail = samples[count - 1].err_lora_tx; - g_sender_last_error_remote[i] = samples[count - 1].last_error; - g_sender_last_error_remote_utc[i] = time_get_utc(); - g_sender_last_error_remote_ms[i] = millis(); - if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[i]) { - g_sender_discovery_sent[i] = mqtt_publish_discovery(samples[count - 1].device_id); - } - publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[i], g_sender_faults_remote_published[i], - g_sender_last_error_remote[i], g_sender_last_error_remote_published[i], - g_sender_last_error_remote_utc[i], g_sender_last_error_remote_ms[i]); - } - break; + 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(i); + break; + } + } + bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id; + if (duplicate) { + send_batch_ack(batch_id, pkt.device_id_short); + } else if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { + if (sender_idx >= 0) { + web_server_set_last_batch(static_cast(sender_idx), samples, count); + for (size_t s = 0; s < count; ++s) { + samples[s].link_valid = true; + samples[s].link_rssi_dbm = pkt.rssi_dbm; + samples[s].link_snr_db = pkt.snr_db; + samples[s].short_id = pkt.device_id_short; + mqtt_publish_state(samples[s]); } + if (count > 0) { + 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]); + } + g_last_batch_id_rx[sender_idx] = batch_id; + send_batch_ack(batch_id, pkt.device_id_short); } } else { note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); diff --git a/src/meter_driver.cpp b/src/meter_driver.cpp index 1816501..507f3bc 100644 --- a/src/meter_driver.cpp +++ b/src/meter_driver.cpp @@ -5,167 +5,11 @@ #include static constexpr uint32_t METER_READ_TIMEOUT_MS = 2000; -static constexpr size_t SML_BUFFER_SIZE = 2048; - -static const uint8_t OBIS_ENERGY_TOTAL[6] = {0x01, 0x00, 0x01, 0x08, 0x00, 0xFF}; -static const uint8_t OBIS_TOTAL_POWER[6] = {0x01, 0x00, 0x10, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_P1[6] = {0x01, 0x00, 0x24, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_P2[6] = {0x01, 0x00, 0x38, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_P3[6] = {0x01, 0x00, 0x4C, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_V1[6] = {0x01, 0x00, 0x20, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_V2[6] = {0x01, 0x00, 0x34, 0x07, 0x00, 0xFF}; -static const uint8_t OBIS_V3[6] = {0x01, 0x00, 0x48, 0x07, 0x00, 0xFF}; - -static bool find_obis_value(const uint8_t *buf, size_t len, const uint8_t *obis, float &out_value) { - for (size_t i = 0; i + 6 < len; ++i) { - if (memcmp(&buf[i], obis, 6) == 0) { - int8_t scaler = 0; - bool scaler_found = false; - bool value_found = false; - int64_t value = 0; - size_t cursor = i + 6; - size_t limit = (i + 6 + 120 < len) ? i + 6 + 120 : len; - - while (cursor < limit) { - uint8_t tl = buf[cursor++]; - if (tl == 0x00) { - continue; - } - uint8_t type = (tl >> 4) & 0x0F; - uint8_t tlen = tl & 0x0F; - if (tlen == 0 || cursor + tlen > len) { - continue; - } - - if (type == 0x05 || type == 0x06) { - int64_t val = 0; - for (uint8_t b = 0; b < tlen; ++b) { - val = (val << 8) | buf[cursor + b]; - } - if (type == 0x05) { - int64_t sign_bit = 1LL << ((tlen * 8) - 1); - if (val & sign_bit) { - int64_t mask = (1LL << (tlen * 8)) - 1; - val = -((~val + 1) & mask); - } - } - - if (!scaler_found && tlen <= 2 && val >= -6 && val <= 6) { - scaler = static_cast(val); - scaler_found = true; - } else if (!value_found) { - value = val; - value_found = true; - } - } - cursor += tlen; - if (value_found && scaler_found) { - break; - } - } - - if (value_found) { - out_value = static_cast(value) * powf(10.0f, scaler); - return true; - } - } - } - return false; -} void meter_init() { Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); } -static bool meter_read_sml(MeterData &data) { - uint8_t buffer[SML_BUFFER_SIZE]; - size_t len = 0; - bool started = false; - uint32_t start = millis(); - const uint8_t start_seq[] = {0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01}; - const uint8_t end_seq[] = {0x1B, 0x1B, 0x1B, 0x1B, 0x1A}; - - while (millis() - start < METER_READ_TIMEOUT_MS) { - while (Serial2.available()) { - uint8_t b = Serial2.read(); - if (!started) { - buffer[len++] = b; - if (len >= sizeof(start_seq)) { - if (memcmp(&buffer[len - sizeof(start_seq)], start_seq, sizeof(start_seq)) == 0) { - started = true; - len = 0; - } - } - if (len >= sizeof(buffer)) { - len = 0; - } - } else { - if (len < sizeof(buffer)) { - buffer[len++] = b; - if (len >= sizeof(end_seq)) { - if (memcmp(&buffer[len - sizeof(end_seq)], end_seq, sizeof(end_seq)) == 0) { - start = millis(); - goto parse_frame; - } - } - } - } - } - delay(5); - } - -parse_frame: - if (!started || len == 0) { - return false; - } - - data.energy_total_kwh = NAN; - data.total_power_w = NAN; - data.phase_power_w[0] = NAN; - data.phase_power_w[1] = NAN; - data.phase_power_w[2] = NAN; - data.phase_voltage_v[0] = NAN; - data.phase_voltage_v[1] = NAN; - data.phase_voltage_v[2] = NAN; - - bool ok = true; - float value = 0.0f; - - if (find_obis_value(buffer, len, OBIS_ENERGY_TOTAL, value)) { - data.energy_total_kwh = value; - } else { - ok = false; - } - - if (find_obis_value(buffer, len, OBIS_TOTAL_POWER, value)) { - data.total_power_w = value; - } else { - ok = false; - } - - if (find_obis_value(buffer, len, OBIS_P1, value)) { - data.phase_power_w[0] = value; - } - if (find_obis_value(buffer, len, OBIS_P2, value)) { - data.phase_power_w[1] = value; - } - if (find_obis_value(buffer, len, OBIS_P3, value)) { - data.phase_power_w[2] = value; - } - if (find_obis_value(buffer, len, OBIS_V1, value)) { - data.phase_voltage_v[0] = value; - } - if (find_obis_value(buffer, len, OBIS_V2, value)) { - data.phase_voltage_v[1] = value; - } - if (find_obis_value(buffer, len, OBIS_V3, value)) { - data.phase_voltage_v[2] = value; - } - - data.valid = ok; - return ok; -} - static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) { const char *p = strstr(line, obis); if (!p) { @@ -243,10 +87,6 @@ static bool meter_read_ascii(MeterData &data) { bool p1_ok = false; bool p2_ok = false; bool p3_ok = false; - bool v1_ok = false; - bool v2_ok = false; - bool v3_ok = false; - char line[128]; size_t line_len = 0; @@ -298,21 +138,6 @@ static bool meter_read_ascii(MeterData &data) { p3_ok = true; got_any = true; } - if (parse_obis_ascii_value(line, "1-0:32.7.0", value)) { - data.phase_voltage_v[0] = value; - v1_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:52.7.0", value)) { - data.phase_voltage_v[1] = value; - v2_ok = true; - got_any = true; - } - if (parse_obis_ascii_value(line, "1-0:72.7.0", value)) { - data.phase_voltage_v[2] = value; - v3_ok = true; - got_any = true; - } line_len = 0; continue; @@ -324,7 +149,7 @@ static bool meter_read_ascii(MeterData &data) { delay(5); } - data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok || v1_ok || v2_ok || v3_ok; + data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok; return data.valid; } @@ -334,13 +159,7 @@ bool meter_read(MeterData &data) { data.phase_power_w[0] = NAN; data.phase_power_w[1] = NAN; data.phase_power_w[2] = NAN; - data.phase_voltage_v[0] = NAN; - data.phase_voltage_v[1] = NAN; - data.phase_voltage_v[2] = NAN; data.valid = false; - if (meter_read_ascii(data)) { - return true; - } - return meter_read_sml(data); + return meter_read_ascii(data); } diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index cf81cf6..d08c616 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -126,9 +126,6 @@ bool mqtt_publish_discovery(const char *device_id) { bool ok = true; ok = ok && publish_discovery_sensor(device_id, "energy", "Energy", "kWh", "energy", state_topic.c_str(), "{{ value_json.e_kwh }}"); ok = ok && publish_discovery_sensor(device_id, "power", "Power", "W", "power", state_topic.c_str(), "{{ value_json.p_w }}"); - ok = ok && publish_discovery_sensor(device_id, "v1", "Voltage L1", "V", "voltage", state_topic.c_str(), "{{ value_json.v1_v }}"); - ok = ok && publish_discovery_sensor(device_id, "v2", "Voltage L2", "V", "voltage", state_topic.c_str(), "{{ value_json.v2_v }}"); - ok = ok && publish_discovery_sensor(device_id, "v3", "Voltage L3", "V", "voltage", state_topic.c_str(), "{{ value_json.v3_v }}"); ok = ok && publish_discovery_sensor(device_id, "p1", "Power L1", "W", "power", state_topic.c_str(), "{{ value_json.p1_w }}"); ok = ok && publish_discovery_sensor(device_id, "p2", "Power L2", "W", "power", state_topic.c_str(), "{{ value_json.p2_w }}"); ok = ok && publish_discovery_sensor(device_id, "p3", "Power L3", "W", "power", state_topic.c_str(), "{{ value_json.p3_w }}"); diff --git a/src/web_server.cpp b/src/web_server.cpp index 4a83854..e56bd6b 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -1,12 +1,17 @@ #include "web_server.h" #include #include "wifi_manager.h" +#include "config.h" static WebServer server(80); static const SenderStatus *g_statuses = nullptr; static uint8_t g_status_count = 0; static WifiMqttConfig g_config; static bool g_is_ap = false; +static const FaultCounters *g_sender_faults = nullptr; +static const FaultType *g_sender_last_errors = nullptr; +static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES]; +static uint8_t g_last_batch_count[NUM_SENDERS] = {}; static String html_header(const String &title) { String h = ""; @@ -19,10 +24,40 @@ static String html_footer() { return ""; } +static String format_faults(uint8_t idx) { + if (!g_sender_faults || !g_sender_last_errors || idx >= g_status_count) { + return ""; + } + String s; + s += " faults m:"; + s += String(g_sender_faults[idx].meter_read_fail); + s += " d:"; + s += String(g_sender_faults[idx].decode_fail); + s += " tx:"; + s += String(g_sender_faults[idx].lora_tx_fail); + s += " last:"; + s += String(static_cast(g_sender_last_errors[idx])); + return s; +} + static String render_sender_block(const SenderStatus &status) { String s; s += "
"; - s += "" + String(status.last_data.device_id) + "
"; + uint8_t idx = 0; + if (g_statuses) { + for (uint8_t i = 0; i < g_status_count; ++i) { + if (&g_statuses[i] == &status) { + idx = i; + break; + } + } + } + s += "" + String(status.last_data.device_id) + ""; + if (status.has_data && status.last_data.link_valid) { + s += " R:" + String(status.last_data.link_rssi_dbm) + " S:" + String(status.last_data.link_snr_db, 1); + } + s += format_faults(idx); + s += "
"; if (!status.has_data) { s += "No data"; } else { @@ -45,6 +80,7 @@ static void handle_root() { } html += "

Configure WiFi/MQTT/NTP

"; + html += "

Manual

"; html += html_footer(); server.send(200, "text/html", html); } @@ -100,6 +136,31 @@ static void handle_sender() { if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) { String html = html_header("Sender " + device_id); html += render_sender_block(g_statuses[i]); + if (g_last_batch_count[i] > 0) { + html += "

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

"; + html += ""; + html += ""; + html += ""; + for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) { + const MeterData &d = g_last_batch[i][r]; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + } + html += "
#tse_kwhp_wp1_wp2_wp3_wbat_vbat_pctrssisnrerr_txerr_last
" + String(r) + "" + String(d.ts_utc) + "" + String(d.energy_total_kwh, 3) + "" + String(d.total_power_w, 1) + "" + String(d.phase_power_w[0], 1) + "" + String(d.phase_power_w[1], 1) + "" + String(d.phase_power_w[2], 1) + "" + String(d.battery_voltage_v, 2) + "" + String(d.battery_percent) + "" + String(d.link_rssi_dbm) + "" + String(d.link_snr_db, 1) + "" + String(d.err_lora_tx) + "" + String(static_cast(d.last_error)) + "
"; + } html += html_footer(); server.send(200, "text/html", html); return; @@ -108,16 +169,50 @@ static void handle_sender() { server.send(404, "text/plain", "Not found"); } +static void handle_manual() { + String html = html_header("DD3 Manual"); + html += "
    "; + html += "
  • Energy: total kWh since meter start.
  • "; + html += "
  • Power: total active power in W.
  • "; + html += "
  • P1/P2/P3: phase power in W.
  • "; + html += "
  • bat_v: battery voltage (V), bat_pct: estimated percent.
  • "; + html += "
  • RSSI/SNR: LoRa link quality from last packet.
  • "; + html += "
  • err_tx: LoRa TX error count; err_last: last error code.
  • "; + html += "
  • faults m/d/tx: meter read/decode/tx counters.
  • "; + html += "
"; + html += html_footer(); + server.send(200, "text/html", html); +} + void web_server_set_config(const WifiMqttConfig &config) { g_config = config; } +void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) { + g_sender_faults = faults; + g_sender_last_errors = last_errors; +} + +void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count) { + if (!samples || sender_index >= NUM_SENDERS) { + return; + } + if (count > METER_BATCH_MAX_SAMPLES) { + count = METER_BATCH_MAX_SAMPLES; + } + g_last_batch_count[sender_index] = static_cast(count); + for (size_t i = 0; i < count; ++i) { + g_last_batch[sender_index][i] = samples[i]; + } +} + void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) { g_statuses = statuses; g_status_count = count; g_is_ap = true; server.on("/", handle_root); + server.on("/manual", handle_manual); server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_POST, handle_wifi_post); server.on("/sender/", handle_sender); @@ -137,6 +232,7 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) { g_is_ap = false; server.on("/", handle_root); + server.on("/manual", handle_manual); server.on("/sender/", handle_sender); server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_POST, handle_wifi_post);