diff --git a/lib/wafer/dll/dll.ex b/lib/wafer/dll/dll.ex new file mode 100644 index 0000000..ce8a652 --- /dev/null +++ b/lib/wafer/dll/dll.ex @@ -0,0 +1,8 @@ +defmodule Wafer.DLL do + @moduledoc """ + Implements a safe bytewise data-link-layer which allows the transmission and + receiption of arbitrary erlang terms in tiny packets. + + See `Rx` and `Tx` for details. + """ +end diff --git a/lib/wafer/dll/rx.ex b/lib/wafer/dll/rx.ex new file mode 100644 index 0000000..da396ec --- /dev/null +++ b/lib/wafer/dll/rx.ex @@ -0,0 +1,71 @@ +defmodule Wafer.DLL.Rx do + defstruct [:buffer, :state] + alias __MODULE__ + + @moduledoc """ + Bytewise reception buffer for our data link layer. + """ + + defguardp is_byte(byte) when is_integer(byte) and byte >= 0 and byte <= 255 + + @byte_start 0x7D + @byte_end 0x7E + @byte_esc 0x7F + + @type t :: %Rx{buffer: binary, state: any} + + @doc """ + Initialize a new empty buffer reading for receiving. + """ + def init, do: %Rx{buffer: <<>>, state: :idle} + + @doc """ + Receive a byte into the buffer. + """ + @spec rx(t, byte) :: t + def rx(%Rx{state: state}, @byte_start) when state != :escaping, + do: %Rx{buffer: <<>>, state: :receiving} + + def rx(%Rx{state: :idle} = rx, _byte), do: rx + def rx(%Rx{state: :receiving} = rx, @byte_esc), do: %{rx | state: :escaping} + + def rx(%Rx{state: :receiving, buffer: <>}, @byte_end) do + if :erlang.crc32(buffer) == crc do + %Rx{buffer: :erlang.binary_to_term(buffer), state: :complete} + else + %Rx{buffer: buffer, state: {:error, :crc_mismatch}} + end + end + + def rx(%Rx{state: :receiving} = rx, @byte_end), do: %{rx | state: {:error, :too_short}} + + def rx(%Rx{state: :escaping, buffer: buffer}, byte) when is_byte(byte), + do: %Rx{state: :receiving, buffer: <>} + + def rx(%Rx{state: :receiving, buffer: buffer}, byte) when is_byte(byte), + do: %Rx{state: :receiving, buffer: <>} + + def rx(%Rx{state: :complete} = rx, _byte), do: rx + + @doc """ + Has the reception completed successfully? + """ + @spec complete?(t) :: boolean + def complete?(%Rx{state: :complete}), do: true + def complete?(%Rx{}), do: false + + @doc """ + Was there an error during reception? + """ + @spec error?(t) :: boolean + def error?(%Rx{state: {:error, _}}), do: true + def error?(%Rx{}), do: false + + @doc """ + Attempt to retrieve the received value from the buffer. + """ + @spec value(t) :: {:ok, any} | {:error, any} + def value(%Rx{state: :complete, buffer: buffer}), do: {:ok, buffer} + def value(%Rx{state: {:error, reason}}), do: {:error, reason} + def value(%Rx{}), do: {:error, :incomplete} +end diff --git a/lib/wafer/dll/tx.ex b/lib/wafer/dll/tx.ex new file mode 100644 index 0000000..1684271 --- /dev/null +++ b/lib/wafer/dll/tx.ex @@ -0,0 +1,51 @@ +defmodule Wafer.DLL.Tx do + defstruct [:buffer, :state] + alias __MODULE__ + + @moduledoc """ + A bytewise transmission buffer for our data link layer. + """ + + @byte_start 0x7D + @byte_end 0x7E + @byte_esc 0x7F + + @type t :: %Tx{buffer: binary, state: any} + + @doc """ + Initialize a new transmission buffer from a term. + """ + @spec init(any) :: t + def init(term) do + data = :erlang.term_to_binary(term) + crc = :erlang.crc32(data) + %Tx{buffer: <>, state: :idle} + end + + @doc """ + Get the next byte to transmit. + """ + @spec tx(t) :: {byte, t} | :done + def tx(%Tx{state: :idle} = tx), do: {@byte_start, %{tx | state: :transmitting}} + + def tx(%Tx{state: :transmitting, buffer: <<@byte_start::integer-size(8), _::binary>>} = rx), + do: {@byte_esc, %{rx | state: :escaping}} + + def tx(%Tx{state: :transmitting, buffer: <<@byte_end::integer-size(8), _::binary>>} = rx), + do: {@byte_esc, %{rx | state: :escaping}} + + def tx(%Tx{state: :transmitting, buffer: <<@byte_esc::integer-size(8), _::binary>>} = rx), + do: {@byte_esc, %{rx | state: :escaping}} + + def tx(%Tx{state: :transmitting, buffer: <>} = rx), + do: {byte, %{rx | state: :transmitting, buffer: buffer}} + + def tx(%Tx{state: :escaping, buffer: <>} = rx), + do: {byte, %{rx | state: :escaping, buffer: buffer}} + + def tx(%Tx{buffer: <<>>, state: :transmitting} = rx), do: {@byte_end, %{rx | state: :complete}} + def tx(%Tx{state: :complete}), do: :done + + def complete?(%Tx{state: :complete}), do: true + def complete?(%Tx{}), do: false +end diff --git a/test/dll/rx_test.exs b/test/dll/rx_test.exs new file mode 100644 index 0000000..14da6e4 --- /dev/null +++ b/test/dll/rx_test.exs @@ -0,0 +1,121 @@ +defmodule WaferDLLRxTest do + use ExUnit.Case, async: true + alias Wafer.DLL.Rx + + test "ignores bytes until it receives the start byte" do + rx = Rx.init() + + rx = Rx.rx(rx, 0) + assert rx.state == :idle + + rx = Rx.rx(rx, 1) + assert rx.state == :idle + + rx = Rx.rx(rx, 2) + assert rx.state == :idle + + rx = Rx.rx(rx, 0x7D) + assert rx.state == :receiving + end + + test "resynchronises on start byte" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0) + + assert rx.buffer == <<0>> + + rx = Rx.rx(rx, 0x7D) + assert rx.buffer == <<>> + end + + test "handles escaped bytes" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0x7F) + |> Rx.rx(0) + + assert rx.buffer == <<0>> + end + + test "handles double escapes" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0x7F) + |> Rx.rx(0x7F) + + assert rx.buffer == <<0x7F>> + end + + test "handles escaped start bytes" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0x7F) + |> Rx.rx(0x7D) + + assert rx.buffer == <<0x7D>> + end + + test "handles escaped end bytes" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0x7F) + |> Rx.rx(0x7E) + + assert rx.buffer == <<0x7E>> + end + + test "receives bytes until the end" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(167) + |> Rx.rx(142) + |> Rx.rx(61) + |> Rx.rx(132) + |> Rx.rx(131) + |> Rx.rx(100) + |> Rx.rx(0) + |> Rx.rx(1) + |> Rx.rx(97) + |> Rx.rx(126) + + assert rx.state == :complete + assert rx.buffer == :a + end + + test "results in an error when the payload is too short" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(0) + |> Rx.rx(0x7E) + + assert rx.state == {:error, :too_short} + end + + test "ignores non-start bytes when the payload is complete" do + rx = + Rx.init() + |> Rx.rx(0x7D) + |> Rx.rx(167) + |> Rx.rx(142) + |> Rx.rx(61) + |> Rx.rx(132) + |> Rx.rx(131) + |> Rx.rx(100) + |> Rx.rx(0) + |> Rx.rx(1) + |> Rx.rx(97) + |> Rx.rx(126) + |> Rx.rx(0) + + assert rx.state == :complete + assert rx.buffer == :a + end +end diff --git a/test/dll/tx_test.exs b/test/dll/tx_test.exs new file mode 100644 index 0000000..28470f2 --- /dev/null +++ b/test/dll/tx_test.exs @@ -0,0 +1,34 @@ +defmodule WaferDllTxTest do + use ExUnit.Case, async: true + alias Wafer.DLL.Tx + + test "it sends the start byte first" do + bytes = collect(:test) + byte = List.first(bytes) + assert byte == 0x7D + end + + test "it sends the end byte last" do + bytes = collect(:test) + byte = List.last(bytes) + assert byte == 0x7E + end + + test "it includes a valid CRC" do + crc0 = :erlang.crc32(:erlang.term_to_binary(:test)) + + <<_start::integer-size(8), crc1::integer-size(32), _::binary>> = + :binary.list_to_bin(collect(:test)) + + assert crc0 == crc1 + end + + defp collect(term), do: collect(Tx.init(term), []) + + defp collect(tx, bytes) do + case Tx.tx(tx) do + {byte, tx} -> collect(tx, [byte | bytes]) + :done -> Enum.reverse(bytes) + end + end +end diff --git a/test/dll_test.exs b/test/dll_test.exs new file mode 100644 index 0000000..9dbd341 --- /dev/null +++ b/test/dll_test.exs @@ -0,0 +1,28 @@ +defmodule WaferDLLTest do + use ExUnit.Case, async: true + alias Wafer.DLL.{Rx, Tx} + + test "synchronised transmission" do + value = {:marty, "McFly"} + + tx = Tx.init(value) + rx = Rx.init() + + {tx, rx} = transmit(tx, rx) + + assert Tx.complete?(tx) + assert Rx.complete?(rx) + assert {:ok, value} = Rx.value(rx) + end + + def transmit(%Tx{} = tx, %Rx{} = rx) do + case Tx.tx(tx) do + {byte, tx} -> + rx = Rx.rx(rx, byte) + transmit(tx, rx) + + :done -> + {tx, rx} + end + end +end