defmodule Modbux.Tcp.Client do
@moduledoc """
API for Modbus TCP Client.
"""
alias Modbux.Tcp.Client
alias Modbux.Tcp
use GenServer, restart: :permanent, shutdown: 500
require Logger
@timeout 2000
@port 502
@ip {0, 0, 0, 0}
@active false
@to 2000
defstruct ip: nil,
tcp_port: nil,
socket: nil,
timeout: @to,
active: false,
transid: 0,
status: nil,
d_pid: nil,
msg_len: 0,
pending_msg: %{},
cmd: nil
@type client_option ::
{:ip, {byte(), byte(), byte(), byte()}}
| {:active, boolean}
| {:tcp_port, non_neg_integer}
| {:timeout, non_neg_integer}
@doc """
Starts a Modbus TCP Client process.
The following options are available:
* `ip` - is the internet address of the desired Modbux TCP Server.
* `tcp_port` - is the desired Modbux TCP Server tcp port number.
* `timeout` - is the connection timeout.
* `active` - (`true` or `false`) specifies whether data is received as
messages (mailbox) or by calling `confirmation/1` each time `request/2` is called.
The messages (when active mode is true) have the following form:
`{:modbus_tcp, cmd, values}`
## Example
```elixir
Modbux.Tcp.Client.start_link(ip: {10,77,0,2}, port: 502, timeout: 2000, active: true)
```
"""
def start_link(params, opts \\ []) do
GenServer.start_link(__MODULE__, params, opts)
end
@doc """
Stops the Client.
"""
def stop(pid) do
GenServer.stop(pid)
end
@doc """
Gets the state of the Client.
"""
def state(pid) do
GenServer.call(pid, :state)
end
@doc """
Configure the Client (`status` must be `:closed`).
The following options are available:
* `ip` - is the internet address of the desired Modbux TCP Server.
* `tcp_port` - is the Modbux TCP Server tcp port number .
* `timeout` - is the connection timeout.
* `active` - (`true` or `false`) specifies whether data is received as
messages (mailbox) or by calling `confirmation/1` each time `request/2` is called.
"""
def configure(pid, params) do
GenServer.call(pid, {:configure, params})
end
@doc """
Connect the Client to a Server.
"""
def connect(pid) do
GenServer.call(pid, :connect)
end
@doc """
Close the tcp port of the Client.
"""
def close(pid) do
GenServer.call(pid, :close)
end
@doc """
Send a request to Modbux TCP Server.
`cmd` is a 4 elements tuple, as follows:
- `{:rc, slave, address, count}` read `count` coils.
- `{:ri, slave, address, count}` read `count` inputs.
- `{:rhr, slave, address, count}` read `count` holding registers.
- `{:rir, slave, address, count}` read `count` input registers.
- `{:fc, slave, address, value}` force single coil.
- `{:phr, slave, address, value}` preset single holding register.
- `{:fc, slave, address, values}` force multiple coils.
- `{:phr, slave, address, values}` preset multiple holding registers.
"""
def request(pid, cmd) do
GenServer.call(pid, {:request, cmd})
end
@doc """
In passive mode (active: false), reads the confirmation of the connected Modbux Server.
"""
def confirmation(pid) do
GenServer.call(pid, :confirmation)
end
@doc """
In passive mode (active: false), flushed the pending messages.
"""
def flush(pid) do
GenServer.call(pid, :flush)
end
# callbacks
def init(args) do
port = args[:tcp_port] || @port
ip = args[:ip] || @ip
timeout = args[:timeout] || @timeout
status = :closed
active =
if args[:active] == nil do
@active
else
args[:active]
end
state = %Client{ip: ip, tcp_port: port, timeout: timeout, status: status, active: active}
{:ok, state}
end
def handle_call(:state, from, state) do
Logger.debug("(#{__MODULE__}, :state) from: #{inspect(from)}")
{:reply, state, state}
end
def handle_call({:configure, args}, _from, state) do
case state.status do
:closed ->
port = args[:tcp_port] || state.tcp_port
ip = args[:ip] || state.ip
timeout = args[:timeout] || state.timeout
d_pid = args[:d_pid] || state.d_pid
active =
if args[:active] == nil do
state.active
else
args[:active]
end
new_state = %Client{state | ip: ip, tcp_port: port, timeout: timeout, active: active, d_pid: d_pid}
{:reply, :ok, new_state}
_ ->
{:reply, :error, state}
end
end
def handle_call(:connect, {from, _ref}, state) do
Logger.debug("(#{__MODULE__}, :connect) state: #{inspect(state)}")
Logger.debug("(#{__MODULE__}, :connect) from: #{inspect(from)}")
case :gen_tcp.connect(
state.ip,
state.tcp_port,
[:binary, packet: :raw, active: state.active],
state.timeout
) do
{:ok, socket} ->
ctrl_pid =
if state.d_pid == nil do
from
else
state.d_pid
end
# state
new_state = %Client{state | socket: socket, status: :connected, d_pid: ctrl_pid}
{:reply, :ok, new_state}
{:error, reason} ->
Logger.error("(#{__MODULE__}, :connect) reason #{inspect(reason)}")
# state
{:reply, {:error, reason}, state}
end
end
def handle_call(:close, _from, state) do
Logger.debug("(#{__MODULE__}, :close) state: #{inspect(state)}")
if state.socket != nil do
new_state = close_socket(state)
{:reply, :ok, new_state}
else
Logger.error("(#{__MODULE__}, :close) No port to close")
# state
{:reply, {:error, :closed}, state}
end
end
def handle_call({:request, cmd}, _from, state) do
Logger.debug("(#{__MODULE__}, :request) state: #{inspect(state)}")
case state.status do
:connected ->
request = Tcp.pack_req(cmd, state.transid)
length = Tcp.res_len(cmd)
case :gen_tcp.send(state.socket, request) do
:ok ->
new_state =
if state.active do
new_msg = Map.put(state.pending_msg, state.transid, cmd)
n_msg =
if state.transid + 1 > 0xFFFF do
0
else
state.transid + 1
end
%Client{state | msg_len: length, cmd: cmd, pending_msg: new_msg, transid: n_msg}
else
%Client{state | msg_len: length, cmd: cmd}
end
{:reply, :ok, new_state}
{:error, :closed} ->
new_state = close_socket(state)
{:reply, {:error, :closed}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
:closed ->
{:reply, {:error, :closed}, state}
end
end
# only in passive mode
def handle_call(:confirmation, _from, state) do
Logger.debug("(#{__MODULE__}, :confirmation) state: #{inspect(state)}")
if state.active do
{:reply, :error, state}
else
case state.status do
:connected ->
case :gen_tcp.recv(state.socket, state.msg_len, state.timeout) do
{:ok, response} ->
values = Tcp.parse_res(state.cmd, response, state.transid)
Logger.debug("(#{__MODULE__}, :confirmation) response: #{inspect(response)}")
n_msg =
if state.transid + 1 > 0xFFFF do
0
else
state.transid + 1
end
new_state = %Client{state | transid: n_msg, cmd: nil, msg_len: 0}
case values do
# escribió algo
nil ->
{:reply, :ok, new_state}
# leemos algo
_ ->
{:reply, {:ok, values}, new_state}
end
{:error, reason} ->
Logger.error("(#{__MODULE__}, :confirmation) reason: #{inspect(reason)}")
# cerrar?
new_state = close_socket(state)
new_state = %Client{new_state | cmd: nil, msg_len: 0}
{:reply, {:error, reason}, new_state}
end
:closed ->
{:reply, {:error, :closed}, state}
end
end
end
def handle_call(:flush, _from, state) do
new_state = %Client{state | pending_msg: %{}}
{:reply, {:ok, state.pending_msg}, new_state}
end
# only for active mode (active: true)
def handle_info({:tcp, _port, response}, state) do
Logger.debug("(#{__MODULE__}, :message_active) response: #{inspect(response)}")
Logger.debug("(#{__MODULE__}, :message_active) state: #{inspect(state)}")
h = :binary.at(response, 0)
l = :binary.at(response, 1)
transid = h * 256 + l
Logger.debug("(#{__MODULE__}, :message_active) transid: #{inspect(transid)}")
case Map.fetch(state.pending_msg, transid) do
:error ->
Logger.error("(#{__MODULE__}, :message_active) unknown transaction id")
{:noreply, state}
{:ok, cmd} ->
values = Tcp.parse_res(cmd, response, transid)
msg = {:modbus_tcp, cmd, values}
send(state.d_pid, msg)
new_pending_msg = Map.delete(state.pending_msg, transid)
new_state = %Client{state | cmd: nil, msg_len: 0, pending_msg: new_pending_msg}
{:noreply, new_state}
end
end
def handle_info({:tcp_closed, _port}, state) do
Logger.info("(#{__MODULE__}, :tcp_close) Server close the port")
new_state = close_socket(state)
{:noreply, new_state}
end
def handle_info(msg, state) do
Logger.error("(#{__MODULE__}, :random_msg) msg: #{inspect(msg)}")
{:noreply, state}
end
defp close_socket(state) do
:ok = :gen_tcp.close(state.socket)
new_state = %Client{state | socket: nil, status: :closed}
new_state
end
end