From ce0ee77f774513405d48036b87b8c144e6a83ce0 Mon Sep 17 00:00:00 2001 From: acidburns Date: Thu, 29 Jan 2026 22:15:50 +0100 Subject: [PATCH] optional RTC 3231 integration --- README.md | 73 +++++++++++++++++----------- include/config.h | 3 ++ include/rtc_ds3231.h | 8 ++++ include/time_manager.h | 3 ++ src/json_codec.cpp | 104 ++++++++++++++++++++++++++++++---------- src/main.cpp | 10 +++- src/rtc_ds3231.cpp | 105 +++++++++++++++++++++++++++++++++++++++++ src/time_manager.cpp | 33 +++++++++++++ 8 files changed, 285 insertions(+), 54 deletions(-) create mode 100644 include/rtc_ds3231.h create mode 100644 src/rtc_ds3231.cpp diff --git a/README.md b/README.md index f83b4a7..17ad80c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/include/config.h b/include/config.h index 09e98c8..6436321 100644 --- a/include/config.h +++ b/include/config.h @@ -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; diff --git a/include/rtc_ds3231.h b/include/rtc_ds3231.h new file mode 100644 index 0000000..2423036 --- /dev/null +++ b/include/rtc_ds3231.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +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); diff --git a/include/time_manager.h b/include/time_manager.h index 3cefab9..ab7222f 100644 --- a/include/time_manager.h +++ b/include/time_manager.h @@ -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(); diff --git a/src/json_codec.cpp b/src/json_codec.cpp index 6a769d7..af7b1db 100644 --- a/src/json_codec.cpp +++ b/src/json_codec.cpp @@ -3,27 +3,71 @@ #include #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; diff --git a/src/main.cpp b/src/main.cpp index 96e1413..3325644 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); } diff --git a/src/rtc_ds3231.cpp b/src/rtc_ds3231.cpp new file mode 100644 index 0000000..1b1d91f --- /dev/null +++ b/src/rtc_ds3231.cpp @@ -0,0 +1,105 @@ +#include "rtc_ds3231.h" +#include "config.h" +#include +#include + +static constexpr uint8_t DS3231_ADDR = 0x68; + +static uint8_t bcd_to_dec(uint8_t val) { + return static_cast((val >> 4) * 10 + (val & 0x0F)); +} + +static uint8_t dec_to_bcd(uint8_t val) { + return static_cast(((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(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(t); + return true; +} + +bool rtc_ds3231_set_epoch(uint32_t epoch_utc) { + time_t t = static_cast(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(tm_utc.tm_sec)); + regs[1] = dec_to_bcd(static_cast(tm_utc.tm_min)); + regs[2] = dec_to_bcd(static_cast(tm_utc.tm_hour)); + regs[3] = dec_to_bcd(static_cast(tm_utc.tm_wday + 1)); + regs[4] = dec_to_bcd(static_cast(tm_utc.tm_mday)); + regs[5] = dec_to_bcd(static_cast(tm_utc.tm_mon + 1)); + regs[6] = dec_to_bcd(static_cast((tm_utc.tm_year + 1900) - 2000)); + + return write_registers(0x00, regs, sizeof(regs)); +} diff --git a/src/time_manager.cpp b/src/time_manager.cpp index a34f7d4..96ae190 100644 --- a/src/time_manager.cpp +++ b/src/time_manager.cpp @@ -1,10 +1,12 @@ #include "time_manager.h" #include "compressor.h" #include "config.h" +#include "rtc_ds3231.h" #include 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; +}