From a9750e4c61919378e9ec6e39586728cdab03a93a Mon Sep 17 00:00:00 2001 From: Chris D'Ambrosio Date: Tue, 12 Jul 2022 22:39:05 -0700 Subject: [PATCH 1/2] Support FILETRANSFER command --- lib/jeff/command.ex | 2 ++ lib/jeff/command/file_transfer.ex | 46 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 lib/jeff/command/file_transfer.ex diff --git a/lib/jeff/command.ex b/lib/jeff/command.ex index c1c8c1b..1bd38ba 100644 --- a/lib/jeff/command.ex +++ b/lib/jeff/command.ex @@ -48,6 +48,7 @@ defmodule Jeff.Command do ComSettings, EncryptionKey, EncryptionServer, + FileTransfer, LedSettings, OutputSettings, TextSettings @@ -117,6 +118,7 @@ defmodule Jeff.Command do defp encode(KEYSET, params), do: EncryptionKey.encode(params) defp encode(CHLNG, params), do: ChallengeData.encode(params) defp encode(SCRYPT, params), do: EncryptionServer.encode(params) + defp encode(FILETRANSFER, params), do: FileTransfer.encode(params) defp encode(ACURXSIZE, size: size), do: <> defp encode(ABORT, _params), do: nil diff --git a/lib/jeff/command/file_transfer.ex b/lib/jeff/command/file_transfer.ex new file mode 100644 index 0000000..f7546cc --- /dev/null +++ b/lib/jeff/command/file_transfer.ex @@ -0,0 +1,46 @@ +defmodule Jeff.Command.FileTransfer do + @moduledoc """ + File Transfer Command Settings + + OSDP v2.2 Specification Reference: 6.26 + """ + + defstruct type: 1, + total_size: 0, + offset: 0, + fragment_size: 0, + data: <<>> + + @type t :: %__MODULE__{ + type: 1..255, + total_size: non_neg_integer(), + offset: non_neg_integer(), + data: binary(), + } + + @type param() :: + {:type, 1..255} + | {:total_size, non_neg_integer()} + | {:offset, non_neg_integer()} + | {:fragment_size, non_neg_integer()} + | {:data, binary()} + @type params() :: t() | [param()] + + @spec new(params()) :: t() + def new(params) do + struct(__MODULE__, params) + end + + @spec encode(params()) :: binary() + def encode(params) do + settings = new(params) + + << + settings.type, + settings.total_size::size(4)-unit(8)-little, + settings.offset::size(4)-unit(8)-little, + settings.fragment_size::size(2)-unit(8)-little, + settings.data::binary + >> + end +end From 4990ae3b325f9e8b5797aafcc96bdbfae005b7cf Mon Sep 17 00:00:00 2001 From: Jon Carstens Date: Tue, 19 Jul 2022 17:21:39 -0600 Subject: [PATCH 2/2] Add `Jeff.file_transfer/3` This adds pieces to send file data to a device. It uses a functional core in `Jeff.Command.FileTransfer` to check each reply after a FILETRANSFER attempt to determine next steps for continuing the data transfer. The functional core allows for simpler testing --- lib/jeff.ex | 29 +++++- lib/jeff/command/file_transfer.ex | 103 ++++++++++++++++++-- lib/jeff/reply.ex | 2 + lib/jeff/reply/file_transfer_status.ex | 60 ++++++++++++ test/command/file_transfer_test.exs | 129 +++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 lib/jeff/reply/file_transfer_status.ex create mode 100644 test/command/file_transfer_test.exs diff --git a/lib/jeff.ex b/lib/jeff.ex index 43ad267..9e910b8 100644 --- a/lib/jeff.ex +++ b/lib/jeff.ex @@ -3,7 +3,7 @@ defmodule Jeff do Control an Access Control Unit (ACU) and send commands to a Peripheral Device (PD) """ - alias Jeff.{ACU, Command, Device, Reply} + alias Jeff.{ACU, Command, Command.FileTransfer, Device, Reply} @type acu() :: GenServer.server() @type device_opt() :: ACU.device_opt() @@ -85,4 +85,31 @@ defmodule Jeff do def set_com(acu, address, params) do ACU.send_command(acu, address, COMSET, params).data end + + @doc """ + Send file data to a PD + """ + @spec file_transfer(acu(), osdp_address(), binary()) :: + Reply.FileTransferStatus.t() | Reply.ErrorCode.t() + def file_transfer(acu, address, data) when is_binary(data) do + with %{name: PDCAP, data: caps} <- ACU.send_command(acu, address, CAP) do + max = caps[:receive_buffer_size] || 128 + + FileTransfer.command_set(data, max) + |> run_file_transfer(acu, address) + end + end + + defp run_file_transfer([cmd | rem], acu, address) do + ACU.send_command(acu, address, FILETRANSFER, Map.to_list(cmd)) + |> FileTransfer.adjust_from_reply(rem) + |> case do + {:cont, next, delay} -> + :timer.sleep(delay) + run_file_transfer(next, acu, address) + + {:halt, data} -> + data + end + end end diff --git a/lib/jeff/command/file_transfer.ex b/lib/jeff/command/file_transfer.ex index f7546cc..691d169 100644 --- a/lib/jeff/command/file_transfer.ex +++ b/lib/jeff/command/file_transfer.ex @@ -13,16 +13,17 @@ defmodule Jeff.Command.FileTransfer do @type t :: %__MODULE__{ type: 1..255, - total_size: non_neg_integer(), - offset: non_neg_integer(), + total_size: pos_integer(), + offset: pos_integer(), data: binary(), + fragment_size: pos_integer() } @type param() :: {:type, 1..255} - | {:total_size, non_neg_integer()} - | {:offset, non_neg_integer()} - | {:fragment_size, non_neg_integer()} + | {:total_size, pos_integer()} + | {:offset, pos_integer()} + | {:fragment_size, pos_integer()} | {:data, binary()} @type params() :: t() | [param()] @@ -31,7 +32,7 @@ defmodule Jeff.Command.FileTransfer do struct(__MODULE__, params) end - @spec encode(params()) :: binary() + @spec encode(params()) :: <<_::64>> def encode(params) do settings = new(params) @@ -43,4 +44,94 @@ defmodule Jeff.Command.FileTransfer do settings.data::binary >> end + + @doc """ + Create set of FileTransfer commands to run + + FileTransfers may require multiple command/reply pairs in order to transmit + all the data to the PD. This function helps chunk the data according to the + max byte length allowed by the PD. In most cases, you would run + `Jeff.capabilities/2` before this check for the Receive Buffer size reported + by the PD and use that as the max value. + + The first message will always be 128 bytes of data if the max value is larger + + You can then cycle through sending these commands and check the returned + FTSTAT reply (%Jeff.Reply.FileTransferStatus{}) with `adjust_from_reply/2` to + adjust the command set as needed + """ + @spec command_set(binary(), pos_integer()) :: [t()] + def command_set(data, max \\ 128) do + base = new(total_size: byte_size(data)) + chunk_data(data, base, max, []) + end + + @doc """ + Adjust file transfer command set based on the FTSTAT reply + + Mostly used internally to potentially adjust the remaining command set + based on the FTSTAT reply from the previous command. In some cases the + next set of commands may need to be adjusted or prevented and this provides + the functional core to make that decision + """ + @spec adjust_from_reply(Jeff.Reply.t(), [t()]) :: + {:cont, [t()], pos_integer()} + | {:halt, Jeff.Reply.FileTransferStatus.t() | Jeff.Reply.ErrorCode.t()} + def adjust_from_reply(%{name: NAK, data: error_code}, _commands), do: {:halt, error_code} + def adjust_from_reply(%{name: FTSTAT, data: ftstat}, []), do: {:halt, ftstat} + + def adjust_from_reply(%{name: FTSTAT, data: %{status: :finishing} = ftstat}, commands) do + # OSDP v2.2 Section 7.25 + # Finishing status requires we send an "idle" message until we get a different + # status. In idle message, fragment size == 0 and offset == total size + idle = hd(commands) + commands = maybe_adjust_message_size(ftstat, commands) + {:cont, [%{idle | fragment_size: 0, offset: idle.total_size} | commands], ftstat.delay} + end + + def adjust_from_reply(%{name: FTSTAT, data: %{status: status} = ftstat}, commands) + when status in [:ok, :processed, :rebooting] do + {:cont, maybe_adjust_message_size(ftstat, commands), ftstat.delay} + end + + def adjust_from_reply(%{name: FTSTAT, data: ftstat}, _commands), do: {:halt, ftstat} + + defp maybe_adjust_message_size(%{update_msg_max: max}, commands) + when not is_nil(max) and max > 0 do + base = hd(commands) + data = for %{data: d} <- commands, into: <<>>, do: d + chunk_data(data, base, max, []) + end + + defp maybe_adjust_message_size(_ftstat, commands), do: commands + + defp chunk_data(data, _base, _max, acc) when byte_size(data) == 0 do + Enum.reverse(acc) + end + + defp chunk_data(<>, base, max, []) when max >= 128 do + # First command must be 128 bytes in cases where the PD max receive buffer is + # more than 128 + chunk_data(rest, base, max, [%{base | data: data, fragment_size: 128}]) + end + + defp chunk_data(data, base, max, acc) when byte_size(data) >= max do + <> = data + cmd = %{base | data: frag, fragment_size: max, offset: next_offset(base, acc)} + chunk_data(rest, base, max, [cmd | acc]) + end + + defp chunk_data(data, base, _max, acc) do + frag_size = byte_size(data) + # cmd = new(total_size: total, data: data, fragment_size: frag_size, offset: o + frag_size) + cmd = %{base | data: data, fragment_size: frag_size, offset: next_offset(base, acc)} + Enum.reverse([cmd | acc]) + end + + # Start from the base offset + defp next_offset(%{offset: o}, []) when is_integer(o), do: o + # First command and no base offset, use 0 + defp next_offset(_base, []), do: 0 + # offset from the last command + defp next_offset(_base, [%{offset: o, fragment_size: fs} | _]), do: o + fs end diff --git a/lib/jeff/reply.ex b/lib/jeff/reply.ex index 2ed5724..c9b4fe0 100644 --- a/lib/jeff/reply.ex +++ b/lib/jeff/reply.ex @@ -39,6 +39,7 @@ defmodule Jeff.Reply do ComData, EncryptionClient, ErrorCode, + FileTransferStatus, IdReport, KeypadData, LocalStatus @@ -126,6 +127,7 @@ defmodule Jeff.Reply do defp decode(RAW, data), do: CardData.decode(data) defp decode(CCRYPT, data), do: EncryptionClient.decode(data) defp decode(RMAC_I, data), do: data + defp decode(FTSTAT, data), do: FileTransferStatus.decode(data) defp decode(_name, nil), do: nil defp decode(_name, <<>>), do: nil defp decode(name, data), do: Module.concat(__MODULE__, name).decode(data) diff --git a/lib/jeff/reply/file_transfer_status.ex b/lib/jeff/reply/file_transfer_status.ex new file mode 100644 index 0000000..7649f78 --- /dev/null +++ b/lib/jeff/reply/file_transfer_status.ex @@ -0,0 +1,60 @@ +defmodule Jeff.Reply.FileTransferStatus do + @moduledoc """ + File Transfer Status (osdp_FTSTAT) + + OSDP v2.2 Specification Reference: 7.25 + """ + + defstruct [ + :separate_poll_response?, + :leave_secure_channel?, + :interleave_ok?, + :delay, + :status, + :update_msg_max + ] + + @type status :: + :ok + | :processed + | :rebooting + | :finishing + | :abort + | :unrecognized_contents + | :malformed + | integer() + + @type t :: %__MODULE__{ + separate_poll_response?: boolean(), + leave_secure_channel?: boolean(), + interleave_ok?: boolean(), + delay: pos_integer(), + status: status(), + update_msg_max: pos_integer() + } + + @spec decode(binary()) :: t() + def decode( + <<_::5, spr::1, leave::1, interleave::1, delay::16-little, status::16-little-signed, + update_msg_max::16-little>> + ) do + %__MODULE__{ + separate_poll_response?: spr == 1, + leave_secure_channel?: leave == 1, + interleave_ok?: interleave == 1, + delay: delay, + status: decode_status(status), + update_msg_max: update_msg_max + } + end + + defp decode_status(0), do: :ok + defp decode_status(1), do: :processed + defp decode_status(2), do: :rebooting + defp decode_status(3), do: :finishing + defp decode_status(-1), do: :abort + defp decode_status(-2), do: :unrecognized_contents + defp decode_status(-3), do: :malformed + # All other statuses are reserved - Report the raw status for now + defp decode_status(status), do: status +end diff --git a/test/command/file_transfer_test.exs b/test/command/file_transfer_test.exs new file mode 100644 index 0000000..32ed7d3 --- /dev/null +++ b/test/command/file_transfer_test.exs @@ -0,0 +1,129 @@ +defmodule Jeff.FileTransferTest do + use ExUnit.Case, async: true + + alias Jeff.{Command.FileTransfer, Reply.FileTransferStatus} + + describe "command_set/2" do + test "data < 128 bytes" do + assert FileTransfer.command_set(<<1, 2>>) == [ + %FileTransfer{data: <<1, 2>>, fragment_size: 2, offset: 0, total_size: 2} + ] + end + + test "data > 128 bytes defaults first command size" do + ones = :binary.copy(<<1>>, 128) + twos = :binary.copy(<<2>>, 15) + [first, second] = FileTransfer.command_set(ones <> twos) + assert %FileTransfer{data: ^ones, fragment_size: 128, total_size: 143} = first + assert %FileTransfer{data: ^twos, fragment_size: 15, total_size: 143} = second + end + + test "custom max message length" do + [first, second] = FileTransfer.command_set(<<1, 2, 3, 4, 5, 6>>, 3) + assert %FileTransfer{data: <<1, 2, 3>>, fragment_size: 3, total_size: 6} = first + assert %FileTransfer{data: <<4, 5, 6>>, fragment_size: 3, total_size: 6} = second + end + + test "increments offsets" do + [first, second, third] = FileTransfer.command_set(:binary.copy(<<1>>, 1024), 512) + assert %FileTransfer{fragment_size: 128, offset: 0} = first + assert %FileTransfer{fragment_size: 512, offset: 128} = second + assert %FileTransfer{fragment_size: 384, offset: 640} = third + end + end + + describe "adjust_from_reply/2" do + test "halts when NAK errors" do + err = %Jeff.Reply.ErrorCode{code: 2} + assert {:halt, ^err} = FileTransfer.adjust_from_reply(%Jeff.Reply{name: NAK, data: err}, []) + end + + test "halts when no more commands" do + ftstat = %FileTransferStatus{status: :ok} + + assert {:halt, ^ftstat} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, []) + end + + test "adds idle message when finishing status" do + ftstat = %FileTransferStatus{status: :finishing} + cmd = %FileTransfer{total_size: 10, fragment_size: 3, offset: 7} + + assert {:cont, [idle, ^cmd], _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, [cmd]) + + assert idle.offset == idle.total_size + assert idle.fragment_size == 0 + end + + test "adds idle message and updates max length when finishing status" do + ftstat = %FileTransferStatus{status: :finishing, update_msg_max: 1} + cmd = %FileTransfer{total_size: 10, fragment_size: 2, offset: 8, data: <<1, 2>>} + + assert {:cont, [idle, one, two], _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, [cmd]) + + assert idle.offset == idle.total_size + assert idle.fragment_size == 0 + + assert %{total_size: 10, fragment_size: 1, offset: 8, data: <<1>>} = one + assert %{total_size: 10, fragment_size: 1, offset: 9, data: <<2>>} = two + end + + test "continues with updated max length for successful statuses" do + ftstat = %FileTransferStatus{status: :ok, update_msg_max: 1} + ftstat2 = %FileTransferStatus{status: :processed, update_msg_max: 1} + ftstat3 = %FileTransferStatus{status: :rebooting, update_msg_max: 1} + + commands = [%FileTransfer{total_size: 10, fragment_size: 2, offset: 8, data: <<1, 2>>}] + + expected = [ + %FileTransfer{total_size: 10, fragment_size: 1, offset: 8, data: <<1>>}, + %FileTransfer{total_size: 10, fragment_size: 1, offset: 9, data: <<2>>} + ] + + assert {:cont, ^expected, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands) + + assert {:cont, ^expected, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat2}, commands) + + assert {:cont, ^expected, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat3}, commands) + end + + test "continues with successful statuses" do + ftstat = %FileTransferStatus{status: :ok, update_msg_max: 0} + ftstat2 = %FileTransferStatus{status: :processed, update_msg_max: 0} + ftstat3 = %FileTransferStatus{status: :rebooting, update_msg_max: 0} + + commands = [ + %FileTransfer{total_size: 10, offset: 5, fragment_size: 5, data: <<1, 2, 3, 4, 5>>} + ] + + assert {:cont, ^commands, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands) + + assert {:cont, ^commands, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat2}, commands) + + assert {:cont, ^commands, _} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat3}, commands) + end + + test "halts with unsuccessful FTSTAT" do + bad = [:abort, :unrecognized_contents, :malformed, -5, -100] + + commands = [ + %FileTransfer{total_size: 10, offset: 5, fragment_size: 5, data: <<1, 2, 3, 4, 5>>} + ] + + for status <- bad do + ftstat = %FileTransferStatus{status: status} + + assert {:halt, ^ftstat} = + FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands) + end + end + end +end