defmodule Jeff.ACU do
@moduledoc """
GenServer process for an ACU
"""
require Logger
use GenServer
alias Jeff.{Bus, Command, Device, Events, Message, Reply, SecureChannel, Transport}
@type acu() :: Jeff.acu()
@type address_availability :: :available | :registered | :timeout | :error
@type osdp_address() :: Jeff.osdp_address()
@type start_opt() ::
{:name, atom()}
| {:serial_port, String.t()}
| {:controlling_process, Process.dest()}
| {:transport_opts, Transport.opts()}
@type device_opt() :: {:check_scheme, atom()}
@doc """
Start the ACU process.
"""
@spec start_link([start_opt()]) :: GenServer.on_start()
def start_link(opts \\ []) do
{name, opts} = Keyword.pop(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Register a peripheral device on the ACU communication bus.
"""
@spec add_device(acu(), osdp_address(), [device_opt()]) :: Device.t()
def add_device(acu, address, opts \\ []) do
GenServer.call(acu, {:add_device, address, opts})
end
@doc """
Remove a peripheral device from the ACU communication bus.
"""
@spec remove_device(acu(), osdp_address()) :: Device.t()
def remove_device(acu, address) do
GenServer.call(acu, {:remove_device, address})
end
@doc """
Send a command to a peripheral device.
"""
@spec send_command(acu(), osdp_address(), atom(), keyword()) ::
{:ok, Reply.t()} | {:error, :timeout}
def send_command(acu, address, name, params \\ []) do
GenServer.call(acu, {:send_command, address, name, params})
end
@doc """
Send a command to a peripheral device that is not yet registered on the ACU.
Intended to be used for maintenance/diagnostic purposes.
"""
@spec send_command_oob(acu(), osdp_address(), atom(), keyword()) ::
{:ok, Reply.t()} | {:error, :timeout | :registered}
def send_command_oob(acu, address, name, params \\ []) do
GenServer.call(acu, {:send_command_oob, address, name, params})
end
@doc """
Determine if a device is available to be registered on the bus.
"""
@spec check_address(acu(), osdp_address()) :: address_availability()
def check_address(acu, address) do
GenServer.call(acu, {:check_address, address})
end
@doc """
Get the state of the ACU
"""
@spec state(acu()) :: Bus.t()
def state(acu), do: GenServer.call(acu, :state)
@impl GenServer
def init(opts) do
controlling_process = Keyword.get(opts, :controlling_process)
serial_port = Keyword.get(opts, :serial_port, "/dev/ttyUSB0")
transport_opts = Keyword.get(opts, :transport_opts, [])
{:ok, conn} = Transport.start_link(serial_port, transport_opts)
state = Bus.new()
state = %{state | conn: conn, controlling_process: controlling_process}
{:ok, tick(state)}
end
@impl GenServer
def handle_call({:send_command, address, name, params}, from, state) do
params = Keyword.put(params, :caller, from)
command = Command.new(address, name, params)
state = Bus.send_command(state, command)
{:noreply, state}
end
def handle_call({:send_command_oob, address, name, params}, _from, state) do
device = Device.new(address: address)
command = Command.new(address, name, params)
%{bytes: bytes} = Message.new(device, command)
resp =
case send_data_oob(state, address, bytes, command.timeout) do
{:error, _reason} = error -> error
{:ok, bytes} -> {:ok, Message.decode(bytes) |> Reply.new()}
end
{:reply, resp, state}
end
@impl GenServer
def handle_call({:check_address, address}, _from, state) do
device = Device.new(address: address)
command = Command.new(address, POLL)
%{bytes: bytes} = Message.new(device, command)
status =
case send_data_oob(state, address, bytes, command.timeout) do
{:error, :registered} ->
:registered
{:error, :timeout} ->
:timeout
{:ok, bytes} ->
try do
_ = Message.decode(bytes)
:available
rescue
_error -> :error
end
end
{:reply, status, state}
end
def handle_call({:add_device, address, opts}, _from, state) do
opts = Keyword.merge(opts, address: address)
state = Bus.add_device(state, opts)
device = Bus.get_device(state, address)
{:reply, device, state}
end
def handle_call({:remove_device, address}, _from, state) do
# TODO: Change this return to :ok
# I'm not sure returning a device is needed, but this allows
# this function to be safe without changing a ton of internals
# by just creating a dummy device struct when it doesn't exist
# in the registry. The Bus.remove_device/2 call will be a noop
device =
if Bus.registered?(state, address),
do: Bus.get_device(state, address),
else: Device.new(address: address)
state = Bus.remove_device(state, address)
{:reply, device, state}
end
def handle_call(:state, _from, state), do: {:reply, state, state}
@impl GenServer
def handle_info(:tick, %{command: nil, reply: nil} = state) do
{:noreply, tick(state)}
end
def handle_info(:tick, %{command: command, reply: nil, conn: conn} = state) do
device = Bus.current_device(state)
%{device: device, bytes: bytes} = Message.new(device, command)
# save the device with the possibly updated secure channel
state = Bus.put_device(state, device)
:ok = Transport.send(conn, bytes)
state = Transport.recv(conn, command.timeout) |> handle_recv(state)
{:noreply, tick(state)}
end
# handle transport connected
def handle_info(:connected, state) do
{:noreply, state}
end
def handle_info(_, state) do
{:noreply, state}
end
@impl GenServer
def terminate(_reason, state) do
Transport.close(state.conn)
end
# Helper functions
defp handle_reply(state, %{name: CCRYPT} = reply) do
device = Bus.current_device(state)
secure_channel = SecureChannel.initialize(device.secure_channel, reply.data)
device = %{device | secure_channel: secure_channel}
Bus.put_device(state, device)
end
defp handle_reply(state, %{name: RMAC_I} = reply) do
device = Bus.current_device(state)
secure_channel = SecureChannel.establish(device.secure_channel, reply.data)
device = %{device | secure_channel: secure_channel}
Bus.put_device(state, device)
end
# NAK - unexpected sequence number
defp handle_reply(state, %{name: NAK, data: %Reply.ErrorCode{code: 0x04}}) do
device = Bus.current_device(state)
device = Device.reset(device)
Bus.put_device(state, device)
end
defp handle_reply(state, _reply), do: state
defp handle_recv(
{:ok, bytes},
%{controlling_process: controlling_process, command: command} = state
) do
reply_message = Message.decode(bytes)
device = Bus.current_device(state)
state =
if device.secure_channel.established? do
len = reply_message.length - Message.check_size(reply_message) - 4
<<bytes::binary-size(len), _rest::binary>> = reply_message.bytes
secure_channel = SecureChannel.calculate_mac(device.secure_channel, bytes, false)
Bus.put_device(state, %{device | secure_channel: secure_channel})
else
state
end
reply_message =
if reply_message.sb_type == 0x18 do
data = SecureChannel.decrypt(device.secure_channel, reply_message.data)
%{reply_message | data: data}
else
reply_message
end
reply = Reply.new(reply_message)
if controlling_process do
if reply.name == MFGREP do
send(controlling_process, reply)
end
if reply.name == ISTATR do
send(controlling_process, reply)
end
if reply.name == KEYPAD do
event = Events.Keypress.from_reply(reply)
send(controlling_process, event)
end
if reply.name == RAW do
event = Events.CardRead.from_reply(reply)
send(controlling_process, event)
end
end
state = handle_reply(state, reply)
if command.caller do
GenServer.reply(command.caller, {:ok, reply})
end
%{state | reply: reply}
end
defp handle_recv({:error, :timeout}, state) do
# Make sure to report the timeout to any potential callers
_ =
if from = get_in(state.command, [Access.key(:caller)]),
do: GenServer.reply(from, {:error, :timeout})
%{state | reply: :timeout}
end
defp send_data_oob(state, address, bytes, timeout) do
if Bus.registered?(state, address) do
{:error, :registered}
else
:ok = Transport.send(state.conn, bytes)
Transport.recv(state.conn, timeout)
end
end
defp tick(bus) do
send(self(), :tick)
Bus.tick(bus)
end
end