Add SD logging and update docs

- Add optional microSD CSV logging per sender/day on receiver
- Wire logger into receiver packet handling
- Document new batch header fields, build envs, and SD logging
- Make sender links open in a new tab
This commit is contained in:
2026-02-02 00:22:35 +01:00
parent 5085b9ad3d
commit f3af5b3f1c
6 changed files with 149 additions and 4 deletions

View File

@@ -175,7 +175,7 @@ Packet layout:
LoRa radio settings: LoRa radio settings:
- Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`) - 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 ## Data Format
MeterData JSON (sender + MQTT): MeterData JSON (sender + MQTT):
@@ -205,6 +205,10 @@ Fixed header (little-endian):
- `dt_s` u8 (seconds, >0) - `dt_s` u8 (seconds, >0)
- `n` u8 (sample count, <=30) - `n` u8 (sample count, <=30)
- `battery_mV` u16 - `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: Body:
- `E0` u32 (absolute energy in Wh) - `E0` u32 (absolute energy in Wh)
@@ -219,6 +223,7 @@ Body:
Notes: Notes:
- Receiver reconstructs timestamps from `t_last` and `dt_s`. - Receiver reconstructs timestamps from `t_last` and `dt_s`.
- Total power is computed on receiver as `p1 + p2 + p3`. - 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 ## Device IDs
- Derived from WiFi STA MAC. - Derived from WiFi STA MAC.
@@ -250,6 +255,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
- `/`: status overview - `/`: status overview
- `/wifi`: WiFi/MQTT/NTP config (AP and STA) - `/wifi`: WiFi/MQTT/NTP config (AP and STA)
- `/sender/<device_id>`: per-sender details - `/sender/<device_id>`: per-sender details
- Sender IDs on `/` are clickable (open sender page in a new tab).
## MQTT ## MQTT
- Topic: `smartmeter/<deviceId>/state` - Topic: `smartmeter/<deviceId>/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-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`: production build for 868 MHz modules
- `lilygo-t3-v1-6-1-868-test`: test 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 ## Config Knobs
Key timing settings in `include/config.h`: 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) - `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion)
- `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON` - `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON`
- `LORA_SEND_BYPASS` (debug only) - `LORA_SEND_BYPASS` (debug only)
- `ENABLE_SD_LOGGING` / `PIN_SD_CS`
## Limits & Known Constraints ## Limits & Known Constraints
- **Compression**: MeterData uses lightweight RLE (good for JSON but not optimal). - **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). - **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. - **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/<device_id>/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 ## Files & Modules
- `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs - `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs
- `include/data_model.h`, `src/data_model.cpp`: MeterData + ID init - `include/data_model.h`, `src/data_model.cpp`: MeterData + ID init

View File

@@ -73,9 +73,11 @@ constexpr uint8_t BATCH_QUEUE_DEPTH = 10;
constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep; constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep;
constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120;
constexpr bool ENABLE_HA_DISCOVERY = true; constexpr bool ENABLE_HA_DISCOVERY = true;
constexpr bool SERIAL_DEBUG_MODE = true; constexpr bool SERIAL_DEBUG_MODE = false;
constexpr bool SERIAL_DEBUG_DUMP_JSON = true; constexpr bool SERIAL_DEBUG_DUMP_JSON = false;
constexpr bool LORA_SEND_BYPASS = 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; constexpr uint8_t NUM_SENDERS = 1;
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {

View File

@@ -13,6 +13,7 @@
#include "web_server.h" #include "web_server.h"
#include "display_ui.h" #include "display_ui.h"
#include "test_mode.h" #include "test_mode.h"
#include "sd_logger.h"
#include <stdarg.h> #include <stdarg.h>
#include <math.h> #include <math.h>
#ifdef ARDUINO_ARCH_ESP32 #ifdef ARDUINO_ARCH_ESP32
@@ -625,6 +626,7 @@ void setup() {
update_battery_cache(); update_battery_cache();
} else { } else {
power_receiver_init(); power_receiver_init();
sd_logger_init();
wifi_manager_init(); wifi_manager_init();
init_sender_statuses(); init_sender_statuses();
display_set_sender_statuses(g_sender_statuses, NUM_SENDERS); display_set_sender_statuses(g_sender_statuses, NUM_SENDERS);
@@ -806,6 +808,7 @@ static void receiver_loop() {
data.link_valid = true; data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm; data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db; 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) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
data.short_id = pkt.device_id_short; data.short_id = pkt.device_id_short;
@@ -897,6 +900,7 @@ static void receiver_loop() {
data.err_decode = batch.err_d; data.err_decode = batch.err_d;
data.err_lora_tx = batch.err_tx; data.err_lora_tx = batch.err_tx;
data.last_error = static_cast<FaultType>(batch.err_last); data.last_error = static_cast<FaultType>(batch.err_last);
sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None);
} }
if (sender_idx >= 0) { if (sender_idx >= 0) {

114
src/sd_logger.cpp Normal file
View File

@@ -0,0 +1,114 @@
#include "sd_logger.h"
#include "config.h"
#include <SD.h>
#include <SPI.h>
#include <time.h>
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<time_t>(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();
}

7
src/sd_logger.h Normal file
View File

@@ -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);

View File

@@ -52,7 +52,8 @@ static String render_sender_block(const SenderStatus &status) {
} }
} }
} }
s += "<strong>" + String(status.last_data.device_id) + "</strong>"; String device_id = status.last_data.device_id;
s += "<strong><a href='/sender/" + device_id + "' target='_blank' rel='noopener noreferrer'>" + device_id + "</a></strong>";
if (status.has_data && status.last_data.link_valid) { 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); s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
} }