457 lines
11 KiB
C++
457 lines
11 KiB
C++
#include "meter_driver.h"
|
|
#include "config.h"
|
|
#include <math.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
// Dedicated reader task pumps UART continuously; keep timeout short so parser can
|
|
// recover quickly from broken frames.
|
|
static constexpr uint32_t METER_FRAME_TIMEOUT_MS = 3000;
|
|
static constexpr size_t METER_FRAME_MAX = 512;
|
|
|
|
enum class MeterRxState : uint8_t {
|
|
WaitStart = 0,
|
|
InFrame = 1
|
|
};
|
|
|
|
static MeterRxState g_rx_state = MeterRxState::WaitStart;
|
|
static char g_frame_buf[METER_FRAME_MAX + 1];
|
|
static size_t g_frame_len = 0;
|
|
static uint32_t g_last_rx_ms = 0;
|
|
static uint32_t g_bytes_rx = 0;
|
|
static uint32_t g_frames_ok = 0;
|
|
static uint32_t g_frames_parse_fail = 0;
|
|
static uint32_t g_rx_overflow = 0;
|
|
static uint32_t g_rx_timeout = 0;
|
|
static uint32_t g_last_log_ms = 0;
|
|
static uint32_t g_last_good_frame_ms = 0;
|
|
static constexpr uint32_t METER_FIXED_FRAC_MAX_DIV = 10000;
|
|
|
|
void meter_init() {
|
|
#ifdef ARDUINO_ARCH_ESP32
|
|
// Buffer enough serial data to survive long LoRa blocking sections.
|
|
Serial2.setRxBufferSize(8192);
|
|
#endif
|
|
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
|
}
|
|
|
|
enum class ObisField : uint8_t {
|
|
None = 0,
|
|
Energy = 1,
|
|
TotalPower = 2,
|
|
Phase1 = 3,
|
|
Phase2 = 4,
|
|
Phase3 = 5,
|
|
MeterSeconds = 6
|
|
};
|
|
|
|
static ObisField detect_obis_field(const char *line) {
|
|
if (!line) {
|
|
return ObisField::None;
|
|
}
|
|
const char *p = line;
|
|
while (*p == ' ' || *p == '\t') {
|
|
++p;
|
|
}
|
|
if (strncmp(p, "1-0:1.8.0", 9) == 0) {
|
|
return ObisField::Energy;
|
|
}
|
|
if (strncmp(p, "1-0:16.7.0", 10) == 0) {
|
|
return ObisField::TotalPower;
|
|
}
|
|
if (strncmp(p, "1-0:36.7.0", 10) == 0) {
|
|
return ObisField::Phase1;
|
|
}
|
|
if (strncmp(p, "1-0:56.7.0", 10) == 0) {
|
|
return ObisField::Phase2;
|
|
}
|
|
if (strncmp(p, "1-0:76.7.0", 10) == 0) {
|
|
return ObisField::Phase3;
|
|
}
|
|
if (strncmp(p, "0-0:96.8.0*255", 14) == 0) {
|
|
return ObisField::MeterSeconds;
|
|
}
|
|
return ObisField::None;
|
|
}
|
|
|
|
static bool parse_decimal_fixed(const char *start, const char *end, float &out_value) {
|
|
if (!start || !end || end <= start) {
|
|
return false;
|
|
}
|
|
|
|
const char *cur = start;
|
|
bool started = false;
|
|
bool negative = false;
|
|
bool in_fraction = false;
|
|
bool saw_digit = false;
|
|
uint64_t int_part = 0;
|
|
uint32_t frac_part = 0;
|
|
uint32_t frac_div = 1;
|
|
|
|
while (cur < end) {
|
|
char c = *cur++;
|
|
if (!started) {
|
|
if (c == '+' || c == '-') {
|
|
started = true;
|
|
negative = (c == '-');
|
|
continue;
|
|
}
|
|
if (c >= '0' && c <= '9') {
|
|
started = true;
|
|
saw_digit = true;
|
|
int_part = static_cast<uint64_t>(c - '0');
|
|
continue;
|
|
}
|
|
if (c == '.' || c == ',') {
|
|
started = true;
|
|
in_fraction = true;
|
|
continue;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (c >= '0' && c <= '9') {
|
|
saw_digit = true;
|
|
uint32_t digit = static_cast<uint32_t>(c - '0');
|
|
if (!in_fraction) {
|
|
if (int_part <= (UINT64_MAX - digit) / 10ULL) {
|
|
int_part = int_part * 10ULL + digit;
|
|
}
|
|
} else if (frac_div < METER_FIXED_FRAC_MAX_DIV) {
|
|
frac_part = frac_part * 10U + digit;
|
|
frac_div *= 10U;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ((c == '.' || c == ',') && !in_fraction) {
|
|
in_fraction = true;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (!saw_digit) {
|
|
return false;
|
|
}
|
|
double value = static_cast<double>(int_part);
|
|
if (frac_div > 1U) {
|
|
value += static_cast<double>(frac_part) / static_cast<double>(frac_div);
|
|
}
|
|
if (negative) {
|
|
value = -value;
|
|
}
|
|
out_value = static_cast<float>(value);
|
|
return true;
|
|
}
|
|
|
|
static bool parse_obis_ascii_payload_value(const char *line, float &out_value) {
|
|
const char *lparen = strchr(line, '(');
|
|
if (!lparen) {
|
|
return false;
|
|
}
|
|
const char *end = lparen + 1;
|
|
while (*end && *end != ')' && *end != '*') {
|
|
++end;
|
|
}
|
|
if (end <= lparen + 1) {
|
|
return false;
|
|
}
|
|
return parse_decimal_fixed(lparen + 1, end, out_value);
|
|
}
|
|
|
|
static bool parse_obis_ascii_unit_scale(const char *line, float &value) {
|
|
const char *lparen = strchr(line, '(');
|
|
if (!lparen) {
|
|
return false;
|
|
}
|
|
const char *asterisk = strchr(lparen, '*');
|
|
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 int8_t hex_nibble(char c) {
|
|
if (c >= '0' && c <= '9') {
|
|
return static_cast<int8_t>(c - '0');
|
|
}
|
|
if (c >= 'A' && c <= 'F') {
|
|
return static_cast<int8_t>(10 + (c - 'A'));
|
|
}
|
|
if (c >= 'a' && c <= 'f') {
|
|
return static_cast<int8_t>(10 + (c - 'a'));
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static bool parse_obis_hex_payload_u32(const char *line, uint32_t &out_value) {
|
|
const char *lparen = strchr(line, '(');
|
|
if (!lparen) {
|
|
return false;
|
|
}
|
|
const char *cur = lparen + 1;
|
|
uint32_t value = 0;
|
|
size_t n = 0;
|
|
while (*cur && *cur != ')' && *cur != '*') {
|
|
int8_t nib = hex_nibble(*cur++);
|
|
if (nib < 0) {
|
|
if (n == 0) {
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (n >= 8) {
|
|
return false;
|
|
}
|
|
value = (value << 4) | static_cast<uint32_t>(nib);
|
|
n++;
|
|
}
|
|
if (n == 0) {
|
|
return false;
|
|
}
|
|
out_value = value;
|
|
return true;
|
|
}
|
|
|
|
static void meter_debug_log() {
|
|
if (!SERIAL_DEBUG_MODE) {
|
|
return;
|
|
}
|
|
uint32_t now_ms = millis();
|
|
if (now_ms - g_last_log_ms < 60000) {
|
|
return;
|
|
}
|
|
g_last_log_ms = now_ms;
|
|
Serial.printf("meter: ok=%lu parse_fail=%lu overflow=%lu timeout=%lu bytes=%lu\n",
|
|
static_cast<unsigned long>(g_frames_ok),
|
|
static_cast<unsigned long>(g_frames_parse_fail),
|
|
static_cast<unsigned long>(g_rx_overflow),
|
|
static_cast<unsigned long>(g_rx_timeout),
|
|
static_cast<unsigned long>(g_bytes_rx));
|
|
}
|
|
|
|
void meter_get_stats(MeterDriverStats &out) {
|
|
out.frames_ok = g_frames_ok;
|
|
out.frames_parse_fail = g_frames_parse_fail;
|
|
out.rx_overflow = g_rx_overflow;
|
|
out.rx_timeout = g_rx_timeout;
|
|
out.bytes_rx = g_bytes_rx;
|
|
out.last_rx_ms = g_last_rx_ms;
|
|
out.last_good_frame_ms = g_last_good_frame_ms;
|
|
}
|
|
|
|
bool meter_poll_frame(const char *&frame, size_t &len) {
|
|
frame = nullptr;
|
|
len = 0;
|
|
uint32_t now_ms = millis();
|
|
|
|
if (g_rx_state == MeterRxState::InFrame && (now_ms - g_last_rx_ms > METER_FRAME_TIMEOUT_MS)) {
|
|
g_rx_timeout++;
|
|
g_rx_state = MeterRxState::WaitStart;
|
|
g_frame_len = 0;
|
|
}
|
|
|
|
while (Serial2.available()) {
|
|
char c = static_cast<char>(Serial2.read());
|
|
g_bytes_rx++;
|
|
g_last_rx_ms = now_ms;
|
|
|
|
if (g_rx_state == MeterRxState::WaitStart) {
|
|
if (c == '/') {
|
|
g_rx_state = MeterRxState::InFrame;
|
|
g_frame_len = 0;
|
|
g_frame_buf[g_frame_len++] = c;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Fast resync if a new telegram starts before current frame completed.
|
|
if (c == '/') {
|
|
g_frame_len = 0;
|
|
g_frame_buf[g_frame_len++] = c;
|
|
continue;
|
|
}
|
|
|
|
if (g_frame_len + 1 >= sizeof(g_frame_buf)) {
|
|
g_rx_overflow++;
|
|
g_rx_state = MeterRxState::WaitStart;
|
|
g_frame_len = 0;
|
|
continue;
|
|
}
|
|
|
|
g_frame_buf[g_frame_len++] = c;
|
|
if (c == '!') {
|
|
g_frame_buf[g_frame_len] = '\0';
|
|
frame = g_frame_buf;
|
|
len = g_frame_len;
|
|
g_rx_state = MeterRxState::WaitStart;
|
|
g_frame_len = 0;
|
|
meter_debug_log();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
meter_debug_log();
|
|
return false;
|
|
}
|
|
|
|
bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
|
|
if (!frame || len == 0) {
|
|
return 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;
|
|
char line[128];
|
|
size_t line_len = 0;
|
|
|
|
for (size_t i = 0; i < len; ++i) {
|
|
char c = frame[i];
|
|
if (c == '\r') {
|
|
continue;
|
|
}
|
|
if (c == '!') {
|
|
if (line_len + 1 < sizeof(line)) {
|
|
line[line_len++] = c;
|
|
}
|
|
line[line_len] = '\0';
|
|
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
|
|
if (data.valid) {
|
|
g_frames_ok++;
|
|
g_last_good_frame_ms = millis();
|
|
} else {
|
|
g_frames_parse_fail++;
|
|
}
|
|
return data.valid;
|
|
}
|
|
if (c == '\n') {
|
|
line[line_len] = '\0';
|
|
if (line[0] == '!') {
|
|
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
|
|
if (data.valid) {
|
|
g_frames_ok++;
|
|
g_last_good_frame_ms = millis();
|
|
} else {
|
|
g_frames_parse_fail++;
|
|
}
|
|
return data.valid;
|
|
}
|
|
|
|
ObisField field = detect_obis_field(line);
|
|
float value = NAN;
|
|
uint32_t meter_seconds = 0;
|
|
switch (field) {
|
|
case ObisField::Energy:
|
|
if (parse_obis_ascii_payload_value(line, value)) {
|
|
parse_obis_ascii_unit_scale(line, value);
|
|
data.energy_total_kwh = value;
|
|
energy_ok = true;
|
|
got_any = true;
|
|
}
|
|
break;
|
|
case ObisField::TotalPower:
|
|
if (parse_obis_ascii_payload_value(line, value)) {
|
|
data.total_power_w = value;
|
|
total_p_ok = true;
|
|
got_any = true;
|
|
}
|
|
break;
|
|
case ObisField::Phase1:
|
|
if (parse_obis_ascii_payload_value(line, value)) {
|
|
data.phase_power_w[0] = value;
|
|
p1_ok = true;
|
|
got_any = true;
|
|
}
|
|
break;
|
|
case ObisField::Phase2:
|
|
if (parse_obis_ascii_payload_value(line, value)) {
|
|
data.phase_power_w[1] = value;
|
|
p2_ok = true;
|
|
got_any = true;
|
|
}
|
|
break;
|
|
case ObisField::Phase3:
|
|
if (parse_obis_ascii_payload_value(line, value)) {
|
|
data.phase_power_w[2] = value;
|
|
p3_ok = true;
|
|
got_any = true;
|
|
}
|
|
break;
|
|
case ObisField::MeterSeconds:
|
|
if (parse_obis_hex_payload_u32(line, meter_seconds)) {
|
|
data.meter_seconds = meter_seconds;
|
|
data.meter_seconds_valid = true;
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (energy_ok && total_p_ok && p1_ok && p2_ok && p3_ok && data.meter_seconds_valid) {
|
|
data.valid = true;
|
|
g_frames_ok++;
|
|
g_last_good_frame_ms = millis();
|
|
return true;
|
|
}
|
|
|
|
line_len = 0;
|
|
continue;
|
|
}
|
|
if (line_len + 1 < sizeof(line)) {
|
|
line[line_len++] = c;
|
|
}
|
|
}
|
|
|
|
data.valid = got_any;
|
|
if (data.valid) {
|
|
g_frames_ok++;
|
|
g_last_good_frame_ms = millis();
|
|
} else {
|
|
g_frames_parse_fail++;
|
|
}
|
|
return data.valid;
|
|
}
|
|
|
|
bool meter_read(MeterData &data) {
|
|
data.meter_seconds = 0;
|
|
data.meter_seconds_valid = false;
|
|
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.valid = false;
|
|
|
|
const char *frame = nullptr;
|
|
size_t len = 0;
|
|
if (!meter_poll_frame(frame, len)) {
|
|
return false;
|
|
}
|
|
return meter_parse_frame(frame, len, data);
|
|
}
|