optional RTC 3231 integration

This commit is contained in:
2026-01-29 22:15:50 +01:00
parent e480677b49
commit ce0ee77f77
8 changed files with 285 additions and 54 deletions

View File

@@ -21,6 +21,10 @@ Variants:
- SCL: GPIO22
- RST: **not used** (SSD1306 init uses `-1` reset pin)
- I2C address: 0x3C
- I2C RTC (DS3231)
- SDA: GPIO21
- SCL: GPIO22
- I2C address: 0x68
- Battery ADC: GPIO35 (via on-board divider)
- **Role select**: GPIO13 (INPUT_PULLDOWN)
- LOW = Sender
@@ -44,20 +48,22 @@ Variants:
- Phase voltage: 32.7 / 52.7 / 72.7
- Reads battery voltage and estimates SoC.
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
- Deep sleeps between cycles.
- Light sleeps between meter reads; batches are sent every 30s.
- Listens for LoRa time sync packets to set UTC clock.
- Uses DS3231 RTC after boot if no time sync has arrived yet.
- OLED shows status + meter data pages.
**Sender flow (pseudo-code)**:
```cpp
void sender_cycle() {
meter_read(data); // SML/OBIS -> MeterData
read_battery(data); // VBAT + SoC
data.ts_utc = time_get_utc_or_uptime();
void sender_loop() {
meter_read_every_second(); // SML/OBIS -> MeterData samples
read_battery(data); // VBAT + SoC
json = meterDataToJson(data);
compressed = compressBuffer(json);
lora_send(packet(MeterData, compressed));
if (time_to_send_batch()) {
json = meterBatchToJson(samples);
compressed = compressBuffer(json);
lora_send(packet(MeterBatch, compressed));
}
display_set_last_meter(data);
display_set_last_read(ok);
@@ -65,8 +71,7 @@ void sender_cycle() {
display_tick();
lora_receive_time_sync(); // optional
keep_oled_on_for_read_window();
deep_sleep(SENDER_WAKE_INTERVAL_SEC);
light_sleep_until_next_event();
}
```
@@ -92,16 +97,24 @@ bool lora_send(const LoraPacket &pkt); // add header + CRC16 and transmit
**Receiver loop (pseudo-code)**:
```cpp
void receiver_loop() {
if (lora_receive(pkt) && pkt.type == MeterData) {
json = decompressBuffer(pkt.payload);
if (jsonToMeterData(json, data)) {
update_sender_status(data);
mqtt_publish_state(data);
if (lora_receive(pkt)) {
if (pkt.type == MeterData) {
json = decompressBuffer(pkt.payload);
if (jsonToMeterData(json, data)) {
update_sender_status(data);
mqtt_publish_state(data);
}
} else if (pkt.type == MeterBatch) {
json = reassemble_and_decompress_batch(pkt);
for (sample in jsonToMeterBatch(json)) {
update_sender_status(sample);
mqtt_publish_state(sample);
}
}
}
if (time_to_send_timesync()) {
time_send_timesync(self_short_id);
time_send_timesync(self_short_id); // 60s for first 10 min, then hourly
}
mqtt_loop();
@@ -115,6 +128,7 @@ void receiver_loop() {
```cpp
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
bool jsonToMeterData(const String &json, MeterData &data);
bool jsonToMeterBatch(const String &json, MeterData *samples, size_t max, size_t &count);
bool mqtt_publish_state(const MeterData &data);
void web_server_loop(); // AP or STA UI
void time_send_timesync(uint16_t self_id);
@@ -155,7 +169,7 @@ 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)
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch)
[5..N-3] compressed payload
[N-2..N-1] CRC16 (bytes 0..N-3)
```
@@ -169,16 +183,16 @@ JSON payload (sender + MQTT):
```json
{
"id": "dd3-01",
"id": "F19C",
"ts": 1737200000,
"energy_kwh": 1234.567,
"p_total_w": 950.0,
"p1_w": 500.0,
"p2_w": 450.0,
"p3_w": 0.0,
"v1_v": 230.1,
"v2_v": 229.8,
"v3_v": 231.0,
"e_kwh": 1234.57,
"p_w": 950.00,
"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
}
@@ -188,6 +202,7 @@ JSON payload (sender + MQTT):
- Derived from WiFi STA MAC.
- `short_id = (MAC[4] << 8) | MAC[5]`
- `device_id = dd3-%04X`
- JSON `id` uses only the last 4 hex digits (e.g., `F19C`) to save airtime.
Receiver expects known senders in `include/config.h` via:
```cpp
@@ -225,6 +240,10 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
## NTP
- NTP servers are configurable in the web UI (`/wifi`).
- Defaults: `pool.ntp.org` and `time.nist.gov`.
## RTC (DS3231)
- Optional DS3231 on the I2C bus. Connect SDA to GPIO21 and SCL to GPIO22 (same bus as the OLED).
- Receiver time sync packets set the RTC.
- On boot, if no LoRa time sync has arrived yet, the sender uses the RTC time as the initial `ts_utc`.
## Build Environments
- `lilygo-t3-v1-6-1`: production build
@@ -235,7 +254,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
## 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.
- **Payload size**: JSON < 256 bytes (enforced by ArduinoJson static doc).
- **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).

View File

@@ -51,6 +51,9 @@ constexpr uint8_t LORA_SYNC_WORD = 0x34;
// Timing
constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30;
constexpr uint32_t TIME_SYNC_INTERVAL_SEC = 60;
constexpr uint32_t TIME_SYNC_SLOW_INTERVAL_SEC = 3600;
constexpr uint32_t TIME_SYNC_FAST_WINDOW_MS = 10UL * 60UL * 1000UL;
constexpr bool ENABLE_DS3231 = true;
constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000;
constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t SENDER_OLED_READ_MS = 10000;

8
include/rtc_ds3231.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
#include <Arduino.h>
bool rtc_ds3231_init();
bool rtc_ds3231_is_present();
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc);
bool rtc_ds3231_set_epoch(uint32_t epoch_utc);

View File

@@ -10,3 +10,6 @@ void time_set_utc(uint32_t epoch);
void time_send_timesync(uint16_t device_id_short);
bool time_handle_timesync_payload(const uint8_t *payload, size_t len);
void time_get_local_hhmm(char *out, size_t out_len);
void time_rtc_init();
bool time_try_load_from_rtc();
bool time_rtc_present();

View File

@@ -3,27 +3,71 @@
#include <math.h>
#include "power_manager.h"
static float round2(float value) {
if (isnan(value)) {
return value;
}
return roundf(value * 100.0f) / 100.0f;
}
static const char *short_id_from_device_id(const char *device_id) {
if (!device_id) {
return "";
}
size_t len = strlen(device_id);
if (len >= 4) {
return device_id + (len - 4);
}
return device_id;
}
static void format_float_2(char *buf, size_t buf_len, float value) {
if (!buf || buf_len == 0) {
return;
}
if (isnan(value)) {
snprintf(buf, buf_len, "null");
return;
}
snprintf(buf, buf_len, "%.2f", round2(value));
}
bool meterDataToJson(const MeterData &data, String &out_json) {
StaticJsonDocument<192> doc;
doc["id"] = data.device_id;
doc["id"] = short_id_from_device_id(data.device_id);
doc["ts"] = data.ts_utc;
doc["energy_kwh"] = data.energy_total_kwh;
doc["p_total_w"] = data.total_power_w;
doc["p1_w"] = data.phase_power_w[0];
doc["p2_w"] = data.phase_power_w[1];
doc["p3_w"] = data.phase_power_w[2];
doc["v1_v"] = data.phase_voltage_v[0];
doc["v2_v"] = data.phase_voltage_v[1];
doc["v3_v"] = data.phase_voltage_v[2];
char bat_buf[8];
snprintf(bat_buf, sizeof(bat_buf), "%.2f", data.battery_voltage_v);
doc["bat_v"] = serialized(bat_buf);
char buf[16];
format_float_2(buf, sizeof(buf), data.energy_total_kwh);
doc["e_kwh"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.total_power_w);
doc["p_w"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.phase_power_w[0]);
doc["p1_w"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.phase_power_w[1]);
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);
out_json = "";
size_t len = serializeJson(doc, 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<192> doc;
DeserializationError err = deserializeJson(doc, json);
@@ -32,12 +76,16 @@ bool jsonToMeterData(const String &json, MeterData &data) {
}
const char *id = doc["id"] | "";
strncpy(data.device_id, id, sizeof(data.device_id));
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));
}
data.device_id[sizeof(data.device_id) - 1] = '\0';
data.ts_utc = doc["ts"] | 0;
data.energy_total_kwh = doc["energy_kwh"] | NAN;
data.total_power_w = doc["p_total_w"] | NAN;
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.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;
@@ -66,21 +114,21 @@ bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json)
}
DynamicJsonDocument doc(8192);
doc["id"] = samples[count - 1].device_id;
doc["bat_v"] = samples[count - 1].battery_voltage_v;
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;
JsonArray arr = doc.createNestedArray("s");
for (size_t i = 0; i < count; ++i) {
JsonArray row = arr.createNestedArray();
row.add(samples[i].ts_utc);
row.add(samples[i].energy_total_kwh);
row.add(samples[i].total_power_w);
row.add(samples[i].phase_power_w[0]);
row.add(samples[i].phase_power_w[1]);
row.add(samples[i].phase_power_w[2]);
row.add(samples[i].phase_voltage_v[0]);
row.add(samples[i].phase_voltage_v[1]);
row.add(samples[i].phase_voltage_v[2]);
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);
}
@@ -117,7 +165,11 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou
}
MeterData &data = out_samples[idx];
data = {};
strncpy(data.device_id, id, sizeof(data.device_id));
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));
}
data.device_id[sizeof(data.device_id) - 1] = '\0';
data.ts_utc = row[0] | 0;
data.energy_total_kwh = row[1] | NAN;

View File

@@ -22,6 +22,7 @@ static bool g_ap_mode = false;
static WifiMqttConfig g_cfg;
static uint32_t g_last_timesync_ms = 0;
static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000;
static uint32_t g_boot_ms = 0;
static constexpr size_t BATCH_HEADER_SIZE = 6;
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE;
@@ -244,11 +245,14 @@ void setup() {
Serial.begin(115200);
delay(200);
g_boot_ms = millis();
g_role = detect_role();
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
lora_init();
display_init();
time_rtc_init();
time_try_load_from_rtc();
display_set_role(g_role);
display_set_self_ids(g_short_id, g_device_id);
@@ -381,7 +385,11 @@ static void receiver_loop() {
}
}
if (!g_ap_mode && millis() - g_last_timesync_ms > TIME_SYNC_INTERVAL_SEC * 1000UL) {
uint32_t interval_sec = TIME_SYNC_INTERVAL_SEC;
if (time_rtc_present() && millis() - g_boot_ms >= TIME_SYNC_FAST_WINDOW_MS) {
interval_sec = TIME_SYNC_SLOW_INTERVAL_SEC;
}
if (!g_ap_mode && millis() - g_last_timesync_ms > interval_sec * 1000UL) {
g_last_timesync_ms = millis();
time_send_timesync(g_short_id);
}

105
src/rtc_ds3231.cpp Normal file
View File

@@ -0,0 +1,105 @@
#include "rtc_ds3231.h"
#include "config.h"
#include <Wire.h>
#include <time.h>
static constexpr uint8_t DS3231_ADDR = 0x68;
static uint8_t bcd_to_dec(uint8_t val) {
return static_cast<uint8_t>((val >> 4) * 10 + (val & 0x0F));
}
static uint8_t dec_to_bcd(uint8_t val) {
return static_cast<uint8_t>(((val / 10) << 4) | (val % 10));
}
static bool read_registers(uint8_t start_reg, uint8_t *out, size_t len) {
if (!out || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
size_t read = Wire.requestFrom(DS3231_ADDR, static_cast<uint8_t>(len));
if (read != len) {
return false;
}
for (size_t i = 0; i < len; ++i) {
out[i] = Wire.read();
}
return true;
}
static bool write_registers(uint8_t start_reg, const uint8_t *data, size_t len) {
if (!data || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
for (size_t i = 0; i < len; ++i) {
Wire.write(data[i]);
}
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_init() {
Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL);
Wire.setClock(100000);
return rtc_ds3231_is_present();
}
bool rtc_ds3231_is_present() {
Wire.beginTransmission(DS3231_ADDR);
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc) {
uint8_t regs[7] = {};
if (!read_registers(0x00, regs, sizeof(regs))) {
return false;
}
uint8_t sec = bcd_to_dec(regs[0] & 0x7F);
uint8_t min = bcd_to_dec(regs[1] & 0x7F);
uint8_t hour = bcd_to_dec(regs[2] & 0x3F);
uint8_t day = bcd_to_dec(regs[4] & 0x3F);
uint8_t month = bcd_to_dec(regs[5] & 0x1F);
uint16_t year = 2000 + bcd_to_dec(regs[6]);
struct tm tm_utc = {};
tm_utc.tm_sec = sec;
tm_utc.tm_min = min;
tm_utc.tm_hour = hour;
tm_utc.tm_mday = day;
tm_utc.tm_mon = month - 1;
tm_utc.tm_year = year - 1900;
tm_utc.tm_isdst = 0;
time_t t = timegm(&tm_utc);
if (t <= 0) {
return false;
}
epoch_utc = static_cast<uint32_t>(t);
return true;
}
bool rtc_ds3231_set_epoch(uint32_t epoch_utc) {
time_t t = static_cast<time_t>(epoch_utc);
struct tm tm_utc = {};
if (!gmtime_r(&t, &tm_utc)) {
return false;
}
uint8_t regs[7] = {};
regs[0] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_sec));
regs[1] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_min));
regs[2] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_hour));
regs[3] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_wday + 1));
regs[4] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mday));
regs[5] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mon + 1));
regs[6] = dec_to_bcd(static_cast<uint8_t>((tm_utc.tm_year + 1900) - 2000));
return write_registers(0x00, regs, sizeof(regs));
}

View File

@@ -1,10 +1,12 @@
#include "time_manager.h"
#include "compressor.h"
#include "config.h"
#include "rtc_ds3231.h"
#include <time.h>
static bool g_time_synced = false;
static bool g_tz_set = false;
static bool g_rtc_present = false;
void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2) {
const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org";
@@ -40,6 +42,10 @@ void time_set_utc(uint32_t epoch) {
tv.tv_usec = 0;
settimeofday(&tv, nullptr);
g_time_synced = true;
if (g_rtc_present) {
rtc_ds3231_set_epoch(epoch);
}
}
void time_send_timesync(uint16_t device_id_short) {
@@ -101,3 +107,30 @@ void time_get_local_hhmm(char *out, size_t out_len) {
localtime_r(&now, &timeinfo);
snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
}
void time_rtc_init() {
if (!ENABLE_DS3231) {
g_rtc_present = false;
return;
}
g_rtc_present = rtc_ds3231_init();
}
bool time_try_load_from_rtc() {
if (!g_rtc_present) {
return false;
}
if (time_is_synced()) {
return true;
}
uint32_t epoch = 0;
if (!rtc_ds3231_read_epoch(epoch) || epoch == 0) {
return false;
}
time_set_utc(epoch);
return true;
}
bool time_rtc_present() {
return g_rtc_present;
}