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:
19
README.md
19
README.md
@@ -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
|
||||||
|
|||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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
114
src/sd_logger.cpp
Normal 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
7
src/sd_logger.h
Normal 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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user