defmodule P1.Parser do use Combine import Combine.Parsers.Base import Combine.Parsers.Text @moduledoc """ Understands the P1 format of Smartmeters and translates them to elixir types As the specification says that all lines wih obis codes are optional and the order in which they appear is free, this parser works with the choice parser from the combine library this means all parsers that can parse a line with an obis code will be consulted and hopefully only one will return a valid result """ # credo:disable-for-this-file Credo.Check.Refactor.PipeChainStart @doc false def parse(line) do if (String.trim(line) == "") do {:ok, []} else case Combine.parse(line, parser()) do {:error, reason} -> {:error, reason} result -> {:ok, result} end end end @doc false def parse!(line) do case parse(line) do {:error, reason} -> raise reason {:ok, result} -> result end end defp parser do choice(nil, [ header_parser(), version_parser(), timestamp_parser(), equipment_identifier_parser(), tariff_indicator_parser(), total_energy_parser(), active_power_parser(), power_failures_parser(), long_power_failures_parser(), long_failures_log_parser(), voltage_swells_parser(), voltage_sags_parser(), voltage_parser(), amperage_parser(), active_power_phase_parser(), message_parser(), message_code_parser(), mbus_device_type_parser(), mbus_equipment_identifier_parser(), mbus_device_measurement_parser(), checksum_parser() ]) end # /ISk5MT382-1000 defp header_parser do map(char("/"), fn _ -> :header end) |> word_of(~r/\w{3}/) |> ignore(char("5")) |> word_of(~r/.+/) end # 1-3:0.2.8(50) defp version_parser do map(string("1-3:0.2.8"), fn _ -> :version end) |> parens(word()) end # 0-0:96.1.1(4B384547303034303436333935353037) # 0-1:96.1.0(3232323241424344313233343536373839) defp equipment_identifier_parser do map(string("0-"), fn _ -> :equipment_identifier end) |> digit() |> ignore(string(":96.1.1")) |> parens(hex()) end # 0-0:1.0.0(101209113020W) defp timestamp_parser do map(string("0-0:1.0.0"), fn _ -> :timestamp end) |> parens(map(word_of(~r/\d+[SW]/), &(timestamp(&1)))) end # 1-0:1.8.1(123456.789*kWh) # 1-0:1.8.2(123456.789*kWh) # 1-0:2.8.1(123456.789*kWh) # 1-0:2.8.2(123456.789*kWh) defp total_energy_parser do map(string("1-0:"), fn _ -> :total_energy end) |> map(digit(), &(direction(&1))) |> ignore(string(".8.")) |> map(digit(), &(tariff(&1))) |> parens(unit(float())) end # 0-0:96.14.0(0002) defp tariff_indicator_parser do map(string("0-0:96.14.0"), fn _ -> :tariff_indicator end) |> parens(map(integer(), &(tariff(&1)))) end # 1-0:1.7.0(01.193*kW) # 1-0:2.7.0(00.000*kW) defp active_power_parser do map(string("1-0:"), fn _ -> :active_power end) |> map(digit(), &(direction(&1))) |> ignore(string(".7.")) |> ignore(digit()) |> parens(unit(float())) end defp event(previous \\ nil) do previous |> parens(ts()) |> parens(unit(integer())) end # 0-0:96.7.21(00004) defp power_failures_parser do map(string("0-0:96.7.21"), fn _ -> :power_failures end) |> parens(integer()) end # 0-0:96.7.9(00002) defp long_power_failures_parser do map(string("0-0:96.7.9"), fn _ -> :long_power_failures end) |> parens(integer()) end # 1-0:99.97.0(2)(0-0:96.7.19)(101208152415W)(0000000240*s)(101208151004W)(0000000301*s) defp long_failures_log_parser do map(string("1-0:99.97.0"), fn _ -> :long_failures_log end) |> parens(integer()) |> ignore(string("(0-0:96.7.19)")) |> many(sequence([event()])) end # 1-0:32.32.0(00002) # 1-0:52.32.0(00001) # 1-0:72.32.0(00000) defp voltage_sags_parser do map(string("1-0:"), fn _ -> :voltage_sags end) |> map(both(digit(), digit(), &(join(&1, &2))), &(phase(&1))) |> ignore(string(".32.0")) |> parens(integer()) end # 1-0:32.36.0(00000) # 1-0:52.36.0(00003) # 1-0:72.36.0(00000) defp voltage_swells_parser do map(string("1-0:"), fn _ -> :voltage_swells end) |> map(both(digit(), digit(), &(join(&1, &2))), &(phase(&1))) |> ignore(string(".36.0")) |> parens(integer()) end # 1-0:32.7.0(220.1*V) # 1-0:52.7.0(220.2*V) # 1-0:72.7.0(220.3*V) defp voltage_parser do map(string("1-0:"), fn _ -> :voltage end) |> map(both(digit(), digit(), &(join(&1, &2))), &(phase(&1))) |> ignore(string(".7.0")) |> parens(unit(float(), string("V"))) end # 1-0:31.7.0(001*A) # 1-0:51.7.0(002*A) # 1-0:71.7.0(003*A) defp amperage_parser do map(string("1-0:"), fn _ -> :amperage end) |> map(both(digit(), digit(), &(join(&1, &2))), &(phase(&1))) |> ignore(string(".7.0")) |> parens(unit(integer(), string("A"))) end # 1-0:21.7.0(01.111*kW) # 1-0:41.7.0(02.222*kW) # 1-0:61.7.0(03.333*kW) # 1-0:22.7.0(04.444*kW) # 1-0:42.7.0(05.555*kW) # 1-0:62.7.0(06.666*kW) defp active_power_phase_parser do map(string("1-0:"), fn _ -> :active_power end) |> map(digit(), &(active_power_phase(&1))) |> map(digit(), &(direction(&1))) |> ignore(string(".7.0")) |> parens(unit(float(), string("kW"))) end defp message_parser do map(string("0-0:96.13.0"), fn _ -> :text_message end) |> parens(either(map(hex(), &(Hexate.decode(&1))), string(""))) end defp message_code_parser do map(string("0-0:96.13.1"), fn _ -> :message_code end) |> parens(either(map(hex(16), &(Hexate.decode(&1))), string(""))) end # 0-1:24.1.0(003) defp mbus_device_type_parser do map(string("0-"), fn _ -> :mbus_device_type end) |> digit() |> ignore(string(":24.1.0")) |> parens(integer()) end # 0-1:96.1.0(3232323241424344313233343536373839) defp mbus_equipment_identifier_parser do map(string("0-"), fn _ -> :mbus_equipment_identifier end) |> digit() |> ignore(string(":96.1.0")) |> parens(hex()) end # 0-1:24.2.1(101209112500W)(12785.123*m3) defp mbus_device_measurement_parser do map(string("0-"), fn _ -> :mbus_device_measurement end) |> digit() |> ignore(string(":24.2.1")) |> parens(ts()) |> parens(unit(float())) end # !DEB0 defp checksum_parser do map(char("!"), fn _ -> :checksum end) |> hex(4) end # Helper functions defp ts(previous \\ nil) do previous |> map(word_of(~r/\d+[SW]/), &(timestamp(&1))) end defp timestamp(text) do # as this is only valid in the netherlands, i can use this trick tz_offset = case String.last(text) do "S" -> "+02:00" "W" -> "+01:00" end [date | time] = text |> String.slice(0 .. String.length(text) - 1) |> String.codepoints |> Enum.chunk_every(2) |> Enum.map(&Enum.join/1) |> Enum.chunk_every(3) "20#{Enum.join(date, "-")}T#{Enum.join(hd(time), ":")}#{tz_offset}" end defp hex, do: word_of(~r/[0-9a-f]+/i) defp hex(size) when is_integer(size), do: word_of(~r/[0-9a-f]{#{size}}/i) defp hex(previous), do: previous |> word_of(~r/[0-9a-f]+/i) defp hex(previous, size), do: previous |> word_of(~r/[0-9a-f]{#{size}}/i) defp parens(previous, parser), do: previous |> between(ignore(char("(")), parser, ignore(char(")"))) defp unit(parser), do: pair_both(parser, pair_right(ignore(char("*")), word())) defp unit(parser, unit), do: pair_both(parser, pair_right(ignore(char("*")), unit)) defp join(a, b), do: Enum.join([a, b]) |> String.to_integer defp phase(x) do case x do l when l in [2, 31, 32] -> :l1 l when l in [4, 51, 52] -> :l2 l when l in [6, 71, 72] -> :l3 _ -> x end end defp active_power_phase(x) do case x do 2 -> :l1 4 -> :l2 6 -> :l3 end end defp direction(x) do case x do 1 -> :consume 2 -> :produce _ -> x end end defp tariff(x) do case x do 1 -> :low 2 -> :normal _ -> x end end end