defmodule Modbus.Tcp.Master do
@moduledoc """
TCP Master server.
## Example
```elixir
# run with: mix opto22
alias Modbus.Tcp.Master
# opto22 learning center configured with script/opto22.otg
# the otg is for an R2 but seems to work for R1, EB1, and EB2
# digital points increment address by 4 per module and by 1 per point
# analog points increment address by 8 per module and by 2 per point
{:ok, pid} = Master.start_link(ip: {10, 77, 0, 10}, port: 502)
# turn on 'alarm'
:ok = Master.exec(pid, {:fc, 1, 4, 1})
# turn on 'outside light'
:ok = Master.exec(pid, {:fc, 1, 5, 1})
# turn on 'inside light'
:ok = Master.exec(pid, {:fc, 1, 6, 1})
# turn on 'freezer door status'
:ok = Master.exec(pid, {:fc, 1, 7, 1})
:timer.sleep(400)
# turn off all digital outputs
:ok = Master.exec(pid, {:fc, 1, 4, [0, 0, 0, 0]})
# read the 'emergency' switch
{:ok, [0]} = Master.exec(pid, {:rc, 1, 8, 1})
# read the 'fuel level' knob (0 to 10,000)
{:ok, data} = Master.exec(pid, {:rir, 1, 32, 2})
[_] = Modbus.IEEE754.from_2n_regs(data, :be)
# write to the 'fuel display' (0 to 10,000)
data = Modbus.IEEE754.to_2_regs(+5000.0, :be)
:ok = Master.exec(pid, {:phr, 1, 16, data})
```
"""
alias Modbus.Tcp
@to 2000
##########################################
# Public API
##########################################
@doc """
Opens the connection.
`opts` is a keyword list where:
- `ip` is the internet address to connect to.
- `port` is the tcp port number to connect to.
- `timeout` is the optional connection timeout.
Returns `{:ok, pid}` | `{:error, reason}`.
## Example
```elixir
Modbus.Tcp.Master.start_link(ip: {10,77,0,10}, port: 502, timeout: 2000)
```
"""
def start_link(opts) do
ip = Keyword.fetch!(opts, :ip)
port = Keyword.fetch!(opts, :port)
timeout = Keyword.get(opts, :timeout, @to)
init = %{ip: ip, port: port, timeout: timeout}
GenServer.start_link(__MODULE__.Server, init)
end
@doc """
Closes the connection.
"""
def stop(pid) do
GenServer.stop(pid)
end
@doc """
Executes a Modbus TCP command.
`cmd` is one of:
- `{: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.
Returns `:ok` | `{:ok, [values]}` | `{:error, reason}`.
"""
def exec(pid, cmd, timeout \\ @to) when is_tuple(cmd) and is_integer(timeout) do
GenServer.call(pid, {:exec, cmd, timeout})
end
defmodule Server do
@moduledoc false
use GenServer
def init(init) do
%{ip: ip, port: port, timeout: timeout} = init
opts = [:binary, packet: :raw, active: false]
case :gen_tcp.connect(ip, port, opts, timeout) do
{:ok, sid} ->
state = %{socket: sid, transid: 0}
{:ok, state}
{:error, reason} ->
{:stop, reason}
end
end
def handle_call(:socket, _from, state) do
{:reply, state.socket, state}
end
def handle_call({:exec, cmd, timeout}, _from, state) do
%{socket: socket, transid: transid} = state
resp = exec(socket, cmd, transid, timeout)
{:reply, resp, Map.put(state, :transid, transid + 1)}
end
defp exec(socket, cmd, transid, timeout) do
case request(cmd, transid) do
{:ok, request, length} ->
# clear input buffer
:gen_tcp.recv(socket, 0, 0)
case :gen_tcp.send(socket, request) do
:ok ->
case :gen_tcp.recv(socket, length, timeout) do
{:ok, response} ->
values = Tcp.parse_res(cmd, response, transid)
case values do
nil -> :ok
_ -> {:ok, values}
end
error ->
error
end
error ->
error
end
error ->
error
end
end
defp request(cmd, transid) do
try do
request = Tcp.pack_req(cmd, transid)
length = Tcp.res_len(cmd)
{:ok, request, length}
rescue
_ ->
{:error, {:invalid, cmd}}
end
end
end
end