Skip to content

Commit c52cda8

Browse files
committed
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
1 parent a9750e4 commit c52cda8

File tree

5 files changed

+315
-6
lines changed

5 files changed

+315
-6
lines changed

lib/jeff.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,31 @@ defmodule Jeff do
8585
def set_com(acu, address, params) do
8686
ACU.send_command(acu, address, COMSET, params).data
8787
end
88+
89+
@doc """
90+
Send file data to a PD
91+
"""
92+
@spec file_transfer(acu(), osdp_address(), binary()) ::
93+
Reply.FileTransferStatus.t() | Reply.ErrorCode.t()
94+
def file_transfer(acu, address, data) when is_binary(data) do
95+
with %{name: PDCAP, data: caps} <- ACU.send_command(acu, address, CAP) do
96+
max = caps[:receive_buffer_size] || 128
97+
98+
Jeff.Command.FileTransfer.command_set(data, max)
99+
|> run_file_transfer(acu, address)
100+
end
101+
end
102+
103+
defp run_file_transfer([cmd | rem], acu, address) do
104+
ACU.send_command(acu, address, FILETRANSFER, Map.to_list(cmd))
105+
|> Jeff.Command.FileTransfer.adjust_from_reply(rem)
106+
|> case do
107+
{:cont, next, delay} ->
108+
:timer.sleep(delay)
109+
run_file_transfer(next, acu, address)
110+
111+
{:halt, data} ->
112+
data
113+
end
114+
end
88115
end

lib/jeff/command/file_transfer.ex

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ defmodule Jeff.Command.FileTransfer do
1313

1414
@type t :: %__MODULE__{
1515
type: 1..255,
16-
total_size: non_neg_integer(),
17-
offset: non_neg_integer(),
16+
total_size: pos_integer(),
17+
offset: pos_integer(),
1818
data: binary(),
19+
fragment_size: pos_integer()
1920
}
2021

2122
@type param() ::
2223
{:type, 1..255}
23-
| {:total_size, non_neg_integer()}
24-
| {:offset, non_neg_integer()}
25-
| {:fragment_size, non_neg_integer()}
24+
| {:total_size, pos_integer()}
25+
| {:offset, pos_integer()}
26+
| {:fragment_size, pos_integer()}
2627
| {:data, binary()}
2728
@type params() :: t() | [param()]
2829

@@ -31,7 +32,7 @@ defmodule Jeff.Command.FileTransfer do
3132
struct(__MODULE__, params)
3233
end
3334

34-
@spec encode(params()) :: binary()
35+
@spec encode(params()) :: <<_::64>>
3536
def encode(params) do
3637
settings = new(params)
3738

@@ -43,4 +44,94 @@ defmodule Jeff.Command.FileTransfer do
4344
settings.data::binary
4445
>>
4546
end
47+
48+
@doc """
49+
Create set of FileTransfer commands to run
50+
51+
FileTransfers may require multiple command/reply pairs in order to transmit
52+
all the data to the PD. This function helps chunk the data according to the
53+
max byte length allowed by the PD. In most cases, you would run
54+
`Jeff.capabilities/2` before this check for the Receive Buffer size reported
55+
by the PD and use that as the max value.
56+
57+
The first message will always be 128 bytes of data if the max value is larger
58+
59+
You can then cycle through sending these commands and check the returned
60+
FTSTAT reply (%Jeff.Reply.FileTransferStatus{}) with `adjust_from_reply/2` to
61+
adjust the command set as needed
62+
"""
63+
@spec command_set(binary(), pos_integer()) :: [t()]
64+
def command_set(data, max \\ 128) do
65+
base = new(total_size: byte_size(data))
66+
chunk_data(data, base, max, [])
67+
end
68+
69+
@doc """
70+
Adjust file transfer command set based on the FTSTAT reply
71+
72+
Mostly used internally to potentially adjust the remaining command set
73+
based on the FTSTAT reply from the previous command. In some cases the
74+
next set of commands may need to be adjusted or prevented and this provides
75+
the functional core to make that decision
76+
"""
77+
@spec adjust_from_reply(Jeff.Reply.t(), [t()]) ::
78+
{:cont, [t()], pos_integer()}
79+
| {:halt, Jeff.Reply.FileTransferStatus.t() | Jeff.Reply.ErrorCode.t()}
80+
def adjust_from_reply(%{name: NAK, data: error_code}, _commands), do: {:halt, error_code}
81+
def adjust_from_reply(%{name: FTSTAT, data: ftstat}, []), do: {:halt, ftstat}
82+
83+
def adjust_from_reply(%{name: FTSTAT, data: %{status: :finishing} = ftstat}, commands) do
84+
# OSDP v2.2 Section 7.25
85+
# Finishing status requires we send an "idle" message until we get a different
86+
# status. In idle message, fragment size == 0 and offset == total size
87+
idle = hd(commands)
88+
commands = maybe_adjust_message_size(ftstat, commands)
89+
{:cont, [%{idle | fragment_size: 0, offset: idle.total_size} | commands], ftstat.delay}
90+
end
91+
92+
def adjust_from_reply(%{name: FTSTAT, data: %{status: status} = ftstat}, commands)
93+
when status in [:ok, :processed, :rebooting] do
94+
{:cont, maybe_adjust_message_size(ftstat, commands), ftstat.delay}
95+
end
96+
97+
def adjust_from_reply(%{name: FTSTAT, data: ftstat}, _commands), do: {:halt, ftstat}
98+
99+
defp maybe_adjust_message_size(%{update_msg_max: max}, commands)
100+
when not is_nil(max) and max > 0 do
101+
base = hd(commands)
102+
data = for %{data: d} <- commands, into: <<>>, do: d
103+
chunk_data(data, base, max, [])
104+
end
105+
106+
defp maybe_adjust_message_size(_ftstat, commands), do: commands
107+
108+
defp chunk_data(data, _base, _max, acc) when byte_size(data) == 0 do
109+
Enum.reverse(acc)
110+
end
111+
112+
defp chunk_data(<<data::binary-128, rest::binary>>, base, max, []) when max >= 128 do
113+
# First command must be 128 bytes in cases where the PD max receive buffer is
114+
# more than 128
115+
chunk_data(rest, base, max, [%{base | data: data, fragment_size: 128}])
116+
end
117+
118+
defp chunk_data(data, base, max, acc) when byte_size(data) >= max do
119+
<<frag::binary-size(max), rest::binary>> = data
120+
cmd = %{base | data: frag, fragment_size: max, offset: next_offset(base, acc)}
121+
chunk_data(rest, base, max, [cmd | acc])
122+
end
123+
124+
defp chunk_data(data, base, _max, acc) do
125+
frag_size = byte_size(data)
126+
# cmd = new(total_size: total, data: data, fragment_size: frag_size, offset: o + frag_size)
127+
cmd = %{base | data: data, fragment_size: frag_size, offset: next_offset(base, acc)}
128+
Enum.reverse([cmd | acc])
129+
end
130+
131+
# Start from the base offset
132+
defp next_offset(%{offset: o}, []) when is_integer(o), do: o
133+
# First command and no base offset, use 0
134+
defp next_offset(_base, []), do: 0
135+
# offset from the last command
136+
defp next_offset(_base, [%{offset: o, fragment_size: fs} | _]), do: o + fs
46137
end

lib/jeff/reply.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ defmodule Jeff.Reply do
3939
ComData,
4040
EncryptionClient,
4141
ErrorCode,
42+
FileTransferStatus,
4243
IdReport,
4344
KeypadData,
4445
LocalStatus
@@ -126,6 +127,7 @@ defmodule Jeff.Reply do
126127
defp decode(RAW, data), do: CardData.decode(data)
127128
defp decode(CCRYPT, data), do: EncryptionClient.decode(data)
128129
defp decode(RMAC_I, data), do: data
130+
defp decode(FTSTAT, data), do: FileTransferStatus.decode(data)
129131
defp decode(_name, nil), do: nil
130132
defp decode(_name, <<>>), do: nil
131133
defp decode(name, data), do: Module.concat(__MODULE__, name).decode(data)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule Jeff.Reply.FileTransferStatus do
2+
@moduledoc """
3+
File Transfer Status (osdp_FTSTAT)
4+
5+
OSDP v2.2 Specification Reference: 7.25
6+
"""
7+
8+
defstruct [
9+
:separate_poll_response?,
10+
:leave_secure_channel?,
11+
:interleave_ok?,
12+
:delay,
13+
:status,
14+
:update_msg_max
15+
]
16+
17+
@type status ::
18+
:ok
19+
| :processed
20+
| :rebooting
21+
| :finishing
22+
| :abort
23+
| :unrecognized_contents
24+
| :malformed
25+
| integer()
26+
27+
@type t :: %__MODULE__{
28+
separate_poll_response?: boolean(),
29+
leave_secure_channel?: boolean(),
30+
interleave_ok?: boolean(),
31+
delay: pos_integer(),
32+
status: status(),
33+
update_msg_max: pos_integer()
34+
}
35+
36+
@spec decode(binary()) :: t()
37+
def decode(
38+
<<_::5, spr::1, leave::1, interleave::1, delay::16-little, status::16-little-signed,
39+
update_msg_max::16-little>>
40+
) do
41+
%__MODULE__{
42+
separate_poll_response?: spr == 1,
43+
leave_secure_channel?: leave == 1,
44+
interleave_ok?: interleave == 1,
45+
delay: delay,
46+
status: decode_status(status),
47+
update_msg_max: update_msg_max
48+
}
49+
end
50+
51+
defp decode_status(0), do: :ok
52+
defp decode_status(1), do: :processed
53+
defp decode_status(2), do: :rebooting
54+
defp decode_status(3), do: :finishing
55+
defp decode_status(-1), do: :abort
56+
defp decode_status(-2), do: :unrecognized_contents
57+
defp decode_status(-3), do: :malformed
58+
# All other statuses are reserved - Report the raw status for now
59+
defp decode_status(status), do: status
60+
end

test/command/file_transfer_test.exs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
defmodule Jeff.FileTransferTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Jeff.{Command.FileTransfer, Reply.FileTransferStatus}
5+
6+
describe "command_set/2" do
7+
test "data < 128 bytes" do
8+
assert FileTransfer.command_set(<<1, 2>>) == [
9+
%FileTransfer{data: <<1, 2>>, fragment_size: 2, offset: 0, total_size: 2}
10+
]
11+
end
12+
13+
test "data > 128 bytes defaults first command size" do
14+
ones = :binary.copy(<<1>>, 128)
15+
twos = :binary.copy(<<2>>, 15)
16+
[first, second] = FileTransfer.command_set(ones <> twos)
17+
assert %FileTransfer{data: ^ones, fragment_size: 128, total_size: 143} = first
18+
assert %FileTransfer{data: ^twos, fragment_size: 15, total_size: 143} = second
19+
end
20+
21+
test "custom max message length" do
22+
[first, second] = FileTransfer.command_set(<<1, 2, 3, 4, 5, 6>>, 3)
23+
assert %FileTransfer{data: <<1, 2, 3>>, fragment_size: 3, total_size: 6} = first
24+
assert %FileTransfer{data: <<4, 5, 6>>, fragment_size: 3, total_size: 6} = second
25+
end
26+
27+
test "increments offsets" do
28+
[first, second, third] = FileTransfer.command_set(:binary.copy(<<1>>, 1024), 512)
29+
assert %FileTransfer{fragment_size: 128, offset: 0} = first
30+
assert %FileTransfer{fragment_size: 512, offset: 128} = second
31+
assert %FileTransfer{fragment_size: 384, offset: 640} = third
32+
end
33+
end
34+
35+
describe "adjust_from_reply/2" do
36+
test "halts when NAK errors" do
37+
err = %Jeff.Reply.ErrorCode{code: 2}
38+
assert {:halt, ^err} = FileTransfer.adjust_from_reply(%Jeff.Reply{name: NAK, data: err}, [])
39+
end
40+
41+
test "halts when no more commands" do
42+
ftstat = %FileTransferStatus{status: :ok}
43+
44+
assert {:halt, ^ftstat} =
45+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, [])
46+
end
47+
48+
test "adds idle message when finishing status" do
49+
ftstat = %FileTransferStatus{status: :finishing}
50+
cmd = %FileTransfer{total_size: 10, fragment_size: 3, offset: 7}
51+
52+
assert {:cont, [idle, ^cmd], _} =
53+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, [cmd])
54+
55+
assert idle.offset == idle.total_size
56+
assert idle.fragment_size == 0
57+
end
58+
59+
test "adds idle message and updates max length when finishing status" do
60+
ftstat = %FileTransferStatus{status: :finishing, update_msg_max: 1}
61+
cmd = %FileTransfer{total_size: 10, fragment_size: 2, offset: 8, data: <<1, 2>>}
62+
63+
assert {:cont, [idle, one, two], _} =
64+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, [cmd])
65+
66+
assert idle.offset == idle.total_size
67+
assert idle.fragment_size == 0
68+
69+
assert %{total_size: 10, fragment_size: 1, offset: 8, data: <<1>>} = one
70+
assert %{total_size: 10, fragment_size: 1, offset: 9, data: <<2>>} = two
71+
end
72+
73+
test "continues with updated max length for successful statuses" do
74+
ftstat = %FileTransferStatus{status: :ok, update_msg_max: 1}
75+
ftstat2 = %FileTransferStatus{status: :processed, update_msg_max: 1}
76+
ftstat3 = %FileTransferStatus{status: :rebooting, update_msg_max: 1}
77+
78+
commands = [%FileTransfer{total_size: 10, fragment_size: 2, offset: 8, data: <<1, 2>>}]
79+
80+
expected = [
81+
%FileTransfer{total_size: 10, fragment_size: 1, offset: 8, data: <<1>>},
82+
%FileTransfer{total_size: 10, fragment_size: 1, offset: 9, data: <<2>>}
83+
]
84+
85+
assert {:cont, ^expected, _} =
86+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands)
87+
88+
assert {:cont, ^expected, _} =
89+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat2}, commands)
90+
91+
assert {:cont, ^expected, _} =
92+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat3}, commands)
93+
end
94+
95+
test "continues with successful statuses" do
96+
ftstat = %FileTransferStatus{status: :ok, update_msg_max: 0}
97+
ftstat2 = %FileTransferStatus{status: :processed, update_msg_max: 0}
98+
ftstat3 = %FileTransferStatus{status: :rebooting, update_msg_max: 0}
99+
100+
commands = [
101+
%FileTransfer{total_size: 10, offset: 5, fragment_size: 5, data: <<1, 2, 3, 4, 5>>}
102+
]
103+
104+
assert {:cont, ^commands, _} =
105+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands)
106+
107+
assert {:cont, ^commands, _} =
108+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat2}, commands)
109+
110+
assert {:cont, ^commands, _} =
111+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat3}, commands)
112+
end
113+
114+
test "halts with unsuccessful FTSTAT" do
115+
bad = [:abort, :unrecognized_contents, :malformed, -5, -100]
116+
117+
commands = [
118+
%FileTransfer{total_size: 10, offset: 5, fragment_size: 5, data: <<1, 2, 3, 4, 5>>}
119+
]
120+
121+
for status <- bad do
122+
ftstat = %FileTransferStatus{status: status}
123+
124+
assert {:halt, ^ftstat} =
125+
FileTransfer.adjust_from_reply(%Jeff.Reply{name: FTSTAT, data: ftstat}, commands)
126+
end
127+
end
128+
end
129+
end

0 commit comments

Comments
 (0)