From d1db480b324d97e8458812a52aae64bd4618561a Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:37:11 +0200 Subject: [PATCH] * Remove dynamic memory allocations * Add support for TF-PSA encryption * Improve test coverage * Add logging * Incorporate pull request "add OBIS for israel meters": https://github.com/esphome-libs/dsmr_parser/pull/50 * Increase the version of the library to "1.3.0" --- README.md | 10 +- library.json | 2 +- src/dsmr_parser/aes128gcm.h | 47 - src/dsmr_parser/decryption/aes128gcm.h | 41 + .../{ => decryption}/aes128gcm_bearssl.h | 20 +- .../{ => decryption}/aes128gcm_mbedtls.h | 10 +- src/dsmr_parser/decryption/aes128gcm_tfpsa.h | 98 ++ src/dsmr_parser/dlms_packet_decryptor.h | 87 +- src/dsmr_parser/fields.h | 255 ++-- src/dsmr_parser/packet_accumulator.h | 93 +- src/dsmr_parser/parser.h | 600 +++----- src/dsmr_parser/util.h | 163 +- .../aes128gcm_bearssl_include_test.cpp | 4 +- .../aes128gcm_mbedtls_include_test.cpp | 4 +- .../aes128gcm_tfpsa_include_test.cpp | 12 + tests/dlms_packet_decryptor_example_test.cpp | 28 +- tests/dlms_packet_decryptor_include_test.cpp | 8 +- tests/dlms_packet_decryptor_test.cpp | 105 +- tests/packet_accumulator_example_test.cpp | 66 +- tests/packet_accumulator_include_test.cpp | 2 +- tests/packet_accumulator_test.cpp | 211 ++- tests/parser_include_test.cpp | 3 +- tests/parser_test.cpp | 1355 +++++++++-------- tests/test_util.h | 43 + 24 files changed, 1660 insertions(+), 1607 deletions(-) delete mode 100644 src/dsmr_parser/aes128gcm.h create mode 100644 src/dsmr_parser/decryption/aes128gcm.h rename src/dsmr_parser/{ => decryption}/aes128gcm_bearssl.h (55%) rename src/dsmr_parser/{ => decryption}/aes128gcm_mbedtls.h (53%) create mode 100644 src/dsmr_parser/decryption/aes128gcm_tfpsa.h rename tests/{ => decryption}/aes128gcm_bearssl_include_test.cpp (70%) rename tests/{ => decryption}/aes128gcm_mbedtls_include_test.cpp (70%) create mode 100644 tests/decryption/aes128gcm_tfpsa_include_test.cpp create mode 100644 tests/test_util.h diff --git a/README.md b/README.md index e57d7b5..ef0f4e5 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ The primary goal is to make the parser independent of the Arduino framework and * Combines all fixes from [matthijskooijman/arduino-dsmr](https://github.com/matthijskooijman/arduino-dsmr) and [glmnet/arduino-dsmr](https://github.com/glmnet/arduino-dsmr) including unmerged pull requests. * Added an extensive unit test suite * Code optimizations +* No dynamic memory allocations * Supported compilers: MSVC, GCC, Clang * Header-only library, no dependencies -* Code can be used on any platform, not only embedded. +* Code can be used on any platform, not only embedded # Differences from the original arduino-dsmr * Requires a C++20 compatible compiler. @@ -17,20 +18,19 @@ The primary goal is to make the parser independent of the Arduino framework and # How to use ## General usage The library is header-only. Add the `src/dsmr_parser` folder to your project.
-Note: [dlms_packet_decryptor.h](https://github.com/esphome-libs/dsmr_parser/blob/main/src/dsmr_parser/dlms_packet_decryptor.h) depends on [Mbed TLS](https://www.trustedfirmware.org/projects/mbed-tls/) or [BearSsl](https://bearssl.org/) library. `Mbed TLS` is already included in the `ESP-IDF` framework and can be easily added to any other platforms. +Note: [dlms_packet_decryptor.h](https://github.com/esphome-libs/dsmr_parser/blob/main/src/dsmr_parser/dlms_packet_decryptor.h) requires one of the encryption libraries: [TF-PSA](https://github.com/Mbed-TLS/TF-PSA-Crypto), [Mbed TLS](https://github.com/Mbed-TLS/mbedtls) or [BearSsl](https://bearssl.org/). ## Usage from PlatformIO The library is available on the PlatformIO registry:
[esphome/dsmr_parser](https://registry.platformio.org/libraries/esphome/dsmr_parser) # Examples -* How to use the parser - * [minimal_parse.ino](https://github.com/matthijskooijman/arduino-dsmr/blob/master/examples/minimal_parse/minimal_parse.ino) - * [parse.ino](https://github.com/matthijskooijman/arduino-dsmr/blob/master/examples/parse/parse.ino) * Complete example using PacketAccumulator * [packet_accumulator_example_test.cpp](https://github.com/esphome-libs/dsmr_parser/blob/main/src/test/packet_accumulator_example_test.cpp) * Example using DlmsPacketDecryptor * [dlms_packet_decryptor_example_test.cpp](https://github.com/esphome-libs/dsmr_parser/blob/main/src/test/dlms_packet_decryptor_example_test.cpp) +* Usage in EspHome project + * [DSMR component](https://github.com/esphome/esphome/tree/dev/esphome/components/dsmr) # History behind arduino-dsmr [matthijskooijman](https://github.com/matthijskooijman) is the original creator of this DSMR parser. diff --git a/library.json b/library.json index 8903635..f0f2eb6 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "dsmr_parser", - "version": "1.2.0", + "version": "1.3.0", "description": "A parser for Dutch Smart Meter Requirements (DSMR) telegrams. Fork of arduino-dsmr. Doesn't depend on the Arduino framework and has many bug fixes and code quality improvements. Supports encrypted DSMR packets.", "keywords": "dsmr", "repository": { diff --git a/src/dsmr_parser/aes128gcm.h b/src/dsmr_parser/aes128gcm.h deleted file mode 100644 index ce11599..0000000 --- a/src/dsmr_parser/aes128gcm.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once -#include "util.h" -#include - -namespace dsmr_parser { - -class Aes128GcmEncryptionKey final { - std::array key{}; - - Aes128GcmEncryptionKey() = default; - -public: - // key_hex is a string like "00112233445566778899AABBCCDDEEFF" - static std::optional from_hex(std::string_view key_hex) { - if (key_hex.size() != 32) { - return {}; - } - - Aes128GcmEncryptionKey res; - - for (size_t i = 0; i < 16; ++i) { - const auto hi = to_hex_value(key_hex[2 * i]); - const auto lo = to_hex_value(key_hex[2 * i + 1]); - if (!hi || !lo) { - return {}; - } - res.key[i] = static_cast((*hi << 4) | *lo); - } - - return res; - } - - const uint8_t* data() const { return key.data(); } - -private: - static std::optional to_hex_value(const char c) { - if (c >= '0' && c <= '9') - return static_cast(c - '0'); - if (c >= 'a' && c <= 'f') - return static_cast(c - 'a' + 10); - if (c >= 'A' && c <= 'F') - return static_cast(c - 'A' + 10); - return {}; - } -}; - -} diff --git a/src/dsmr_parser/decryption/aes128gcm.h b/src/dsmr_parser/decryption/aes128gcm.h new file mode 100644 index 0000000..9896f92 --- /dev/null +++ b/src/dsmr_parser/decryption/aes128gcm.h @@ -0,0 +1,41 @@ +#pragma once +#include "../util.h" +#include +#include +#include + +namespace dsmr_parser { + +class Aes128GcmDecryptionKey final { + std::array key{}; + explicit Aes128GcmDecryptionKey(const std::array k) : key(k) {} + +public: + // hex is a string like "00112233445566778899AABBCCDDEEFF" + static std::optional from_hex(const std::string_view hex) { + if (hex.size() != 32) + return std::nullopt; + std::array arr{}; + for (size_t i = 0; i < 16; ++i) { + auto [ptr, ec] = std::from_chars(hex.data() + i * 2, hex.data() + i * 2 + 2, arr[i], 16); + if (ec != std::errc{}) + return std::nullopt; + } + return Aes128GcmDecryptionKey(arr); + } + + const uint8_t* data() const { return key.data(); } +}; + +class Aes128GcmDecryptor { +public: + virtual void set_encryption_key(const Aes128GcmDecryptionKey& key) = 0; + virtual bool decrypt_inplace(std::span aad, std::span nonce, std::span ciphertext, + std::span tag) = 0; + +protected: + virtual ~Aes128GcmDecryptor() = default; + std::optional decryption_key_; +}; + +} diff --git a/src/dsmr_parser/aes128gcm_bearssl.h b/src/dsmr_parser/decryption/aes128gcm_bearssl.h similarity index 55% rename from src/dsmr_parser/aes128gcm_bearssl.h rename to src/dsmr_parser/decryption/aes128gcm_bearssl.h index 3663992..5beedea 100644 --- a/src/dsmr_parser/aes128gcm_bearssl.h +++ b/src/dsmr_parser/decryption/aes128gcm_bearssl.h @@ -1,12 +1,20 @@ #pragma once -#include "aes128gcm.h" -#include "util.h" + +#if __has_include() +#include +#elif __has_include() #include +#else +#error "BearSSL header not found" +#endif + +#include "../util.h" +#include "aes128gcm.h" #include namespace dsmr_parser { -class Aes128GcmBearSsl final : NonCopyableAndNonMovable { +class Aes128GcmBearSsl final : public Aes128GcmDecryptor, NonCopyableAndNonMovable { br_gcm_context gcm; br_aes_ct_ctr_keys aes; bool initialized = false; @@ -14,14 +22,14 @@ class Aes128GcmBearSsl final : NonCopyableAndNonMovable { public: Aes128GcmBearSsl() = default; - void set_encryption_key(const Aes128GcmEncryptionKey& key) { + void set_encryption_key(const Aes128GcmDecryptionKey& key) override { br_aes_ct_ctr_init(&aes, key.data(), 16); br_gcm_init(&gcm, &aes.vtable, br_ghash_ctmul32); initialized = true; } - bool decrypt_inplace(const std::array& aad, const std::array& nonce, std::span ciphertext, - const std::array& tag) { + bool decrypt_inplace(std::span aad, std::span nonce, std::span ciphertext, + std::span tag) override { if (!initialized) { return false; } diff --git a/src/dsmr_parser/aes128gcm_mbedtls.h b/src/dsmr_parser/decryption/aes128gcm_mbedtls.h similarity index 53% rename from src/dsmr_parser/aes128gcm_mbedtls.h rename to src/dsmr_parser/decryption/aes128gcm_mbedtls.h index 9295274..9af225d 100644 --- a/src/dsmr_parser/aes128gcm_mbedtls.h +++ b/src/dsmr_parser/decryption/aes128gcm_mbedtls.h @@ -1,21 +1,21 @@ #pragma once +#include "../util.h" #include "aes128gcm.h" -#include "util.h" #include #include namespace dsmr_parser { -class Aes128GcmMbedTls final : NonCopyableAndNonMovable { +class Aes128GcmMbedTls final : public Aes128GcmDecryptor, NonCopyableAndNonMovable { mbedtls_gcm_context gcm; public: Aes128GcmMbedTls() { mbedtls_gcm_init(&gcm); } - void set_encryption_key(const Aes128GcmEncryptionKey& key) { mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key.data(), 128); } + void set_encryption_key(const Aes128GcmDecryptionKey& key) override { mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key.data(), 128); } - bool decrypt_inplace(const std::array& aad, const std::array& nonce, std::span ciphertext, - const std::array& tag) { + bool decrypt_inplace(std::span aad, std::span nonce, std::span ciphertext, + std::span tag) override { const auto& res = mbedtls_gcm_auth_decrypt(&gcm, ciphertext.size(), nonce.data(), nonce.size(), aad.data(), aad.size(), tag.data(), tag.size(), ciphertext.data(), ciphertext.data()); return res == 0; diff --git a/src/dsmr_parser/decryption/aes128gcm_tfpsa.h b/src/dsmr_parser/decryption/aes128gcm_tfpsa.h new file mode 100644 index 0000000..f7f7d2c --- /dev/null +++ b/src/dsmr_parser/decryption/aes128gcm_tfpsa.h @@ -0,0 +1,98 @@ +#pragma once + +#include "../util.h" +#include "aes128gcm.h" +#include +#include + +namespace dsmr_parser { + +class Aes128GcmTfPsa final : public Aes128GcmDecryptor, NonCopyableAndNonMovable { + psa_key_id_t key_id = 0; + bool initialized = false; + +public: + Aes128GcmTfPsa() { initialized = (psa_crypto_init() == PSA_SUCCESS); } + + void set_encryption_key(const Aes128GcmDecryptionKey& key) override { + if (!initialized) { + return; + } + + if (key_id != 0) { + psa_destroy_key(key_id); + key_id = 0; + } + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, 128); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_GCM, 12)); + + const psa_status_t status = psa_import_key(&attributes, key.data(), 16, &key_id); + + psa_reset_key_attributes(&attributes); + + if (status != PSA_SUCCESS) { + key_id = 0; + } + } + + bool decrypt_inplace(std::span aad, std::span nonce, std::span ciphertext, + std::span tag) override { + if (!initialized || key_id == 0) { + return false; + } + + psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; + size_t out_len = 0; + size_t tail_len = 0; + + psa_status_t status = psa_aead_decrypt_setup(&op, key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_GCM, tag.size())); + if (status != PSA_SUCCESS) { + psa_aead_abort(&op); + return false; + } + + status = psa_aead_set_nonce(&op, nonce.data(), nonce.size()); + if (status != PSA_SUCCESS) { + psa_aead_abort(&op); + return false; + } + + status = psa_aead_update_ad(&op, aad.data(), aad.size()); + if (status != PSA_SUCCESS) { + psa_aead_abort(&op); + return false; + } + + status = psa_aead_update(&op, ciphertext.data(), ciphertext.size(), ciphertext.data(), ciphertext.size(), &out_len); + if (status != PSA_SUCCESS || out_len != ciphertext.size()) { + psa_aead_abort(&op); + return false; + } + + status = psa_aead_verify(&op, nullptr, 0, &tail_len, tag.data(), tag.size()); + + if (status != PSA_SUCCESS) { + psa_aead_abort(&op); + return false; + } + + if (tail_len != 0) { + psa_aead_abort(&op); + return false; + } + + return true; + } + + ~Aes128GcmTfPsa() { + if (key_id != 0) { + psa_destroy_key(key_id); + } + } +}; + +} // namespace dsmr_parser diff --git a/src/dsmr_parser/dlms_packet_decryptor.h b/src/dsmr_parser/dlms_packet_decryptor.h index f984b9c..3e26d7f 100644 --- a/src/dsmr_parser/dlms_packet_decryptor.h +++ b/src/dsmr_parser/dlms_packet_decryptor.h @@ -1,8 +1,10 @@ #pragma once -#include "aes128gcm.h" +#include "decryption/aes128gcm.h" +#include "packet_accumulator.h" #include "util.h" #include #include +#include #include #include #include @@ -11,13 +13,12 @@ namespace dsmr_parser { // Decrypts DLMS packets encrypted with AES-128-GCM. // The encryption is described in the "specs/Luxembourg Smarty P1 specification v1.1.3.pdf" chapter "3.2.5 P1 software – Channel security". -template -class DlmsPacketDecryptor final { +class DlmsPacketDecryptor final : NonCopyableAndNonMovable { #pragma pack(push, 1) // The packet has the following structure: // Header (18 bytes) | Encrypted Telegram | GCM Tag (12 bytes) - struct DlmsPacket final { + struct DlmsPacket final : NonCopyableAndNonMovable { private: struct { uint8_t tag; // always = 0xDB @@ -33,15 +34,26 @@ class DlmsPacketDecryptor final { public: static DlmsPacket* from_bytes(const std::span bytes) { if (bytes.size() < sizeof(header) + /* tag length */ 12) { + Logger::log(LogLevel::DEBUG, "DLMS packet is too short. Size: %zu", bytes.size()); return nullptr; } auto& packet = *reinterpret_cast(bytes.data()); + const auto expected_length = sizeof(header) + /* tag length */ 12 + packet.telegram_length(); + if (expected_length != bytes.size()) { + Logger::log(LogLevel::DEBUG, "DLMS packet length mismatch. Expected: %zu, actual: %zu", expected_length, bytes.size()); + return nullptr; + } + + if (packet.telegram_length() < 10) { + Logger::log(LogLevel::DEBUG, "DLMS encrypted telegram is too short. Size: %zu", packet.telegram_length()); + return nullptr; + } - const auto& length_correct = bytes.size() == sizeof(header) + /* tag length */ 12 + packet.telegram_length(); - const auto& header_bytes_consistent = packet.header.tag == 0xDB && packet.header.system_title_length == 0x08 && - packet.header.long_form_length_indicator == 0x82 && packet.header.security_control_field == 0x30; - if (!length_correct || !header_bytes_consistent) { + const auto header_bytes_consistent = packet.header.tag == 0xDB && packet.header.system_title_length == 0x08 && + packet.header.long_form_length_indicator == 0x82 && packet.header.security_control_field == 0x30; + if (!header_bytes_consistent) { + Logger::log(LogLevel::DEBUG, "DLMS packet header is corrupted"); return nullptr; } @@ -49,7 +61,7 @@ class DlmsPacketDecryptor final { } // Also called "IV" - std::array nonce() const { + std::array nonce() const { // nonce = SystemTitle (8 bytes) + InvocationCounter (4 bytes) const auto& st = header.system_title; const auto& ic = header.invocation_counter_big_endian; @@ -57,10 +69,8 @@ class DlmsPacketDecryptor final { } std::span encrypted_telegram() { return {encrypted_telegram_with_gcm_tag, telegram_length()}; } - std::array gcm_tag() const { - const uint8_t* p = encrypted_telegram_with_gcm_tag + telegram_length(); - return {p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8], p[9], p[10], p[11]}; - } + + std::span gcm_tag() const { return std::span{encrypted_telegram_with_gcm_tag + telegram_length(), 12}; } private: // encrypted and decrypted telegrams have the same length @@ -73,28 +83,63 @@ class DlmsPacketDecryptor final { #pragma pack(pop) static_assert(sizeof(DlmsPacket) == 19, "EncryptedPacket struct must be 19 bytes"); - Aes128Gcm decryptor; + Aes128GcmDecryptor& decryptor; + + static void log_span_as_hex(const LogLevel level, const std::span data) { + constexpr size_t kCharsPerChunk = 200; + constexpr size_t kBytesPerChunk = kCharsPerChunk / 2; + for (size_t i = 0; i < data.size(); i += kBytesPerChunk) { + char hex[kCharsPerChunk + 1]; + const size_t n = std::min(kBytesPerChunk, data.size() - i); + for (size_t j = 0; j < n; ++j) { + constexpr char kHex[] = "0123456789ABCDEF"; + hex[j * 2] = kHex[data[i + j] >> 4]; + hex[j * 2 + 1] = kHex[data[i + j] & 0x0F]; + } + hex[n * 2] = '\0'; + Logger::log(level, "%s", hex); + } + } public: - void set_encryption_key(const Aes128GcmEncryptionKey& key) { decryptor.set_encryption_key(key); } + explicit DlmsPacketDecryptor(Aes128GcmDecryptor& dec) : decryptor(dec) {} + + std::optional decrypt_inplace(std::span dlms_packet_bytes) { + Logger::log(LogLevel::VERY_VERBOSE, "Decrypt DLMS packet:"); + log_span_as_hex(LogLevel::VERY_VERBOSE, dlms_packet_bytes); + Logger::log(LogLevel::VERY_VERBOSE, "========="); - std::optional decrypt_inplace(std::span dlms_packet_bytes) { auto dlms_packet = DlmsPacket::from_bytes(dlms_packet_bytes); if (dlms_packet == nullptr) { - return {}; + return std::nullopt; } // aad = AdditionalAuthenticatedData = SecurityControlField + AuthenticationKey. // SecurityControlField is always 0x30. // AuthenticationKey = "00112233445566778899AABBCCDDEEFF". It is hardcoded and is the same for all DSMR devices. - constexpr std::array aad{0x30, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; - + constexpr std::array aad{0x30, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; const bool res = decryptor.decrypt_inplace(aad, dlms_packet->nonce(), dlms_packet->encrypted_telegram(), dlms_packet->gcm_tag()); if (!res) { - return {}; + Logger::log(LogLevel::DEBUG, "Decryption of DLMS packet failed"); + return std::nullopt; } - return std::string_view{reinterpret_cast(dlms_packet->encrypted_telegram().data()), dlms_packet->encrypted_telegram().size()}; + // The unencrypted DSMR telegram looks like "/data!abcd\r\n". We skip everything after the "!" sign. The encryption already handles integrity check. + const auto telegram = std::string_view{reinterpret_cast(dlms_packet->encrypted_telegram().data()), dlms_packet->encrypted_telegram().size()}; + if (telegram.front() != '/') { + Logger::log(LogLevel::DEBUG, "Unencrypted DSMR telegram should start with '/' character"); + return std::nullopt; + } + const auto bangPos = std::ranges::find(telegram, '!'); + if (bangPos == telegram.end()) { + Logger::log(LogLevel::DEBUG, "Unencrypted DSMR telegram should contain '!' character"); + return std::nullopt; + } + const auto dsmrUnencryptedTelegram = std::string_view{telegram.begin(), bangPos + 1}; + + Logger::log(LogLevel::VERBOSE, "DLMS packet decryption succeeded"); + + return DsmrUnencryptedTelegram(dsmrUnencryptedTelegram); } }; diff --git a/src/dsmr_parser/fields.h b/src/dsmr_parser/fields.h index 1a55650..c3bdb63 100644 --- a/src/dsmr_parser/fields.h +++ b/src/dsmr_parser/fields.h @@ -2,6 +2,8 @@ #include "parser.h" #include "util.h" +#include +#include #ifndef DSMR_GAS_MBUS_ID #define DSMR_GAS_MBUS_ID 1 @@ -18,23 +20,22 @@ namespace dsmr_parser { -// Superclass for data items in a P1 message. template struct ParsedField { template void apply(F& f) { f.apply(*static_cast(this)); } - // By defaults, fields have no unit static const char* unit() noexcept { return ""; } }; template struct StringField : ParsedField { - ParseResult parse(const char* str, const char* end) { - ParseResult res = StringParser::parse_string(minlen, maxlen, str, end); - if (!res.err) - static_cast(this)->val() = res.result; + std::optional parse(std::string_view input) { + std::string_view sv; + auto res = parse_string(sv, minlen, maxlen, input); + if (res) + static_cast(this)->val() = sv; return res; } }; @@ -70,13 +71,14 @@ struct FixedValue { // integer unit is passed as a template argument. template struct FixedField : ParsedField { - ParseResult parse(const char* str, const char* end) { + std::optional parse(std::string_view input) { // Some smart meters publish int values instead of a float. // E.g. most meters would publish "1-0:1.8.0(000441.879*kWh)", // but some use "1-0:1.8.0(000441879*Wh)" instead. - auto res = NumParser::parse_float_or_int(3, _unit, _int_unit, str, end); - if (!res.err) - static_cast(this)->val()._value = res.result; + int32_t val; + auto res = parse_float_or_int(val, 3, _unit, _int_unit, input); + if (res) + static_cast(this)->val()._value = val; return res; } @@ -85,60 +87,51 @@ struct FixedField : ParsedField { }; struct TimestampedFixedValue : public FixedValue { - std::string timestamp; + std::string_view timestamp; }; // Some numerical values are prefixed with a timestamp. This is simply // both of them concatenated, e.g. 0-1:24.2.1(150117180000W)(00473.789*m3) template struct TimestampedFixedField : public FixedField { - ParseResult parse(const char* str, const char* end) { - // First, parse timestamp - ParseResult res = StringParser::parse_string(13, 13, str, end); - if (res.err) - return res; - - static_cast(this)->val().timestamp = res.result; - - // Which is immediately followed by the numerical value - return FixedField::parse(res.next, end); + std::optional parse(std::string_view input) { + std::string_view ts; + auto res = parse_string(ts, 13, 13, input); + if (!res) + return std::nullopt; + static_cast(this)->val().timestamp = ts; + return FixedField::parse(*res); } }; -// Take the last value of multiple values +// Take the last value of multiple parenthesized values // e.g. 0-0:98.1.0(1)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW) template struct LastFixedField : public FixedField { - ParseResult parse(const char* str, const char* end) { - // we parse last entry 2 times - const char* last = end; - - ParseResult res; - res.next = str; - - while (res.next != end) { - last = res.next; - res = StringParser::parse_string(1, 20, res.next, end); - if (res.err) - return res; + std::optional parse(std::string_view input) { + std::string_view last = input; + std::string_view remaining = input; + while (!remaining.empty()) { + last = remaining; + std::string_view sv; + auto res = parse_string(sv, 1, 20, remaining); + if (!res) + return std::nullopt; + remaining = *res; } - - // (04.329*kW) Which is followed by the numerical value - return FixedField::parse(last, end); + return FixedField::parse(last); } }; // A integer number is just represented as an integer. template struct IntField : ParsedField { - ParseResult parse(const char* str, const char* end) { - ParseResult res = NumParser::parse(0, _unit, str, end); - if (!res.err) { + std::optional parse(std::string_view input) { + int32_t val; + auto res = parse_num(val, 0, _unit, input); + if (res) { auto& dst = static_cast(this)->val(); - using Dst = std::remove_reference_t; - - // Narrow conversion. It is possible to loose data here - dst = static_cast(res.result); + dst = static_cast>(val); } return res; } @@ -146,71 +139,56 @@ struct IntField : ParsedField { static const char* unit() noexcept { return _unit; } }; -// Take the average value of multiple values. Example: +// Take the average of multiple timestamped values. Example: // 0-0:98.1.0(2)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW)(230202000000W)(230214224500W)(04.529*kW) // Will produce an average between 4.329 and 4.529 template struct AveragedFixedField : public FixedField { - ParseResult parse(const char* str, const char* end) { - // get the number of values that are available in the data - auto numberOfValues = NumParser::parse(0, "", str, end); - if (numberOfValues.err) { - return numberOfValues; - } + std::optional parse(std::string_view input) { + int32_t count; + auto res = parse_num(count, 0, "", input); + if (!res) + return std::nullopt; - if (numberOfValues.result == 0) { - numberOfValues.next = end; // mark that we consumed all input + if (count == 0) { static_cast(this)->val()._value = 0; - return numberOfValues; + return std::string_view{}; } - // Skip (1-0:1.6.0) - auto res = StringParser::parse_string(1, 20, numberOfValues.next, end); - if (res.err) - return res; - - // Skip another (1-0:1.6.0) - res = StringParser::parse_string(1, 20, res.next, end); - if (res.err) - return res; - - ParseResult average; - average.succeed(0); - average.next = res.next; - for (int32_t i = 0; i < numberOfValues.result; i++) { - // skip date (230201000000W) - res = StringParser::parse_string(1, 20, average.next, end); - if (res.err) - return res; - - // skip second date (230117224500W) - res = StringParser::parse_string(1, 20, res.next, end); - if (res.err) - return res; - - // parse value (04.329*kW) or (04329*W) - auto monthValue = NumParser::parse_float_or_int(3, _unit, _int_unit, res.next, end); - if (monthValue.err) - return monthValue; - - average.next = monthValue.next; - average.result += monthValue.result; + std::string_view sv; + res = parse_string(sv, 1, 20, *res); + if (!res) + return std::nullopt; + res = parse_string(sv, 1, 20, *res); + if (!res) + return std::nullopt; + + int32_t total = 0; + for (int32_t i = 0; i < count; i++) { + res = parse_string(sv, 1, 20, *res); + if (!res) + return std::nullopt; + res = parse_string(sv, 1, 20, *res); + if (!res) + return std::nullopt; + int32_t val; + res = parse_float_or_int(val, 3, _unit, _int_unit, *res); + if (!res) + return std::nullopt; + total += val; } - average.result /= numberOfValues.result; - static_cast(this)->val()._value = average.result; - - return average; + static_cast(this)->val()._value = total / count; + return res; } }; -// A RawField is not parsed, the entire value (including any parenthesis around it) is returned as a string. +// Raw field — no parsing, just store the entire value, including any parenthesis around it, as a string_view template struct RawField : ParsedField { - ParseResult parse(const char* str, const char* end) { - // Just copy the string verbatim value without any parsing - static_cast(this)->val().append(str, static_cast(end - str)); - return ParseResult().until(end); + std::optional parse(std::string_view input) { + static_cast(this)->val() = input; + return std::string_view{}; } }; @@ -242,11 +220,6 @@ struct units final { static inline constexpr char kHz[] = "kHz"; }; -const uint8_t GAS_MBUS_ID = DSMR_GAS_MBUS_ID; -const uint8_t WATER_MBUS_ID = DSMR_WATER_MBUS_ID; -const uint8_t THERMAL_MBUS_ID = DSMR_THERMAL_MBUS_ID; -const uint8_t SUB_MBUS_ID = DSMR_SUB_MBUS_ID; - #define DEFINE_FIELD(fieldname, value_t, obis, field_t, ...) \ struct fieldname : field_t { \ value_t fieldname; \ @@ -258,17 +231,17 @@ const uint8_t SUB_MBUS_ID = DSMR_SUB_MBUS_ID; } // Meter identification. This is not a normal field, but a specially-formatted first line of the message -DEFINE_FIELD(identification, std::string, ObisId(255, 255, 255, 255, 255, 255), RawField); +DEFINE_FIELD(identification, std::string_view, ObisId(255, 255, 255, 255, 255, 255), RawField); // Version information for P1 output -DEFINE_FIELD(p1_version, std::string, ObisId(1, 3, 0, 2, 8), StringField, 2, 2); -DEFINE_FIELD(p1_version_be, std::string, ObisId(0, 0, 96, 1, 4), StringField, 2, 96); +DEFINE_FIELD(p1_version, std::string_view, ObisId(1, 3, 0, 2, 8), StringField, 2, 2); +DEFINE_FIELD(p1_version_be, std::string_view, ObisId(0, 0, 96, 1, 4), StringField, 2, 96); // Date-time stamp of the P1 message -DEFINE_FIELD(timestamp, std::string, ObisId(0, 0, 1, 0, 0), TimestampField); +DEFINE_FIELD(timestamp, std::string_view, ObisId(0, 0, 1, 0, 0), TimestampField); // Equipment identifier -DEFINE_FIELD(equipment_id, std::string, ObisId(0, 0, 96, 1, 1), StringField, 0, 96); +DEFINE_FIELD(equipment_id, std::string_view, ObisId(0, 0, 96, 1, 1), StringField, 0, 96); // Meter Reading electricity delivered to client (Special for Lux) in 0,001 kWh // TODO: by OBIS 1-0:1.8.0.255 IEC 62056 it should be Positive active energy (A+) total [kWh], should we rename it? @@ -324,10 +297,28 @@ DEFINE_FIELD(energy_returned_tariff1_ch, FixedValue, ObisId(1, 1, 2, 8, 1), Fixe // Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh DEFINE_FIELD(energy_returned_tariff2_ch, FixedValue, ObisId(1, 1, 2, 8, 2), FixedField, units::kWh, units::Wh); +// Specific fields used for Israel +// Meter Reading electricity delivered to client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff1_il, FixedValue, ObisId(1, 0, 1, 8, 11), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff2_il, FixedValue, ObisId(1, 0, 1, 8, 12), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 3) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff3_il, FixedValue, ObisId(1, 0, 1, 8, 13), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff1_il, FixedValue, ObisId(1, 0, 2, 8, 11), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff2_il, FixedValue, ObisId(1, 0, 2, 8, 12), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 3) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff3_il, FixedValue, ObisId(1, 0, 2, 8, 13), FixedField, units::kWh, units::Wh); +// Tariff indicator electricity. +DEFINE_FIELD(electricity_tariff_il, std::string_view, ObisId(0, 0, 96, 14, 1), StringField, 2, 2); +// Power Failure Event Log (long power failures) +DEFINE_FIELD(electricity_failure_log_il, std::string_view, ObisId(1, 0, 99, 1, 0), RawField); + // Tariff indicator electricity. The tariff indicator can also be used // to switch tariff dependent loads e.g boilers. This is the // responsibility of the P1 user -DEFINE_FIELD(electricity_tariff, std::string, ObisId(0, 0, 96, 14, 0), StringField, 4, 4); +DEFINE_FIELD(electricity_tariff, std::string_view, ObisId(0, 0, 96, 14, 0), StringField, 4, 4); // Actual electricity power delivered (+P) in 1 Watt resolution DEFINE_FIELD(power_delivered, FixedValue, ObisId(1, 0, 1, 7, 0), FixedField, units::kW, units::W); @@ -356,7 +347,7 @@ DEFINE_FIELD(electricity_failures, uint32_t, ObisId(0, 0, 96, 7, 21), IntField, DEFINE_FIELD(electricity_long_failures, uint32_t, ObisId(0, 0, 96, 7, 9), IntField, units::none); // Power Failure Event Log (long power failures) -DEFINE_FIELD(electricity_failure_log, std::string, ObisId(1, 0, 99, 97, 0), RawField); +DEFINE_FIELD(electricity_failure_log, std::string_view, ObisId(1, 0, 99, 97, 0), RawField); // Number of voltage sags in phase L1 DEFINE_FIELD(electricity_sags_l1, uint32_t, ObisId(1, 0, 32, 32, 0), IntField, units::none); @@ -389,10 +380,10 @@ DEFINE_FIELD(voltage_swell_time_l3, uint32_t, ObisId(1, 0, 72, 37, 0), IntField, DEFINE_FIELD(voltage_swell_l3, uint32_t, ObisId(1, 0, 72, 38, 0), IntField, units::V); // Text message codes: numeric 8 digits (Note: Missing from 5.0 spec) -DEFINE_FIELD(message_short, std::string, ObisId(0, 0, 96, 13, 1), StringField, 0, 16); +DEFINE_FIELD(message_short, std::string_view, ObisId(0, 0, 96, 13, 1), StringField, 0, 16); // Text message max 2048 characters (Note: Spec says 1024 in comment and // 2048 in format spec, so we stick to 2048). -DEFINE_FIELD(message_long, std::string, ObisId(0, 0, 96, 13, 0), StringField, 0, 2048); +DEFINE_FIELD(message_long, std::string_view, ObisId(0, 0, 96, 13, 0), StringField, 0, 2048); // Instantaneous voltage L1 in 0.1V resolution (Note: Spec says V // resolution in comment, but 0.1V resolution in format spec. Added in 5.0) @@ -487,63 +478,63 @@ DEFINE_FIELD(active_demand_net, FixedValue, ObisId(1, 0, 16, 24, 0), FixedField, DEFINE_FIELD(active_demand_abs, FixedValue, ObisId(1, 0, 15, 24, 0), FixedField, units::kW, units::W); // Device-Type -DEFINE_FIELD(gas_device_type, uint16_t, ObisId(0, GAS_MBUS_ID, 24, 1, 0), IntField, units::none); +DEFINE_FIELD(gas_device_type, uint16_t, ObisId(0, DSMR_GAS_MBUS_ID, 24, 1, 0), IntField, units::none); // Equipment identifier (Gas) -DEFINE_FIELD(gas_equipment_id, std::string, ObisId(0, GAS_MBUS_ID, 96, 1, 0), StringField, 0, 96); +DEFINE_FIELD(gas_equipment_id, std::string_view, ObisId(0, DSMR_GAS_MBUS_ID, 96, 1, 0), StringField, 0, 96); // Equipment identifier (Gas) BE -DEFINE_FIELD(gas_equipment_id_be, std::string, ObisId(0, GAS_MBUS_ID, 96, 1, 1), StringField, 0, 96); +DEFINE_FIELD(gas_equipment_id_be, std::string_view, ObisId(0, DSMR_GAS_MBUS_ID, 96, 1, 1), StringField, 0, 96); // Valve position Gas (on/off/released) (Note: Removed in 4.0.7 / 4.2.2 / 5.0). -DEFINE_FIELD(gas_valve_position, uint8_t, ObisId(0, GAS_MBUS_ID, 24, 4, 0), IntField, units::none); +DEFINE_FIELD(gas_valve_position, uint8_t, ObisId(0, DSMR_GAS_MBUS_ID, 24, 4, 0), IntField, units::none); // Last 5-minute value (temperature converted), gas delivered to client // in m3, including decimal values and capture time (Note: 4.x spec has "hourly value") -DEFINE_FIELD(gas_delivered, TimestampedFixedValue, ObisId(0, GAS_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); +DEFINE_FIELD(gas_delivered, TimestampedFixedValue, ObisId(0, DSMR_GAS_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); // Eneco in the Netherlands has smart meters for their district heating network, which uses the gas_delivered in GJ rather than m3 -DEFINE_FIELD(gas_delivered_gj, TimestampedFixedValue, ObisId(0, GAS_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::GJ, units::MJ); +DEFINE_FIELD(gas_delivered_gj, TimestampedFixedValue, ObisId(0, DSMR_GAS_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::GJ, units::MJ); // _BE -DEFINE_FIELD(gas_delivered_be, TimestampedFixedValue, ObisId(0, GAS_MBUS_ID, 24, 2, 3), TimestampedFixedField, units::m3, units::dm3); -DEFINE_FIELD(gas_delivered_text, std::string, ObisId(0, GAS_MBUS_ID, 24, 3, 0), RawField); +DEFINE_FIELD(gas_delivered_be, TimestampedFixedValue, ObisId(0, DSMR_GAS_MBUS_ID, 24, 2, 3), TimestampedFixedField, units::m3, units::dm3); +DEFINE_FIELD(gas_delivered_text, std::string_view, ObisId(0, DSMR_GAS_MBUS_ID, 24, 3, 0), RawField); // Device-Type -DEFINE_FIELD(thermal_device_type, uint16_t, ObisId(0, THERMAL_MBUS_ID, 24, 1, 0), IntField, units::none); +DEFINE_FIELD(thermal_device_type, uint16_t, ObisId(0, DSMR_THERMAL_MBUS_ID, 24, 1, 0), IntField, units::none); // Equipment identifier (Thermal: heat or cold) -DEFINE_FIELD(thermal_equipment_id, std::string, ObisId(0, THERMAL_MBUS_ID, 96, 1, 0), StringField, 0, 96); +DEFINE_FIELD(thermal_equipment_id, std::string_view, ObisId(0, DSMR_THERMAL_MBUS_ID, 96, 1, 0), StringField, 0, 96); // Valve position (on/off/released) (Note: Removed in 4.0.7 / 4.2.2 / 5.0). -DEFINE_FIELD(thermal_valve_position, uint8_t, ObisId(0, THERMAL_MBUS_ID, 24, 4, 0), IntField, units::none); +DEFINE_FIELD(thermal_valve_position, uint8_t, ObisId(0, DSMR_THERMAL_MBUS_ID, 24, 4, 0), IntField, units::none); // Last 5-minute Meter reading Heat or Cold in 0,01 GJ and capture time // (Note: 4.x spec has "hourly meter reading") -DEFINE_FIELD(thermal_delivered, TimestampedFixedValue, ObisId(0, THERMAL_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::GJ, units::MJ); +DEFINE_FIELD(thermal_delivered, TimestampedFixedValue, ObisId(0, DSMR_THERMAL_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::GJ, units::MJ); // Device-Type -DEFINE_FIELD(water_device_type, uint16_t, ObisId(0, WATER_MBUS_ID, 24, 1, 0), IntField, units::none); +DEFINE_FIELD(water_device_type, uint16_t, ObisId(0, DSMR_WATER_MBUS_ID, 24, 1, 0), IntField, units::none); // Equipment identifier (Thermal: heat or cold) -DEFINE_FIELD(water_equipment_id, std::string, ObisId(0, WATER_MBUS_ID, 96, 1, 0), StringField, 0, 96); +DEFINE_FIELD(water_equipment_id, std::string_view, ObisId(0, DSMR_WATER_MBUS_ID, 96, 1, 0), StringField, 0, 96); // Valve position (on/off/released) (Note: Removed in 4.0.7 / 4.2.2 / 5.0). -DEFINE_FIELD(water_valve_position, uint8_t, ObisId(0, WATER_MBUS_ID, 24, 4, 0), IntField, units::none); +DEFINE_FIELD(water_valve_position, uint8_t, ObisId(0, DSMR_WATER_MBUS_ID, 24, 4, 0), IntField, units::none); // Last 5-minute Meter reading in 0,001 m3 and capture time // (Note: 4.x spec has "hourly meter reading") -DEFINE_FIELD(water_delivered, TimestampedFixedValue, ObisId(0, WATER_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); +DEFINE_FIELD(water_delivered, TimestampedFixedValue, ObisId(0, DSMR_WATER_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); // Device-Type -DEFINE_FIELD(sub_device_type, uint16_t, ObisId(0, SUB_MBUS_ID, 24, 1, 0), IntField, units::none); +DEFINE_FIELD(sub_device_type, uint16_t, ObisId(0, DSMR_SUB_MBUS_ID, 24, 1, 0), IntField, units::none); // Equipment identifier (Thermal: heat or cold) -DEFINE_FIELD(sub_equipment_id, std::string, ObisId(0, SUB_MBUS_ID, 96, 1, 0), StringField, 0, 96); +DEFINE_FIELD(sub_equipment_id, std::string_view, ObisId(0, DSMR_SUB_MBUS_ID, 96, 1, 0), StringField, 0, 96); // Valve position (on/off/released) (Note: Removed in 4.0.7 / 4.2.2 / 5.0). -DEFINE_FIELD(sub_valve_position, uint8_t, ObisId(0, SUB_MBUS_ID, 24, 4, 0), IntField, units::none); +DEFINE_FIELD(sub_valve_position, uint8_t, ObisId(0, DSMR_SUB_MBUS_ID, 24, 4, 0), IntField, units::none); // Last 5-minute Meter reading Heat or Cold and capture time (e.g. sub // E meter) (Note: 4.x spec has "hourly meter reading") -DEFINE_FIELD(sub_delivered, TimestampedFixedValue, ObisId(0, SUB_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); +DEFINE_FIELD(sub_delivered, TimestampedFixedValue, ObisId(0, DSMR_SUB_MBUS_ID, 24, 2, 1), TimestampedFixedField, units::m3, units::dm3); // Extra fields used for Belgian capacity rate/peak consumption (cappaciteitstarief). Current quart-hourly energy consumption DEFINE_FIELD(active_energy_import_current_average_demand, FixedValue, ObisId(1, 0, 1, 4, 0), FixedField, units::kW, units::W); @@ -565,11 +556,11 @@ DEFINE_FIELD(active_energy_import_maximum_demand_running_month, TimestampedFixed DEFINE_FIELD(active_energy_import_maximum_demand_last_13_months, FixedValue, ObisId(0, 0, 98, 1, 0), AveragedFixedField, units::kW, units::W); // Image Core Version and checksum -DEFINE_FIELD(fw_core_version, std::string, ObisId(1, 0, 0, 2, 0), StringField, 0, 96); -DEFINE_FIELD(fw_core_checksum, std::string, ObisId(1, 0, 0, 2, 8), StringField, 0, 96); +DEFINE_FIELD(fw_core_version, std::string_view, ObisId(1, 0, 0, 2, 0), StringField, 0, 96); +DEFINE_FIELD(fw_core_checksum, std::string_view, ObisId(1, 0, 0, 2, 8), StringField, 0, 96); // Image Module Version and checksum -DEFINE_FIELD(fw_module_version, std::string, ObisId(1, 1, 0, 2, 0), StringField, 0, 96); -DEFINE_FIELD(fw_module_checksum, std::string, ObisId(1, 1, 0, 2, 8), StringField, 0, 96); +DEFINE_FIELD(fw_module_version, std::string_view, ObisId(1, 1, 0, 2, 0), StringField, 0, 96); +DEFINE_FIELD(fw_module_checksum, std::string_view, ObisId(1, 1, 0, 2, 8), StringField, 0, 96); // Instantaneous power factor DEFINE_FIELD(power_factor, FixedValue, ObisId(1, 0, 13, 7, 0), FixedField, units::none, units::none); diff --git a/src/dsmr_parser/packet_accumulator.h b/src/dsmr_parser/packet_accumulator.h index df13b2a..a9cae84 100644 --- a/src/dsmr_parser/packet_accumulator.h +++ b/src/dsmr_parser/packet_accumulator.h @@ -10,15 +10,15 @@ namespace dsmr_parser { // Receives unencrypted DSMR packets. class PacketAccumulator final { class DsmrPacketBuffer final { - std::span _buffer; + std::span _buffer; std::size_t _packetSize = 0; public: - explicit DsmrPacketBuffer(std::span buffer) : _buffer{buffer} {} + explicit DsmrPacketBuffer(std::span buffer) : _buffer{buffer} {} - std::string_view packet() const { return std::string_view(_buffer.data(), _packetSize); } + std::string_view packet() const { return std::string_view(reinterpret_cast(_buffer.data()), _packetSize); } - void add(char byte) { + void add(uint8_t byte) { _buffer[_packetSize] = byte; _packetSize++; } @@ -45,13 +45,13 @@ class PacketAccumulator final { size_t amount_of_crc_nibbles = 0; public: - bool add_to_crc(char byte) { + bool add_to_crc(uint8_t byte) { if (byte >= '0' && byte <= '9') { byte = byte - '0'; } else if (byte >= 'A' && byte <= 'F') { - byte = static_cast(byte - 'A' + 10); + byte = static_cast(byte - 'A' + 10); } else if (byte >= 'a' && byte <= 'f') { - byte = static_cast(byte - 'a' + 10); + byte = static_cast(byte - 'a' + 10); } else { return false; } @@ -68,115 +68,76 @@ class PacketAccumulator final { enum class State { WaitingForPacketStartSymbol, WaitingForPacketEndSymbol, WaitingForCrc }; State _state = State::WaitingForPacketStartSymbol; - std::span _raw_buffer; + std::span _raw_buffer; DsmrPacketBuffer _buf; CrcAccumulator _crc_accumulator; bool _check_crc; public: - enum class Error { - BufferOverflow, - PacketStartSymbolInPacket, - IncorrectCrcCharacter, - CrcMismatch, - }; - - class Result final { - friend class PacketAccumulator; - - std::optional _packet; - std::optional _error; - - Result() = default; - Result(std::string_view packet) : _packet(packet) {} - Result(Error error) : _error(error) {} - - public: - auto packet() const { return _packet; } - auto error() const { return _error; } - }; - - PacketAccumulator(std::span buffer, bool check_crc) : _raw_buffer(buffer), _buf(buffer), _check_crc(check_crc) {} + PacketAccumulator(std::span buffer, bool check_crc) : _raw_buffer(buffer), _buf(buffer), _check_crc(check_crc) {} - Result process_byte(const char byte) { + std::optional process_byte(const uint8_t byte) { if (!_buf.has_space()) { + Logger::log(LogLevel::DEBUG, "Buffer overflow. Discarding the accumulated data"); _buf = DsmrPacketBuffer(_raw_buffer); _state = State::WaitingForPacketStartSymbol; - if (byte != '/') { - return Error::BufferOverflow; - } } if (byte == '/') { + Logger::log(LogLevel::VERBOSE, "Found telegram start symbol '/'"); _buf = DsmrPacketBuffer(_raw_buffer); _buf.add(byte); - const auto prev_state = _state; _state = State::WaitingForPacketEndSymbol; - - if (prev_state == State::WaitingForPacketEndSymbol || prev_state == State::WaitingForCrc) { - return Error::PacketStartSymbolInPacket; - } - return {}; + return std::nullopt; } switch (_state) { case State::WaitingForPacketStartSymbol: - return {}; + return std::nullopt; case State::WaitingForPacketEndSymbol: _buf.add(byte); if (byte != '!') { - return {}; + return std::nullopt; } + Logger::log(LogLevel::VERBOSE, "Found telegram end symbol '!'"); if (!_check_crc) { _state = State::WaitingForPacketStartSymbol; - return Result(_buf.packet()); + Logger::log(LogLevel::VERBOSE, "Successfully received the telegram without CRC check"); + return DsmrUnencryptedTelegram(_buf.packet()); } _state = State::WaitingForCrc; _crc_accumulator = CrcAccumulator(); - return {}; + return std::nullopt; case State::WaitingForCrc: if (!_crc_accumulator.add_to_crc(byte)) { + Logger::log(LogLevel::DEBUG, "Incorrect CRC character '%c'", byte); _state = State::WaitingForPacketStartSymbol; - return Error::IncorrectCrcCharacter; + return std::nullopt; } if (!_crc_accumulator.has_full_crc()) { - return {}; + return std::nullopt; } _state = State::WaitingForPacketStartSymbol; if (_crc_accumulator.crc_value() == _buf.calculate_crc16()) { - return _buf.packet(); + Logger::log(LogLevel::VERBOSE, "Successfully received the telegram with correct CRC"); + return DsmrUnencryptedTelegram(_buf.packet()); } - return Error::CrcMismatch; + Logger::log(LogLevel::DEBUG, "CRC mismatch: expected %04X, got %04X", _crc_accumulator.crc_value(), _buf.calculate_crc16()); + return std::nullopt; } // unreachable - return {}; + return std::nullopt; } }; -inline const char* to_string(const PacketAccumulator::Error error) { - switch (error) { - case PacketAccumulator::Error::BufferOverflow: - return "BufferOverflow"; - case PacketAccumulator::Error::PacketStartSymbolInPacket: - return "PacketStartSymbolInPacket"; - case PacketAccumulator::Error::IncorrectCrcCharacter: - return "IncorrectCrcCharacter"; - case PacketAccumulator::Error::CrcMismatch: - return "CrcMismatch"; - } - - // unreachable - return "Unknown error"; -} - } diff --git a/src/dsmr_parser/parser.h b/src/dsmr_parser/parser.h index a145902..5763eb9 100644 --- a/src/dsmr_parser/parser.h +++ b/src/dsmr_parser/parser.h @@ -1,460 +1,290 @@ #pragma once #include "util.h" -#include +#include +#include +#include namespace dsmr_parser { -// ParsedData is a template for the result of parsing a Dsmr P1 message. +// ParsedData is a template for the result of parsing a DSMR telegram. // You pass the fields you want to add to it as template arguments. -// -// This template will then generate a class that extends all the fields -// passed (the fields really are classes themselves). Since each field -// class has a single member variable, with the same name as the field -// class, all of these fields will be available on the generated class. -// -// In other words, if I have: -// -// using MyData = ParsedData< -// identification, -// equipment_id -// >; -// -// MyData data; -// -// then I can refer to the fields like data.identification and -// data.equipment_id normally. -// -// Furthermore, this class offers some helper methods that can be used -// to loop over all the fields inside it. +// Each field becomes a base class, exposing its member variable directly. template struct ParsedData final : Ts... { -private: - static const auto& fields_map() { - static const auto& m = []() { - const auto hasher = [](const ObisId& id) noexcept { - std::uint64_t x = 0; - std::memcpy(&x, id.v.data(), 6); - return std::hash{}(x); - }; - using FieldParseFunc = ParseResult (*)(ParsedData&, const char*, const char*); - std::unordered_map tmp; - (void)std::initializer_list{(tmp.emplace(Ts::id, - [](ParsedData& self, const char* str, const char* end) { - auto& field = static_cast(self); - ParseResult res; - if (field.present()) - return res.fail("Duplicate field", str); - field.present() = true; - return field.parse(str, end); - }), - 0)...}; - return tmp; - }(); - return m; - } - -public: - ParseResult parse_line(const ObisId& obisId, const char* str, const char* end) { - const auto& m = fields_map(); - auto it = m.find(obisId); - if (it == m.end()) - return ParseResult().until(str); - return it->second(*this, str, end); - } - - template - void applyEach(F&& f) { - (Ts::apply(f), ...); + std::optional parse_line(const ObisId& obis_id, std::string_view input) { + std::optional res = input; + auto try_field = [&](auto& field) -> bool { + using F = std::remove_reference_t; + if (!(F::id == obis_id)) + return false; + if (field.present()) { + Logger::log(LogLevel::ERROR, "Duplicate field [%.*s]", static_cast(input.size()), input.data()); + res = std::nullopt; + } else { + field.present() = true; + res = field.parse(input); + } + return true; + }; + (void)try_field; + (void)(try_field(static_cast(*this)) || ...); + return res; } bool all_present() { return (Ts::present() && ...); } }; -struct StringParser final { - static ParseResult parse_string(size_t min, size_t max, const char* str, const char* end) { - ParseResult res; - if (str >= end || *str != '(') - return res.fail("Missing (", str); - - const char* str_start = str + 1; // Skip ( - const char* str_end = str_start; - - while (str_end < end && *str_end != ')') - ++str_end; - - // We can have )) at the end. Thus we should add the first ) to the string. - // Like in the situation when we parse "((ER11))". - if (str_end + 1 < end && *(str_end + 1) == ')') - ++str_end; - - if (str_end == end) - return res.fail("Missing )", str_end); - - const auto& len = static_cast(str_end - str_start); - if (len < min || len > max) - return res.fail("Invalid string length", str_start); - - res.result.append(str_start, len); - - return res.until(str_end + 1); // Skip ) +// Parse a parenthesized string: (content) +// Handles double-closing brackets like ((ER11)) +inline std::optional parse_string(std::string_view& out, size_t min, size_t max, std::string_view input) { + if (input.empty() || input.front() != '(') { + Logger::log(LogLevel::ERROR, "Missing ( '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } -}; -static constexpr char INVALID_NUMBER[] = "Invalid number"; -static constexpr char INVALID_UNIT[] = "Invalid unit"; + size_t pos = 1; + while (pos < input.size() && input[pos] != ')') + ++pos; -struct NumParser final { - static ParseResult parse_float_or_int(const size_t max_decimals, const char* float_unit, const char* int_unit, const char* str, const char* end) { - auto float_res = NumParser::parse(max_decimals, float_unit, str, end); - if (!float_res.err) - return float_res; + // Handle )) at the end — include the first ) in the string + if (pos + 1 < input.size() && input[pos + 1] == ')') + ++pos; - auto int_res = NumParser::parse(0, int_unit, str, end); - if (!int_res.err) - return int_res; + if (pos == input.size()) { + Logger::log(LogLevel::ERROR, "Missing ) '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } - return float_res; + auto len = pos - 1; + if (len < min || len > max) { + Logger::log(LogLevel::ERROR, "Invalid string length '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } - static ParseResult parse(size_t max_decimals, const char* unit, const char* str, const char* end) { - ParseResult res; - if (str >= end || *str != '(') - return res.fail("Missing (", str); + out = input.substr(1, len); + return input.substr(pos + 1); +} - const char* cur_symbol = str + 1; // Skip ( +// Parse a numeric value in parentheses: ([-]digits[.decimals][*unit]) +inline std::optional parse_num(int32_t& out, size_t max_decimals, const char* unit, std::string_view input, bool log_errors = true) { + if (input.empty() || input.front() != '(') { + if (log_errors) + Logger::log(LogLevel::ERROR, "Missing ( '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } - bool negative = false; - if (cur_symbol < end && *cur_symbol == '-') { - negative = true; - ++cur_symbol; - } + size_t p = 1; + bool negative = (p < input.size() && input[p] == '-'); + if (negative) + ++p; - int32_t value = 0; + int32_t value = 0; - // Parse integer part - while (cur_symbol < end && !strchr("*.)", *cur_symbol)) { - if (*cur_symbol < '0' || *cur_symbol > '9') - return res.fail(INVALID_NUMBER, cur_symbol); - value *= 10; - value += (*cur_symbol - '0'); - ++cur_symbol; + while (p < input.size() && input[p] != '.' && input[p] != '*' && input[p] != ')') { + if (input[p] < '0' || input[p] > '9') { + if (log_errors) + Logger::log(LogLevel::ERROR, "Invalid number '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } + value = value * 10 + (input[p] - '0'); + ++p; + } - // Parse decimal part, if any - if (max_decimals && cur_symbol < end && *cur_symbol == '.') { - ++cur_symbol; - - while (cur_symbol < end && !strchr("*)", *cur_symbol) && max_decimals) { - max_decimals--; - if (*cur_symbol < '0' || *cur_symbol > '9') - return res.fail(INVALID_NUMBER, cur_symbol); - value *= 10; - value += (*cur_symbol - '0'); - ++cur_symbol; + size_t remaining = max_decimals; + if (remaining && p < input.size() && input[p] == '.') { + ++p; + while (p < input.size() && input[p] != '*' && input[p] != ')' && remaining) { + if (input[p] < '0' || input[p] > '9') { + if (log_errors) + Logger::log(LogLevel::ERROR, "Invalid number '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } + value = value * 10 + (input[p] - '0'); + --remaining; + ++p; } + } - // Fill in missing decimals with zeroes - while (max_decimals--) - value *= 10; - - // Workaround for https://github.com/matthijskooijman/arduino-dsmr/issues/50 - // If value is 0, then we allow missing unit. - if (unit && *unit && (cur_symbol >= end || (*cur_symbol != '*' && *cur_symbol != '.')) && value == 0) { - cur_symbol = std::find(cur_symbol, end, ')'); - } - - // If a unit was passed, check that the unit in the message matches the unit passed. - else if (unit && *unit) { - if (cur_symbol >= end || *cur_symbol != '*') - return res.fail("Missing unit", cur_symbol); - const char* unit_start = ++cur_symbol; // skip * - while (cur_symbol < end && *cur_symbol != ')' && *unit) { - // Next character in units do not match? - if (std::tolower(static_cast(*cur_symbol++)) != std::tolower(static_cast(*unit++))) - return res.fail(INVALID_UNIT, unit_start); + while (remaining--) + value *= 10; + + if (unit && *unit) { + // Value 0 allows missing unit (workaround for some meters) + if (value == 0 && (p >= input.size() || (input[p] != '*' && input[p] != '.'))) { + auto close = input.find(')', p); + p = (close != std::string_view::npos) ? close : input.size(); + } else { + if (p >= input.size() || input[p] != '*') { + if (log_errors) + Logger::log(LogLevel::ERROR, "Missing unit '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } + ++p; + const char* u = unit; + while (p < input.size() && input[p] != ')' && *u) { + if (std::tolower(static_cast(input[p])) != std::tolower(static_cast(*u))) { + if (log_errors) + Logger::log(LogLevel::ERROR, "Invalid unit '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } + ++p; + ++u; + } + if (*u) { + if (log_errors) + Logger::log(LogLevel::ERROR, "Invalid unit '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } - // At the end of the message unit, but not the passed unit? - if (*unit) - return res.fail(INVALID_UNIT, unit_start); } + } - if (cur_symbol >= end || *cur_symbol != ')') - return res.fail("Extra data", cur_symbol); + if (p >= input.size() || input[p] != ')') { + if (log_errors) + Logger::log(LogLevel::ERROR, "Extra data '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } - if (negative) - value = -value; + out = negative ? -value : value; + return input.substr(p + 1); +} - return res.succeed(value).until(cur_symbol + 1); // Skip ) - } -}; +// Try float unit first, fall back to integer unit +inline std::optional parse_float_or_int(int32_t& out, size_t max_decimals, const char* float_unit, const char* int_unit, + std::string_view input) { + auto res = parse_num(out, max_decimals, float_unit, input, /*log_errors=*/false); + if (res) + return res; + return parse_num(out, 0, int_unit, input); +} -struct ObisIdParser final { - static ParseResult parse(const char* str, const char* end) noexcept { - // Parse a Obis ID of the form 1-2:3.4.5.6 - // Stops parsing on the first unrecognized character. Any unparsed - // parts are set to 255. - ParseResult res; - ObisId& id = res.result; - res.next = str; - uint8_t part = 0; - while (res.next < end) { - char c = *res.next; - - if (c >= '0' && c <= '9') { - const auto& digit = c - '0'; - if (id.v[part] > 25 || (id.v[part] == 25 && digit > 5)) - return res.fail("Obis ID has number over 255", res.next); - id.v[part] = static_cast(id.v[part] * 10 + digit); - } else if (part == 0 && c == '-') { - part++; - } else if (part == 1 && c == ':') { - part++; - } else if (part > 1 && part < 5 && c == '.') { - part++; - } else { - break; +// Parse OBIS identifier (a-b:c.d.e.f) +inline std::optional parse_obis(ObisId& id, std::string_view input) { + size_t pos = 0; + uint8_t part = 0; + while (pos < input.size()) { + char c = input[pos]; + if (c >= '0' && c <= '9') { + auto digit = static_cast(c - '0'); + if (id.v[part] > 25 || (id.v[part] == 25 && digit > 5)) { + Logger::log(LogLevel::ERROR, "Obis ID has number over 255 '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; } - ++res.next; + id.v[part] = static_cast(id.v[part] * 10 + digit); + } else if (part == 0 && c == '-') { + part++; + } else if (part == 1 && c == ':') { + part++; + } else if (part > 1 && part < 5 && c == '.') { + part++; + } else { + break; } + ++pos; + } - if (res.next == str) - return res.fail("OBIS id Empty", str); + if (pos == 0) { + Logger::log(LogLevel::ERROR, "OBIS id Empty '%.*s'", static_cast(input.size()), input.data()); + return std::nullopt; + } - for (++part; part < 6; ++part) - id.v[part] = 255; + for (++part; part < 6; ++part) + id.v[part] = 255; - return res; - } -}; + return input.substr(pos); +} -struct CrcParser final { +struct DsmrParser final { private: - static const size_t CRC_LEN = 4; - - static bool hex_nibble(char c, uint8_t& out) noexcept { - if (c >= '0' && c <= '9') { - out = static_cast(c - '0'); - return true; - } - if (c >= 'A' && c <= 'F') { - out = static_cast(c - 'A' + 10); - return true; - } - if (c >= 'a' && c <= 'f') { - out = static_cast(c - 'a' + 10); + template + static bool parse_line(Data& data, std::string_view input, bool unknown_error) { + if (input.empty()) return true; - } - return false; - } -public: - // Parse a crc value. str must point to the first of the four hex - // bytes in the CRC. - static ParseResult parse(const char* str, const char* end) { - ParseResult res; - - if (str + CRC_LEN > end) - return res.fail("No checksum found", str); - - uint16_t value = 0; - for (size_t i = 0; i < CRC_LEN; ++i) { - uint8_t nibble; - if (!hex_nibble(str[i], nibble)) - return res.fail("Incomplete or malformed checksum", str + i); - value = static_cast((value << 4) | nibble); - } + ObisId id; + auto idres = parse_obis(id, input); + if (!idres) + return false; - res.next = str + CRC_LEN; - return res.succeed(value); - } -}; + auto datares = data.parse_line(id, *idres); + if (!datares) + return false; -struct P1Parser final { -private: - // uses polynomial x^16+x^15+x^2+1 - static uint16_t crc16_update(uint16_t crc, uint8_t data) noexcept { - crc ^= data; - for (size_t i = 0; i < 8; ++i) { - if (crc & 1) { - crc = (crc >> 1) ^ 0xA001; - } else { - crc = (crc >> 1); - } + if ((*datares).data() != (*idres).data() && !(*datares).empty()) { + Logger::log(LogLevel::ERROR, "Trailing characters on data line '%.*s'", static_cast(input.size()), input.data()); + return false; } - return crc; - } - -public: - // Parse a complete P1 telegram. The string passed should start - // with '/' and run up to and including the ! and the following - // four byte checksum. It's ok if the string is longer, the .next - // pointer in the result will indicate the next unprocessed byte. - template - static ParseResult parse(ParsedData& data, const char* str, size_t n, bool unknown_error = false, bool check_crc = true) { - ParseResult res; - - const char* const buf_begin = str; - const char* const buf_end = str + n; - - if (!n || *buf_begin != '/') - return res.fail("Data should start with /", buf_begin); - - // The payload starts after '/', and runs up to (but not including) '!' - const char* const data_begin = buf_begin + 1; - - // Find the terminating '!' (or the end of buffer if not present) - const char* term = std::find(data_begin, buf_end, '!'); - if (term == buf_end) - return res.fail("Data should end with !"); - - if (check_crc) { - // With CRC enabled, '!' must exist and be followed by 4 hex chars. - if (term >= buf_end) - return res.fail("No checksum found", term); - - // Compute CRC over '/' .. '!' (inclusive). - uint16_t crc = 0; - for (const char* p = buf_begin; p <= term; ++p) - crc = crc16_update(crc, static_cast(*p)); - - // Parse and verify the 4-hex checksum after '!' - ParseResult check = CrcParser::parse(term + 1, buf_end); - if (check.err) - return check; - if (check.result != crc) - return res.fail("Checksum mismatch", term + 1); - - // Parse payload (between '/' and '!') - res = parse_data(data, data_begin, term, unknown_error); - res.next = check.next; // Advance past checksum - return res; + if ((*datares).data() == (*idres).data() && unknown_error) { + Logger::log(LogLevel::ERROR, "Unknown field '%.*s'", static_cast(input.size()), input.data()); + return false; } - // No CRC checking: parse up to '!' if present, otherwise up to buf_end. - res = parse_data(data, data_begin, term, unknown_error); - res.next = (term < buf_end) ? term : buf_end; - return res; + return true; } - // Parse the data part of a message. Str should point to the first - // character after the leading /, end should point to the ! before the - // checksum. Does not verify the checksum. +public: template - static ParseResult parse_data(ParsedData& data, const char* str, const char* end, bool unknown_error = false) { - // Split into lines and parse those - const char* line_end = str; - const char* line_start = str; + static bool parse(ParsedData& data, DsmrUnencryptedTelegram telegram, bool unknown_error = false) { + // Strip leading '/' and trailing '!' + auto input = telegram.content().substr(1, telegram.content().size() - 2); + + size_t pos = 0; + size_t line_start = 0; // Parse ID line - while (line_end < end) { - if (*line_end == '\r' || *line_end == '\n') { - // The first identification line looks like: - // XXX5 - // The DSMR spec is vague on details, but in 62056-21, the X's - // are a three-letter (registerd) manufacturer ID, the id - // string is up to 16 chars of arbitrary characters and the - // '5' is a baud rate indication. 5 apparently means 9600, - // which DSMR 3.x and below used. It seems that DSMR 2.x - // passed '3' here (which is mandatory for "mode D" - // communication according to 62956-21), so we also allow - // that. Apparently swedish meters use '9' for 115200. This code - // used to check the format of the line somewhat, but for - // flexibility (and since we do not actually parse the contents - // of the line anyway), just allow anything now. - // - // Offer it for processing using the all-ones Obis ID, which - // is not otherwise valid. - ParseResult tmp = data.parse_line(ObisId(255, 255, 255, 255, 255, 255), line_start, line_end); - if (tmp.err) - return tmp; - line_start = ++line_end; + while (pos < input.size()) { + if (input[pos] == '\r' || input[pos] == '\n') { + auto res = data.parse_line(ObisId(255, 255, 255, 255, 255, 255), input.substr(line_start, pos - line_start)); + if (!res) + return false; + line_start = ++pos; break; } - ++line_end; + ++pos; } - // Parse data lines - // We need to track brackets to handle cases like: - // 0-0:96.13.0(303132333435 - // 30313233343) - // Also we need to handle cases like: - // 1-0:0.2.0((ER11)) - bool open_bracket_found = false; - while (line_end < end) { - char c = *line_end; - char next_c = (line_end + 1 < end) ? *(line_end + 1) : '\0'; - - if ((c == '(' && next_c == '(') || (c == ')' && next_c == ')')) { - // we have a case like: - // 1-0:0.2.0((ER11)) - // Treat double brackets as a single bracket for bracket tracking - line_end++; - c = next_c; + // Parse data lines — track brackets to handle multi-line values + // and double brackets like ((ER11)) + bool open_bracket = false; + while (pos < input.size()) { + char c = input[pos]; + char nc = (pos + 1 < input.size()) ? input[pos + 1] : '\0'; + + if ((c == '(' && nc == '(') || (c == ')' && nc == ')')) { + ++pos; + c = nc; } if (c == '(') { - if (open_bracket_found) { - return ParseResult().fail("Unexpected '(' symbol", line_end); + if (open_bracket) { + Logger::log(LogLevel::ERROR, "Unexpected '(' symbol"); + return false; } - open_bracket_found = true; + open_bracket = true; } else if (c == ')') { - if (!open_bracket_found) { - return ParseResult().fail("Unexpected ')' symbol", line_end); + if (!open_bracket) { + Logger::log(LogLevel::ERROR, "Unexpected ')' symbol"); + return false; } - open_bracket_found = false; + open_bracket = false; } else if (c == '\r' || c == '\n') { - - // handles case like: - // 0-1:24.3.0(120517020000)(08)(60)(1)(0-1:24.2.1)(m3) - // (00124.477) - const auto& next_part_of_the_data_line_on_next_line = (end - line_end > 2) && (line_end[1] == '(' || line_end[2] == '('); - - const auto& break_in_the_middle_of_the_data_line = open_bracket_found || next_part_of_the_data_line_on_next_line; - - if (!break_in_the_middle_of_the_data_line) { - // End of logical line -> parse it - ParseResult tmp = parse_line(data, line_start, line_end, unknown_error); - if (tmp.err) - return tmp; - - line_start = line_end + 1; + bool continuation = open_bracket || ((input.size() - pos > 2) && (input[pos + 1] == '(' || input[pos + 2] == '(')); + if (!continuation) { + if (!parse_line(data, input.substr(line_start, pos - line_start), unknown_error)) + return false; + line_start = pos + 1; } } - ++line_end; + ++pos; } - if (line_end != line_start) - return ParseResult().fail("Last dataline not CRLF terminated", line_end); - - return ParseResult(); - } + if (pos != line_start) { + Logger::log(LogLevel::ERROR, "Last dataline not CRLF terminated"); + return false; + } - template - static ParseResult parse_line(Data& data, const char* line, const char* end, bool unknown_error) { - ParseResult res; - if (line == end) - return res; - - ParseResult idres = ObisIdParser::parse(line, end); - if (idres.err) - return idres; - - ParseResult datares = data.parse_line(idres.result, idres.next, end); - if (datares.err) - return datares; - - // If datares.next didn't move at all, there was no parser for - // this field, that's ok. But if it did move, but not all the way - // to the end, that's an error. - if (datares.next != idres.next && datares.next != end) - return res.fail("Trailing characters on data line", datares.next); - else if (datares.next == idres.next && unknown_error) - return res.fail("Unknown field", line); - - return res.until(end); + return true; } }; } diff --git a/src/dsmr_parser/util.h b/src/dsmr_parser/util.h index ef724c1..147a1d6 100644 --- a/src/dsmr_parser/util.h +++ b/src/dsmr_parser/util.h @@ -1,11 +1,14 @@ #pragma once -#include #include +#include #include -#include -#include -#include +#include +#include +#include +#if defined(_MSC_VER) +#include +#endif namespace dsmr_parser { @@ -32,117 +35,61 @@ class NonCopyableAndNonMovable : NonCopyable { NonCopyableAndNonMovable& operator=(NonCopyableAndNonMovable&&) = delete; }; -// The ParseResult class wraps the result of a parse function. The type -// of the result is passed as a template parameter and can be void to -// not return any result. -// -// A ParseResult can either: -// - Return an error. In this case, err is set to an error message, ctx -// is optionally set to where the error occurred. The result (if any) -// and the next pointer are meaningless. -// - Return succesfully. In this case, err and ctx are NULL, result -// contains the result (if any) and next points one past the last -// byte processed by the parser. -// -// The ParseResult class has some convenience functions: -// - succeed(result): sets the result to the given value and returns -// the ParseResult again. -// - fail(err): Set the err member to the error message passed, -// optionally sets the ctx and return the ParseResult again. -// - until(next): Set the next member and return the ParseResult again. -// -// Furthermore, ParseResults can be implicitely converted to other -// types. In this case, the error message, context and and next pointer are -// conserved, the return value is reset to the default value for the -// target type. -// -// Note that ctx points into the string being parsed, so it does not -// need to be freed, lives as long as the original string and is -// probably way longer that needed. - -// Superclass for ParseResult so we can specialize for void without -// having to duplicate all content -template -struct _ParseResult { - T result; - - template - P& succeed(U&& value) { - result = std::forward(value); - return *static_cast(this); - } +// An OBIS ID like: "1-0:1.7.0.255" +struct ObisId final { + std::array v{}; + constexpr explicit ObisId(const uint8_t a, const uint8_t b = 255, const uint8_t c = 255, const uint8_t d = 255, const uint8_t e = 255, + const uint8_t f = 255) noexcept + : v{a, b, c, d, e, f} {}; + ObisId() = default; + bool operator==(const ObisId&) const = default; }; -// partial specialization for void result -template -struct _ParseResult {}; - -// Actual ParseResult class -template -struct ParseResult final : public _ParseResult, T> { - const char* next = nullptr; - const char* err = nullptr; - const char* ctx = nullptr; +// Represents an unencrypted DSMR telegram that starts with '/' and ends with '!' without CRC at the end. +// Example: +// "/AAA5MTR\r\n" +// "\r\n" +// "1-0:1.7.0(00.100*kW)\r\n" +// "1-0:1.7.0(00.200*kW)\r\n" +// "!"; +class DsmrUnencryptedTelegram final { + std::string_view data; +public: + explicit DsmrUnencryptedTelegram(std::string_view telegram) : data(telegram) {} + std::string_view content() const { return data; } +}; - ParseResult& fail(const char* error, const char* context = nullptr) noexcept { - this->err = error; - this->ctx = context; - return *this; - } +enum class LogLevel { + VERY_VERBOSE, + VERBOSE, + DEBUG, + INFO, + WARNING, + ERROR, +}; - ParseResult& until(const char* nextToken) noexcept { - this->next = nextToken; - return *this; +class Logger final { +public: + static void set_log_function(std::function func) { _log_function = std::move(func); } + +#if defined(_MSC_VER) + static void log(LogLevel log_level, _In_z_ _Printf_format_string_ const char* fmt, ...) { +#elif defined(__clang__) || defined(__GNUC__) + __attribute__((format(printf, 2, 3))) + static void log(LogLevel log_level, const char* fmt, ...) { +#else + static void log(LogLevel log_level, const char* fmt, ...) { +#endif + va_list args; + va_start(args, fmt); + _log_function(log_level, fmt, args); + va_end(args); } - ParseResult() = default; +private: + Logger() = default; - template - ParseResult(const ParseResult& other) noexcept : next(other.next), err(other.err), ctx(other.ctx) {} - - // Returns the error, including context in a fancy multi-line format. - // The start and end passed are the first and one-past-the-end - // characters in the total parsed string. These are needed to properly - // limit the context output. - std::string fullError(const char* start, const char* end) const { - std::string res; - if (this->ctx && start && end) { - // Find the entire line surrounding the context - const char* line_end = this->ctx; - while (line_end < end && line_end[0] != '\r' && line_end[0] != '\n') - ++line_end; - const char* line_start = this->ctx; - while (line_start > start && line_start[-1] != '\r' && line_start[-1] != '\n') - --line_start; - - // We can now predict the context string length, so let String allocate - // memory in advance - res.reserve(static_cast((line_end - line_start) + 2 + (this->ctx - line_start) + 1 + 2)); - - // Write the line - res.append(line_start, static_cast(line_end - line_start)); - - res += "\r\n"; - - // Write a marker to point out ctx - while (line_start++ < this->ctx) - res += ' '; - res += '^'; - res += "\r\n"; - } - res += this->err; - return res; - } -}; - -// An OBIS id is 6 bytes, usually noted as a-b:c.d.e.f. Here we put them in an array for easy parsing. -struct ObisId final { - std::array v{}; - constexpr explicit ObisId(const uint8_t a, const uint8_t b = 255, const uint8_t c = 255, const uint8_t d = 255, const uint8_t e = 255, - const uint8_t f = 255) noexcept - : v{a, b, c, d, e, f} {}; - ObisId() = default; - auto operator<=>(const ObisId&) const = default; + inline static std::function _log_function = [](LogLevel, const char*, va_list) {}; }; } diff --git a/tests/aes128gcm_bearssl_include_test.cpp b/tests/decryption/aes128gcm_bearssl_include_test.cpp similarity index 70% rename from tests/aes128gcm_bearssl_include_test.cpp rename to tests/decryption/aes128gcm_bearssl_include_test.cpp index 87c7ef1..bc541b4 100644 --- a/tests/aes128gcm_bearssl_include_test.cpp +++ b/tests/decryption/aes128gcm_bearssl_include_test.cpp @@ -1,12 +1,12 @@ // This code tests that the header has all necessary dependencies included in its headers. // We check that the code compiles. -#include "dsmr_parser/aes128gcm_bearssl.h" +#include "dsmr_parser/decryption/aes128gcm_bearssl.h" using namespace dsmr_parser; void Aes128GcmBearssl_some_function() { Aes128GcmBearSsl aes; - const auto& key = Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + const auto& key = Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); aes.set_encryption_key(*key); } diff --git a/tests/aes128gcm_mbedtls_include_test.cpp b/tests/decryption/aes128gcm_mbedtls_include_test.cpp similarity index 70% rename from tests/aes128gcm_mbedtls_include_test.cpp rename to tests/decryption/aes128gcm_mbedtls_include_test.cpp index 1c1fb63..ad28b89 100644 --- a/tests/aes128gcm_mbedtls_include_test.cpp +++ b/tests/decryption/aes128gcm_mbedtls_include_test.cpp @@ -1,12 +1,12 @@ // This code tests that the header has all necessary dependencies included in its headers. // We check that the code compiles. -#include "dsmr_parser/aes128gcm_mbedtls.h" +#include "dsmr_parser/decryption/aes128gcm_mbedtls.h" using namespace dsmr_parser; void Aes128GcmMbedtls_some_function() { Aes128GcmMbedTls aes; - const auto& key = Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + const auto& key = Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); aes.set_encryption_key(*key); } diff --git a/tests/decryption/aes128gcm_tfpsa_include_test.cpp b/tests/decryption/aes128gcm_tfpsa_include_test.cpp new file mode 100644 index 0000000..f631c02 --- /dev/null +++ b/tests/decryption/aes128gcm_tfpsa_include_test.cpp @@ -0,0 +1,12 @@ +// This code tests that the header has all necessary dependencies included in its headers. +// We check that the code compiles. + +#include "dsmr_parser/decryption/aes128gcm_tfpsa.h" + +using namespace dsmr_parser; + +void Aes128GcmTfPsa_some_function() { + Aes128GcmTfPsa aes; + const auto& key = Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + aes.set_encryption_key(*key); +} diff --git a/tests/dlms_packet_decryptor_example_test.cpp b/tests/dlms_packet_decryptor_example_test.cpp index e14b384..e831d76 100644 --- a/tests/dlms_packet_decryptor_example_test.cpp +++ b/tests/dlms_packet_decryptor_example_test.cpp @@ -1,4 +1,8 @@ -#include "dsmr_parser/aes128gcm_mbedtls.h" // or "dsmr_parser/aes128gcm_bearssl.h" +// Include one of the decryption implementations depending on what encryption library you use: +#include "dsmr_parser/decryption/aes128gcm_mbedtls.h" +// or "dsmr_parser/decryption/aes128gcm_bearssl.h" +// or "dsmr_parser/decryption/aes128gcm_tfpsa.h" + #include "dsmr_parser/dlms_packet_decryptor.h" #include "dsmr_parser/fields.h" #include "dsmr_parser/parser.h" @@ -17,22 +21,30 @@ using namespace dsmr_parser; std::array dlms_packet_buffer; // Buffer to store the incoming bytes from the P1 port and the decrypted dsmr telegram size_t dlms_packet_buffer_position = 0; // needed to accumulate bytes -DlmsPacketDecryptor decryptor; +// Create the decryptor from the decryption implementation header that you included above. +// Available implementations: +// * Aes128GcmBearSsl (from "dsmr_parser/decryption/aes128gcm_bearssl.h" header) +// * Aes128GcmMbedTls (from "dsmr_parser/decryption/aes128gcm_mbedtls.h" header) +// * Aes128GcmTfPsa (from "dsmr_parser/decryption/aes128gcm_tfpsa.h" header) +Aes128GcmMbedTls gcm_decryptor; + +// Create the DLMS packet decryption. You only need to create it once. It is stateless. +DlmsPacketDecryptor decryptor(gcm_decryptor); long last_read_timestamp = 0; // timestamp of the last byte received. Needed to detect inter-frame gaps. Uart uart; // UART connected to P1 port // This encryption key is unique per smart meter and must be provided by the electricity company. -const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); +const auto encryption_key = Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").value(); // You must set the encryption key before calling `decryptor.decrypt_inplace` method. -inline void set_encryption_key() { decryptor.set_encryption_key(encryption_key); } +inline void set_encryption_key() { gcm_decryptor.set_encryption_key(encryption_key); } // Main loop that reads data from the P1 port and decrypts packets. inline void loop() { // To receive the encrypted packets, we need to rely on the "inter-frame delay" technique. - // The packets don't have any start and stop bytes. However, there is a guaranteed 10-second delay between packets. + // The packets don't have any start and stop bytes. However, there is a guaranteed 10 second delay between packets. while (uart.available()) { // reset buffer if overflow. @@ -49,7 +61,7 @@ inline void loop() { // detect inter-frame delay. If no byte is received for more than 1 second, then the packet is complete if ((millis() - last_read_timestamp) > 1000 && dlms_packet_buffer_position > 0) { - std::optional dsmr_telegram = decryptor.decrypt_inplace({dlms_packet_buffer.data(), dlms_packet_buffer_position}); + std::optional dsmr_telegram = decryptor.decrypt_inplace({dlms_packet_buffer.data(), dlms_packet_buffer_position}); dlms_packet_buffer_position = 0; // reset for the next packet // check that decryption was successful @@ -58,8 +70,6 @@ inline void loop() { return; } - // decrypted_dsmr_telegram is a normal DSMR telegram with a CRC checksum at the end. - printf("Decrypted DSMR telegram:\n%s\n", std::string(*dsmr_telegram).c_str()); - // Parse it using P1Parser::parse() method. + // Parse it using P1Parser::parse() method. Look at "packet_accumulator_example_test.cpp" } } diff --git a/tests/dlms_packet_decryptor_include_test.cpp b/tests/dlms_packet_decryptor_include_test.cpp index f66b4f3..d1ddb05 100644 --- a/tests/dlms_packet_decryptor_include_test.cpp +++ b/tests/dlms_packet_decryptor_include_test.cpp @@ -1,15 +1,15 @@ // This code tests that the encrypted_packet_accumulator header has all necessary dependencies included in its headers. // We check that the code compiles. -#include "dsmr_parser/aes128gcm_mbedtls.h" +#include "dsmr_parser/decryption/aes128gcm_mbedtls.h" #include "dsmr_parser/dlms_packet_decryptor.h" using namespace dsmr_parser; void DlmsPacketDecryptor_some_function() { std::array packet_buffer; - const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); - DlmsPacketDecryptor decryptor; - decryptor.set_encryption_key(encryption_key); + Aes128GcmMbedTls gcm_decryptor; + gcm_decryptor.set_encryption_key(*Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + DlmsPacketDecryptor decryptor(gcm_decryptor); decryptor.decrypt_inplace(packet_buffer); } diff --git a/tests/dlms_packet_decryptor_test.cpp b/tests/dlms_packet_decryptor_test.cpp index 49390df..1b1a19d 100644 --- a/tests/dlms_packet_decryptor_test.cpp +++ b/tests/dlms_packet_decryptor_test.cpp @@ -1,6 +1,8 @@ -#include "dsmr_parser/aes128gcm_bearssl.h" -#include "dsmr_parser/aes128gcm_mbedtls.h" +#include "dsmr_parser/decryption/aes128gcm_bearssl.h" +#include "dsmr_parser/decryption/aes128gcm_mbedtls.h" +#include "dsmr_parser/decryption/aes128gcm_tfpsa.h" #include "dsmr_parser/dlms_packet_decryptor.h" +#include "test_util.h" #include #include #include @@ -18,67 +20,89 @@ inline std::vector get_test_encrypted_packet() { return read_binary_file(std::filesystem::path(std::source_location::current().file_name()).parent_path() / "test_data" / "encrypted_packet.bin"); } -TEST_CASE("EncryptionKey FromHex method works correctly") { +TEST_CASE_FIXTURE(LogFixture, "EncryptionKey FromHex method works correctly") { // success cases - REQUIRE(Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); - REQUIRE(Aes128GcmEncryptionKey::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + REQUIRE(Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + REQUIRE(Aes128GcmDecryptionKey::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); // failure cases - REQUIRE(!Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAA")); // key too short - REQUIRE(!Aes128GcmEncryptionKey::from_hex("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); // non hex symbols in key + REQUIRE(!Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAA")); // key too short + REQUIRE(!Aes128GcmDecryptionKey::from_hex("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); // non hex symbols in key } -TEST_CASE("Can decrypt a correct packet") { - const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); - const auto packet_starts = std::string_view{"/EST5\\253710000_A\r\n"}; - const auto packet_ends = std::string_view{"1-0:4.7.0(000000166*var)\r\n!7EF9\r\n"}; +TEST_CASE_FIXTURE(LogFixture, "Can decrypt a correct packet") { + const auto encryption_key = *Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + const std::string_view packet_starts = "/EST5\\253710000_A\r\n"; + const std::string_view packet_ends = "1-0:4.7.0(000000166*var)\r\n!"; SUBCASE("MbedTls") { - DlmsPacketDecryptor decryptor; - decryptor.set_encryption_key(encryption_key); + Aes128GcmMbedTls gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); auto packet = get_test_encrypted_packet(); const auto dsmr_telegram = decryptor.decrypt_inplace({packet.data(), packet.size()}); REQUIRE(dsmr_telegram); - REQUIRE(dsmr_telegram->starts_with(packet_starts)); - REQUIRE(dsmr_telegram->ends_with(packet_ends)); + REQUIRE(dsmr_telegram->content().starts_with(packet_starts)); + REQUIRE(dsmr_telegram->content().ends_with(packet_ends)); } SUBCASE("BearSsl") { - DlmsPacketDecryptor decryptor; - decryptor.set_encryption_key(encryption_key); + Aes128GcmBearSsl gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); auto packet = get_test_encrypted_packet(); const auto dsmr_telegram = decryptor.decrypt_inplace({packet.data(), packet.size()}); REQUIRE(dsmr_telegram); - REQUIRE(dsmr_telegram->starts_with(packet_starts)); - REQUIRE(dsmr_telegram->ends_with(packet_ends)); + REQUIRE(dsmr_telegram->content().starts_with(packet_starts)); + REQUIRE(dsmr_telegram->content().ends_with(packet_ends)); + } + + SUBCASE("TfPsa") { + Aes128GcmTfPsa gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); + auto packet = get_test_encrypted_packet(); + const auto dsmr_telegram = decryptor.decrypt_inplace({packet.data(), packet.size()}); + REQUIRE(dsmr_telegram); + REQUIRE(dsmr_telegram->content().starts_with(packet_starts)); + REQUIRE(dsmr_telegram->content().ends_with(packet_ends)); } } -TEST_CASE("Fail to decrypt corrupted packet") { - const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); +TEST_CASE_FIXTURE(LogFixture, "Fail to decrypt corrupted packet") { + const auto encryption_key = *Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); auto packet = get_test_encrypted_packet(); packet[50] ^= 0xFF; SUBCASE("MbedTls") { - DlmsPacketDecryptor decryptor; - decryptor.set_encryption_key(encryption_key); + Aes128GcmMbedTls gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); } SUBCASE("BearSsl") { - DlmsPacketDecryptor decryptor; - decryptor.set_encryption_key(encryption_key); + Aes128GcmBearSsl gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); + REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); + } + + SUBCASE("TfPsa") { + Aes128GcmTfPsa gcm_decryptor; + gcm_decryptor.set_encryption_key(encryption_key); + DlmsPacketDecryptor decryptor(gcm_decryptor); REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); } } -TEST_CASE("Fail to decrypt packet with corrupted header") { - DlmsPacketDecryptor decryptor; - const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); +TEST_CASE_FIXTURE(LogFixture, "Fail to decrypt packet with corrupted header") { + Aes128GcmMbedTls gcm_decryptor; + gcm_decryptor.set_encryption_key(*Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + DlmsPacketDecryptor decryptor(gcm_decryptor); auto packet = get_test_encrypted_packet(); - decryptor.set_encryption_key(encryption_key); packet[0] = 0; @@ -86,26 +110,33 @@ TEST_CASE("Fail to decrypt packet with corrupted header") { REQUIRE_FALSE(dsmr_telegram); } -TEST_CASE("Decryption fails if the dlms packet is too small") { - DlmsPacketDecryptor decryptor; - const auto encryption_key = *Aes128GcmEncryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); +TEST_CASE_FIXTURE(LogFixture, "Decryption fails if the dlms packet is too small") { + Aes128GcmMbedTls gcm_decryptor; + gcm_decryptor.set_encryption_key(*Aes128GcmDecryptionKey::from_hex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + DlmsPacketDecryptor decryptor(gcm_decryptor); std::vector small_dlms_packet(10); - decryptor.set_encryption_key(encryption_key); - const auto dsmr_telegram = decryptor.decrypt_inplace({small_dlms_packet.data(), small_dlms_packet.size()}); REQUIRE_FALSE(dsmr_telegram); } -TEST_CASE("Does not crash if decrypt_inplace is called without set_encryption_key") { +TEST_CASE_FIXTURE(LogFixture, "Does not crash if decrypt_inplace is called without set_encryption_key") { auto packet = get_test_encrypted_packet(); SUBCASE("MbedTls") { - DlmsPacketDecryptor decryptor; + Aes128GcmMbedTls gcm_decryptor; + DlmsPacketDecryptor decryptor(gcm_decryptor); REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); } SUBCASE("BearSsl") { - DlmsPacketDecryptor decryptor; + Aes128GcmBearSsl gcm_decryptor; + DlmsPacketDecryptor decryptor(gcm_decryptor); + REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); + } + + SUBCASE("TfPsa") { + Aes128GcmTfPsa gcm_decryptor; + DlmsPacketDecryptor decryptor(gcm_decryptor); REQUIRE_FALSE(decryptor.decrypt_inplace({packet.data(), packet.size()})); } } diff --git a/tests/packet_accumulator_example_test.cpp b/tests/packet_accumulator_example_test.cpp index d3052d2..84b05a4 100644 --- a/tests/packet_accumulator_example_test.cpp +++ b/tests/packet_accumulator_example_test.cpp @@ -1,18 +1,26 @@ #include "dsmr_parser/fields.h" #include "dsmr_parser/packet_accumulator.h" #include "dsmr_parser/parser.h" +#include "test_util.h" #include #include using namespace dsmr_parser; using namespace fields; -TEST_CASE("PacketAccumulator example") { +// Specify the fields you want to parse. +// Full list of available fields is in "fields.h" file +using MyParsedData = ParsedData; +TEST_CASE_FIXTURE(LogFixture, "PacketAccumulator example") { // Buffer to store the incoming bytes. // This Buffer must be large enough to hold the full DSMR message. // Advice: define the Buffer as a global variable, to avoid using stack and heap memory. - std::array buffer; + std::array buffer; // For the sake of this example, we define a data that is supposed to come from the P1 port. const auto& data_from_p1_port = "garbage before" @@ -32,16 +40,6 @@ TEST_CASE("PacketAccumulator example") { "1-0:1.8.1(000671.578*kWh)\r\n" "!60e5"; - // Specify the fields you want to parse. - // Full list of available fields is in "fields.h" file - ParsedData< - /* String */ identification, - /* String */ p1_version, - /* String */ timestamp, - /* String */ equipment_id, - /* FixedValue */ energy_delivered_tariff1> - data; - // This class is used to receive the message from the P1 port. // It retrieves bytes from the UART and finds a DSMR message and optionally checks the CRC. // You only need to create this class once. @@ -49,33 +47,27 @@ TEST_CASE("PacketAccumulator example") { // Main loop. // We need to read data from P1 port 1 byte at a time. - for (const auto& byte : data_from_p1_port) { - // Feed the byte to the accumulator - auto res = accumulator.process_byte(byte); - - // During receiving, errors may occur, such as CRC mismatches. - // You can optionally log these errors, or ignore them. - if (res.error()) { - printf("Error during receiving a packet: %s", to_string(*res.error())); + for (const char byte : data_from_p1_port) { + // Feed the byte to the accumulator. + std::optional dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if(!dsmrTelegram) { + // No full packet received yet + continue; } - // When a full packet is received, the packet() method will return it. - // The packet starts with '/' and ends with the '!'. - // The CRC is not included. - if (res.packet()) { - // Get the recieved packet - const auto packet = *res.packet(); - - // Parse the packet. - // Specify `check_crc` as false, since the accumulator already checked the CRC and didn't include it in the packet - P1Parser::parse(data, packet.data(), packet.size(), /* unknown_error */ false, /* check_crc */ false); - - // Now you can use the parsed data. - printf("Identification: %s\n", data.identification.c_str()); - printf("P1 version: %s\n", data.p1_version.c_str()); - printf("Timestamp: %s\n", data.timestamp.c_str()); - printf("Equipment ID: %s\n", data.equipment_id.c_str()); - printf("Energy delivered tariff 1: %.3f\n", static_cast(data.energy_delivered_tariff1.val())); + // Parse the packet. + MyParsedData data; + bool res = DsmrParser::parse(data, dsmrTelegram.value()); + if (!res) { + // Parsing failed + continue; } + + // If parsing succeeded, you can use the parsed data. + std::cout << "Identification: " << data.identification << '\n'; + std::cout << "P1 version: " << data.p1_version << '\n'; + std::cout << "Timestamp: " << data.timestamp << '\n'; + std::cout << "Equipment ID: " << data.equipment_id << '\n'; + std::cout << "Energy delivered tariff 1: " << data.energy_delivered_tariff1.val() << '\n'; } } diff --git a/tests/packet_accumulator_include_test.cpp b/tests/packet_accumulator_include_test.cpp index 88b6eba..d6ebbe3 100644 --- a/tests/packet_accumulator_include_test.cpp +++ b/tests/packet_accumulator_include_test.cpp @@ -3,4 +3,4 @@ #include "dsmr_parser/packet_accumulator.h" -void PacketAccumulator_some_function() { dsmr_parser::PacketAccumulator({}, true); } +void PacketAccumulator_some_function() { dsmr_parser::PacketAccumulator(std::span{}, true); } diff --git a/tests/packet_accumulator_test.cpp b/tests/packet_accumulator_test.cpp index 4e7a1a2..e6de694 100644 --- a/tests/packet_accumulator_test.cpp +++ b/tests/packet_accumulator_test.cpp @@ -1,78 +1,87 @@ #include "dsmr_parser/packet_accumulator.h" +#include "test_util.h" #include #include #include using namespace dsmr_parser; -TEST_CASE("Packet with correct CRC lower case") { - std::vector buffer(1000); - const auto& msg = "/some !a3D4"; +TEST_CASE_FIXTURE(LogFixture, "Packet with correct CRC") { + std::vector buffer(1000); + const auto& msg = "/KFM5KAIFA-METER\r\n" + "\r\n" + "1-0:1.8.1(000671.578*kWh)\r\n" + "1-0:1.7.0(00.318*kW)\r\n" + "!1e1D\r\n"; auto accumulator = PacketAccumulator(buffer, true); + std::optional dsmrTelegram; for (const auto& byte : msg) { - auto res = accumulator.process_byte(byte); - REQUIRE(res.error().has_value() == false); - - if (res.packet()) { - REQUIRE(std::string(*res.packet()) == std::string(msg, std::size(msg) - 5)); - return; - } + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if(dsmrTelegram) + break; } - - REQUIRE(false); + REQUIRE(dsmrTelegram); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Found telegram end symbol '!'")); + REQUIRE(log.contains("Successfully received the telegram with correct CRC")); } -TEST_CASE("Packet with incorrect CRC") { - std::vector buffer(1000); +TEST_CASE_FIXTURE(LogFixture, "Packet with incorrect CRC") { + std::vector buffer(1000); const auto& msg = "/some data!0000"; PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; for (const auto& byte : msg) { - auto packet = accumulator.process_byte(byte); - if (packet.error()) { - REQUIRE(*packet.error() == PacketAccumulator::Error::CrcMismatch); - return; - } + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; } - REQUIRE(false); + REQUIRE(dsmrTelegram == std::nullopt); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Found telegram end symbol '!'")); + REQUIRE(log.contains("CRC mismatch")); } -TEST_CASE("Packet with incorrect CRC symbol") { - std::vector buffer(1000); +TEST_CASE_FIXTURE(LogFixture, "Packet with incorrect CRC symbol") { + std::vector buffer(1000); const auto& msg = "/some data!G000"; PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; for (const auto& byte : msg) { - auto packet = accumulator.process_byte(byte); - if (packet.error()) { - REQUIRE(*packet.error() == PacketAccumulator::Error::IncorrectCrcCharacter); - return; - } + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; } - - REQUIRE(false); + REQUIRE(dsmrTelegram == std::nullopt); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Found telegram end symbol '!'")); + REQUIRE(log.contains("Incorrect CRC character 'G'")); } -TEST_CASE("Packet without CRC") { - std::vector buffer(1000); +TEST_CASE_FIXTURE(LogFixture, "Packet without CRC") { + std::vector buffer(1000); const auto& msg = "/some data!"; PacketAccumulator accumulator(buffer, false); + std::optional dsmrTelegram; for (const auto& byte : msg) { - auto res = accumulator.process_byte(byte); - REQUIRE(res.error().has_value() == false); - - if (res.packet()) { - REQUIRE(std::string(*res.packet()) == std::string(msg, std::size(msg) - 1)); - return; - } + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; } + + REQUIRE(dsmrTelegram); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Found telegram end symbol '!'")); + REQUIRE(log.contains("Successfully received the telegram without CRC check")); } -TEST_CASE("Parse data with different packets. CRC check") { - std::vector buffer(15); +TEST_CASE_FIXTURE(LogFixture, "Parse data with different packets. CRC check") { + std::vector buffer(15); const auto& msg = "garbage /some !a3D4" // correct package "garbage /some !a3D3" // CRC mismatch "garbage /so/some !a3D4" // Packet start symbol '/' in the middle of the packet @@ -81,50 +90,122 @@ TEST_CASE("Parse data with different packets. CRC check") { "/garbage garbage garbage" // buffer overflow "/some !a3D4"; // correct package - std::vector received_packets; - std::vector occurred_errors; - + std::vector received_packets; PacketAccumulator accumulator(buffer, true); for (const auto& byte : msg) { - auto res = accumulator.process_byte(byte); - if (res.error()) { - occurred_errors.push_back(*res.error()); - } - - if (res.packet()) { - received_packets.push_back(std::string(*res.packet())); + auto dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) { + received_packets.push_back(*dsmrTelegram); } } - using enum PacketAccumulator::Error; - REQUIRE(occurred_errors == std::vector{CrcMismatch, PacketStartSymbolInPacket, IncorrectCrcCharacter, BufferOverflow}); - REQUIRE(received_packets == std::vector(4, "/some !")); + REQUIRE(received_packets.size() == 4); + REQUIRE(std::string(received_packets[0].content()) == "/some !"); + REQUIRE(std::string(received_packets[1].content()) == "/some !"); + REQUIRE(std::string(received_packets[2].content()) == "/some !"); + REQUIRE(std::string(received_packets[3].content()) == "/some !"); } -TEST_CASE("Parse data with different packets. No CRC check") { - std::vector buffer(15); +TEST_CASE_FIXTURE(LogFixture, "Parse data with different packets. No CRC check") { + std::vector buffer(15); const auto& msg = "garbage /some !" // correct package "garbage /so/some !" // Packet start symbol '/' in the middle of the packet "/some !" // correct package "/garbage garbage garbage" // buffer overflow "/some !"; // correct package - std::vector received_packets; - std::vector occurred_errors; + std::vector received_packets; PacketAccumulator accumulator(buffer, false); for (const auto& byte : msg) { - auto res = accumulator.process_byte(byte); - if (res.error()) { - occurred_errors.push_back(*res.error()); + auto dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) { + received_packets.push_back(*dsmrTelegram); } + } + + REQUIRE(std::string(received_packets[0].content()) == "/some !"); + REQUIRE(std::string(received_packets[1].content()) == "/some !"); + REQUIRE(std::string(received_packets[2].content()) == "/some !"); + REQUIRE(std::string(received_packets[3].content()) == "/some !"); +} + +TEST_CASE_FIXTURE(LogFixture, "Packet with correct CRC upper case") { + std::vector buffer(1000); + const auto& msg = "/some !A3D4"; + + PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; + for (const auto& byte : msg) { + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; + } + REQUIRE(dsmrTelegram); + REQUIRE(log.contains("Successfully received the telegram with correct CRC")); +} - if (res.packet()) { - received_packets.push_back(std::string(*res.packet())); +TEST_CASE_FIXTURE(LogFixture, "Buffer overflow discards accumulated data") { + std::vector buffer(5); + const auto& msg = "/123456"; + + PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; + for (const auto& byte : msg) { + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; + } + REQUIRE(dsmrTelegram == std::nullopt); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Buffer overflow. Discarding the accumulated data")); +} + +TEST_CASE_FIXTURE(LogFixture, "Only garbage data produces no packet") { + std::vector buffer(1000); + const auto& msg = "garbage data with no start symbol"; + + PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; + for (const auto& byte : msg) { + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; + } + REQUIRE(dsmrTelegram == std::nullopt); +} + +TEST_CASE_FIXTURE(LogFixture, "Start symbol during CRC accumulation resets state") { + std::vector buffer(1000); + // First packet has incomplete CRC ("a3"), then '/' starts a new packet + const auto& msg = "/some !a3/some !a3D4"; + + std::vector received_packets; + PacketAccumulator accumulator(buffer, true); + for (const auto& byte : msg) { + auto dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) { + received_packets.push_back(*dsmrTelegram); } } + REQUIRE(received_packets.size() == 1); + REQUIRE(std::string(received_packets[0].content()) == "/some !"); + REQUIRE(log.contains("Successfully received the telegram with correct CRC")); +} + +TEST_CASE_FIXTURE(LogFixture, "Incomplete CRC at end of stream produces no packet") { + std::vector buffer(1000); + // Only 2 of 4 CRC nibbles provided + const auto& msg = "/some !a3"; - using enum PacketAccumulator::Error; - REQUIRE(occurred_errors == std::vector{PacketStartSymbolInPacket, BufferOverflow}); - REQUIRE(received_packets == std::vector(4, "/some !")); + PacketAccumulator accumulator(buffer, true); + std::optional dsmrTelegram; + for (const auto& byte : msg) { + dsmrTelegram = accumulator.process_byte(static_cast(byte)); + if (dsmrTelegram) + break; + } + REQUIRE(dsmrTelegram == std::nullopt); + REQUIRE(log.contains("Found telegram start symbol '/'")); + REQUIRE(log.contains("Found telegram end symbol '!'")); } diff --git a/tests/parser_include_test.cpp b/tests/parser_include_test.cpp index fe53664..527c9e2 100644 --- a/tests/parser_include_test.cpp +++ b/tests/parser_include_test.cpp @@ -8,7 +8,6 @@ using namespace dsmr_parser; using namespace fields; void P1Parser_some_function() { - const auto& msg = ""; ParsedData data; - P1Parser::parse(data, msg, std::size(msg), true); + DsmrParser::parse(data, DsmrUnencryptedTelegram("msg"), true); } diff --git a/tests/parser_test.cpp b/tests/parser_test.cpp index cc0366e..9e25bd5 100644 --- a/tests/parser_test.cpp +++ b/tests/parser_test.cpp @@ -1,718 +1,729 @@ -#include "dsmr_parser/fields.h" +#include "dsmr_parser/fields.h" #include "dsmr_parser/parser.h" -#include -#include - -using namespace dsmr_parser; -using namespace fields; - -struct Printer { - template - void apply(Item& i) { - if (i.present()) { - std::cout << Item::name << ": " << i.val() << Item::unit() << std::endl; - } - } -}; - -TEST_CASE("Should parse all fields in the DSMR message correctly") { - const auto& msg = "/KFM5KAIFA-METER\r\n" - "\r\n" - "1-3:0.2.8(40)\r\n" - "0-0:1.0.0(150117185916W)\r\n" - "0-0:96.1.1(0000000000000000000000000000000000)\r\n" - "1-0:1.8.1(000671.578*kWh)\r\n" - "1-0:1.8.2(000842.472*kWh)\r\n" - "1-0:2.8.1(000000.000*kWh)\r\n" +#include "test_util.h" +#include +#include + +using namespace dsmr_parser; +using namespace fields; + +TEST_CASE_FIXTURE(LogFixture, "Should parse all fields in the DSMR message correctly") { + const auto& msg = "/KFM5KAIFA-METER\r\n" + "\r\n" + "1-3:0.2.8(40)\r\n" + "0-0:1.0.0(150117185916W)\r\n" + "0-0:96.1.1(0000000000000000000000000000000000)\r\n" + "1-0:1.8.1(000671.578*kWh)\r\n" + "1-0:1.8.2(000842.472*kWh)\r\n" + "1-0:2.8.1(000000.000*kWh)\r\n" "1-0:2.8.2(000000.000*kWh)\r\n" + "1-0:1.8.11(007132.419*kWh)\r\n" + "1-0:1.8.12(000155.482*kWh)\r\n" + "1-0:1.8.13(025605.254*kWh)\r\n" + "1-0:2.8.11(000000.000*kWh)\r\n" + "1-0:2.8.12(000000.000*kWh)\r\n" + "1-0:2.8.13(000000.000*kWh)\r\n" "0-0:96.14.0(0001)\r\n" - "1-0:1.7.0(00.333*kW)\r\n" - "1-0:2.7.0(00.000*kW)\r\n" - "0-0:17.0.0(999.9*kW)\r\n" - "0-0:96.3.10(1)\r\n" - "0-0:96.7.21(00008)\r\n" - "0-0:96.7.9(00007)\r\n" - "1-0:99.97.0(1)(0-0:96.7.19)(000101000001W)(2147483647*s)\r\n" - "0-0:98.1.0(2)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW)(230202000000W)(230214224500W)(04529*W)\r\n" + "0-0:96.14.1(03)\r\n" + "1-0:1.7.0(00.333*kW)\r\n" + "1-0:2.7.0(00.000*kW)\r\n" + "0-0:17.0.0(999.9*kW)\r\n" + "0-0:96.3.10(1)\r\n" + "0-0:96.7.21(00008)\r\n" + "0-0:96.7.9(00007)\r\n" + "1-0:99.97.0(1)(0-0:96.7.19)(000101000001W)(2147483647*s)\r\n" + "0-0:98.1.0(2)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW)(230202000000W)(230214224500W)(04529*W)\r\n" + "1-0:99.1.0(1)(0-0:96.10.7)(1-0:1.29.0)(1-0:2.29.0)(260411233000S)(00)(000000.205*kWh)(000000.000*kWh)\r\n" "1-0:32.32.0(00000)\r\n" "1-0:32.36.0(00000)\r\n" "0-0:96.13.1()\r\n" "0-0:96.13.0()\r\n" + "1-0:32.7.0(234.0*V)\r\n" + "1-0:52.7.0(231.0*V)\r\n" + "1-0:72.7.0(231.0*V)\r\n" "1-0:31.7.0(001*A)\r\n" + "1-0:51.7.0(002.4*A)\r\n" + "1-0:71.7.0(000.0*A)\r\n" "1-0:21.7.0(00.332*kW)\r\n" "1-0:22.7.0(00.000*kW)\r\n" - "0-1:24.1.0(003)\r\n" - "0-1:96.1.0(0000000000000000000000000000000000)\r\n" - "0-1:24.2.1(150117180000W)(00473.789*m3)\r\n" - "1-0:0.2.0((ER11))\r\n" - "1-0:0.2.8(1.0.smth smth-123)\r\n" - "1-1:0.2.0((ER12)\r\n" - "1-1:0.2.8(ER13))\r\n" - "0-1:24.4.0(1)\r\n" - "1-0:16.24.0(-03.618*kW)\r\n" - "1-0:13.7.0(0.998)\r\n" - "1-0:33.7.0(0.975)\r\n" - "1-0:53.7.0(0.963)\r\n" - "1-0:73.7.0(0.987)\r\n" - "1-0:13.3.0(0.000)\r\n" - "1-0:0.8.2(00900*s)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* String */ p1_version, - /* String */ timestamp, - /* String */ equipment_id, - /* FixedValue */ energy_delivered_tariff1, - /* FixedValue */ energy_delivered_tariff2, - /* FixedValue */ energy_returned_tariff1, + "1-0:41.7.0(00.430*kW)\r\n" + "1-0:42.7.0(00.000*kW)\r\n" + "1-0:61.7.0(00.000*kW)\r\n" + "1-0:62.7.0(00.000*kW)\r\n" + "0-1:24.1.0(003)\r\n" + "0-1:96.1.0(0000000000000000000000000000000000)\r\n" + "0-1:24.2.1(150117180000W)(00473.789*m3)\r\n" + "1-0:0.2.0((ER11))\r\n" + "1-0:0.2.8(1.0.smth smth-123)\r\n" + "1-1:0.2.0((ER12)\r\n" + "1-1:0.2.8(ER13))\r\n" + "0-1:24.4.0(1)\r\n" + "1-0:16.24.0(-03.618*kW)\r\n" + "1-0:13.7.0(0.998)\r\n" + "1-0:33.7.0(0.975)\r\n" + "1-0:53.7.0(0.963)\r\n" + "1-0:73.7.0(0.987)\r\n" + "1-0:13.3.0(0.000)\r\n" + "1-0:0.8.2(00900*s)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* String */ p1_version, + /* String */ timestamp, + /* String */ equipment_id, + /* FixedValue */ energy_delivered_tariff1, + /* FixedValue */ energy_delivered_tariff2, + /* FixedValue */ energy_returned_tariff1, /* FixedValue */ energy_returned_tariff2, + /* FixedValue */ energy_delivered_tariff1_il, + /* FixedValue */ energy_delivered_tariff2_il, + /* FixedValue */ energy_delivered_tariff3_il, + /* FixedValue */ energy_returned_tariff1_il, + /* FixedValue */ energy_returned_tariff2_il, + /* FixedValue */ energy_returned_tariff3_il, /* String */ electricity_tariff, - /* FixedValue */ power_delivered, - /* FixedValue */ power_returned, - /* FixedValue */ electricity_threshold, - /* uint8_t */ electricity_switch_position, - /* uint32_t */ electricity_failures, - /* uint32_t */ electricity_long_failures, + /* String */ electricity_tariff_il, + /* FixedValue */ power_delivered, + /* FixedValue */ power_returned, + /* FixedValue */ electricity_threshold, + /* uint8_t */ electricity_switch_position, + /* uint32_t */ electricity_failures, + /* uint32_t */ electricity_long_failures, /* String */ electricity_failure_log, - /* uint32_t */ electricity_sags_l1, - /* uint32_t */ electricity_sags_l2, - /* uint32_t */ electricity_sags_l3, - /* uint32_t */ electricity_swells_l1, - /* uint32_t */ electricity_swells_l2, - /* uint32_t */ electricity_swells_l3, - /* String */ message_short, - /* String */ message_long, - /* FixedValue */ voltage_l1, - /* FixedValue */ voltage_l2, - /* FixedValue */ voltage_l3, - /* FixedValue */ current_l1, - /* FixedValue */ current_l2, - /* FixedValue */ current_l3, - /* FixedValue */ power_delivered_l1, - /* FixedValue */ power_delivered_l2, - /* FixedValue */ power_delivered_l3, - /* FixedValue */ power_returned_l1, - /* FixedValue */ power_returned_l2, - /* FixedValue */ power_returned_l3, - /* uint16_t */ gas_device_type, - /* String */ gas_equipment_id, - /* uint8_t */ gas_valve_position, - /* TimestampedFixedValue */ gas_delivered, - /* uint16_t */ thermal_device_type, - /* String */ thermal_equipment_id, - /* uint8_t */ thermal_valve_position, - /* TimestampedFixedValue */ thermal_delivered, - /* uint16_t */ water_device_type, - /* String */ water_equipment_id, - /* uint8_t */ water_valve_position, - /* TimestampedFixedValue */ water_delivered, - /* AveragedFixedField */ active_energy_import_maximum_demand_last_13_months, - /* String */ fw_core_version, - /* String */ fw_core_checksum, - /* String */ fw_module_version, - /* String */ fw_module_checksum, - /* FixedValue */ active_demand_net, - /* FixedValue */ power_factor, - /* FixedValue */ power_factor_l1, - /* FixedValue */ power_factor_l2, - /* FixedValue */ power_factor_l3, - /* FixedValue */ min_power_factor, - /* FixedValue */ period_3_for_instantaneous_values> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), /* unknown_error */ true, /* check_crc */ false); - REQUIRE(res.err == nullptr); - - // Print all values - data.applyEach(Printer()); - - // Check that all fields have correct values - REQUIRE(data.identification == "KFM5KAIFA-METER"); - REQUIRE(data.p1_version == "40"); - REQUIRE(data.timestamp == "150117185916W"); - REQUIRE(data.equipment_id == "0000000000000000000000000000000000"); - REQUIRE(data.energy_delivered_tariff1 == 671.578f); - REQUIRE(data.energy_delivered_tariff2 == 842.472f); - REQUIRE(data.energy_returned_tariff1 == 0.0f); - REQUIRE(data.energy_returned_tariff2 == 0.0f); + /* String */ electricity_failure_log_il, + /* uint32_t */ electricity_sags_l1, + /* uint32_t */ electricity_sags_l2, + /* uint32_t */ electricity_sags_l3, + /* uint32_t */ electricity_swells_l1, + /* uint32_t */ electricity_swells_l2, + /* uint32_t */ electricity_swells_l3, + /* String */ message_short, + /* String */ message_long, + /* FixedValue */ voltage_l1, + /* FixedValue */ voltage_l2, + /* FixedValue */ voltage_l3, + /* FixedValue */ current_l1, + /* FixedValue */ current_l2, + /* FixedValue */ current_l3, + /* FixedValue */ power_delivered_l1, + /* FixedValue */ power_delivered_l2, + /* FixedValue */ power_delivered_l3, + /* FixedValue */ power_returned_l1, + /* FixedValue */ power_returned_l2, + /* FixedValue */ power_returned_l3, + /* uint16_t */ gas_device_type, + /* String */ gas_equipment_id, + /* uint8_t */ gas_valve_position, + /* TimestampedFixedValue */ gas_delivered, + /* uint16_t */ thermal_device_type, + /* String */ thermal_equipment_id, + /* uint8_t */ thermal_valve_position, + /* TimestampedFixedValue */ thermal_delivered, + /* uint16_t */ water_device_type, + /* String */ water_equipment_id, + /* uint8_t */ water_valve_position, + /* TimestampedFixedValue */ water_delivered, + /* AveragedFixedField */ active_energy_import_maximum_demand_last_13_months, + /* String */ fw_core_version, + /* String */ fw_core_checksum, + /* String */ fw_module_version, + /* String */ fw_module_checksum, + /* FixedValue */ active_demand_net, + /* FixedValue */ power_factor, + /* FixedValue */ power_factor_l1, + /* FixedValue */ power_factor_l2, + /* FixedValue */ power_factor_l3, + /* FixedValue */ min_power_factor, + /* FixedValue */ period_3_for_instantaneous_values> + data; + + auto res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /* unknown_error */ true); + REQUIRE(res); + + // Check that all fields have correct values + REQUIRE(data.identification == "KFM5KAIFA-METER"); + REQUIRE(data.p1_version == "40"); + REQUIRE(data.timestamp == "150117185916W"); + REQUIRE(data.equipment_id == "0000000000000000000000000000000000"); + REQUIRE(data.energy_delivered_tariff1 == 671.578f); + REQUIRE(data.energy_delivered_tariff2 == 842.472f); + REQUIRE(data.energy_returned_tariff1 == 0.0f); + REQUIRE(data.energy_returned_tariff2 == 0.0f); REQUIRE(data.electricity_tariff == "0001"); - REQUIRE(data.power_delivered == 0.333f); - REQUIRE(data.power_returned == 0.0f); - REQUIRE(data.electricity_threshold == 999.9f); - REQUIRE(data.electricity_switch_position == 1); - REQUIRE(data.electricity_failures == 8); - REQUIRE(data.electricity_long_failures == 7); + REQUIRE(data.electricity_tariff_il == "03"); + REQUIRE(data.energy_delivered_tariff1_il == 7132.419f); + REQUIRE(data.energy_delivered_tariff2_il == 155.482f); + REQUIRE(data.energy_delivered_tariff3_il == 25605.254f); + REQUIRE(data.energy_returned_tariff1_il == 0.0f); + REQUIRE(data.energy_returned_tariff2_il == 0.0f); + REQUIRE(data.energy_returned_tariff3_il == 0.0f); + REQUIRE(data.power_delivered == 0.333f); + REQUIRE(data.power_returned == 0.0f); + REQUIRE(data.electricity_threshold == 999.9f); + REQUIRE(data.electricity_switch_position == 1); + REQUIRE(data.electricity_failures == 8); + REQUIRE(data.electricity_long_failures == 7); REQUIRE(data.electricity_failure_log == "(1)(0-0:96.7.19)(000101000001W)(2147483647*s)"); - REQUIRE(data.electricity_sags_l1 == 0); - REQUIRE(data.electricity_swells_l1 == 0); - REQUIRE(data.message_short.empty()); - REQUIRE(data.message_long.empty()); + REQUIRE(data.electricity_failure_log_il == "(1)(0-0:96.10.7)(1-0:1.29.0)(1-0:2.29.0)(260411233000S)(00)(000000.205*kWh)(000000.000*kWh)"); + REQUIRE(data.electricity_sags_l1 == 0); + REQUIRE(data.electricity_swells_l1 == 0); + REQUIRE(data.message_short.empty()); + REQUIRE(data.message_long.empty()); + REQUIRE(data.voltage_l1 == 234.0f); + REQUIRE(data.voltage_l2 == 231.0f); + REQUIRE(data.voltage_l3 == 231.0f); REQUIRE(data.current_l1 == 1.0f); + REQUIRE(data.current_l2 == 2.4f); + REQUIRE(data.current_l3 == 0.0f); REQUIRE(data.power_delivered_l1 == 0.332f); + REQUIRE(data.power_delivered_l2 == 0.430f); + REQUIRE(data.power_delivered_l3 == 0.0f); REQUIRE(data.power_returned_l1 == 0.0f); - REQUIRE(data.gas_device_type == 3); - REQUIRE(data.gas_equipment_id == "0000000000000000000000000000000000"); - REQUIRE(data.gas_valve_position == 1); - REQUIRE(data.gas_delivered == 473.789f); - REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 4.429f); - REQUIRE(data.fw_core_version == "(ER11)"); - REQUIRE(data.fw_core_checksum == "1.0.smth smth-123"); - REQUIRE(data.fw_module_version == "(ER12"); - REQUIRE(data.fw_module_checksum == "ER13)"); - REQUIRE(data.active_demand_net == -3.618f); - REQUIRE(data.power_factor == 0.998f); - REQUIRE(data.power_factor_l1 == 0.975f); - REQUIRE(data.power_factor_l2 == 0.963f); - REQUIRE(data.power_factor_l3 == 0.987f); - REQUIRE(data.min_power_factor == 0.0f); - REQUIRE(data.period_3_for_instantaneous_values == 900); -} - -TEST_CASE("Should calculate CRC correctly") { - const auto& msg = "/KFM5KAIFA-METER\r\n" - "\r\n" - "1-0:1.8.1(000671.578*kWh)\r\n" - "1-0:1.7.0(00.318*kW)\r\n" - "!1e1D\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), /* unknown_error */ false, /* check_crc */ true); - REQUIRE(res.err == nullptr); -} - -TEST_CASE("Should report an error if the crc has incorrect format") { - const auto& msg = "/KFM5KAIFA-METER\r\n" - "\r\n" - "1-0:1.8.1(000671.578*kWh)\r\n" - "1-0:1.7.0(00.318*kW)\r\n" - "!1ED\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), true); - REQUIRE(std::string(res.err) == "Incomplete or malformed checksum"); -} - -TEST_CASE("Should report an error if the crc of a package is incorrect") { - const auto& msg = "/KFM5KAIFA-METER\r\n" - "\r\n" - "1-0:.8.1(000671.578*kWh)\r\n" - "1-0:1.7.0(00.318*kW)\r\n" - "!1E1D\r\n"; - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), true); - REQUIRE(std::string(res.err) == "Checksum mismatch"); - - const auto& fullError = res.fullError(msg, msg + std::size(msg)); - REQUIRE(fullError == "!1E1D\r\n ^\r\nChecksum mismatch"); -} - -TEST_CASE("Should parse Wh-based integers for FixedField (fallback int_unit path)") { - const auto& msg = "/ABC5MTR\r\n" - "\r\n" - "1-0:1.8.0(000441879*Wh)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ energy_delivered_lux> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); - REQUIRE(data.energy_delivered_lux == 441.879f); // 441,879 Wh => 441.879 kWh - REQUIRE(fields::energy_delivered_lux::unit() == std::string("kWh")); - REQUIRE(fields::energy_delivered_lux::int_unit() == std::string("Wh")); -} - -TEST_CASE("Should parse TimestampedFixedField for gas_delivered_be and expose timestamp") { - const auto& msg = "/DEF5MTR\r\n" - "\r\n" - "0-1:24.2.3(230101120000W)(00012.345*m3)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* TimestampedFixedValue */ gas_delivered_be> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); - REQUIRE(data.gas_delivered_be == 12.345f); - REQUIRE(data.gas_delivered_be.timestamp == "230101120000W"); -} - -TEST_CASE("Should take the last value with LastFixedField (capacity rate history)") { - const auto& msg = "/KFM5MTR\r\n" - "\r\n" - "0-0:98.1.0(1)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ active_energy_import_maximum_demand_last_13_months> - data; - - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.active_energy_import_maximum_demand_last_13_months == 4.329f); -} - -TEST_CASE("Should detect duplicate fields") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.100*kW)\r\n" - "1-0:1.7.0(00.200*kW)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Duplicate field"); -} - -TEST_CASE("Should error on unknown field when unknown_error is true") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:2.7.0(00.000*kW)\r\n" // power_returned not part of ParsedData below - "!\r\n"; - - ParsedData< - /* String */ identification> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/true, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Unknown field"); -} - -TEST_CASE("Should report OBIS ID numbers over 255") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "256-0:1.7.0(00.100*kW)\r\n" // invalid OBIS (256) - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Obis ID has number over 255"); -} - -TEST_CASE("Should validate string length bounds (p1_version too short)") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-3:0.2.8(4)\r\n" // p1_version expects 2 chars - "!\r\n"; - - ParsedData< - /* String */ identification, - /* String */ p1_version> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Invalid string length"); -} - -TEST_CASE("Should validate string length bounds (p1_version too long)") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-3:0.2.8(123)\r\n" // p1_version expects 2 chars - "!\r\n"; - - ParsedData< - /* String */ identification, - /* String */ p1_version> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Invalid string length"); -} - -TEST_CASE("Should validate units for numeric fields") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.318*kVA)\r\n" // expects kW, not kVA - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Invalid unit"); -} - -TEST_CASE("Should report missing closing parenthesis for StringField") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-3:0.2.8(40\r\n" // missing ')' - "!\r\n"; - - ParsedData< - /* String */ identification, - /* String */ p1_version> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Last dataline not CRLF terminated"); -} - -TEST_CASE("Should compute FixedField with decimals and millivolt int_unit correctly") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:32.7.0(230.1*V)\r\n" // voltage_l1 (V / mV) - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ voltage_l1> - data; - - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.voltage_l1 == 230.1f); -} - -TEST_CASE("all_present() should reflect presence of all requested fields") { - SUBCASE("All fields present -> true") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.123*kW)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.all_present()); - } - - SUBCASE("Missing a requested field -> false") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE_FALSE(data.all_present()); - } -} - -TEST_CASE("Should report last dataline not CRLF terminated") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.123*kW)" // no CRLF before '!' + REQUIRE(data.power_returned_l2 == 0.0f); + REQUIRE(data.power_returned_l3 == 0.0f); + REQUIRE(data.gas_device_type == 3); + REQUIRE(data.gas_equipment_id == "0000000000000000000000000000000000"); + REQUIRE(data.gas_valve_position == 1); + REQUIRE(data.gas_delivered == 473.789f); + REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 4.429f); + REQUIRE(data.fw_core_version == "(ER11)"); + REQUIRE(data.fw_core_checksum == "1.0.smth smth-123"); + REQUIRE(data.fw_module_version == "(ER12"); + REQUIRE(data.fw_module_checksum == "ER13)"); + REQUIRE(data.active_demand_net == -3.618f); + REQUIRE(data.power_factor == 0.998f); + REQUIRE(data.power_factor_l1 == 0.975f); + REQUIRE(data.power_factor_l2 == 0.963f); + REQUIRE(data.power_factor_l3 == 0.987f); + REQUIRE(data.min_power_factor == 0.0f); + REQUIRE(data.period_3_for_instantaneous_values == 900); +} + +TEST_CASE_FIXTURE(LogFixture, "Should parse Wh-based integers for FixedField (fallback int_unit path)") { + const auto& msg = "/ABC5MTR\r\n" + "\r\n" + "1-0:1.8.0(000441879*Wh)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ energy_delivered_lux> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); + REQUIRE(data.energy_delivered_lux == 441.879f); // 441,879 Wh => 441.879 kWh + REQUIRE(fields::energy_delivered_lux::unit() == std::string("kWh")); + REQUIRE(fields::energy_delivered_lux::int_unit() == std::string("Wh")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should parse TimestampedFixedField for gas_delivered_be and expose timestamp") { + const auto& msg = "/DEF5MTR\r\n" + "\r\n" + "0-1:24.2.3(230101120000W)(00012.345*m3)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* TimestampedFixedValue */ gas_delivered_be> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); + REQUIRE(data.gas_delivered_be == 12.345f); + REQUIRE(data.gas_delivered_be.timestamp == "230101120000W"); +} + +TEST_CASE_FIXTURE(LogFixture, "Should take the last value with LastFixedField (capacity rate history)") { + const auto& msg = "/KFM5MTR\r\n" + "\r\n" + "0-0:98.1.0(1)(1-0:1.6.0)(1-0:1.6.0)(230201000000W)(230117224500W)(04.329*kW)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ active_energy_import_maximum_demand_last_13_months> + data; + + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.active_energy_import_maximum_demand_last_13_months == 4.329f); +} + +TEST_CASE_FIXTURE(LogFixture, "Should detect duplicate fields") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(00.100*kW)\r\n" + "1-0:1.7.0(00.200*kW)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Duplicate field")); + REQUIRE(log.contains("(00.200*kW)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should error on unknown field when unknown_error is true") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:2.7.0(00.000*kW)\r\n" // power_returned not part of ParsedData below + "!"; + + ParsedData< + /* String */ identification> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /*unknown_error=*/true); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Unknown field")); + REQUIRE(log.contains("1-0:2.7.0(00.000*kW)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should report OBIS ID numbers over 255") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "256-0:1.7.0(00.100*kW)\r\n" // invalid OBIS (256) + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Obis ID has number over 255")); + REQUIRE(log.contains("6-0:1.7.0(00.100*kW)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should validate string length bounds (p1_version too short)") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-3:0.2.8(4)\r\n" // p1_version expects 2 chars + "!"; + + ParsedData< + /* String */ identification, + /* String */ p1_version> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Invalid string length")); + REQUIRE(log.contains("4)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should validate string length bounds (p1_version too long)") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-3:0.2.8(123)\r\n" // p1_version expects 2 chars + "!"; + + ParsedData< + /* String */ identification, + /* String */ p1_version> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Invalid string length")); + REQUIRE(log.contains("123)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should validate units for numeric fields") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(00.318*kVA)\r\n" // expects kW, not kVA + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing unit")); + REQUIRE(log.contains("kVA)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should report missing closing parenthesis for StringField") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-3:0.2.8(40\r\n" // missing ')' + "!"; + + ParsedData< + /* String */ identification, + /* String */ p1_version> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Last dataline not CRLF terminated")); +} + +TEST_CASE_FIXTURE(LogFixture, "Should compute FixedField with decimals and millivolt int_unit correctly") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:32.7.0(230.1*V)\r\n" // voltage_l1 (V / mV) + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ voltage_l1> + data; + + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.voltage_l1 == 230.1f); +} + +TEST_CASE_FIXTURE(LogFixture, "all_present() should reflect presence of all requested fields") { + SUBCASE("All fields present -> true") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(00.123*kW)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.all_present()); + } + + SUBCASE("Missing a requested field -> false") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(data.all_present()); + } +} + +TEST_CASE_FIXTURE(LogFixture, "Should report last dataline not CRLF terminated") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(00.123*kW)" // no CRLF before '!' + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Last dataline not CRLF terminated")); +} + +TEST_CASE_FIXTURE(LogFixture, "Doesn't crash for a small packet") { + const auto& msg = "/!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + auto res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); +} + +TEST_CASE_FIXTURE(LogFixture, "Doesn't crash for a small packet 2") { + const auto& msg = "/a!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + auto res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Last dataline not CRLF terminated")); +} + +TEST_CASE_FIXTURE(LogFixture, "Trailing characters on data line") { + const auto& msg = "/AAA5MTR\r\n\r\n" + "1-0:1.7.0(00.123*kW) trailing\r\n" + "!"; + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Trailing characters on data line")); + REQUIRE(log.contains(" trailing")); +} + +TEST_CASE_FIXTURE(LogFixture, "Unknown field ignored when unknown_error is false") { + const auto& msg = "/AAA5MTR\r\n\r\n" + "1-0:2.7.0(00.000*kW)\r\n" + "!"; + ParsedData data; + auto res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); +} + +TEST_CASE_FIXTURE(LogFixture, "Missing unit when required") { + const auto& msg = "/AAA5MTR\r\n\r\n" + "1-0:1.7.0(00.123)\r\n" + "!"; + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing unit")); + REQUIRE(log.contains(")")); +} + +TEST_CASE_FIXTURE(LogFixture, "Unit present when not expected") { + const auto& msg = "/AAA5MTR\r\n\r\n" + "0-0:96.7.21(00008*s)\r\n" + "!"; + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Extra data")); + REQUIRE(log.contains("*s)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Malformed packet that starts with ')'") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-3:0.2.8)40(\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* String */ p1_version> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Unexpected ')' symbol")); +} + +TEST_CASE_FIXTURE(LogFixture, "Non-digit in numeric part") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(00.A23*kW)\r\n" + "!"; + + ParsedData< + /* String */ identification, + /* FixedValue */ power_delivered> + data; + + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing unit")); + REQUIRE(log.contains("A23*kW)")); +} + +TEST_CASE_FIXTURE(LogFixture, "OBIS id empty line") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "garbage\r\n" + "!"; + + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("OBIS id Empty")); + REQUIRE(log.contains("garbage")); +} + +TEST_CASE_FIXTURE(LogFixture, "Accepts LF-only line endings") { + const auto& msg = "/AAA5MTR\n" + "\n" + "1-0:1.7.0(00.123*kW)\n" + "!"; + + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.power_delivered == 0.123f); +} + +TEST_CASE_FIXTURE(LogFixture, "Unit matching is case-insensitive") { + const auto& msg = "/ABC5MTR\r\n" + "\r\n" + "1-0:1.8.1(000001.000*kwh)\r\n" + "!"; + + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.energy_delivered_tariff1 == 1.000f); +} + +TEST_CASE_FIXTURE(LogFixture, "Numeric without decimals is accepted (auto-padded)") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.7.0(1*kW)\r\n" + "!"; + + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); + REQUIRE(data.power_delivered == 1.0f); +} + +TEST_CASE_FIXTURE(LogFixture, "Can parse a dataline if it has a break in the middle") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-1:24.3.0(120517020000)(08)(60)(1)(0-1:24.2.1)(m3)\r\n" + "(00124.477)\r\n" + "0-0:96.13.0(303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n" + "303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n" + "303132333435363738393A3B3C3D3E3F)\r\n" + "!"; + + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(data.gas_delivered_text == "(120517020000)(08)(60)(1)(0-1:24.2.1)(m3)\r\n(00124.477)"); + REQUIRE(data.message_long == "303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n303132333435363738393A3B3C3D3E3F30313233343536373" + "8393A3B3C3D3E3F\r\n303132333435363738393A3B3C3D3E3F"); +} + +TEST_CASE_FIXTURE(LogFixture, "Can parse a 0 value without a unit") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-1:24.2.1(000101000000W)(00000000.0000)\r\n" + "!"; + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); + REQUIRE(data.gas_delivered == 0.0f); +} + +TEST_CASE_FIXTURE(LogFixture, "Whitespace after OBIS ID") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-1:24.2.1 (000101000000W)(00000000.0000)\r\n" + "!"; + ParsedData data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /*unknown_error=*/true); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing (")); + REQUIRE(log.contains(" (000101000000W)(00000000.0000)")); +} + +TEST_CASE_FIXTURE(LogFixture, "Use integer fallback unit") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-1:24.2.1(230101120000W)(00012*dm3)\r\n" + "1-0:14.7.0(50*Hz)\r\n" + "!"; + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /*unknown_error=*/true); + REQUIRE(data.gas_delivered == 0.012f); + REQUIRE(data.frequency == 0.05f); +} + +TEST_CASE_FIXTURE(LogFixture, "AveragedFixedField works properly for a long array") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-0:98.1.0(11)(1-0:1.6.0)(1-0:1.6.0)(230101000000W)(221206183000W)(06.134*kW)(230201000000W)(230127174500W)(05.644*kW)(230301000000W)(" + "230226063000W)(04.895*kW)(230401000000S)(230305181500W)(04.879*kW)(230501000000S)(230416094500S)(04.395*kW)(230601000000S)(230522084500S)(" + "03.242*kW)(230701000000S)(230623053000S)(01.475*kW)(230801000000S)(230724060000S)(02.525*kW)(230901000000S)(230819174500S)(02.491*kW)(" + "231001000000S)(230911063000S)(02.342*kW)(231101000000W)(231031234500W)(02.048*kW)\r\n" + "!"; + + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /* unknown_error */ true); + + REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 3.642f); +} + +TEST_CASE_FIXTURE(LogFixture, "AveragedFixedField works properly for an empty array") { + const auto& msg = "/KMP5 ZABF000000000000\r\n" + "0-0:98.1.0(0)(garbage that will be skipped)\r\n" + "1-0:1.8.1(000001.000*kwh)\r\n" + "!"; + + ParsedData data; + DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /* unknown_error */ true); + + REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 0.0f); + REQUIRE(data.energy_delivered_tariff1.val() == 1.0f); +} + +TEST_CASE_FIXTURE(LogFixture, "Should parse gas_delivered_gj field") { + const auto& msg = "/identification\r\n" + "0-1:24.2.1(251129203200W)(3.829*GJ)\r\n" "!"; - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; + ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Last dataline not CRLF terminated"); + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg), /* unknown_error */ true); + REQUIRE(res); + REQUIRE(data.gas_delivered_gj == 3.829f); } -TEST_CASE("Should report an error if checksum is not found") { +TEST_CASE_FIXTURE(LogFixture, "Missing opening parenthesis for numeric field") { const auto& msg = "/AAA5MTR\r\n" "\r\n" - "1-0:1.7.0(00.123*kW)" + "1-0:1.7.0\r\n" "!"; - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "No checksum found"); -} - -TEST_CASE("Doesn't crash for an empty packet") { - const auto& msg = ""; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "Data should start with /"); - - res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Data should start with /"); -} - -TEST_CASE("Doesn't crash for a small packet") { - const auto& msg = "/!"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "No checksum found"); - - res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); -} - -TEST_CASE("Doesn't crash for a small packet 2") { - const auto& msg = "/a!"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - auto res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "No checksum found"); - - res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Last dataline not CRLF terminated"); -} - -TEST_CASE("Doesn't crash for a partial checksum") { - const auto& msg = "/!A1"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "No checksum found"); -} - -TEST_CASE("Doesn't crash for a packet that doesn't end with '!' symbol") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.123*kW)"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/true); - REQUIRE(std::string(res.err) == "Data should end with !"); -} - -TEST_CASE("Trailing characters on data line") { - const auto& msg = "/AAA5MTR\r\n\r\n" - "1-0:1.7.0(00.123*kW) trailing\r\n" - "!\r\n"; ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), false, false); - REQUIRE(std::string(res.err) == "Trailing characters on data line"); -} - -TEST_CASE("Unknown field ignored when unknown_error is false") { - const auto& msg = "/AAA5MTR\r\n\r\n" - "1-0:2.7.0(00.000*kW)\r\n" - "!\r\n"; - ParsedData data; - auto res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); -} - -TEST_CASE("Missing unit when required") { - const auto& msg = "/AAA5MTR\r\n\r\n" - "1-0:1.7.0(00.123)\r\n" - "!\r\n"; - ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), false, false); - REQUIRE(std::string(res.err) == "Missing unit"); -} - -TEST_CASE("Unit present when not expected") { - const auto& msg = "/AAA5MTR\r\n\r\n" - "0-0:96.7.21(00008*s)\r\n" - "!\r\n"; - ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), false, false); - REQUIRE(std::string(res.err) == "Extra data"); -} - -TEST_CASE("Malformed packet that starts with ')'") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-3:0.2.8)40(\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* String */ p1_version> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Unexpected ')' symbol"); -} - -TEST_CASE("Non-digit in numeric part") { - const auto& msg = "/AAA5MTR\r\n" - "\r\n" - "1-0:1.7.0(00.A23*kW)\r\n" - "!\r\n"; - - ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered> - data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Invalid number"); + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing ( ''")); } -TEST_CASE("OBIS id empty line") { +TEST_CASE_FIXTURE(LogFixture, "Non-digit in integer part of numeric field") { const auto& msg = "/AAA5MTR\r\n" "\r\n" - "garbage\r\n" - "!\r\n"; - - ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "OBIS id Empty"); -} - -TEST_CASE("Accepts LF-only line endings") { - const auto& msg = "/AAA5MTR\n" - "\n" - "1-0:1.7.0(00.123*kW)\n" - "!\n"; + "1-0:1.7.0(A0.123*kW)\r\n" + "!"; ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.power_delivered == 0.123f); + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Invalid number")); + REQUIRE(log.contains("A0.123*kW)")); } -TEST_CASE("Unit matching is case-insensitive") { - const auto& msg = "/ABC5MTR\r\n" +TEST_CASE_FIXTURE(LogFixture, "Unit too short for numeric field") { + const auto& msg = "/AAA5MTR\r\n" "\r\n" - "1-0:1.8.1(000001.000*kwh)\r\n" - "!\r\n"; + "1-0:1.8.1(000001.000*kW)\r\n" + "!"; ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.energy_delivered_tariff1 == 1.000f); + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Missing unit")); + REQUIRE(log.contains("kW)")); } -TEST_CASE("Numeric without decimals is accepted (auto-padded)") { +TEST_CASE_FIXTURE(LogFixture, "Nested opening parenthesis") { const auto& msg = "/AAA5MTR\r\n" "\r\n" - "1-0:1.7.0(1*kW)\r\n" + "1-0:1.7.0(0(0.123*kW))\r\n" "!"; ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); - REQUIRE(data.power_delivered == 1.0f); -} - -TEST_CASE("Can parse a dataline if it has a break in the middle") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-1:24.3.0(120517020000)(08)(60)(1)(0-1:24.2.1)(m3)\r\n" - "(00124.477)\r\n" - "0-0:96.13.0(303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n" - "303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n" - "303132333435363738393A3B3C3D3E3F)\r\n" - "!"; - - ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(data.gas_delivered_text == "(120517020000)(08)(60)(1)(0-1:24.2.1)(m3)\r\n(00124.477)"); - REQUIRE(data.message_long == "303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F\r\n303132333435363738393A3B3C3D3E3F30313233343536373" - "8393A3B3C3D3E3F\r\n303132333435363738393A3B3C3D3E3F"); -} - -TEST_CASE("Can parse a 0 value without a unit") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-1:24.2.1(000101000000W)(00000000.0000)\r\n" - "!"; - ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); - REQUIRE(res.err == nullptr); - REQUIRE(data.gas_delivered == 0.0f); -} - -TEST_CASE("Whitespace after OBIS ID") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-1:24.2.1 (000101000000W)(00000000.0000)\r\n" - "!"; - ParsedData data; - const auto& res = P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/ true, /*check_crc=*/false); - REQUIRE(std::string(res.err) == "Missing ("); -} - -TEST_CASE("Use integer fallback unit") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-1:24.2.1(230101120000W)(00012*dm3)\r\n" - "1-0:14.7.0(50*Hz)\r\n" - "!"; - ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /*unknown_error=*/ true, /*check_crc=*/false); - REQUIRE(data.gas_delivered == 0.012f); - REQUIRE(data.frequency == 0.05f); -} - -TEST_CASE("AveragedFixedField works properly for a long array") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-0:98.1.0(11)(1-0:1.6.0)(1-0:1.6.0)(230101000000W)(221206183000W)(06.134*kW)(230201000000W)(230127174500W)(05.644*kW)(230301000000W)(" - "230226063000W)(04.895*kW)(230401000000S)(230305181500W)(04.879*kW)(230501000000S)(230416094500S)(04.395*kW)(230601000000S)(230522084500S)(" - "03.242*kW)(230701000000S)(230623053000S)(01.475*kW)(230801000000S)(230724060000S)(02.525*kW)(230901000000S)(230819174500S)(02.491*kW)(" - "231001000000S)(230911063000S)(02.342*kW)(231101000000W)(231031234500W)(02.048*kW)\r\n" - "!"; - - ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /* unknown_error */ true, /* check_crc */ false); - - REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 3.642f); -} - -TEST_CASE("AveragedFixedField works properly for an empty array") { - const auto& msg = "/KMP5 ZABF000000000000\r\n" - "0-0:98.1.0(0)(garbage that will be skipped)\r\n" - "1-0:1.8.1(000001.000*kwh)\r\n" - "!"; - - ParsedData data; - P1Parser::parse(data, msg, std::size(msg), /* unknown_error */ true, /* check_crc */ false); - - REQUIRE(data.active_energy_import_maximum_demand_last_13_months.val() == 0.0f); - REQUIRE(data.energy_delivered_tariff1.val() == 1.0f); -} - -TEST_CASE("Should parse gas_delivered_gj field") { - const auto& msg = "/identification\r\n" - "0-1:24.2.1(251129203200W)(3.829*GJ)\r\n" - "!"; - - ParsedData data; - - const auto& res = P1Parser::parse(data, msg, std::size(msg), /* unknown_error */ true, /* check_crc */ false); - REQUIRE(res.err == nullptr); - REQUIRE(data.gas_delivered_gj == 3.829f); -} + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE_FALSE(res); + REQUIRE(log.contains("Unexpected '(' symbol")); +} + +TEST_CASE_FIXTURE(LogFixture, "ParsedData without any fields") { + const auto& msg = "/AAA5MTR\r\n" + "\r\n" + "1-0:1.8.1(000671.578*kWh)\r\n" + "1-0:1.8.2(000842.472*kWh)\r\n" + "1-0:2.8.1(000000.000*kWh)\r\n" + "!"; + + ParsedData<> data; + const auto& res = DsmrParser::parse(data, DsmrUnencryptedTelegram(msg)); + REQUIRE(res); + REQUIRE(data.all_present()); +} diff --git a/tests/test_util.h b/tests/test_util.h new file mode 100644 index 0000000..96a4cbf --- /dev/null +++ b/tests/test_util.h @@ -0,0 +1,43 @@ +#pragma once + +#include "dsmr_parser/util.h" +#include "dsmr_parser/packet_accumulator.h" +#include +#include +#include + +class LogCapturer { +public: + LogCapturer() { + dsmr_parser::Logger::set_log_function([this](dsmr_parser::LogLevel, const char* fmt, va_list args) { + char buf[1024]; +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wformat-nonliteral" +#endif + vsnprintf(buf, sizeof(buf), fmt, args); +#if defined(__clang__) +#pragma clang diagnostic pop +#endif + messages.emplace_back(buf); + }); + } + + ~LogCapturer() { dsmr_parser::Logger::set_log_function([](dsmr_parser::LogLevel, const char*, va_list) {}); } + + bool contains(const std::string& substr) const { + for (const auto& msg : messages) { + if (msg.find(substr) != std::string::npos) + return true; + } + return false; + } + + void clear() { messages.clear(); } + + std::vector messages; +}; + +struct LogFixture { + LogCapturer log; +};