diff --git a/README.md b/README.md index 7cd0664..3565a94 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Packet layout: LoRa radio settings: - Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`) -- SF11, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 +- SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 ## Data Format MeterData JSON (sender + MQTT): @@ -205,6 +205,10 @@ Fixed header (little-endian): - `dt_s` u8 (seconds, >0) - `n` u8 (sample count, <=30) - `battery_mV` u16 +- `err_m` u8 (meter read failures, sender-side counter) +- `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) Body: - `E0` u32 (absolute energy in Wh) @@ -219,6 +223,7 @@ Body: Notes: - Receiver reconstructs timestamps from `t_last` and `dt_s`. - Total power is computed on receiver as `p1 + p2 + p3`. +- Sender error counters are carried in the batch header and applied to all samples. ## Device IDs - Derived from WiFi STA MAC. @@ -250,6 +255,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `/`: status overview - `/wifi`: WiFi/MQTT/NTP config (AP and STA) - `/sender/`: per-sender details +- Sender IDs on `/` are clickable (open sender page in a new tab). ## MQTT - Topic: `smartmeter//state` @@ -272,6 +278,8 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `lilygo-t3-v1-6-1-test`: test build with `ENABLE_TEST_MODE` - `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 +- `lilygo-t3-v1-6-1-payload-test`: build with `PAYLOAD_CODEC_TEST` +- `lilygo-t3-v1-6-1-868-payload-test`: 868 MHz build with `PAYLOAD_CODEC_TEST` ## Config Knobs Key timing settings in `include/config.h`: @@ -283,6 +291,7 @@ Key timing settings in `include/config.h`: - `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion) - `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON` - `LORA_SEND_BYPASS` (debug only) + - `ENABLE_SD_LOGGING` / `PIN_SD_CS` ## Limits & Known Constraints - **Compression**: MeterData uses lightweight RLE (good for JSON but not optimal). @@ -292,6 +301,14 @@ Key timing settings in `include/config.h`: - **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. +## SD Logging (Receiver) +Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`. + +- Path: `/dd3//YYYY-MM-DD.csv` +- Columns: + `ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last` +- `err_last` is written as text (`meter`, `decode`, `loratx`) only on the last sample of a batch that reports an error. + ## Files & Modules - `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs - `include/data_model.h`, `src/data_model.cpp`: MeterData + ID init diff --git a/include/config.h b/include/config.h index 250e727..a3bab78 100644 --- a/include/config.h +++ b/include/config.h @@ -73,9 +73,11 @@ 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 = true; -constexpr bool SERIAL_DEBUG_DUMP_JSON = true; +constexpr bool SERIAL_DEBUG_MODE = false; +constexpr bool SERIAL_DEBUG_DUMP_JSON = false; constexpr bool LORA_SEND_BYPASS = false; +constexpr bool ENABLE_SD_LOGGING = false; +constexpr uint8_t PIN_SD_CS = 25; constexpr uint8_t NUM_SENDERS = 1; inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { diff --git a/src/main.cpp b/src/main.cpp index dcbaf46..9dc364f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include "web_server.h" #include "display_ui.h" #include "test_mode.h" +#include "sd_logger.h" #include #include #ifdef ARDUINO_ARCH_ESP32 @@ -625,6 +626,7 @@ void setup() { update_battery_cache(); } else { power_receiver_init(); + sd_logger_init(); wifi_manager_init(); init_sender_statuses(); display_set_sender_statuses(g_sender_statuses, NUM_SENDERS); @@ -806,6 +808,7 @@ static void receiver_loop() { data.link_valid = true; data.link_rssi_dbm = pkt.rssi_dbm; data.link_snr_db = pkt.snr_db; + sd_logger_log_sample(data, data.last_error != FaultType::None); for (uint8_t i = 0; i < NUM_SENDERS; ++i) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { data.short_id = pkt.device_id_short; @@ -897,6 +900,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); + sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None); } if (sender_idx >= 0) { diff --git a/src/sd_logger.cpp b/src/sd_logger.cpp new file mode 100644 index 0000000..ceabfcd --- /dev/null +++ b/src/sd_logger.cpp @@ -0,0 +1,114 @@ +#include "sd_logger.h" +#include "config.h" +#include +#include +#include + +static bool g_sd_ready = false; + +static const char *fault_text(FaultType fault) { + switch (fault) { + case FaultType::MeterRead: + return "meter"; + case FaultType::Decode: + return "decode"; + case FaultType::LoraTx: + return "loratx"; + default: + return ""; + } +} + +static bool ensure_dir(const String &path) { + if (SD.exists(path)) { + return true; + } + return SD.mkdir(path); +} + +static String format_date_utc(uint32_t ts_utc) { + time_t t = static_cast(ts_utc); + struct tm tm_utc; + gmtime_r(&t, &tm_utc); + char buf[16]; + snprintf(buf, sizeof(buf), "%04d-%02d-%02d", + tm_utc.tm_year + 1900, + tm_utc.tm_mon + 1, + tm_utc.tm_mday); + return String(buf); +} + +void sd_logger_init() { + if (!ENABLE_SD_LOGGING) { + g_sd_ready = false; + return; + } + g_sd_ready = SD.begin(PIN_SD_CS); +} + +bool sd_logger_is_ready() { + return g_sd_ready; +} + +void sd_logger_log_sample(const MeterData &data, bool include_error_text) { + if (!g_sd_ready || data.ts_utc == 0) { + return; + } + + String root_dir = "/dd3"; + if (!ensure_dir(root_dir)) { + return; + } + + String sender_dir = root_dir + "/" + String(data.device_id); + if (!ensure_dir(sender_dir)) { + return; + } + + String filename = sender_dir + "/" + format_date_utc(data.ts_utc) + ".csv"; + bool new_file = !SD.exists(filename); + File f = SD.open(filename, FILE_APPEND); + if (!f) { + return; + } + + if (new_file) { + f.println("ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last"); + } + + f.print(data.ts_utc); + f.print(','); + f.print(data.total_power_w, 1); + f.print(','); + f.print(data.phase_power_w[0], 1); + f.print(','); + f.print(data.phase_power_w[1], 1); + f.print(','); + f.print(data.phase_power_w[2], 1); + f.print(','); + f.print(data.energy_total_kwh, 3); + f.print(','); + f.print(data.battery_voltage_v, 2); + f.print(','); + f.print(data.battery_percent); + f.print(','); + f.print(data.link_rssi_dbm); + f.print(','); + if (isnan(data.link_snr_db)) { + f.print(""); + } else { + f.print(data.link_snr_db, 1); + } + f.print(','); + f.print(data.err_meter_read); + f.print(','); + f.print(data.err_decode); + f.print(','); + f.print(data.err_lora_tx); + f.print(','); + if (include_error_text && data.last_error != FaultType::None) { + f.print(fault_text(data.last_error)); + } + f.println(); + f.close(); +} diff --git a/src/sd_logger.h b/src/sd_logger.h new file mode 100644 index 0000000..60b1611 --- /dev/null +++ b/src/sd_logger.h @@ -0,0 +1,7 @@ +#pragma once + +#include "data_model.h" + +void sd_logger_init(); +bool sd_logger_is_ready(); +void sd_logger_log_sample(const MeterData &data, bool include_error_text); diff --git a/src/web_server.cpp b/src/web_server.cpp index ac4456e..4682c5d 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -52,7 +52,8 @@ static String render_sender_block(const SenderStatus &status) { } } } - s += "" + String(status.last_data.device_id) + ""; + String device_id = status.last_data.device_id; + s += "" + device_id + ""; if (status.has_data && status.last_data.link_valid) { s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1); }