A connection to an ADS device, usually a TwinCAT PLC, over TCP or Secure ADS.
{:ok, plc} = ADS.Client.start_link(host: "10.0.0.5", net_id: "5.36.69.176.1.1")
{:ok, 1500} = ADS.Client.read(plc, "MAIN.nSpeed")
:ok = ADS.Client.write(plc, "MAIN.nSpeed", 1600)
{:ok, %{"nSpeed" => 1600, "fTemp" => 21.5}} = ADS.Client.read(plc, "MAIN.stPump")
{:ok, [{:ok, 1600}, {:ok, true}]} = ADS.Client.read_many(plc, ["MAIN.nSpeed", "MAIN.bOn"])
{:error, :device_symbol_not_found} = ADS.Client.read(plc, "MAIN.nNone")The client talks to one system, by its AMS NetId, and to the PLC runtime on AMS port 851 unless
told otherwise: ams_port: for the client, or port: for one request. Every request gets its
result within its timeout, as a value: the client never exits the caller, and it never logs.
The errors are those listed in ADS.
Routes
A TwinCAT system talks only to clients it has a route for: the client's NetId and its address,
added in TwinCAT's engineering, its system tray menu or a target's web interface. The client's
NetId is :local_net_id, by default its IP address on the connection with .1.1 added. To a
client without a route, the system closes the connection, and the client tries again. So it does
to a second connection from a NetId it's already connected to: clients on one machine to the
same system share one client, or each has a NetId and a route of its own.
Variables by name
read/3 and write/4 reach a variable by its name, as the PLC knows it, "MAIN.nSpeed" or
"GVL.astPumps[2].fTemp". The client asks the PLC for a handle of the name, and for the
descriptions of its type (see ADS.Symbol and ADS.DataType), and keeps both for as long as
the connection lasts. Values are Elixir terms as in ADS.Type: a structure is a map, an array a
list. A handle that has gone stale, as after an online change, is looked up again, once.
type: gives the type instead, which saves asking for its description, and also reaches data by
index group and offset:
{:ok, 1500} = ADS.Client.read(plc, "MAIN.nSpeed", type: :int)
{:ok, 42} = ADS.Client.read(plc, {0x4020, 0}, type: :dint)
:ok = ADS.Client.write(plc, {0x4020, 4}, [1, 2, 3], type: {:array, :int, [{1, 3}]})read_many/3 and write_many/3 read and write many at once, in sum commands of up to 500, or
one at a time with a device that has none. symbols/2 and data_types/2 upload all of the
PLC's descriptions, and request/3 sends any ADS command (see ADS.Command).
Notifications
subscribe/3 asks the PLC to send a variable's value when it changes, or every cycle, to the
subscriber, the caller by default:
{:ok, ref} = ADS.Client.subscribe(plc, ["MAIN.nSpeed", "MAIN.bOn"], cycle_time: 100)
# the current values at once, then each change
receive do
{ADS.Client, ^ref, {:value, "MAIN.nSpeed", speed, %DateTime{}}} -> speed
end| Message | When |
|---|---|
{ADS.Client, ref, {:value, target, value, timestamp}} | a value, with the PLC's time of it in UTC |
{ADS.Client, ref, {:disconnected, reason}} | the connection dropped; the values come again once it's back |
{ADS.Client, ref, {:error, target, reason}} | a notification couldn't be added again, after a reconnect or an online change; the target is left out |
A subscription ends with unsubscribe/2, or when the subscriber exits. Beckhoff recommends at
most 550 notifications for a device.
Many requests at once
Up to :max_pending requests are on the way at once, matched to their answers by invoke id;
more wait in the client, within their own timeouts. Callers may share a client, and
send_request/3 lets one process have many requests out.
Connections
start_link/1 returns at once, and the client connects in the background: a request made while
it's connecting waits for the connection. When the connection drops, the client connects again
by itself, after 100 ms at first and up to 5 seconds between attempts (:backoff), and adds its
subscriptions' notifications again. While it's down, requests fail at once with
{:error, :closed}, and status/1 says why. Two requests in a row that time out with nothing at
all heard since the first was sent mean the connection is dead, and the client reconnects.
Secure ADS
secure: connects to port 8016 with TLS 1.2, as ADS.Secure describes, in one of three ways:
# a pre-shared key, as in the PLC's StaticRoutes.xml
secure: [psk: {"scada", "secret"}]
# certificates from a CA both sides trust
secure: [cacertfile: "ca.pem", certfile: "scada.crt", keyfile: "scada.key"]
# self-signed: the PLC's certificate known by its fingerprint, and a route added once
secure: [fingerprint: "6E:3B:...", certfile: "scada.pem", keyfile: "scada.key",
add_route: {"Administrator", "1"}]The PLC's NetId comes in its answer, so net_id: may be left out.
| Option | |
|---|---|
:psk | {identity, password} |
:cacerts, :cacertfile | the CA, for certificates from one |
:fingerprint | the SHA-256 of the PLC's self-signed certificate in hex, as TwinCAT shows it, or :any |
:cert and :key, or :certfile and :keyfile | the client's own certificate, for both kinds |
:add_route | {user, password} of a user on the PLC, to add a route for a self-signed certificate |
:ignore_cn | true for certificates that don't name their host: the PLC's isn't checked, and the PLC is asked not to check the client's |
:ip_address | true for a route that names the client by address rather than host name |
:hostname | the name the client gives, by default its host name |
:ssl | more options for OTP's :ssl |
Options
:host- the PLC's host name or address (required):net_id- the PLC's AMS NetId, such as"5.36.69.176.1.1"(required, except withsecure:):ams_port- the AMS port of the device (default 851, the first PLC runtime):local_net_id- the client's NetId, as the PLC's route has it (default the client's IP address on the connection, with.1.1):local_ams_port- the client's AMS port (default one picked from 32768 to 65535, the range for clients):port- the TCP port (default 48898, or 8016 withsecure:):secure- Secure ADS, as above:timeout- how long a request may take, in ms, waiting its turn included (default 5000):max_pending- how many requests may be on the way at once (default 32):connect_timeout- in ms (default 5000):backoff-{first, longest}wait between attempts to connect, in ms (default{100, 5000}):max_size- the most data an answer may have, in bytes (default 16 MB):codepage- of the system's strings of one byte to a character: its STRINGs, the names and comments of its symbols and types, its host name, routes and adapters, and Secure ADS's host name and login (default:windows_1252; seeADS.Codepage):name- to register the process
Summary
Types
A target, and the type of its value if it's to be given rather than looked up.
A variable, by its name, or by index group and offset (which needs a type).
Functions
The network adapters of the system, with their addresses, as its system service lists them (see
ADS.Adapter). port: asks another AMS port than 10000.
The PLC's description of a data type, by name; see ADS.DataType.
Uploads the descriptions of all the PLC's data types.
The IPv4 address the system finds for a host name, as its system service looks it up: on the
system, so with its DNS and its hosts file. port: asks another AMS port than 10000.
The host name of the system, as its system service answers it. port: asks another AMS port
than 10000.
The status, and the AMS addresses of both ends as {net_id, port}: the client's own, and the
device's, once known.
Reads a variable, by its name or by {index_group, index_offset} with type:, and gives its
value as ADS.Type has it.
Reads the name and version of the device, such as %{name: "Plc30 App", version: {3, 1, 4026}}.
Reads many variables at once, each a name, or {target, type}, and gives a result for each, in
order: {:ok, value} or {:error, reason}.
Reads the ADS state of the device, :run for a running PLC, and its device state.
Sends a request and waits for its result; see ADS.Command for both. port: sends it to
another AMS port than the client's, and timeout: overrides the client's for it.
The routes of the system, as its system service lists them: the clients and systems it talks
to, by name, NetId and address (see ADS.Route). port: asks another AMS port than 10000.
Sends a request without waiting for it, and returns a reference. The result comes as a message
to the caller, or to the process to: in opts
Starts a client, linked to the caller, with the options above. It returns at once, and connects
in the background. Raises ArgumentError for options that don't make sense.
Whether the client is connected: :connected, :connecting, or {:disconnected, reason} with
the reason it last couldn't connect or lost its connection, such as :econnrefused or
:closed.
Closes the connection and stops the client, letting go of its handles and notifications: it waits for the device to close its end too, so that it has read them, for a second at most.
Asks the PLC to send the values of variables, each a name or {target, type}, to the
subscriber; see "Notifications" above.
The PLC's description of a variable, by name; see ADS.Symbol.
Uploads the descriptions of all the PLC's variables.
The ADS.Type of a type name: built in, or as the PLC describes it, and those its description
refers to. The client keeps the types it has looked up for as long as the connection lasts.
Ends a subscription, and asks the PLC to delete its notifications.
Writes a variable, by its name or by {index_group, index_offset} with type:. Raises
ArgumentError for a value that doesn't fit its type, before writing anything.
Changes the ADS state of the device: :run starts a PLC, :stop stops it, and :reset resets
it. device_state: and data: go along, 0 and nothing by default.
Writes many variables at once, each {target, value} or {target, value, type}, and gives a
result for each, in order: :ok or {:error, reason}. Raises ArgumentError for a value that
doesn't fit its type, before writing anything.
Types
@type item() :: target() | {target(), ADS.Type.t()}
A target, and the type of its value if it's to be given rather than looked up.
@type target() :: String.t() | {ADS.index_group(), ADS.index_offset()}
A variable, by its name, or by index group and offset (which needs a type).
Functions
@spec adapters(GenServer.server(), keyword()) :: {:ok, [ADS.Adapter.t()]} | {:error, ADS.error()}
The network adapters of the system, with their addresses, as its system service lists them (see
ADS.Adapter). port: asks another AMS port than 10000.
{:ok, [%ADS.Adapter{addresses: [{{10, 0, 0, 5}, {255, 255, 255, 0}}]} | _]} =
ADS.Client.adapters(plc)
@spec data_type(GenServer.server(), String.t(), keyword()) :: {:ok, ADS.DataType.t()} | {:error, ADS.error()}
The PLC's description of a data type, by name; see ADS.DataType.
@spec data_types(GenServer.server(), keyword()) :: {:ok, [ADS.DataType.t()]} | {:error, ADS.error()}
Uploads the descriptions of all the PLC's data types.
@spec host_address(GenServer.server(), String.t(), keyword()) :: {:ok, :inet.ip4_address()} | {:error, ADS.error()}
The IPv4 address the system finds for a host name, as its system service looks it up: on the
system, so with its DNS and its hosts file. port: asks another AMS port than 10000.
{:ok, {10, 0, 0, 7}} = ADS.Client.host_address(plc, "scada-1")
{:error, :device_not_found} = ADS.Client.host_address(plc, "nowhere.invalid")
@spec host_name(GenServer.server(), keyword()) :: {:ok, String.t()} | {:error, ADS.error()}
The host name of the system, as its system service answers it. port: asks another AMS port
than 10000.
{:ok, "CX-1234AB"} = ADS.Client.host_name(plc)
@spec info(GenServer.server()) :: %{ status: :connected | :connecting | {:disconnected, term()}, local: {ADS.net_id(), ADS.ams_port()} | nil, remote: {ADS.net_id(), ADS.ams_port()} | nil }
The status, and the AMS addresses of both ends as {net_id, port}: the client's own, and the
device's, once known.
@spec read(GenServer.server(), target(), keyword()) :: {:ok, term()} | {:error, ADS.error()}
Reads a variable, by its name or by {index_group, index_offset} with type:, and gives its
value as ADS.Type has it.
@spec read_device_info(GenServer.server(), keyword()) :: {:ok, %{name: String.t(), version: {byte(), byte(), 0..65535}}} | {:error, ADS.error()}
Reads the name and version of the device, such as %{name: "Plc30 App", version: {3, 1, 4026}}.
@spec read_many(GenServer.server(), [item()], keyword()) :: {:ok, ok: term(), error: ADS.error()}
Reads many variables at once, each a name, or {target, type}, and gives a result for each, in
order: {:ok, value} or {:error, reason}.
{:ok, [{:ok, 1500}, {:error, :device_symbol_not_found}, {:ok, 42}]} =
ADS.Client.read_many(plc, ["MAIN.nSpeed", "MAIN.nNone", {{0x4020, 0}, :dint}])
@spec read_state(GenServer.server(), keyword()) :: {:ok, %{ads_state: ADS.state(), device_state: 0..65535}} | {:error, ADS.error()}
Reads the ADS state of the device, :run for a running PLC, and its device state.
@spec request(GenServer.server(), ADS.Command.request(), keyword()) :: ADS.Command.result()
Sends a request and waits for its result; see ADS.Command for both. port: sends it to
another AMS port than the client's, and timeout: overrides the client's for it.
ADS.Client.request(plc, {:read, 0x4020, 0, 4})
ADS.Client.request(plc, :read_state, port: 10000)
@spec routes(GenServer.server(), keyword()) :: {:ok, [ADS.Route.t()]} | {:error, ADS.error()}
The routes of the system, as its system service lists them: the clients and systems it talks
to, by name, NetId and address (see ADS.Route). port: asks another AMS port than 10000.
{:ok, [%ADS.Route{name: "scada", net_id: "10.0.0.7.1.1", address: "10.0.0.7"} | _]} =
ADS.Client.routes(plc)
@spec send_request(GenServer.server(), ADS.Command.request(), keyword()) :: reference()
Sends a request without waiting for it, and returns a reference. The result comes as a message
to the caller, or to the process to: in opts:
{ADS.Client, ref, result}It comes within the timeout, unless the client itself stops; monitor the client to hear of that.
@spec start_link(keyword()) :: GenServer.on_start()
Starts a client, linked to the caller, with the options above. It returns at once, and connects
in the background. Raises ArgumentError for options that don't make sense.
@spec status(GenServer.server()) :: :connected | :connecting | {:disconnected, term()}
Whether the client is connected: :connected, :connecting, or {:disconnected, reason} with
the reason it last couldn't connect or lost its connection, such as :econnrefused or
:closed.
@spec stop(GenServer.server()) :: :ok
Closes the connection and stops the client, letting go of its handles and notifications: it waits for the device to close its end too, so that it has read them, for a second at most.
@spec subscribe(GenServer.server(), item() | [item()], keyword()) :: {:ok, reference()} | {:error, ADS.error()}
Asks the PLC to send the values of variables, each a name or {target, type}, to the
subscriber; see "Notifications" above.
:mode-:on_change, the value when it changes, checked every cycle time (the default), or:cyclic, every cycle time:cycle_time- in ms (default 100):max_delay- the longest the PLC may hold a value back, to send it along with others, in ms (default 0):to- the subscriber (default the caller)
@spec symbol(GenServer.server(), String.t(), keyword()) :: {:ok, ADS.Symbol.t()} | {:error, ADS.error()}
The PLC's description of a variable, by name; see ADS.Symbol.
@spec symbols(GenServer.server(), keyword()) :: {:ok, [ADS.Symbol.t()]} | {:error, ADS.error()}
Uploads the descriptions of all the PLC's variables.
@spec type(GenServer.server(), String.t(), keyword()) :: {:ok, ADS.Type.t()} | {:error, ADS.error()}
The ADS.Type of a type name: built in, or as the PLC describes it, and those its description
refers to. The client keeps the types it has looked up for as long as the connection lasts.
{:ok, {:struct, 16, [{"nSpeed", 0, :int}, {"fTemp", 8, :lreal}]}} = ADS.Client.type(plc, "ST_Pump")
@spec unsubscribe(GenServer.server(), reference()) :: :ok
Ends a subscription, and asks the PLC to delete its notifications.
@spec write(GenServer.server(), target(), term(), keyword()) :: :ok | {:error, ADS.error()}
Writes a variable, by its name or by {index_group, index_offset} with type:. Raises
ArgumentError for a value that doesn't fit its type, before writing anything.
@spec write_control(GenServer.server(), ADS.state(), keyword()) :: :ok | {:error, ADS.error()}
Changes the ADS state of the device: :run starts a PLC, :stop stops it, and :reset resets
it. device_state: and data: go along, 0 and nothing by default.
@spec write_many( GenServer.server(), [{target(), term()} | {target(), term(), ADS.Type.t()}], keyword() ) :: {:ok, [:ok | {:error, ADS.error()}]}
Writes many variables at once, each {target, value} or {target, value, type}, and gives a
result for each, in order: :ok or {:error, reason}. Raises ArgumentError for a value that
doesn't fit its type, before writing anything.