diff --git a/README.md b/README.md index 69b6d20..f83b4a7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Variants: ## Firmware Roles ### Sender (battery-powered) -- Reads DD3 smart meter via optical IR (UART 9600 7E1). +- Reads smart meter via optical IR (UART 9600 7E1). - Extracts OBIS values: - Energy total: 1-0:1.8.0*255 - Total power: 1-0:16.7.0*255 @@ -65,6 +65,7 @@ void sender_cycle() { display_tick(); lora_receive_time_sync(); // optional + keep_oled_on_for_read_window(); deep_sleep(SENDER_WAKE_INTERVAL_SEC); } ``` @@ -195,11 +196,11 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; ``` ## OLED Behavior -- After reset, OLED stays **ON for 10 minutes** regardless of switch. -- After that: +- Sender: OLED stays **ON for 10 seconds** on each wake, then powers down for sleep. +- Receiver: OLED follows the 10-minute auto-off behavior: - GPIO14 HIGH: OLED forced ON. - - GPIO14 LOW: start 10-minute auto-off timer. -- Pages rotate every 10s. + - GPIO14 LOW: auto-off after 10 minutes. +- Pages rotate every 4s. ## Power & Battery - Sender disables WiFi/BLE, reads VBAT via ADC, uses linear SoC map: @@ -233,7 +234,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**: heuristic SML parser; may need tuning for some DD3 meters. +- **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). - **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`. - **OLED**: no hardware reset line is used (matches working reference). @@ -244,7 +245,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode - `include/compressor.h`, `src/compressor.cpp`: RLE compression - `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC -- `include/meter_driver.h`, `src/meter_driver.cpp`: SML/OBIS parse +- `include/meter_driver.h`, `src/meter_driver.cpp`: IEC 62056-21 ASCII + SML parse - `include/power_manager.h`, `src/power_manager.cpp`: ADC + sleep - `include/time_manager.h`, `src/time_manager.cpp`: NTP + time sync - `include/wifi_manager.h`, `src/wifi_manager.cpp`: NVS config + WiFi diff --git a/include/config.h b/include/config.h index cc0a3a9..09e98c8 100644 --- a/include/config.h +++ b/include/config.h @@ -10,7 +10,8 @@ enum class DeviceRole : uint8_t { enum class PayloadType : uint8_t { MeterData = 0, TestCode = 1, - TimeSync = 2 + TimeSync = 2, + MeterBatch = 3 }; constexpr uint8_t PROTOCOL_VERSION = 1; @@ -53,6 +54,9 @@ constexpr uint32_t TIME_SYNC_INTERVAL_SEC = 60; 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; +constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000; +constexpr uint32_t METER_SEND_INTERVAL_MS = 30000; +constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; constexpr uint8_t NUM_SENDERS = 1; inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { diff --git a/include/json_codec.h b/include/json_codec.h index 7c79166..bf448e9 100644 --- a/include/json_codec.h +++ b/include/json_codec.h @@ -5,3 +5,5 @@ bool meterDataToJson(const MeterData &data, String &out_json); bool jsonToMeterData(const String &json, MeterData &data); +bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json); +bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count); diff --git a/include/power_manager.h b/include/power_manager.h index 39c6159..110c377 100644 --- a/include/power_manager.h +++ b/include/power_manager.h @@ -7,4 +7,5 @@ void power_sender_init(); void power_receiver_init(); void read_battery(MeterData &data); uint8_t battery_percent_from_voltage(float voltage_v); +void light_sleep_ms(uint32_t ms); void go_to_deep_sleep(uint32_t seconds); diff --git a/src/json_codec.cpp b/src/json_codec.cpp index 39b717b..6a769d7 100644 --- a/src/json_codec.cpp +++ b/src/json_codec.cpp @@ -59,3 +59,90 @@ bool jsonToMeterData(const String &json, MeterData &data) { return true; } + +bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json) { + if (!samples || count == 0) { + return false; + } + + DynamicJsonDocument doc(8192); + doc["id"] = samples[count - 1].device_id; + doc["bat_v"] = 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(samples[i].valid ? 1 : 0); + } + + out_json = ""; + size_t len = serializeJson(doc, out_json); + return len > 0; +} + +bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count) { + out_count = 0; + if (!out_samples || max_count == 0) { + return false; + } + + DynamicJsonDocument doc(8192); + DeserializationError err = deserializeJson(doc, json); + if (err) { + return false; + } + + JsonArray arr = doc["s"].as(); + if (arr.isNull()) { + return false; + } + + const char *id = doc["id"] | ""; + float bat_v = doc["bat_v"] | NAN; + uint8_t bat_pct = doc["bat_pct"] | 0; + + size_t idx = 0; + for (JsonArray row : arr) { + if (idx >= max_count) { + break; + } + MeterData &data = out_samples[idx]; + data = {}; + 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; + data.total_power_w = row[2] | NAN; + data.phase_power_w[0] = row[3] | NAN; + data.phase_power_w[1] = row[4] | NAN; + data.phase_power_w[2] = row[5] | NAN; + data.phase_voltage_v[0] = row[6] | NAN; + data.phase_voltage_v[1] = row[7] | NAN; + data.phase_voltage_v[2] = row[8] | NAN; + data.valid = (row[9] | 1) != 0; + data.battery_voltage_v = bat_v; + if (doc["bat_pct"].isNull() && !isnan(bat_v)) { + data.battery_percent = battery_percent_from_voltage(bat_v); + } else { + data.battery_percent = bat_pct; + } + + if (strlen(data.device_id) >= 8) { + const char *suffix = data.device_id + strlen(data.device_id) - 4; + data.short_id = static_cast(strtoul(suffix, nullptr, 16)); + } + idx++; + } + + out_count = idx; + return idx > 0; +} diff --git a/src/main.cpp b/src/main.cpp index 9d31a42..96e1413 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,32 @@ static WifiMqttConfig g_cfg; static uint32_t g_last_timesync_ms = 0; static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000; +static constexpr size_t BATCH_HEADER_SIZE = 6; +static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE; +static constexpr size_t BATCH_MAX_COMPRESSED = 4096; +static constexpr size_t BATCH_MAX_DECOMPRESSED = 8192; +static constexpr uint32_t BATCH_RX_TIMEOUT_MS = 2000; + +static MeterData g_meter_samples[METER_BATCH_MAX_SAMPLES]; +static uint8_t g_meter_sample_count = 0; +static uint8_t g_meter_sample_head = 0; +static uint32_t g_last_sample_ms = 0; +static uint32_t g_last_send_ms = 0; +static uint16_t g_batch_id = 1; + +struct BatchRxState { + bool active; + uint16_t batch_id; + uint8_t next_index; + uint8_t expected_chunks; + uint16_t total_len; + uint16_t received_len; + uint32_t last_rx_ms; + uint8_t buffer[BATCH_MAX_COMPRESSED]; +}; + +static BatchRxState g_batch_rx = {}; + static void init_sender_statuses() { for (uint8_t i = 0; i < NUM_SENDERS; ++i) { g_sender_statuses[i] = {}; @@ -33,6 +59,187 @@ static void init_sender_statuses() { } } +static void push_meter_sample(const MeterData &data) { + g_meter_samples[g_meter_sample_head] = data; + g_meter_sample_head = (g_meter_sample_head + 1) % METER_BATCH_MAX_SAMPLES; + if (g_meter_sample_count < METER_BATCH_MAX_SAMPLES) { + g_meter_sample_count++; + } +} + +static size_t copy_meter_samples(MeterData *out, size_t max_count) { + if (!out || max_count == 0 || g_meter_sample_count == 0) { + return 0; + } + size_t count = g_meter_sample_count < max_count ? g_meter_sample_count : max_count; + size_t start = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - count) % METER_BATCH_MAX_SAMPLES; + for (size_t i = 0; i < count; ++i) { + out[i] = g_meter_samples[(start + i) % METER_BATCH_MAX_SAMPLES]; + } + return count; +} + +static uint32_t last_sample_ts() { + if (g_meter_sample_count == 0) { + uint32_t now_utc = time_get_utc(); + return now_utc > 0 ? now_utc : millis() / 1000; + } + size_t idx = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - 1) % METER_BATCH_MAX_SAMPLES; + return g_meter_samples[idx].ts_utc; +} + +static void write_u16_le(uint8_t *dst, uint16_t value) { + dst[0] = static_cast(value & 0xFF); + dst[1] = static_cast((value >> 8) & 0xFF); +} + +static uint16_t read_u16_le(const uint8_t *src) { + return static_cast(src[0]) | (static_cast(src[1]) << 8); +} + +static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display) { + if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) { + return false; + } + uint8_t chunk_count = static_cast((len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); + if (chunk_count == 0) { + return false; + } + + bool all_ok = true; + size_t offset = 0; + for (uint8_t i = 0; i < chunk_count; ++i) { + size_t chunk_len = len - offset; + if (chunk_len > BATCH_CHUNK_PAYLOAD) { + chunk_len = BATCH_CHUNK_PAYLOAD; + } + LoraPacket pkt = {}; + pkt.protocol_version = PROTOCOL_VERSION; + pkt.role = DeviceRole::Sender; + pkt.device_id_short = g_short_id; + pkt.payload_type = PayloadType::MeterBatch; + pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; + + uint8_t *payload = pkt.payload; + write_u16_le(&payload[0], g_batch_id); + payload[2] = i; + payload[3] = chunk_count; + write_u16_le(&payload[4], static_cast(len)); + memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len); + + bool ok = lora_send(pkt); + all_ok = all_ok && ok; + offset += chunk_len; + delay(10); + } + + if (all_ok) { + g_batch_id++; + } + display_set_last_tx(all_ok, ts_for_display); + return all_ok; +} + +static bool send_meter_batch(uint32_t ts_for_display) { + MeterData ordered[METER_BATCH_MAX_SAMPLES]; + size_t count = copy_meter_samples(ordered, METER_BATCH_MAX_SAMPLES); + if (count == 0) { + return false; + } + + String json; + if (!meterBatchToJson(ordered, count, json)) { + return false; + } + + static uint8_t compressed[BATCH_MAX_COMPRESSED]; + size_t compressed_len = 0; + if (!compressBuffer(reinterpret_cast(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) { + return false; + } + + bool ok = send_batch_payload(compressed, compressed_len, ts_for_display); + if (ok) { + g_meter_sample_count = 0; + g_meter_sample_head = 0; + } + return ok; +} + +static void reset_batch_rx() { + g_batch_rx.active = false; + g_batch_rx.batch_id = 0; + g_batch_rx.next_index = 0; + g_batch_rx.expected_chunks = 0; + g_batch_rx.total_len = 0; + g_batch_rx.received_len = 0; + g_batch_rx.last_rx_ms = 0; +} + +static bool process_batch_packet(const LoraPacket &pkt, String &out_json) { + if (pkt.payload_len < BATCH_HEADER_SIZE) { + return false; + } + uint16_t batch_id = read_u16_le(&pkt.payload[0]); + uint8_t chunk_index = pkt.payload[2]; + uint8_t chunk_count = pkt.payload[3]; + uint16_t total_len = read_u16_le(&pkt.payload[4]); + const uint8_t *chunk_data = &pkt.payload[BATCH_HEADER_SIZE]; + size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE; + uint32_t now_ms = millis(); + + if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > BATCH_RX_TIMEOUT_MS)) { + if (chunk_index != 0) { + reset_batch_rx(); + return false; + } + if (total_len == 0 || total_len > BATCH_MAX_COMPRESSED || chunk_count == 0) { + reset_batch_rx(); + return false; + } + g_batch_rx.active = true; + g_batch_rx.batch_id = batch_id; + g_batch_rx.expected_chunks = chunk_count; + g_batch_rx.total_len = total_len; + g_batch_rx.received_len = 0; + g_batch_rx.next_index = 0; + } + + if (!g_batch_rx.active || chunk_index != g_batch_rx.next_index || chunk_count != g_batch_rx.expected_chunks) { + reset_batch_rx(); + return false; + } + + if (g_batch_rx.received_len + chunk_len > g_batch_rx.total_len || g_batch_rx.received_len + chunk_len > BATCH_MAX_COMPRESSED) { + reset_batch_rx(); + return false; + } + + memcpy(&g_batch_rx.buffer[g_batch_rx.received_len], chunk_data, chunk_len); + g_batch_rx.received_len += static_cast(chunk_len); + g_batch_rx.next_index++; + g_batch_rx.last_rx_ms = now_ms; + + if (g_batch_rx.next_index == g_batch_rx.expected_chunks && g_batch_rx.received_len == g_batch_rx.total_len) { + static uint8_t decompressed[BATCH_MAX_DECOMPRESSED]; + size_t decompressed_len = 0; + if (!decompressBuffer(g_batch_rx.buffer, g_batch_rx.received_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { + reset_batch_rx(); + return false; + } + if (decompressed_len >= sizeof(decompressed)) { + reset_batch_rx(); + return false; + } + decompressed[decompressed_len] = '\0'; + out_json = String(reinterpret_cast(decompressed)); + reset_batch_rx(); + return true; + } + + return false; +} + void setup() { Serial.begin(115200); delay(200); @@ -48,6 +255,8 @@ void setup() { if (g_role == DeviceRole::Sender) { power_sender_init(); meter_init(); + g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; + g_last_send_ms = millis(); } else { power_receiver_init(); wifi_manager_init(); @@ -78,56 +287,45 @@ void setup() { } } -static void sender_cycle() { - MeterData data = {}; - data.short_id = g_short_id; - strncpy(data.device_id, g_device_id, sizeof(data.device_id)); +static void sender_loop() { + uint32_t now_ms = millis(); - bool meter_ok = meter_read(data); - read_battery(data); + if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { + g_last_sample_ms = now_ms; + MeterData data = {}; + data.short_id = g_short_id; + strncpy(data.device_id, g_device_id, sizeof(data.device_id)); - uint32_t now_utc = time_get_utc(); - data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000; - data.valid = meter_ok; + bool meter_ok = meter_read(data); + read_battery(data); - display_set_last_meter(data); - display_set_last_read(meter_ok, data.ts_utc); + uint32_t now_utc = time_get_utc(); + data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000; + data.valid = meter_ok; - String json; - bool json_ok = meterDataToJson(data, json); - - bool tx_ok = false; - if (json_ok) { - uint8_t compressed[LORA_MAX_PAYLOAD]; - size_t compressed_len = 0; - if (compressBuffer(reinterpret_cast(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) { - LoraPacket pkt = {}; - pkt.protocol_version = PROTOCOL_VERSION; - pkt.role = DeviceRole::Sender; - pkt.device_id_short = g_short_id; - pkt.payload_type = PayloadType::MeterData; - pkt.payload_len = compressed_len; - memcpy(pkt.payload, compressed, compressed_len); - tx_ok = lora_send(pkt); - } + push_meter_sample(data); + display_set_last_meter(data); + display_set_last_read(meter_ok, data.ts_utc); } - display_set_last_tx(tx_ok, data.ts_utc); - display_tick(); + if (now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { + g_last_send_ms = now_ms; + send_meter_batch(last_sample_ts()); + } LoraPacket rx = {}; - if (lora_receive(rx, 200) && rx.protocol_version == PROTOCOL_VERSION && rx.payload_type == PayloadType::TimeSync) { + if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION && rx.payload_type == PayloadType::TimeSync) { time_handle_timesync_payload(rx.payload, rx.payload_len); } - uint32_t start = millis(); - while (millis() - start < SENDER_OLED_READ_MS) { - display_tick(); - delay(50); + display_tick(); + + uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS; + uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; + uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due; + if (next_due > now_ms) { + light_sleep_ms(next_due - now_ms); } - display_power_down(); - lora_sleep(); - go_to_deep_sleep(SENDER_WAKE_INTERVAL_SEC); } static void receiver_loop() { @@ -135,24 +333,48 @@ static void receiver_loop() { g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TIME_SYNC_OFFSET_MS); } LoraPacket pkt = {}; - if (lora_receive(pkt, 0) && pkt.protocol_version == PROTOCOL_VERSION && pkt.payload_type == PayloadType::MeterData) { - uint8_t decompressed[256]; - size_t decompressed_len = 0; - if (decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { - if (decompressed_len >= sizeof(decompressed)) { - return; + if (lora_receive(pkt, 0) && pkt.protocol_version == PROTOCOL_VERSION) { + if (pkt.payload_type == PayloadType::MeterData) { + uint8_t decompressed[256]; + size_t decompressed_len = 0; + if (decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) { + if (decompressed_len >= sizeof(decompressed)) { + return; + } + decompressed[decompressed_len] = '\0'; + MeterData data = {}; + if (jsonToMeterData(String(reinterpret_cast(decompressed)), data)) { + 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; + g_sender_statuses[i].last_data = data; + g_sender_statuses[i].last_update_ts_utc = data.ts_utc; + g_sender_statuses[i].has_data = true; + mqtt_publish_state(data); + break; + } + } + } } - decompressed[decompressed_len] = '\0'; - MeterData data = {}; - if (jsonToMeterData(String(reinterpret_cast(decompressed)), data)) { - 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; - g_sender_statuses[i].last_data = data; - g_sender_statuses[i].last_update_ts_utc = data.ts_utc; - g_sender_statuses[i].has_data = true; - mqtt_publish_state(data); - break; + } else if (pkt.payload_type == PayloadType::MeterBatch) { + String json; + if (process_batch_packet(pkt, json)) { + MeterData samples[METER_BATCH_MAX_SAMPLES]; + size_t count = 0; + if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { + for (uint8_t i = 0; i < NUM_SENDERS; ++i) { + if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { + for (size_t s = 0; s < count; ++s) { + samples[s].short_id = pkt.device_id_short; + mqtt_publish_state(samples[s]); + } + if (count > 0) { + g_sender_statuses[i].last_data = samples[count - 1]; + g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc; + g_sender_statuses[i].has_data = true; + } + break; + } } } } @@ -188,7 +410,7 @@ void loop() { #endif if (g_role == DeviceRole::Sender) { - sender_cycle(); + sender_loop(); } else { receiver_loop(); } diff --git a/src/meter_driver.cpp b/src/meter_driver.cpp index 78edd7b..1816501 100644 --- a/src/meter_driver.cpp +++ b/src/meter_driver.cpp @@ -1,6 +1,7 @@ #include "meter_driver.h" #include "config.h" #include +#include #include static constexpr uint32_t METER_READ_TIMEOUT_MS = 2000; @@ -76,7 +77,7 @@ void meter_init() { Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); } -bool meter_read(MeterData &data) { +static bool meter_read_sml(MeterData &data) { uint8_t buffer[SML_BUFFER_SIZE]; size_t len = 0; bool started = false; @@ -164,3 +165,182 @@ parse_frame: data.valid = ok; return ok; } + +static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) { + const char *p = strstr(line, obis); + if (!p) { + return false; + } + const char *lparen = strchr(p, '('); + if (!lparen) { + return false; + } + const char *cur = lparen + 1; + char num_buf[24]; + size_t n = 0; + while (*cur && *cur != ')' && *cur != '*') { + char c = *cur++; + if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == ',') { + if (c == ',') { + c = '.'; + } + if (n + 1 < sizeof(num_buf)) { + num_buf[n++] = c; + } + } else if (n == 0) { + continue; + } else { + break; + } + } + if (n == 0) { + return false; + } + num_buf[n] = '\0'; + out_value = static_cast(atof(num_buf)); + return true; +} + +static bool parse_obis_ascii_unit_scale(const char *line, const char *obis, float &value) { + const char *p = strstr(line, obis); + if (!p) { + return false; + } + const char *asterisk = strchr(p, '*'); + if (!asterisk) { + return false; + } + const char *end = strchr(asterisk, ')'); + if (!end) { + return false; + } + char unit_buf[8]; + size_t ulen = 0; + for (const char *c = asterisk + 1; c < end && ulen + 1 < sizeof(unit_buf); ++c) { + if (*c == ' ') { + continue; + } + unit_buf[ulen++] = *c; + } + unit_buf[ulen] = '\0'; + if (ulen == 0) { + return false; + } + if (strcmp(unit_buf, "Wh") == 0) { + value *= 0.001f; + return true; + } + return false; +} + +static bool meter_read_ascii(MeterData &data) { + const uint32_t start_ms = millis(); + bool in_telegram = false; + bool got_any = false; + + bool energy_ok = false; + bool total_p_ok = false; + bool p1_ok = false; + bool p2_ok = false; + bool p3_ok = false; + bool v1_ok = false; + bool v2_ok = false; + bool v3_ok = false; + + char line[128]; + size_t line_len = 0; + + while (millis() - start_ms < METER_READ_TIMEOUT_MS) { + while (Serial2.available()) { + char c = static_cast(Serial2.read()); + if (!in_telegram) { + if (c == '/') { + in_telegram = true; + line_len = 0; + line[line_len++] = c; + } + continue; + } + + if (c == '\r') { + continue; + } + if (c == '\n') { + line[line_len] = '\0'; + if (line[0] == '!') { + return got_any; + } + + float value = NAN; + if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) { + parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value); + data.energy_total_kwh = value; + energy_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:16.7.0", value)) { + data.total_power_w = value; + total_p_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:36.7.0", value)) { + data.phase_power_w[0] = value; + p1_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:56.7.0", value)) { + data.phase_power_w[1] = value; + p2_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:76.7.0", value)) { + data.phase_power_w[2] = value; + p3_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:32.7.0", value)) { + data.phase_voltage_v[0] = value; + v1_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:52.7.0", value)) { + data.phase_voltage_v[1] = value; + v2_ok = true; + got_any = true; + } + if (parse_obis_ascii_value(line, "1-0:72.7.0", value)) { + data.phase_voltage_v[2] = value; + v3_ok = true; + got_any = true; + } + + line_len = 0; + continue; + } + if (line_len + 1 < sizeof(line)) { + line[line_len++] = c; + } + } + delay(5); + } + + data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok || v1_ok || v2_ok || v3_ok; + return data.valid; +} + +bool meter_read(MeterData &data) { + data.energy_total_kwh = NAN; + data.total_power_w = NAN; + data.phase_power_w[0] = NAN; + data.phase_power_w[1] = NAN; + data.phase_power_w[2] = NAN; + data.phase_voltage_v[0] = NAN; + data.phase_voltage_v[1] = NAN; + data.phase_voltage_v[2] = NAN; + data.valid = false; + + if (meter_read_ascii(data)) { + return true; + } + return meter_read_sml(data); +} diff --git a/src/power_manager.cpp b/src/power_manager.cpp index adc9885..50b74e0 100644 --- a/src/power_manager.cpp +++ b/src/power_manager.cpp @@ -47,6 +47,14 @@ uint8_t battery_percent_from_voltage(float voltage_v) { return static_cast(pct + 0.5f); } +void light_sleep_ms(uint32_t ms) { + if (ms == 0) { + return; + } + esp_sleep_enable_timer_wakeup(static_cast(ms) * 1000ULL); + esp_light_sleep_start(); +} + void go_to_deep_sleep(uint32_t seconds) { esp_sleep_enable_timer_wakeup(static_cast(seconds) * 1000000ULL); esp_deep_sleep_start();