Packages

OPCUA Erlang library

Current section

Files

Jump to
opcua src opcua_client.erl
Raw

src/opcua_client.erl

-module(opcua_client).
-behaviour(gen_statem).
% Inspired by: https://gist.github.com/ferd/c86f6b407cf220812f9d893a659da3b8
% When adding a state handler, remember to always add a timeout by calling
% enter_timeouts or event_timeouts so the state machine keep consuming data
% from the protocol. When adding a completly new state, remember to update
% enter_timeouts and event_timeouts themselves to handle the new state name.
%%% EXPORTS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% API functions
-export([connect/1, connect/2]).
-export([close/1]).
-export([browse/2, browse/3]).
-export([read/2, read/3, read/4]).
-export([write/2, write/3, write/4, write/5]).
-export([get/2, get/3]).
-export([add_nodes/3]).
-export([add_references/3]).
-export([del_nodes/3]).
-export([del_references/3]).
%% Startup functions
-export([start_link/1]).
%% Behaviour gen_statem callback functions
-export([init/1]).
-export([callback_mode/0]).
-export([handle_event/4]).
-export([terminate/3]).
%%% INCLUDES %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-include_lib("kernel/include/logger.hrl").
-include_lib("stdlib/include/assert.hrl").
-include("opcua.hrl").
-include("opcua_internal.hrl").
%%% TYPES %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-type client_auth_spec() :: anonymous | {user_name, binary(), binary()} | {certificate, opcua_keychain:ident()}.
-type endpoint_selector() :: fun((Conn :: opcua:connection(), Endpoints :: [map()]) ->
{ok, Endpoint :: term(), TokenPolicyId :: binary(),
AuthMethod :: client_auth_spec()}
| {error, not_found}).
-type connect_options() :: #{
% The parent keychain to use for the connection, if not defined it will use
% the default keychain.
keychain => opcua_keychain:state(),
% The number of time the client will retry connecting. Default: 3
connect_retry => non_neg_integer(),
% The connection timeout. Default: infinty.
connect_timeout => infinity | non_neg_integer(),
% The keychain manager to use, if not specfied it uses the default one.
keychain => term(),
% If the client should lookup the server endpoints first.
% The default is false if mode is none, true otherwise.
endpoint_lookup => boolean(),
% The security to use for endpoint lookup, if not specified,
% mode and policy will be none.
% Note that to be able to establish a secure connection, the client
% needs to know the server certificate/identity, this is what the endpoint
% lookup is usually used for.
endpoint_lookup_security => #{
mode => opcua:security_mode(),
policy => opcua:security_policy_type()
},
% The endpoint selector function to use if endpoint lookup is enabled.
% If not specified, the first endpoint and token type that match the then
% required options mode and auth will be selected.
endpoint_selector => endpoint_selector(),
% The security mode to use if the endpoint_selector is not defined or
% the endpoint selection is not enabled. By default it uses none.
mode => opcua:security_mode(),
% The security policy to use if the endpoint_selector is not defined or
% the endpoint selection is not enabled. By default it uses none.
policy => opcua:security_policy_type(),
% The client identity, must be defined if the policy type is not none.
identity => undefined | opcua_keychain:ident(),
% The server identity, if not defined, the identity will be found by
% looking up the server endpoints. This could be specified to force the
% use of a specific endpoint, or do a secured endpoint lookup (if that
% ever make sense).
server_identity => undefined | opcua_keychain:ident(),
% The authentication method to use if the endpoint_selector is not defined
% or the endpoint selection is not enabled. By default it uses anonymous.
auth => client_auth_spec()
}.
-type browse_options() :: opcua:references_options().
-type read_options() :: #{
}.
-type write_options() :: #{
}.
-type get_options() :: #{
}.
-type add_node_options() :: #{
% The default parent node for the added node. If not specified, the standard
% node 'objects' will be used as default parent node.
parent => opcua:node_spec(),
% The default reference type for the reference beween the parent node and
% the added node. If not specified, the reference type will be `organizes`.
ref_type => opcua:node_spec()
}.
-type add_ref_options() :: #{
% Add the reverse reference automatically, possibly changing the reference
% type if the reference is not symetric (contains/contained_in).
% If not specified, no inverse reference will be added.
bidirectional => boolean()
}.
-type del_node_options() :: #{
% If the reference having the deleted node as target should be deleted.
% If not specified, they will not be deleted.
delete_target_references => boolean()
}.
-type del_ref_options() :: #{
% If the opposite direction reference should be deleted too.
% If not specified, they will not be deleted.
delete_bidirectional => boolean()
}.
-type browse_spec() :: opcua:node_spec() | {opcua:node_spec(), browse_options()}.
-type attrib_spec_range() :: {non_neg_integer(), non_neg_integer()}.
-type attrib_spec_index_level() :: non_neg_integer() | attrib_spec_range().
-type attrib_spec_index() :: attrib_spec_index_level() | [attrib_spec_index_level()].
-type attrib_spec() :: atom() | {atom(), attrib_spec_index()}.
-type read_spec() :: {opcua:node_spec(), attrib_spec() | [attrib_spec()]}.
-type write_spec() :: {opcua:node_spec(), {attrib_spec(), term()} | [{attrib_spec(), term()}]}.
-type ref_desc() :: map().
-record(data, {
opts :: undefined | map(),
socket :: undefined | inet:socket(),
conn :: undefined | opcua:connection(),
proto :: undefined | term(),
continuations = #{} :: #{term() => {term(), cont_fun()}}
}).
-type cont_fun() :: fun((#data{}, ok | error, ResultOrReason :: term(),
Params :: term()) -> #data{}).
-export_type([connect_options/0, browse_options/0,
read_options/0, write_options/0]).
%%% API FUNCTIONS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
connect(EndpointUrl) ->
connect(EndpointUrl, #{}).
close(Pid) ->
gen_statem:call(Pid, close).
-spec connect(EndpointUrl :: binary(), Opts :: connect_options()) ->
{ok, ClientPid :: pid()} | {error, Reason :: term()}.
connect(EndpointUrl, Opts) ->
Pid = opcua_client_pool_sup:start_client(#{}),
EndpointRec = opcua_util:parse_endpoint(EndpointUrl),
try prepare_connect_options(Opts) of
FullOpts ->
Msg = {connect, EndpointRec, FullOpts},
case gen_statem:call(Pid, Msg, infinity) of
{error, _Reason} = Error -> Error;
ok -> {ok, Pid}
end
catch throw:Reason -> {error, Reason}
end.
-spec browse(pid(), opcua:node_spec() | [browse_spec()]) ->
[ref_desc()] | [[ref_desc()]].
browse(Pid, BrowseSpec) when is_list(BrowseSpec) ->
browse(Pid, BrowseSpec, #{});
browse(Pid, NodeSpec) ->
[Result] = browse(Pid, [NodeSpec], #{}),
Result.
-spec browse(pid(), opcua:node_spec() | [browse_spec()], browse_options()) ->
[ref_desc()] | [[ref_desc()]].
browse(Pid, BrowseSpec, DefaultOpts) when is_list(BrowseSpec) ->
PreparedDefaultOpts = prepare_browse_opts(maps:merge(#{
include_subtypes => false,
type => undefined,
direction => forward
}, DefaultOpts)),
PreparedSpec = prepare_browse_spec(BrowseSpec, []),
Command = {browse, PreparedSpec, PreparedDefaultOpts},
case gen_statem:call(Pid, Command) of
{ok, Result} -> Result;
{error, Reason} ->
erlang:error(Reason)
end;
browse(Pid, NodeSpec, DefaultOpts) ->
[Result] = browse(Pid, [NodeSpec], DefaultOpts),
Result.
-spec read(pid(), [read_spec()]) -> [map()].
read(Pid, ReadSpec) ->
read(Pid, ReadSpec, #{}).
-spec read(pid(), opcua:node_spec() | [read_spec()],
attrib_spec() | [attrib_spec()] | read_options()) ->
map() | [map()].
read(Pid, ReadSpec, Opts) when is_list(ReadSpec) ->
PreparedSpec = prepare_read_spec(ReadSpec, []),
Command = {read, PreparedSpec, Opts},
case gen_statem:call(Pid, Command) of
{ok, Result} -> Result;
{error, Reason} ->
erlang:error(Reason)
end;
read(Pid, NodeSpec, AttribSpecs) ->
read(Pid, NodeSpec, AttribSpecs, #{}).
read(Pid, NodeSpec, AttribSpecs, Opts) when is_list(AttribSpecs) ->
[Result] = read(Pid, [{NodeSpec, AttribSpecs}], Opts),
Result;
read(Pid, NodeSpec, AttribSpec, Opts) ->
Key = result_key(AttribSpec),
case read(Pid, [{NodeSpec, [AttribSpec]}], Opts) of
[#{Key := #opcua_error{status = Status}}] -> erlang:error(Status);
[#{Key := Result}] -> Result
end.
-spec write(pid(), [write_spec()]) -> [map()].
write(Pid, WriteSpec) ->
write(Pid, WriteSpec, #{}).
-spec write(pid(), opcua:node_spec() | [write_spec()],
[{attrib_spec(), term()}] | write_options()) ->
map() | [map()].
write(Pid, WriteSpec, Opts) when is_list(WriteSpec) ->
PreparedSpec = prepare_write_spec(WriteSpec, []),
Command = {write, PreparedSpec, Opts},
case gen_statem:call(Pid, Command) of
{ok, Result} -> Result;
{error, Reason} ->
erlang:error(Reason)
end;
write(Pid, NodeSpec, AttribValuePairs) ->
write(Pid, NodeSpec, AttribValuePairs, #{}).
write(Pid, NodeSpec, AttribValuePairs, Opts) when is_list(AttribValuePairs) ->
[Result] = write(Pid, [{NodeSpec, AttribValuePairs}], Opts),
Result;
write(Pid, NodeSpec, AttribSpec, Value) ->
write(Pid, NodeSpec, AttribSpec, Value, #{}).
write(Pid, NodeSpec, AttribSpec, Value, Opts) ->
Key = result_key(AttribSpec),
case write(Pid, NodeSpec, [{AttribSpec, Value}], Opts) of
#{Key := #opcua_error{status = Status}} -> erlang:error(Status);
#{Key := Result} -> Result
end.
-spec get(pid(), NodeSpec | [NodeSpec]) -> Node | [Node | Error]
when NodeSpec :: opcua:node_spec(),
Node :: opcua:node_rec(), Error :: opcua:error().
get(Pid, NodeSpec) ->
get(Pid, NodeSpec, #{}).
-spec get(pid(), NodeSpec | [NodeSpec], Opts) -> Node | [Node | Error]
when NodeSpec :: opcua:node_spec(), Opts :: get_options(),
Node :: opcua:node_rec(), Error :: opcua:error().
get(Pid, NodeSpecs, Opts) when is_list(NodeSpecs) ->
NodeIds = [opcua_node:id(Spec) || Spec <- NodeSpecs],
case gen_statem:call(Pid, {get, NodeIds, Opts}) of
{ok, Nodes} -> Nodes;
{error, Reason} -> erlang:error(Reason)
end;
get(Pid, NodeSpec, Opts) ->
case get(Pid, [NodeSpec], Opts) of
[#opcua_error{status = Status}] -> erlang:error(Status);
[Node] -> Node
end.
-spec add_nodes(pid(), [NodeDef], Opts) -> [Node | Error]
when NodeDef :: Node | {ParentSpec, Node} | {ParentSpec, RefTypeSpec, Node},
ParentSpec :: opcua:node_spec(), RefTypeSpec :: opcua:node_spec(),
Node :: opcua:node_rec(), Error :: opcua:error(),
Opts :: add_node_options().
add_nodes(_Pid, _NodeDefs, _Opts) ->
erlang:error(not_implemented).
-spec add_references(pid(), [RefDef], Opts) -> [Status]
when RefDef :: {SourceSpec, RefTypeSpec, TargetSpec} | opcua:node_ref(),
SourceSpec :: opcua:node_spec(), RefTypeSpec :: opcua:node_spec(),
TargetSpec :: opcua:node_spec(), Opts :: add_ref_options(),
Status :: opcua:status().
add_references(_Pid, _RefDefs, _Opts) ->
erlang:error(not_implemented).
-spec del_nodes(pid(), [NodeSpec], Opts) -> [Status]
when NodeSpec :: opcua:node_spec(), Opts :: del_node_options(),
Status :: opcua:status().
del_nodes(_Pid, _NodeSpecs, _Opts) ->
erlang:error(not_implemented).
-spec del_references(pid(), [RefDef], Opts) -> [Status]
when RefDef :: {SourceSpec, RefTypeSpec, TargetSpec} | opcua:node_ref(),
SourceSpec :: opcua:node_spec(), RefTypeSpec :: opcua:node_spec(),
TargetSpec :: opcua:node_spec(), Opts :: del_ref_options(),
Status :: opcua:status().
del_references(_Pid, _RefDefs, _Opts) ->
erlang:error(not_implemented).
%%% STARTUP FUNCTIONS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
start_link(Opts) ->
gen_statem:start_link(?MODULE, Opts, []).
%%% BEHAVIOUR gen_statem CALLBACK FUNCTIONS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
init(_Opts) ->
?LOG_DEBUG("OPCUA client process starting", []),
{ok, disconnected, #data{}}.
callback_mode() -> [handle_event_function, state_enter].
%% STATE: disconnected
handle_event({call, From}, {connect, EndpointRec, Opts}, disconnected = State,
#data{conn = undefined} = Data) ->
Data2 = Data#data{opts = Opts},
{ProtoMode, ProtoOpts} = proto_initial_mode(Data2),
case opcua_client_uacp:init(ProtoMode, ProtoOpts) of
% No error use-case yet, disabling to make dialyzer happy
% {error, Reason} ->
% {stop, normal, do_abort_all(Data2, Reason)};
{ok, Proto} ->
Data3 = delay_response(Data2#data{proto = Proto}, on_ready, From),
{next_state, {connecting, 0, ProtoMode, EndpointRec}, Data3,
event_timeouts(State, Data2)}
end;
%% STATE: {connecting, N, EndpointRec}
handle_event(enter, _OldState, {connecting, N, _, _} = State, Data) ->
?LOG_DEBUG("Client ~p entered ~p", [self(), State]),
#data{opts = #{connect_retry := MaxRetry}} = Data,
case MaxRetry =:= infinity orelse N =< MaxRetry of
true ->
{keep_state, Data, enter_timeouts(State, Data)};
false ->
{stop, normal, do_abort_all(Data, retry_exhausted)}
end;
handle_event(state_timeout, retry,
{connecting, N, ProtoMode, EndpointRec} = State, Data) ->
case conn_init(Data, ProtoMode, EndpointRec) of
{ok, Data2} ->
{next_state, handshaking, Data2, event_timeouts(State, Data2)};
{error, _Reason} ->
{next_state, {connecting, N + 1, ProtoMode, EndpointRec}, Data,
event_timeouts(State, Data)}
end;
%% STATE: handshaking
handle_event(enter, _OldState, handshaking = State, Data) ->
?LOG_DEBUG("Client ~p entered ~p", [self(), State]),
case proto_handshake(Data) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Data2} ->
% {stop, normal, do_abort_all(Data2, Reason)};
{ok, Data2} ->
{keep_state, Data2, enter_timeouts(State, Data2)}
end;
handle_event(info, {opcua_connection, {reconnect, {Peer, Url}}},
handshaking = State, Data) ->
?LOG_INFO("Reconnecting to endpoint ~s", [Url]),
{next_state, {reconnecting, {Peer, Url}}, Data, event_timeouts(State, Data)};
handle_event(info, {opcua_connection, ready}, handshaking = State, Data) ->
{next_state, connected, Data, event_timeouts(State, Data)};
handle_event(info, {opcua_connection, _}, handshaking, Data) ->
{stop, normal, do_abort_all(Data, opcua_handshaking_failed)};
handle_event(state_timeout, abort, handshaking, Data) ->
{stop, normal, do_abort_all(Data, handshake_timeout)};
%% STATE: connected
handle_event(enter, _OldState, connected = State, Data) ->
?LOG_DEBUG("Client ~p entered connected", [self()]),
Data2 = do_continue(Data, on_ready, ok),
{keep_state, Data2, enter_timeouts(State, Data)};
handle_event({call, From}, close, connected = State, Data) ->
Data2 = delay_response(Data, on_closed, From),
{next_state, closing, Data2, event_timeouts(State, Data)};
handle_event({call, From}, {browse, BrowseSpec, Opts}, connected = State, Data) ->
Data2 = do_browse(Data, BrowseSpec, Opts, From, fun contfun_reply/4),
{keep_state, Data2, enter_timeouts(State, Data2)};
handle_event({call, From}, {read, ReadSpec, Opts},
connected = State, Data) ->
Data2 = do_read(Data, ReadSpec, Opts, From, fun contfun_reply/4),
{keep_state, Data2, enter_timeouts(State, Data2)};
handle_event({call, From}, {write, WriteSpec, Opts},
connected = State, Data) ->
Data2 = do_write(Data, WriteSpec, Opts, From, fun contfun_reply/4),
{keep_state, Data2, enter_timeouts(State, Data2)};
handle_event({call, From}, {get, NodeIds, Opts}, connected = State, Data) ->
Data2 = do_get(Data, NodeIds, Opts, From, fun contfun_reply/4),
{keep_state, Data2, enter_timeouts(State, Data2)};
%% STATE: {reconnecting, Endpoint}
handle_event(enter, _OldState, {reconnecting, _Conn} = State, Data) ->
?LOG_DEBUG("Client ~p entered reconnecting", [self()]),
case proto_close(Data) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Data2} ->
% Data3 = do_continue(Data2, on_closed, error, Reason),
% {stop, Reason, do_abort_all(Data3, closed)};
{ok, Data2} ->
{keep_state, Data2, enter_timeouts(State, Data2)}
end;
handle_event(info, {opcua_connection, closed},
{reconnecting, PeerInfo} = State, Data) ->
reconnect(Data, State, PeerInfo);
handle_event(state_timeout, abort, {reconnecting, PeerInfo} = State, Data) ->
reconnect(Data, State, PeerInfo);
handle_event(info, {tcp_closed, Sock}, {reconnecting, PeerInfo} = State,
#data{socket = Sock} = Data) ->
%% When closing the server may close the socket at any time
reconnect(Data, State, PeerInfo);
%% STATE: closing
handle_event(enter, _OldState, closing = State, Data) ->
?LOG_DEBUG("Client ~p entered closing", [self()]),
case proto_close(Data) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Data2} ->
% Data3 = do_continue(Data2, on_closed, error, Reason),
% {stop, Reason, do_abort_all(Data3, closed)};
{ok, Data2} ->
{keep_state, Data2, enter_timeouts(State, Data2)}
end;
handle_event(info, {opcua_connection, closed}, closing, Data) ->
Data2 = do_continue(Data, on_closed, ok),
Data3 = do_abort_all(Data2, closed),
{stop, normal, Data3};
handle_event(state_timeout, abort, closing, Data) ->
{stop, normal, do_abort_all(Data, close_timeout)};
handle_event(info, {tcp_closed, Sock}, closing, #data{socket = Sock} = Data) ->
%% When closing the server may close the socket at any time
Data2 = do_continue(Data, on_closed, ok),
Data3 = do_abort_all(Data2, closed),
{stop, normal, Data3};
%% STATE: handshaking, connected, reconnecting and closing
handle_event(timeout, produce, State, Data) ->
case proto_produce(Data) of
{ok, Data2} ->
{keep_state, Data2, event_timeouts(State, Data2)};
{ok, Output, Data2} ->
case conn_send(Data2, Output) of
ok -> {keep_state, Data2, event_timeouts(State, Data2)};
{error, Reason} -> {stop, Reason, do_abort_all(Data2, Reason)}
end;
{error, Reason, Data2} -> {stop, Reason, do_abort_all(Data2, Reason)}
end;
handle_event(info, {tcp, Sock, Input}, State, #data{socket = Sock} = Data) ->
?DUMP("Received Data: ~p", [Input]),
case proto_handle_data(Data, Input) of
{ok, Responses, Data2} ->
Data3 = do_continue(Data2, Responses),
{keep_state, Data3, event_timeouts(State, Data3)};
{error, Reason, Data2} ->
{stop, Reason, do_abort_all(Data2, Reason)}
end;
handle_event(info, {tcp_passive, Sock}, State, #data{socket = Sock} = Data) ->
case conn_activate(Data) of
ok ->
{keep_state, Data, event_timeouts(State, Data)};
{error, Reason} ->
{stop, Reason, do_abort_all(Data, socket_error)}
end;
handle_event(info, {tcp_closed, Sock}, _State, #data{socket = Sock} = Data) ->
{stop, normal, do_abort_all(Data, socket_closed)};
handle_event(info, {tcp_error, Sock}, _State, #data{socket = Sock} = Data) ->
{stop, tcp_error, do_abort_all(Data, socket_error)};
%% GENERIC STATE HANDLERS
handle_event(enter, _OldState, NewState, Data) ->
?LOG_DEBUG("Client ~p entered ~p", [self(), NewState]),
{keep_state, Data, enter_timeouts(NewState, Data)};
handle_event(call, _, _, Data) ->
{stop, unexpected_call, do_abort_all(Data, unexpected_call)};
handle_event(cast, _, _, Data) ->
%TODO: Should be changed to not crash the client later on
{stop, unexpected_cast, do_abort_all(Data, unexpected_cast)};
handle_event(info, _, _, Data) ->
%TODO: Should be changed to not crash the client later on
{stop, unexpected_message, do_abort_all(Data, unexpected_message)}.
terminate(Reason, State, Data) ->
?LOG_DEBUG("OPCUA client process terminated in state ~w: ~p", [State, Reason]),
proto_terminate(Data, Reason),
conn_close(Data).
%%% INTERNAL FUNCTIONS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%-- INTERNAL ASYNC API FUNCTIONS -----------------------------------------------
do_browse(Data, BrowseSpec, Opts, Params, ContFun) ->
case proto_browse(Data, BrowseSpec, Opts) of
{async, Handle, Data2} ->
schedule_continuation(Data2, Handle, Params, ContFun)
end.
do_read(Data, ReadSpec, Opts, Params, ContFun) ->
case proto_read(Data, ReadSpec, Opts) of
{async, Handle, Data2} ->
UnpackParams = {ReadSpec, Params, ContFun},
schedule_continuation(Data2, Handle, UnpackParams,
fun contfun_unpack_read/4)
end.
do_write(Data, WriteSpec, Opts, Params, ContFun) ->
case proto_write(Data, WriteSpec, Opts) of
{async, Handle, Data2} ->
UnpackParams = {WriteSpec, Params, ContFun},
schedule_continuation(Data2, Handle, UnpackParams,
fun contfun_unpack_write/4)
end.
do_get(Data, NodeIds, _Opts, Params, ContFun) ->
Attribs = [{{A, undefined}, #{}} || A <- opcua_nodeset:attributes()],
ReadSpec = [{NID, Attribs} || NID <- NodeIds],
UnpackParams = {Params, ContFun},
do_read(Data, ReadSpec, #{}, UnpackParams, fun contfun_unpack_get/4).
schedule_continuation(#data{continuations = ContMap} = Data, Key, Params, ContFun) ->
?assertNot(maps:is_key(Key, ContMap)),
Data#data{continuations = ContMap#{Key => {Params, ContFun}}}.
do_continue(Data, []) -> Data;
do_continue(#data{continuations = ContMap} = Data,
[{Key, Outcome, ResultOrReason} | Rest]) ->
case maps:take(Key, ContMap) of
error ->
?LOG_WARNING("Continuation for ~w not found", [Key]),
do_continue(Data, Rest);
{{Params, ContFun}, ContMap2} ->
Data2 = Data#data{continuations = ContMap2},
do_continue(ContFun(Data2, Outcome, ResultOrReason, Params), Rest)
end.
do_continue(Data, Key, Outcome) ->
do_continue(Data, Key, Outcome, '_NO_RESULT_').
do_continue(Data, Key, Outcome, Result) ->
do_continue(Data, [{Key, Outcome, Result}]).
do_abort_all(#data{continuations = ContMap} = Data, Reason) ->
do_continue(Data, [{K, error, Reason} || K <- maps:keys(ContMap)]).
contfun_unpack_read(#data{conn = Conn} = Data, ok, Results,
{ReadSpec, SubParams, SubContFun}) ->
UnpackedResult = unpack_read_result(Conn, ReadSpec, Results),
SubContFun(Data, ok, UnpackedResult, SubParams).
contfun_unpack_write(Data, ok, Results, {WriteSpec, SubParams, SubContFun}) ->
UnpackedResult = unpack_write_result(WriteSpec, Results),
SubContFun(Data, ok, UnpackedResult, SubParams).
contfun_unpack_get(Data, error, Reason, {Params, ContFun}) ->
ContFun(Data, error, Reason, Params);
contfun_unpack_get(Data, ok, Result, {Params, ContFun}) ->
Nodes = [opcua_node:from_attributes(A) || A <- Result],
ContFun(Data, ok, Nodes, Params).
contfun_reply(Data, Outcome, '_NO_RESULT_', From) ->
gen_statem:reply(From, Outcome),
Data;
contfun_reply(Data, Outcome, ResultOrReason, From) ->
gen_statem:reply(From, {Outcome, ResultOrReason}),
Data.
delay_response(Data, Tag, From) ->
schedule_continuation(Data, Tag, From, fun contfun_reply/4).
%-- OTHER INTERNAL FUNCTIONS ---------------------------------------------------
result_key(Attrib) when is_atom(Attrib) -> Attrib;
result_key({Attrib, undefined}) when is_atom(Attrib) -> Attrib;
result_key({Attrib, IndexRange}) when is_atom(Attrib) -> {Attrib, IndexRange}.
prepare_connect_options(Opts) ->
EndpointLookupSec = case maps:get(endpoint_lookup_security, Opts, #{}) of
undefined -> #{};
Value -> Value
end,
Merged = maps:merge(#{
connect_retry => 3,
connect_timeout => infinity,
keychain => default,
endpoint_lookup => undefined,
endpoint_lookup_security =>
maps:merge(#{
mode => none,
policy => none
}, EndpointLookupSec),
mode => none,
policy => none,
identity => undefined,
server_identity => undefined,
auth => anonymous
}, Opts),
prepare_keychain(
prepare_lookup(
prepare_server_identity(
prepare_client_identity(
prepare_selector(Merged))))).
prepare_browse_spec([], Acc) ->
lists:reverse(Acc);
prepare_browse_spec([{NodeSpec, Opts} | Rest], Acc)
when is_map(Opts); Opts =:= undefined ->
FixedOpts = prepare_browse_opts(Opts),
Acc2 = [{opcua_node:id(NodeSpec), FixedOpts} | Acc],
prepare_browse_spec(Rest, Acc2);
prepare_browse_spec([NodeSpec | Rest], Acc)->
Acc2 = [{opcua_node:id(NodeSpec), undefined} | Acc],
prepare_browse_spec(Rest, Acc2).
prepare_browse_opts(#{type := NodeSpec} = Opts) ->
Opts#{type => opcua_node:id(NodeSpec)};
prepare_browse_opts(Opts) ->
Opts.
prepare_read_spec([], Acc) ->
lists:reverse(Acc);
prepare_read_spec([{NodeSpec, Attribs} | Rest], Acc)
when is_list(Attribs) ->
FixedAttribs = prepare_read_spec_attr(Attribs, []),
Acc2 = [{opcua_node:id(NodeSpec), FixedAttribs} | Acc],
prepare_read_spec(Rest, Acc2);
prepare_read_spec([{NodeSpec, Attrib} | Rest], Acc)->
FixedAttribs = prepare_read_spec_attr([Attrib], []),
Acc2 = [{opcua_node:id(NodeSpec), FixedAttribs} | Acc],
prepare_read_spec(Rest, Acc2).
prepare_read_spec_attr([], Acc) ->
lists:reverse(Acc);
prepare_read_spec_attr([Attr | Rest], Acc) when is_atom(Attr) ->
prepare_read_spec_attr(Rest, [{{Attr, undefined}, undefined} | Acc]);
prepare_read_spec_attr([{Attr, IndexRange} | Rest], Acc) when is_atom(Attr) ->
prepare_read_spec_attr(Rest, [{{Attr, IndexRange}, undefined} | Acc]).
prepare_write_spec([], Acc) ->
lists:reverse(Acc);
prepare_write_spec([{NodeSpec, Attribs} | Rest], Acc)
when is_list(Attribs) ->
FixedAttribs = prepare_write_spec_attr(Attribs, []),
Acc2 = [{opcua_node:id(NodeSpec), FixedAttribs} | Acc],
prepare_write_spec(Rest, Acc2);
prepare_write_spec([{NodeSpec, Attrib, Value} | Rest], Acc)->
FixedAttribs = prepare_write_spec_attr([{Attrib, Value}], []),
Acc2 = [{opcua_node:id(NodeSpec), FixedAttribs} | Acc],
prepare_write_spec(Rest, Acc2).
prepare_write_spec_attr([], Acc) ->
lists:reverse(Acc);
prepare_write_spec_attr([{Attr, Val} | Rest], Acc) when is_atom(Attr) ->
prepare_write_spec_attr(Rest, [{{Attr, undefined}, Val, undefined} | Acc]);
prepare_write_spec_attr([{{Attr, IndexRange}, Val} | Rest], Acc) when is_atom(Attr) ->
prepare_write_spec_attr(Rest, [{{Attr, IndexRange}, Val, undefined} | Acc]).
prepare_keychain(#{keychain := Keychain} = Opts) ->
% Make sure the keychain can be shared with other processes
Opts#{keychain := opcua_keychain:shareable(Keychain)}.
prepare_lookup(#{mode := none, endpoint_lookup := undefined} = Opts) ->
Opts#{endpoint_lookup := false};
prepare_lookup(#{endpoint_lookup := undefined} = Opts) ->
Opts#{endpoint_lookup := true};
prepare_lookup(Opts) ->
Opts.
prepare_selector(#{endpoint_selector := Selector} = Opts)
when Selector =/= undefined -> Opts;
prepare_selector(#{mode := Mode, policy := Policy, auth := AuthSpec} = Opts) ->
DefaultSelector = fun(Conn, Endpoints) ->
select_endpoint(Conn, Mode, Policy, AuthSpec, Endpoints)
end,
Opts#{endpoint_selector => DefaultSelector}.
prepare_client_identity(#{keychain := Keychain, identity := undefined} = Opts) ->
case opcua_keychain:lookup(Keychain, alias, client) of
not_found -> Opts;
[Id | _] -> Opts#{identity => Id}
end;
prepare_client_identity(#{keychain := Keychain, identity := Id} = Opts) ->
case opcua_keychain:info(Keychain, Id) of
not_found -> throw(client_identity_not_found);
#{id := Id} -> Opts
end.
prepare_server_identity(#{keychain := Keychain, server_identity := Id} = Opts)
when Id =/= undefined ->
case opcua_keychain:info(Keychain, Id) of
not_found -> throw(server_identity_not_found);
#{id := Id} -> Opts
end;
prepare_server_identity(Opts) ->
Opts.
proto_initial_mode(#data{opts = #{endpoint_lookup := true} = Opts}) ->
#{endpoint_selector := Selector, endpoint_lookup_security := SubOpts} = Opts,
#{mode := Mode, policy := Policy} = SubOpts,
ProtoOpts = #{endpoint_selector => Selector, mode => Mode, policy => Policy},
{lookup_endpoint, ProtoOpts};
proto_initial_mode(#data{opts = #{endpoint_lookup := false} = Opts}) ->
#{endpoint_selector := Selector, mode := Mode, policy := Policy} = Opts,
ProtoOpts = #{endpoint_selector => Selector, mode => Mode, policy => Policy},
{open_session, ProtoOpts}.
reconnect(#data{opts = Opts, conn = #uacp_connection{keychain = Keychain}} = Data,
State, {PeerIdent, EndpointUrl}) ->
Data2 = conn_close(Data),
#{endpoint_selector := Selector} = Opts,
ProtoOpts = #{endpoint_selector => Selector},
case opcua_client_uacp:init(open_session, ProtoOpts) of
% No error use-case yet, disabling to make dialyzer happy
% {error, Reason} ->
% stop_and_reply_all(internal_error, Data2, {error, Reason});
{ok, Proto} ->
Data3 = Data2#data{proto = Proto,
opts = maps:merge(Opts, #{
server_identity => PeerIdent,
keychain => Keychain
})},
{next_state,
{connecting, 0, open_session, opcua_util:parse_endpoint(EndpointUrl)},
Data3,
event_timeouts(State, Data3)}
end.
select_endpoint(Conn, Mode, Policy, AuthSpec, Endpoints) ->
ServerCert = opcua_connection:peer_certificate(Conn),
PolicyUri = opcua_util:policy_uri(Policy),
AuthType = auth_type(AuthSpec),
FilteredBySecurity = filter_by_security(Endpoints, Mode, PolicyUri, ServerCert),
FilteredByTokens = filter_by_token_type(FilteredBySecurity, AuthType),
case take_first_who_validates(Conn, Mode, FilteredByTokens) of
not_found -> {error, not_found};
{Conn3, PeerIdentity, #{user_identity_tokens:= Tokens} = Endpoint} ->
[#{policy_id := PolicyId} | _] = filter_tokens(Tokens, AuthType),
{ok, Conn3, PeerIdentity, Endpoint, PolicyId, AuthSpec}
end.
take_first_who_validates(_, _, []) ->
not_found;
take_first_who_validates(Conn, Mode, [#{server_certificate := undefined}| Rest])
when Mode =/= none ->
take_first_who_validates(Conn, Mode, Rest);
take_first_who_validates(Conn, Mode, [CandidateEndpoint| Rest]) ->
#{server_certificate := C} = CandidateEndpoint,
case opcua_keychain:validate(Conn, C) of
{ok, Conn2, PeerIdentity} -> {Conn2, PeerIdentity, CandidateEndpoint};
{error, _Reason} ->
take_first_who_validates(Conn, Mode, Rest)
end.
filter_by_security(Endpoints, Mode, PolicyUri, ServerDerCert) ->
[E || E = #{security_mode := M,
security_policy_uri := P,
server_certificate := CertOrChain} <- Endpoints,
M =:= Mode, P =:= PolicyUri,
ServerDerCert =:= undefined orelse
cert_head_of_chain(CertOrChain, ServerDerCert)].
cert_head_of_chain(DerChain, DerCert) ->
Size = byte_size(DerCert),
case DerChain of
<<DerCert:Size/binary, _/binary>> -> true;
_ -> false
end.
filter_by_token_type(Endpoints, AuthType) ->
[ E || #{user_identity_tokens := Tokens} = E <- Endpoints,
length(filter_tokens(Tokens, AuthType)) > 0].
filter_tokens(Tokens, AuthType) ->
[I || I = #{token_type := T} <- Tokens, T =:= AuthType].
auth_type(anonymous) -> anonymous;
auth_type({user_name, _, _}) -> user_name.
unpack_read_result(Space, ReadSpec, ReadResult) ->
unpack_read_result(Space, ReadSpec, ReadResult, #{}, []).
unpack_read_result(_Space, [], [], _, Acc) ->
lists:reverse(Acc);
unpack_read_result(Space, [{_NodeId, []} | MoreNodes], ReadResult, Map, Acc) ->
unpack_read_result(Space, MoreNodes, ReadResult, #{}, [Map | Acc]);
unpack_read_result(Space, [{NodeId, [{AttribSpec, _} | MoreAttribs]} | MoreNodes],
[Result | MoreResults], Map, Acc) ->
ResultKey = result_key(AttribSpec),
AttribType = opcua_nodeset:attribute_type(ResultKey),
UnpackedResult = unpack_attribute_value(Space, ResultKey, AttribType, Result),
unpack_read_result(Space, [{NodeId, MoreAttribs} | MoreNodes], MoreResults,
Map#{ResultKey => UnpackedResult}, Acc).
unpack_attribute_value(_Space, _, _, #opcua_error{} = Value) -> Value;
unpack_attribute_value(_Space, _, variant, #opcua_variant{} = Value) -> Value;
unpack_attribute_value(_Space, _, Type, #opcua_variant{type = Type, value = Value}) -> Value;
unpack_attribute_value(Space, _, #opcua_node_id{} = Type, #opcua_variant{value = Value}) ->
opcua_codec:resolve(Space, Type, Value);
unpack_attribute_value(_Space, Key, Type, Value) ->
?LOG_ERROR("Unexpected attribute ~s value with expected type ~p: ~p",
[Key, Type, Value]),
%TODO: Should we just crash if we receive unexpected attribute data ?
Value.
unpack_write_result(WriteSpec, WriteResult) ->
unpack_write_result(WriteSpec, WriteResult, #{}, []).
unpack_write_result([], [], _, Acc) ->
lists:reverse(Acc);
unpack_write_result([{_NodeId, []} | MoreNodes], WriteResult, Map, Acc) ->
unpack_write_result(MoreNodes, WriteResult, #{}, [Map | Acc]);
unpack_write_result([{NodeId, [{AttribSpec, _, _} | MoreAttribs]} | MoreNodes],
[Result | MoreResults], Map, Acc) ->
ResultKey = result_key(AttribSpec),
unpack_write_result([{NodeId, MoreAttribs} | MoreNodes], MoreResults,
Map#{ResultKey => Result}, Acc).
%== Protocol Module Abstraction Functions ======================================
proto_produce(#data{conn = Conn, proto = Proto} = Data) ->
case opcua_client_uacp:produce(Conn, Proto) of
{ok, Conn2, Proto2} ->
{ok, Data#data{conn = Conn2, proto = Proto2}};
{ok, Output, Conn2, Proto2} ->
{ok, Output, Data#data{conn = Conn2, proto = Proto2}};
{error, Reason, Proto2} ->
{error, Reason, Data#data{proto = Proto2}}
end.
proto_handle_data(#data{conn = Conn, proto = Proto} = Data, Input) ->
case opcua_client_uacp:handle_data(Input, Conn, Proto) of
{ok, Responses, Conn2, Proto2} ->
{ok, Responses, Data#data{conn = Conn2, proto = Proto2}};
{error, Reason, Proto2} ->
{error, Reason, Data#data{proto = Proto2}}
end.
proto_handshake(#data{conn = Conn, proto = Proto} = Data) ->
case opcua_client_uacp:handshake(Conn, Proto) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Proto2} ->
% {error, Reason, Data#data{proto = Proto2}};
{ok, Conn2, Proto2} ->
{ok, Data#data{conn = Conn2, proto = Proto2}}
end.
proto_browse(#data{conn = Conn, proto = Proto} = Data, NodeId, Opts) ->
case opcua_client_uacp:browse(NodeId, Opts, Conn, Proto) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Proto2} ->
% {error, Reason, Data#data{proto = Proto2}};
{async, Handle, Conn2, Proto2} ->
{async, Handle, Data#data{conn = Conn2, proto = Proto2}}
end.
proto_read(#data{conn = Conn, proto = Proto} = Data, ReadSpecs, Opts) ->
case opcua_client_uacp:read(ReadSpecs, Opts, Conn, Proto) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Proto2} ->
% {error, Reason, Data#data{proto = Proto2}};
{async, Handle, Conn2, Proto2} ->
{async, Handle, Data#data{conn = Conn2, proto = Proto2}}
end.
proto_write(#data{conn = Conn, proto = Proto} = Data, WriteSpec, Opts) ->
case opcua_client_uacp:write(WriteSpec, Opts, Conn, Proto) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Proto2} ->
% {error, Reason, Data#data{proto = Proto2}};
{async, Handle, Conn2, Proto2} ->
{async, Handle, Data#data{conn = Conn2, proto = Proto2}}
end.
proto_close(#data{conn = Conn, proto = Proto} = Data) ->
case opcua_client_uacp:close(Conn, Proto) of
% No error use-case yet, diabling to make dialyzer happy
% {error, Reason, Proto2} ->
% {error, Reason, Data#data{proto = Proto2}};
{ok, Conn2, Proto2} ->
{ok, Data#data{conn = Conn2, proto = Proto2}}
end.
proto_terminate(#data{conn = Conn, proto = Proto}, Reason) ->
opcua_client_uacp:terminate(Reason, Conn, Proto).
%== Connection Managment =======================================================
conn_init(#data{opts = CliOpts, socket = undefined} = Data, ProtoMode, EndpointUrl) ->
#{keychain := ParentKeychain,
identity := Identity,
connect_timeout := Timeout
} = CliOpts,
{SecurityMode, SecurityPolicy} = case ProtoMode of
lookup_endpoint ->
#{mode := M,
policy := P} = maps:get(endpoint_lookup_security, CliOpts),
{M,P};
open_session ->
M = maps:get(mode, CliOpts, none),
P = maps:get(policy, CliOpts, none),
{M,P}
end,
#opcua_endpoint_url{host = Host, port = Port, url = Url} = EndpointUrl,
?LOG_DEBUG("Connecting to ~s", [Url]),
Opts = [binary, {active, false}, {packet, raw}],
case gen_tcp:connect(Host, Port, Opts, Timeout) of
{error, _Reason} = Error -> Error;
{ok, Socket} ->
PeerNameRes = inet:peername(Socket),
SockNameRes = inet:sockname(Socket),
case {PeerNameRes, SockNameRes} of
{{error, _Reason} = Error, _} -> Error;
{_, {error, _Reason} = Error} -> Error;
{{ok, PeerName}, {ok, SockName}} ->
{ok, Keychain} = opcua_keychain_ets:new(ParentKeychain),
ClientSpace = opcua_space_backend:new([opcua_nodeset]),
Conn = opcua_connection:set_security_policy(
opcua_connection:set_security_mode(
opcua_connection:new(ClientSpace, Keychain,
Identity, EndpointUrl,
PeerName, SockName),
SecurityMode),
SecurityPolicy),
Data2 = Data#data{socket = Socket, conn = Conn},
case conn_lock_peer(Data2) of
{error, _Reason} = Error -> Error;
{ok, Data3} ->
case conn_activate(Data3) of
{error, _Reason} = Error -> Error;
ok -> {ok, Data3}
end
end
end
end.
conn_lock_peer(#data{opts = #{server_identity := undefined}} = Data) ->
{ok, Data};
conn_lock_peer(#data{opts = #{server_identity := Ident}, conn = Conn} = Data) ->
% We don't have a server certificate, but an expected server identity,
% the certificate MUST already be in the keychain.
case opcua_connection:lock_peer(Conn, Ident) of
{error, _Reason} = Error -> Error;
{ok, Conn2} -> {ok, Data#data{conn = Conn2}}
end.
conn_activate(#data{socket = Socket}) ->
inet:setopts(Socket, [{active, 5}]).
conn_send(#data{socket = Socket}, Packet) ->
?DUMP("Sending Data: ~p", [Packet]),
gen_tcp:send(Socket, Packet).
conn_close(#data{socket = undefined}) -> ok;
conn_close(#data{socket = Socket} = Data) ->
?LOG_DEBUG("Closing connection"),
gen_tcp:close(Socket),
Data#data{socket = undefined}.
%== Timeouts ===================================================================
enter_timeouts({connecting, 0, _, _} = State, Data) ->
[{state_timeout, 0, retry} | event_timeouts(State, Data)];
enter_timeouts({connecting, 1, _, _} = State, Data) ->
[{state_timeout, 500, retry} | event_timeouts(State, Data)];
enter_timeouts({connecting, 2, _, _} = State, Data) ->
[{state_timeout, 1000, retry} | event_timeouts(State, Data)];
enter_timeouts({connecting, 3, _, _} = State, Data) ->
[{state_timeout, 3000, retry} | event_timeouts(State, Data)];
enter_timeouts({connecting, _, _, _} = State, Data) ->
[{state_timeout, 10000, retry} | event_timeouts(State, Data)];
enter_timeouts(handshaking = State, Data) ->
[{state_timeout, 3000, abort} | event_timeouts(State, Data)];
enter_timeouts({reconnecting, _} = State, Data) ->
[{state_timeout, 3000, abort} | event_timeouts(State, Data)];
enter_timeouts(closing = State, Data) ->
[{state_timeout, 4000, abort} | event_timeouts(State, Data)];
enter_timeouts(State, Data) ->
event_timeouts(State, Data).
event_timeouts({reconnecting, _}, Data) ->
event_timeouts(reconnecting, Data);
event_timeouts(State, Data)
when State =:= handshaking; State =:= connected;
State =:= reconnecting; State =:= closing ->
#data{conn = Conn, proto = Proto} = Data,
case opcua_client_uacp:can_produce(Conn, Proto) of
true -> [{timeout, 0, produce}];
false -> []
end;
event_timeouts(_State, _Data) ->
[].