defmodule NetMD.Query do @moduledoc """ The NetMD query DSL: build command payloads and parse replies from printf/scanf-like format strings, as used by netmd-js and libnetmd. Format strings mix literal hex bytes with `%` fields: | Field | Meaning | |-------|------------------------------------------------------| | `%b` | 1-byte integer | | `%w` | 2-byte integer | | `%d` | 4-byte integer | | `%q` | 8-byte integer | | `%x` | binary preceded by 2 bytes of length | | `%s` | null-terminated binary preceded by 2 bytes of length | | `%z` | binary preceded by 1 byte of length | | `%*` | raw binary (rest of input when scanning) | | `%#` | rest of input as binary (scan only) | | `%?` | ignore one byte (scan only) | | `%B` | BCD-encoded 1-byte number | | `%W` | BCD-encoded 2-byte number | Integers are big-endian by default; `%<` overrides to little-endian for the following field (`%` explicitly selects big-endian. ## Examples iex> NetMD.Query.format("1808 %b 00", [2]) <<0x18, 0x08, 0x02, 0x00>> iex> NetMD.Query.scan(<<0x18, 0x08, 0x02, 0x00>>, "1808 %b 00") {:ok, [2]} """ defmodule ScanError do @moduledoc "Raised by `NetMD.Query.scan!/2` when a reply does not match." defexception [:message] end @typedoc "A value produced or consumed by a `%` field." @type value :: integer() | binary() @number_bits %{?b => 8, ?w => 16, ?d => 32, ?q => 64} @doc """ Build a binary query from `format` and `args`. Raises `ArgumentError` on malformed formats or mismatched arguments. """ @spec format(String.t(), [value()]) :: binary() def format(format, args \\ []) when is_binary(format) and is_list(args) do format |> tokenize() |> build(args, []) end @doc """ Parse `data` according to `format`, returning the field values in order. Literal bytes in the format must match the input exactly and the input must be fully consumed. """ @spec scan(binary(), String.t()) :: {:ok, [value()]} | {:error, term()} def scan(data, format) when is_binary(data) and is_binary(format) do format |> tokenize() |> match(data, []) end @doc """ Same as `scan/2` but raises `NetMD.Query.ScanError` on mismatch. """ @spec scan!(binary(), String.t()) :: [value()] def scan!(data, format) do case scan(data, format) do {:ok, values} -> values {:error, reason} -> raise ScanError, message: "scan mismatch: #{inspect(reason)} for format #{inspect(format)} " <> "on #{Base.encode16(data, case: :lower)}" end end @doc """ Encode a non-negative integer as binary-coded decimal in `length` bytes. iex> NetMD.Query.int_to_bcd(24, 1) <<0x24>> iex> NetMD.Query.int_to_bcd(9999, 2) <<0x99, 0x99>> """ @spec int_to_bcd(non_neg_integer(), pos_integer()) :: binary() def int_to_bcd(value, length \\ 1) when value >= 0 do digits = Integer.digits(value) if length(digits) > length * 2 do raise ArgumentError, "#{value} cannot fit in #{length} bytes in BCD" end padding = List.duplicate(0, length * 2 - length(digits)) for digit <- padding ++ digits, into: <<>>, do: <> end @doc """ Decode a binary-coded decimal binary to an integer. iex> NetMD.Query.bcd_to_int(<<0x99, 0x99>>) 9999 """ @spec bcd_to_int(binary()) :: non_neg_integer() def bcd_to_int(bcd) when is_binary(bcd) do for <>, reduce: 0 do acc -> acc * 10 + nibble end end # Tokenizer: hex pairs become {:const, byte}, fields {:fmt, char, endianness}. # A space may fall between the two digits of a hex pair, matching the # reference parser. defp tokenize(format) do format |> String.to_charlist() |> tokenize(nil, []) end defp tokenize([], nil, acc), do: Enum.reverse(acc) defp tokenize([], _half, _acc) do raise ArgumentError, "dangling hex digit in format" end defp tokenize([?\s | rest], half, acc), do: tokenize(rest, half, acc) defp tokenize([?% | _rest], half, _acc) when half != nil do raise ArgumentError, "field started in the middle of a hex pair" end defp tokenize([?%, ?<, char | rest], nil, acc), do: tokenize(rest, nil, [{:fmt, char, :little} | acc]) defp tokenize([?%, ?>, char | rest], nil, acc), do: tokenize(rest, nil, [{:fmt, char, :big} | acc]) defp tokenize([?%, char | rest], nil, acc), do: tokenize(rest, nil, [{:fmt, char, :big} | acc]) defp tokenize([char | rest], nil, acc), do: tokenize(rest, hex_digit(char), acc) defp tokenize([char | rest], half, acc), do: tokenize(rest, nil, [{:const, half * 16 + hex_digit(char)} | acc]) defp hex_digit(char) when char in ?0..?9, do: char - ?0 defp hex_digit(char) when char in ?a..?f, do: char - ?a + 10 defp hex_digit(char) when char in ?A..?F, do: char - ?A + 10 defp hex_digit(char) do raise ArgumentError, "unexpected character #{<>} in format" end # format/2 walker defp build([], [], acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() defp build([], leftover, _acc) do raise ArgumentError, "#{length(leftover)} unused arguments" end defp build([{:const, byte} | tokens], args, acc), do: build(tokens, args, [<> | acc]) defp build([{:fmt, char, endianness} | tokens], [arg | args], acc) when is_map_key(@number_bits, char) and is_integer(arg) do bits = Map.fetch!(@number_bits, char) part = case endianness do :big -> <> :little -> <> end build(tokens, args, [part | acc]) end defp build([{:fmt, ?x, _} | tokens], [arg | args], acc) when is_binary(arg) do build(tokens, args, [<> | acc]) end defp build([{:fmt, ?s, _} | tokens], [arg | args], acc) when is_binary(arg) do build(tokens, args, [<> | acc]) end defp build([{:fmt, ?z, _} | tokens], [arg | args], acc) when is_binary(arg) do build(tokens, args, [<> | acc]) end defp build([{:fmt, ?*, _} | tokens], [arg | args], acc) when is_binary(arg) do build(tokens, args, [arg | acc]) end # The reference implementation encodes both %B and %W through a one-byte # BCD conversion, so values are limited to 0..99 and %W always has a zero # high byte. Mirrored here to stay byte-for-byte equivalent. defp build([{:fmt, ?B, _} | tokens], [arg | args], acc) when arg in 0..99 do build(tokens, args, [int_to_bcd(arg, 1) | acc]) end defp build([{:fmt, ?W, _} | tokens], [arg | args], acc) when arg in 0..99 do build(tokens, args, [<<0, int_to_bcd(arg, 1)::binary>> | acc]) end defp build([{:fmt, char, _} | _tokens], [], _acc) do raise ArgumentError, "missing argument for %#{<>}" end defp build([{:fmt, char, _} | _tokens], [arg | _], _acc) do raise ArgumentError, "bad argument #{inspect(arg)} for %#{<>}" end # scan/2 walker defp match([], <<>>, acc), do: {:ok, Enum.reverse(acc)} defp match([], rest, _acc), do: {:error, {:trailing_bytes, byte_size(rest)}} defp match([{:const, byte} | tokens], data, acc) do case data do <<^byte, rest::binary>> -> match(tokens, rest, acc) <> -> {:error, {:mismatch, byte, got}} <<>> -> {:error, :input_exhausted} end end defp match([{:fmt, ??, _} | tokens], data, acc) do case data do <<_skipped, rest::binary>> -> match(tokens, rest, acc) # netmd-js's %? is `inputStack.shift()`, a harmless no-op past the end of # input, so a trailing %? after a %x that consumed the whole reply must not # fail. Real replies rely on this: playback_status2 on the MZ-N707 ends # exactly at the %x blob, with nothing left for the final %? to skip. <<>> -> match(tokens, <<>>, acc) end end defp match([{:fmt, char, endianness} | tokens], data, acc) when is_map_key(@number_bits, char) do bits = Map.fetch!(@number_bits, char) result = case {endianness, data} do {:big, <>} -> {value, rest} {:little, <>} -> {value, rest} _ -> :error end case result do {value, rest} -> match(tokens, rest, [value | acc]) :error -> {:error, :input_exhausted} end end defp match([{:fmt, char, _} | tokens], data, acc) when char in [?x, ?s] do case data do <> -> match(tokens, rest, [value | acc]) _ -> {:error, :input_exhausted} end end defp match([{:fmt, ?z, _} | tokens], data, acc) do case data do <> -> match(tokens, rest, [value | acc]) _ -> {:error, :input_exhausted} end end defp match([{:fmt, char, _} | tokens], data, acc) when char in [?*, ?#] do match(tokens, <<>>, [data | acc]) end defp match([{:fmt, ?B, _} | tokens], data, acc) do case data do <> -> match(tokens, rest, [bcd_to_int(bcd) | acc]) <<>> -> {:error, :input_exhausted} end end defp match([{:fmt, ?W, _} | tokens], data, acc) do case data do <> -> match(tokens, rest, [bcd_to_int(bcd) | acc]) _ -> {:error, :input_exhausted} end end defp match([{:fmt, char, _} | _tokens], _data, _acc) do raise ArgumentError, "unrecognized format field %#{<>}" end end