Packages

BACstack is a low-level Elixir implementation for the ASHRAE standard 135, BACnet - Building Automation and Controller network. In its current published state, v0.0.x, it can (only) act as a BACnet client for other BACnet devices.

Current section

Files

Jump to
bacstack lib bacnet stack client.ex
Raw

lib/bacnet/stack/client.ex

defmodule BACnet.Stack.Client do
@moduledoc """
The BACnet client is responsible for connecting the application to the BACnet transport protocol
and vice versa - it interfaces with the BACnet transport protocol, using the transport behaviour.
The client will take requests and send them to the BACnet transport protocol and ultimately listen for
frames from the BACnet transport protocol.
The application will receive process messages on the specified `notification_receiver`. If there's no
notification receiver and a confirmed service request is received, the client will automatically send
a Reject APDU with reason `:other` to the remote BACnet device,
the application won't be informed in any way.
BACnet BVLL and BACnet NPDU are directly forwarded to the application, without any processing.
For BACnet BVLL, the following message is sent:
```elixir
{:bacnet_transport, protocol_id, source_address, {:bvlc, bvlc}, portal}
```
This is the same as `t:BACnet.Stack.TransportBehaviour.transport_msg/0`.
For BACnet NSPDU, the following message is sent:
```elixir
{:bacnet_transport, protocol_id, source_address, {:network, bvlc, npci, nsdu}, portal}
```
This is the same as `t:BACnet.Stack.TransportBehaviour.transport_msg/0`.
For BACnet APDU, the following message is sent:
```elixir
{:bacnet_client, reference() | nil, apdu, {source_address, bvlc, npci}, pid()}
```
The reference is only present on confirmed service requests and is used for `reply/4`.
APDU is `t:BACnet.Protocol.apdu/0`. The PID is of the `Client` process.
For the rest, see `t:BACnet.Stack.TransportBehaviour.transport_msg/0`.
BACnet APDUs are decoded, checked against the internal cache and forwarded to the application.
The client has an internal cache for requests for the application and replies from the application
to the transport protocol in order to deduplicate request (i.e. when the application takes for the
remote BACnet device far too long and re-sends the APDU).
In case of duplicated request, the request will not be forwarded to the application,
instead it will be silently dropped and the application will possibly reply in a timely fashion,
or the APDU timeout will occur and the Client will reply instead - this situation may arise when
the response does not arrive at the remote BACnet device within
the remote BACnet device's configured APDU timeout window.
Individual APDU timeouts on a per-source basis can be applied using `set_apdu_timeouts/2`.
The client will also keep track of sent APDUs for confirmed service requests and re-send them
automatically, if no response arrives in the APDU timeout timeframe. If the maximum APDU retries
is reached, the request will be deleted and the application will get an APDU timeout response.
Invoke IDs are automatically managed on a per destination (with device ID, if given) basis to avoid
duplicated invoke IDs being sent to the same destination.
This mechanism can be disabled on startup and allows external management (or usage with care) if desired.
If the application takes too long to respond to a remote BACnet request, the client will automatically
send an Abort APDU and respond to a `reply` request from the application with an app timeout response.
If the application replies to a routed request (Forwarded NPDU), the client will automatically
set the correct destination address in `reply/4`.
The BACnet client will not automatically respond to Who-Is, Who-Has, Time-Synchronization, etc. queries,
as this is outside of the responsibility of this low level BACnet client implementation.
"""
alias BACnet.Protocol
alias BACnet.Protocol.APDU
alias BACnet.Protocol.APDU.ConfirmedServiceRequest
alias BACnet.Protocol.BvlcForwardedNPDU
alias BACnet.Protocol.Constants
alias BACnet.Protocol.IncompleteAPDU
alias BACnet.Protocol.NPCI
alias BACnet.Protocol.NpciTarget
alias BACnet.Stack.EncoderProtocol
alias BACnet.Stack.Segmentator
alias BACnet.Stack.SegmentsStore
alias BACnet.Stack.Telemetry
alias BACnet.Stack.TransportBehaviour
import BACnet.Internal, only: [is_server: 1, log_debug: 1]
require Constants
require Logger
use GenServer
@apdu_timer_offset 50
@apdu_timeout_multiplicator 0.9
@call_timeout Application.compile_env(:bacstack, :client_call_timeout, 60_000)
@retry_send_after_time Application.compile_env(:bacstack, :client_retry_send_after_time, 1000)
@default_apdu_retries 3
@default_apdu_timeout 3000
@default_window_size 16
defguardp is_apdu(type)
when type in [
APDU.Abort,
APDU.ComplexACK,
APDU.ConfirmedServiceRequest,
APDU.Error,
APDU.Reject,
APDU.SegmentACK,
APDU.SimpleACK,
APDU.UnconfirmedServiceRequest
]
defguardp is_apdu_resp(type)
when type in [
APDU.Abort,
APDU.ComplexACK,
APDU.Error,
APDU.Reject,
APDU.SegmentACK,
APDU.SimpleACK
]
defmodule ApduTimer do
@moduledoc """
Internal module for `BACnet.Stack.Client`.
It is used as APDU timer for outgoing APDUs.
It holds together all the necessary information
to track the APDU, time, retry count and contains
information that is used to reply to the application.
"""
@typedoc """
Representative type for its purpose.
"""
@type t :: %__MODULE__{
portal: TransportBehaviour.portal(),
destination: term(),
device_id: non_neg_integer() | nil,
apdu: Protocol.apdu(),
send_opts: Keyword.t(),
call_ref: term(),
retry_count: non_neg_integer(),
timer: reference(),
monotonic_time: integer()
}
@fields [
:portal,
:destination,
:device_id,
:apdu,
:send_opts,
:call_ref,
:retry_count,
:monotonic_time,
:timer
]
@enforce_keys @fields
defstruct @fields
end
defmodule ReplyTimer do
@moduledoc """
Internal module for `BACnet.Stack.Client`.
It is used as reply timer for incoming APDUs.
It holds together all the necessary information
to fire when the application does not respond
fast enough and will reply negatively to the
remote BACnet client.
"""
@typedoc """
Representative type for its purpose.
"""
@type t :: %__MODULE__{
bvlc: Protocol.bvlc(),
npci: NPCI.t(),
portal: TransportBehaviour.portal(),
service_req: Protocol.apdu(),
source_addr: term(),
device_id: non_neg_integer() | nil,
ref: reference(),
monotonic_time: integer(),
has_retried: boolean(),
timer: reference() | nil
}
@fields [
:bvlc,
:npci,
:portal,
:service_req,
:source_addr,
:device_id,
:ref,
:monotonic_time,
:has_retried,
:timer
]
@enforce_keys @fields
defstruct @fields
end
defmodule State do
@moduledoc """
Internal module for `BACnet.Stack.Client`.
It is used as `GenServer` state.
"""
@typedoc """
Key for the application reply timer.
"""
@type app_timer_key ::
{address :: term(), device_id :: non_neg_integer() | nil, invoke_id :: byte()}
@typedoc """
Representative type for its purpose.
"""
@type t :: %__MODULE__{
apdu_timers: %{optional(app_timer_key()) => BACnet.Stack.Client.ApduTimer.t()},
apdu_timeouts: BACnet.Stack.Client.apdu_timeouts(),
app_reply_mapping: %{
optional(reference()) => app_timer_key()
},
app_reply_timers: %{
optional(app_timer_key()) => BACnet.Stack.Client.ReplyTimer.t()
},
notification_receiver: [Process.dest()],
segmentator: Segmentator.server(),
segments_store: SegmentsStore.server(),
transport_broadcast_addr: term(),
transport_mod: module(),
transport_pid: TransportBehaviour.transport(),
transport_portal: TransportBehaviour.portal(),
opts: %{
apdu_retries: non_neg_integer(),
apdu_timeout: pos_integer(),
disable_app_timeout: boolean(),
disable_invoke_id_management: boolean(),
npci_source: NpciTarget.t() | nil,
segmented_rcv_window_overwrite: boolean(),
supervisor_mod: module()
}
}
@fields [
:apdu_timers,
:apdu_timeouts,
:app_reply_mapping,
:app_reply_timers,
:notification_receiver,
:segmentator,
:segments_store,
:transport_broadcast_addr,
:transport_mod,
:transport_pid,
:transport_portal,
:opts
]
@enforce_keys @fields
defstruct @fields
end
@typedoc """
Per-source APDU timeouts.
Device ID is only known, if the source transmits it with the APDU,
as such, most of the time it can be nil.
"""
@type apdu_timeouts :: %{
optional({source_address :: term(), device_id :: non_neg_integer() | nil}) =>
apdu_timeout :: non_neg_integer()
}
@typedoc """
Valid start options. For a description of each, see `start_link/1`.
"""
@type start_option ::
{:apdu_retries, pos_integer()}
| {:apdu_timeout, pos_integer()}
| {:disable_app_timeout, boolean()}
| {:disable_invoke_id_management, boolean()}
| {:notification_receiver, Process.dest() | [Process.dest()]}
| {:npci_source, NpciTarget.t()}
| {:segmentator, Segmentator.server()}
| {:segments_store, SegmentsStore.server()}
| {:segmented_rcv_window_overwrite, boolean()}
| {:transport, module() | {module(), TransportBehaviour.transport()}}
| GenServer.option()
@typedoc """
Represents a server process of the Client module.
"""
@type server :: GenServer.server()
@typedoc """
List of start options.
"""
@type start_options :: [start_option()]
@doc """
Starts and links the BACnet Client.
The following options are available, in addition to `t:GenServer.options/0`:
- `apdu_retries: pos_integer()` - Optional. The amount of APDU sending retries (defaults to 3).
Only applied to confirmed service requests.
- `apdu_timeout: pos_integer()` - Optional. The APDU timeout to be waiting for a response, in ms (defaults to 3000ms).
Only applied to confirmed service requests.
- `disable_app_timeout: boolean()` - Optional. Disables the application timeout mechanism.
- `disable_invoke_id_management: boolean()` - Optional. Disables `invoke_id` management and override in request payloads.
- `notification_receiver: Process.dest() | [Process.dest()]` - Optional. The destination to send messages to.
- `npci_source: NpciTarget.t()` - Optional. The NPCI target to use as source for outgoing APDUs.
- `segmentator: Segmentator.server()` - Required. The segmentator server to use.
- `segments_store: SegmentsStore.server()` - Required. The segments store server to use.
- `segmented_rcv_window_overwrite: boolean()` - Optional. Enable to overwrite the window size to 1 for incoming
segmented APDUs when it is bound to be routed (i.e. subject to BACnet/IP UDP packet re-ordering).
If you're having difficulty receiving segmented APDUs and the packets get routed on BACnet/IP,
you should consider enabling this and see if it helps.
- `transport: module() | {module(), TransportBehaviour.transport()}` - Required. The transport to use.
`module` is equivalent to `{module, module}` (the module name is registered process name).
The given transport must implement the `BACnet.Stack.TransportBehaviour` behaviour.
"""
@spec start_link(start_options()) :: GenServer.on_start()
def start_link(opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError, "start_link/1 expected a keyword list, got: #{inspect(opts)}"
end
{opts2, genserver_opts} =
Keyword.split(opts, [
:apdu_retries,
:apdu_timeout,
:disable_app_timeout,
:disable_invoke_id_management,
:notification_receiver,
:npci_source,
:segmentator,
:segments_store,
:segmented_rcv_window_overwrite,
:transport
])
validate_start_link_opts(opts2)
GenServer.start_link(__MODULE__, Map.new(opts2), genserver_opts)
end
@doc """
Add a source to the per-source APDU timeouts map. This is only used for receiving.
Each source is identified by source address and device ID (device ID
is only known if the source transmit it in the BACnet NPCI).
"""
@spec add_apdu_timeout(server(), term(), non_neg_integer() | nil, non_neg_integer()) :: :ok
def add_apdu_timeout(server, source_address, device_id, timeout)
when is_server(server) and
((is_integer(device_id) and device_id in 1..4_194_302) or is_nil(device_id)) and
is_integer(timeout) and timeout > 0 do
GenServer.call(server, {:add_apdu_timeout, source_address, device_id, timeout})
end
@doc """
Get the per-source APDU timeouts map. This is only used for receiving.
Each source is identified by source address and device ID (device ID
is only known if the source transmit it in the BACnet NPCI).
"""
@spec get_apdu_timeouts(server()) :: {:ok, apdu_timeouts()}
def get_apdu_timeouts(server) when is_server(server) do
GenServer.call(server, :get_apdu_timeouts)
end
@doc """
Get the transport used in the client.
"""
@spec get_transport(server()) ::
{module(), TransportBehaviour.transport(), TransportBehaviour.portal()}
def get_transport(server) when is_server(server) do
GenServer.call(server, :get_transport)
end
@doc """
Replies to a confirmed service request from a remote BACnet device.
The APDU frame may be segmentated by the client, depending on the
APDU size and maximum transmittable APDU size.
The reference identifies the request in the client. The request is hold
in the client to be able to apply application timeout constraints and
automatically respond on application timeout.
The remote BACnet device may send the same request again within the
configured APDU timeout and thus will be silently deduplicated (dropped).
The reference is given as part of the BACnet client notification process message.
If an automatic application timeout response has been sent (Abort APDU),
`{:error, :app_timeout}` will be returned when trying to reply to the
request.
See `send/4` for more information about `opts`.
The options `:max_apdu_length`, `:max_segments` and `:segmentation_supported` of `opts`
are automatically derived from the confirmed service request, if not explicitely given.
"""
@spec reply(server(), reference(), Protocol.apdu(), Keyword.t()) ::
:ok
| {:error, :app_timeout}
| {:error, term()}
| {:error, {Exception.t(), stacktrace :: Exception.stacktrace()}}
def reply(server, ref, %type{} = reply, opts \\ [])
when is_server(server) and is_reference(ref) and is_apdu_resp(type) and is_list(opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError,
"reply/4 expected opts to be a keyword list, " <>
"got: #{inspect(opts)}"
end
validate_send_opts(opts, "reply/4")
GenServer.call(server, {:reply, ref, reply, opts}, @call_timeout)
end
@doc """
Remove a source from the per-source APDU timeouts map. This is only used for receiving.
Each source is identified by source address and device ID (device ID
is only known if the source transmit it in the BACnet NPCI).
"""
@spec remove_apdu_timeout(server(), term(), non_neg_integer() | nil) :: :ok
def remove_apdu_timeout(server, source_address, device_id)
when is_server(server) and
((is_integer(device_id) and device_id in 1..4_194_302) or is_nil(device_id)) do
GenServer.call(server, {:remove_apdu_timeout, source_address, device_id})
end
@doc """
Sends the given APDU frame to the specified destination (remote BACnet device).
The APDU frame may be segmentated by the client.
The client will keep track of sent confirmed service requests and automatically
re-send the APDUs, if the APDU times out, without a response from the remote
BACnet server. If the maximum APDU retry count gets reached,
`{:error, :apdu_timeout}` will be returned.
This function returns, for confirmed service requests, after receiving the
response from the remote BACnet server, for everything else almost immediately.
As such, this function will block for maximum 60s (default compiled value), before
the backpressure mechanism will exit the caller.
Destination depends on the transport module and is validated against the
transport module.
BACnet Abort/Error/Reject are also returned in `:ok` tuples, not only
acknowledgements and requests.
When sending and the APDU is too large and thus is needed to be segmented,
the client will check accordinging to the given options,
whether segmentation can occur and how many segments are supported.
If the remote device does not support segmentation or a buffer overflow
would occur due to too many segments, this client will send an Abort APDU
to the remote device and return an error to the caller. The same will happen
for too long APDUs that can not be segmented.
See the `c:BACnet.Stack.TransportBehaviour.send/4` documentation for what `opts` can be.
In addition the following are available:
- `device_id: pos_integer()` - Optional. The remote BACnet device ID.
Only used for invoke ID management together with the destination address.
Specifying it allows invoke IDs to be used on a per device ID basis,
if multiple devices run on the same destination address (i.e. MS/TP to IP gateway).
Please note, if wrongfully used, this may lead to collisions and invalid data -
including replies sent to requests that were never meant for that request.
- `max_apdu_length: pos_integer()` - Optional. The maximum APDU length
the remote BACnet device supports (defaults to the transport max APDU length).
- `max_segments: pos_integer()` - Optional. Maximum amount of segments the
remote BACnet device can accept (defaults to 2).
- `segmentation_supported: Constants.segmentation()` - Optional. Which kind
of segmentation the remote BACnet device supports (defaults to none).
"""
@spec send(server(), term(), Protocol.apdu(), Keyword.t()) ::
:ok
| {:ok, Protocol.apdu()}
| {:error, :apdu_timeout}
| {:error, :apdu_too_long}
| {:error, :segmentation_not_supported}
| {:error, term()}
| {:error, {Exception.t(), stacktrace :: Exception.stacktrace()}}
| {:error, {term(), stacktrace :: Exception.stacktrace()}}
def send(server, destination, %type{} = data, opts \\ [])
when is_server(server) and is_apdu(type) and is_list(opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError,
"send/4 expected opts to be a keyword list, " <>
"got: #{inspect(opts)}"
end
validate_send_opts(opts, "send/4")
GenServer.call(server, {:send, destination, data, opts}, @call_timeout)
end
@doc """
Set the per-source APDU timeouts map. This is only used for receiving.
Each source is identified by source address and device ID (device ID
is only known if the source transmit it in the BACnet NPCI).
"""
@spec set_apdu_timeouts(server(), apdu_timeouts()) :: :ok
def set_apdu_timeouts(server, %{} = timeouts) when is_server(server) do
GenServer.call(server, {:set_apdu_timeouts, timeouts})
end
@doc """
Puts the subscriber in the `notification_receiver` list.
The list contains only unique elements, so this function call is idempotent.
After this function returns, the subscriber will start to receive
process messages as lined out by the module documentation.
If `subscriber` is a PID, it will be monitored and automatically removed.
This means for short lived processes, using the PID is recommended
as the PID is automatically removed when the process dies.
"""
@spec subscribe(server(), pid() | Process.dest() | GenServer.server()) :: :ok
def subscribe(server, subscriber) when is_server(server) and is_server(subscriber) do
GenServer.call(server, {:subscribe, subscriber})
end
@doc """
Removes the subscriber from the `notification_receiver` list.
After this function returns, the subscriber will stop receiving
process messages as lined out by the module documentation.
"""
@spec unsubscribe(server(), pid() | Process.dest() | GenServer.server()) :: :ok
def unsubscribe(server, subscriber) when is_server(server) and is_server(subscriber) do
GenServer.call(server, {:unsubscribe, subscriber})
end
@doc false
def init(opts) do
# Transform transport option into transport module and pid/name
{transport_mod, transport_pid} =
case opts.transport do
{mod, pid} -> {mod, pid}
mod -> {mod, mod}
end
state_opts =
opts
|> Map.drop([
:notification_receiver,
:segmentator,
:segments_store,
:transport
])
|> Map.put_new(:apdu_retries, @default_apdu_retries)
|> Map.put_new(:apdu_timeout, @default_apdu_timeout)
|> Map.put_new(:disable_app_timeout, false)
|> Map.put_new(:disable_invoke_id_management, false)
|> Map.put_new(:npci_source, nil)
|> Map.put_new(:segmented_rcv_window_overwrite, false)
state = %State{
apdu_timers: %{},
apdu_timeouts: %{},
app_reply_mapping: %{},
app_reply_timers: %{},
notification_receiver: List.wrap(Map.get(opts, :notification_receiver, [])),
segmentator: opts.segmentator,
segments_store: opts.segments_store,
transport_broadcast_addr: transport_mod.get_broadcast_address(transport_pid),
transport_mod: transport_mod,
transport_pid: transport_pid,
transport_portal: transport_mod.get_portal(transport_pid),
opts: state_opts
}
log_debug(fn -> "Client: Started on #{inspect(self())}" end)
{:ok, state}
end
@doc false
def handle_call(
{:add_apdu_timeout, source_address, device_id, timeout},
_from,
%State{apdu_timeouts: apdu_timeouts} = state
) do
log_debug(fn -> "Client: Received add_apdu_timeout request" end)
new_state = %{
state
| apdu_timeouts: Map.put(apdu_timeouts, {source_address, device_id}, timeout)
}
{:reply, :ok, new_state}
end
def handle_call(:get_apdu_timeouts, _from, %State{} = state) do
log_debug(fn -> "Client: Received get_apdu_timeouts request" end)
{:reply, {:ok, state.apdu_timeouts}, state}
end
def handle_call(
{:remove_apdu_timeout, source_address, device_id},
_from,
%State{apdu_timeouts: apdu_timeouts} = state
) do
log_debug(fn -> "Client: Received remove_apdu_timeout request" end)
new_state = %{state | apdu_timeouts: Map.delete(apdu_timeouts, {source_address, device_id})}
{:reply, :ok, new_state}
end
def handle_call({:set_apdu_timeouts, %{} = new_value}, _from, %State{} = state) do
log_debug(fn -> "Client: Received set_apdu_timeouts request" end)
new_state = %{state | apdu_timeouts: new_value}
{:reply, :ok, new_state}
end
def handle_call(:get_transport, _from, %State{} = state) do
log_debug(fn -> "Client: Received get_transport request" end)
{:reply, {state.transport_mod, state.transport_pid, state.transport_portal}, state}
end
def handle_call(
{:transport_call, function, args},
_from,
%State{transport_mod: trans_mod} = state
)
when is_atom(function) and is_list(args) do
log_debug(fn -> "Client: Received transport_call request" end)
result =
try do
case apply(trans_mod, function, args) do
{:ok, _term} = term -> term
{:error, _err} = term -> term
term -> {:ok, term}
end
catch
_kind, e -> {:error, {e, __STACKTRACE__}}
end
{:reply, result, state}
end
def handle_call(:get_broadcast_address, _from, %State{transport_mod: trans_mod} = state) do
log_debug(fn -> "Client: Received get_broadcast_address request" end)
result =
try do
brd = trans_mod.get_broadcast_address(state.transport_pid)
{:ok, brd}
catch
_kind, e -> {:error, {e, __STACKTRACE__}}
end
{:reply, result, state}
end
def handle_call({:subscribe, pid}, _from, %State{} = state) do
log_debug(fn -> "Client: Received subscribe request" end)
new_state =
Map.update!(state, :notification_receiver, fn list ->
if Enum.member?(list, pid) do
list
else
# Monitor PIDs, because they should be removed
# once the subscribing process dies
if is_pid(pid) do
Process.monitor(pid)
end
[pid | list]
end
end)
{:reply, :ok, new_state}
end
def handle_call({:unsubscribe, pid}, _from, %State{} = state) do
log_debug(fn -> "Client: Received unsubscribe request" end)
new_state =
Map.update!(state, :notification_receiver, fn list ->
List.delete(list, pid)
end)
{:reply, :ok, new_state}
end
def handle_call(
{:send, destination, %{} = data, opts},
from,
%State{transport_mod: trans_mod} = state
) do
log_debug(fn -> "Client: Received send request" end)
try do
trans_mod.is_valid_destination(destination)
catch
_kind, e -> {:reply, {:error, {e, __STACKTRACE__}}, state}
else
is_valid_dest ->
if is_valid_dest do
if state.opts.disable_invoke_id_management and
Map.has_key?(data, :invoke_id) and
Map.has_key?(
state.apdu_timers,
{destination, opts[:device_id], Map.get(data, :invoke_id)}
) do
{:reply, {:error, :duplicate_invoke_id}, state}
else
needs_tracking = is_struct(data, ConfirmedServiceRequest)
case send_data(
data,
opts,
destination,
state.transport_portal,
opts[:device_id],
needs_tracking,
from,
state,
false
) do
{:ok, new_state} ->
if needs_tracking, do: {:noreply, new_state}, else: {:reply, :ok, new_state}
{:retry, new_state} ->
# We don't have a free invoke_id, so retry later
Process.send_after(
self(),
{:"$gen_call", from, {:send, destination, data, opts}},
@retry_send_after_time
)
{:noreply, new_state}
term ->
{:reply, term, state}
end
end
else
{:reply, {:error, :invalid_destination}, state}
end
end
end
def handle_call({:reply, ref, %{} = data, opts}, _from, %State{} = state) do
log_debug(fn -> "Client: Received reply request" end)
reply_key = Map.get(state.app_reply_mapping, ref, ref)
{reply, new_state} =
case Map.fetch(state.app_reply_timers, reply_key) do
{:ok, reply} ->
destination =
case reply.bvlc do
%BvlcForwardedNPDU{} = forwarded ->
{forwarded.originating_ip, forwarded.originating_port}
_else ->
reply.source_addr
end
new_opts =
opts
|> Keyword.put_new(:max_apdu_length, reply.service_req.max_apdu)
|> Keyword.put_new_lazy(:max_segments, fn ->
case reply.service_req.max_segments do
# BTL-2017: Unspecified might be as low as 2 and is kept for backwards compatibility
:unspecified -> 2
val -> val
end
end)
|> Keyword.put_new_lazy(:segmentation_supported, fn ->
if reply.service_req.segmented_response_accepted do
Constants.macro_assert_name(:segmentation, :segmented_receive)
end
end)
Telemetry.execute_client_inc_apdu_reply(self(), data, new_opts, reply, state)
case send_data(
data,
new_opts,
destination,
reply.portal,
reply.device_id,
false,
nil,
state,
true
) do
{:ok, new_state} ->
if reply.timer, do: Process.cancel_timer(reply.timer)
new_state2 =
new_state
|> Map.update!(:app_reply_mapping, fn mapping ->
Map.delete(mapping, reply_key)
end)
|> Map.update!(:app_reply_timers, fn timers ->
Map.delete(timers, reply_key)
end)
{:ok, new_state2}
{:error, _err} = err ->
{err, state}
end
:error ->
{{:error, :app_timeout}, state}
end
{:reply, reply, new_state}
end
def handle_call(_msg, _from, state) do
{:noreply, state}
end
@doc false
def handle_cast(_msg, state) do
{:noreply, state}
end
@doc false
def handle_info(
{:bacnet_transport, _proto, source_address, {:bvlc, _bvlc}, _portal} = data,
%State{notification_receiver: dest} = state
) do
log_debug(fn ->
"Client: Got BACnet stack BVLC data from #{inspect(source_address)}, data: #{inspect(data)}"
end)
Telemetry.execute_client_transport_message(self(), data, state)
send_process_dest(dest, data)
{:noreply, state}
end
def handle_info(
{:bacnet_transport, _proto, source_address, {:network, _bvlc, _npci, _nsdu}, _portal} =
data,
%State{notification_receiver: dest} = state
) do
log_debug(fn ->
"Client: Got BACnet stack NSDU data from #{inspect(source_address)}, data: #{inspect(data)}"
end)
Telemetry.execute_client_transport_message(self(), data, state)
send_process_dest(dest, data)
{:noreply, state}
end
def handle_info(
{:bacnet_transport, _proto, source_address, {:apdu, bvlc, npci, raw_apdu} = cb_data,
portal} = data,
%State{} = state
) do
log_debug(fn ->
"Client: Got BACnet stack APDU data from #{inspect(source_address)}, data: #{inspect(cb_data)}"
end)
Telemetry.execute_client_transport_message(self(), data, state)
new_state = handle_raw_apdu(source_address, bvlc, npci, raw_apdu, portal, state)
{:noreply, new_state}
end
def handle_info({:apdu_timer, key}, %State{opts: %{apdu_retries: max_retry}} = state) do
# If remote server takes too long to reply, send APDU again
# for n times and if still no reply, abort and reply to call
log_debug(fn -> "Client: Received APDU timer message for #{inspect(key)}" end)
new_state =
case Map.fetch(state.apdu_timers, key) do
{:ok, %ApduTimer{retry_count: ^max_retry} = timer} ->
log_debug(fn ->
"Client: APDU timer #{inspect(key)} has reached max retry count, removing"
end)
Telemetry.execute_client_request_apdu_timer(self(), timer, state)
GenServer.reply(timer.call_ref, {:error, :apdu_timeout})
%{state | apdu_timers: Map.delete(state.apdu_timers, key)}
{:ok, %ApduTimer{} = timer} ->
case send_data(
timer.apdu,
timer.send_opts,
timer.destination,
timer.portal,
timer.device_id,
# No tracking because we update the timer "inline"
false,
nil,
state,
# Skip check because we "lock" the invoke ID using the APDU timer
true
) do
{:ok, new_state} ->
%{
new_state
| apdu_timers:
Map.update!(state.apdu_timers, key, fn timer ->
%{
timer
| retry_count: timer.retry_count + 1,
timer:
Process.send_after(
self(),
{:apdu_timer, key},
state.opts.apdu_timeout + @apdu_timer_offset
)
}
end)
}
{:error, err} ->
log_debug(fn ->
"Client: Error on trying to re-send APDU on APDU timeout, error: #{inspect(err)}"
end)
state
end
:error ->
state
end
{:noreply, new_state}
end
def handle_info({:reply_timer, reply_key}, %State{} = state) do
# If app takes too long to reply to request, send abort APDU to remote device
log_debug(fn -> "Client: Received reply timer message for #{inspect(reply_key)}" end)
new_state =
case Map.fetch(state.app_reply_timers, reply_key) do
{:ok, %ReplyTimer{} = reply} ->
apdu = %APDU.Abort{
sent_by_server: true,
invoke_id: reply.service_req.invoke_id,
reason: Constants.macro_assert_name(:abort_reason, :application_exceeded_reply_time)
}
Telemetry.execute_client_inc_apdu_timeout(self(), reply, state)
case send_data(
apdu,
get_reply_opts_for_npci(apdu, :original_unicast, reply.npci, state),
reply.source_addr,
reply.portal,
reply.device_id,
false,
nil,
state,
true
) do
{:ok, new_state} ->
%{
new_state
| app_reply_mapping: Map.delete(state.app_reply_mapping, reply.ref),
app_reply_timers: Map.delete(state.app_reply_timers, reply_key)
}
{:error, err} ->
log_debug(fn ->
"Client: Encountered error during app reply timeout when sending APDU abort reply, error: #{inspect(err)}"
end)
if reply.has_retried do
# We have already retried sending the Abort APDU, we do not do it again,
# clean up regardless
%{
state
| app_reply_mapping: Map.delete(state.app_reply_mapping, reply.ref),
app_reply_timers: Map.delete(state.app_reply_timers, reply_key)
}
else
# Retry in 10ms again once
timers =
Map.put(state.app_reply_timers, reply_key, %{
reply
| timer: Process.send_after(self(), {:reply_timer, reply_key}, 10),
has_retried: true
})
%{state | app_reply_timers: timers}
end
end
:error ->
state
end
{:noreply, new_state}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, %State{} = state) do
# Remove dead processes from our notification receiver list
new_state =
Map.update!(state, :notification_receiver, fn list ->
List.delete(list, pid)
end)
{:noreply, new_state}
end
# def handle_info({:DOWN, _ref, _type, _object, _reason}, %State{} = state)
# when object == state.transport_pid do
# # When transport goes down, go down too - we need to get initialized from start
# {:stop, :transport_down, state}
# end
def handle_info(_msg, state) do
{:noreply, state}
end
@spec handle_raw_apdu(
term(),
Protocol.bvlc(),
NPCI.t(),
binary(),
TransportBehaviour.portal(),
State.t()
) :: State.t()
defp handle_raw_apdu(
source_address,
bvlc,
%NPCI{} = npci,
raw_apdu,
portal,
%State{transport_mod: trans_mod} = state
) do
case APDU.decode(raw_apdu) do
{:ok, apdu} ->
Telemetry.execute_client_inc_apdu(self(), source_address, bvlc, npci, apdu, state)
handle_apdu(source_address, bvlc, npci, apdu, portal, state)
{:incomplete, %IncompleteAPDU{} = incomplete} ->
log_debug(fn ->
"Client: Received fragmented APDU from #{inspect(source_address)}, " <>
"invoke_id: #{incomplete.invoke_id}, seq_number: #{incomplete.sequence_number}, " <>
"more_follows: #{inspect(incomplete.more_follows)}"
end)
incomplete_apdu =
if state.opts.segmented_rcv_window_overwrite and
trans_mod.is_destination_routed(state.transport_pid, source_address) do
IncompleteAPDU.set_window_size(incomplete, 1)
else
incomplete
end
case SegmentsStore.segment(
state.segments_store,
incomplete_apdu,
trans_mod,
portal,
source_address,
destination: npci.source
) do
{:ok, complete_data} ->
log_debug(fn ->
"Client: Completed fragmented APDU from #{inspect(source_address)}, invoke_id: #{incomplete.invoke_id}"
end)
Telemetry.execute_client_inc_apdu_segmentation_completed(
self(),
source_address,
bvlc,
npci,
raw_apdu,
complete_data,
incomplete_apdu,
state
)
handle_raw_apdu(source_address, bvlc, npci, complete_data, portal, state)
:incomplete ->
Telemetry.execute_client_inc_apdu_segmentation_incomplete(
self(),
source_address,
bvlc,
npci,
raw_apdu,
incomplete_apdu,
state
)
state
{:error, err, cancelled} ->
log_debug(fn ->
"Client: Got error from segments store for #{inspect(source_address)}, invoke_id: #{incomplete.invoke_id}, " <>
"cancelled: #{inspect(cancelled)}, error: #{inspect(err)}"
end)
Telemetry.execute_client_inc_apdu_segmentation_error(
self(),
source_address,
bvlc,
npci,
raw_apdu,
incomplete_apdu,
err,
cancelled,
state
)
state
end
{:error, err} ->
Telemetry.execute_client_inc_apdu_decode_error(
self(),
source_address,
bvlc,
npci,
raw_apdu,
err,
state
)
if is_tuple(err) and is_atom(elem(err, 0)) and
String.starts_with?("#{elem(err, 0)}", "unknown_") do
log_debug(fn ->
"Client: Encountered unknown enumeration (constant) while decoding APDU, " <>
if(npci.expects_reply, do: "sending Reject APDU to source, ", else: "") <>
"error: #{inspect(err)}"
end)
if npci.expects_reply do
send_reply_for_unknown_enumeration(
source_address,
bvlc,
npci,
raw_apdu,
portal,
state
)
else
state
end
else
log_debug(fn -> "Client: Got invalid APDU data, error: #{inspect(err)}" end)
state
end
end
# rescue
# e ->
# log_debug(fn ->
# "Client: Encountered exception while decoding APDU, " <>
# "exception: #{inspect(Exception.message(e))}"
# end)
# state
end
@spec handle_apdu(
term(),
Protocol.bvlc(),
NPCI.t(),
Protocol.apdu(),
TransportBehaviour.portal(),
State.t()
) :: State.t()
defp handle_apdu(source_address, bvlc, npci, apdu, portal, state)
# Do not handle APDU and send Reject APDU if no listener configured and reply expected
# Unless this is a reply to one of the requests sent
defp handle_apdu(
source_address,
bvlc,
%NPCI{expects_reply: true} = npci,
%apdu_type{invoke_id: invoke_id} = apdu,
portal,
%State{apdu_timers: apdu_timers, notification_receiver: []} = state
)
when not is_apdu_resp(apdu_type) or map_size(apdu_timers) == 0 do
log_debug(fn ->
"Client: Received APDU with reply expected and no listener, " <>
"sending Reject APDU to source"
end)
# Get the BACnet device ID, if available from NPCI source
device_id =
case npci.source do
%NpciTarget{address: adr} when adr != nil -> adr
_else -> nil
end
reject = %APDU.Reject{
invoke_id: invoke_id,
reason: Constants.macro_assert_name(:reject_reason, :other)
}
Telemetry.execute_client_inc_apdu_rejected(
self(),
source_address,
bvlc,
npci,
reject,
apdu,
state
)
case send_data(
reject,
get_reply_opts_for_npci(reject, bvlc, npci, state),
source_address,
portal,
device_id,
false,
nil,
state,
true
) do
{:ok, new_state} ->
new_state
{:error, err} ->
log_debug(fn ->
"Client: Encountered error on sending Reject reply due to no listener, error: #{inspect(err)}"
end)
state
end
end
# Do not handle APDU if there's no listener configured
# Usually no reply is expected from us, but might be "expected" due to response
# Unless this is a reply to one of the requests sent
defp handle_apdu(
_source_address,
_bvlc,
%NPCI{} = _npci,
%apdu_type{} = _apdu,
_portal,
%State{apdu_timers: apdu_timers, notification_receiver: []} = state
)
when not is_apdu_resp(apdu_type) or map_size(apdu_timers) == 0 do
state
end
# Handle APDU since we have a listener
defp handle_apdu(source_address, bvlc, %NPCI{} = npci, apdu, portal, %State{} = state) do
# Get the BACnet device ID, if available from NPCI source
device_id =
case npci.source do
%NpciTarget{address: adr} when adr != nil -> adr
_else -> nil
end
# Get the invoke_id from the APDU (only relevant for confirmed requests)
invoke_id = Map.get(apdu, :invoke_id)
reply_key = {source_address, device_id, invoke_id}
# If confirmed service request, look up and see if it already exists, if it does, ignore it
# If it does not exist yet, create an app reply timer to send Abort APDU to client on timeout
{handle, ref, new_state} =
check_for_duplicated_requests(
device_id,
source_address,
bvlc,
npci,
apdu,
reply_key,
portal,
state
)
# case state.notification_receiver do
# [] -> :ok
# dest -> send_process_dest(dest, {:bacnet_client_info, self(), :apdu, bvlc, npci, {apdu, portal, ref}})
# end
log_debug(fn ->
dev_id = if device_id, do: ":DEV-#{inspect(device_id)}"
keyword = if handle, do: "handled", else: "ignored"
"Client: Received APDU #{inspect(apdu.__struct__)} from #{inspect(source_address)}#{dev_id} to be #{keyword}"
end)
if handle do
Telemetry.execute_client_inc_apdu_handled(self(), source_address, bvlc, npci, apdu, state)
do_handle_apdu(ref, source_address, bvlc, npci, apdu, portal, device_id, new_state)
else
Telemetry.execute_client_inc_apdu_duplicated(
self(),
source_address,
bvlc,
npci,
apdu,
state
)
new_state
end
end
# If confirmed service request, look up and see if it already exists, if it does, ignore it
# If it does not exist yet, create an app reply timer to send abort APDU to client on timeout
@spec check_for_duplicated_requests(
non_neg_integer() | nil,
term(),
Protocol.bvlc(),
NPCI.t(),
Protocol.apdu(),
term(),
term(),
State.t()
) :: {should_handle :: boolean(), reply_id :: reference() | nil, new_state :: State.t()}
defp check_for_duplicated_requests(
device_id,
source_address,
bvlc,
npci,
apdu,
reply_key,
portal,
state
)
defp check_for_duplicated_requests(
device_id,
source_address,
bvlc,
%NPCI{} = npci,
%ConfirmedServiceRequest{} = apdu,
reply_key,
portal,
%State{} = state
) do
case Map.fetch(state.app_reply_timers, reply_key) do
# Duplicated request (same request by source_address, device_id, invoke_id)
{:ok, _reply} ->
log_debug(fn ->
dev_id = if device_id, do: ":DEV-#{inspect(device_id)}"
"Client: Received duplicated request from " <>
inspect(source_address) <> "#{dev_id} with invoke ID #{apdu.invoke_id}"
end)
{false, nil, state}
:error ->
# Get timeout or use default APDU timeout, and multiply with factor - the rest is our reserve to reply
timeout =
trunc(
Enum.find_value(state.apdu_timeouts, state.opts.apdu_timeout, fn
{{^source_address, ^device_id}, value} -> value
{{^source_address, nil}, value} -> value
_else -> nil
end) * @apdu_timeout_multiplicator
)
put_app_reply_timer(
reply_key,
%ReplyTimer{
bvlc: bvlc,
npci: npci,
portal: portal,
service_req: apdu,
source_addr: source_address,
device_id: device_id,
ref: make_ref(),
has_retried: false,
monotonic_time: System.monotonic_time(),
timer:
unless(state.opts.disable_app_timeout,
do: Process.send_after(self(), {:reply_timer, reply_key}, timeout)
)
},
state
)
end
end
defp check_for_duplicated_requests(
_device_id,
_source_address,
_bvlc,
_npci,
_apdu,
_reply_key,
_portal,
%State{} = state
),
do: {true, nil, state}
@spec put_app_reply_timer(
term(),
ReplyTimer.t(),
State.t()
) :: {boolean(), reference(), State.t()}
defp put_app_reply_timer(reply_key, reply_timer, %State{} = state) do
{true, reply_timer.ref,
%{
state
| app_reply_mapping: Map.put(state.app_reply_mapping, reply_timer.ref, reply_key),
app_reply_timers: Map.put(state.app_reply_timers, reply_key, reply_timer)
}}
end
@spec do_handle_apdu(
reference() | nil,
term(),
Protocol.bvlc(),
NPCI.t(),
Protocol.apdu(),
TransportBehaviour.portal(),
non_neg_integer() | nil,
State.t()
) :: State.t()
defp do_handle_apdu(ref, source_address, bvlc, npci, apdu, portal, device_id, state)
defp do_handle_apdu(
ref,
source_address,
bvlc,
%NPCI{} = npci,
%ConfirmedServiceRequest{} = apdu,
portal,
device_id,
%State{} = state
) do
send_notification_apdu(
ref,
source_address,
bvlc,
npci,
apdu,
portal,
device_id,
state
)
end
defp do_handle_apdu(
ref,
source_address,
bvlc,
%NPCI{} = npci,
%APDU.UnconfirmedServiceRequest{} = apdu,
portal,
device_id,
%State{} = state
) do
send_notification_apdu(
ref,
source_address,
bvlc,
npci,
apdu,
portal,
device_id,
state
)
end
defp do_handle_apdu(
ref,
source_address,
bvlc,
%NPCI{} = npci,
%type{} = apdu,
portal,
device_id,
%State{} = state
)
when type in [APDU.ComplexACK, APDU.SimpleACK] do
case remove_apdu_timer_for_response(source_address, apdu, device_id, state) do
{nil, new_state} ->
send_notification_apdu(
ref,
source_address,
bvlc,
npci,
apdu,
portal,
device_id,
new_state
)
{%ApduTimer{} = timer, new_state} ->
Telemetry.execute_client_request_stop(
self(),
source_address,
bvlc,
npci,
apdu,
timer,
state
)
GenServer.reply(timer.call_ref, {:ok, apdu})
new_state
end
end
defp do_handle_apdu(
_ref,
source_address,
_bvlc,
%NPCI{} = _npci,
%APDU.SegmentACK{} = apdu,
_portal,
_device_id,
%State{} = state
) do
# Send SegmentACK to Segmentator for processing
case Segmentator.handle_apdu(state.segmentator, source_address, apdu) do
:ok ->
:ok
{:error, err} ->
log_debug(fn ->
"Client: Error while handling SegmentACK by Segmentator, error: #{inspect(err)}"
end)
Telemetry.execute_client_exception(
self(),
:error,
err,
[Telemetry.make_stacktrace_from_env(__ENV__)],
%{},
state
)
end
state
end
defp do_handle_apdu(
ref,
source_address,
bvlc,
%NPCI{} = npci,
%type{} = apdu,
portal,
device_id,
%State{} = state
)
when type in [APDU.Abort, APDU.Error, APDU.Reject] do
# Send the APDU to the Segmentator and SegmentsStore process
Segmentator.handle_apdu(state.segmentator, source_address, apdu)
SegmentsStore.cancel(state.segments_store, source_address, apdu)
case remove_apdu_timer_for_response(source_address, apdu, device_id, state) do
{nil, new_state} ->
send_notification_apdu(
ref,
source_address,
bvlc,
npci,
apdu,
portal,
device_id,
new_state
)
{%ApduTimer{} = timer, new_state} ->
Telemetry.execute_client_request_stop(
self(),
source_address,
bvlc,
npci,
apdu,
timer,
state
)
GenServer.reply(timer.call_ref, {:ok, apdu})
new_state
end
end
@spec remove_apdu_timer_for_response(
term(),
Protocol.apdu(),
non_neg_integer() | nil,
State.t()
) ::
{ApduTimer.t() | nil, State.t()}
defp remove_apdu_timer_for_response(
source_address,
%{invoke_id: invoke_id} = _apdu,
device_id,
state
) do
reply_key = {source_address, device_id, invoke_id}
case Map.fetch(state.apdu_timers, reply_key) do
{:ok, %ApduTimer{} = timer} ->
Process.cancel_timer(timer.timer)
{timer, %{state | apdu_timers: Map.delete(state.apdu_timers, reply_key)}}
_else ->
{nil, state}
end
end
@spec send_notification_apdu(
reference() | nil,
term(),
Protocol.bvlc(),
NPCI.t(),
Protocol.apdu(),
TransportBehaviour.portal(),
non_neg_integer() | nil,
State.t()
) :: State.t()
defp send_notification_apdu(
ref,
source_address,
bvlc,
%NPCI{} = npci,
apdu,
_portal,
_device_id,
%State{notification_receiver: dest} = state
) do
send_process_dest(dest, {:bacnet_client, ref, apdu, {source_address, bvlc, npci}, self()})
state
end
@spec send_process_dest(Process.dest() | [Process.dest()], term()) :: :ok
defp send_process_dest(dest, msg) when is_list(dest) do
Enum.each(dest, &Kernel.send(&1, msg))
catch
# Ignore any exception coming from send/2 (an "invalid" destination raises! [i.e. an atom but it's not registered])
_type, _err -> :ok
end
# We do this here at compile time, so we have at runtime no need to recompute the same thing (hot path)
# It is not a MapSet because MapSet.to_list/1 gets always called, so using Map iterator is cheaper
@new_invoke_id_mapset Map.new(Enum.to_list(0..255//1), fn key -> {key, nil} end)
@spec find_free_invoke_id(term(), non_neg_integer() | nil, State.t()) ::
{:ok, byte()} | :error
defp find_free_invoke_id(destination, device_id, state)
# Optimize case where apdu timers is empty
defp find_free_invoke_id(_destination, _device_id, %State{apdu_timers: timers} = _state)
when map_size(timers) == 0,
do: {:ok, 0}
defp find_free_invoke_id(destination, device_id, %State{} = state) do
free_ids =
Enum.reduce(state.apdu_timers, @new_invoke_id_mapset, fn
{_key,
%ApduTimer{destination: ^destination, device_id: ^device_id, apdu: %{invoke_id: id}}} =
_timer,
acc ->
Map.delete(acc, id)
_else, acc ->
acc
end)
if map_size(free_ids) > 0 do
free_ids
|> :maps.iterator()
|> :maps.next()
|> elem(0)
|> then(&{:ok, &1})
else
:error
end
end
@spec send_data(
Protocol.apdu(),
Keyword.t(),
term(),
TransportBehaviour.portal(),
non_neg_integer() | nil,
boolean(),
term() | nil,
State.t(),
boolean()
) :: {:ok, new_state :: State.t()} | {:error, term()} | {:retry, new_state :: State.t()}
defp send_data(
apdu,
opts,
destination,
portal,
device_id,
needs_tracking,
call_ref,
state,
skip_invoke_id_check
)
defp send_data(
%{invoke_id: _id} = apdu,
opts,
destination,
portal,
device_id,
needs_tracking,
call_ref,
%State{opts: %{disable_invoke_id_management: false}} = state,
false
) do
case find_free_invoke_id(destination, device_id, state) do
{:ok, new_invoke_id} ->
send_data(
%{apdu | invoke_id: new_invoke_id},
opts,
destination,
portal,
device_id,
needs_tracking,
call_ref,
state,
true
)
:error ->
{:retry, state}
end
end
defp send_data(
apdu,
opts,
destination,
portal,
device_id,
needs_tracking,
call_ref,
%State{transport_mod: trans_mod} = state,
_skip_invoke_id_check
) do
sys_mono_time = System.monotonic_time()
try do
# Catch any errors when trying to encode the APDU
bin = EncoderProtocol.encode(apdu)
# 50 is the minimum APDU size each device needs to support
max_apdu_len = min(max(opts[:max_apdu_length] || 0, 50), trans_mod.max_npdu_length())
{bin, max_apdu_len}
catch
kind, e ->
Telemetry.execute_client_exception(
self(),
kind,
e,
__STACKTRACE__,
%{apdu: apdu, destination: destination},
state
)
{:error, {e, __STACKTRACE__}}
else
{apdu_data, max_apdu_len0} ->
send_opts =
opts
|> Keyword.drop([:max_apdu_length, :max_segments, :segmentation_supported])
|> kw_put_new(:source, state.opts.npci_source)
|> Keyword.put_new(:is_broadcast, state.transport_broadcast_addr == destination)
# Do basic NPCI size calculation and subtract it from the max APDU size
# 6 = APCI header, 2 = NPCI header
max_apdu_len =
max_apdu_len0 - 6 - 2 - if(send_opts[:source], do: 9, else: 0) -
if(send_opts[:destination], do: 10, else: 0)
apdu_length = IO.iodata_length(apdu_data)
max_segments = opts[:max_segments] || 2
apdu_too_long = apdu_length > max_apdu_len
apdu_supports_seg = EncoderProtocol.supports_segmentation(apdu)
supports_segments = supports_segmentation(opts[:segmentation_supported])
result =
cond do
apdu_too_long and not apdu_supports_seg ->
# If segmentation is not supported by the APDU type,
# send an abort and indiciate Abort APDU_TOO_LONG,
# but only if this is not a request to a remote device
resp =
if EncoderProtocol.is_response(apdu) do
abort = %APDU.Abort{
sent_by_server: true,
invoke_id: apdu.invoke_id,
reason: Constants.macro_assert_name(:abort_reason, :apdu_too_long)
}
Telemetry.execute_client_send_error(
self(),
destination,
apdu,
send_opts,
abort,
:apdu_too_long,
state
)
trans_mod.send(portal, destination, abort, send_opts)
else
:ok
end
# Return the send error if present, or our error (no error swallowing)
with :ok <- resp do
{:error, :apdu_too_long}
end
apdu_too_long and not supports_segments ->
# Segmentation not supported by the remote device,
# indicate Abort SEGMENTATION_NOT_SUPPORTED,
# but only if this is a response,
# if this is a request, we do not need to send anything
resp =
if EncoderProtocol.is_response(apdu) do
abort = %APDU.Abort{
sent_by_server: true,
invoke_id: apdu.invoke_id,
reason:
Constants.macro_assert_name(:abort_reason, :segmentation_not_supported)
}
Telemetry.execute_client_send_error(
self(),
destination,
apdu,
send_opts,
abort,
:apdu_segmentation_unsupported,
state
)
trans_mod.send(portal, destination, abort, send_opts)
else
:ok
end
# Return the send error if present, or our error (no error swallowing)
with :ok <- resp do
{:error, :segmentation_not_supported}
end
apdu_too_long ->
Telemetry.execute_client_send(
self(),
destination,
apdu,
send_opts,
true,
state
)
Segmentator.create_sequence(
state.segmentator,
{trans_mod, state.transport_pid, portal},
destination,
%{
apdu
| proposed_window_size:
Map.get(apdu, :proposed_window_size) || @default_window_size
},
max_apdu_len,
max_segments,
send_opts
)
true ->
Telemetry.execute_client_send(
self(),
destination,
apdu,
send_opts,
false,
state
)
trans_mod.send(portal, destination, apdu_data, send_opts)
end
if needs_tracking do
case result do
:ok ->
key = {destination, device_id, Map.get(apdu, :invoke_id)}
# Do basic APDU segments count estimation
factor = if(apdu_too_long, do: apdu_length / (max_apdu_len - 5) + 1, else: 1)
timer = %ApduTimer{
portal: portal,
destination: destination,
device_id: device_id,
apdu: apdu,
send_opts: opts,
call_ref: call_ref,
retry_count: 0,
monotonic_time: sys_mono_time,
timer:
Process.send_after(
self(),
{:apdu_timer, key},
state.opts.apdu_timeout * factor + @apdu_timer_offset
)
}
Telemetry.execute_client_request_start(
self(),
destination,
apdu,
send_opts,
timer,
state
)
new_state = %{state | apdu_timers: Map.put(state.apdu_timers, key, timer)}
{:ok, new_state}
{:error, _err} = err ->
err
end
else
case result do
:ok -> {:ok, state}
term -> term
end
end
end
end
@spec send_reply_for_unknown_enumeration(
term(),
Protocol.bvlc(),
NPCI.t(),
binary(),
TransportBehaviour.portal(),
State.t()
) :: State.t()
defp send_reply_for_unknown_enumeration(
source_address,
bvlc,
%NPCI{} = npci,
raw_apdu,
portal,
%State{} = state
) do
case APDU.get_invoke_id_from_raw_apdu(raw_apdu) do
{:ok, invoke_id} ->
device_id =
case npci.source do
%NpciTarget{address: adr} when adr != nil -> adr
_else -> nil
end
reject = %APDU.Reject{
invoke_id: invoke_id,
reason: Constants.macro_assert_name(:reject_reason, :undefined_enumeration)
}
case send_data(
reject,
get_reply_opts_for_npci(reject, bvlc, npci, state),
source_address,
portal,
device_id,
false,
nil,
state,
true
) do
{:ok, new_state} ->
new_state
{:error, err} ->
log_debug(fn ->
"Client: Encountered error on sending Reject reply due to unknown enumeration, error: #{inspect(err)}"
end)
state
end
_else ->
log_debug(fn ->
"Client: Unable to extract invoke ID from invalid APDU (caused by unknown enumeration)"
end)
state
end
end
@spec supports_segmentation(Constants.segmentation() | term()) :: boolean()
defp supports_segmentation(Constants.macro_assert_name(:segmentation, :segmented_both)),
do: true
defp supports_segmentation(Constants.macro_assert_name(:segmentation, :segmented_receive)),
do: true
defp supports_segmentation(_term), do: false
@spec get_reply_opts_for_npci(Protocol.apdu(), Protocol.bvlc(), NPCI.t(), State.t()) ::
Keyword.t()
defp get_reply_opts_for_npci(_apdu, _bvlc, %NPCI{} = npci, %State{} = _state) do
# Source gets always set in send_data/8
kw_put_lazy([], :destination, get_reply_opts_destination(npci))
end
defp get_reply_opts_destination(%NPCI{source: %NpciTarget{} = src} = _npci) do
src
end
defp get_reply_opts_destination(%NPCI{} = _npci) do
nil
end
@spec kw_put_lazy(Keyword.t(), atom(), term()) :: Keyword.t()
defp kw_put_lazy(kw, _key, nil), do: kw
defp kw_put_lazy(kw, key, val), do: Keyword.put(kw, key, val)
@spec kw_put_new(Keyword.t(), atom(), term() | nil) :: Keyword.t()
defp kw_put_new(kw, _key, nil), do: kw
defp kw_put_new(kw, key, val), do: Keyword.put_new(kw, key, val)
defp validate_start_link_opts(opts) do
case opts[:apdu_retries] do
nil ->
:ok
term when is_integer(term) and term >= 0 ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected apdu_retries to be a non negative integer, " <>
"got: #{inspect(term)}"
end
case opts[:apdu_timeout] do
nil ->
:ok
term when is_integer(term) and term > 0 ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected apdu_timeout to be a positive integer, " <>
"got: #{inspect(term)}"
end
case opts[:disable_app_timeout] do
nil ->
:ok
term when is_boolean(term) ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected disable_app_timeout to be a boolean, " <>
"got: #{inspect(term)}"
end
case opts[:disable_invoke_id_management] do
nil ->
:ok
term when is_boolean(term) ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected disable_invoke_id_management to be a boolean, " <>
"got: #{inspect(term)}"
end
case opts[:notification_receiver] do
nil ->
:ok
term when is_atom(term) ->
:ok
term when is_pid(term) ->
:ok
term when is_port(term) ->
:ok
term when is_tuple(term) and tuple_size(term) == 2 ->
:ok
term when is_list(term) ->
unless Enum.all?(
term,
&(is_atom(&1) or is_pid(&1) or is_port(&1) or
(is_tuple(&1) and tuple_size(&1) == 2))
) do
raise ArgumentError,
"start_link/1 expected notification_receiver to be a Process destination or " <>
"list of Process destinations, got: #{inspect(term)}"
end
term ->
raise ArgumentError,
"start_link/1 expected notification_receiver to be a Process destination or " <>
"list of Process destinations, got: #{inspect(term)}"
end
case opts[:npci_source] do
nil ->
:ok
%NpciTarget{} ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected npci_source to be a NpciTarget, " <>
"got: #{inspect(term)}"
end
case opts[:segmentator] do
term when is_atom(term) ->
:ok
term when is_pid(term) ->
:ok
term when is_tuple(term) and tuple_size(term) == 2 ->
:ok
term when is_tuple(term) and tuple_size(term) == 3 ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected segmentator to be a GenServer name, " <>
"got: #{inspect(term)}"
end
case opts[:segments_store] do
term when is_atom(term) ->
:ok
term when is_pid(term) ->
:ok
term when is_tuple(term) and tuple_size(term) == 2 ->
:ok
term when is_tuple(term) and tuple_size(term) == 3 ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected segments_store to be a GenServer name, " <>
"got: #{inspect(term)}"
end
case opts[:segmented_rcv_window_overwrite] do
nil ->
:ok
term when is_boolean(term) ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected segmented_rcv_window_overwrite to be a boolean, " <>
"got: #{inspect(term)}"
end
transport = opts[:transport]
case transport do
mod when is_atom(mod) ->
:ok
{mod, term} when is_atom(mod) and is_atom(term) ->
:ok
{mod, term} when is_atom(mod) and is_pid(term) ->
:ok
{mod, term} when is_atom(mod) and is_port(term) ->
:ok
{mod, term} when is_atom(mod) and is_tuple(term) and tuple_size(term) == 2 ->
:ok
{mod, term} when is_atom(mod) and is_tuple(term) and tuple_size(term) == 3 ->
:ok
term ->
raise ArgumentError,
"start_link/1 expected transport to be a tuple of module name " <>
"and TransportBehaviour.transport(), got: #{inspect(term)}"
end
# Unwrap the transport module name
{transport_mod, _pid} =
case transport do
{mod, pid} -> {mod, pid}
mod -> {mod, nil}
end
unless Code.ensure_loaded?(transport_mod) do
raise ArgumentError, "Given transport module #{inspect(transport_mod)} is not loaded"
end
unless Enum.any?(transport_mod.__info__(:attributes), fn
{:behaviour, TransportBehaviour} -> true
{:behaviour, [TransportBehaviour]} -> true
_else -> false
end) do
raise ArgumentError,
"Given transport module #{inspect(transport_mod)} does not " <>
"implement the BACnet transport behaviour"
end
end
defp validate_send_opts(opts, function) when is_list(opts) and is_binary(function) do
# Validate send options
Enum.each(opts, fn
{:device_id, device_id} ->
unless (is_integer(device_id) and device_id in 1..4_194_302) or is_nil(device_id) do
raise ArgumentError,
message:
"#{function} expected device_id in opts to be nil " <>
" or an integer in the range 1 - 4_194_302 inclusive, got: " <> inspect(device_id)
end
{:max_apdu_length, max_apdu_length} ->
unless is_integer(max_apdu_length) and max_apdu_length > 0 do
raise ArgumentError,
message:
"#{function} expected max_apdu_length in opts to be " <>
"a positive integer, got: " <> inspect(max_apdu_length)
end
{:max_segments, max_segments} ->
unless is_integer(max_segments) and max_segments > 0 do
raise ArgumentError,
message:
"#{function} expected max_segments in opts to be " <>
"a positive integer, got: " <> inspect(max_segments)
end
{:segmentation_supported, segmentation_supported} ->
unless Constants.has_by_name(:segmentation, segmentation_supported) do
raise ArgumentError,
message:
"#{function} expected segmentation_supported in opts to be " <>
"a valid atom, got: " <> inspect(segmentation_supported)
end
# Unknown option - ignore
_else ->
nil
end)
end
end