Skip to content

Commit

Permalink
Rewrite library to use yecc (#26)
Browse files Browse the repository at this point in the history
* Rewrite library to use yecc

* Fix missing requested erlang version

* Add benchmark script
  • Loading branch information
robinvdvleuten authored Mar 13, 2024
1 parent 63961f1 commit 25b5610
Show file tree
Hide file tree
Showing 14 changed files with 653 additions and 674 deletions.
19 changes: 9 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: test

on:
push: {branches: main}
pull_request: {branches: main}
pull_request:
push:
branches:
- main

jobs:
test:
Expand All @@ -14,10 +16,10 @@ jobs:
fail-fast: false
matrix:
include:
- elixir: '1.11'
otp: 23
- elixir: '1.15'
otp: 26
- elixir: 1.12.x
otp: 22.3.x
- elixir: 1.16.x
otp: 26.2.x
lint: lint

steps:
Expand All @@ -37,7 +39,7 @@ jobs:
restore-keys: |
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
- run: mix deps.get --only test
- run: mix deps.get --check-locked

- run: mix format --check-formatted
if: ${{ matrix.lint }}
Expand All @@ -50,8 +52,5 @@ jobs:
- run: mix compile --warnings-as-errors
if: ${{ matrix.lint }}

- run: mix credo --strict
if: ${{ matrix.lint }}

- name: Run mix test
run: mix test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
/doc/
/erl_crash.dump
/ex_dsmr-*.tar
/src/*.erl
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ telegram =
])

DSMR.parse(telegram)
#=> {:ok, %DSMR.Telegram{checksum: %DSMR.Telegram.Checksum{value: "6796"}, data: [%DSMR.Telegram.COSEM{obis: %DSMR.Telegram.OBIS{channel: 3, code: "1-3:0.2.8", medium: :electricity, tags: [general: :version]}, values: [%DSMR.Telegram.Value{unit: nil, value: 42}]}, ...]}
#=> {:ok, %DSMR.Telegram{checksum: "6796", data: [{[1, 3, 0, 2 , 8], "42"}, ...]}
```

See the [online documentation](https://hexdocs.pm/dsmr) for more information.
Expand Down
54 changes: 54 additions & 0 deletions bench/benchmark.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
telegram_v4_2 =
Enum.join([
"/KFM5KAIFA-METER\r\n",
"\r\n",
"1-3:0.2.8(42)\r\n",
"0-0:1.0.0(161113205757W)\r\n",
"0-0:96.1.1(3960221976967177082151037881335713)\r\n",
"1-0:1.8.1(001581.123*kWh)\r\n",
"1-0:1.8.2(001435.706*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(0002)\r\n",
"1-0:1.7.0(02.027*kW)\r\n",
"1-0:2.7.0(00.000*kW)\r\n",
"0-0:96.7.21(00015)\r\n",
"0-0:96.7.9(00007)\r\n",
"1-0:99.97.0(3)(0-0:96.7.19)(000104180320W)(0000237126*s)(000101000001W)",
"(2147583646*s)(000102000003W)(2317482647*s)\r\n",
"1-0:32.32.0(00000)\r\n",
"1-0:52.32.0(00000)\r\n",
"1-0:72.32.0(00000)\r\n",
"1-0:32.36.0(00000)\r\n",
"1-0:52.36.0(00000)\r\n",
"1-0:72.36.0(00000)\r\n",
"0-0:96.13.1()\r\n",
"0-0:96.13.0()\r\n",
"1-0:31.7.0(000*A)\r\n",
"1-0:51.7.0(006*A)\r\n",
"1-0:71.7.0(002*A)\r\n",
"1-0:21.7.0(00.170*kW)\r\n",
"1-0:22.7.0(00.000*kW)\r\n",
"1-0:41.7.0(01.247*kW)\r\n",
"1-0:42.7.0(00.000*kW)\r\n",
"1-0:61.7.0(00.209*kW)\r\n",
"1-0:62.7.0(00.000*kW)\r\n",
"0-1:24.1.0(003)\r\n",
"0-1:96.1.0(4819243993373755377509728609491464)\r\n",
"0-1:24.2.1(161129200000W)(00981.443*m3)\r\n",
"!6796\r\n"
])

Benchee.run(
%{
"DSMR.parse/1" => fn ->
{:ok, _result} = DSMR.parse(telegram_v4_2, checksum: false)
end,
"DSMR.parse/1 with floats as decimals" => fn ->
{:ok, _result} = DSMR.parse(telegram_v4_2, checksum: false, floats: :decimals)
end,
"DSMR.parse/1 with checksum validation" => fn ->
{:ok, _result} = DSMR.parse(telegram_v4_2)
end
}
)
115 changes: 17 additions & 98 deletions lib/dsmr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,120 +3,39 @@ defmodule DSMR do
A library for parsing Dutch Smart Meter Requirements (DSMR) telegram data.
"""

alias DSMR.{CRC16, Telegram}

defmodule ChecksumError do
@type t() :: %__MODULE__{}

defexception [:message]
end

defmodule ParseError do
@type t() :: %__MODULE__{}
defexception [:checksum]

defexception [:message]
@impl true
def message(_exception), do: "checksum mismatch"
end

@doc """
Parses telegram data from a string and returns a struct.
If the telegram is parsed successfully, this function returns `{:ok, telegram}`
where `telegram` is a `DSMR.Telegram` struct. If the parsing fails, this
function returns `{:error, parse_error}` where `parse_error` is a `DSMR.ParseError` struct.
You can use `raise/1` with that struct or `Exception.message/1` to turn it into a string.
"""
@spec parse(String.t(), keyword()) :: {:ok, Telegram.t()} | {:error, ParseError.t()}
def parse(string, opts \\ []) do
validate_checksum = Keyword.get(opts, :checksum, true)
@spec parse(binary(), keyword()) :: {:ok, DSMR.Telegram.t()} | {:error, any()}
def parse(string, options \\ []) when is_binary(string) and is_list(options) do
validate_checksum = Keyword.get(options, :checksum, true)

with :ok <- valid_checksum?(string, validate_checksum),
{:ok, parsed, "", _, _, _} <- DSMR.Parser.telegram_parser(string),
{:ok, telegram} <- create_telegram(parsed) do
with {:ok, tokens} <- DSMR.Lexer.tokenize(string, options),
{:ok, telegram} <- :dsmr_parser.parse(tokens),
:ok <- valid_checksum?(telegram, string, validate_checksum) do
{:ok, telegram}
else
{:error, %ChecksumError{} = error} ->
{:error, error}

_ ->
{:error, %ParseError{message: "Could not parse #{inspect(string)}."}}
end
end

@doc """
Parses telegram data from a string and raises if the data cannot be parsed.
This function behaves exactly like `parse/1`, but returns the telegram directly
if parsed successfully or raises an exception otherwise.
"""
@spec parse!(String.t(), keyword()) :: Telegram.t()
def parse!(string, opts \\ []) do
case parse(string, opts) do
{:ok, telegram} -> telegram
{:error, error} -> raise error
end
end

defp create_telegram(parsed) do
telegram =
Enum.reduce(parsed, %Telegram{}, fn line, telegram ->
case line do
{:header, header} ->
%{telegram | header: Telegram.Header.new(header)}

{:cosem, [{:obis, [0, channel, 24, 1, 0]} | _value] = mbus} ->
append_mbus(telegram, channel, mbus)

{:cosem, [{:obis, [0, channel, 96, 1, 0]} | _value] = mbus} ->
append_mbus(telegram, channel, mbus)

{:cosem, [{:obis, [0, channel, 24, 2, 1]} | _values] = mbus} ->
append_mbus(telegram, channel, mbus)

{:cosem, cosem} ->
append_cosem(telegram, cosem)

{:footer, checksum} ->
%{telegram | checksum: Telegram.Checksum.new(checksum)}
end
end)

{:ok, telegram}
end

defp append_cosem(telegram, cosem) do
%{telegram | data: telegram.data ++ [Telegram.COSEM.new(cosem)]}
end

defp append_mbus(telegram, channel, cosem) do
if index = find_mbus_index(telegram.data, channel) do
new_mbus = Telegram.MBus.new(channel, cosem)

mbus = Enum.fetch!(telegram.data, index)
mbus = %{mbus | data: mbus.data ++ new_mbus.data}

%{telegram | data: List.replace_at(telegram.data, index, mbus)}
else
mbus = Telegram.MBus.new(channel, cosem)
%{telegram | data: telegram.data ++ [mbus]}
end
end

defp find_mbus_index(data, channel) when is_list(data) do
Enum.find_index(data, &find_mbus_index(&1, channel))
end

defp find_mbus_index(%Telegram.MBus{} = mbus, channel), do: mbus.channel == channel
defp find_mbus_index(_cosem, _channel), do: false

defp valid_checksum?(_string, false), do: :ok
defp valid_checksum?(_telegram, _string, false), do: :ok
# @TODO Only skip empty checksums when telegram version does not require it.
defp valid_checksum?(%DSMR.Telegram{checksum: ""}, _string, _), do: :ok

defp valid_checksum?(string, _) do
[telegram, checksum] = String.split(string, "!")
defp valid_checksum?(%DSMR.Telegram{} = telegram, string, _) do
[raw, _rest] = String.split(string, "!")
checksum = DSMR.CRC16.checksum(raw <> "!")

if CRC16.checksum(telegram <> "!") === String.trim(checksum) do
if checksum === telegram.checksum do
:ok
else
{:error, %ChecksumError{message: "Incorrect checksum"}}
{:error, %ChecksumError{checksum: checksum}}
end
end
end
Loading

0 comments on commit 25b5610

Please sign in to comment.