diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3c8f90a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..ad89d06 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,14 @@ +on: push + +jobs: + build-windows-msvc: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - run: ./build-win.ps1 + build-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get install -y clang + - run: ./build-linux.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b587149 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/build/ +/out/ +.idea/ +.vs/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f25005f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required (VERSION 3.20) +project(arduino-dsmr-test LANGUAGES CXX) +include(FetchContent) + +file(DOWNLOAD + https://github.com/doctest/doctest/releases/download/v2.4.12/doctest.h + ${CMAKE_BINARY_DIR}/doctest/doctest.h + EXPECTED_MD5 0671bbca9fb00cb19a168cffa89ac184) + +FetchContent_Populate( + mded_tls + URL https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 + URL_HASH MD5=eb965a5bb8044bc43a49adb435fa72ee) +set(ENABLE_PROGRAMS OFF CACHE INTERNAL "") +set(ENABLE_TESTING OFF CACHE INTERNAL "") +add_subdirectory(${mded_tls_SOURCE_DIR}) + +FetchContent_Populate( + cmake_template + URL https://github.com/cpp-best-practices/cmake_template/archive/refs/heads/main.zip + URL_HASH MD5=c391b4c21eeabac1d5142bcf404c740c) +include(${cmake_template_SOURCE_DIR}/cmake/CompilerWarnings.cmake) +include(${cmake_template_SOURCE_DIR}/cmake/Sanitizers.cmake) + +file(GLOB_RECURSE arduino_dsmr_test_src_files CONFIGURE_DEPENDS "src/*.h" "src/*.cpp") +add_executable(arduino_dsmr_test ${arduino_dsmr_test_src_files}) +target_include_directories(arduino_dsmr_test PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_BINARY_DIR}/doctest) +target_include_directories(arduino_dsmr_test SYSTEM PRIVATE $) +target_compile_features(arduino_dsmr_test PRIVATE cxx_std_20) +target_link_libraries(arduino_dsmr_test PRIVATE mbedtls) + +# enable warnings +add_library(arduino_dsmr_test_warnings INTERFACE) +myproject_set_project_warnings(arduino_dsmr_test_warnings ON "" "" "" "") +if(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang") + target_compile_options(arduino_dsmr_test_warnings INTERFACE -Wno-gnu-zero-variadic-macro-arguments) +endif() +target_link_libraries(arduino_dsmr_test PRIVATE arduino_dsmr_test_warnings) + +# enable sanitizers: address, leak, undefined behaviour +add_library(arduino_dsmr_test_sanitizers INTERFACE) +myproject_enable_sanitizers(arduino_dsmr_test_sanitizers ON ON ON OFF OFF) +target_link_libraries(arduino_dsmr_test PRIVATE arduino_dsmr_test_sanitizers) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9027736 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015 Matthijs Kooijman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index df7278b..1dcd28f 100644 --- a/README.md +++ b/README.md @@ -1,246 +1,56 @@ -# Arduino Dutch Smart meter (DSMR) parser - -This is an Arduino library for interfacing with Dutch smart meters, through -their P1 port. This library can take care of controlling the "request" pin, -reading messages and parsing them. - -This code was written for Arduino, but most of the parsing code it is pretty -generic C++ (except for the Arduino- and AVR-based string handling), so it -should be possible to adapt for use outside of the Arduino environment. - -When using Arduino, version 1.6.6 or above is required because this -library needs C++11 support which was enabled in that version. - -## Protocol - -Every smart meter in the Netherlands has to comply with the Dutch Smart -Meter Requirements (DSMR). At the time of writing, DSMR 4.x is the -current version. The DSMR 5.0 P1 specification is available and expected to -be used in smart meters starting in 2016. This code should support both -the 4.x and 5.0 specifications. 3.x meters might also work, but this has -not been verified or tested (feedback welcome). - -The DSMR specifications can be found on [the site of Netbeheer -Nederland][netbeheer], in particular on [this -page][dossier-slimme-meter]. Of particular interest is the "P1 companion -standard" that specifies the P1 port and protocol (though not very -clearly). Specifications can also be found in the `specs` subdirectory -of this repository (including some older versions that are no longer -online, which is why I started collecting them here). - -[netbeheer]: http://www.netbeheernederland.nl -[dossier-slimme-meter]: https://www.netbeheernederland.nl/dossiers/slimme-meter-15/documenten - -According to DSMR, every smart electricity meter needs to have a P1 -port. This is a [6p6c socket][6p6c] (commonly, but incorrectly referred -to as RJ11 or RJ12). Telephone plugs will fit, provided that you have -some that actually have 6 pins wired, or you have just 4 and do not need -power from the P1 port. - -[6p6c]: http://en.wikipedia.org/wiki/Modular_connector#6P6C - -Pinouts and electrical specs can best be looked up in the spec (The 5.0 -version is the most clear in this regard, though not everything may -apply to 4.x meters). - -Note that the message format for the P1 port is based on the IEC 62056-21 -"mode D" format. That spec is not available for free, though there seem -to be [a version available on the net][iec62056-21]. DLMS also seems a -related standard, but that apparently defines a binary format. It does -seem all of these use "OBIS identifiers" and "COSEM data objects" -(DLMS has [some lists of objects][objlists], of which 1001-7 seems to -somewhat match th DSMR specs), to describe the various properties, -though it's not entirely clear how all of these fit together. However, -the DSMR spec has a complete, though sometimes confusing list of fields -used. - -[iec62056-21]: https://www.ungelesen.net/protagWork/media/downloads/solar-steuerung/iec62056-21%7Bed1.0%7Den_.pdf -[objlists]: http://www.dlms.com/documentation/listofstandardobiscodesandmaintenanceproces/index.html - -A typical P1 message looks something like this: - - /KFM5KAIFA-METER - - 1-0:1.8.1(000671.578*kWh) - 1-0:1.7.0(00.318*kW) - !1E1D - -This includes an identification header at the top, a checksum at the -bottom, and one or more lines of data in the middle. This example is -really stripped down, real messages will have more data in them. - -The first part of the line (e.g. `1-0:1.8.1`) is the (OBIS) id of the -field, which defines the meaning and format of the rest of the line. - -## Parsing a message - -Unlike other solutions floating around (which typically do some pattern -matching to extract the data they need), this code properly parses -messages, verifying the checksum and really parses each line according -to the specifications. This should make for more reliable parsing, and -allows for useful parser error messages: - - 1-0:1.8.1(000671.578*XWh) - ^ - Error: Invalid unit - - 1-0:1.8.1(0006#71.578*kWh) - ^ - Error: Invalid number - - !6F4A - ^ - Checksum mismatch - -This library uses C++ templates extensively. This allows defining a -custom datatype by listing the fields you are interested in, and then -all necessary parsing will happen automatically. The code generated -parses each line in the message in turn and for each line loops over the -fields in the datatype to find one whose ID matches. If found, the value -is parsed and stored into the corresponding field. - -As an example, consider we want to parse the identification and current -power fields in the example message above. We define a datatype: - - using MyData = ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered - >; - -The syntax is a bit weird because of the template magic used, but the -above essentially defines a struct with members for each field to be -parsed. For each field, there is also an associated `xxx_present` -member, which can be used to check whether the field was present in the -parsed data (if it is false, the associated field contains uninitialized -data). There is some extra stuff in the background, but the `MyData` -can be used just like the below struct. It also takes up the same amount -of space. - - struct MyData { - bool identification_present; - String identification; - bool power_delivered_present; - FixedValue power_delivered; - }; - -After this, call the parser. By passing our custom datatype defined -above, the parser knows what fields to look for. - - MyData data; - ParseResult res = P1Parser::parse(&data, msg, lengthof(msg)); - -Finally, we can check if the parsing was succesful and access the parsed -values as members of `data`: - - if (!res.err && res.all_present()) { - // Succesfully parsed, print results: - Serial.println(data.identification); - Serial.print(data.power_delivered.int_val()); - Serial.println("W"); - } - -In this case, we check whether parsing was successful, but also check -that all defined fields were present in the parsed message (using the -`all_present()` method), to prevent printing undefined values. If you -want to support optional fields, you can use the `xxx_present` members -for each field individually instead. - -Additionally, this template approach allows looping over all available -fields in a generic way, for example to print the parse results with -just a few lines of code. See the parse and read examples for how this -works. - -Note that these examples contain the full list of supported fields, -which causes parsing and printing code to be generated for all those -fields, even if they are not present in the output you want to parse. It -is recommended to limit the list of fields to just the ones that you -need, to make the parsing and printing code smaller and faster. - -## Parsed value types - -Some values are parsed to an Arduino `String` value or C++ integer type, -those should be fairly straightforward. There are three special types -that need some explanation: `FixedValue` and `TimestampedFixedValue`. - -When looking at the DSMR P1 format, it defines a floating point format. -It is described as `Fn(x,y)`, where `n` is the total number of (decimal) -digits, of which at least `x` and at most `y` are behind the decimal -separator (e.g. fractional digits). - -However, this floating point format is a lot more limited than the C -`float` format. For one, it is decimal-based, not binary. Furthermore, -the decimal separator doesn't float very far, the biggest value for `y` -used is 3. Even more, it seems that for any given field, there is no -actual floating involved, fields have `x` equal to `y`, so the number of -fractional digits is fixed. - -Because of this, parsing into a `float` value isn't really useful (and -on the Arduino, which doesn't have an FPU, very inefficient too). For -this reason, we use the `FixedValue` type, which stores the value as an -integer, in thousands of the original unit. This means that a value of -1.234kWh is stored as 1234 (effectively the value has been translated to -Wh). - -If you access the field directly, it will automatically be converted to -`float`, keeping the original value. Alternatively, if an integer -version is sufficient, you can call the `int_val()` method to get the -integer version returned. - - // Print as float, in kW - Serial.print(data.power_delivered); - // Print as integer, in W - Serial.print(data.power_delivered.int_val()); - -Additionally there is a `TimestampedFixedValue` method, which works -identically, but additionally has a `timestamp()` method which returns -the timestamp sent along with the value. - -These timestamps are returned as a string, exactly as present in the P1 -message (YYMMDDhhmmssX, where X is S or W for summer- or wintertime). -Parsing these into something like a UNIX timestamp is tricky (think -leap years and seconds) and of limited use, so this just keeps the -original format. - -## Connecting the P1 port - -The P1 port essentially consists of three parts: - -- A 5V power supply (this was not present in 3.x). -- A serial TX pin. This sends meter data using 0/5V signalling, using - idle low. Note that this is the voltage level commonly referred to as - "TTL serial", but the polarity is reversed (more like RS232). This - port uses 115200 bps 8N1 (3.x and before used 9600 bps). -- A request pin - 5V needs to be applied to this pin to start - generating output on the TX pin. - -To connect to an Arduino that has an unused hardware serial port (like -an Arduino Mega, Leonardo or Micro), the signal has to inverted. This -can be done using a dedicated inverter IC, or just a transistor and some -resistors. - -It's also possible to do all of the serial reception, including the -inverting, in software using Arduino's SoftwareSerial library. However, -it seems there are still occasional reception errors when using -SoftwareSerial. - -## Sub meters - -In addition to a smart electricity meter, there can be additional -sub meters attached (e.g., gas, water, thermal and sub electricity -meter). These can talk to the main meter using the (wired or wireless) -MBUS protocol to regularly (hourly for 4.x, every 5 minutes for 5.0) -send over their meter readings. Based on the configuration / connection, -each of these subs gets an MBUS identifier (1-4). - -In the P1 message, this identifier is used as the second number in the -OBIS identifiers for the fields for the sub. Currently, the code has -the assignment from MBUS identifier to device type hardcoded in -`fields.h`. For the most common configuration of an electricity meter with -a single gas meter as sub, this works straight away. Other -configurations might need changes to `fields.h` to work. - -## License - -All of the code and documentation in this library is licensed under the -MIT license, with the exception of the examples, which are licensed -under an even more liberal license. +This is a fork of [matthijskooijman/arduino-dsmr](https://github.com/matthijskooijman/arduino-dsmr). +The primary goal is to make the parser independent of the Arduino framework and usable on ESP32 with the ESP-IDF framework or any other platform. + +# Features +* 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 +* Small refactoring and code optimizations +* Supported compilers: MSVC, GCC, Clang +* Header-only library, no dependencies +* Code can be used on any platform, not only embedded. + +# Differences from the original arduino-dsmr +* Requires a C++20 compatible compiler. +* [P1Reader](https://github.com/matthijskooijman/arduino-dsmr/blob/master/src/dsmr/reader.h) class is replaced with the [PacketAccumulator](https://github.com/PolarGoose/arduino-dsmr-2/blob/master/src/dsmr/packet_accumulator.h) class with a different interface to allow usage on any platform. +* Added [EncryptedPacketAccumulator](https://github.com/PolarGoose/arduino-dsmr-2/blob/master/src/arduino-dsmr-2/encrypted_packet_accumulator.h) class to receive encrypted DSMR messages (like from "Luxembourg Smarty"). + +# How to use +## General usage +The library is header-only. Add the `src/arduino-dsmr-2` folder to your project.
+Note: `encrypted_packet_accumulator.h` header depends on [Mbed TLS](https://www.trustedfirmware.org/projects/mbed-tls/) library. It is already included in the `ESP-IDF` framework and can be easily added to any other platforms. + +## Usage from PlatformIO +The library is available on the PlatformIO registry:
+[PlatformIO arduino-dsmr-2](https://registry.platformio.org/libraries/polargoose/arduino-dsmr-2/installation) + +# 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/PolarGoose/arduino-dsmr-2/blob/master/src/test/packet_accumulator_example_test.cpp) +* Example using EncryptedPacketAccumulator + * [encrypted_packet_accumulator_example_test.cpp](https://github.com/PolarGoose/arduino-dsmr-2/blob/master/src/test/encrypted_packet_accumulator_example_test.cpp) + +# History behind arduino-dsmr +[matthijskooijman](https://github.com/matthijskooijman) is the original creator of this DSMR parser. +[glmnet](https://github.com/glmnet) and [zuidwijk](https://github.com/zuidwijk) continued work on this parser in the fork [glmnet/arduino-dsmr](https://github.com/glmnet/arduino-dsmr). They used the parser to create [ESPHome DSMR](https://esphome.io/components/sensor/dsmr/) component. +After that, the work on the `arduino-dsmr` parser stopped. +Since then, some issues and unmerged pull requests have accumulated. Additionally, the dependency on the Arduino framework causes various issues for some ESP32 boards. +This fork addresses the existing issues and makes the parser usable on any platform. + +## The reasons `arduino-dsmr-2` fork was created +* Dependency on the Arduino framework limits the applicability of this parser. For example, it is not possible to use it on Linux. +* The Arduino framework on ESP32 inflates the FW size and doesn't allow usage of the latest version of ESP-IDF. +* Many pull requests and bug fixes needed to be integrated into the parser. +* Lack of support for encrypted DSMR messages. + +# How to work with the code +* You can open the code using any IDE that supports CMake. + * `build-windows.ps1` script needs `Visual Studio 2022` to be installed. + * `build-linux.sh` script needs `clang` to be installed. + +# References +* [DSMR parser in Python](https://github.com/ndokter/dsmr_parser/tree/master) - alternative DSMR parser implementation in Python. +* [SmartyReader](https://www.weigu.lu/microcontroller/smartyReader_P1/index.html) - open source hardware to communicate with P1 port. +* [SmartyReader. Chapter "Encryption"](https://www.weigu.lu/microcontroller/smartyReader/index.html) - how the encrypted DSMR protocol works. diff --git a/build-linux.sh b/build-linux.sh new file mode 100755 index 0000000..403763c --- /dev/null +++ b/build-linux.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# This script requires clang to be installed: +# sudo apt-get update && sudo apt-get install -y clang + +set -o xtrace -o errexit -o nounset -o pipefail +readonly currentScriptDir=`dirname "$(realpath -s "${BASH_SOURCE[0]}")"` +readonly buildDir="${currentScriptDir}/build" + +build_and_test() { + local build_type="$1" # Debug or Release + local target="$2" # linux-gcc or linux-clang + + echo -e "\n\nBuild and test ${target}-$build_type" + + cmake -S "$currentScriptDir" \ + -B "$buildDir/${target}-$build_type" \ + -G "Ninja" \ + -D CMAKE_BUILD_TYPE="$build_type" + cmake --build "$buildDir/${target}-$build_type" + "$buildDir/${target}-$build_type/arduino_dsmr_test" +} + +build_and_test Debug linux-gcc +build_and_test Release linux-gcc + +export CC=clang +export CXX=clang++ +build_and_test Debug linux-clang +build_and_test Release linux-clang + +echo "Success" diff --git a/build-win.ps1 b/build-win.ps1 new file mode 100644 index 0000000..a8c5657 --- /dev/null +++ b/build-win.ps1 @@ -0,0 +1,59 @@ +Function Info($msg) { + Write-Host -ForegroundColor DarkGreen "`nINFO: $msg`n" +} + +Function Error($msg) { + Write-Host `n`n + Write-Error $msg + exit 1 +} + +Function CheckReturnCodeOfPreviousCommand($msg) { + if(-Not $?) { + Error "${msg}. Error code: $LastExitCode" + } +} + +Function BuildAndTest($buildType, $arch) { + Info "Build and test $buildType-$arch" + + $thisBuildDir = "$buildDir/win-$buildType-$arch" + + Info "Cmake generate cache" + cmake -S $root ` + -B $thisBuildDir ` + -G Ninja ` + -D CMAKE_BUILD_TYPE=$buildType + CheckReturnCodeOfPreviousCommand "cmake cache failed" + + Info "Cmake build" + cmake --build $thisBuildDir + CheckReturnCodeOfPreviousCommand "cmake build failed" + + Info "Run tests" + & "$thisBuildDir/arduino_dsmr_test.exe" + CheckReturnCodeOfPreviousCommand "tests failed" +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$root = Resolve-Path $PSScriptRoot +$buildDir = "$root/build" + +Info "Find Visual Studio installation path" +$vswhereCommand = Get-Command -Name "${Env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$installationPath = & $vswhereCommand -prerelease -latest -property installationPath + +Info "Open Visual Studio 2022 Developer PowerShell amd64" +& "$installationPath\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 + +BuildAndTest -buildType Debug -arch amd64 +BuildAndTest -buildType Release -arch amd64 + +Info "Open Visual Studio 2022 Developer PowerShell x86" +& "$installationPath\Common7\Tools\Launch-VsDevShell.ps1" -Arch x86 + +BuildAndTest -buildType Debug -arch x86 +BuildAndTest -buildType Release -arch x86 diff --git a/examples/minimal_parse/minimal_parse.ino b/examples/minimal_parse/minimal_parse.ino deleted file mode 100644 index c4b2655..0000000 --- a/examples/minimal_parse/minimal_parse.ino +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Permission is hereby granted, free of charge, to anyone - * obtaining a copy of this document and accompanying files, - * to do whatever they want with them without any restriction, - * including, but not limited to, copying, modification and redistribution. - * NO WARRANTY OF ANY KIND IS PROVIDED. - * - * Example that shows how to parse a P1 message and automatically print - * the result. -*/ - -#include "dsmr.h" - -// Data to parse -const char 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"; - -/** - * Define the data we're interested in, as well as the datastructure to - * hold the parsed data. - * Each template argument below results in a field of the same name. - */ -using MyData = ParsedData< - /* String */ identification, - /* FixedValue */ power_delivered ->; - -void setup() { - Serial.begin(115200); - - MyData data; - ParseResult res = P1Parser::parse(&data, msg, lengthof(msg)); - if (res.err) { - // Parsing error, show it - Serial.println(res.fullError(msg, msg + lengthof(msg))); - } else if (!data.all_present()) { - Serial.println("Some fields are missing"); - } else { - // Succesfully parsed, print results: - Serial.println(data.identification); - Serial.print(data.power_delivered.int_val()); - Serial.println("W"); - } -} - -void loop () { -} diff --git a/examples/parse/parse.ino b/examples/parse/parse.ino deleted file mode 100644 index 6ad117d..0000000 --- a/examples/parse/parse.ino +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Permission is hereby granted, free of charge, to anyone - * obtaining a copy of this document and accompanying files, - * to do whatever they want with them without any restriction, - * including, but not limited to, copying, modification and redistribution. - * NO WARRANTY OF ANY KIND IS PROVIDED. - * - * Example that shows how to parse a P1 message and automatically print - * the result. -*/ - -#include "dsmr.h" - -// Data to parse -const char raw[] = - "/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" - "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" - "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:31.7.0(001*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" - "0-1:24.4.0(1)\r\n" - "!6F4A\r\n"; - -/** - * Define the data we're interested in, as well as the datastructure to - * hold the parsed data. This list shows all supported fields, remove - * any fields you are not using from the below list to make the parsing - * and printing code smaller. - * Each template argument below results in a field of the same name. - */ -using MyData = 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, - /* 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_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, - /* uint16_t */ sub_device_type, - /* String */ sub_equipment_id, - /* uint8_t */ sub_valve_position, - /* TimestampedFixedValue */ sub_delivered>; - -/** - * This illustrates looping over all parsed fields using the - * ParsedData::applyEach method. - * - * When passed an instance of this Printer object, applyEach will loop - * over each field and call Printer::apply, passing a reference to each - * field in turn. This passes the actual field object, not the field - * value, so each call to Printer::apply will have a differently typed - * parameter. - * - * For this reason, Printer::apply is a template, resulting in one - * distinct apply method for each field used. This allows looking up - * things like Item::name, which is different for every field type, - * without having to resort to virtual method calls (which result in - * extra storage usage). The tradeoff is here that there is more code - * generated (but due to compiler inlining, it's pretty much the same as - * if you just manually printed all field names and values (with no - * cost at all if you don't use the Printer). - */ -struct Printer -{ - template - void apply(Item &i) - { - if (i.present()) - { - Serial.print(Item::name); - Serial.print(F(": ")); - Serial.print(i.val()); - Serial.print(Item::unit()); - Serial.println(); - } - } -}; - -void setup() -{ - Serial.begin(115200); - - MyData data; - ParseResult res = P1Parser::parse(&data, raw, lengthof(raw), true); - if (res.err) - { - // Parsing error, show it - Serial.println(res.fullError(raw, raw + lengthof(raw))); - } - else - { - // Parsed succesfully, print all values - data.applyEach(Printer()); - } -} - -void loop() -{ -} diff --git a/examples/read/read.ino b/examples/read/read.ino deleted file mode 100644 index 9b3e8d4..0000000 --- a/examples/read/read.ino +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Permission is hereby granted, free of charge, to anyone - * obtaining a copy of this document and accompanying files, - * to do whatever they want with them without any restriction, - * including, but not limited to, copying, modification and redistribution. - * NO WARRANTY OF ANY KIND IS PROVIDED. - * - * Example that shows how to periodically read a P1 message from a - * serial port and automatically print the result. -*/ - -#include "dsmr.h" - -/** - * Define the data we're interested in, as well as the datastructure to - * hold the parsed data. This list shows all supported fields, remove - * any fields you are not using from the below list to make the parsing - * and printing code smaller. - * Each template argument below results in a field of the same name. - */ -using MyData = 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, - /* 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_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, - /* uint16_t */ sub_device_type, - /* String */ sub_equipment_id, - /* uint8_t */ sub_valve_position, - /* TimestampedFixedValue */ sub_delivered>; - -/** - * This illustrates looping over all parsed fields using the - * ParsedData::applyEach method. - * - * When passed an instance of this Printer object, applyEach will loop - * over each field and call Printer::apply, passing a reference to each - * field in turn. This passes the actual field object, not the field - * value, so each call to Printer::apply will have a differently typed - * parameter. - * - * For this reason, Printer::apply is a template, resulting in one - * distinct apply method for each field used. This allows looking up - * things like Item::name, which is different for every field type, - * without having to resort to virtual method calls (which result in - * extra storage usage). The tradeoff is here that there is more code - * generated (but due to compiler inlining, it's pretty much the same as - * if you just manually printed all field names and values (with no - * cost at all if you don't use the Printer). - */ -struct Printer -{ - template - void apply(Item &i) - { - if (i.present()) - { - Serial.print(Item::name); - Serial.print(F(": ")); - Serial.print(i.val()); - Serial.print(Item::unit()); - Serial.println(); - } - } -}; - -// Set up to read from the second serial port, and use D2 as the request -// pin. On boards with only one (USB) serial port, you can also use -// SoftwareSerial. -#ifdef ARDUINO_ARCH_ESP32 -// Create Serial1 connected to UART 1 -HardwareSerial Serial1(1); -#endif -P1Reader reader(&Serial1, 2); - -unsigned long last; - -void setup() -{ - Serial.begin(115200); - Serial1.begin(115200); -#ifdef VCC_ENABLE - // This is needed on Pinoccio Scout boards to enable the 3V3 pin. - pinMode(VCC_ENABLE, OUTPUT); - digitalWrite(VCC_ENABLE, HIGH); -#endif - - // start a read right away - reader.enable(true); - last = millis(); -} - -void loop() -{ - // Allow the reader to check the serial buffer regularly - reader.loop(); - - // Every minute, fire off a one-off reading - unsigned long now = millis(); - if (now - last > 60000) - { - reader.enable(true); - last = now; - } - - if (reader.available()) - { - MyData data; - String err; - if (reader.parse(&data, &err)) - { - // Parse succesful, print result - data.applyEach(Printer()); - } - else - { - // Parser error, print error - Serial.println(err); - } - } -} diff --git a/library.json b/library.json index 35ceb6e..536a16f 100644 --- a/library.json +++ b/library.json @@ -1,12 +1,12 @@ { - "name": "Dsmr", - "version": "0.9", - "description": "Parser and utilities for Dutch Smart Meters (Implementing DSMR)", + "name": "arduino-dsmr-2", + "version": "8.0", + "description": "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": { "type": "git", - "url": "https://github.com/glmnet/arduino-dsmr.git" + "url": "https://github.com/PolarGoose/arduino-dsmr-2" }, "license": "MIT", - "exclude": ["specs/"] + "export": { "include": [ "src/arduino-dsmr-2/*.h" ] } } diff --git a/specs/Luxembourg Smarty P1 specification v1.1.3.pdf b/specs/Luxembourg Smarty P1 specification v1.1.3.pdf new file mode 100644 index 0000000..f596986 Binary files /dev/null and b/specs/Luxembourg Smarty P1 specification v1.1.3.pdf differ diff --git a/specs/Swedish HAN H1 port v2.0 specification.pdf b/specs/Swedish HAN H1 port v2.0 specification.pdf new file mode 100644 index 0000000..307e0b3 Binary files /dev/null and b/specs/Swedish HAN H1 port v2.0 specification.pdf differ diff --git a/specs/Swedish HAN Interface description Aidon v1.7A.pdf b/specs/Swedish HAN Interface description Aidon v1.7A.pdf new file mode 100644 index 0000000..306629e Binary files /dev/null and b/specs/Swedish HAN Interface description Aidon v1.7A.pdf differ diff --git a/src/.clang-format b/src/.clang-format new file mode 100644 index 0000000..4762084 --- /dev/null +++ b/src/.clang-format @@ -0,0 +1,8 @@ +# https://clang.llvm.org/docs/ClangFormatStyleOptions.html +BasedOnStyle: LLVM +CompactNamespaces: true +AlwaysBreakTemplateDeclarations: Yes +FixNamespaceComments: false +PointerAlignment: Left +AlignEscapedNewlines: Left +ColumnLimit: 160 diff --git a/src/arduino-dsmr-2/encrypted_packet_accumulator.h b/src/arduino-dsmr-2/encrypted_packet_accumulator.h new file mode 100644 index 0000000..ea2fb1b --- /dev/null +++ b/src/arduino-dsmr-2/encrypted_packet_accumulator.h @@ -0,0 +1,256 @@ +#pragma once +#include "util.h" +#include +#include +#include +#include +#include + +namespace arduino_dsmr_2 { + +// Some smart meters sent DSMR 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". +// The packet has the following structure: +// Header (18 bytes) | Telegram | GCM Tag (12 bytes) +class EncryptedPacketAccumulator { + class HeaderAccumulator { +#pragma pack(push, 1) + union Header { + struct Fields { + uint8_t tag; // always = 0xDB + uint8_t system_title_length; // always = 0x08 + uint8_t system_title[8]; + uint8_t long_form_length_indicator; // always = 0x82 + uint8_t total_length_big_endian[2]; // SecurityControlFieldLength + InvocationCounterLength + CiphertextLength + GcmTagLength + uint8_t security_control_field; // always = 0x30 + uint8_t invocation_counter_big_endian[4]; // also called "frame counter" + } fields; + std::array bytes; + }; +#pragma pack(pop) + static_assert(sizeof(Header) == 18, "PacketHeader struct must be 18 bytes"); + + Header header; + std::size_t number_of_accumulated_bytes = 0; + + public: + bool received_whole_header() const { return number_of_accumulated_bytes == sizeof(Header); } + + void add(const std::uint8_t byte) { + header.bytes[number_of_accumulated_bytes] = byte; + number_of_accumulated_bytes++; + } + + int telegram_with_gcm_tag_length() const { + // Convert from big-endian to the host's little-endian + const auto total_length = (header.fields.total_length_big_endian[0] << 8) | (header.fields.total_length_big_endian[1]); + return total_length - 5; // 5 = SecurityControlFieldLength + InvocationCounterLength + } + + // Also called "IV" + auto nonce() const { + // nonce = SystemTitle (8 bytes) + InvocationCounter (4 bytes) + const auto& st = header.fields.system_title; + const auto& ic = header.fields.invocation_counter_big_endian; + return std::array{st[0], st[1], st[2], st[3], st[4], st[5], st[6], st[7], ic[0], ic[1], ic[2], ic[3]}; + } + + bool check_consistency() const { + // There is no way to check if the received header is valid. + // Best we can do is to check the values of the constant fields and that the length is realistic. + const auto& hf = header.fields; + return hf.tag == 0xDB && hf.system_title_length == 0x08 && hf.long_form_length_indicator == 0x82 && hf.security_control_field == 0x30 && + telegram_with_gcm_tag_length() > 25; + } + }; + + class TelegramAccumulator { + std::span _buffer; + std::size_t _packetSize = 0; + + public: + explicit TelegramAccumulator(std::span buffer) : _buffer(buffer) {} + + void add(const uint8_t byte) { + _buffer[_packetSize] = byte; + _packetSize++; + } + + size_t number_of_accumulated_bytes() const { return _packetSize; } + size_t capacity() const { return _buffer.size(); } + + // The tag is always last 12 bytes + std::span telegram() const { return {_buffer.data(), _packetSize - 12}; } + std::span tag() const { return {_buffer.data() + _packetSize - 12, 12}; } + }; + + class MbedTlsAes128GcmDecryptor : NonCopyableAndNonMovable { + mbedtls_gcm_context gcm; + + public: + MbedTlsAes128GcmDecryptor() { mbedtls_gcm_init(&gcm); } + + bool set_encryption_key(const std::span key) { return mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key.data(), 128) == 0; } + + bool decrypt(std::span iv, std::span ciphertext, std::span tag, std::span decrypted_output) { + // aad = AdditionalAuthenticatedData = SecurityControlField + AuthenticationKey. + // SecurityControlField is always 0x30. + // AuthenticationKey = "00112233445566778899AABBCCDDEEFF". It is hardcoded and is the same for all DSMR devices. + constexpr uint8_t aad[] = {0x30, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + const auto& res = mbedtls_gcm_auth_decrypt(&gcm, ciphertext.size(), iv.data(), iv.size(), aad, std::size(aad), tag.data(), tag.size(), ciphertext.data(), + reinterpret_cast(decrypted_output.data())); + return res == 0; + } + + ~MbedTlsAes128GcmDecryptor() { mbedtls_gcm_free(&gcm); } + }; + + enum class State { WaitingForPacketStartSymbol, AccumulatingPacketHeader, AccumulatingTelegramWithGcmTag }; + State _state = State::WaitingForPacketStartSymbol; + std::span _raw_receive_encrypted_packet_buffer; + std::span _raw_decrypted_telegram_buffer; + HeaderAccumulator _header_accumulator; + TelegramAccumulator _encrypted_telegram_accumulator; + std::array _encryption_key{}; + +public: + enum class Error { BufferOverflow, HeaderCorrupted, FailedToSetEncryptionKey, DecryptionFailed }; + enum class SetEncryptionKeyError { EncryptionKeyLengthIsNot32Bytes, EncryptionKeyContainsNonHexSymbols }; + + class Result { + friend EncryptedPacketAccumulator; + + 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; } + }; + + explicit EncryptedPacketAccumulator(std::span encrypted_packet_buffer, std::span decrypted_telegram_buffer) + : _raw_receive_encrypted_packet_buffer(encrypted_packet_buffer), _raw_decrypted_telegram_buffer(decrypted_telegram_buffer), + _encrypted_telegram_accumulator(encrypted_packet_buffer) {} + + // key_hex is a string like "00112233445566778899AABBCCDDEEFF" + std::optional set_encryption_key(std::string_view key_hex) { + if (key_hex.size() != 32) { + return SetEncryptionKeyError::EncryptionKeyLengthIsNot32Bytes; + } + + 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 SetEncryptionKeyError::EncryptionKeyContainsNonHexSymbols; + } + _encryption_key[i] = static_cast((*hi << 4) | *lo); + } + + return {}; + } + + Result process_byte(const uint8_t byte) { + switch (_state) { + case State::WaitingForPacketStartSymbol: + if (byte == 0xDB) { + _header_accumulator = HeaderAccumulator(); + _header_accumulator.add(byte); + _encrypted_telegram_accumulator = TelegramAccumulator(_raw_receive_encrypted_packet_buffer); + _state = State::AccumulatingPacketHeader; + } + return {}; + case State::AccumulatingPacketHeader: + _header_accumulator.add(byte); + if (!_header_accumulator.received_whole_header()) { + return {}; + } + + if (!_header_accumulator.check_consistency()) { + _state = State::WaitingForPacketStartSymbol; + return Error::HeaderCorrupted; + } + + if (_header_accumulator.telegram_with_gcm_tag_length() > static_cast(_encrypted_telegram_accumulator.capacity())) { + _state = State::WaitingForPacketStartSymbol; + return Error::BufferOverflow; + } + + _state = State::AccumulatingTelegramWithGcmTag; + return {}; + case State::AccumulatingTelegramWithGcmTag: + _encrypted_telegram_accumulator.add(byte); + + if (static_cast(_encrypted_telegram_accumulator.number_of_accumulated_bytes()) != _header_accumulator.telegram_with_gcm_tag_length()) { + return {}; + } + + _state = State::WaitingForPacketStartSymbol; + + MbedTlsAes128GcmDecryptor decryptor; + + if (!decryptor.set_encryption_key(_encryption_key)) { + return Error::FailedToSetEncryptionKey; + } + + if (!decryptor.decrypt(_header_accumulator.nonce(), _encrypted_telegram_accumulator.telegram(), _encrypted_telegram_accumulator.tag(), + _raw_decrypted_telegram_buffer)) { + return Error::DecryptionFailed; + } + + return std::string_view(_raw_decrypted_telegram_buffer.data(), _encrypted_telegram_accumulator.telegram().size()); + } + + // Unreachable + return {}; + } + + // According to the specification, packets arrive once every 10 seconds. + // It is possible that some bytes are lost during transmission. + // Thus, you need to use a timeout to detect when a packet transmission finishes. + // In case the transmission finished, but the `process_byte` method did not return a complete packet, + // you need to call this method to reset the internal state machine. + void reset() { _state = State::WaitingForPacketStartSymbol; } + +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 {}; + } +}; + +inline const char* to_string(const EncryptedPacketAccumulator::Error error) { + switch (error) { + case EncryptedPacketAccumulator::Error::BufferOverflow: + return "BufferOverflow"; + case EncryptedPacketAccumulator::Error::HeaderCorrupted: + return "HeaderCorrupted"; + case EncryptedPacketAccumulator::Error::FailedToSetEncryptionKey: + return "FailedToSetEncryptionKey"; + case EncryptedPacketAccumulator::Error::DecryptionFailed: + return "DecryptionFailed"; + } + return "Unknown error"; +} + +inline const char* to_string(const EncryptedPacketAccumulator::SetEncryptionKeyError error) { + switch (error) { + case EncryptedPacketAccumulator::SetEncryptionKeyError::EncryptionKeyLengthIsNot32Bytes: + return "EncryptionKeyLengthIsNot32Bytes"; + case EncryptedPacketAccumulator::SetEncryptionKeyError::EncryptionKeyContainsNonHexSymbols: + return "EncryptionKeyContainsNonHexSymbols"; + } + return "Unknown error"; +} + +} diff --git a/src/arduino-dsmr-2/fields.h b/src/arduino-dsmr-2/fields.h new file mode 100644 index 0000000..3585c12 --- /dev/null +++ b/src/arduino-dsmr-2/fields.h @@ -0,0 +1,587 @@ +#pragma once + +#include "parser.h" +#include "util.h" + +#ifndef DSMR_GAS_MBUS_ID +#define DSMR_GAS_MBUS_ID 1 +#endif +#ifndef DSMR_WATER_MBUS_ID +#define DSMR_WATER_MBUS_ID 2 +#endif +#ifndef DSMR_THERMAL_MBUS_ID +#define DSMR_THERMAL_MBUS_ID 3 +#endif +#ifndef DSMR_SUB_MBUS_ID +#define DSMR_SUB_MBUS_ID 4 +#endif + +namespace arduino_dsmr_2 { + +// 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() { 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; + return res; + } +}; + +// A timestamp is essentially a string using YYMMDDhhmmssX format (where +// X is W or S for wintertime or summertime). Parsing this into a proper +// (UNIX) timestamp is hard to do generically. Parsing it into a +// single integer needs > 4 bytes top fit and isn't very useful (you +// cannot really do any calculation with those values). So we just parse +// into a string for now. +template +struct TimestampField : StringField {}; + +// Value that is parsed as a three-decimal float, but stored as an +// integer (by multiplying by 1000). Supports val() (or implicit cast to +// float) to get the original value, and int_val() to get the more +// efficient integer value. The unit() and int_unit() methods on +// FixedField return the corresponding units for these values. +struct FixedValue { + operator float() const { return val(); } + float val() const { return static_cast(_value) / 1000.0f; } + uint32_t int_val() const { return _value; } + + uint32_t _value; +}; + +// Floating point numbers in the message never have more than 3 decimal +// digits. To prevent inefficient floating point operations, we store +// them as a fixed-point number: an integer that stores the value in +// thousands. For example, a value of 1.234 kWh is stored as 1234. This +// effectively means that the integer value is the value in Wh. To allow +// automatic printing of these values, both the original unit and the +// integer unit is passed as a template argument. +template +struct FixedField : ParsedField { + ParseResult parse(const char* str, const char* end) { + // Check if the value is a float value, plus its expected unit type. + ParseResult res_float = NumParser::parse(3, _unit, str, end); + if (!res_float.err) { + static_cast(this)->val()._value = res_float.result; + return res_float; + } + // If not, then check for an int value, plus its expected unit type. + // This accomodates for some smart meters that publish int values instead + // of floats. 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. + ParseResult res_int = NumParser::parse(0, _int_unit, str, end); + if (!res_int.err) { + static_cast(this)->val()._value = res_int.result; + return res_int; + } + // If not, then return the initial error result for the float parsing step. + return res_float; + } + + static const char* unit() { return _unit; } + static const char* int_unit() { return _int_unit; } +}; + +struct TimestampedFixedValue : public FixedValue { + std::string 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); + } +}; + +// Take the last value of multiple 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; + } + + // (04.329*kW) Which is followed by the numerical value + return FixedField::parse(last, end); + } +}; + +// 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) { + 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); + } + return res; + } + + static const char* unit() { return _unit; } +}; + +// Take the average value of multiple 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; + } + + if (numberOfValues.result == 0) { + numberOfValues.next = end; // mark that we consumed all input + static_cast(this)->val()._value = 0; + return numberOfValues; + } + + // 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(0u); + average.next = res.next; + for (uint32_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(3, _unit, res.next, end); + if (monthValue.err) { + monthValue = NumParser::parse(0, _int_unit, res.next, end); + if (monthValue.err) + return monthValue; + } + + average.next = monthValue.next; + average.result += monthValue.result; + } + + average.result /= numberOfValues.result; + static_cast(this)->val()._value = average.result; + + return average; + } +}; + +// A RawField is not parsed, the entire value (including any parenthesis around it) is returned as a string. +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); + } +}; + +namespace fields { +struct units { + static inline constexpr char none[] = ""; + static inline constexpr char kWh[] = "kWh"; + static inline constexpr char Wh[] = "Wh"; + static inline constexpr char kW[] = "kW"; + static inline constexpr char W[] = "W"; + static inline constexpr char kV[] = "kV"; + static inline constexpr char V[] = "V"; + static inline constexpr char mV[] = "mV"; + static inline constexpr char kA[] = "kA"; + static inline constexpr char A[] = "A"; + static inline constexpr char mA[] = "mA"; + static inline constexpr char m3[] = "m3"; + static inline constexpr char dm3[] = "dm3"; + static inline constexpr char GJ[] = "GJ"; + static inline constexpr char MJ[] = "MJ"; + static inline constexpr char kvar[] = "kvar"; + static inline constexpr char var[] = "var"; + static inline constexpr char kvarh[] = "kvarh"; + static inline constexpr char varh[] = "varh"; + static inline constexpr char kVA[] = "kVA"; + static inline constexpr char VA[] = "VA"; + static inline constexpr char s[] = "s"; + static inline constexpr char Hz[] = "Hz"; + 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; \ + bool fieldname##_present = false; \ + static inline constexpr ObisId id = obis; \ + static inline constexpr char name[] = #fieldname; \ + value_t& val() { return fieldname; } \ + bool& present() { return fieldname##_present; } \ + } + +// 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); + +// 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); + +// Date-time stamp of the P1 message +DEFINE_FIELD(timestamp, std::string, ObisId(0, 0, 1, 0, 0), TimestampField); + +// Equipment identifier +DEFINE_FIELD(equipment_id, std::string, 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? +DEFINE_FIELD(energy_delivered_lux, FixedValue, ObisId(1, 0, 1, 8, 0), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff1, FixedValue, ObisId(1, 0, 1, 8, 1), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff2, FixedValue, ObisId(1, 0, 1, 8, 2), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 3) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff3, FixedValue, ObisId(1, 0, 1, 8, 3), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 4) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff4, FixedValue, ObisId(1, 0, 1, 8, 4), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Special for Lux) in 0,001 kWh +// TODO: by OBIS 1-0:2.8.0.255 IEC 62056 it should be Negative active energy (A+) total [kWh], should we rename it? +DEFINE_FIELD(energy_returned_lux, FixedValue, ObisId(1, 0, 2, 8, 0), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff1, FixedValue, ObisId(1, 0, 2, 8, 1), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff2, FixedValue, ObisId(1, 0, 2, 8, 2), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff3, FixedValue, ObisId(1, 0, 2, 8, 3), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff4, FixedValue, ObisId(1, 0, 2, 8, 4), FixedField, units::kWh, units::Wh); + +// Extra fields used for Luxembourg and Lithuania +DEFINE_FIELD(total_imported_energy, FixedValue, ObisId(1, 0, 3, 8, 0), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered to client (Tariff 1) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_delivered_tariff1, FixedValue, ObisId(1, 0, 3, 8, 1), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered to client (Tariff 2) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_delivered_tariff2, FixedValue, ObisId(1, 0, 3, 8, 2), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered to client (Tariff 3) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_delivered_tariff3, FixedValue, ObisId(1, 0, 3, 8, 3), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered to client (Tariff 4) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_delivered_tariff4, FixedValue, ObisId(1, 0, 3, 8, 4), FixedField, units::kvarh, units::varh); + +DEFINE_FIELD(total_exported_energy, FixedValue, ObisId(1, 0, 4, 8, 0), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered by client (Tariff 1) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_returned_tariff1, FixedValue, ObisId(1, 0, 4, 8, 1), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered by client (Tariff 2) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_returned_tariff2, FixedValue, ObisId(1, 0, 4, 8, 2), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered by client (Tariff 3) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_returned_tariff3, FixedValue, ObisId(1, 0, 4, 8, 3), FixedField, units::kvarh, units::varh); +// Meter Reading Reactive energy delivered by client (Tariff 4) in 0,001 kvarh +DEFINE_FIELD(reactive_energy_returned_tariff4, FixedValue, ObisId(1, 0, 4, 8, 4), FixedField, units::kvarh, units::varh); + +// Specific fields used for Switzerland +// Meter Reading electricity delivered to client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff1_ch, FixedValue, ObisId(1, 1, 1, 8, 1), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered to client (Tariff 2) in 0,001 kWh +DEFINE_FIELD(energy_delivered_tariff2_ch, FixedValue, ObisId(1, 1, 1, 8, 2), FixedField, units::kWh, units::Wh); +// Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh +DEFINE_FIELD(energy_returned_tariff1_ch, FixedValue, ObisId(1, 1, 2, 8, 1), FixedField, units::kWh, units::Wh); +// 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); + +// 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); + +// 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); +// Actual electricity power received (-P) in 1 Watt resolution +DEFINE_FIELD(power_returned, FixedValue, ObisId(1, 0, 2, 7, 0), FixedField, units::kW, units::W); + +// Extra fields used for Luxembourg and Lithuania +DEFINE_FIELD(reactive_power_delivered, FixedValue, ObisId(1, 0, 3, 7, 0), FixedField, units::kvar, units::var); +DEFINE_FIELD(reactive_power_returned, FixedValue, ObisId(1, 0, 4, 7, 0), FixedField, units::kvar, units::var); + +// Specific fields used for Switzerland +// Actual electricity power delivered (+P) in 1 Watt resolution +DEFINE_FIELD(power_delivered_ch, FixedValue, ObisId(1, 1, 1, 7, 0), FixedField, units::kW, units::W); +// Actual electricity power received (-P) in 1 Watt resolution +DEFINE_FIELD(power_returned_ch, FixedValue, ObisId(1, 1, 2, 7, 0), FixedField, units::kW, units::W); + +// The actual threshold Electricity in kW. Removed in 4.0.7 / 4.2.2 / 5.0 +DEFINE_FIELD(electricity_threshold, FixedValue, ObisId(0, 0, 17, 0, 0), FixedField, units::kW, units::W); + +// Switch position Electricity (in/out/enabled). Removed in 4.0.7 / 4.2.2 / 5.0 +DEFINE_FIELD(electricity_switch_position, uint8_t, ObisId(0, 0, 96, 3, 10), IntField, units::none); + +// Number of power failures in any phase +DEFINE_FIELD(electricity_failures, uint32_t, ObisId(0, 0, 96, 7, 21), IntField, units::none); +// Number of long power failures in any phase +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); + +// Number of voltage sags in phase L1 +DEFINE_FIELD(electricity_sags_l1, uint32_t, ObisId(1, 0, 32, 32, 0), IntField, units::none); +DEFINE_FIELD(voltage_sag_time_l1, uint32_t, ObisId(1, 0, 32, 33, 0), IntField, units::s); +DEFINE_FIELD(voltage_sag_l1, uint32_t, ObisId(1, 0, 32, 34, 0), IntField, units::V); + +// Number of voltage sags in phase L2 (polyphase meters only) +DEFINE_FIELD(electricity_sags_l2, uint32_t, ObisId(1, 0, 52, 32, 0), IntField, units::none); +DEFINE_FIELD(voltage_sag_time_l2, uint32_t, ObisId(1, 0, 52, 33, 0), IntField, units::s); +DEFINE_FIELD(voltage_sag_l2, uint32_t, ObisId(1, 0, 52, 34, 0), IntField, units::V); + +// Number of voltage sags in phase L3 (polyphase meters only) +DEFINE_FIELD(electricity_sags_l3, uint32_t, ObisId(1, 0, 72, 32, 0), IntField, units::none); +DEFINE_FIELD(voltage_sag_time_l3, uint32_t, ObisId(1, 0, 72, 33, 0), IntField, units::s); +DEFINE_FIELD(voltage_sag_l3, uint32_t, ObisId(1, 0, 72, 34, 0), IntField, units::V); + +// Number of voltage swells in phase L1 +DEFINE_FIELD(electricity_swells_l1, uint32_t, ObisId(1, 0, 32, 36, 0), IntField, units::none); +DEFINE_FIELD(voltage_swell_time_l1, uint32_t, ObisId(1, 0, 32, 37, 0), IntField, units::s); +DEFINE_FIELD(voltage_swell_l1, uint32_t, ObisId(1, 0, 32, 38, 0), IntField, units::V); + +// Number of voltage swells in phase L2 (polyphase meters only) +DEFINE_FIELD(electricity_swells_l2, uint32_t, ObisId(1, 0, 52, 36, 0), IntField, units::none); +DEFINE_FIELD(voltage_swell_time_l2, uint32_t, ObisId(1, 0, 52, 37, 0), IntField, units::s); +DEFINE_FIELD(voltage_swell_l2, uint32_t, ObisId(1, 0, 52, 38, 0), IntField, units::V); + +// Number of voltage swells in phase L3 (polyphase meters only) +DEFINE_FIELD(electricity_swells_l3, uint32_t, ObisId(1, 0, 72, 36, 0), IntField, units::none); +DEFINE_FIELD(voltage_swell_time_l3, uint32_t, ObisId(1, 0, 72, 37, 0), IntField, units::s); +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); +// 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); + +// 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) +DEFINE_FIELD(voltage_l1, FixedValue, ObisId(1, 0, 32, 7, 0), FixedField, units::V, units::mV); +DEFINE_FIELD(voltage_avg_l1, FixedValue, ObisId(1, 0, 32, 24, 0), FixedField, units::V, units::mV); +// Instantaneous voltage L2 in 0.1V resolution (Note: Spec says V +// resolution in comment, but 0.1V resolution in format spec. Added in 5.0) +DEFINE_FIELD(voltage_l2, FixedValue, ObisId(1, 0, 52, 7, 0), FixedField, units::V, units::mV); +DEFINE_FIELD(voltage_avg_l2, FixedValue, ObisId(1, 0, 52, 24, 0), FixedField, units::V, units::mV); +// Instantaneous voltage L3 in 0.1V resolution (Note: Spec says V +// resolution in comment, but 0.1V resolution in format spec. Added in 5.0) +DEFINE_FIELD(voltage_l3, FixedValue, ObisId(1, 0, 72, 7, 0), FixedField, units::V, units::mV); +DEFINE_FIELD(voltage_avg_l3, FixedValue, ObisId(1, 0, 72, 24, 0), FixedField, units::V, units::mV); + +// Instantaneous voltage (U) [V] +DEFINE_FIELD(voltage, FixedValue, ObisId(1, 0, 12, 7, 0), FixedField, units::V, units::mV); +// Frequency [Hz] +DEFINE_FIELD(frequency, FixedValue, ObisId(1, 0, 14, 7, 0), FixedField, units::kHz, units::Hz); +// Absolute active instantaneous power (|A|) [kW] +DEFINE_FIELD(abs_power, FixedValue, ObisId(1, 0, 15, 7, 0), FixedField, units::kW, units::W); + +// Instantaneous current L1 in A resolution +DEFINE_FIELD(current_l1, FixedValue, ObisId(1, 0, 31, 7, 0), FixedField, units::A, units::mA); +DEFINE_FIELD(current_fuse_l1, FixedValue, ObisId(1, 0, 31, 4, 0), FixedField, units::A, units::mA); +// Instantaneous current L2 in A resolution +DEFINE_FIELD(current_l2, FixedValue, ObisId(1, 0, 51, 7, 0), FixedField, units::A, units::mA); +DEFINE_FIELD(current_fuse_l2, FixedValue, ObisId(1, 0, 51, 4, 0), FixedField, units::A, units::mA); +// Instantaneous current L3 in A resolution +DEFINE_FIELD(current_l3, FixedValue, ObisId(1, 0, 71, 7, 0), FixedField, units::A, units::mA); +DEFINE_FIELD(current_fuse_l3, FixedValue, ObisId(1, 0, 71, 4, 0), FixedField, units::A, units::mA); + +// Instantaneous active power L1 (+P) in W resolution +DEFINE_FIELD(power_delivered_l1, FixedValue, ObisId(1, 0, 21, 7, 0), FixedField, units::kW, units::W); +// Instantaneous active power L2 (+P) in W resolution +DEFINE_FIELD(power_delivered_l2, FixedValue, ObisId(1, 0, 41, 7, 0), FixedField, units::kW, units::W); +// Instantaneous active power L3 (+P) in W resolution +DEFINE_FIELD(power_delivered_l3, FixedValue, ObisId(1, 0, 61, 7, 0), FixedField, units::kW, units::W); + +// Instantaneous active power L1 (-P) in W resolution +DEFINE_FIELD(power_returned_l1, FixedValue, ObisId(1, 0, 22, 7, 0), FixedField, units::kW, units::W); +// Instantaneous active power L2 (-P) in W resolution +DEFINE_FIELD(power_returned_l2, FixedValue, ObisId(1, 0, 42, 7, 0), FixedField, units::kW, units::W); +// Instantaneous active power L3 (-P) in W resolution +DEFINE_FIELD(power_returned_l3, FixedValue, ObisId(1, 0, 62, 7, 0), FixedField, units::kW, units::W); + +// Instantaneous current (I) [A] +DEFINE_FIELD(current, FixedValue, ObisId(1, 0, 11, 7, 0), FixedField, units::A, units::mA); +// Instantaneous current (I) in neutral [A] +DEFINE_FIELD(current_n, FixedValue, ObisId(1, 0, 91, 7, 0), FixedField, units::A, units::mA); +// Instantaneous sum of all phase current's (I) [A] +DEFINE_FIELD(current_sum, FixedValue, ObisId(1, 0, 90, 7, 0), FixedField, units::A, units::mA); + +// LUX and Lithuania + +// IEC 62056 define the unit of reactive power as kvar. Some meters e.g. L+G E360 uses mixed case kVar +// Instantaneous reactive power L1 (+Q) in W resolution +DEFINE_FIELD(reactive_power_delivered_l1, FixedValue, ObisId(1, 0, 23, 7, 0), FixedField, units::kvar, units::var); +// Instantaneous reactive power L2 (+Q) in W resolution +DEFINE_FIELD(reactive_power_delivered_l2, FixedValue, ObisId(1, 0, 43, 7, 0), FixedField, units::kvar, units::var); +// Instantaneous reactive power L3 (+Q) in W resolution +DEFINE_FIELD(reactive_power_delivered_l3, FixedValue, ObisId(1, 0, 63, 7, 0), FixedField, units::kvar, units::var); +// Instantaneous reactive power L1 (-Q) in W resolution +DEFINE_FIELD(reactive_power_returned_l1, FixedValue, ObisId(1, 0, 24, 7, 0), FixedField, units::kvar, units::var); +// Instantaneous reactive power L2 (-Q) in W resolution +DEFINE_FIELD(reactive_power_returned_l2, FixedValue, ObisId(1, 0, 44, 7, 0), FixedField, units::kvar, units::var); +// Instantaneous reactive power L3 (-Q) in W resolution +DEFINE_FIELD(reactive_power_returned_l3, FixedValue, ObisId(1, 0, 64, 7, 0), FixedField, units::kvar, units::var); + +// Apparent instantaneous power (+S) in kVA resolution +DEFINE_FIELD(apparent_delivery_power, FixedValue, ObisId(1, 0, 9, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L1 (+S) in kVA resolution +DEFINE_FIELD(apparent_delivery_power_l1, FixedValue, ObisId(1, 0, 29, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L2 (+S) in kVA resolution +DEFINE_FIELD(apparent_delivery_power_l2, FixedValue, ObisId(1, 0, 49, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L3 (+S) in kVA resolution +DEFINE_FIELD(apparent_delivery_power_l3, FixedValue, ObisId(1, 0, 69, 7, 0), FixedField, units::kVA, units::VA); + +// Apparent instantaneous power (-S) in kVA resolution +DEFINE_FIELD(apparent_return_power, FixedValue, ObisId(1, 0, 10, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L1 (-S) in kVA resolution +DEFINE_FIELD(apparent_return_power_l1, FixedValue, ObisId(1, 0, 30, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L2 (-S) in kVA resolution +DEFINE_FIELD(apparent_return_power_l2, FixedValue, ObisId(1, 0, 50, 7, 0), FixedField, units::kVA, units::VA); +// Apparent instantaneous power L3 (-S) in kVA resolution +DEFINE_FIELD(apparent_return_power_l3, FixedValue, ObisId(1, 0, 70, 7, 0), FixedField, units::kVA, units::VA); + +// Active Demand Avg3 Plus in W resolution +DEFINE_FIELD(active_demand_power, FixedValue, ObisId(1, 0, 1, 24, 0), FixedField, units::kW, units::W); +// Active Demand Avg3 Net in W resolution +// TODO: 1-0.16.24.0.255 can have negative value, this library is not ready for negative numbers. +// DEFINE_FIELD(active_demand_net, int32_t, ObisId(1, 0, 16, 24, 0), IntField, units::kW); +// Active Demand Avg3 Absolute in W resolution +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); + +// Equipment identifier (Gas) +DEFINE_FIELD(gas_equipment_id, std::string, ObisId(0, 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); + +// 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); + +// 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); +// _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); + +// Device-Type +DEFINE_FIELD(thermal_device_type, uint16_t, ObisId(0, 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); + +// 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); + +// 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); + +// Device-Type +DEFINE_FIELD(water_device_type, uint16_t, ObisId(0, 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); + +// 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); + +// 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); + +// Device-Type +DEFINE_FIELD(sub_device_type, uint16_t, ObisId(0, 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); + +// 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); + +// 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); + +// 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); +DEFINE_FIELD(active_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 2, 4, 0), FixedField, units::kW, units::W); +DEFINE_FIELD(reactive_energy_import_current_average_demand, FixedValue, ObisId(1, 0, 3, 4, 0), FixedField, units::kvar, units::kvar); +DEFINE_FIELD(reactive_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 4, 4, 0), FixedField, units::kvar, units::kvar); +DEFINE_FIELD(apparent_energy_import_current_average_demand, FixedValue, ObisId(1, 0, 9, 4, 0), FixedField, units::kVA, units::VA); +DEFINE_FIELD(apparent_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 10, 4, 0), FixedField, units::kVA, units::VA); +DEFINE_FIELD(active_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 1, 5, 0), FixedField, units::kW, units::W); +DEFINE_FIELD(active_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 2, 5, 0), FixedField, units::kW, units::W); +DEFINE_FIELD(reactive_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 3, 5, 0), FixedField, units::kvar, units::kvar); +DEFINE_FIELD(reactive_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 4, 5, 0), FixedField, units::kvar, units::kvar); +DEFINE_FIELD(apparent_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 9, 5, 0), FixedField, units::kVA, units::VA); +DEFINE_FIELD(apparent_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 10, 5, 0), FixedField, units::kVA, units::VA); + +// Maximum energy consumption from the current month +DEFINE_FIELD(active_energy_import_maximum_demand_running_month, TimestampedFixedValue, ObisId(1, 0, 1, 6, 0), TimestampedFixedField, units::kW, units::W); +// Maximum energy consumption from the last 13 months +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, FixedValue, ObisId(1, 0, 0, 2, 0), FixedField, units::none, units::none); +DEFINE_FIELD(fw_core_checksum, std::string, ObisId(1, 0, 0, 2, 8), StringField, 0, 8); +// Image Module Version and checksum +DEFINE_FIELD(fw_module_version, FixedValue, ObisId(1, 1, 0, 2, 0), FixedField, units::none, units::none); +DEFINE_FIELD(fw_module_checksum, std::string, ObisId(1, 1, 0, 2, 8), StringField, 0, 8); + +} +} diff --git a/src/arduino-dsmr-2/packet_accumulator.h b/src/arduino-dsmr-2/packet_accumulator.h new file mode 100644 index 0000000..b998fed --- /dev/null +++ b/src/arduino-dsmr-2/packet_accumulator.h @@ -0,0 +1,182 @@ +#pragma once +#include "util.h" +#include +#include +#include +#include + +namespace arduino_dsmr_2 { + +// Receives unencrypted DSMR packets. +class PacketAccumulator { + class DsmrPacketBuffer { + std::span _buffer; + std::size_t _packetSize = 0; + + public: + explicit DsmrPacketBuffer(std::span buffer) : _buffer{buffer} {} + + std::string_view packet() const { return std::string_view(_buffer.data(), _packetSize); } + + void add(char byte) { + _buffer[_packetSize] = byte; + _packetSize++; + } + + bool has_space() const { return _packetSize < _buffer.size(); } + + uint16_t calculate_crc16() const { + uint16_t crc = 0; + for (std::size_t i = 0; i < _packetSize; ++i) { + crc ^= static_cast(_buffer[i]); + for (std::size_t bit = 0; bit < 8; bit++) { + if (crc & 1) + crc = (crc >> 1) ^ 0xa001; + else + crc = (crc >> 1); + } + } + return crc; + } + }; + + class CrcAccumulator { + uint16_t crc = 0; + size_t amount_of_crc_nibbles = 0; + + public: + bool add_to_crc(char byte) { + if (byte >= '0' && byte <= '9') { + byte = byte - '0'; + } else if (byte >= 'A' && byte <= 'F') { + byte = static_cast(byte - 'A' + 10); + } else if (byte >= 'a' && byte <= 'f') { + byte = static_cast(byte - 'a' + 10); + } else { + return false; + } + + crc = static_cast((crc << 4) | (byte & 0xF)); + amount_of_crc_nibbles++; + return true; + } + + bool has_full_crc() const { return amount_of_crc_nibbles == 4; } + + uint16_t crc_value() const { return crc; } + }; + + enum class State { WaitingForPacketStartSymbol, WaitingForPacketEndSymbol, WaitingForCrc }; + State _state = State::WaitingForPacketStartSymbol; + std::span _raw_buffer; + DsmrPacketBuffer _buf; + CrcAccumulator _crc_accumulator; + bool _check_crc; + +public: + enum class Error { + BufferOverflow, + PacketStartSymbolInPacket, + IncorrectCrcCharacter, + CrcMismatch, + }; + + class Result { + 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) {} + + Result process_byte(const char byte) { + if (!_buf.has_space()) { + _buf = DsmrPacketBuffer(_raw_buffer); + _state = State::WaitingForPacketStartSymbol; + if (byte != '/') { + return Error::BufferOverflow; + } + } + + if (byte == '/') { + _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 {}; + } + + switch (_state) { + case State::WaitingForPacketStartSymbol: + return {}; + + case State::WaitingForPacketEndSymbol: + _buf.add(byte); + + if (byte != '!') { + return {}; + } + + if (!_check_crc) { + _state = State::WaitingForPacketStartSymbol; + return Result(_buf.packet()); + } + + _state = State::WaitingForCrc; + _crc_accumulator = CrcAccumulator(); + return {}; + + case State::WaitingForCrc: + if (!_crc_accumulator.add_to_crc(byte)) { + _state = State::WaitingForPacketStartSymbol; + return Error::IncorrectCrcCharacter; + } + + if (!_crc_accumulator.has_full_crc()) { + return {}; + } + + _state = State::WaitingForPacketStartSymbol; + + if (_crc_accumulator.crc_value() == _buf.calculate_crc16()) { + return _buf.packet(); + } + + return Error::CrcMismatch; + } + + // unreachable + return {}; + } +}; + +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/arduino-dsmr-2/parser.h b/src/arduino-dsmr-2/parser.h new file mode 100644 index 0000000..164c769 --- /dev/null +++ b/src/arduino-dsmr-2/parser.h @@ -0,0 +1,411 @@ +#pragma once + +#include "util.h" + +namespace arduino_dsmr_2 { + +// uses polynomial x^16+x^15+x^2+1 +inline uint16_t crc16_update(uint16_t crc, uint8_t data) { + crc ^= data; + for (size_t i = 0; i < 8; ++i) { + if (crc & 1) { + crc = (crc >> 1) ^ 0xA001; + } else { + crc = (crc >> 1); + } + } + return crc; +} + +// ParsedData is a template for the result of parsing a Dsmr P1 message. +// 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. +template +struct ParsedData : Ts... { + ParseResult parse_line(const ObisId& obisId, const char* str, const char* end) { + ParseResult res; + const auto& try_one = [&](auto& field) -> bool { + using FieldType = std::decay_t; + if (obisId != FieldType::id) { + return false; + } + + if (field.present()) + res = ParseResult().fail("Duplicate field", str); + else { + field.present() = true; + res = field.parse(str, end); + } + return true; + }; + + const bool found = (try_one(static_cast(*this)) || ...); + return found ? res : ParseResult().until(str); + } + + template + void applyEach(F&& f) { + (Ts::apply(f), ...); + } + + bool all_present() { return (Ts::present() && ...); } +}; + +struct StringParser { + 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; + + 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 ) + } +}; + +static constexpr char INVALID_NUMBER[] = "Invalid number"; +static constexpr char INVALID_UNIT[] = "Invalid unit"; + +struct NumParser { + 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); + + const char* num_start = str + 1; // Skip ( + const char* num_end = num_start; + + uint32_t value = 0; + + // Parse integer part + while (num_end < end && !strchr("*.)", *num_end)) { + if (*num_end < '0' || *num_end > '9') + return res.fail(INVALID_NUMBER, num_end); + value *= 10; + value += static_cast(*num_end - '0'); + ++num_end; + } + + // Parse decimal part, if any + if (max_decimals && num_end < end && *num_end == '.') { + ++num_end; + + while (num_end < end && !strchr("*)", *num_end) && max_decimals) { + max_decimals--; + if (*num_end < '0' || *num_end > '9') + return res.fail(INVALID_NUMBER, num_end); + value *= 10; + value += static_cast(*num_end - '0'); + ++num_end; + } + } + + // 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 && (num_end >= end || (*num_end != '*' && *num_end != '.')) && value == 0) { + num_end = std::find(num_end, end, ')'); + } + + // If a unit was passed, check that the unit in the messages + // messages the unit passed. + else if (unit && *unit) { + if (num_end >= end || *num_end != '*') + return res.fail("Missing unit", num_end); + const char* unit_start = ++num_end; // skip * + while (num_end < end && *num_end != ')' && *unit) { + // Next character in units do not match? + if (std::tolower(static_cast(*num_end++)) != std::tolower(static_cast(*unit++))) + return res.fail(INVALID_UNIT, unit_start); + } + // At the end of the message unit, but not the passed unit? + if (*unit) + return res.fail(INVALID_UNIT, unit_start); + } + + if (num_end >= end || *num_end != ')') + return res.fail("Extra data", num_end); + + return res.succeed(value).until(num_end + 1); // Skip ) + } +}; + +struct ObisIdParser { + static ParseResult parse(const char* str, const char* end) { + // 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; + } + ++res.next; + } + + if (res.next == str) + return res.fail("OBIS id Empty", str); + + for (++part; part < 6; ++part) + id.v[part] = 255; + + return res; + } +}; + +struct CrcParser { +private: + static const size_t CRC_LEN = 4; + + static bool hex_nibble(char c, uint8_t& out) { + 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); + 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); + } + + res.next = str + CRC_LEN; + return res.succeed(value); + } +}; + +struct P1Parser { + + // 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; + } + + // 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; + } + + // 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. + 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; + + // 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; + break; + } + ++line_end; + } + + // Parse data lines + // We need to track brackets to handle cases like: + // 0-0:96.13.0(303132333435 + // 30313233343) + bool open_bracket_found = false; + while (line_end < end) { + char c = *line_end; + + if (c == '(') { + if (open_bracket_found) { + return ParseResult().fail("Unexpected '(' symbol", line_end); + } + open_bracket_found = true; + } else if (c == ')') { + if (!open_bracket_found) { + return ParseResult().fail("Unexpected ')' symbol", line_end); + } + open_bracket_found = 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; + } + } + + ++line_end; + } + + if (line_end != line_start) + return ParseResult().fail("Last dataline not CRLF terminated", line_end); + + return ParseResult(); + } + + 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); + } +}; + +} diff --git a/src/arduino-dsmr-2/util.h b/src/arduino-dsmr-2/util.h new file mode 100644 index 0000000..8504cb0 --- /dev/null +++ b/src/arduino-dsmr-2/util.h @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace arduino_dsmr_2 { + +class NonCopyable { +protected: + NonCopyable() = default; + ~NonCopyable() = default; + +public: + NonCopyable(NonCopyable&&) = default; + NonCopyable& operator=(NonCopyable&&) = default; + + NonCopyable(const NonCopyable&) = delete; + NonCopyable& operator=(const NonCopyable&) = delete; +}; + +class NonCopyableAndNonMovable : NonCopyable { +protected: + NonCopyableAndNonMovable() = default; + ~NonCopyableAndNonMovable() = default; + +public: + NonCopyableAndNonMovable(NonCopyableAndNonMovable&&) = delete; + 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); + } +}; + +// partial specialization for void result +template +struct _ParseResult {}; + +// Actual ParseResult class +template +struct ParseResult : public _ParseResult, T> { + const char* next = nullptr; + const char* err = nullptr; + const char* ctx = nullptr; + + ParseResult& fail(const char* error, const char* context = nullptr) { + this->err = error; + this->ctx = context; + return *this; + } + ParseResult& until(const char* nextToken) { + this->next = nextToken; + return *this; + } + ParseResult() = default; + + template + ParseResult(const ParseResult& other) : 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 { + std::array v{}; + constexpr 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; +}; + +} diff --git a/src/dsmr.h b/src/dsmr.h deleted file mode 100644 index ced73ec..0000000 --- a/src/dsmr.h +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * Main included file. If you include this, you'll get everything, - * imported into global scope - */ - -#ifndef DSMR_INCLUDE_DSMR_H -#define DSMR_INCLUDE_DSMR_H - -#include "dsmr/parser.h" -#include "dsmr/reader.h" -#include "dsmr/fields.h" - -// Allow using everything without the namespace prefixes -using namespace dsmr; -using namespace dsmr::fields; - -#endif // DSMR_INCLUDE_DSMR_H diff --git a/src/dsmr/crc16.h b/src/dsmr/crc16.h deleted file mode 100644 index 276dc9e..0000000 --- a/src/dsmr/crc16.h +++ /dev/null @@ -1,107 +0,0 @@ -/* CRC compatibility, adapted from the Teensy 3 core at: - https://github.com/PaulStoffregen/cores/tree/master/teensy3 - which was in turn adapted by Paul Stoffregen from the C-only comments here: - http://svn.savannah.nongnu.org/viewvc/trunk/avr-libc/include/util/crc16.h?revision=933&root=avr-libc&view=markup */ - -/* Copyright (c) 2002, 2003, 2004 Marek Michalkiewicz - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. - - * Neither the name of the copyright holders nor the names of - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE - LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. */ - -#pragma once - -#include - -static inline uint16_t _crc16_update(uint16_t crc, uint8_t data) __attribute__((always_inline, unused)); -static inline uint16_t _crc16_update(uint16_t crc, uint8_t data) -{ - unsigned int i; - - crc ^= data; - for (i = 0; i < 8; ++i) - { - if (crc & 1) - { - crc = (crc >> 1) ^ 0xA001; - } - else - { - crc = (crc >> 1); - } - } - return crc; -} - -static inline uint16_t _crc_xmodem_update(uint16_t crc, uint8_t data) __attribute__((always_inline, unused)); -static inline uint16_t _crc_xmodem_update(uint16_t crc, uint8_t data) -{ - unsigned int i; - - crc = crc ^ ((uint16_t)data << 8); - for (i = 0; i < 8; i++) - { - if (crc & 0x8000) - { - crc = (crc << 1) ^ 0x1021; - } - else - { - crc <<= 1; - } - } - return crc; -} - -static inline uint16_t _crc_ccitt_update(uint16_t crc, uint8_t data) __attribute__((always_inline, unused)); -static inline uint16_t _crc_ccitt_update(uint16_t crc, uint8_t data) -{ - data ^= (crc & 255); - data ^= data << 4; - - return ((((uint16_t)data << 8) | (crc >> 8)) ^ (uint8_t)(data >> 4) ^ ((uint16_t)data << 3)); -} - -static inline uint8_t _crc_ibutton_update(uint8_t crc, uint8_t data) __attribute__((always_inline, unused)); -static inline uint8_t _crc_ibutton_update(uint8_t crc, uint8_t data) -{ - unsigned int i; - - crc = crc ^ data; - for (i = 0; i < 8; i++) - { - if (crc & 0x01) - { - crc = (crc >> 1) ^ 0x8C; - } - else - { - crc >>= 1; - } - } - return crc; -} diff --git a/src/dsmr/fields.cpp b/src/dsmr/fields.cpp deleted file mode 100644 index 77c544c..0000000 --- a/src/dsmr/fields.cpp +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * Field parsing functions - */ - -#include "fields.h" - -using namespace dsmr; -using namespace dsmr::fields; - -// Since C++11 it is possible to define the initial values for static -// const members in the class declaration, but if their address is -// taken, they still need a normal definition somewhere (to allocate -// storage). -constexpr char units::none[]; -constexpr char units::kWh[]; -constexpr char units::Wh[]; -constexpr char units::kW[]; -constexpr char units::W[]; -constexpr char units::kV[]; -constexpr char units::V[]; -constexpr char units::mV[]; -constexpr char units::kA[]; -constexpr char units::A[]; -constexpr char units::mA[]; -constexpr char units::m3[]; -constexpr char units::dm3[]; -constexpr char units::GJ[]; -constexpr char units::MJ[]; -constexpr char units::kvar[]; -constexpr char units::kvarh[]; -constexpr char units::kVA[]; -constexpr char units::VA[]; -constexpr char units::s[]; -constexpr char units::Hz[]; -constexpr char units::kHz[]; - -constexpr ObisId identification::id; -constexpr char identification::name[]; - -constexpr ObisId p1_version::id; -constexpr char p1_version::name[]; - -/* extra field for Belgium */ -constexpr ObisId p1_version_be::id; -constexpr char p1_version_be::name[]; - -/* extra field for Switzerland */ -constexpr ObisId p1_version_ch::id; -constexpr char p1_version_ch::name[]; - -constexpr ObisId timestamp::id; -constexpr char timestamp::name[]; - -constexpr ObisId equipment_id::id; -constexpr char equipment_id::name[]; - -/* extra for Lux */ -constexpr ObisId energy_delivered_lux::id; -constexpr char energy_delivered_lux::name[]; - -constexpr ObisId energy_delivered_tariff1::id; -constexpr char energy_delivered_tariff1::name[]; - -constexpr ObisId energy_delivered_tariff2::id; -constexpr char energy_delivered_tariff2::name[]; - -constexpr ObisId energy_delivered_tariff3::id; -constexpr char energy_delivered_tariff3::name[]; - -constexpr ObisId energy_delivered_tariff4::id; -constexpr char energy_delivered_tariff4::name[]; - -constexpr ObisId reactive_energy_delivered_tariff1::id; -constexpr char reactive_energy_delivered_tariff1::name[]; - -constexpr ObisId reactive_energy_delivered_tariff2::id; -constexpr char reactive_energy_delivered_tariff2::name[]; - -constexpr ObisId reactive_energy_delivered_tariff3::id; -constexpr char reactive_energy_delivered_tariff3::name[]; - -constexpr ObisId reactive_energy_delivered_tariff4::id; -constexpr char reactive_energy_delivered_tariff4::name[]; - -/* specific for Switzerland */ -constexpr ObisId energy_delivered_tariff1_ch::id; -constexpr char energy_delivered_tariff1_ch::name[]; - -/* specific for Switzerland */ -constexpr ObisId energy_delivered_tariff2_ch::id; -constexpr char energy_delivered_tariff2_ch::name[]; - -/* extra for Lux */ -constexpr ObisId energy_returned_lux::id; -constexpr char energy_returned_lux::name[]; - -constexpr ObisId energy_returned_tariff1::id; -constexpr char energy_returned_tariff1::name[]; - -constexpr ObisId energy_returned_tariff2::id; -constexpr char energy_returned_tariff2::name[]; - -constexpr ObisId energy_returned_tariff3::id; -constexpr char energy_returned_tariff3::name[]; - -constexpr ObisId energy_returned_tariff4::id; -constexpr char energy_returned_tariff4::name[]; - -constexpr ObisId reactive_energy_returned_tariff1::id; -constexpr char reactive_energy_returned_tariff1::name[]; - -constexpr ObisId reactive_energy_returned_tariff2::id; -constexpr char reactive_energy_returned_tariff2::name[]; - -constexpr ObisId reactive_energy_returned_tariff3::id; -constexpr char reactive_energy_returned_tariff3::name[]; - -constexpr ObisId reactive_energy_returned_tariff4::id; -constexpr char reactive_energy_returned_tariff4::name[]; - -/* specific for Switzerland */ -constexpr ObisId energy_returned_tariff1_ch::id; -constexpr char energy_returned_tariff1_ch::name[]; - -/* specific for Switzerland */ -constexpr ObisId energy_returned_tariff2_ch::id; -constexpr char energy_returned_tariff2_ch::name[]; - -/* extra for Lux */ -constexpr ObisId total_imported_energy::id; -constexpr char total_imported_energy::name[]; - -/* extra for Lux */ -constexpr ObisId total_exported_energy::id; -constexpr char total_exported_energy::name[]; - -/* extra for Lux */ -constexpr ObisId reactive_power_delivered::id; -constexpr char reactive_power_delivered::name[]; - -/* extra for Lux */ -constexpr ObisId reactive_power_returned::id; -constexpr char reactive_power_returned::name[]; - -constexpr ObisId electricity_tariff::id; -constexpr char electricity_tariff::name[]; - -constexpr ObisId power_delivered::id; -constexpr char power_delivered::name[]; - -constexpr ObisId power_returned::id; -constexpr char power_returned::name[]; - -/* specific for Switzerland */ -constexpr ObisId power_delivered_ch::id; -constexpr char power_delivered_ch::name[]; - -/* specific for Switzerland */ -constexpr ObisId power_returned_ch::id; -constexpr char power_returned_ch::name[]; - -constexpr ObisId electricity_threshold::id; -constexpr char electricity_threshold::name[]; - -constexpr ObisId electricity_switch_position::id; -constexpr char electricity_switch_position::name[]; - -constexpr ObisId electricity_failures::id; -constexpr char electricity_failures::name[]; - -constexpr ObisId electricity_long_failures::id; -constexpr char electricity_long_failures::name[]; - -constexpr ObisId electricity_failure_log::id; -constexpr char electricity_failure_log::name[]; - -constexpr ObisId electricity_sags_l1::id; -constexpr char electricity_sags_l1::name[]; - -constexpr ObisId electricity_sags_l2::id; -constexpr char electricity_sags_l2::name[]; - -constexpr ObisId electricity_sags_l3::id; -constexpr char electricity_sags_l3::name[]; - -constexpr ObisId voltage_sag_time_l1::id; -constexpr char voltage_sag_time_l1::name[]; - -constexpr ObisId voltage_sag_time_l2::id; -constexpr char voltage_sag_time_l2::name[]; - -constexpr ObisId voltage_sag_time_l3::id; -constexpr char voltage_sag_time_l3::name[]; - -constexpr ObisId voltage_sag_l1::id; -constexpr char voltage_sag_l1::name[]; - -constexpr ObisId voltage_sag_l2::id; -constexpr char voltage_sag_l2::name[]; - -constexpr ObisId voltage_sag_l3::id; -constexpr char voltage_sag_l3::name[]; - -constexpr ObisId electricity_swells_l1::id; -constexpr char electricity_swells_l1::name[]; - -constexpr ObisId electricity_swells_l2::id; -constexpr char electricity_swells_l2::name[]; - -constexpr ObisId electricity_swells_l3::id; -constexpr char electricity_swells_l3::name[]; - -constexpr ObisId voltage_swell_time_l1::id; -constexpr char voltage_swell_time_l1::name[]; - -constexpr ObisId voltage_swell_time_l2::id; -constexpr char voltage_swell_time_l2::name[]; - -constexpr ObisId voltage_swell_time_l3::id; -constexpr char voltage_swell_time_l3::name[]; - -constexpr ObisId voltage_swell_l1::id; -constexpr char voltage_swell_l1::name[]; - -constexpr ObisId voltage_swell_l2::id; -constexpr char voltage_swell_l2::name[]; - -constexpr ObisId voltage_swell_l3::id; -constexpr char voltage_swell_l3::name[]; - -constexpr ObisId message_short::id; -constexpr char message_short::name[]; - -constexpr ObisId message_long::id; -constexpr char message_long::name[]; - -constexpr ObisId voltage::id; -constexpr char voltage::name[]; - -constexpr ObisId voltage_l1::id; -constexpr char voltage_l1::name[]; - -constexpr ObisId voltage_l2::id; -constexpr char voltage_l2::name[]; - -constexpr ObisId voltage_l3::id; -constexpr char voltage_l3::name[]; - -constexpr ObisId voltage_avg_l1::id; -constexpr char voltage_avg_l1::name[]; - -constexpr ObisId voltage_avg_l2::id; -constexpr char voltage_avg_l2::name[]; - -constexpr ObisId voltage_avg_l3::id; -constexpr char voltage_avg_l3::name[]; - -constexpr ObisId current_l1::id; -constexpr char current_l1::name[]; - -constexpr ObisId current_l2::id; -constexpr char current_l2::name[]; - -constexpr ObisId current_l3::id; -constexpr char current_l3::name[]; - -constexpr ObisId current_fuse_l1::id; -constexpr char current_fuse_l1::name[]; - -constexpr ObisId current_fuse_l2::id; -constexpr char current_fuse_l2::name[]; - -constexpr ObisId current_fuse_l3::id; -constexpr char current_fuse_l3::name[]; - -constexpr ObisId current::id; -constexpr char current::name[]; - -constexpr ObisId current_n::id; -constexpr char current_n::name[]; - -constexpr ObisId current_sum::id; -constexpr char current_sum::name[]; - -constexpr ObisId power_delivered_l1::id; -constexpr char power_delivered_l1::name[]; - -constexpr ObisId power_delivered_l2::id; -constexpr char power_delivered_l2::name[]; - -constexpr ObisId power_delivered_l3::id; -constexpr char power_delivered_l3::name[]; - -constexpr ObisId power_returned_l1::id; -constexpr char power_returned_l1::name[]; - -constexpr ObisId power_returned_l2::id; -constexpr char power_returned_l2::name[]; - -constexpr ObisId power_returned_l3::id; -constexpr char power_returned_l3::name[]; - -constexpr ObisId apparent_delivery_power::id; -constexpr char apparent_delivery_power::name[]; - -constexpr ObisId apparent_delivery_power_l1::id; -constexpr char apparent_delivery_power_l1::name[]; - -constexpr ObisId apparent_delivery_power_l2::id; -constexpr char apparent_delivery_power_l2::name[]; - -constexpr ObisId apparent_delivery_power_l3::id; -constexpr char apparent_delivery_power_l3::name[]; - -constexpr ObisId apparent_return_power::id; -constexpr char apparent_return_power::name[]; - -constexpr ObisId apparent_return_power_l1::id; -constexpr char apparent_return_power_l1::name[]; - -constexpr ObisId apparent_return_power_l2::id; -constexpr char apparent_return_power_l2::name[]; - -constexpr ObisId apparent_return_power_l3::id; -constexpr char apparent_return_power_l3::name[]; - -constexpr ObisId active_demand_power::id; -constexpr char active_demand_power::name[]; - -//constexpr ObisId active_demand_net::id; -//constexpr char active_demand_net::name[]; - -constexpr ObisId active_demand_abs::id; -constexpr char active_demand_abs::name[]; - -/* LUX */ -constexpr ObisId reactive_power_delivered_l1::id; -constexpr char reactive_power_delivered_l1::name[]; - -/* LUX */ -constexpr ObisId reactive_power_delivered_l2::id; -constexpr char reactive_power_delivered_l2::name[]; - -/* LUX */ -constexpr ObisId reactive_power_delivered_l3::id; -constexpr char reactive_power_delivered_l3::name[]; - -/* LUX */ -constexpr ObisId reactive_power_returned_l1::id; -constexpr char reactive_power_returned_l1::name[]; - -/* LUX */ -constexpr ObisId reactive_power_returned_l2::id; -constexpr char reactive_power_returned_l2::name[]; - -/* LUX */ -constexpr ObisId reactive_power_returned_l3::id; -constexpr char reactive_power_returned_l3::name[]; - -constexpr ObisId gas_device_type::id; -constexpr char gas_device_type::name[]; - -constexpr ObisId gas_equipment_id::id; -constexpr char gas_equipment_id::name[]; - -constexpr ObisId gas_valve_position::id; -constexpr char gas_valve_position::name[]; - -/* _NL */ -constexpr ObisId gas_delivered::id; -constexpr char gas_delivered::name[]; - -/* _BE */ -constexpr ObisId gas_delivered_be::id; -constexpr char gas_delivered_be::name[]; - -constexpr ObisId gas_delivered_text::id; -constexpr char gas_delivered_text::name[]; - -constexpr ObisId thermal_device_type::id; -constexpr char thermal_device_type::name[]; - -constexpr ObisId thermal_equipment_id::id; -constexpr char thermal_equipment_id::name[]; - -constexpr ObisId thermal_valve_position::id; -constexpr char thermal_valve_position::name[]; - -constexpr ObisId thermal_delivered::id; -constexpr char thermal_delivered::name[]; - -constexpr ObisId water_device_type::id; -constexpr char water_device_type::name[]; - -constexpr ObisId water_equipment_id::id; -constexpr char water_equipment_id::name[]; - -constexpr ObisId water_valve_position::id; -constexpr char water_valve_position::name[]; - -constexpr ObisId water_delivered::id; -constexpr char water_delivered::name[]; - -constexpr ObisId sub_device_type::id; -constexpr char sub_device_type::name[]; - -constexpr ObisId sub_equipment_id::id; -constexpr char sub_equipment_id::name[]; - -constexpr ObisId sub_valve_position::id; -constexpr char sub_valve_position::name[]; - -constexpr ObisId sub_delivered::id; -constexpr char sub_delivered::name[]; - -constexpr ObisId active_energy_import_current_average_demand::id; -constexpr char active_energy_import_current_average_demand::name[]; - -constexpr ObisId active_energy_export_current_average_demand::id; -constexpr char active_energy_export_current_average_demand::name[]; - -constexpr ObisId apparent_energy_import_current_average_demand::id; -constexpr char apparent_energy_import_current_average_demand::name[]; - -constexpr ObisId apparent_energy_export_current_average_demand::id; -constexpr char apparent_energy_export_current_average_demand::name[]; - -constexpr ObisId active_energy_import_last_completed_demand::id; -constexpr char active_energy_import_last_completed_demand::name[]; - -constexpr ObisId active_energy_export_last_completed_demand::id; -constexpr char active_energy_export_last_completed_demand::name[]; - -constexpr ObisId reactive_energy_import_last_completed_demand::id; -constexpr char reactive_energy_import_last_completed_demand::name[]; - -constexpr ObisId reactive_energy_export_last_completed_demand::id; -constexpr char reactive_energy_export_last_completed_demand::name[]; - -constexpr ObisId apparent_energy_import_last_completed_demand::id; -constexpr char apparent_energy_import_last_completed_demand::name[]; - -constexpr ObisId apparent_energy_export_last_completed_demand::id; -constexpr char apparent_energy_export_last_completed_demand::name[]; - -constexpr ObisId active_energy_import_maximum_demand_running_month::id; -constexpr char active_energy_import_maximum_demand_running_month::name[]; - -constexpr ObisId active_energy_import_maximum_demand_last_13_months::id; -constexpr char active_energy_import_maximum_demand_last_13_months::name[]; - -constexpr ObisId fw_core_version::id; -constexpr char fw_core_version::name[]; - -constexpr ObisId fw_core_checksum::id; -constexpr char fw_core_checksum::name[]; - -constexpr ObisId fw_module_version::id; -constexpr char fw_module_version::name[]; - -constexpr ObisId fw_module_checksum::id; -constexpr char fw_module_checksum::name[]; \ No newline at end of file diff --git a/src/dsmr/fields.h b/src/dsmr/fields.h deleted file mode 100644 index 6db7282..0000000 --- a/src/dsmr/fields.h +++ /dev/null @@ -1,607 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * Field parsing functions - */ - -#pragma once - -#include "util.h" -#include "parser.h" - -#ifndef DSMR_GAS_MBUS_ID -#define DSMR_GAS_MBUS_ID 1 -#endif -#ifndef DSMR_WATER_MBUS_ID -#define DSMR_WATER_MBUS_ID 2 -#endif -#ifndef DSMR_THERMAL_MBUS_ID -#define DSMR_THERMAL_MBUS_ID 3 -#endif -#ifndef DSMR_SUB_MBUS_ID -#define DSMR_SUB_MBUS_ID 4 -#endif - -namespace dsmr -{ - - /** - * 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() { 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; - return res; - } - }; - - // A timestamp is essentially a string using YYMMDDhhmmssX format (where - // X is W or S for wintertime or summertime). Parsing this into a proper - // (UNIX) timestamp is hard to do generically. Parsing it into a - // single integer needs > 4 bytes top fit and isn't very useful (you - // cannot really do any calculation with those values). So we just parse - // into a string for now. - template - struct TimestampField : StringField - { - }; - - // Value that is parsed as a three-decimal float, but stored as an - // integer (by multiplying by 1000). Supports val() (or implicit cast to - // float) to get the original value, and int_val() to get the more - // efficient integer value. The unit() and int_unit() methods on - // FixedField return the corresponding units for these values. - struct FixedValue - { - operator float() { return val(); } - float val() { return _value / 1000.0; } - uint32_t int_val() { return _value; } - - uint32_t _value; - }; - - // Floating point numbers in the message never have more than 3 decimal - // digits. To prevent inefficient floating point operations, we store - // them as a fixed-point number: an integer that stores the value in - // thousands. For example, a value of 1.234 kWh is stored as 1234. This - // effectively means that the integer value is the value in Wh. To allow - // automatic printing of these values, both the original unit and the - // integer unit is passed as a template argument. - template - struct FixedField : ParsedField - { - ParseResult parse(const char *str, const char *end) - { - // Check if the value is a float value, plus its expected unit type. - ParseResult res_float = NumParser::parse(3, _unit, str, end); - if (!res_float.err) { - static_cast(this)->val()._value = res_float.result; - return res_float; - } - // If not, then check for an int value, plus its expected unit type. - // This accomodates for some smart meters that publish int values instead - // of floats. 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. - ParseResult res_int = NumParser::parse(0, _int_unit, str, end); - if (!res_int.err) { - static_cast(this)->val()._value = res_int.result; - return res_int; - } - // If not, then return the initial error result for the float parsing step. - return res_float; - } - - static const char *unit() { return _unit; } - static const char *int_unit() { return _int_unit; } - }; - - struct TimestampedFixedValue : public FixedValue - { - String 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); - } - }; - - // Take the last value of multiple 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; - } - - // (04.329*kW) Which is followed by the numerical value - return FixedField::parse(last, end); - } - }; - - // 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) - static_cast(this)->val() = res.result; - return res; - } - - static const char *unit() { return _unit; } - }; - - // A RawField is not parsed, the entire value (including any - // parenthesis around it) is returned as a string. - template - struct RawField : ParsedField - { - ParseResult parse(const char *str, const char *end) - { - // Just copy the string verbatim value without any parsing - concat_hack(static_cast(this)->val(), str, end - str); - return ParseResult().until(end); - } - }; - - namespace fields - { - - struct units - { - // These variables are inside a struct, since that allows us to make - // them constexpr and define their values here, but define the storage - // in a cpp file. Global const(expr) variables have implicitly - // internal linkage, meaning each cpp file that includes us will have - // its own copy of the variable. Since we take the address of these - // variables (passing it as a template argument), this would cause a - // compiler warning. By putting these in a struct, this is prevented. - static constexpr char none[] = ""; - static constexpr char kWh[] = "kWh"; - static constexpr char Wh[] = "Wh"; - static constexpr char kW[] = "kW"; - static constexpr char W[] = "W"; - static constexpr char kV[] = "kV"; - static constexpr char V[] = "V"; - static constexpr char mV[] = "mV"; - static constexpr char kA[] = "kA"; - static constexpr char A[] = "A"; - static constexpr char mA[] = "mA"; - static constexpr char m3[] = "m3"; - static constexpr char dm3[] = "dm3"; - static constexpr char GJ[] = "GJ"; - static constexpr char MJ[] = "MJ"; - static constexpr char kvar[] = "kvar"; - static constexpr char kvarh[] = "kvarh"; - static constexpr char kVA[] = "kVA"; - static constexpr char VA[] = "VA"; - static constexpr char s[] = "s"; - static constexpr char Hz[] ="Hz"; - static 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, field_args...) \ - struct fieldname : field_t \ - { \ - value_t fieldname; \ - bool fieldname##_present = false; \ - static constexpr ObisId id = obis; \ - static constexpr char name[] = #fieldname; \ - value_t &val() { return fieldname; } \ - bool &present() { return fieldname##_present; } \ - } - - /* Meter identification. This is not a normal field, but a - * specially-formatted first line of the message */ - DEFINE_FIELD(identification, String, ObisId(255, 255, 255, 255, 255, 255), RawField); - - /* Version information for P1 output */ - DEFINE_FIELD(p1_version, String, ObisId(1, 3, 0, 2, 8), StringField, 2, 2); - DEFINE_FIELD(p1_version_be, String, ObisId(0, 0, 96, 1, 4), StringField, 2, 96); - DEFINE_FIELD(p1_version_ch, String, ObisId(0, 0, 96, 1, 4), StringField, 2, 96); - - /* Date-time stamp of the P1 message */ - DEFINE_FIELD(timestamp, String, ObisId(0, 0, 1, 0, 0), TimestampField); - - /* Equipment identifier */ - DEFINE_FIELD(equipment_id, String, 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? */ - DEFINE_FIELD(energy_delivered_lux, FixedValue, ObisId(1, 0, 1, 8, 0), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered to client (Tariff 1) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff1, FixedValue, ObisId(1, 0, 1, 8, 1), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered to client (Tariff 2) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff2, FixedValue, ObisId(1, 0, 1, 8, 2), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered to client (Tariff 3) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff3, FixedValue, ObisId(1, 0, 1, 8, 3), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered to client (Tariff 4) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff4, FixedValue, ObisId(1, 0, 1, 8, 4), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Special for Lux) in 0,001 kWh */ - /* TODO: by OBIS 1-0:2.8.0.255 IEC 62056 it should be Negative active energy (A+) total [kWh], should we rename it? */ - DEFINE_FIELD(energy_returned_lux, FixedValue, ObisId(1, 0, 2, 8, 0), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh */ - DEFINE_FIELD(energy_returned_tariff1, FixedValue, ObisId(1, 0, 2, 8, 1), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh */ - DEFINE_FIELD(energy_returned_tariff2, FixedValue, ObisId(1, 0, 2, 8, 2), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh */ - DEFINE_FIELD(energy_returned_tariff3, FixedValue, ObisId(1, 0, 2, 8, 3), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Tariff 2) in 0,001 kWh */ - DEFINE_FIELD(energy_returned_tariff4, FixedValue, ObisId(1, 0, 2, 8, 4), FixedField, units::kWh, units::Wh); - /* - * Extra fields used for Luxembourg and Lithuania - */ - DEFINE_FIELD(total_imported_energy, FixedValue, ObisId(1, 0, 3, 8, 0), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered to client (Tariff 1) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_delivered_tariff1, FixedValue, ObisId(1, 0, 3, 8, 1), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered to client (Tariff 2) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_delivered_tariff2, FixedValue, ObisId(1, 0, 3, 8, 2), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered to client (Tariff 3) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_delivered_tariff3, FixedValue, ObisId(1, 0, 3, 8, 3), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered to client (Tariff 4) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_delivered_tariff4, FixedValue, ObisId(1, 0, 3, 8, 4), FixedField, units::kvarh, units::kvarh); - - DEFINE_FIELD(total_exported_energy, FixedValue, ObisId(1, 0, 4, 8, 0), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered by client (Tariff 1) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_returned_tariff1, FixedValue, ObisId(1, 0, 4, 8, 1), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered by client (Tariff 2) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_returned_tariff2, FixedValue, ObisId(1, 0, 4, 8, 2), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered by client (Tariff 3) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_returned_tariff3, FixedValue, ObisId(1, 0, 4, 8, 3), FixedField, units::kvarh, units::kvarh); - /* Meter Reading Reactive energy delivered by client (Tariff 4) in 0,001 kvarh */ - DEFINE_FIELD(reactive_energy_returned_tariff4, FixedValue, ObisId(1, 0, 4, 8, 4), FixedField, units::kvarh, units::kvarh); - - /* - * Specific fields used for Switzerland - */ - /* Meter Reading electricity delivered to client (Tariff 1) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff1_ch, FixedValue, ObisId(1, 1, 1, 8, 1), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered to client (Tariff 2) in 0,001 kWh */ - DEFINE_FIELD(energy_delivered_tariff2_ch, FixedValue, ObisId(1, 1, 1, 8, 2), FixedField, units::kWh, units::Wh); - /* Meter Reading electricity delivered by client (Tariff 1) in 0,001 kWh */ - DEFINE_FIELD(energy_returned_tariff1_ch, FixedValue, ObisId(1, 1, 2, 8, 1), FixedField, units::kWh, units::Wh); - /* 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); - - /* 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, String, 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); - /* Actual electricity power received (-P) in 1 Watt resolution */ - DEFINE_FIELD(power_returned, FixedValue, ObisId(1, 0, 2, 7, 0), FixedField, units::kW, units::W); - - /* - * Extra fields used for Luxembourg and Lithuania - */ - DEFINE_FIELD(reactive_power_delivered, FixedValue, ObisId(1, 0, 3, 7, 0), FixedField, units::kvar, units::kvar); - DEFINE_FIELD(reactive_power_returned, FixedValue, ObisId(1, 0, 4, 7, 0), FixedField, units::kvar, units::kvar); - - /* - * Specific fields used for Switzerland - */ - /* Actual electricity power delivered (+P) in 1 Watt resolution */ - DEFINE_FIELD(power_delivered_ch, FixedValue, ObisId(1, 1, 1, 7, 0), FixedField, units::kW, units::W); - /* Actual electricity power received (-P) in 1 Watt resolution */ - DEFINE_FIELD(power_returned_ch, FixedValue, ObisId(1, 1, 2, 7, 0), FixedField, units::kW, units::W); - - /* The actual threshold Electricity in kW. Removed in 4.0.7 / 4.2.2 / 5.0 */ - DEFINE_FIELD(electricity_threshold, FixedValue, ObisId(0, 0, 17, 0, 0), FixedField, units::kW, units::W); - - /* Switch position Electricity (in/out/enabled). Removed in 4.0.7 / 4.2.2 / 5.0 */ - DEFINE_FIELD(electricity_switch_position, uint8_t, ObisId(0, 0, 96, 3, 10), IntField, units::none); - - /* Number of power failures in any phase */ - DEFINE_FIELD(electricity_failures, uint32_t, ObisId(0, 0, 96, 7, 21), IntField, units::none); - /* Number of long power failures in any phase */ - 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, String, 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); - DEFINE_FIELD(voltage_sag_time_l1, uint32_t, ObisId(1, 0, 32, 33, 0), IntField, units::s); - DEFINE_FIELD(voltage_sag_l1, uint32_t, ObisId(1, 0, 32, 34, 0), IntField, units::V); - - /* Number of voltage sags in phase L2 (polyphase meters only) */ - DEFINE_FIELD(electricity_sags_l2, uint32_t, ObisId(1, 0, 52, 32, 0), IntField, units::none); - DEFINE_FIELD(voltage_sag_time_l2, uint32_t, ObisId(1, 0, 52, 33, 0), IntField, units::s); - DEFINE_FIELD(voltage_sag_l2, uint32_t, ObisId(1, 0, 52, 34, 0), IntField, units::V); - - /* Number of voltage sags in phase L3 (polyphase meters only) */ - DEFINE_FIELD(electricity_sags_l3, uint32_t, ObisId(1, 0, 72, 32, 0), IntField, units::none); - DEFINE_FIELD(voltage_sag_time_l3, uint32_t, ObisId(1, 0, 72, 33, 0), IntField, units::s); - DEFINE_FIELD(voltage_sag_l3, uint32_t, ObisId(1, 0, 72, 34, 0), IntField, units::V); - - /* Number of voltage swells in phase L1 */ - DEFINE_FIELD(electricity_swells_l1, uint32_t, ObisId(1, 0, 32, 36, 0), IntField, units::none); - DEFINE_FIELD(voltage_swell_time_l1, uint32_t, ObisId(1, 0, 32, 37, 0), IntField, units::s); - DEFINE_FIELD(voltage_swell_l1, uint32_t, ObisId(1, 0, 32, 38, 0), IntField, units::V); - - /* Number of voltage swells in phase L2 (polyphase meters only) */ - DEFINE_FIELD(electricity_swells_l2, uint32_t, ObisId(1, 0, 52, 36, 0), IntField, units::none); - DEFINE_FIELD(voltage_swell_time_l2, uint32_t, ObisId(1, 0, 52, 37, 0), IntField, units::s); - DEFINE_FIELD(voltage_swell_l2, uint32_t, ObisId(1, 0, 52, 38, 0), IntField, units::V); - - /* Number of voltage swells in phase L3 (polyphase meters only) */ - DEFINE_FIELD(electricity_swells_l3, uint32_t, ObisId(1, 0, 72, 36, 0), IntField, units::none); - DEFINE_FIELD(voltage_swell_time_l3, uint32_t, ObisId(1, 0, 72, 37, 0), IntField, units::s); - 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, String, 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, String, 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) */ - DEFINE_FIELD(voltage_l1, FixedValue, ObisId(1, 0, 32, 7, 0), FixedField, units::V, units::mV); - DEFINE_FIELD(voltage_avg_l1, FixedValue, ObisId(1, 0, 32, 24, 0), FixedField, units::V, units::mV); - /* Instantaneous voltage L2 in 0.1V resolution (Note: Spec says V - * resolution in comment, but 0.1V resolution in format spec. Added in - * 5.0) */ - DEFINE_FIELD(voltage_l2, FixedValue, ObisId(1, 0, 52, 7, 0), FixedField, units::V, units::mV); - DEFINE_FIELD(voltage_avg_l2, FixedValue, ObisId(1, 0, 52, 24, 0), FixedField, units::V, units::mV); - /* Instantaneous voltage L3 in 0.1V resolution (Note: Spec says V - * resolution in comment, but 0.1V resolution in format spec. Added in - * 5.0) */ - DEFINE_FIELD(voltage_l3, FixedValue, ObisId(1, 0, 72, 7, 0), FixedField, units::V, units::mV); - DEFINE_FIELD(voltage_avg_l3, FixedValue, ObisId(1, 0, 72, 24, 0), FixedField, units::V, units::mV); - - /* Instantaneous voltage (U) [V] */ - DEFINE_FIELD(voltage, FixedValue, ObisId(1, 0, 12, 7, 0), FixedField, units::V, units::mV); - /* Frequency [Hz] */ - DEFINE_FIELD(frequency, FixedValue, ObisId(1, 0, 14, 7, 0), FixedField, units::kHz, units::Hz); - /* Absolute active instantaneous power (|A|) [kW] */ - DEFINE_FIELD(abs_power, FixedValue, ObisId(1, 0, 15, 7, 0), FixedField, units::kW, units::W); - - /* Instantaneous current L1 in A resolution */ - DEFINE_FIELD(current_l1, FixedValue, ObisId(1, 0, 31, 7, 0), FixedField, units::A, units::mA); - DEFINE_FIELD(current_fuse_l1, FixedValue, ObisId(1, 0, 31, 4, 0), FixedField, units::A, units::mA); - /* Instantaneous current L2 in A resolution */ - DEFINE_FIELD(current_l2, FixedValue, ObisId(1, 0, 51, 7, 0), FixedField, units::A, units::mA); - DEFINE_FIELD(current_fuse_l2, FixedValue, ObisId(1, 0, 51, 4, 0), FixedField, units::A, units::mA); - /* Instantaneous current L3 in A resolution */ - DEFINE_FIELD(current_l3, FixedValue, ObisId(1, 0, 71, 7, 0), FixedField, units::A, units::mA); - DEFINE_FIELD(current_fuse_l3, FixedValue, ObisId(1, 0, 71, 4, 0), FixedField, units::A, units::mA); - - /* Instantaneous active power L1 (+P) in W resolution */ - DEFINE_FIELD(power_delivered_l1, FixedValue, ObisId(1, 0, 21, 7, 0), FixedField, units::kW, units::W); - /* Instantaneous active power L2 (+P) in W resolution */ - DEFINE_FIELD(power_delivered_l2, FixedValue, ObisId(1, 0, 41, 7, 0), FixedField, units::kW, units::W); - /* Instantaneous active power L3 (+P) in W resolution */ - DEFINE_FIELD(power_delivered_l3, FixedValue, ObisId(1, 0, 61, 7, 0), FixedField, units::kW, units::W); - - /* Instantaneous active power L1 (-P) in W resolution */ - DEFINE_FIELD(power_returned_l1, FixedValue, ObisId(1, 0, 22, 7, 0), FixedField, units::kW, units::W); - /* Instantaneous active power L2 (-P) in W resolution */ - DEFINE_FIELD(power_returned_l2, FixedValue, ObisId(1, 0, 42, 7, 0), FixedField, units::kW, units::W); - /* Instantaneous active power L3 (-P) in W resolution */ - DEFINE_FIELD(power_returned_l3, FixedValue, ObisId(1, 0, 62, 7, 0), FixedField, units::kW, units::W); - - /* Instantaneous current (I) [A] */ - DEFINE_FIELD(current, FixedValue, ObisId(1, 0, 11, 7, 0), FixedField, units::A, units::mA); - /* Instantaneous current (I) in neutral [A] */ - DEFINE_FIELD(current_n, FixedValue, ObisId(1, 0, 91, 7, 0), FixedField, units::A, units::mA); - /* Instantaneous sum of all phase current's (I) [A] */ - DEFINE_FIELD(current_sum, FixedValue, ObisId(1, 0, 90, 7, 0), FixedField, units::A, units::mA); - - /* - * LUX and Lithuania - */ - /* TODO: by IEC 62056 unit's shoudl be kvar, safe to change? */ - /* Instantaneous reactive power L1 (+Q) in W resolution */ - DEFINE_FIELD(reactive_power_delivered_l1, FixedValue, ObisId(1, 0, 23, 7, 0), FixedField, units::none, units::none); - /* Instantaneous reactive power L2 (+Q) in W resolution */ - DEFINE_FIELD(reactive_power_delivered_l2, FixedValue, ObisId(1, 0, 43, 7, 0), FixedField, units::none, units::none); - /* Instantaneous reactive power L3 (+Q) in W resolution */ - DEFINE_FIELD(reactive_power_delivered_l3, FixedValue, ObisId(1, 0, 63, 7, 0), FixedField, units::none, units::none); - - /* - * LUX and Lithuania - */ - /* TODO: by IEC 62056 unit's shoudl be kvar, safe to change? */ - /* Instantaneous reactive power L1 (-Q) in W resolution */ - DEFINE_FIELD(reactive_power_returned_l1, FixedValue, ObisId(1, 0, 24, 7, 0), FixedField, units::none, units::none); - /* Instantaneous reactive power L2 (-Q) in W resolution */ - DEFINE_FIELD(reactive_power_returned_l2, FixedValue, ObisId(1, 0, 44, 7, 0), FixedField, units::none, units::none); - /* Instantaneous reactive power L3 (-Q) in W resolution */ - DEFINE_FIELD(reactive_power_returned_l3, FixedValue, ObisId(1, 0, 64, 7, 0), FixedField, units::none, units::none); - - /* Apparent instantaneous power (+S) in kVA resolution */ - DEFINE_FIELD(apparent_delivery_power, FixedValue, ObisId(1, 0, 9, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L1 (+S) in kVA resolution */ - DEFINE_FIELD(apparent_delivery_power_l1, FixedValue, ObisId(1, 0, 29, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L2 (+S) in kVA resolution */ - DEFINE_FIELD(apparent_delivery_power_l2, FixedValue, ObisId(1, 0, 49, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L3 (+S) in kVA resolution */ - DEFINE_FIELD(apparent_delivery_power_l3, FixedValue, ObisId(1, 0, 69, 7, 0), FixedField, units::kVA, units::VA); - - /* Apparent instantaneous power (-S) in kVA resolution */ - DEFINE_FIELD(apparent_return_power, FixedValue, ObisId(1, 0, 10, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L1 (-S) in kVA resolution */ - DEFINE_FIELD(apparent_return_power_l1, FixedValue, ObisId(1, 0, 30, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L2 (-S) in kVA resolution */ - DEFINE_FIELD(apparent_return_power_l2, FixedValue, ObisId(1, 0, 50, 7, 0), FixedField, units::kVA, units::VA); - /* Apparent instantaneous power L3 (-S) in kVA resolution */ - DEFINE_FIELD(apparent_return_power_l3, FixedValue, ObisId(1, 0, 70, 7, 0), FixedField, units::kVA, units::VA); - - /* Active Demand Avg3 Plus in W resolution */ - DEFINE_FIELD(active_demand_power, FixedValue, ObisId(1, 0, 1, 24, 0), FixedField, units::kW, units::W); - /* Active Demand Avg3 Net in W resolution */ - /* TODO: 1-0.16.24.0.255 can have negative value, this library is not ready for negative numbers. */ - // DEFINE_FIELD(active_demand_net, int32_t, ObisId(1, 0, 16, 24, 0), IntField, units::kW); - /* Active Demand Avg3 Absolute in W resolution */ - 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); - - /* Equipment identifier (Gas) */ - DEFINE_FIELD(gas_equipment_id, String, ObisId(0, GAS_MBUS_ID, 96, 1, 0), StringField, 0, 96); - /* Equipment identifier (Gas) BE */ - DEFINE_FIELD(gas_equipment_id_be, String, ObisId(0, 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); - - /* 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); - /* _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, String, ObisId(0, 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); - - /* Equipment identifier (Thermal: heat or cold) */ - DEFINE_FIELD(thermal_equipment_id, String, ObisId(0, 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); - - /* 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); - - /* Device-Type */ - DEFINE_FIELD(water_device_type, uint16_t, ObisId(0, WATER_MBUS_ID, 24, 1, 0), IntField, units::none); - - /* Equipment identifier (Thermal: heat or cold) */ - DEFINE_FIELD(water_equipment_id, String, ObisId(0, 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); - - /* 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); - - /* Device-Type */ - DEFINE_FIELD(sub_device_type, uint16_t, ObisId(0, SUB_MBUS_ID, 24, 1, 0), IntField, units::none); - - /* Equipment identifier (Thermal: heat or cold) */ - DEFINE_FIELD(sub_equipment_id, String, ObisId(0, 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); - - /* 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); - - /* 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); - DEFINE_FIELD(active_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 2, 4, 0), FixedField, units::kW, units::W); - DEFINE_FIELD(reactive_energy_import_current_average_demand, FixedValue, ObisId(1, 0, 3, 4, 0), FixedField, units::kvar, units::kvar); - DEFINE_FIELD(reactive_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 4, 4, 0), FixedField, units::kvar, units::kvar); - DEFINE_FIELD(apparent_energy_import_current_average_demand, FixedValue, ObisId(1, 0, 9, 4, 0), FixedField, units::kVA, units::VA); - DEFINE_FIELD(apparent_energy_export_current_average_demand, FixedValue, ObisId(1, 0, 10, 4, 0), FixedField, units::kVA, units::VA); - DEFINE_FIELD(active_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 1, 5, 0), FixedField, units::kW, units::W); - DEFINE_FIELD(active_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 2, 5, 0), FixedField, units::kW, units::W); - DEFINE_FIELD(reactive_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 3, 5, 0), FixedField, units::kvar, units::kvar); - DEFINE_FIELD(reactive_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 4, 5, 0), FixedField, units::kvar, units::kvar); - DEFINE_FIELD(apparent_energy_import_last_completed_demand, FixedValue, ObisId(1, 0, 9, 5, 0), FixedField, units::kVA, units::VA); - DEFINE_FIELD(apparent_energy_export_last_completed_demand, FixedValue, ObisId(1, 0, 10, 5, 0), FixedField, units::kVA, units::VA); - - /*Maximum energy consumption from the current month*/ - DEFINE_FIELD(active_energy_import_maximum_demand_running_month, TimestampedFixedValue, ObisId(1, 0, 1, 6, 0), TimestampedFixedField, units::kW, units::W); - /*Maximum energy consumption from the last 13 months*/ - DEFINE_FIELD(active_energy_import_maximum_demand_last_13_months, FixedValue, ObisId(0, 0, 98, 1, 0), LastFixedField, units::kW, units::W); - - /* Image Core Version and checksum */ - DEFINE_FIELD(fw_core_version, FixedValue, ObisId(1, 0, 0, 2, 0), FixedField, units::none, units::none); - DEFINE_FIELD(fw_core_checksum, String, ObisId(1, 0, 0, 2, 8), StringField, 0, 8); - /* Image Module Version and checksum */ - DEFINE_FIELD(fw_module_version, FixedValue, ObisId(1, 1, 0, 2, 0), FixedField, units::none, units::none); - DEFINE_FIELD(fw_module_checksum, String, ObisId(1, 1, 0, 2, 8), StringField, 0, 8); - - } // namespace fields - -} // namespace dsmr diff --git a/src/dsmr/parser.h b/src/dsmr/parser.h deleted file mode 100644 index 2f92f29..0000000 --- a/src/dsmr/parser.h +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * Message parsing core - */ - -#pragma once - -#include "crc16.h" -#include "util.h" - -namespace dsmr -{ - - /** - * ParsedData is a template for the result of parsing a Dsmr P1 message. - * 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. - */ - template - struct ParsedData; - - /** - * Base case: No fields present. - */ - template <> - struct ParsedData<> - { - ParseResult __attribute__((__always_inline__)) - parse_line_inlined(const ObisId & /* id */, const char *str, const char * /* end */) - { - // Parsing succeeded, but found no matching handler (so return - // set the next pointer to show nothing was parsed). - return ParseResult().until(str); - } - - template - void __attribute__((__always_inline__)) applyEach_inlined(F && /* f */) - { - // Nothing to do - } - - bool all_present_inlined() { return true; } - }; - - /** - * General case: At least one typename is passed. - */ - template - struct ParsedData : public T, ParsedData - { - /** - * This method is used by the parser to parse a single line. The - * OBIS id of the line is passed, and this method recursively finds a - * field with a matching id. If any, it calls it's parse method, which - * parses the value and stores it in the field. - */ - ParseResult parse_line(const ObisId &id, const char *str, const char *end) - { - return parse_line_inlined(id, str, end); - } - - /** - * always_inline version of parse_line. This is a separate method, to - * allow recursively inlining all calls, but still have a non-inlined - * top-level parse_line method. - */ - ParseResult __attribute__((__always_inline__)) - parse_line_inlined(const ObisId &id, const char *str, const char *end) - { - if (id == T::id) - { - if (T::present()) - return ParseResult().fail("Duplicate field", str); - T::present() = true; - return T::parse(str, end); - } - return ParsedData::parse_line_inlined(id, str, end); - } - - template - void applyEach(F &&f) { applyEach_inlined(f); } - - template - void __attribute__((__always_inline__)) applyEach_inlined(F &&f) - { - T::apply(f); - return ParsedData::applyEach_inlined(f); - } - - /** - * Returns true when all defined fields are present. - */ - bool all_present() { return all_present_inlined(); } - - bool all_present_inlined() { return T::present() && ParsedData::all_present_inlined(); } - }; - - struct StringParser - { - 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; - - if (str_end == end) - return res.fail("Missing )", str_end); - - size_t len = str_end - str_start; - if (len < min || len > max) - return res.fail("Invalid string length", str_start); - - concat_hack(res.result, str_start, len); - - return res.until(str_end + 1); // Skip ) - } - }; - - // Do not use F() for multiply-used strings (including strings used from - // multiple template instantiations), that would result in multiple - // instances of the string in the binary - static constexpr char INVALID_NUMBER[] = "Invalid number"; - static constexpr char INVALID_UNIT[] = "Invalid unit"; - - struct NumParser - { - 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); - - const char *num_start = str + 1; // Skip ( - const char *num_end = num_start; - - uint32_t value = 0; - - // Parse integer part - while (num_end < end && !strchr("*.)", *num_end)) - { - if (*num_end < '0' || *num_end > '9') - return res.fail(INVALID_NUMBER, num_end); - value *= 10; - value += *num_end - '0'; - ++num_end; - } - - // Parse decimal part, if any - if (max_decimals && num_end < end && *num_end == '.') - { - ++num_end; - - while (num_end < end && !strchr("*)", *num_end) && max_decimals--) - { - if (*num_end < '0' || *num_end > '9') - return res.fail(INVALID_NUMBER, num_end); - value *= 10; - value += *num_end - '0'; - ++num_end; - } - } - - // Fill in missing decimals with zeroes - while (max_decimals--) - value *= 10; - - if (unit && *unit) - { - if (num_end >= end || *num_end != '*') - return res.fail("Missing unit", num_end); - const char *unit_start = ++num_end; // skip * - while (num_end < end && *num_end != ')' && *unit) - { - if (tolower(*num_end++) != tolower(*unit++)) - return res.fail(INVALID_UNIT, unit_start); - } - if (*unit) - return res.fail(INVALID_UNIT, unit_start); - } - - if (num_end >= end || *num_end != ')') - return res.fail("Extra data", num_end); - - return res.succeed(value).until(num_end + 1); // Skip ) - } - }; - - struct ObisIdParser - { - static ParseResult parse(const char *str, const char *end) - { - // 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') - { - uint8_t 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] = 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; - } - ++res.next; - } - - if (res.next == str) - return res.fail("OBIS id Empty", str); - - for (++part; part < 6; ++part) - id.v[part] = 255; - - return res; - } - }; - - struct CrcParser - { - static const size_t CRC_LEN = 4; - - // 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; - // This should never happen with the code in this library, but - // check anyway - if (str + CRC_LEN > end) - return res.fail("No checksum found", str); - - // A bit of a messy way to parse the checksum, but all - // integer-parse functions assume nul-termination - char buf[CRC_LEN + 1]; - memcpy(buf, str, CRC_LEN); - buf[CRC_LEN] = '\0'; - char *endp; - uint16_t check = strtoul(buf, &endp, 16); - - // See if all four bytes formed a valid number - if (endp != buf + CRC_LEN) - return res.fail("Incomplete or malformed checksum", str); - - res.next = str + CRC_LEN; - return res.succeed(check); - } - }; - - struct P1Parser - { - /** - * 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; - if (!n || str[0] != '/') - return res.fail("Data should start with /", str); - - // Skip / - const char *data_start = str + 1; - - // Look for ! that terminates the data - const char *data_end = data_start; - if (check_crc) - { - uint16_t crc = _crc16_update(0, *str); // Include the / in CRC - while (data_end < str + n && *data_end != '!') - { - crc = _crc16_update(crc, *data_end); - ++data_end; - } - if (data_end >= str + n) - return res.fail("No checksum found", data_end); - - crc = _crc16_update(crc, *data_end); // Include the ! in CRC - - ParseResult check_res = CrcParser::parse(data_end + 1, str + n); - if (check_res.err) - return check_res; - - // Check CRC - if (check_res.result != crc) - { - return res.fail("Checksum mismatch", data_end + 1); - } - res = parse_data(data, data_start, data_end, unknown_error); - res.next = check_res.next; - } - else - { - while (data_end < str + n && *data_end != '!') - { - ++data_end; - } - - res = parse_data(data, data_start, data_end, unknown_error); - res.next = data_end; - } - - return res; - } - - /** - * 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. - */ - template - static ParseResult parse_data(ParsedData *data, const char *str, const char *end, - bool unknown_error = false) - { - ParseResult res; - // Split into lines and parse those - const char *line_end = str, *line_start = str; - - // 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-leter (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. - if (line_start + 3 >= line_end || (line_start[3] != '5' && line_start[3] != '3')) - return res.fail("Invalid identification string", line_start); - // 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; - break; - } - ++line_end; - } - - // Parse data lines - while (line_end < end) - { - if (*line_end == '\r' || *line_end == '\n') - { - ParseResult tmp = parse_line(data, line_start, line_end, unknown_error); - if (tmp.err) - return tmp; - line_start = line_end + 1; - } - line_end++; - } - - if (line_end != line_start) - return res.fail("Last dataline not CRLF terminated", line_end); - - return res; - } - - 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); - } - }; - -} // namespace dsmr diff --git a/src/dsmr/reader.h b/src/dsmr/reader.h deleted file mode 100644 index ddcfa6e..0000000 --- a/src/dsmr/reader.h +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * P1 reader, that takes care of toggling a request pin, reading data - * from a serial port and parsing it. - */ - -#ifndef DSMR_INCLUDE_READER_H -#define DSMR_INCLUDE_READER_H - -#include -#include "crc16.h" - -#include "parser.h" - -namespace dsmr -{ - - /** - * Controls the request pin on the P1 port to enable (periodic) - * transmission of messages and reads those messages. - * - * To enable the request pin, call enable(). This lets the Smart Meter - * start periodically sending messages. While the request pin is - * enabled, loop() should be regularly called to read pending bytes. - * - * Once a full and correct message is received, loop() (and available()) - * start returning true, until the message is cleared. You can then - * either read the raw message using raw(), or parse it using parse(). - * - * The message is cleared when: - * - clear() is called - * - parse() is called - * - loop() is called and the start of a new message is available - * - * When disable is called, the request pin is disabled again and any - * partial message is discarded. Any bytes received while disabled are - * dropped. - */ - class P1Reader - { - public: - /** - * Create a new P1Reader. The stream passed should be the serial - * port to which the P1 TX pin is connected. The req_pin is the - * pin connected to the request pin. The pin is configured as an - * output, the Stream is assumed to be already set up (e.g. baud - * rate configured). - */ - P1Reader(Stream *stream, uint8_t req_pin) - : stream(stream), req_pin(req_pin), once(false), state(State::DISABLED_STATE) - { - pinMode(req_pin, OUTPUT); - digitalWrite(req_pin, LOW); - } - - /** - * Enable the request pin, to request data on the P1 port. - * @param once When true, the request pin is automatically - * disabled once a complete and correct message was - * receivedc. When false, the request pin stays - * enabled, so messages will continue to be sent - * periodically. - */ - void enable(bool once) - { - digitalWrite(this->req_pin, HIGH); - this->state = State::WAITING_STATE; - this->once = once; - } - - /* Disable the request pin again, to stop data from being sent on - * the P1 port. This will also clear any incomplete data that was - * previously received, but a complete message will be kept until - * clear() is called. - */ - void disable() - { - digitalWrite(this->req_pin, LOW); - this->state = State::DISABLED_STATE; - if (!this->_available) - this->buffer = ""; - // Clear any pending bytes - while (this->stream->read() >= 0) /* nothing */ - ; - } - - /** - * Returns true when a complete and correct message was received, - * until it is cleared. - */ - bool available() - { - return this->_available; - } - - /** - * Check for new data to read. Should be called regularly, such as - * once every loop. Returns true if a complete message is available - * (just like available). - */ - bool loop() - { - while (true) - { - if (state == State::CHECKSUM_STATE) - { - // Let the Stream buffer the CRC bytes. Convert to size_t to - // prevent unsigned vs signed comparison - if ((size_t)this->stream->available() < CrcParser::CRC_LEN) - return false; - - char buf[CrcParser::CRC_LEN]; - for (uint8_t i = 0; i < CrcParser::CRC_LEN; ++i) - buf[i] = this->stream->read(); - - ParseResult crc = CrcParser::parse(buf, buf + lengthof(buf)); - - // Prepare for next message - state = State::WAITING_STATE; - - if (!crc.err && crc.result == this->crc) - { - // Message complete, checksum correct - this->_available = true; - - if (once) - this->disable(); - - return true; - } - } - else - { - // For other states, read bytes one by one - int c = this->stream->read(); - if (c < 0) - return false; - - switch (this->state) - { - case State::DISABLED_STATE: - // Where did this byte come from? Just toss it - break; - case State::WAITING_STATE: - if (c == '/') - { - this->state = State::READING_STATE; - // Include the / in the CRC - this->crc = _crc16_update(0, c); - this->clear(); - } - break; - case State::READING_STATE: - // Include the ! in the CRC - this->crc = _crc16_update(this->crc, c); - if (c == '!') - this->state = State::CHECKSUM_STATE; - else - buffer.concat((char)c); - - break; - case State::CHECKSUM_STATE: - // This cannot happen (given the surrounding if), but the - // compiler is not smart enough to see this, so list this - // case to prevent a warning. - abort(); - break; - } - } - } - return false; - } - - /** - * Returns the data read so far. - */ - const String &raw() - { - return buffer; - } - - /** - * If a complete message has been received, parse it and store the - * result into the ParsedData object passed. - * - * After parsing, the message is cleared. - * - * If parsing fails, false is returned. If err is passed, the error - * message is appended to that string. - */ - template - bool parse(ParsedData *data, String *err) - { - const char *str = buffer.c_str(), *end = buffer.c_str() + buffer.length(); - ParseResult res = P1Parser::parse_data(data, str, end); - - if (res.err && err) - *err = res.fullError(str, end); - - // Clear the message - this->clear(); - - return res.err == NULL; - } - - /** - * Clear any complete message from the buffer. - */ - void clear() - { - if (_available) - { - buffer = ""; - _available = false; - } - } - - protected: - Stream *stream; - uint8_t req_pin; - enum class State : uint8_t - { - DISABLED_STATE, - WAITING_STATE, - READING_STATE, - CHECKSUM_STATE, - }; - bool _available; - bool once; - State state; - String buffer; - uint16_t crc; - }; - -} // namespace dsmr - -#endif // DSMR_INCLUDE_READER_H diff --git a/src/dsmr/util.h b/src/dsmr/util.h deleted file mode 100644 index ca0c1c9..0000000 --- a/src/dsmr/util.h +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Arduino DSMR parser. - * - * This software is licensed under the MIT License. - * - * Copyright (c) 2015 Matthijs Kooijman - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS - * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN - * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * Various utility functions - */ - -#pragma once - -#include - -namespace dsmr -{ - - /** - * Small utility to get the length of an array at compiletime. - */ - template - inline unsigned int lengthof(const T (&)[sz]) { return sz; } - - // Hack until https://github.com/arduino/Arduino/pull/1936 is merged. - // This appends the given number of bytes from the given C string to the - // given Arduino string, without requiring a trailing NUL. - // Requires that there _is_ room for nul-termination - static void concat_hack(String &s, const char *append, size_t n) - { - // Add null termination. Inefficient, but it works... - char buf[n + 1]; - memcpy(buf, append, n); - buf[n] = 0; - s.concat(buf); - } - - /** - * 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; - - P &succeed(T &result) - { - this->result = result; - return *static_cast

(this); - } - P &succeed(T &&result) - { - this->result = result; - return *static_cast

(this); - } - }; - - // partial specialization for void result - template - struct _ParseResult - { - }; - - // Actual ParseResult class - template - struct ParseResult : public _ParseResult, T> - { - const char *next = NULL; - const char *err = NULL; - const char *ctx = NULL; - - ParseResult &fail(const char *err, const char *ctx = NULL) - { - this->err = err; - this->ctx = ctx; - return *this; - } - ParseResult &until(const char *next) - { - this->next = next; - return *this; - } - ParseResult() = default; - ParseResult(const ParseResult &other) = default; - - template - ParseResult(const ParseResult &other) : 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. - */ - String fullError(const char *start, const char *end) const - { - 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((line_end - line_start) + 2 + (this->ctx - line_start) + 1 + 2); - - // Write the line - concat_hack(res, line_start, 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 - { - uint8_t v[6]; - - constexpr ObisId(uint8_t a, uint8_t b = 255, uint8_t c = 255, uint8_t d = 255, uint8_t e = 255, uint8_t f = 255) - : v{a, b, c, d, e, f} {}; - constexpr ObisId() : v() {} // Zeroes - - bool operator==(const ObisId &other) const { return memcmp(&v, &other.v, sizeof(v)) == 0; } - }; - -} // namespace dsmr diff --git a/src/test/encrypted_packet_accumulator_example_test.cpp b/src/test/encrypted_packet_accumulator_example_test.cpp new file mode 100644 index 0000000..7feaa4c --- /dev/null +++ b/src/test/encrypted_packet_accumulator_example_test.cpp @@ -0,0 +1,58 @@ +#include "arduino-dsmr-2/encrypted_packet_accumulator.h" +#include "arduino-dsmr-2/fields.h" +#include "arduino-dsmr-2/parser.h" +#include +#include + +using namespace arduino_dsmr_2; +using namespace fields; + +TEST_CASE("EncryptedPacketAccumulator example") { + + // Buffers to store the incoming bytes and the decrypted packet. + // Both of these buffers must be large enough to hold the full DSMR message. + // Advice: define the buffers as global variables, to avoid using stack and heap memory. + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + // For the sake of this example, this variable is supposed to contain data that comes from the P1 port. + std::vector encrypted_data_from_p1_port; + + // This class is similar to the PacketAccumulator, but it handles encrypted packets. + // Use this class only if you have a smart meter that uses encryption. + // You only need to create this class once. + EncryptedPacketAccumulator accumulator(encrypted_packet_buffer, decrypted_packet_buffer); + + // Set the encryption key. This key is unique for each smart meter and should be provided by your energy supplier. + const auto error = accumulator.set_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + if (error) { + printf("Failed to set encryption key: %s", to_string(*error)); + return; + } + + // Main loop. + // We need to read data from P1 port 1 byte at a time. + for (const auto& byte : encrypted_data_from_p1_port) { + // feed the byte to the accumulator + auto res = accumulator.process_byte(byte); + + // During receiving, errors may occur. + // You can optionally log these errors, or ignore them. + if (res.error()) { + printf("Error during receiving a packet: %s", to_string(*res.error())); + } + + // When a full packet is received, the packet() method returns unencrypted packet. + if (res.packet()) { + // Parse the received packet the same way as with the PacketAccumulator example + } + + // The specification says that packets arrive once every 10 seconds. + // In case some bytes are lost during transmission, you need to use a timeout to detect when a packet transmission finishes. + // For example, if you stopped receiving bytes for more than 1 second, but you haven't received a complete packet yet, + // you should reset EncryptedPacketAccumulator and start receiving a new packet from scratch. + if (/* timeout */ false) { + accumulator = EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); + } + } +} diff --git a/src/test/encrypted_packet_accumulator_include_test.cpp b/src/test/encrypted_packet_accumulator_include_test.cpp new file mode 100644 index 0000000..56e03d5 --- /dev/null +++ b/src/test/encrypted_packet_accumulator_include_test.cpp @@ -0,0 +1,8 @@ +// This code tests that the encrypted_packet_accumulator header has all necessary dependencies included in its headers. +// We check that the code compiles. + +#include "arduino-dsmr-2/encrypted_packet_accumulator.h" + +std::array encrypted_packet_buffer; +std::array decrypted_packet_buffer; +void EncryptedPacketAccumulator_some_function() { arduino_dsmr_2::EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); } diff --git a/src/test/encrypted_packet_accumulator_test.cpp b/src/test/encrypted_packet_accumulator_test.cpp new file mode 100644 index 0000000..b2e7d1a --- /dev/null +++ b/src/test/encrypted_packet_accumulator_test.cpp @@ -0,0 +1,157 @@ +#include "arduino-dsmr-2/encrypted_packet_accumulator.h" +#include +#include +#include +#include +#include + +using namespace arduino_dsmr_2; + +template +std::vector concat(const std::vector& first, const Vecs&... rest) { + std::vector out; + out.reserve(first.size() + (rest.size() + ... + 0)); + out.insert(out.end(), first.begin(), first.end()); + (out.insert(out.end(), rest.begin(), rest.end()), ...); + return out; +} + +inline std::vector read_binary_file(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} + +const auto& encrypted_packet = + read_binary_file(std::filesystem::path(std::source_location::current().file_name()).parent_path() / "test_data" / "encrypted_packet.bin"); + +static void change_length(std::vector& packet, const std::uint16_t total_len) { + packet[11] = static_cast((total_len >> 8) & 0xFF); + packet[12] = static_cast(total_len & 0xFF); +} + +TEST_CASE("Can receive correct packet") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + auto accumulator = EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); + REQUIRE(!accumulator.set_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + + for (const auto& byte : encrypted_packet) { + const auto& res = accumulator.process_byte(byte); + REQUIRE(res.error().has_value() == false); + + if (res.packet()) { + REQUIRE(std::string(*res.packet()).ends_with("1-0:4.7.0(000000166*var)\r\n!7EF9\r\n")); + REQUIRE(std::string(*res.packet()).starts_with("/EST5\\253710000_A\r\n")); + return; + } + } + + REQUIRE(false); +} + +TEST_CASE("Error on corrupted packet") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + auto corrupted_packet = encrypted_packet; + corrupted_packet[50] ^= 0xFF; + + auto accumulator = EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); + REQUIRE(!accumulator.set_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").has_value()); + + for (const auto& byte : corrupted_packet) { + const auto& res = accumulator.process_byte(byte); + if (res.error()) { + REQUIRE(*res.error() == EncryptedPacketAccumulator::Error::DecryptionFailed); + return; + } + } + + REQUIRE(false); +} + +TEST_CASE("Encryption key validation") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + auto accumulator = EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); + REQUIRE(!accumulator.set_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").has_value()); + REQUIRE(!accumulator.set_encryption_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").has_value()); + REQUIRE(*accumulator.set_encryption_key("AAAAAAAAAAA") == EncryptedPacketAccumulator::SetEncryptionKeyError::EncryptionKeyLengthIsNot32Bytes); + REQUIRE(*accumulator.set_encryption_key("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == + EncryptedPacketAccumulator::SetEncryptionKeyError::EncryptionKeyContainsNonHexSymbols); +} + +TEST_CASE("BufferOverflow when telegram length exceeds capacity") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + EncryptedPacketAccumulator acc(encrypted_packet_buffer, decrypted_packet_buffer); + for (const auto byte : encrypted_packet) { + const auto& res = acc.process_byte(byte); + if (res.error()) { + REQUIRE(*res.error() == EncryptedPacketAccumulator::Error::BufferOverflow); + return; + } + } + REQUIRE(false); +} + +TEST_CASE("Telegram is too small") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + EncryptedPacketAccumulator acc(encrypted_packet_buffer, decrypted_packet_buffer); + auto too_small_packet = encrypted_packet; + change_length(too_small_packet, 16); + + for (const auto byte : too_small_packet) { + const auto& res = acc.process_byte(byte); + if (res.error()) { + REQUIRE(*res.error() == EncryptedPacketAccumulator::Error::HeaderCorrupted); + return; + } + } + REQUIRE(false); +} + +TEST_CASE("Receive many packets") { + std::array encrypted_packet_buffer; + std::array decrypted_packet_buffer; + + auto accumulator = EncryptedPacketAccumulator(encrypted_packet_buffer, decrypted_packet_buffer); + REQUIRE(!accumulator.set_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").has_value()); + + const auto& garbage = std::vector(100, 0x55); + + auto packet_corrupted = encrypted_packet; + packet_corrupted[50] ^= 0xFF; + + auto packet_too_short_length = encrypted_packet; + change_length(packet_too_short_length, 16); + + auto packet_too_long_length = encrypted_packet; + change_length(packet_too_long_length, 2000); + + size_t received_packets = 0; + std::vector occurred_errors; + + for (const auto byte : concat(garbage, encrypted_packet, garbage, packet_too_short_length, packet_corrupted, encrypted_packet, packet_corrupted, + encrypted_packet, packet_too_long_length, encrypted_packet)) { + auto res = accumulator.process_byte(byte); + + if (res.packet()) { + received_packets++; + } + + if (res.error()) { + occurred_errors.push_back(*res.error()); + } + } + + REQUIRE(received_packets == 4); + + using enum EncryptedPacketAccumulator::Error; + REQUIRE(occurred_errors == std::vector{HeaderCorrupted, HeaderCorrupted, HeaderCorrupted, HeaderCorrupted, DecryptionFailed, DecryptionFailed, BufferOverflow, + HeaderCorrupted, HeaderCorrupted, HeaderCorrupted}); +} diff --git a/src/test/main.cpp b/src/test/main.cpp new file mode 100644 index 0000000..b8e3a4b --- /dev/null +++ b/src/test/main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/src/test/packet_accumulator_example_test.cpp b/src/test/packet_accumulator_example_test.cpp new file mode 100644 index 0000000..7af60f4 --- /dev/null +++ b/src/test/packet_accumulator_example_test.cpp @@ -0,0 +1,81 @@ +#include "arduino-dsmr-2/fields.h" +#include "arduino-dsmr-2/packet_accumulator.h" +#include "arduino-dsmr-2/parser.h" +#include +#include + +using namespace arduino_dsmr_2; +using namespace fields; + +TEST_CASE("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; + + // 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" + "/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" + "!60e5" + "garbage after" + "/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" + "!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. + PacketAccumulator accumulator(/* buffer */ buffer, /* check_crc */ true); + + // 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())); + } + + // 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())); + } + } +} diff --git a/src/test/packet_accumulator_include_test.cpp b/src/test/packet_accumulator_include_test.cpp new file mode 100644 index 0000000..e8beb0b --- /dev/null +++ b/src/test/packet_accumulator_include_test.cpp @@ -0,0 +1,6 @@ +// This code tests that the packet_accumulator header has all necessary dependencies included in its headers. +// We check that the code compiles. + +#include "arduino-dsmr-2/packet_accumulator.h" + +void PacketAccumulator_some_function() { arduino_dsmr_2::PacketAccumulator({}, true); } diff --git a/src/test/packet_accumulator_test.cpp b/src/test/packet_accumulator_test.cpp new file mode 100644 index 0000000..4522801 --- /dev/null +++ b/src/test/packet_accumulator_test.cpp @@ -0,0 +1,130 @@ +#include "arduino-dsmr-2/packet_accumulator.h" +#include +#include +#include + +using namespace arduino_dsmr_2; + +TEST_CASE("Packet with correct CRC lower case") { + std::vector buffer(1000); + const auto& msg = "/some !a3D4"; + + auto accumulator = PacketAccumulator(buffer, true); + 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; + } + } + + REQUIRE(false); +} + +TEST_CASE("Packet with incorrect CRC") { + std::vector buffer(1000); + const auto& msg = "/some data!0000"; + + PacketAccumulator accumulator(buffer, true); + for (const auto& byte : msg) { + auto packet = accumulator.process_byte(byte); + if (packet.error()) { + REQUIRE(*packet.error() == PacketAccumulator::Error::CrcMismatch); + return; + } + } + + REQUIRE(false); +} + +TEST_CASE("Packet with incorrect CRC symbol") { + std::vector buffer(1000); + const auto& msg = "/some data!G000"; + + PacketAccumulator accumulator(buffer, true); + for (const auto& byte : msg) { + auto packet = accumulator.process_byte(byte); + if (packet.error()) { + REQUIRE(*packet.error() == PacketAccumulator::Error::IncorrectCrcCharacter); + return; + } + } + + REQUIRE(false); +} + +TEST_CASE("Packet without CRC") { + std::vector buffer(1000); + const auto& msg = "/some data!"; + + PacketAccumulator accumulator(buffer, false); + 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; + } + } +} + +TEST_CASE("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 + "garbage /some !a3G4" // Incorrect CRC character + "/some !a3D4" // correct package + "/garbage garbage garbage" // buffer overflow + "/some !a3D4"; // correct package + + std::vector received_packets; + std::vector occurred_errors; + + 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())); + } + } + + using enum PacketAccumulator::Error; + REQUIRE(occurred_errors == std::vector{CrcMismatch, PacketStartSymbolInPacket, IncorrectCrcCharacter, BufferOverflow}); + REQUIRE(received_packets == std::vector(4, "/some !")); +} + +TEST_CASE("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; + + PacketAccumulator accumulator(buffer, false); + 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())); + } + } + + using enum PacketAccumulator::Error; + REQUIRE(occurred_errors == std::vector{PacketStartSymbolInPacket, BufferOverflow}); + REQUIRE(received_packets == std::vector(4, "/some !")); +} diff --git a/src/test/parser_include_test.cpp b/src/test/parser_include_test.cpp new file mode 100644 index 0000000..05cf808 --- /dev/null +++ b/src/test/parser_include_test.cpp @@ -0,0 +1,14 @@ +// This code tests that the parser has all necessary dependencies included in its headers. +// We check that the code compiles. + +#include "arduino-dsmr-2/fields.h" +#include "arduino-dsmr-2/parser.h" + +using namespace arduino_dsmr_2; +using namespace fields; + +void P1Parser_some_function() { + const auto& msg = ""; + ParsedData data; + P1Parser::parse(&data, msg, std::size(msg), true); +} diff --git a/src/test/parser_test.cpp b/src/test/parser_test.cpp new file mode 100644 index 0000000..64b1777 --- /dev/null +++ b/src/test/parser_test.cpp @@ -0,0 +1,658 @@ +#include "arduino-dsmr-2/fields.h" +#include "arduino-dsmr-2/parser.h" +#include +#include + +using namespace arduino_dsmr_2; +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" + "1-0:2.8.2(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" + "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:31.7.0(001*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" + "0-1:24.4.0(1)\r\n" + "!f2C9\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, + /* 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_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> + data; + + auto res = P1Parser::parse(&data, msg, std::size(msg), true); + 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); + 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_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.current_l1 == 1.0f); + REQUIRE(data.power_delivered_l1 == 0.332f); + 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); +} + +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)); + std::cout << "Full error" << std::endl << fullError << std::endl; + 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 '!' + "!"; + + 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) == "Last dataline not CRLF terminated"); +} + +TEST_CASE("Should report an error if checksum is not found") { + 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) == "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"); +} + +TEST_CASE("OBIS id empty line") { + 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"; + + ParsedData data; + P1Parser::parse(&data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); + REQUIRE(data.power_delivered == 0.123f); +} + +TEST_CASE("Unit matching is case-insensitive") { + const auto& msg = "/ABC5MTR\r\n" + "\r\n" + "1-0:1.8.1(000001.000*kwh)\r\n" + "!\r\n"; + + ParsedData data; + P1Parser::parse(&data, msg, std::size(msg), /*unknown_error=*/false, /*check_crc=*/false); + REQUIRE(data.energy_delivered_tariff1 == 1.000f); +} + +TEST_CASE("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 = 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=*/false, /*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=*/false, /*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); +} diff --git a/src/test/test_data/encrypted_packet.bin b/src/test/test_data/encrypted_packet.bin new file mode 100644 index 0000000..1fc75c2 Binary files /dev/null and b/src/test/test_data/encrypted_packet.bin differ diff --git a/src/test/test_data/generate_encrypted_packet.py b/src/test/test_data/generate_encrypted_packet.py new file mode 100644 index 0000000..c532147 --- /dev/null +++ b/src/test/test_data/generate_encrypted_packet.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Generate an encrypted DLMS packet using AES128-GCM encryption. +# It is a packet that is sent by Luxembourg Smarty P1. +# The packet format is described in the file "specs/Luxembourg Smarty P1 specification v1.1.3.pdf" chapter "3.2.5 P1 software – Channel security" + +from binascii import unhexlify +from dlms_cosem.protocol.xdlms import GeneralGlobalCipher +from dlms_cosem.security import SecurityControlField, encrypt + +TELEGRAM_SAGEMCOM_T210_D_R = ( + '/EST5\\253710000_A\r\n' + '\r\n' + '1-3:0.2.8(50)\r\n' + '0-0:1.0.0(221006155014S)\r\n' + '1-0:1.8.0(006545766*Wh)\r\n' + '1-0:1.8.1(005017120*Wh)\r\n' + '1-0:1.8.2(001528646*Wh)\r\n' + '1-0:1.7.0(000000286*W)\r\n' + '1-0:2.8.0(000000058*Wh)\r\n' + '1-0:2.8.1(000000000*Wh)\r\n' + '1-0:2.8.2(000000058*Wh)\r\n' + '1-0:2.7.0(000000000*W)\r\n' + '1-0:3.8.0(000000747*varh)\r\n' + '1-0:3.8.1(000000000*varh)\r\n' + '1-0:3.8.2(000000747*varh)\r\n' + '1-0:3.7.0(000000000*var)\r\n' + '1-0:4.8.0(003897726*varh)\r\n' + '1-0:4.8.1(002692848*varh)\r\n' + '1-0:4.8.2(001204878*varh)\r\n' + '1-0:4.7.0(000000166*var)\r\n' + '!7EF9\r\n') + +# The code below creates full_frame with the format: +# Tag (0xDB) | SystemTitleLength (0x08) | SystemTitle (8 bytes) | LongFormLengthIndicator (0x82) | TotalLength (2 bytes, big endian) (SecurityControlFieldLength + InvocationCounterLength + CiphertextLength + GcmTagLength) | SecurityControlField (0x30) | InvocationCounter (4 bytes, big endian) (also called "frame counter") | EncryptedTelegram | GcmTag (12 bytes) + +system_title = "SYSTEMID".encode("ascii") # must be 8 bytes +invocation_counter = [0x10, 0x00, 0x00, 0x01] # must be 4 bytes (can have any value) big endian + +securityControlField = SecurityControlField(security_suite=0, authenticated=True, encrypted=True) +encryptedTelegramWithGcmTag = encrypt(security_control=securityControlField, + key=unhexlify("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), # the encryption key is supposed to be provided by the electricity provider. + + auth_key=unhexlify("00112233445566778899AABBCCDDEEFF"), # the auth_key is hardcoded and specified in "specs/Luxembourg Smarty P1 specification v1.1.3.pdf" + # chapter "3.2.5 P1 software – Channel security". + system_title=system_title, + invocation_counter=int.from_bytes(invocation_counter), + plain_text=TELEGRAM_SAGEMCOM_T210_D_R.encode("ascii")) + +full_frame = bytearray(GeneralGlobalCipher.TAG.to_bytes(1, "big", signed=False)) # = 0xDB +full_frame.extend([0x08]) # length of the title +full_frame.extend(system_title) # 8 bytes string +full_frame.extend([0x82]) # it means 2 length bytes follow + +total_len = 1 + 4 + len(encryptedTelegramWithGcmTag) # TotalLength = SecurityControlFieldLength + InvocationCounterLength + CiphertextWithGcmTagLength +full_frame.extend(total_len.to_bytes(2, "big", signed=False)) +full_frame.extend(securityControlField.to_bytes()) # = 0x30 +full_frame.extend(invocation_counter) # invocation counter (any 4 bytes value, big endian) +full_frame.extend(encryptedTelegramWithGcmTag) + +with open("encrypted_packet.bin", "wb") as f: + f.write(full_frame)