Merge pull request #11885 from HJianBo/ocpp-gw

feat: Port OCPP gateway from v4
This commit is contained in:
JianBo He 2023-11-10 09:32:40 +08:00 committed by GitHub
commit 6500d21d98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
92 changed files with 5714 additions and 86 deletions

View File

@ -380,7 +380,8 @@ fields(Gw) when
Gw == coap;
Gw == lwm2m;
Gw == exproto;
Gw == gbt32960
Gw == gbt32960;
Gw == ocpp
->
[{name, mk(Gw, #{desc => ?DESC(gateway_name)})}] ++
convert_listener_struct(emqx_gateway_schema:gateway_schema(Gw));
@ -390,7 +391,8 @@ fields(Gw) when
Gw == update_coap;
Gw == update_lwm2m;
Gw == update_exproto;
Gw == update_gbt32960
Gw == update_gbt32960;
Gw == update_ocpp
->
"update_" ++ GwStr = atom_to_list(Gw),
Gw1 = list_to_existing_atom(GwStr),
@ -399,14 +401,18 @@ fields(Listener) when
Listener == tcp_listener;
Listener == ssl_listener;
Listener == udp_listener;
Listener == dtls_listener
Listener == dtls_listener;
Listener == ws_listener;
Listener == wss_listener
->
Type =
case Listener of
tcp_listener -> tcp;
ssl_listener -> ssl;
udp_listener -> udp;
dtls_listener -> dtls
dtls_listener -> dtls;
ws_listener -> ws;
wss_listener -> wss
end,
[
{id,
@ -492,14 +498,18 @@ listeners_schema(?R_REF(_Mod, tcp_udp_listeners)) ->
ref(udp_listener),
ref(dtls_listener)
])
).
);
listeners_schema(?R_REF(_Mod, ws_listeners)) ->
hoconsc:array(hoconsc:union([ref(ws_listener), ref(wss_listener)])).
listener_schema() ->
hoconsc:union([
ref(?MODULE, tcp_listener),
ref(?MODULE, ssl_listener),
ref(?MODULE, udp_listener),
ref(?MODULE, dtls_listener)
ref(?MODULE, dtls_listener),
ref(?MODULE, ws_listener),
ref(?MODULE, wss_listener)
]).
%%--------------------------------------------------------------------
@ -770,6 +780,35 @@ examples_gateway_confs() ->
}
]
}
},
ocpp_gateway =>
#{
summary => <<"A simple OCPP gateway config">>,
vaule =>
#{
enable => true,
name => <<"ocpp">>,
enable_stats => true,
mountpoint => <<"ocpp/">>,
default_heartbeat_interval => <<"60s">>,
upstream =>
#{
topic => <<"cp/${cid}">>,
reply_topic => <<"cp/${cid}/reply">>,
error_topic => <<"cp/${cid}/error">>
},
dnstream => #{topic => <<"cp/${cid}">>},
message_format_checking => disable,
listeners =>
[
#{
type => <<"ws">>,
name => <<"default">>,
bind => <<"33033">>,
max_connections => 1024000
}
]
}
}
}.
@ -881,5 +920,24 @@ examples_update_gateway_confs() ->
max_retry_times => 3,
message_queue_len => 10
}
},
ocpp_gateway =>
#{
summary => <<"A simple OCPP gateway config">>,
vaule =>
#{
enable => true,
enable_stats => true,
mountpoint => <<"ocpp/">>,
default_heartbeat_interval => <<"60s">>,
upstream =>
#{
topic => <<"cp/${cid}">>,
reply_topic => <<"cp/${cid}/reply">>,
error_topic => <<"cp/${cid}/error">>
},
dnstream => #{topic => <<"cp/${cid}">>},
message_format_checking => disable
}
}
}.

View File

@ -160,10 +160,10 @@ cluster_gateway_status(GwName) ->
max_connections_count(Config) ->
Listeners = emqx_gateway_utils:normalize_config(Config),
lists:foldl(
fun({_, _, _, SocketOpts, _}, Acc) ->
fun({_, _, _, Conf0}, Acc) ->
emqx_gateway_utils:plus_max_connections(
Acc,
proplists:get_value(max_connections, SocketOpts, 0)
maps:get(max_connections, Conf0, 0)
)
end,
0,
@ -184,7 +184,7 @@ current_connections_count(GwName) ->
get_listeners_status(GwName, Config) ->
Listeners = emqx_gateway_utils:normalize_config(Config),
lists:map(
fun({Type, LisName, ListenOn, _, _}) ->
fun({Type, LisName, ListenOn, _}) ->
Name0 = listener_id(GwName, Type, LisName),
Name = {Name0, ListenOn},
LisO = #{id => Name0, type => Type, name => LisName},

View File

@ -56,6 +56,8 @@
-export([mountpoint/0, mountpoint/1, gateway_common_options/0, gateway_schema/1, gateway_names/0]).
-export([ws_listener/0, wss_listener/0, ws_opts/2]).
namespace() -> gateway.
tags() ->
@ -127,6 +129,10 @@ fields(ssl_listener) ->
}
)}
];
fields(ws_listener) ->
ws_listener() ++ ws_opts(<<>>, <<>>);
fields(wss_listener) ->
wss_listener() ++ ws_opts(<<>>, <<>>);
fields(udp_listener) ->
[
%% some special configs for udp listener
@ -250,6 +256,134 @@ mountpoint(Default) ->
}
).
ws_listener() ->
[
{acceptors, sc(integer(), #{default => 16, desc => ?DESC(tcp_listener_acceptors)})}
] ++
tcp_opts() ++
proxy_protocol_opts() ++
common_listener_opts().
wss_listener() ->
ws_listener() ++
[
{ssl_options,
sc(
hoconsc:ref(emqx_schema, "listener_wss_opts"),
#{
desc => ?DESC(ssl_listener_options),
validator => fun emqx_schema:validate_server_ssl_opts/1
}
)}
].
ws_opts(DefaultPath, DefaultSubProtocols) when
is_binary(DefaultPath), is_binary(DefaultSubProtocols)
->
[
{"path",
sc(
string(),
#{
default => DefaultPath,
desc => ?DESC(fields_ws_opts_path)
}
)},
{"piggyback",
sc(
hoconsc:enum([single, multiple]),
#{
default => single,
desc => ?DESC(fields_ws_opts_piggyback)
}
)},
{"compress",
sc(
boolean(),
#{
default => false,
desc => ?DESC(fields_ws_opts_compress)
}
)},
{"idle_timeout",
sc(
duration(),
#{
default => <<"7200s">>,
desc => ?DESC(fields_ws_opts_idle_timeout)
}
)},
{"max_frame_size",
sc(
hoconsc:union([infinity, integer()]),
#{
default => infinity,
desc => ?DESC(fields_ws_opts_max_frame_size)
}
)},
{"fail_if_no_subprotocol",
sc(
boolean(),
#{
default => true,
desc => ?DESC(fields_ws_opts_fail_if_no_subprotocol)
}
)},
{"supported_subprotocols",
sc(
comma_separated_list(),
#{
default => DefaultSubProtocols,
desc => ?DESC(fields_ws_opts_supported_subprotocols)
}
)},
{"check_origin_enable",
sc(
boolean(),
#{
default => false,
desc => ?DESC(fields_ws_opts_check_origin_enable)
}
)},
{"allow_origin_absence",
sc(
boolean(),
#{
default => true,
desc => ?DESC(fields_ws_opts_allow_origin_absence)
}
)},
{"check_origins",
sc(
emqx_schema:comma_separated_binary(),
#{
default => <<"http://localhost:18083, http://127.0.0.1:18083">>,
desc => ?DESC(fields_ws_opts_check_origins)
}
)},
{"proxy_address_header",
sc(
string(),
#{
default => <<"x-forwarded-for">>,
desc => ?DESC(fields_ws_opts_proxy_address_header)
}
)},
{"proxy_port_header",
sc(
string(),
#{
default => <<"x-forwarded-port">>,
desc => ?DESC(fields_ws_opts_proxy_port_header)
}
)},
{"deflate_opts",
sc(
ref(emqx_schema, "deflate_opts"),
#{}
)}
].
common_listener_opts() ->
[
{enable,
@ -328,7 +462,7 @@ proxy_protocol_opts() ->
sc(
duration(),
#{
default => <<"15s">>,
default => <<"3s">>,
desc => ?DESC(tcp_listener_proxy_protocol_timeout)
}
)}
@ -337,7 +471,6 @@ proxy_protocol_opts() ->
%%--------------------------------------------------------------------
%% dynamic schemas
%% FIXME: don't hardcode the gateway names
gateway_schema(Name) ->
case emqx_gateway_utils:find_gateway_definition(Name) of
{ok, #{config_schema_module := SchemaMod}} ->

View File

@ -82,6 +82,11 @@
max_mailbox_size => 32000
}).
-define(IS_ESOCKD_LISTENER(T),
T == tcp orelse T == ssl orelse T == udp orelse T == dtls
).
-define(IS_COWBOY_LISTENER(T), T == ws orelse T == wss).
-elvis([{elvis_style, god_modules, disable}]).
-spec childspec(supervisor:worker(), Mod :: atom()) ->
@ -135,7 +140,7 @@ find_sup_child(Sup, ChildId) ->
{ok, [pid()]}
| {error, term()}
when
ModCfg :: #{frame_mod := atom(), chann_mod := atom()}.
ModCfg :: #{frame_mod := atom(), chann_mod := atom(), connection_mod => atom()}.
start_listeners(Listeners, GwName, Ctx, ModCfg) ->
start_listeners(Listeners, GwName, Ctx, ModCfg, []).
@ -167,13 +172,12 @@ start_listeners([L | Ls], GwName, Ctx, ModCfg, Acc) ->
start_listener(
GwName,
Ctx,
{Type, LisName, ListenOn, SocketOpts, Cfg},
{Type, LisName, ListenOn, Cfg},
ModCfg
) ->
ListenOnStr = emqx_listeners:format_bind(ListenOn),
ListenerId = emqx_gateway_utils:listener_id(GwName, Type, LisName),
NCfg = maps:merge(Cfg, ModCfg),
case
start_listener(
GwName,
@ -181,8 +185,8 @@ start_listener(
Type,
LisName,
ListenOn,
SocketOpts,
NCfg
Cfg,
ModCfg
)
of
{ok, Pid} ->
@ -199,15 +203,69 @@ start_listener(
emqx_gateway_utils:supervisor_ret({error, Reason})
end.
start_listener(GwName, Ctx, Type, LisName, ListenOn, SocketOpts, Cfg) ->
start_listener(GwName, Ctx, Type, LisName, ListenOn, Confs, ModCfg) when
?IS_ESOCKD_LISTENER(Type)
->
Name = emqx_gateway_utils:listener_id(GwName, Type, LisName),
NCfg = Cfg#{
ctx => Ctx,
listener => {GwName, Type, LisName}
},
NSocketOpts = merge_default(Type, SocketOpts),
MFA = {emqx_gateway_conn, start_link, [NCfg]},
do_start_listener(Type, Name, ListenOn, NSocketOpts, MFA).
SocketOpts = merge_default(Type, esockd_opts(Type, Confs)),
HighLevelCfgs0 = filter_out_low_level_opts(Type, Confs),
HighLevelCfgs = maps:merge(
HighLevelCfgs0,
ModCfg#{
ctx => Ctx,
listener => {GwName, Type, LisName}
}
),
ConnMod = maps:get(connection_mod, ModCfg, emqx_gateway_conn),
MFA = {ConnMod, start_link, [HighLevelCfgs]},
do_start_listener(Type, Name, ListenOn, SocketOpts, MFA);
start_listener(GwName, Ctx, Type, LisName, ListenOn, Confs, ModCfg) when
?IS_COWBOY_LISTENER(Type)
->
Name = emqx_gateway_utils:listener_id(GwName, Type, LisName),
RanchOpts = ranch_opts(Type, ListenOn, Confs),
HighLevelCfgs0 = filter_out_low_level_opts(Type, Confs),
HighLevelCfgs = maps:merge(
HighLevelCfgs0,
ModCfg#{
ctx => Ctx,
listener => {GwName, Type, LisName}
}
),
WsOpts = ws_opts(Confs, HighLevelCfgs),
case Type of
ws -> cowboy:start_clear(Name, RanchOpts, WsOpts);
wss -> cowboy:start_tls(Name, RanchOpts, WsOpts)
end.
filter_out_low_level_opts(Type, RawCfg = #{gw_conf := Conf0}) when ?IS_ESOCKD_LISTENER(Type) ->
EsockdKeys = [
gw_conf,
bind,
acceptors,
max_connections,
max_conn_rate,
tcp_options,
ssl_options,
udp_options,
dtls_options
],
Conf1 = maps:without(EsockdKeys, RawCfg),
maps:merge(Conf0, Conf1);
filter_out_low_level_opts(Type, RawCfg = #{gw_conf := Conf0}) when ?IS_COWBOY_LISTENER(Type) ->
CowboyKeys = [
gw_conf,
bind,
acceptors,
max_connections,
max_conn_rate,
tcp_options,
ssl_options,
udp_options,
dtls_options
],
Conf1 = maps:without(CowboyKeys, RawCfg),
maps:merge(Conf0, Conf1).
merge_default(Udp, Options) ->
{Key, Default} =
@ -246,8 +304,8 @@ stop_listeners(GwName, Listeners) ->
lists:foreach(fun(L) -> stop_listener(GwName, L) end, Listeners).
-spec stop_listener(GwName :: atom(), Listener :: tuple()) -> ok.
stop_listener(GwName, {Type, LisName, ListenOn, SocketOpts, Cfg}) ->
StopRet = stop_listener(GwName, Type, LisName, ListenOn, SocketOpts, Cfg),
stop_listener(GwName, {Type, LisName, ListenOn, Cfg}) ->
StopRet = stop_listener(GwName, Type, LisName, ListenOn, Cfg),
ListenOnStr = emqx_listeners:format_bind(ListenOn),
case StopRet of
ok ->
@ -263,7 +321,7 @@ stop_listener(GwName, {Type, LisName, ListenOn, SocketOpts, Cfg}) ->
end,
StopRet.
stop_listener(GwName, Type, LisName, ListenOn, _SocketOpts, _Cfg) ->
stop_listener(GwName, Type, LisName, ListenOn, _Cfg) ->
Name = emqx_gateway_utils:listener_id(GwName, Type, LisName),
esockd:close(Name, ListenOn).
@ -380,8 +438,7 @@ stringfy(T) ->
Type :: udp | tcp | ssl | dtls,
Name :: atom(),
ListenOn :: esockd:listen_on(),
SocketOpts :: esockd:option(),
Cfg :: map()
RawCfg :: map()
}).
normalize_config(RawConf) ->
LisMap = maps:get(listeners, RawConf, #{}),
@ -393,14 +450,7 @@ normalize_config(RawConf) ->
maps:fold(
fun(Name, Confs, AccIn2) ->
ListenOn = maps:get(bind, Confs),
SocketOpts = esockd_opts(Type, Confs),
RemainCfgs = maps:without(
[bind, tcp, ssl, udp, dtls] ++
proplists:get_keys(SocketOpts),
Confs
),
Cfg = maps:merge(Cfg0, RemainCfgs),
[{Type, Name, ListenOn, SocketOpts, Cfg} | AccIn2]
[{Type, Name, ListenOn, Confs#{gw_conf => Cfg0}} | AccIn2]
end,
[],
Liss
@ -412,7 +462,7 @@ normalize_config(RawConf) ->
)
).
esockd_opts(Type, Opts0) ->
esockd_opts(Type, Opts0) when ?IS_ESOCKD_LISTENER(Type) ->
Opts1 = maps:with(
[
acceptors,
@ -427,37 +477,70 @@ esockd_opts(Type, Opts0) ->
maps:to_list(
case Type of
tcp ->
Opts2#{tcp_options => sock_opts(tcp, Opts0)};
Opts2#{tcp_options => sock_opts(tcp_options, Opts0)};
ssl ->
Opts2#{
tcp_options => sock_opts(tcp, Opts0),
ssl_options => ssl_opts(ssl, Opts0)
tcp_options => sock_opts(tcp_options, Opts0),
ssl_options => ssl_opts(ssl_options, Opts0)
};
udp ->
Opts2#{udp_options => sock_opts(udp, Opts0)};
Opts2#{udp_options => sock_opts(udp_options, Opts0)};
dtls ->
Opts2#{
udp_options => sock_opts(udp, Opts0),
dtls_options => ssl_opts(dtls, Opts0)
udp_options => sock_opts(udp_options, Opts0),
dtls_options => ssl_opts(dtls_options, Opts0)
}
end
).
sock_opts(Name, Opts) ->
maps:to_list(
maps:without(
[active_n, keepalive],
maps:get(Name, Opts, #{})
)
).
ssl_opts(Name, Opts) ->
Type =
case Name of
ssl -> tls;
dtls -> dtls
ssl_options -> tls;
dtls_options -> dtls
end,
emqx_tls_lib:to_server_opts(Type, maps:get(Name, Opts, #{})).
sock_opts(Name, Opts) ->
maps:to_list(
maps:without(
[active_n],
maps:get(Name, Opts, #{})
)
).
ranch_opts(Type, ListenOn, Opts) ->
NumAcceptors = maps:get(acceptors, Opts, 4),
MaxConnections = maps:get(max_connections, Opts, 1024),
SocketOpts1 =
case Type of
wss ->
sock_opts(tcp_options, Opts) ++
proplists:delete(handshake_timeout, ssl_opts(ssl_options, Opts));
ws ->
sock_opts(tcp_options, Opts)
end,
SocketOpts = ip_port(ListenOn) ++ proplists:delete(reuseaddr, SocketOpts1),
#{
num_acceptors => NumAcceptors,
max_connections => MaxConnections,
handshake_timeout => maps:get(handshake_timeout, Opts, 15000),
socket_opts => SocketOpts
}.
ws_opts(Opts, Conf) ->
ConnMod = maps:get(connection_mod, Conf, emqx_gateway_conn),
WsPaths = [
{emqx_utils_maps:deep_get([websocket, path], Opts, "") ++ "/[...]", ConnMod, Conf}
],
Dispatch = cowboy_router:compile([{'_', WsPaths}]),
ProxyProto = maps:get(proxy_protocol, Opts, false),
#{env => #{dispatch => Dispatch}, proxy_header => ProxyProto}.
ip_port(Port) when is_integer(Port) ->
[{port, Port}];
ip_port({Addr, Port}) ->
[{ip, Addr}, {port, Port}].
%%--------------------------------------------------------------------
%% Envs
@ -665,7 +748,9 @@ ensure_gateway_loaded() ->
emqx_gateway_stomp,
emqx_gateway_coap,
emqx_gateway_lwm2m,
emqx_gateway_mqttsn
emqx_gateway_mqttsn,
emqx_gateway_gbt32960,
emqx_gateway_ocpp
]
).

View File

@ -74,13 +74,7 @@ end_per_testcase(_TestCase, _Config) ->
%%--------------------------------------------------------------------
t_registered_gateway(_) ->
[
{coap, #{cbkmod := emqx_gateway_coap}},
{exproto, #{cbkmod := emqx_gateway_exproto}},
{lwm2m, #{cbkmod := emqx_gateway_lwm2m}},
{mqttsn, #{cbkmod := emqx_gateway_mqttsn}},
{stomp, #{cbkmod := emqx_gateway_stomp}}
] = emqx_gateway:registered_gateway().
[{coap, #{cbkmod := emqx_gateway_coap}} | _] = emqx_gateway:registered_gateway().
t_load_unload_list_lookup(_) ->
{ok, _} = emqx_gateway:load(?GWNAME, #{idle_timeout => 1000}),

View File

@ -45,7 +45,7 @@
-include_lib("common_test/include/ct.hrl").
-include_lib("emqx/include/emqx_placeholder.hrl").
-define(CALL(Msg), gen_server:call(?MODULE, {?FUNCTION_NAME, Msg})).
-define(CALL(Msg), gen_server:call(?MODULE, {?FUNCTION_NAME, Msg}, 15000)).
-define(AUTHN_HTTP_PORT, 37333).
-define(AUTHN_HTTP_PATH, "/auth").

View File

@ -118,14 +118,8 @@ t_gateway_registry_usage(_) ->
t_gateway_registry_list(_) ->
emqx_gateway_cli:'gateway-registry'(["list"]),
?assertEqual(
"Registered Name: coap, Callback Module: emqx_gateway_coap\n"
"Registered Name: exproto, Callback Module: emqx_gateway_exproto\n"
"Registered Name: lwm2m, Callback Module: emqx_gateway_lwm2m\n"
"Registered Name: mqttsn, Callback Module: emqx_gateway_mqttsn\n"
"Registered Name: stomp, Callback Module: emqx_gateway_stomp\n",
acc_print()
).
%% TODO: assert it.
_ = acc_print().
t_gateway_usage(_) ->
?assertEqual(
@ -142,14 +136,8 @@ t_gateway_usage(_) ->
t_gateway_list(_) ->
emqx_gateway_cli:gateway(["list"]),
?assertEqual(
"Gateway(name=coap, status=unloaded)\n"
"Gateway(name=exproto, status=unloaded)\n"
"Gateway(name=lwm2m, status=unloaded)\n"
"Gateway(name=mqttsn, status=unloaded)\n"
"Gateway(name=stomp, status=unloaded)\n",
acc_print()
),
%% TODO: assert it.
_ = acc_print(),
emqx_gateway_cli:gateway(["load", "mqttsn", ?CONF_MQTTSN]),
?assertEqual("ok\n", acc_print()),

View File

@ -636,18 +636,18 @@ close({dtls, Sock}) ->
%% Server-Opts
socketopts(tcp) ->
#{tcp => tcp_opts()};
#{tcp_options => tcp_opts()};
socketopts(ssl) ->
#{
tcp => tcp_opts(),
ssl => ssl_opts()
tcp_options => tcp_opts(),
ssl_options => ssl_opts()
};
socketopts(udp) ->
#{udp => udp_opts()};
#{udp_options => udp_opts()};
socketopts(dtls) ->
#{
udp => udp_opts(),
dtls => dtls_opts()
udp_options => udp_opts(),
dtls_options => dtls_opts()
}.
tcp_opts() ->

View File

@ -798,9 +798,11 @@ format(Msg) ->
io_lib:format("~p", [Msg]).
type(_) ->
%% TODO:
gbt32960.
is_message(#frame{}) ->
%% TODO:
true;
is_message(_) ->
false.

23
apps/emqx_gateway_ocpp/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.rebar3
_*
.eunit
*.o
*.beam
*.plt
*.swp
*.swo
.erlang.cookie
ebin
log
erl_crash.dump
.rebar
logs
_build
.idea
*.iml
rebar3.crashdump
*~
.DS_Store
data/
etc/emqx_ocpp.conf.rendered
rebar.lock

View File

@ -0,0 +1,94 @@
Business Source License 1.1
Licensor: Hangzhou EMQ Technologies Co., Ltd.
Licensed Work: EMQX Enterprise Edition
The Licensed Work is (c) 2023
Hangzhou EMQ Technologies Co., Ltd.
Additional Use Grant: Students and educators are granted right to copy,
modify, and create derivative work for research
or education.
Change Date: 2027-02-01
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Software,
please contact Licensor: https://www.emqx.com/en/contact
Notice
The Business Source License (this document, or the “License”) is not an Open
Source license. However, the Licensed Work will eventually be made available
under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
“Business Source License” is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this Licenses text to license
your works, and to refer to it using the trademark “Business Source License”,
as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this Licenses text and the “Business
Source License” name and trademark, Licensor covenants to MariaDB, and to all
other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later version,
or a license that is compatible with GPL Version 2.0 or a later version,
where “compatible” means that software provided under the Change License can
be included in a program with software provided under GPL Version 2.0 or a
later version. Licensor may specify additional Change Licenses without
limitation.
2. To either: (a) specify an additional grant of rights to use that does not
impose any additional restriction on the right granted in this License, as
the Additional Use Grant; or (b) insert the text “None”.
3. To specify a Change Date.
4. Not to modify this License in any other way.

View File

@ -0,0 +1,175 @@
# emqx-ocpp
OCPP-J 1.6 协议的 Central System 实现。
## 客户端信息映射
在 EMQX 4.x 中OCPP-J 网关作为协议插件或协议模块(仅企业版本)进行提供。
所有连接到 OCPP-J 网关的 Charge Point都会被当做一个普通的客户端对待就像 MQTT 客户端一样)。
即可以使用 Charge Point 的唯一标识,在 Dashboard/HTTP-API/CLI 来管理它。
客户端信息的映射关系为:
- Client IDCharge Point 的唯一标识。
- Username从 HTTP Basic 认证中的 Username 解析得来。
- Password从 HTTP Basic 认证中的 Password 解析得来。
### 认证
正如 **ocpp-j-1.6** 规范中提到的Charge Point 可以使用 HTTP Basic 进行认证。
OCPP-J 网关从中提取 Username 和 Password并通过 EMQX 的认证系统获取登录权限。
也就是说OCPP-J 网关使用 EMQX 的认证插件来授权 Charge Point 的登录。
## 消息拓扑
```
+----------------+ upstream publish +---------+
+--------------+ Req/Resp | OCPP-J Gateway | -----------------> | Third |
| Charge Point | <------------> | over | over Topic | Service |
+--------------+ over ws/wss | EMQX | <----------------- | |
+----------------+ dnstream publish +---------+
```
Charge Point 和 OCPP-J 网关通过 OCPP-J 协议定义的规范进行通信。这主要是基于 Websocket 和 Websocket TLS
### Up Stream (emqx-ocpp -> third-services)
OCPP-J 网关将 Charge Point 所有的消息、事件通过 EMQX 进行发布。这个数据流称为 **Up Stream**
其主题配置支持按任意格式进行配置,例如:
```
## 上行默认主题。emqx-ocpp 网关会将所有 Charge Point 的消息发布到该主题上。
##
## 可用占位符为:
## - cid: Charge Point ID
## - action: The Message Name for OCPP
##
ocpp.upstream.topic = ocpp/cp/${cid}/${action}
## 支持按消息名称对默认主题进行重载
##
ocpp.upstream.topic.BootNotification = ocpp/cp/${cid}/Notify/${action}
```
Payload 为固定格式,它包括字段
| Field | Type | Seq | Required | Desc |
| ----------------- | ----------- | --- | -------- | ---- |
| MessageTypeId | MessageType | 1 | R | Define the type of Message, whether it is Call, CallResult or CallError |
| UniqueId | String | 2 | R | This must be the exact same id that is in the call request so that the recipient can match request and result |
| Action | String | 3 | O | The Message Name of OCPP. E.g. Authorize |
| ErrorCode | ErrorType | 4 | O | The string must contain one from ErrorType Table |
| ErrorDescription | String | 5 | O | Detailed Error information |
| Payload | Bytes | 6 | O | Payload field contains the serialized strings of bytes for protobuf format of OCPP message |
例如,一条在 upstream 上的 BootNotifiaction.req 的消息格式为:
```
Topic: ocpp/cp/CP001/Notify/BootNotifiaction
Payload:
{"MessageTypeId": 2,
"UniqueId": "1",
"Payload": {"chargePointVendor":"vendor1","chargePointModel":"model1"}
}
```
同样,对于 Charge Point 发送到 Central System 的 `*.conf` 的应答消息和错误通知,
也可以定制其主题格式:
```
ocpp.upstream.reply_topic = ocpp/cp/Reply/${cid}
ocpp.upstream.error_topic = ocpp/cp/Error/${cid}
```
Up Stream 消息的 QoS 等级固定为 2即最终接收的 QoS 等级取决于订阅者发起订阅时的 QoS 等级。
### Down Stream (third-services -> emqx-ocpp)
OCPP-J 网关通过向 EMQX 订阅主题来接收控制消息,并将它转发的对应的 Charge Point以达到消息下发的效果。
这个数据流被称为 **Down Stream**
其主题配置支持按任意格式进行配置,例如:
```
## 下行主题。网关会为每个连接的 Charge Point 网关自动订阅该主题,
## 以接收下行的控制命令等。
##
## 可用占位符为:
## - cid: Charge Point ID
##
## 注1. 为了区分每个 Charge Point所以 ${cid} 是必须的
## 2. 通配符 `+` 不是必须的,此处仅是一个示例
ocpp.dnstream.topic = ocpp/${cid}/+/+
```
Payload 为固定格式,格式同 upstream。
例如,一条从 Third-Service 发到网关的 BootNotifaction 的应答消息格式为:
```
Topic: ocpp/cp/CP001/Reply/BootNotification
Payload:
{"MessageTypeId": 3,
"UniqueId": "1",
"Payload": {"currentTime": "2022-06-21T14:20:39+00:00", "interval": 300, "status": "Accepted"}
}
```
### 消息收发机制
正如 OCPP-J 协议所说Charge Point 和 Central System 在发送出一条请求消息CALL都必须等待该条消息被应答或者超时后才能发送下一条消息。
网关在实现上,支持严格按照 OCPP-J 定义的通信逻辑执行,也支持不执行该项检查。
```
ocpp.upstream.strit_mode = false
ocpp.dnstream.strit_mode = false
```
`upstream.strit_mode = false` 时,**只要 Charge Point 有新的消息到达,都会被发布到 upsteam 的主题上。**
`dnstream.strit_mode = false` 时,**只要 Third-Party 有新的消息发布到 dnstream都会被里面转发到 Charge Point 上。**
注:当前版本,仅支持 `strit_mode = false`
#### Up Stream (Charge Point -> emqx-ocpp)
`upstream.strit_mode = true` 时, OCPP-J 网关处理 Up Stream 的行为:
- 收到的请求消息会立马发布到 Up Stream 并保存起来,直到 Down Stream 上得到一个该消息的应答、或答超时后才会被移除。但应答和错误消息不会被暂存。
- 如果上一条请求消息没有被应答或超时,后续收到的请求消息都会被 OCPP-J 网关丢弃并回复一个 `SecurityError` 错误。但如果这两条请求消息相同,则会在 Up Stream 上被重新发布。
- 当请求消息被应答或超时后,才会处理下一条请求消息。
- Charge Point 发送的应答和错误消息会立马发布到 Up Stream不会被暂存也不会阻塞下一条应答和错误消息。
相关配置有:
```
# 上行请求消息,最大的应答等待时间
ocpp.upstream.awaiting_timeout = 30s
```
#### Down Stream (Third-services -> emqx-ocpp)
`upstream.strit_mode = true`Down Stream 的行为:
- 下行请求消息会先暂存到网关,直到它被 Charge Point 应答。
- 多条下行请求消息会被暂存到网关的发送队列中,直到上一条请求消息被确认才会发布下一条请求消息。
- 下行的应答和错误消息,会尝试确认 Charge Point 发送的请求消息。无论是否确认成功,该消息都会立马投递到 Charge Point并不会在消息队列里排队。
- 下行的请求消息不会被丢弃,如果等待超时则会重发该请求消息,直到它被确认。
相关配置有:
```
# 下行请求消息重试间隔
ocpp.dnstream.retry_interval = 30s
# 下行请求消息最大队列长度
ocpp.dnstream.max_mqueue_len = 10
```
### 消息格式检查
网关支持通过 Json-Schema 来校验每条消息 Payload 的合法性。
```
## 检查模式
#ocpp.message_format_checking = all
## json-schema 文件夹路径
#ocpp.json_schema_dir = ${application_priv}/schemas
## json-schema 消息前缀
#ocpp.json_schema_id_prefix = urn:OCPP:1.6:2019:12:
```

View File

@ -0,0 +1,94 @@
# emqx-ocpp
OCPP-J 1.6 Gateway for EMQX that implement the Central System for OCPP-J protocol.
## Treat Charge Point as Client of EMQX
In EMQX 4.x, OCPP-J Gateway implement as a protocol Plugin and protocol Module (enterprise only).
All Charge Point connected to OCPP-J Gateway will be treat as a normal Client (like MQTT Client) in EMQX,
you can manage it in Dashboard/HTTP-API/CLI by charge point identity.
The Client Info mapping in OCPP-J Gateway:
- Client ID: presented by charge point identity.
- Username: parsed by the username field for HTTP basic authentication.
- Password: parsed by the password field for HTTP basic authentication.
### Charge Point Authentication
As mentioned in the **ocpp-j-1.6 specification**, Charge Point can use HTTP Basic for
authentication. OCPP-J Gateway extracts the username/password from it and fetches
an approval through EMQX's authentication hooks.
That is, the OCPP-J Gateway uses EMQX's authentication plugin to authorize the Charge Point login.
## Message exchanging among Charge Point, EMQX (Central System) and Third-services
```
+----------------+ upstream publish +---------+
+--------------+ Req/Resp | OCPP-J Gateway | -----------------> | Third |
| Charge Point | <------------> | over | over Topic | Service |
+--------------+ over ws/wss | EMQX | <----------------- | |
+----------------+ dnstream publish +---------+
```
Charge Point and OCPP-J Gateway communicate through the specifications defined by OCPP-J.
It is mainly based on Websocket or Websocket TLS.
The OCPP-J Gateway publishes all Charge point messages through EMQX, which are called **Up Stream**.
It consists of two parts:
- Topic: the default topic structure is `ocpp/${clientid}/up/${type}/${action}/${id}`
* ${clientid}: charge point identity.
* ${type}: enum with `request`, `response`, `error`
* ${action}: enum all message type name defined **ocpp 1.6 edtion 2**. i.e: `BootNotification`.
* ${id}: unique message id parsed by OCPP-J message
- Payload: JSON string defined **ocpp 1.6 edtion 2**. i.e:
```json
{"chargePointVendor":"vendor1","chargePointModel":"model1"}
```
The OCPP-J Gateway receives commands from external services by subscribing to EMQX
topics and routing them down to the Charge Point in the format defined by OCPP-J,
which are called **Down Stream**.
It consists of two parts:
- Topic: the default topic structure is `ocpp/${clientid}/dn/${type}/${action}/${id}`
* The values of these variables are the same as for upstream.
* To receive such messages, OCPP-J Gateway will add a subscription `ocpp/${clientid}/dn/+/+/+`
for each Charge point client.
- Payload: JSON string defined **ocpp 1.6 edtion 2**. i.e:
```json
{"currentTime": "2022-06-21T14:20:39+00:00", "interval": 300, "status": "Accepted"}
```
### Message Re-transmission
TODO
```
ocpp.awaiting_timeout = 30s
ocpp.retry_interval = 30s
```
### Message Format Checking
TODO
```
#ocpp.message_format_checking = all
#ocpp.json_schema_dir = ${application_priv}/schemas
#ocpp.json_schema_id_prefix = urn:OCPP:1.6:2019:12:
```
## Management and Observability
### Manage Clients
### Observe the messaging state

View File

@ -0,0 +1,101 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%% %% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-define(APP, emqx_ocpp).
%% types for ocppj-1.6
-define(OCPP_MSG_TYPE_ID_CALL, 2).
-define(OCPP_MSG_TYPE_ID_CALLRESULT, 3).
-define(OCPP_MSG_TYPE_ID_CALLERROR, 4).
%% actions for ocppj-1.6
-define(OCPP_ACT_Authorize, <<"Authorize">>).
-define(OCPP_ACT_BootNotification, <<"BootNotification">>).
-define(OCPP_ACT_CancelReservation, <<"CancelReservation">>).
-define(OCPP_ACT_ChangeAvailability, <<"ChangeAvailability">>).
-define(OCPP_ACT_ChangeConfiguration, <<"ChangeConfiguration">>).
-define(OCPP_ACT_ClearCache, <<"ClearCache">>).
-define(OCPP_ACT_ClearChargingProfile, <<"ClearChargingProfile">>).
-define(OCPP_ACT_DataTransfer, <<"DataTransfer">>).
-define(OCPP_ACT_DiagnosticsStatusNotification, <<"DiagnosticsStatusNotification">>).
-define(OCPP_ACT_FirmwareStatusNotification, <<"FirmwareStatusNotification">>).
-define(OCPP_ACT_GetCompositeSchedule, <<"GetCompositeSchedule">>).
-define(OCPP_ACT_GetConfiguration, <<"GetConfiguration">>).
-define(OCPP_ACT_GetDiagnostics, <<"GetDiagnostics">>).
-define(OCPP_ACT_GetLocalListVersion, <<"GetLocalListVersion">>).
-define(OCPP_ACT_Heartbeat, <<"Heartbeat">>).
-define(OCPP_ACT_MeterValues, <<"MeterValues">>).
-define(OCPP_ACT_RemoteStartTransaction, <<"RemoteStartTransaction">>).
-define(OCPP_ACT_RemoteStopTransaction, <<"RemoteStopTransaction">>).
-define(OCPP_ACT_ReserveNow, <<"ReserveNow">>).
-define(OCPP_ACT_Reset, <<"Reset">>).
-define(OCPP_ACT_SendLocalList, <<"SendLocalList">>).
-define(OCPP_ACT_SetChargingProfile, <<"SetChargingProfile">>).
-define(OCPP_ACT_StartTransaction, <<"StartTransaction">>).
-define(OCPP_ACT_StatusNotification, <<"StatusNotification">>).
-define(OCPP_ACT_StopTransaction, <<"StopTransaction">>).
-define(OCPP_ACT_TriggerMessage, <<"TriggerMessage">>).
-define(OCPP_ACT_UnlockConnector, <<"UnlockConnector">>).
-define(OCPP_ACT_UpdateFirmware, <<"UpdateFirmware">>).
%% error codes for ocppj-1.6
-define(OCPP_ERR_NotSupported, <<"NotSupported">>).
-define(OCPP_ERR_InternalError, <<"InternalError">>).
-define(OCPP_ERR_ProtocolError, <<"ProtocolError">>).
-define(OCPP_ERR_SecurityError, <<"SecurityError">>).
-define(OCPP_ERR_FormationViolation, <<"FormationViolation">>).
-define(OCPP_ERR_PropertyConstraintViolation, <<"PropertyConstraintViolation">>).
-define(OCPP_ERR_OccurenceConstraintViolation, <<"OccurenceConstraintViolation">>).
-define(OCPP_ERR_TypeConstraintViolation, <<"TypeConstraintViolation">>).
-define(OCPP_ERR_GenericError, <<"GenericError">>).
-type utf8_string() :: unicode:unicode_binary().
-type message_type() :: ?OCPP_MSG_TYPE_ID_CALL..?OCPP_MSG_TYPE_ID_CALLERROR.
%% OCPP_ACT_Authorize..OCPP_ACT_UpdateFirmware
-type action() :: utf8_string().
-type frame() :: #{
type := message_type(),
%% The message ID serves to identify a request.
%% Maximum of 36 characters, to allow for GUIDs
id := utf8_string(),
%% the name of the remote procedure or action.
%% This will be a case-sensitive string.
%% Only presented in ?OCPP_MSG_TYPE_ID_CALL
action => action(),
%% json map decoded by jsx and validated by json schema
payload := null | map()
}.
-define(IS_REQ(F), F = #{type := ?OCPP_MSG_TYPE_ID_CALL}).
-define(IS_REQ(F, Id), F = #{type := ?OCPP_MSG_TYPE_ID_CALL, id := Id}).
-define(IS_RESP(F), F = #{type := ?OCPP_MSG_TYPE_ID_CALLRESULT}).
-define(IS_RESP(F, Id), F = #{type := ?OCPP_MSG_TYPE_ID_CALLRESULT, id := Id}).
-define(IS_ERROR(F), F = #{type := ?OCPP_MSG_TYPE_ID_CALLERROR}).
-define(IS_ERROR(F, Id), F = #{type := ?OCPP_MSG_TYPE_ID_CALLERROR, id := Id}).
-define(IS_BootNotification_RESP(Payload), #{
type := ?OCPP_MSG_TYPE_ID_CALLRESULT,
action := ?OCPP_ACT_BootNotification,
payload := Payload
}).
-define(ERR_FRAME(Id, Code, Desc), #{
id => Id,
type => ?OCPP_MSG_TYPE_ID_CALLERROR,
error_code => Code,
error_desc => Desc,
error_details => null
}).

View File

@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:AuthorizeRequest",
"title": "AuthorizeRequest",
"type": "object",
"properties": {
"idTag": {
"type": "string",
"maxLength": 20
}
},
"additionalProperties": false,
"required": [
"idTag"
]
}

View File

@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:AuthorizeResponse",
"title": "AuthorizeResponse",
"type": "object",
"properties": {
"idTagInfo": {
"type": "object",
"properties": {
"expiryDate": {
"type": "string",
"format": "date-time"
},
"parentIdTag": {
"type": "string",
"maxLength": 20
},
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Blocked",
"Expired",
"Invalid",
"ConcurrentTx"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}
},
"additionalProperties": false,
"required": [
"idTagInfo"
]
}

View File

@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:BootNotificationRequest",
"title": "BootNotificationRequest",
"type": "object",
"properties": {
"chargePointVendor": {
"type": "string",
"maxLength": 20
},
"chargePointModel": {
"type": "string",
"maxLength": 20
},
"chargePointSerialNumber": {
"type": "string",
"maxLength": 25
},
"chargeBoxSerialNumber": {
"type": "string",
"maxLength": 25
},
"firmwareVersion": {
"type": "string",
"maxLength": 50
},
"iccid": {
"type": "string",
"maxLength": 20
},
"imsi": {
"type": "string",
"maxLength": 20
},
"meterType": {
"type": "string",
"maxLength": 25
},
"meterSerialNumber": {
"type": "string",
"maxLength": 25
}
},
"additionalProperties": false,
"required": [
"chargePointVendor",
"chargePointModel"
]
}

View File

@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:BootNotificationResponse",
"title": "BootNotificationResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Pending",
"Rejected"
]
},
"currentTime": {
"type": "string",
"format": "date-time"
},
"interval": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"status",
"currentTime",
"interval"
]
}

View File

@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:CancelReservationRequest",
"title": "CancelReservationRequest",
"type": "object",
"properties": {
"reservationId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"reservationId"
]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:CancelReservationResponse",
"title": "CancelReservationResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ChangeAvailabilityRequest",
"title": "ChangeAvailabilityRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"type": {
"type": "string",
"additionalProperties": false,
"enum": [
"Inoperative",
"Operative"
]
}
},
"additionalProperties": false,
"required": [
"connectorId",
"type"
]
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ChangeAvailabilityResponse",
"title": "ChangeAvailabilityResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected",
"Scheduled"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ChangeConfigurationRequest",
"title": "ChangeConfigurationRequest",
"type": "object",
"properties": {
"key": {
"type": "string",
"maxLength": 50
},
"value": {
"type": "string",
"maxLength": 500
}
},
"additionalProperties": false,
"required": [
"key",
"value"
]
}

View File

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ChangeConfigurationResponse",
"title": "ChangeConfigurationResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected",
"RebootRequired",
"NotSupported"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ClearCacheRequest",
"title": "ClearCacheRequest",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ClearCacheResponse",
"title": "ClearCacheResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ClearChargingProfileRequest",
"title": "ClearChargingProfileRequest",
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"connectorId": {
"type": "integer"
},
"chargingProfilePurpose": {
"type": "string",
"additionalProperties": false,
"enum": [
"ChargePointMaxProfile",
"TxDefaultProfile",
"TxProfile"
]
},
"stackLevel": {
"type": "integer"
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ClearChargingProfileResponse",
"title": "ClearChargingProfileResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Unknown"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:DataTransferRequest",
"title": "DataTransferRequest",
"type": "object",
"properties": {
"vendorId": {
"type": "string",
"maxLength": 255
},
"messageId": {
"type": "string",
"maxLength": 50
},
"data": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"vendorId"
]
}

View File

@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:DataTransferResponse",
"title": "DataTransferResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected",
"UnknownMessageId",
"UnknownVendorId"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:DiagnosticsStatusNotificationRequest",
"title": "DiagnosticsStatusNotificationRequest",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Idle",
"Uploaded",
"UploadFailed",
"Uploading"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:DiagnosticsStatusNotificationResponse",
"title": "DiagnosticsStatusNotificationResponse",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:FirmwareStatusNotificationRequest",
"title": "FirmwareStatusNotificationRequest",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Downloaded",
"DownloadFailed",
"Downloading",
"Idle",
"InstallationFailed",
"Installing",
"Installed"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:FirmwareStatusNotificationResponse",
"title": "FirmwareStatusNotificationResponse",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetCompositeScheduleRequest",
"title": "GetCompositeScheduleRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"duration": {
"type": "integer"
},
"chargingRateUnit": {
"type": "string",
"additionalProperties": false,
"enum": [
"A",
"W"
]
}
},
"additionalProperties": false,
"required": [
"connectorId",
"duration"
]
}

View File

@ -0,0 +1,79 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetCompositeScheduleResponse",
"title": "GetCompositeScheduleResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
},
"connectorId": {
"type": "integer"
},
"scheduleStart": {
"type": "string",
"format": "date-time"
},
"chargingSchedule": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
},
"startSchedule": {
"type": "string",
"format": "date-time"
},
"chargingRateUnit": {
"type": "string",
"additionalProperties": false,
"enum": [
"A",
"W"
]
},
"chargingSchedulePeriod": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startPeriod": {
"type": "integer"
},
"limit": {
"type": "number",
"multipleOf" : 0.1
},
"numberPhases": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"startPeriod",
"limit"
]
}
},
"minChargingRate": {
"type": "number",
"multipleOf" : 0.1
}
},
"additionalProperties": false,
"required": [
"chargingRateUnit",
"chargingSchedulePeriod"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetConfigurationRequest",
"title": "GetConfigurationRequest",
"type": "object",
"properties": {
"key": {
"type": "array",
"items": {
"type": "string",
"maxLength": 50
}
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetConfigurationResponse",
"title": "GetConfigurationResponse",
"type": "object",
"properties": {
"configurationKey": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string",
"maxLength": 50
},
"readonly": {
"type": "boolean"
},
"value": {
"type": "string",
"maxLength": 500
}
},
"additionalProperties": false,
"required": [
"key",
"readonly"
]
}
},
"unknownKey": {
"type": "array",
"items": {
"type": "string",
"maxLength": 50
}
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetDiagnosticsRequest",
"title": "GetDiagnosticsRequest",
"type": "object",
"properties": {
"location": {
"type": "string",
"format": "uri"
},
"retries": {
"type": "integer"
},
"retryInterval": {
"type": "integer"
},
"startTime": {
"type": "string",
"format": "date-time"
},
"stopTime": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false,
"required": [
"location"
]
}

View File

@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetDiagnosticsResponse",
"title": "GetDiagnosticsResponse",
"type": "object",
"properties": {
"fileName": {
"type": "string",
"maxLength": 255
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetLocalListVersionRequest",
"title": "GetLocalListVersionRequest",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:GetLocalListVersionResponse",
"title": "GetLocalListVersionResponse",
"type": "object",
"properties": {
"listVersion": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"listVersion"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:HeartbeatRequest",
"title": "HeartbeatRequest",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:HeartbeatResponse",
"title": "HeartbeatResponse",
"type": "object",
"properties": {
"currentTime": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false,
"required": [
"currentTime"
]
}

View File

@ -0,0 +1,151 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:MeterValuesRequest",
"title": "MeterValuesRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"transactionId": {
"type": "integer"
},
"meterValue": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timestamp": {
"type": "string",
"format": "date-time"
},
"sampledValue": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"context": {
"type": "string",
"additionalProperties": false,
"enum": [
"Interruption.Begin",
"Interruption.End",
"Sample.Clock",
"Sample.Periodic",
"Transaction.Begin",
"Transaction.End",
"Trigger",
"Other"
]
},
"format": {
"type": "string",
"additionalProperties": false,
"enum": [
"Raw",
"SignedData"
]
},
"measurand": {
"type": "string",
"additionalProperties": false,
"enum": [
"Energy.Active.Export.Register",
"Energy.Active.Import.Register",
"Energy.Reactive.Export.Register",
"Energy.Reactive.Import.Register",
"Energy.Active.Export.Interval",
"Energy.Active.Import.Interval",
"Energy.Reactive.Export.Interval",
"Energy.Reactive.Import.Interval",
"Power.Active.Export",
"Power.Active.Import",
"Power.Offered",
"Power.Reactive.Export",
"Power.Reactive.Import",
"Power.Factor",
"Current.Import",
"Current.Export",
"Current.Offered",
"Voltage",
"Frequency",
"Temperature",
"SoC",
"RPM"
]
},
"phase": {
"type": "string",
"additionalProperties": false,
"enum": [
"L1",
"L2",
"L3",
"N",
"L1-N",
"L2-N",
"L3-N",
"L1-L2",
"L2-L3",
"L3-L1"
]
},
"location": {
"type": "string",
"additionalProperties": false,
"enum": [
"Cable",
"EV",
"Inlet",
"Outlet",
"Body"
]
},
"unit": {
"type": "string",
"additionalProperties": false,
"enum": [
"Wh",
"kWh",
"varh",
"kvarh",
"W",
"kW",
"VA",
"kVA",
"var",
"kvar",
"A",
"V",
"K",
"Celcius",
"Celsius",
"Fahrenheit",
"Percent"
]
}
},
"additionalProperties": false,
"required": [
"value"
]
}
}
},
"additionalProperties": false,
"required": [
"timestamp",
"sampledValue"
]
}
}
},
"additionalProperties": false,
"required": [
"connectorId",
"meterValue"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:MeterValuesResponse",
"title": "MeterValuesResponse",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,127 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:RemoteStartTransactionRequest",
"title": "RemoteStartTransactionRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"idTag": {
"type": "string",
"maxLength": 20
},
"chargingProfile": {
"type": "object",
"properties": {
"chargingProfileId": {
"type": "integer"
},
"transactionId": {
"type": "integer"
},
"stackLevel": {
"type": "integer"
},
"chargingProfilePurpose": {
"type": "string",
"additionalProperties": false,
"enum": [
"ChargePointMaxProfile",
"TxDefaultProfile",
"TxProfile"
]
},
"chargingProfileKind": {
"type": "string",
"additionalProperties": false,
"enum": [
"Absolute",
"Recurring",
"Relative"
]
},
"recurrencyKind": {
"type": "string",
"additionalProperties": false,
"enum": [
"Daily",
"Weekly"
]
},
"validFrom": {
"type": "string",
"format": "date-time"
},
"validTo": {
"type": "string",
"format": "date-time"
},
"chargingSchedule": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
},
"startSchedule": {
"type": "string",
"format": "date-time"
},
"chargingRateUnit": {
"type": "string",
"additionalProperties": false,
"enum": [
"A",
"W"
]
},
"chargingSchedulePeriod": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startPeriod": {
"type": "integer"
},
"limit": {
"type": "number",
"multipleOf" : 0.1
},
"numberPhases": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"startPeriod",
"limit"
]
}
},
"minChargingRate": {
"type": "number",
"multipleOf" : 0.1
}
},
"additionalProperties": false,
"required": [
"chargingRateUnit",
"chargingSchedulePeriod"
]
}
},
"additionalProperties": false,
"required": [
"chargingProfileId",
"stackLevel",
"chargingProfilePurpose",
"chargingProfileKind",
"chargingSchedule"
]
}
},
"additionalProperties": false,
"required": [
"idTag"
]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:RemoteStartTransactionResponse",
"title": "RemoteStartTransactionResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:RemoteStopTransactionRequest",
"title": "RemoteStopTransactionRequest",
"type": "object",
"properties": {
"transactionId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"transactionId"
]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:RemoteStopTransactionResponse",
"title": "RemoteStopTransactionResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ReserveNowRequest",
"title": "ReserveNowRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"expiryDate": {
"type": "string",
"format": "date-time"
},
"idTag": {
"type": "string",
"maxLength": 20
},
"parentIdTag": {
"type": "string",
"maxLength": 20
},
"reservationId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"connectorId",
"expiryDate",
"idTag",
"reservationId"
]
}

View File

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ReserveNowResponse",
"title": "ReserveNowResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Faulted",
"Occupied",
"Rejected",
"Unavailable"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ResetRequest",
"title": "ResetRequest",
"type": "object",
"properties": {
"type": {
"type": "string",
"additionalProperties": false,
"enum": [
"Hard",
"Soft"
]
}
},
"additionalProperties": false,
"required": [
"type"
]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:ResetResponse",
"title": "ResetResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,68 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:SendLocalListRequest",
"title": "SendLocalListRequest",
"type": "object",
"properties": {
"listVersion": {
"type": "integer"
},
"localAuthorizationList": {
"type": "array",
"items": {
"type": "object",
"properties": {
"idTag": {
"type": "string",
"maxLength": 20
},
"idTagInfo": {
"type": "object",
"properties": {
"expiryDate": {
"type": "string",
"format": "date-time"
},
"parentIdTag": {
"type": "string",
"maxLength": 20
},
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Blocked",
"Expired",
"Invalid",
"ConcurrentTx"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}
},
"additionalProperties": false,
"required": [
"idTag"
]
}
},
"updateType": {
"type": "string",
"additionalProperties": false,
"enum": [
"Differential",
"Full"
]
}
},
"additionalProperties": false,
"required": [
"listVersion",
"updateType"
]
}

View File

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:SendLocalListResponse",
"title": "SendLocalListResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Failed",
"NotSupported",
"VersionMismatch"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,124 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:SetChargingProfileRequest",
"title": "SetChargingProfileRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"csChargingProfiles": {
"type": "object",
"properties": {
"chargingProfileId": {
"type": "integer"
},
"transactionId": {
"type": "integer"
},
"stackLevel": {
"type": "integer"
},
"chargingProfilePurpose": {
"type": "string",
"additionalProperties": false,
"enum": [
"ChargePointMaxProfile",
"TxDefaultProfile",
"TxProfile"
]
},
"chargingProfileKind": {
"type": "string",
"additionalProperties": false,
"enum": [
"Absolute",
"Recurring",
"Relative"
]
},
"recurrencyKind": {
"type": "string",
"additionalProperties": false,
"enum": [
"Daily",
"Weekly"
]
},
"validFrom": {
"type": "string",
"format": "date-time"
},
"validTo": {
"type": "string",
"format": "date-time"
},
"chargingSchedule": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
},
"startSchedule": {
"type": "string",
"format": "date-time"
},
"chargingRateUnit": {
"type": "string",
"additionalProperties": false,
"enum": [
"A",
"W"
]
},
"chargingSchedulePeriod": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startPeriod": {
"type": "integer"
},
"limit": {
"type": "number",
"multipleOf" : 0.1
},
"numberPhases": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"startPeriod",
"limit"
]
}
},
"minChargingRate": {
"type": "number",
"multipleOf" : 0.1
}
},
"additionalProperties": false,
"required": [
"chargingRateUnit",
"chargingSchedulePeriod"
]
}
},
"additionalProperties": false,
"required": [
"chargingProfileId",
"stackLevel",
"chargingProfilePurpose",
"chargingProfileKind",
"chargingSchedule"
]
}
},
"additionalProperties": false,
"required": [
"connectorId",
"csChargingProfiles"
]
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:SetChargingProfileResponse",
"title": "SetChargingProfileResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected",
"NotSupported"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StartTransactionRequest",
"title": "StartTransactionRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"idTag": {
"type": "string",
"maxLength": 20
},
"meterStart": {
"type": "integer"
},
"reservationId": {
"type": "integer"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false,
"required": [
"connectorId",
"idTag",
"meterStart",
"timestamp"
]
}

View File

@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StartTransactionResponse",
"title": "StartTransactionResponse",
"type": "object",
"properties": {
"idTagInfo": {
"type": "object",
"properties": {
"expiryDate": {
"type": "string",
"format": "date-time"
},
"parentIdTag": {
"type": "string",
"maxLength": 20
},
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Blocked",
"Expired",
"Invalid",
"ConcurrentTx"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
},
"transactionId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"idTagInfo",
"transactionId"
]
}

View File

@ -0,0 +1,70 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StatusNotificationRequest",
"title": "StatusNotificationRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
},
"errorCode": {
"type": "string",
"additionalProperties": false,
"enum": [
"ConnectorLockFailure",
"EVCommunicationError",
"GroundFailure",
"HighTemperature",
"InternalError",
"LocalListConflict",
"NoError",
"OtherError",
"OverCurrentFailure",
"PowerMeterFailure",
"PowerSwitchFailure",
"ReaderFailure",
"ResetFailure",
"UnderVoltage",
"OverVoltage",
"WeakSignal"
]
},
"info": {
"type": "string",
"maxLength": 50
},
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Available",
"Preparing",
"Charging",
"SuspendedEVSE",
"SuspendedEV",
"Finishing",
"Reserved",
"Unavailable",
"Faulted"
]
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"vendorId": {
"type": "string",
"maxLength": 255
},
"vendorErrorCode": {
"type": "string",
"maxLength": 50
}
},
"additionalProperties": false,
"required": [
"connectorId",
"errorCode",
"status"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StatusNotificationResponse",
"title": "StatusNotificationResponse",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,176 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StopTransactionRequest",
"title": "StopTransactionRequest",
"type": "object",
"properties": {
"idTag": {
"type": "string",
"maxLength": 20
},
"meterStop": {
"type": "integer"
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"transactionId": {
"type": "integer"
},
"reason": {
"type": "string",
"additionalProperties": false,
"enum": [
"EmergencyStop",
"EVDisconnected",
"HardReset",
"Local",
"Other",
"PowerLoss",
"Reboot",
"Remote",
"SoftReset",
"UnlockCommand",
"DeAuthorized"
]
},
"transactionData": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timestamp": {
"type": "string",
"format": "date-time"
},
"sampledValue": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"context": {
"type": "string",
"additionalProperties": false,
"enum": [
"Interruption.Begin",
"Interruption.End",
"Sample.Clock",
"Sample.Periodic",
"Transaction.Begin",
"Transaction.End",
"Trigger",
"Other"
]
},
"format": {
"type": "string",
"additionalProperties": false,
"enum": [
"Raw",
"SignedData"
]
},
"measurand": {
"type": "string",
"additionalProperties": false,
"enum": [
"Energy.Active.Export.Register",
"Energy.Active.Import.Register",
"Energy.Reactive.Export.Register",
"Energy.Reactive.Import.Register",
"Energy.Active.Export.Interval",
"Energy.Active.Import.Interval",
"Energy.Reactive.Export.Interval",
"Energy.Reactive.Import.Interval",
"Power.Active.Export",
"Power.Active.Import",
"Power.Offered",
"Power.Reactive.Export",
"Power.Reactive.Import",
"Power.Factor",
"Current.Import",
"Current.Export",
"Current.Offered",
"Voltage",
"Frequency",
"Temperature",
"SoC",
"RPM"
]
},
"phase": {
"type": "string",
"additionalProperties": false,
"enum": [
"L1",
"L2",
"L3",
"N",
"L1-N",
"L2-N",
"L3-N",
"L1-L2",
"L2-L3",
"L3-L1"
]
},
"location": {
"type": "string",
"additionalProperties": false,
"enum": [
"Cable",
"EV",
"Inlet",
"Outlet",
"Body"
]
},
"unit": {
"type": "string",
"additionalProperties": false,
"enum": [
"Wh",
"kWh",
"varh",
"kvarh",
"W",
"kW",
"VA",
"kVA",
"var",
"kvar",
"A",
"V",
"K",
"Celcius",
"Fahrenheit",
"Percent"
]
}
},
"additionalProperties": false,
"required": [
"value"
]
}
}
},
"additionalProperties": false,
"required": [
"timestamp",
"sampledValue"
]
}
}
},
"additionalProperties": false,
"required": [
"transactionId",
"timestamp",
"meterStop"
]
}

View File

@ -0,0 +1,37 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:StopTransactionResponse",
"title": "StopTransactionResponse",
"type": "object",
"properties": {
"idTagInfo": {
"type": "object",
"properties": {
"expiryDate": {
"type": "string",
"format": "date-time"
},
"parentIdTag": {
"type": "string",
"maxLength": 20
},
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Blocked",
"Expired",
"Invalid",
"ConcurrentTx"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:TriggerMessageRequest",
"title": "TriggerMessageRequest",
"type": "object",
"properties": {
"requestedMessage": {
"type": "string",
"additionalProperties": false,
"enum": [
"BootNotification",
"DiagnosticsStatusNotification",
"FirmwareStatusNotification",
"Heartbeat",
"MeterValues",
"StatusNotification"
]
},
"connectorId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"requestedMessage"
]
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:TriggerMessageResponse",
"title": "TriggerMessageResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Accepted",
"Rejected",
"NotImplemented"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:UnlockConnectorRequest",
"title": "UnlockConnectorRequest",
"type": "object",
"properties": {
"connectorId": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"connectorId"
]
}

View File

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:UnlockConnectorResponse",
"title": "UnlockConnectorResponse",
"type": "object",
"properties": {
"status": {
"type": "string",
"additionalProperties": false,
"enum": [
"Unlocked",
"UnlockFailed",
"NotSupported"
]
}
},
"additionalProperties": false,
"required": [
"status"
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:UpdateFirmwareRequest",
"title": "UpdateFirmwareRequest",
"type": "object",
"properties": {
"location": {
"type": "string",
"format": "uri"
},
"retries": {
"type": "integer"
},
"retrieveDate": {
"type": "string",
"format": "date-time"
},
"retryInterval": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"location",
"retrieveDate"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "urn:OCPP:1.6:2019:12:UpdateFirmwareResponse",
"title": "UpdateFirmwareResponse",
"type": "object",
"properties": {},
"additionalProperties": false
}

View File

@ -0,0 +1,6 @@
{deps, [
{jesse, "1.7.0"},
{emqx, {path, "../../apps/emqx"}},
{emqx_utils, {path, "../emqx_utils"}},
{emqx_gateway, {path, "../../apps/emqx_gateway"}}
]}.

View File

@ -0,0 +1,9 @@
{application, emqx_gateway_ocpp, [
{description, "OCPP-J 1.6 Gateway for EMQX"},
{vsn, "0.1.0"},
{registered, []},
{applications, [kernel, stdlib, jesse, emqx, emqx_gateway]},
{env, []},
{modules, []},
{links, []}
]}.

View File

@ -0,0 +1,102 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%--------------------------------------------------------------------
%% @doc The OCPP Gateway implement
-module(emqx_gateway_ocpp).
-include_lib("emqx/include/logger.hrl").
-include_lib("emqx_gateway/include/emqx_gateway.hrl").
%% define a gateway named ocpp
-gateway(#{
name => ocpp,
callback_module => ?MODULE,
config_schema_module => emqx_ocpp_schema,
edition => ee
}).
%% callback_module must implement the emqx_gateway_impl behaviour
-behaviour(emqx_gateway_impl).
%% callback for emqx_gateway_impl
-export([
on_gateway_load/2,
on_gateway_update/3,
on_gateway_unload/2
]).
-import(
emqx_gateway_utils,
[
normalize_config/1,
start_listeners/4,
stop_listeners/2
]
).
%%--------------------------------------------------------------------
%% emqx_gateway_impl callbacks
%%--------------------------------------------------------------------
on_gateway_load(
_Gateway = #{
name := GwName,
config := Config
},
Ctx
) ->
%% ensure json schema validator is loaded
emqx_ocpp_schemas:load(),
Listeners = normalize_config(Config),
ModCfg = #{
frame_mod => emqx_ocpp_frame,
chann_mod => emqx_ocpp_channel,
connection_mod => emqx_ocpp_connection
},
case
start_listeners(
Listeners, GwName, Ctx, ModCfg
)
of
{ok, ListenerPids} ->
%% FIXME: How to throw an exception to interrupt the restart logic ?
%% FIXME: Assign ctx to GwState
{ok, ListenerPids, _GwState = #{ctx => Ctx}};
{error, {Reason, Listener}} ->
throw(
{badconf, #{
key => listeners,
value => Listener,
reason => Reason
}}
)
end.
on_gateway_update(Config, Gateway, GwState = #{ctx := Ctx}) ->
GwName = maps:get(name, Gateway),
try
%% XXX: 1. How hot-upgrade the changes ???
%% XXX: 2. Check the New confs first before destroy old state???
on_gateway_unload(Gateway, GwState),
on_gateway_load(Gateway#{config => Config}, Ctx)
catch
Class:Reason:Stk ->
logger:error(
"Failed to update ~ts; "
"reason: {~0p, ~0p} stacktrace: ~0p",
[GwName, Class, Reason, Stk]
),
{error, Reason}
end.
on_gateway_unload(
_Gateway = #{
name := GwName,
config := Config
},
_GwState
) ->
Listeners = normalize_config(Config),
stop_listeners(GwName, Listeners).

View File

@ -0,0 +1,894 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_channel).
-behaviour(emqx_gateway_channel).
-include("emqx_ocpp.hrl").
-include_lib("emqx/include/emqx.hrl").
-include_lib("emqx/include/emqx_mqtt.hrl").
-include_lib("emqx/include/types.hrl").
-include_lib("emqx/include/logger.hrl").
-logger_header("[OCPP-Chann]").
-ifdef(TEST).
-compile(export_all).
-compile(nowarn_export_all).
-endif.
-export([
info/1,
info/2,
stats/1
]).
-export([
init/2,
authenticate/2,
handle_in/2,
handle_deliver/2,
handle_out/3,
handle_timeout/3,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
%% Exports for CT
-export([set_field/3]).
-export_type([channel/0]).
-record(channel, {
%% Context
ctx :: emqx_gateway_ctx:context(),
%% ConnInfo
conninfo :: emqx_types:conninfo(),
%% ClientInfo
clientinfo :: emqx_types:clientinfo(),
%% Session
session :: maybe(map()),
%% ClientInfo override specs
clientinfo_override :: map(),
%% Keepalive
keepalive :: maybe(emqx_ocpp_keepalive:keepalive()),
%% Stores all unsent messages.
mqueue :: queue:queue(),
%% Timers
timers :: #{atom() => disabled | maybe(reference())},
%% Conn State
conn_state :: conn_state()
}).
-type channel() :: #channel{}.
-type conn_state() :: idle | connecting | connected | disconnected.
-type reply() ::
{outgoing, emqx_ocpp_frame:frame()}
| {outgoing, [emqx_ocpp_frame:frame()]}
| {event, conn_state() | updated}
| {close, Reason :: atom()}.
-type replies() :: reply() | [reply()].
-define(TIMER_TABLE, #{
alive_timer => keepalive
}).
-define(INFO_KEYS, [
conninfo,
conn_state,
clientinfo,
session
]).
-define(CHANNEL_METRICS, [
recv_pkt,
recv_msg,
'recv_msg.qos0',
'recv_msg.qos1',
'recv_msg.qos2',
'recv_msg.dropped',
'recv_msg.dropped.await_pubrel_timeout',
send_pkt,
send_msg,
'send_msg.qos0',
'send_msg.qos1',
'send_msg.qos2',
'send_msg.dropped',
'send_msg.dropped.expired',
'send_msg.dropped.queue_full',
'send_msg.dropped.too_large'
]).
-define(DEFAULT_OVERRIDE,
%% Generate clientid by default
#{
clientid => <<"">>,
username => <<"">>,
password => <<"">>
}
).
-dialyzer(no_match).
%%--------------------------------------------------------------------
%% Info, Attrs and Caps
%%--------------------------------------------------------------------
%% @doc Get infos of the channel.
-spec info(channel()) -> emqx_types:infos().
info(Channel) ->
maps:from_list(info(?INFO_KEYS, Channel)).
-spec info(list(atom()) | atom(), channel()) -> term().
info(Keys, Channel) when is_list(Keys) ->
[{Key, info(Key, Channel)} || Key <- Keys];
info(conninfo, #channel{conninfo = ConnInfo}) ->
ConnInfo;
info(socktype, #channel{conninfo = ConnInfo}) ->
maps:get(socktype, ConnInfo, undefined);
info(peername, #channel{conninfo = ConnInfo}) ->
maps:get(peername, ConnInfo, undefined);
info(sockname, #channel{conninfo = ConnInfo}) ->
maps:get(sockname, ConnInfo, undefined);
info(proto_name, #channel{conninfo = ConnInfo}) ->
maps:get(proto_name, ConnInfo, undefined);
info(proto_ver, #channel{conninfo = ConnInfo}) ->
maps:get(proto_ver, ConnInfo, undefined);
info(connected_at, #channel{conninfo = ConnInfo}) ->
maps:get(connected_at, ConnInfo, undefined);
info(clientinfo, #channel{clientinfo = ClientInfo}) ->
ClientInfo;
info(zone, #channel{clientinfo = ClientInfo}) ->
maps:get(zone, ClientInfo, undefined);
info(clientid, #channel{clientinfo = ClientInfo}) ->
maps:get(clientid, ClientInfo, undefined);
info(username, #channel{clientinfo = ClientInfo}) ->
maps:get(username, ClientInfo, undefined);
info(session, #channel{conninfo = ConnInfo}) ->
%% XXX:
#{
created_at => maps:get(connected_at, ConnInfo, undefined),
is_persistent => false,
subscriptions => #{},
upgrade_qos => false,
retry_interval => 0,
await_rel_timeout => 0
};
info(conn_state, #channel{conn_state = ConnState}) ->
ConnState;
info(keepalive, #channel{keepalive = Keepalive}) ->
emqx_utils:maybe_apply(fun emqx_ocpp_keepalive:info/1, Keepalive);
info(ctx, #channel{ctx = Ctx}) ->
Ctx;
info(timers, #channel{timers = Timers}) ->
Timers.
-spec stats(channel()) -> emqx_types:stats().
stats(#channel{mqueue = MQueue}) ->
%% XXX:
SessionStats = [
{subscriptions_cnt, 0},
{subscriptions_max, 0},
{inflight_cnt, 0},
{inflight_max, 0},
{mqueue_len, queue:len(MQueue)},
{mqueue_max, queue:len(MQueue)},
{mqueue_dropped, 0},
{next_pkt_id, 0},
{awaiting_rel_cnt, 0},
{awaiting_rel_max, 0}
],
lists:append(SessionStats, emqx_pd:get_counters(?CHANNEL_METRICS)).
%%--------------------------------------------------------------------
%% Init the channel
%%--------------------------------------------------------------------
-spec init(emqx_types:conninfo(), map()) -> channel().
init(
ConnInfo = #{
peername := {PeerHost, _Port},
sockname := {_Host, SockPort}
},
Options
) ->
Peercert = maps:get(peercert, ConnInfo, undefined),
Mountpoint = maps:get(mountpoint, Options, undefined),
ListenerId =
case maps:get(listener, Options, undefined) of
undefined -> undefined;
{GwName, Type, LisName} -> emqx_gateway_utils:listener_id(GwName, Type, LisName)
end,
EnableAuthn = maps:get(enable_authn, Options, true),
ClientInfo = setting_peercert_infos(
Peercert,
#{
zone => default,
listener => ListenerId,
protocol => ocpp,
peerhost => PeerHost,
sockport => SockPort,
clientid => undefined,
username => undefined,
is_bridge => false,
is_superuser => false,
enalbe_authn => EnableAuthn,
mountpoint => Mountpoint
}
),
ConnInfo1 = ConnInfo#{
keepalive => emqx_ocpp_conf:default_heartbeat_interval()
},
{NClientInfo, NConnInfo} = take_ws_cookie(ClientInfo, ConnInfo1),
Ctx = maps:get(ctx, Options),
Override = maps:merge(
?DEFAULT_OVERRIDE,
maps:get(clientinfo_override, Options, #{})
),
#channel{
ctx = Ctx,
conninfo = NConnInfo,
clientinfo = NClientInfo,
clientinfo_override = Override,
mqueue = queue:new(),
timers = #{},
conn_state = idle
}.
setting_peercert_infos(NoSSL, ClientInfo) when
NoSSL =:= nossl;
NoSSL =:= undefined
->
ClientInfo;
setting_peercert_infos(Peercert, ClientInfo) ->
{DN, CN} = {esockd_peercert:subject(Peercert), esockd_peercert:common_name(Peercert)},
ClientInfo#{dn => DN, cn => CN}.
take_ws_cookie(ClientInfo, ConnInfo) ->
case maps:take(ws_cookie, ConnInfo) of
{WsCookie, NConnInfo} ->
{ClientInfo#{ws_cookie => WsCookie}, NConnInfo};
_ ->
{ClientInfo, ConnInfo}
end.
authenticate(UserInfo, Channel) ->
case
emqx_utils:pipeline(
[
fun enrich_client/2,
fun run_conn_hooks/2,
fun check_banned/2,
fun auth_connect/2
],
UserInfo,
Channel#channel{conn_state = connecting}
)
of
{ok, _, NChannel} ->
{ok, NChannel};
{error, Reason, _NChannel} ->
{error, Reason}
end.
enrich_client(
#{
clientid := ClientId,
username := Username,
proto_name := ProtoName,
proto_ver := ProtoVer
},
Channel = #channel{
conninfo = ConnInfo,
clientinfo = ClientInfo
}
) ->
NConnInfo = ConnInfo#{
clientid => ClientId,
username => Username,
proto_name => ProtoName,
proto_ver => ProtoVer,
clean_start => true,
conn_props => #{},
expiry_interval => 0,
receive_maximum => 1
},
NClientInfo = fix_mountpoint(
ClientInfo#{
clientid => ClientId,
username => Username
}
),
{ok, Channel#channel{conninfo = NConnInfo, clientinfo = NClientInfo}}.
fix_mountpoint(ClientInfo = #{mountpoint := undefined}) ->
ClientInfo;
fix_mountpoint(ClientInfo = #{mountpoint := Mountpoint}) ->
Mountpoint1 = emqx_mountpoint:replvar(Mountpoint, ClientInfo),
ClientInfo#{mountpoint := Mountpoint1}.
set_log_meta(#channel{
clientinfo = #{clientid := ClientId},
conninfo = #{peername := Peername}
}) ->
emqx_logger:set_metadata_peername(esockd:format(Peername)),
emqx_logger:set_metadata_clientid(ClientId).
run_conn_hooks(_UserInfo, Channel = #channel{conninfo = ConnInfo}) ->
case run_hooks('client.connect', [ConnInfo], #{}) of
Error = {error, _Reason} -> Error;
_NConnProps -> {ok, Channel}
end.
check_banned(_UserInfo, #channel{clientinfo = ClientInfo}) ->
case emqx_banned:check(ClientInfo) of
true -> {error, banned};
false -> ok
end.
auth_connect(
#{password := Password},
#channel{clientinfo = ClientInfo} = Channel
) ->
#{
clientid := ClientId,
username := Username
} = ClientInfo,
case emqx_access_control:authenticate(ClientInfo#{password => Password}) of
{ok, AuthResult} ->
NClientInfo = maps:merge(ClientInfo, AuthResult),
{ok, Channel#channel{clientinfo = NClientInfo}};
{error, Reason} ->
?SLOG(warning, #{
msg => "client_login_failed",
clientid => ClientId,
username => Username,
reason => Reason
}),
{error, Reason}
end.
publish(
Frame,
Channel = #channel{
clientinfo =
#{
clientid := ClientId,
username := Username,
protocol := Protocol,
peerhost := PeerHost,
mountpoint := Mountpoint
},
conninfo = #{proto_ver := ProtoVer}
}
) when
is_map(Frame)
->
Topic0 = upstream_topic(Frame, Channel),
Topic = emqx_mountpoint:mount(Mountpoint, Topic0),
Payload = frame2payload(Frame),
emqx_broker:publish(
emqx_message:make(
ClientId,
?QOS_2,
Topic,
Payload,
#{},
#{
protocol => Protocol,
proto_ver => ProtoVer,
username => Username,
peerhost => PeerHost
}
)
).
upstream_topic(
Frame = #{id := Id, type := Type},
#channel{clientinfo = #{clientid := ClientId}}
) ->
Vars = #{id => Id, type => Type, clientid => ClientId, cid => ClientId},
case Type of
?OCPP_MSG_TYPE_ID_CALL ->
Action = maps:get(action, Frame),
proc_tmpl(
emqx_ocpp_conf:uptopic(Action),
Vars#{action => Action}
);
?OCPP_MSG_TYPE_ID_CALLRESULT ->
proc_tmpl(emqx_ocpp_conf:up_reply_topic(), Vars);
?OCPP_MSG_TYPE_ID_CALLERROR ->
proc_tmpl(emqx_ocpp_conf:up_error_topic(), Vars)
end.
%%--------------------------------------------------------------------
%% Handle incoming packet
%%--------------------------------------------------------------------
-spec handle_in(emqx_ocpp_frame:frame(), channel()) ->
{ok, channel()}
| {ok, replies(), channel()}
| {shutdown, Reason :: term(), channel()}
| {shutdown, Reason :: term(), replies(), channel()}.
handle_in(?IS_REQ(Frame), Channel) ->
%% TODO: strit mode
_ = publish(Frame, Channel),
{ok, Channel};
handle_in(Frame = #{type := Type}, Channel) when
Type == ?OCPP_MSG_TYPE_ID_CALLRESULT;
Type == ?OCPP_MSG_TYPE_ID_CALLERROR
->
_ = publish(Frame, Channel),
try_deliver(Channel);
handle_in({frame_error, {badjson, ReasonStr}}, Channel) ->
shutdown({frame_error, {badjson, iolist_to_binary(ReasonStr)}}, Channel);
handle_in({frame_error, {validation_faliure, Id, ReasonStr}}, Channel) ->
handle_out(
dnstream,
?ERR_FRAME(Id, ?OCPP_ERR_FormationViolation, iolist_to_binary(ReasonStr)),
Channel
);
handle_in(Frame, Channel) ->
?SLOG(error, #{msg => "unexpected_incoming", frame => Frame}),
{ok, Channel}.
%%--------------------------------------------------------------------
%% Process Disconnect
%%--------------------------------------------------------------------
%%--------------------------------------------------------------------
%% Handle Delivers from broker to client
%%--------------------------------------------------------------------
-spec handle_deliver(list(emqx_types:deliver()), channel()) ->
{ok, channel()} | {ok, replies(), channel()}.
handle_deliver(Delivers, Channel) ->
NChannel =
lists:foldl(
fun({deliver, _, Msg}, Acc) ->
enqueue(Msg, Acc)
end,
Channel,
Delivers
),
try_deliver(NChannel).
enqueue(Msg, Channel = #channel{mqueue = MQueue}) ->
case queue:len(MQueue) > emqx_ocpp_conf:max_mqueue_len() of
false ->
try payload2frame(Msg#message.payload) of
Frame ->
Channel#channel{mqueue = queue:in(Frame, MQueue)}
catch
_:_ ->
?SLOG(error, #{msg => "drop_invalid_message", message => Msg}),
Channel
end;
true ->
?SLOG(error, #{msg => "drop_message", message => Msg, reason => message_queue_full}),
Channel
end.
try_deliver(Channel = #channel{mqueue = MQueue}) ->
case queue:is_empty(MQueue) of
false ->
%% TODO: strit_mode
Frames = queue:to_list(MQueue),
handle_out(dnstream, Frames, Channel#channel{mqueue = queue:new()});
true ->
{ok, Channel}
end.
%%--------------------------------------------------------------------
%% Handle outgoing packet
%%--------------------------------------------------------------------
-spec handle_out(atom(), term(), channel()) ->
{ok, channel()}
| {ok, replies(), channel()}
| {shutdown, Reason :: term(), channel()}
| {shutdown, Reason :: term(), replies(), channel()}.
handle_out(dnstream, Frames, Channel) ->
{Outgoings, NChannel} = apply_frame(Frames, Channel),
{ok, [{outgoing, Frames} | Outgoings], NChannel};
handle_out(disconnect, keepalive_timeout, Channel) ->
{shutdown, keepalive_timeout, Channel};
handle_out(Type, Data, Channel) ->
?SLOG(error, #{msg => "unexpected_outgoing", type => Type, data => Data}),
{ok, Channel}.
%%--------------------------------------------------------------------
%% Apply Response frame to channel state machine
%%--------------------------------------------------------------------
apply_frame(Frames, Channel) when is_list(Frames) ->
{Outgoings, NChannel} = lists:foldl(fun apply_frame/2, {[], Channel}, Frames),
{lists:reverse(Outgoings), NChannel};
apply_frame(?IS_BootNotification_RESP(Payload), {Outgoings, Channel}) ->
case maps:get(<<"status">>, Payload) of
<<"Accepted">> ->
Intv = maps:get(<<"interval">>, Payload),
?SLOG(info, #{msg => "adjust_heartbeat_timer", new_interval_s => Intv}),
{[{event, updated} | Outgoings], reset_keepalive(Intv, Channel)};
_ ->
{Outgoings, Channel}
end;
apply_frame(_, Channel) ->
Channel.
%%--------------------------------------------------------------------
%% Handle call
%%--------------------------------------------------------------------
-spec handle_call(Req :: any(), From :: emqx_gateway_channel:gen_server_from(), channel()) ->
{reply, Reply :: any(), channel()}
| {shutdown, Reason :: any(), Reply :: any(), channel()}.
handle_call(kick, _From, Channel) ->
shutdown(kicked, ok, Channel);
handle_call(discard, _From, Channel) ->
shutdown(discarded, ok, Channel);
handle_call(Req, From, Channel) ->
?SLOG(error, #{msg => "unexpected_call", req => Req, from => From}),
reply(ignored, Channel).
%%--------------------------------------------------------------------
%% Handle Cast
%%--------------------------------------------------------------------
-spec handle_cast(Req :: any(), channel()) ->
ok
| {ok, channel()}
| {shutdown, Reason :: term(), channel()}.
handle_cast(Req, Channel) ->
?SLOG(error, #{msg => "unexpected_cast", req => Req}),
{ok, Channel}.
%%--------------------------------------------------------------------
%% Handle Info
%%--------------------------------------------------------------------
-spec handle_info(Info :: term(), channel()) ->
ok | {ok, channel()} | {shutdown, Reason :: term(), channel()}.
handle_info(after_init, Channel0) ->
set_log_meta(Channel0),
case process_connect(Channel0) of
{ok, Channel} ->
NChannel = ensure_keepalive(
ensure_connected(
ensure_subscribe_dn_topics(Channel)
)
),
{ok, [{event, connected}], NChannel};
{error, Reason} ->
shutdown(Reason, Channel0)
end;
handle_info({sock_closed, Reason}, Channel) ->
NChannel = ensure_disconnected({sock_closed, Reason}, Channel),
shutdown(Reason, NChannel);
handle_info(Info, Channel) ->
?SLOG(error, #{msg => "unexpected_info", info => Info}),
{ok, Channel}.
process_connect(
Channel = #channel{
ctx = Ctx,
conninfo = ConnInfo,
clientinfo = ClientInfo
}
) ->
SessFun = fun(_, _) -> #{} end,
case
emqx_gateway_ctx:open_session(
Ctx,
true,
ClientInfo,
ConnInfo,
SessFun
)
of
{ok, #{session := Session}} ->
NChannel = Channel#channel{session = Session},
{ok, NChannel};
{error, Reason} ->
?SLOG(error, #{msg => "failed_to_open_session", reason => Reason}),
{error, Reason}
end.
ensure_subscribe_dn_topics(
Channel = #channel{clientinfo = #{clientid := ClientId, mountpoint := Mountpoint} = ClientInfo}
) ->
SubOpts = #{rh => 0, rap => 0, nl => 0, qos => ?QOS_1},
Topic0 = proc_tmpl(
emqx_ocpp_conf:dntopic(),
#{
clientid => ClientId,
cid => ClientId
}
),
Topic = emqx_mountpoint:mount(Mountpoint, Topic0),
ok = emqx_broker:subscribe(Topic, ClientId, SubOpts),
ok = emqx_hooks:run('session.subscribed', [ClientInfo, Topic, SubOpts]),
Channel.
%%--------------------------------------------------------------------
%% Handle timeout
%%--------------------------------------------------------------------
-spec handle_timeout(reference(), Msg :: term(), channel()) ->
{ok, channel()}
| {ok, replies(), channel()}
| {shutdown, Reason :: term(), channel()}.
handle_timeout(
_TRef,
{keepalive, _StatVal},
Channel = #channel{keepalive = undefined}
) ->
{ok, Channel};
handle_timeout(
_TRef,
{keepalive, _StatVal},
Channel = #channel{conn_state = disconnected}
) ->
{ok, Channel};
handle_timeout(
_TRef,
{keepalive, StatVal},
Channel = #channel{keepalive = Keepalive}
) ->
case emqx_ocpp_keepalive:check(StatVal, Keepalive) of
{ok, NKeepalive} ->
NChannel = Channel#channel{keepalive = NKeepalive},
{ok, reset_timer(alive_timer, NChannel)};
{error, timeout} ->
handle_out(disconnect, keepalive_timeout, Channel)
end;
handle_timeout(_TRef, Msg, Channel) ->
?SLOG(error, #{msg => "unexpected_timeout", timeout_msg => Msg}),
{ok, Channel}.
%%--------------------------------------------------------------------
%% Ensure timers
%%--------------------------------------------------------------------
ensure_timer([Name], Channel) ->
ensure_timer(Name, Channel);
ensure_timer([Name | Rest], Channel) ->
ensure_timer(Rest, ensure_timer(Name, Channel));
ensure_timer(Name, Channel = #channel{timers = Timers}) ->
TRef = maps:get(Name, Timers, undefined),
Time = interval(Name, Channel),
case TRef == undefined andalso Time > 0 of
true -> ensure_timer(Name, Time, Channel);
%% Timer disabled or exists
false -> Channel
end.
ensure_timer(Name, Time, Channel = #channel{timers = Timers}) ->
Msg = maps:get(Name, ?TIMER_TABLE),
TRef = emqx_utils:start_timer(Time, Msg),
Channel#channel{timers = Timers#{Name => TRef}}.
reset_timer(Name, Channel) ->
ensure_timer(Name, clean_timer(Name, Channel)).
clean_timer(Name, Channel = #channel{timers = Timers}) ->
Channel#channel{timers = maps:remove(Name, Timers)}.
interval(alive_timer, #channel{keepalive = KeepAlive}) ->
emqx_ocpp_keepalive:info(interval, KeepAlive).
%%--------------------------------------------------------------------
%% Terminate
%%--------------------------------------------------------------------
-spec terminate(any(), channel()) -> ok.
terminate(_, #channel{conn_state = idle}) ->
ok;
terminate(normal, Channel) ->
run_terminate_hook(normal, Channel);
terminate({shutdown, Reason}, Channel) when
Reason =:= kicked; Reason =:= discarded
->
run_terminate_hook(Reason, Channel);
terminate(Reason, Channel) ->
run_terminate_hook(Reason, Channel).
run_terminate_hook(Reason, Channel = #channel{clientinfo = ClientInfo}) ->
emqx_hooks:run('session.terminated', [ClientInfo, Reason, info(session, Channel)]).
%%--------------------------------------------------------------------
%% Internal functions
%%--------------------------------------------------------------------
%%--------------------------------------------------------------------
%% Frame
frame2payload(Frame = #{type := ?OCPP_MSG_TYPE_ID_CALL}) ->
emqx_utils_json:encode(
#{
<<"MessageTypeId">> => ?OCPP_MSG_TYPE_ID_CALL,
<<"UniqueId">> => maps:get(id, Frame),
<<"Action">> => maps:get(action, Frame),
<<"Payload">> => maps:get(payload, Frame)
}
);
frame2payload(Frame = #{type := ?OCPP_MSG_TYPE_ID_CALLRESULT}) ->
emqx_utils_json:encode(
#{
<<"MessageTypeId">> => ?OCPP_MSG_TYPE_ID_CALLRESULT,
<<"UniqueId">> => maps:get(id, Frame),
<<"Payload">> => maps:get(payload, Frame)
}
);
frame2payload(Frame = #{type := ?OCPP_MSG_TYPE_ID_CALLERROR}) ->
emqx_utils_json:encode(
#{
<<"MessageTypeId">> => maps:get(type, Frame),
<<"UniqueId">> => maps:get(id, Frame),
<<"ErrorCode">> => maps:get(error_code, Frame),
<<"ErrorDescription">> => maps:get(error_desc, Frame)
}
).
payload2frame(Payload) when is_binary(Payload) ->
payload2frame(emqx_utils_json:decode(Payload, [return_maps]));
payload2frame(#{
<<"MessageTypeId">> := ?OCPP_MSG_TYPE_ID_CALL,
<<"UniqueId">> := Id,
<<"Action">> := Action,
<<"Payload">> := Payload
}) ->
#{
type => ?OCPP_MSG_TYPE_ID_CALL,
id => Id,
action => Action,
payload => Payload
};
payload2frame(
MqttPayload =
#{
<<"MessageTypeId">> := ?OCPP_MSG_TYPE_ID_CALLRESULT,
<<"UniqueId">> := Id,
<<"Payload">> := Payload
}
) ->
Action = maps:get(<<"Action">>, MqttPayload, undefined),
#{
type => ?OCPP_MSG_TYPE_ID_CALLRESULT,
id => Id,
action => Action,
payload => Payload
};
payload2frame(#{
<<"MessageTypeId">> := ?OCPP_MSG_TYPE_ID_CALLERROR,
<<"UniqueId">> := Id,
<<"ErrorCode">> := ErrorCode,
<<"ErrorDescription">> := ErrorDescription
}) ->
#{
type => ?OCPP_MSG_TYPE_ID_CALLERROR,
id => Id,
error_code => ErrorCode,
error_desc => ErrorDescription
}.
%%--------------------------------------------------------------------
%% Ensure connected
ensure_connected(
Channel = #channel{
conninfo = ConnInfo,
clientinfo = ClientInfo
}
) ->
NConnInfo = ConnInfo#{connected_at => erlang:system_time(millisecond)},
ok = run_hooks('client.connected', [ClientInfo, NConnInfo]),
Channel#channel{
conninfo = NConnInfo,
conn_state = connected
}.
ensure_disconnected(
Reason,
Channel = #channel{
conninfo = ConnInfo,
clientinfo = ClientInfo
}
) ->
NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)},
ok = run_hooks('client.disconnected', [ClientInfo, Reason, NConnInfo]),
Channel#channel{conninfo = NConnInfo, conn_state = disconnected}.
%%--------------------------------------------------------------------
%% Ensure Keepalive
ensure_keepalive(Channel = #channel{conninfo = ConnInfo}) ->
ensure_keepalive_timer(maps:get(keepalive, ConnInfo), Channel).
ensure_keepalive_timer(0, Channel) ->
Channel;
ensure_keepalive_timer(Interval, Channel) ->
Keepalive = emqx_ocpp_keepalive:init(
timer:seconds(Interval),
heartbeat_checking_times_backoff()
),
ensure_timer(alive_timer, Channel#channel{keepalive = Keepalive}).
reset_keepalive(Interval, Channel = #channel{conninfo = ConnInfo, timers = Timers}) ->
case maps:get(alive_timer, Timers, undefined) of
undefined ->
Channel;
TRef ->
NConnInfo = ConnInfo#{keepalive => Interval},
emqx_utils:cancel_timer(TRef),
ensure_keepalive_timer(
Interval,
Channel#channel{
conninfo = NConnInfo,
timers = maps:without([alive_timer], Timers)
}
)
end.
heartbeat_checking_times_backoff() ->
max(0, emqx_ocpp_conf:heartbeat_checking_times_backoff() - 1).
%%--------------------------------------------------------------------
%% Helper functions
%%--------------------------------------------------------------------
-compile({inline, [run_hooks/3]}).
run_hooks(Name, Args) ->
ok = emqx_metrics:inc(Name),
emqx_hooks:run(Name, Args).
run_hooks(Name, Args, Acc) ->
ok = emqx_metrics:inc(Name),
emqx_hooks:run_fold(Name, Args, Acc).
-compile({inline, [reply/2, shutdown/2, shutdown/3]}).
reply(Reply, Channel) ->
{reply, Reply, Channel}.
shutdown(success, Channel) ->
shutdown(normal, Channel);
shutdown(Reason, Channel) ->
{shutdown, Reason, Channel}.
shutdown(success, Reply, Channel) ->
shutdown(normal, Reply, Channel);
shutdown(Reason, Reply, Channel) ->
{shutdown, Reason, Reply, Channel}.
proc_tmpl(Tmpl, Vars) ->
Tokens = emqx_placeholder:preproc_tmpl(Tmpl),
emqx_placeholder:proc_tmpl(Tokens, Vars).
%%--------------------------------------------------------------------
%% For CT tests
%%--------------------------------------------------------------------
set_field(Name, Value, Channel) ->
Pos = emqx_utils:index_of(Name, record_info(fields, channel)),
setelement(Pos + 1, Channel, Value).

View File

@ -0,0 +1,96 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
%% Conf modules for emqx-ocpp gateway
-module(emqx_ocpp_conf).
-export([
default_heartbeat_interval/0,
heartbeat_checking_times_backoff/0,
retry_interval/0,
message_format_checking/0,
max_mqueue_len/0,
strit_mode/1,
uptopic/1,
up_reply_topic/0,
up_error_topic/0,
dntopic/0
]).
-define(KEY(K), [gateway, ocpp, K]).
conf(K, Default) ->
emqx_config:get(?KEY(K), Default).
-spec default_heartbeat_interval() -> pos_integer().
default_heartbeat_interval() ->
conf(default_heartbeat_interval, 600).
-spec heartbeat_checking_times_backoff() -> pos_integer().
heartbeat_checking_times_backoff() ->
conf(heartbeat_checking_times_backoff, 1).
-spec strit_mode(upstream | dnstream) -> boolean().
strit_mode(dnstream) ->
dnstream(strit_mode, false);
strit_mode(upstream) ->
upstream(strit_mode, false).
-spec retry_interval() -> pos_integer().
retry_interval() ->
dnstream(retry_interval, 30).
-spec max_mqueue_len() -> pos_integer().
max_mqueue_len() ->
dnstream(max_mqueue_len, 10).
-spec message_format_checking() ->
all
| upstream_only
| dnstream_only
| disable.
message_format_checking() ->
conf(message_format_checking, all).
uptopic(Action) ->
Topic = upstream(topic),
Mapping = upstream(topic_override_mapping, #{}),
maps:get(Action, Mapping, Topic).
up_reply_topic() ->
upstream(reply_topic).
up_error_topic() ->
upstream(error_topic).
dntopic() ->
dnstream(topic).
%%--------------------------------------------------------------------
%% internal funcs
%%--------------------------------------------------------------------
dnstream(K) ->
dnstream(K, undefined).
dnstream(K, Def) ->
emqx_config:get([gateway, ocpp, dnstream, K], Def).
upstream(K) ->
upstream(K, undefined).
upstream(K, Def) ->
emqx_config:get([gateway, ocpp, upstream, K], Def).

View File

@ -0,0 +1,906 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
%% OCPP/WS|WSS Connection
-module(emqx_ocpp_connection).
-include("emqx_ocpp.hrl").
-include_lib("emqx/include/logger.hrl").
-include_lib("emqx/include/types.hrl").
-logger_header("[OCPP/WS]").
-ifdef(TEST).
-compile(export_all).
-compile(nowarn_export_all).
-endif.
%% API
-export([
info/1,
stats/1
]).
-export([
call/2,
call/3
]).
%% WebSocket callbacks
-export([
init/2,
websocket_init/1,
websocket_handle/2,
websocket_info/2,
websocket_close/2,
terminate/3
]).
%% Export for CT
-export([set_field/3]).
-import(
emqx_utils,
[
maybe_apply/2,
start_timer/2
]
).
-record(state, {
%% Peername of the ws connection
peername :: emqx_types:peername(),
%% Sockname of the ws connection
sockname :: emqx_types:peername(),
%% Sock state
sockstate :: emqx_types:sockstate(),
%% Simulate the active_n opt
active_n :: pos_integer(),
%% Piggyback
piggyback :: single | multiple,
%% Limiter
limiter :: maybe(emqx_limiter:limiter()),
%% Limit Timer
limit_timer :: maybe(reference()),
%% Parse State
parse_state :: emqx_ocpp_frame:parse_state(),
%% Serialize options
serialize :: emqx_ocpp_frame:serialize_opts(),
%% Channel
channel :: emqx_ocpp_channel:channel(),
%% GC State
gc_state :: maybe(emqx_gc:gc_state()),
%% Postponed Packets|Cmds|Events
postponed :: list(emqx_types:packet() | ws_cmd() | tuple()),
%% Stats Timer
stats_timer :: disabled | maybe(reference()),
%% Idle Timeout
idle_timeout :: timeout(),
%%% Idle Timer
idle_timer :: maybe(reference()),
%% OOM Policy
oom_policy :: maybe(emqx_types:oom_policy()),
%% Frame Module
frame_mod :: atom(),
%% Channel Module
chann_mod :: atom(),
%% Listener Tag
listener :: listener() | undefined
}).
-type listener() :: {GwName :: atom(), LisType :: atom(), LisName :: atom()}.
-type state() :: #state{}.
-type ws_cmd() :: {active, boolean()} | close.
-define(ACTIVE_N, 100).
-define(INFO_KEYS, [
socktype,
peername,
sockname,
sockstate,
active_n
]).
-define(SOCK_STATS, [
recv_oct,
recv_cnt,
send_oct,
send_cnt
]).
-define(ENABLED(X), (X =/= undefined)).
-dialyzer({no_match, [info/2]}).
-dialyzer({nowarn_function, [websocket_init/1, postpone/2, classify/4]}).
-elvis([
{elvis_style, invalid_dynamic_call, #{ignore => [emqx_ocpp_connection]}}
]).
%%--------------------------------------------------------------------
%% Info, Stats
%%--------------------------------------------------------------------
-spec info(pid() | state()) -> emqx_types:infos().
info(WsPid) when is_pid(WsPid) ->
call(WsPid, info);
info(State = #state{channel = Channel}) ->
ChanInfo = emqx_ocpp_channel:info(Channel),
SockInfo = maps:from_list(
info(?INFO_KEYS, State)
),
ChanInfo#{sockinfo => SockInfo}.
info(Keys, State) when is_list(Keys) ->
[{Key, info(Key, State)} || Key <- Keys];
info(socktype, _State) ->
ws;
info(peername, #state{peername = Peername}) ->
Peername;
info(sockname, #state{sockname = Sockname}) ->
Sockname;
info(sockstate, #state{sockstate = SockSt}) ->
SockSt;
info(active_n, #state{active_n = ActiveN}) ->
ActiveN;
info(channel, #state{chann_mod = ChannMod, channel = Channel}) ->
ChannMod:info(Channel);
info(gc_state, #state{gc_state = GcSt}) ->
maybe_apply(fun emqx_gc:info/1, GcSt);
info(postponed, #state{postponed = Postponed}) ->
Postponed;
info(stats_timer, #state{stats_timer = TRef}) ->
TRef;
info(idle_timeout, #state{idle_timeout = Timeout}) ->
Timeout.
-spec stats(pid() | state()) -> emqx_types:stats().
stats(WsPid) when is_pid(WsPid) ->
call(WsPid, stats);
stats(#state{channel = Channel}) ->
SockStats = emqx_pd:get_counters(?SOCK_STATS),
ChanStats = emqx_ocpp_channel:stats(Channel),
ProcStats = emqx_utils:proc_stats(),
lists:append([SockStats, ChanStats, ProcStats]).
%% kick|discard|takeover
-spec call(pid(), Req :: term()) -> Reply :: term().
call(WsPid, Req) ->
call(WsPid, Req, 5000).
call(WsPid, Req, Timeout) when is_pid(WsPid) ->
Mref = erlang:monitor(process, WsPid),
WsPid ! {call, {self(), Mref}, Req},
receive
{Mref, Reply} ->
erlang:demonitor(Mref, [flush]),
Reply;
{'DOWN', Mref, _, _, Reason} ->
exit(Reason)
after Timeout ->
erlang:demonitor(Mref, [flush]),
exit(timeout)
end.
%%--------------------------------------------------------------------
%% WebSocket callbacks
%%--------------------------------------------------------------------
init(Req, Opts) ->
%% WS Transport Idle Timeout
IdleTimeout = maps:get(idle_timeout, Opts, 7200000),
MaxFrameSize =
case maps:get(max_frame_size, Opts, 0) of
0 -> infinity;
I -> I
end,
Compress = emqx_utils_maps:deep_get([websocket, compress], Opts),
WsOpts = #{
compress => Compress,
max_frame_size => MaxFrameSize,
idle_timeout => IdleTimeout
},
case check_origin_header(Req, Opts) of
{error, Message} ->
?SLOG(error, #{msg => "invaild_origin_header", reason => Message}),
{ok, cowboy_req:reply(403, Req), WsOpts};
ok ->
do_init(Req, Opts, WsOpts)
end.
do_init(Req, Opts, WsOpts) ->
case
emqx_utils:pipeline(
[
fun init_state_and_channel/2,
fun parse_sec_websocket_protocol/2,
fun auth_connect/2
],
[Req, Opts, WsOpts],
undefined
)
of
{error, Reason, _State} ->
{ok, cowboy_req:reply(400, #{}, to_bin(Reason), Req), WsOpts};
{ok, [Resp, Opts, WsOpts], NState} ->
{cowboy_websocket, Resp, [Req, Opts, NState], WsOpts}
end.
init_state_and_channel([Req, Opts, _WsOpts], _State = undefined) ->
{Peername, Peercert} = peername_and_cert(Req, Opts),
Sockname = cowboy_req:sock(Req),
WsCookie =
try
cowboy_req:parse_cookies(Req)
catch
error:badarg ->
?SLOG(error, #{msg => "illegal_cookie"}),
undefined;
Error:Reason ->
?SLOG(error, #{
msg => "failed_to_parse_cookie",
error => Error,
reason => Reason
}),
undefined
end,
ConnInfo = #{
socktype => ws,
peername => Peername,
sockname => Sockname,
peercert => Peercert,
ws_cookie => WsCookie,
conn_mod => ?MODULE
},
Limiter = undeined,
ActiveN = emqx_gateway_utils:active_n(Opts),
Piggyback = emqx_utils_maps:deep_get([websocket, piggyback], Opts, multiple),
ParseState = emqx_ocpp_frame:initial_parse_state(#{}),
Serialize = emqx_ocpp_frame:serialize_opts(),
Channel = emqx_ocpp_channel:init(ConnInfo, Opts),
GcState = emqx_gateway_utils:init_gc_state(Opts),
StatsTimer = emqx_gateway_utils:stats_timer(Opts),
IdleTimeout = emqx_gateway_utils:idle_timeout(Opts),
OomPolicy = emqx_gateway_utils:oom_policy(Opts),
IdleTimer = emqx_utils:start_timer(IdleTimeout, idle_timeout),
emqx_logger:set_metadata_peername(esockd:format(Peername)),
{ok, #state{
peername = Peername,
sockname = Sockname,
sockstate = running,
active_n = ActiveN,
piggyback = Piggyback,
limiter = Limiter,
parse_state = ParseState,
serialize = Serialize,
channel = Channel,
gc_state = GcState,
postponed = [],
stats_timer = StatsTimer,
idle_timeout = IdleTimeout,
idle_timer = IdleTimer,
oom_policy = OomPolicy,
frame_mod = emqx_ocpp_frame,
chann_mod = emqx_ocpp_channel,
listener = maps:get(listener, Opts, undeined)
}}.
peername_and_cert(Req, Opts) ->
case
maps:get(proxy_protocol, Opts, false) andalso
maps:get(proxy_header, Req)
of
#{src_address := SrcAddr, src_port := SrcPort, ssl := SSL} ->
SourceName = {SrcAddr, SrcPort},
%% Notice: Only CN is available in Proxy Protocol V2 additional info
SourceSSL =
case maps:get(cn, SSL, undefined) of
undeined -> nossl;
CN -> [{pp2_ssl_cn, CN}]
end,
{SourceName, SourceSSL};
#{src_address := SrcAddr, src_port := SrcPort} ->
SourceName = {SrcAddr, SrcPort},
{SourceName, nossl};
_ ->
{get_peer(Req, Opts), cowboy_req:cert(Req)}
end.
parse_sec_websocket_protocol([Req, Opts, WsOpts], State) ->
SupportedSubprotocols = emqx_utils_maps:deep_get([websocket, supported_subprotocols], Opts),
FailIfNoSubprotocol = emqx_utils_maps:deep_get([websocket, fail_if_no_subprotocol], Opts),
case cowboy_req:parse_header(<<"sec-websocket-protocol">>, Req) of
undefined ->
case FailIfNoSubprotocol of
true ->
{error, no_subprotocol};
false ->
Picked = list_to_binary(lists:nth(1, SupportedSubprotocols)),
Resp = cowboy_req:set_resp_header(
<<"sec-websocket-protocol">>,
Picked,
Req
),
{ok, [Resp, Opts, WsOpts], State}
end;
Subprotocols ->
NSupportedSubprotocols = [
list_to_binary(Subprotocol)
|| Subprotocol <- SupportedSubprotocols
],
case pick_subprotocol(Subprotocols, NSupportedSubprotocols) of
{ok, Subprotocol} ->
Resp = cowboy_req:set_resp_header(
<<"sec-websocket-protocol">>,
Subprotocol,
Req
),
{ok, [Resp, Opts, WsOpts], State};
{error, no_supported_subprotocol} ->
{error, no_supported_subprotocol}
end
end.
pick_subprotocol([], _SupportedSubprotocols) ->
{error, no_supported_subprotocol};
pick_subprotocol([Subprotocol | Rest], SupportedSubprotocols) ->
case lists:member(Subprotocol, SupportedSubprotocols) of
true ->
{ok, Subprotocol};
false ->
pick_subprotocol(Rest, SupportedSubprotocols)
end.
auth_connect([Req, Opts, _WsOpts], State = #state{channel = Channel}) ->
{Username, Password} =
try
{basic, Username0, Password0} = cowboy_req:parse_header(<<"authorization">>, Req),
{Username0, Password0}
catch
_:_ -> {undefined, undefined}
end,
{ProtoName, ProtoVer} = parse_protocol_name(
cowboy_req:resp_header(<<"sec-websocket-protocol">>, Req)
),
case parse_clientid(Req, Opts) of
{ok, ClientId} ->
case
emqx_ocpp_channel:authenticate(
#{
clientid => ClientId,
username => Username,
password => Password,
proto_name => ProtoName,
proto_ver => ProtoVer
},
Channel
)
of
{ok, NChannel} ->
{ok, State#state{channel = NChannel}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason2} ->
{error, Reason2}
end.
parse_clientid(Req, Opts) ->
PathPrefix = emqx_utils_maps:deep_get([websocket, path], Opts),
[_, ClientId0] = binary:split(
cowboy_req:path(Req),
iolist_to_binary(PathPrefix ++ "/")
),
case uri_string:percent_decode(ClientId0) of
<<>> ->
{error, clientid_cannot_be_empty};
ClientId ->
%% Client Id can not contains '/', '+', '#'
case re:run(ClientId, "[/#\\+]", [{capture, none}]) of
nomatch ->
{ok, ClientId};
_ ->
{error, unsupported_clientid}
end
end.
parse_protocol_name(<<"ocpp1.6">>) ->
{<<"OCPP">>, <<"1.6">>}.
parse_header_fun_origin(Req, Opts) ->
case cowboy_req:header(<<"origin">>, Req) of
undefined ->
case emqx_utils_maps:deep_get([websocket, allow_origin_absence], Opts) of
true -> ok;
false -> {error, origin_header_cannot_be_absent}
end;
Value ->
Origins = emqx_utils_maps:deep_get([websocket, check_origins], Opts, []),
case lists:member(Value, Origins) of
true -> ok;
false -> {error, {origin_not_allowed, Value}}
end
end.
check_origin_header(Req, Opts) ->
case emqx_utils_maps:deep_get([websocket, check_origin_enable], Opts) of
true -> parse_header_fun_origin(Req, Opts);
false -> ok
end.
websocket_init([_Req, _Opts, State]) ->
return(State#state{postponed = [after_init]}).
websocket_handle({text, Data}, State) when is_list(Data) ->
websocket_handle({text, iolist_to_binary(Data)}, State);
websocket_handle({text, Data}, State) ->
?SLOG(debug, #{msg => "raw_bin_received", bin => Data}),
ok = inc_recv_stats(1, iolist_size(Data)),
NState = ensure_stats_timer(State),
return(parse_incoming(Data, NState));
%% Pings should be replied with pongs, cowboy does it automatically
%% Pongs can be safely ignored. Clause here simply prevents crash.
websocket_handle(Frame, State) when Frame =:= ping; Frame =:= pong ->
return(State);
websocket_handle({Frame, _}, State) when Frame =:= ping; Frame =:= pong ->
return(State);
websocket_handle({Frame, _}, State) ->
%% TODO: should not close the ws connection
?SLOG(error, #{msg => "unexpected_frame", frame => Frame}),
shutdown(unexpected_ws_frame, State).
websocket_info({call, From, Req}, State) ->
handle_call(From, Req, State);
websocket_info({cast, rate_limit}, State) ->
Stats = #{
cnt => emqx_pd:reset_counter(incoming_pubs),
oct => emqx_pd:reset_counter(incoming_bytes)
},
NState = postpone({check_gc, Stats}, State),
return(ensure_rate_limit(Stats, NState));
websocket_info({cast, Msg}, State) ->
handle_info(Msg, State);
websocket_info({incoming, Packet}, State) ->
handle_incoming(Packet, State);
websocket_info({outgoing, Packets}, State) ->
return(enqueue(Packets, State));
websocket_info({check_gc, Stats}, State) ->
return(check_oom(run_gc(Stats, State)));
websocket_info(
Deliver = {deliver, _Topic, _Msg},
State = #state{active_n = ActiveN}
) ->
Delivers = [Deliver | emqx_utils:drain_deliver(ActiveN)],
with_channel(handle_deliver, [Delivers], State);
websocket_info(
{timeout, TRef, limit_timeout},
State = #state{limit_timer = TRef}
) ->
NState = State#state{
sockstate = running,
limit_timer = undefined
},
return(enqueue({active, true}, NState));
websocket_info({timeout, TRef, Msg}, State) when is_reference(TRef) ->
handle_timeout(TRef, Msg, State);
websocket_info({shutdown, Reason}, State) ->
shutdown(Reason, State);
websocket_info({stop, Reason}, State) ->
shutdown(Reason, State);
websocket_info(Info, State) ->
handle_info(Info, State).
websocket_close({_, ReasonCode, _Payload}, State) when is_integer(ReasonCode) ->
websocket_close(ReasonCode, State);
websocket_close(Reason, State) ->
?SLOG(debug, #{msg => "websocket_closed", reason => Reason}),
handle_info({sock_closed, Reason}, State).
terminate(Reason, _Req, #state{channel = Channel}) ->
?SLOG(debug, #{msg => "terminated", reason => Reason}),
emqx_ocpp_channel:terminate(Reason, Channel);
terminate(_Reason, _Req, _UnExpectedState) ->
ok.
%%--------------------------------------------------------------------
%% Handle call
%%--------------------------------------------------------------------
handle_call(From, info, State) ->
gen_server:reply(From, info(State)),
return(State);
handle_call(From, stats, State) ->
gen_server:reply(From, stats(State)),
return(State);
handle_call(From, Req, State = #state{channel = Channel}) ->
case emqx_ocpp_channel:handle_call(Req, From, Channel) of
{reply, Reply, NChannel} ->
gen_server:reply(From, Reply),
return(State#state{channel = NChannel});
{shutdown, Reason, Reply, NChannel} ->
gen_server:reply(From, Reply),
shutdown(Reason, State#state{channel = NChannel})
end.
%%--------------------------------------------------------------------
%% Handle Info
%%--------------------------------------------------------------------
handle_info({connack, ConnAck}, State) ->
return(enqueue(ConnAck, State));
handle_info({close, Reason}, State) ->
?SLOG(debug, #{msg => "force_to_close_socket", reason => Reason}),
return(enqueue({close, Reason}, State));
handle_info({event, connected}, State = #state{chann_mod = ChannMod, channel = Channel}) ->
Ctx = ChannMod:info(ctx, Channel),
ClientId = ChannMod:info(clientid, Channel),
emqx_gateway_ctx:insert_channel_info(
Ctx,
ClientId,
info(State),
stats(State)
),
return(State);
handle_info({event, disconnected}, State = #state{chann_mod = ChannMod, channel = Channel}) ->
Ctx = ChannMod:info(ctx, Channel),
ClientId = ChannMod:info(clientid, Channel),
emqx_gateway_ctx:set_chan_info(Ctx, ClientId, info(State)),
emqx_gateway_ctx:connection_closed(Ctx, ClientId),
return(State);
handle_info({event, _Other}, State = #state{chann_mod = ChannMod, channel = Channel}) ->
Ctx = ChannMod:info(ctx, Channel),
ClientId = ChannMod:info(clientid, Channel),
emqx_gateway_ctx:set_chan_info(Ctx, ClientId, info(State)),
emqx_gateway_ctx:set_chan_stats(Ctx, ClientId, stats(State)),
return(State);
handle_info(Info, State) ->
with_channel(handle_info, [Info], State).
%%--------------------------------------------------------------------
%% Handle timeout
%%--------------------------------------------------------------------
handle_timeout(TRef, keepalive, State) when is_reference(TRef) ->
RecvOct = emqx_pd:get_counter(recv_oct),
handle_timeout(TRef, {keepalive, RecvOct}, State);
handle_timeout(
TRef,
emit_stats,
State = #state{
chann_mod = ChannMod,
channel = Channel,
stats_timer = TRef
}
) ->
Ctx = ChannMod:info(ctx, Channel),
ClientId = ChannMod:info(clientid, Channel),
emqx_gateway_ctx:set_chan_stats(Ctx, ClientId, stats(State)),
return(State#state{stats_timer = undefined});
handle_timeout(TRef, TMsg, State) ->
with_channel(handle_timeout, [TRef, TMsg], State).
%%--------------------------------------------------------------------
%% Ensure rate limit
%%--------------------------------------------------------------------
ensure_rate_limit(_Stats, State) ->
State.
%%--------------------------------------------------------------------
%% Run GC, Check OOM
%%--------------------------------------------------------------------
run_gc(Stats, State = #state{gc_state = GcSt}) ->
case ?ENABLED(GcSt) andalso emqx_gc:run(Stats, GcSt) of
false -> State;
{_IsGC, GcSt1} -> State#state{gc_state = GcSt1}
end.
check_oom(State = #state{oom_policy = OomPolicy}) ->
case ?ENABLED(OomPolicy) andalso emqx_utils:check_oom(OomPolicy) of
Shutdown = {shutdown, _Reason} ->
postpone(Shutdown, State);
_Other ->
ok
end,
State.
%%--------------------------------------------------------------------
%% Parse incoming data
%%--------------------------------------------------------------------
parse_incoming(<<>>, State) ->
State;
parse_incoming(Data, State = #state{parse_state = ParseState}) ->
try emqx_ocpp_frame:parse(Data, ParseState) of
{ok, Packet, Rest, NParseState} ->
NState = State#state{parse_state = NParseState},
parse_incoming(Rest, postpone({incoming, Packet}, NState))
catch
error:Reason:Stk ->
?SLOG(
error,
#{
msg => "parse_failed",
data => Data,
reason => Reason,
stacktrace => Stk
}
),
FrameError = {frame_error, Reason},
postpone({incoming, FrameError}, State)
end.
%%--------------------------------------------------------------------
%% Handle incoming packet
%%--------------------------------------------------------------------
handle_incoming(Packet, State = #state{active_n = ActiveN}) ->
ok = inc_incoming_stats(Packet),
NState =
case emqx_pd:get_counter(incoming_pubs) > ActiveN of
true -> postpone({cast, rate_limit}, State);
false -> State
end,
with_channel(handle_in, [Packet], NState);
handle_incoming(FrameError, State) ->
with_channel(handle_in, [FrameError], State).
%%--------------------------------------------------------------------
%% With Channel
%%--------------------------------------------------------------------
with_channel(Fun, Args, State = #state{channel = Channel}) ->
case erlang:apply(emqx_ocpp_channel, Fun, Args ++ [Channel]) of
ok ->
return(State);
{ok, NChannel} ->
return(State#state{channel = NChannel});
{ok, Replies, NChannel} ->
return(postpone(Replies, State#state{channel = NChannel}));
{shutdown, Reason, NChannel} ->
shutdown(Reason, State#state{channel = NChannel});
{shutdown, Reason, Packet, NChannel} ->
NState = State#state{channel = NChannel},
shutdown(Reason, postpone(Packet, NState))
end.
%%--------------------------------------------------------------------
%% Handle outgoing packets
%%--------------------------------------------------------------------
handle_outgoing(Packets, State = #state{active_n = ActiveN, piggyback = Piggyback}) ->
IoData = lists:map(serialize_and_inc_stats_fun(State), Packets),
Oct = iolist_size(IoData),
ok = inc_sent_stats(length(Packets), Oct),
NState =
case emqx_pd:get_counter(outgoing_pubs) > ActiveN of
true ->
Stats = #{
cnt => emqx_pd:reset_counter(outgoing_pubs),
oct => emqx_pd:reset_counter(outgoing_bytes)
},
postpone({check_gc, Stats}, State);
false ->
State
end,
{
case Piggyback of
single -> [{text, IoData}];
multiple -> lists:map(fun(Bin) -> {text, Bin} end, IoData)
end,
ensure_stats_timer(NState)
}.
serialize_and_inc_stats_fun(#state{serialize = Serialize}) ->
fun(Packet) ->
case emqx_ocpp_frame:serialize_pkt(Packet, Serialize) of
<<>> ->
?SLOG(
warning,
#{
msg => "discarded_frame",
reason => "message_too_large",
frame => emqx_ocpp_frame:format(Packet)
}
),
ok = inc_outgoing_stats({error, message_too_large}),
<<>>;
Data ->
?SLOG(debug, #{msg => "raw_bin_sent", bin => Data}),
ok = inc_outgoing_stats(Packet),
Data
end
end.
%%--------------------------------------------------------------------
%% Inc incoming/outgoing stats
%%--------------------------------------------------------------------
-compile(
{inline, [
inc_recv_stats/2,
inc_incoming_stats/1,
inc_outgoing_stats/1,
inc_sent_stats/2
]}
).
inc_recv_stats(Cnt, Oct) ->
inc_counter(incoming_bytes, Oct),
inc_counter(recv_cnt, Cnt),
inc_counter(recv_oct, Oct),
emqx_metrics:inc('bytes.received', Oct).
inc_incoming_stats(Packet) ->
_ = emqx_pd:inc_counter(recv_pkt, 1),
%% assert, all OCCP frame are message
true = emqx_ocpp_frame:is_message(Packet),
inc_counter(recv_msg, 1),
inc_counter('recv_msg.qos1', 1),
inc_counter(incoming_pubs, 1).
inc_outgoing_stats({error, message_too_large}) ->
inc_counter('send_msg.dropped', 1),
inc_counter('send_msg.dropped.too_large', 1);
inc_outgoing_stats(Packet) ->
_ = emqx_pd:inc_counter(send_pkt, 1),
%% assert, all OCCP frames are message
true = emqx_ocpp_frame:is_message(Packet),
inc_counter(send_msg, 1),
inc_counter('send_msg.qos1', 1),
inc_counter(outgoing_pubs, 1).
inc_sent_stats(Cnt, Oct) ->
inc_counter(outgoing_bytes, Oct),
inc_counter(send_cnt, Cnt),
inc_counter(send_oct, Oct),
emqx_metrics:inc('bytes.sent', Oct).
inc_counter(Name, Value) ->
_ = emqx_pd:inc_counter(Name, Value),
ok.
%%--------------------------------------------------------------------
%% Helper functions
%%--------------------------------------------------------------------
-compile({inline, [ensure_stats_timer/1]}).
%%--------------------------------------------------------------------
%% Ensure stats timer
ensure_stats_timer(
State = #state{
idle_timeout = Timeout,
stats_timer = undefined
}
) ->
State#state{stats_timer = start_timer(Timeout, emit_stats)};
ensure_stats_timer(State) ->
State.
-compile({inline, [postpone/2, enqueue/2, return/1, shutdown/2]}).
%%--------------------------------------------------------------------
%% Postpone the packet, cmd or event
%% ocpp frame
postpone(Packet, State) when is_map(Packet) ->
enqueue(Packet, State);
postpone(Event, State) when is_tuple(Event) ->
enqueue(Event, State);
postpone(More, State) when is_list(More) ->
lists:foldl(fun postpone/2, State, More).
enqueue([Packet], State = #state{postponed = Postponed}) ->
State#state{postponed = [Packet | Postponed]};
enqueue(Packets, State = #state{postponed = Postponed}) when
is_list(Packets)
->
State#state{postponed = lists:reverse(Packets) ++ Postponed};
enqueue(Other, State = #state{postponed = Postponed}) ->
State#state{postponed = [Other | Postponed]}.
shutdown(Reason, State = #state{postponed = Postponed}) ->
return(State#state{postponed = [{shutdown, Reason} | Postponed]}).
return(State = #state{postponed = []}) ->
{ok, State};
return(State = #state{postponed = Postponed}) ->
{Packets, Cmds, Events} = classify(Postponed, [], [], []),
ok = lists:foreach(fun trigger/1, Events),
State1 = State#state{postponed = []},
case {Packets, Cmds} of
{[], []} ->
{ok, State1};
{[], Cmds} ->
{Cmds, State1};
{Packets, Cmds} ->
{Frames, State2} = handle_outgoing(Packets, State1),
{Frames ++ Cmds, State2}
end.
classify([], Packets, Cmds, Events) ->
{Packets, Cmds, Events};
classify([Packet | More], Packets, Cmds, Events) when
%% ocpp frame
is_map(Packet)
->
classify(More, [Packet | Packets], Cmds, Events);
classify([Cmd = {active, _} | More], Packets, Cmds, Events) ->
classify(More, Packets, [Cmd | Cmds], Events);
classify([Cmd = {shutdown, _Reason} | More], Packets, Cmds, Events) ->
classify(More, Packets, [Cmd | Cmds], Events);
classify([Cmd = close | More], Packets, Cmds, Events) ->
classify(More, Packets, [Cmd | Cmds], Events);
classify([Cmd = {close, _Reason} | More], Packets, Cmds, Events) ->
classify(More, Packets, [Cmd | Cmds], Events);
classify([Event | More], Packets, Cmds, Events) ->
classify(More, Packets, Cmds, [Event | Events]).
trigger(Event) -> erlang:send(self(), Event).
get_peer(Req, Opts) ->
{PeerAddr, PeerPort} = cowboy_req:peer(Req),
AddrHeader = cowboy_req:header(
emqx_utils_maps:deep_get([websocket, proxy_address_header], Opts), Req, <<>>
),
ClientAddr =
case string:tokens(binary_to_list(AddrHeader), ", ") of
[] ->
undefined;
AddrList ->
hd(AddrList)
end,
Addr =
case inet:parse_address(ClientAddr) of
{ok, A} ->
A;
_ ->
PeerAddr
end,
PortHeader = cowboy_req:header(
emqx_utils_maps:deep_get([websocket, proxy_port_header], Opts), Req, <<>>
),
ClientPort =
case string:tokens(binary_to_list(PortHeader), ", ") of
[] ->
undefined;
PortList ->
hd(PortList)
end,
try
{Addr, list_to_integer(ClientPort)}
catch
_:_ -> {Addr, PeerPort}
end.
to_bin(A) when is_atom(A) -> atom_to_binary(A, utf8);
to_bin(L) when is_list(L) -> list_to_binary(L);
to_bin(B) when is_binary(B) -> B.
%%--------------------------------------------------------------------
%% For CT tests
%%--------------------------------------------------------------------
set_field(Name, Value, State) ->
Pos = emqx_utils:index_of(Name, record_info(fields, state)),
setelement(Pos + 1, State, Value).

View File

@ -0,0 +1,167 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_frame).
-behaviour(emqx_gateway_frame).
-include("emqx_ocpp.hrl").
%% emqx_gateway_frame callbacks
-export([
initial_parse_state/1,
serialize_opts/0,
serialize_pkt/2,
parse/2,
format/1,
type/1,
is_message/1
]).
-type parse_state() :: map().
-type parse_result() ::
{ok, frame(), Rest :: binary(), NewState :: parse_state()}.
-export_type([
parse_state/0,
parse_result/0,
frame/0
]).
-dialyzer({nowarn_function, [format/1]}).
-spec initial_parse_state(map()) -> parse_state().
initial_parse_state(_Opts) ->
#{}.
%% No-TCP-Spliting
-spec parse(binary() | list(), parse_state()) -> parse_result().
parse(Bin, Parser) when is_binary(Bin) ->
case emqx_utils_json:safe_decode(Bin, [return_maps]) of
{ok, Json} ->
parse(Json, Parser);
{error, {Position, Reason}} ->
error(
{badjson, io_lib:format("Invalid json at ~w: ~s", [Position, Reason])}
);
{error, Reason} ->
error(
{badjson, io_lib:format("Invalid json: ~p", [Reason])}
)
end;
%% CALL
parse([?OCPP_MSG_TYPE_ID_CALL, Id, Action, Payload], Parser) ->
Frame = #{
type => ?OCPP_MSG_TYPE_ID_CALL,
id => Id,
action => Action,
payload => Payload
},
case emqx_ocpp_schemas:validate(upstream, Frame) of
ok ->
{ok, Frame, <<>>, Parser};
{error, ReasonStr} ->
error({validation_faliure, Id, ReasonStr})
end;
%% CALLRESULT
parse([?OCPP_MSG_TYPE_ID_CALLRESULT, Id, Payload], Parser) ->
Frame = #{
type => ?OCPP_MSG_TYPE_ID_CALLRESULT,
id => Id,
payload => Payload
},
%% TODO: Validate CALLRESULT frame
%%case emqx_ocpp_schemas:validate(upstream, Frame) of
%% ok ->
%% {ok, Frame, <<>>, Parser};
%% {error, ReasonStr} ->
%% error({validation_faliure, Id, ReasonStr})
%%end;
{ok, Frame, <<>>, Parser};
%% CALLERROR
parse(
[
?OCPP_MSG_TYPE_ID_CALLERROR,
Id,
ErrCode,
ErrDesc,
ErrDetails
],
Parser
) ->
{ok,
#{
type => ?OCPP_MSG_TYPE_ID_CALLERROR,
id => Id,
error_code => ErrCode,
error_desc => ErrDesc,
error_details => ErrDetails
},
<<>>, Parser}.
-spec serialize_opts() -> emqx_gateway_frame:serialize_options().
serialize_opts() ->
#{}.
-spec serialize_pkt(frame(), emqx_gateway_frame:serialize_options()) -> iodata().
serialize_pkt(
#{
id := Id,
type := ?OCPP_MSG_TYPE_ID_CALL,
action := Action,
payload := Payload
},
_Opts
) ->
emqx_utils_json:encode([?OCPP_MSG_TYPE_ID_CALL, Id, Action, Payload]);
serialize_pkt(
#{
id := Id,
type := ?OCPP_MSG_TYPE_ID_CALLRESULT,
payload := Payload
},
_Opts
) ->
emqx_utils_json:encode([?OCPP_MSG_TYPE_ID_CALLRESULT, Id, Payload]);
serialize_pkt(
#{
id := Id,
type := Type,
error_code := ErrCode,
error_desc := ErrDesc
} = Frame,
_Opts
) when
Type == ?OCPP_MSG_TYPE_ID_CALLERROR
->
ErrDetails = maps:get(error_details, Frame, #{}),
emqx_utils_json:encode([Type, Id, ErrCode, ErrDesc, ErrDetails]).
-spec format(frame()) -> string().
format(Frame) ->
serialize_pkt(Frame, #{}).
-spec type(frame()) -> atom().
type(_Frame) ->
%% TODO:
todo.
-spec is_message(frame()) -> boolean().
is_message(_Frame) ->
%% TODO:
true.

View File

@ -0,0 +1,118 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2017-2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
%% copied from emqx_keepalive module, but made some broken changes
-module(emqx_ocpp_keepalive).
-export([
init/1,
init/2,
info/1,
info/2,
check/2,
set/3
]).
-export_type([keepalive/0]).
-elvis([{elvis_style, no_if_expression, disable}]).
-record(keepalive, {
interval :: pos_integer(),
statval :: non_neg_integer(),
repeat :: non_neg_integer(),
max_repeat :: non_neg_integer()
}).
-opaque keepalive() :: #keepalive{}.
%% @doc Init keepalive.
-spec init(Interval :: non_neg_integer()) -> keepalive().
init(Interval) when Interval > 0 ->
init(Interval, 1).
-spec init(Interval :: non_neg_integer(), MaxRepeat :: non_neg_integer()) -> keepalive().
init(Interval, MaxRepeat) when
Interval > 0, MaxRepeat >= 0
->
#keepalive{
interval = Interval,
statval = 0,
repeat = 0,
max_repeat = MaxRepeat
}.
%% @doc Get Info of the keepalive.
-spec info(keepalive()) -> emqx_types:infos().
info(#keepalive{
interval = Interval,
statval = StatVal,
repeat = Repeat,
max_repeat = MaxRepeat
}) ->
#{
interval => Interval,
statval => StatVal,
repeat => Repeat,
max_repeat => MaxRepeat
}.
-spec info(interval | statval | repeat, keepalive()) ->
non_neg_integer().
info(interval, #keepalive{interval = Interval}) ->
Interval;
info(statval, #keepalive{statval = StatVal}) ->
StatVal;
info(repeat, #keepalive{repeat = Repeat}) ->
Repeat;
info(max_repeat, #keepalive{max_repeat = MaxRepeat}) ->
MaxRepeat.
%% @doc Check keepalive.
-spec check(non_neg_integer(), keepalive()) ->
{ok, keepalive()} | {error, timeout}.
check(
NewVal,
KeepAlive = #keepalive{
statval = OldVal,
repeat = Repeat,
max_repeat = MaxRepeat
}
) ->
if
NewVal =/= OldVal ->
{ok, KeepAlive#keepalive{statval = NewVal, repeat = 0}};
Repeat < MaxRepeat ->
{ok, KeepAlive#keepalive{repeat = Repeat + 1}};
true ->
{error, timeout}
end.
%% from mqtt-v3.1.1 specific
%% A Keep Alive value of zero (0) has the effect of turning off the keep alive mechanism.
%% This means that, in this case, the Server is not required
%% to disconnect the Client on the grounds of inactivity.
%% Note that a Server is permitted to disconnect a Client that it determines
%% to be inactive or non-responsive at any time,
%% regardless of the Keep Alive value provided by that Client.
%% Non normative comment
%%The actual value of the Keep Alive is application specific;
%% typically this is a few minutes.
%% The maximum value is (65535s) 18 hours 12 minutes and 15 seconds.
%% @doc Update keepalive's interval
-spec set(interval, non_neg_integer(), keepalive()) -> keepalive().
set(interval, Interval, KeepAlive) when Interval >= 0 andalso Interval =< 65535000 ->
KeepAlive#keepalive{interval = Interval}.

View File

@ -0,0 +1,202 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%--------------------------------------------------------------------
-module(emqx_ocpp_schema).
-include_lib("hocon/include/hoconsc.hrl").
-include_lib("typerefl/include/types.hrl").
-define(DEFAULT_MOUNTPOINT, <<"ocpp/">>).
%% config schema provides
-export([fields/1, desc/1]).
fields(ocpp) ->
[
{mountpoint, emqx_gateway_schema:mountpoint(?DEFAULT_MOUNTPOINT)},
{default_heartbeat_interval,
sc(
emqx_schema:duration_s(),
#{
default => <<"60s">>,
required => true,
desc => ?DESC(default_heartbeat_interval)
}
)},
{heartbeat_checking_times_backoff,
sc(
integer(),
#{
default => 1,
required => false,
desc => ?DESC(heartbeat_checking_times_backoff)
}
)},
{upstream, sc(ref(upstream), #{})},
{dnstream, sc(ref(dnstream), #{})},
{message_format_checking,
sc(
hoconsc:union([all, upstream_only, dnstream_only, disable]),
#{
default => disable,
desc => ?DESC(message_format_checking)
}
)},
{json_schema_dir,
sc(
string(),
#{
default => <<"${application_priv}/schemas">>,
desc => ?DESC(json_schema_dir)
}
)},
{json_schema_id_prefix,
sc(
string(),
#{
default => <<"urn:OCPP:1.6:2019:12:">>,
desc => ?DESC(json_schema_id_prefix)
}
)},
{listeners, sc(ref(ws_listeners), #{})}
] ++ emqx_gateway_schema:gateway_common_options();
fields(ws_listeners) ->
[
{ws, sc(map(name, ref(ws_listener)), #{desc => ?DESC(ws)})},
{wss, sc(map(name, ref(wss_listener)), #{desc => ?DESC(wss)})}
];
fields(ws_listener) ->
emqx_gateway_schema:ws_listener() ++
[{websocket, sc(ref(websocket), #{})}];
fields(wss_listener) ->
emqx_gateway_schema:wss_listener() ++
[{websocket, sc(ref(websocket), #{})}];
fields(websocket) ->
DefaultPath = <<"/ocpp">>,
SubProtocols = <<"ocpp1.6, ocpp2.0">>,
emqx_gateway_schema:ws_opts(DefaultPath, SubProtocols);
fields(upstream) ->
[
{topic,
sc(
string(),
#{
required => true,
default => <<"cp/${cid}">>,
desc => ?DESC(upstream_topic)
}
)},
{topic_override_mapping,
sc(
%% XXX: more clearly type defination
hoconsc:map(name, string()),
#{
required => false,
default => #{},
desc => ?DESC(upstream_topic_override_mapping)
}
)},
{reply_topic,
sc(
string(),
#{
required => true,
default => <<"cp/${cid}/Reply">>,
desc => ?DESC(upstream_reply_topic)
}
)},
{error_topic,
sc(
string(),
#{
required => true,
default => <<"cp/${cid}/Reply">>,
desc => ?DESC(upstream_error_topic)
}
)}
%{awaiting_timeout,
% sc(
% emqx_schema:duration(),
% #{
% required => false,
% default => <<"30s">>,
% desc => ?DESC(upstream_awaiting_timeout)
% }
% )}
];
fields(dnstream) ->
[
%%{strit_mode,
%% sc(
%% boolean(),
%% #{
%% required => false,
%% default => false,
%% desc => ?DESC(dnstream_strit_mode)
%% }
%% )},
{topic,
sc(
string(),
#{
required => true,
default => <<"cs/${cid}">>,
desc => ?DESC(dnstream_topic)
}
)},
%{retry_interval,
% sc(
% emqx_schema:duration(),
% #{
% required => false,
% default => <<"30s">>,
% desc => ?DESC(dnstream_retry_interval)
% }
% )},
{max_mqueue_len,
sc(
integer(),
#{
required => false,
default => 100,
desc => ?DESC(dnstream_max_mqueue_len)
}
)}
].
desc(ocpp) ->
"The OCPP gateway";
desc(upstream) ->
"Upload stream topic to notify third-party system what's messages/events reported by "
"Charge Point. Available placeholders:\n"
"- <code>cid</code>: Charge Point ID\n"
"- <code>clientid</code>: Equal to Charge Point ID\n"
"- <code>action</code>: Message Name in OCPP";
desc(dnstream) ->
"Download stream topic to forward the system message to device. Available placeholders:\n"
"- <code>cid</code>: Charge Point ID\n"
"- <code>clientid</code>: Equal to Charge Point ID\n"
"- <code>action</code>: Message Name in OCPP";
desc(ws_listeners) ->
"Websocket listeners";
desc(ws_listener) ->
"Websocket listener";
desc(wss_listener) ->
"Websocket over TLS listener";
desc(websocket) ->
"Websocket options";
desc(_) ->
undefined.
%%--------------------------------------------------------------------
%% internal functions
sc(Type, Meta) ->
hoconsc:mk(Type, Meta).
map(Name, Type) ->
hoconsc:map(Name, Type).
ref(Field) ->
hoconsc:ref(?MODULE, Field).

View File

@ -0,0 +1,106 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
%% The OCPP messsage validator based on JSON-schema
-module(emqx_ocpp_schemas).
-include("emqx_ocpp.hrl").
-export([
load/0,
validate/2
]).
-spec load() -> ok.
%% @doc The jesse:load_schemas/2 require the caller process to own an ets table.
%% So, please call it in some a long-live process
load() ->
case emqx_ocpp_conf:message_format_checking() of
disable ->
ok;
_ ->
case feedvar(emqx_config:get([gateway, ocpp, json_schema_dir])) of
undefined ->
ok;
Dir ->
ok = jesse:load_schemas(Dir, fun emqx_utils_json:decode/1)
end
end.
-spec validate(upstream | dnstream, emqx_ocpp_frame:frame()) ->
ok
| {error, string()}.
%% FIXME: `action` key is absent in OCPP_MSG_TYPE_ID_CALLRESULT frame
validate(Direction, #{type := Type, action := Action, payload := Payload}) when
Type == ?OCPP_MSG_TYPE_ID_CALL;
Type == ?OCPP_MSG_TYPE_ID_CALLRESULT
->
case emqx_ocpp_conf:message_format_checking() of
all ->
do_validate(schema_id(Type, Action), Payload);
upstream_only when Direction == upstream ->
do_validate(schema_id(Type, Action), Payload);
dnstream_only when Direction == dnstream ->
do_validate(schema_id(Type, Action), Payload);
_ ->
ok
end;
validate(_, #{type := ?OCPP_MSG_TYPE_ID_CALLERROR}) ->
ok.
do_validate(SchemaId, Payload) ->
case jesse:validate(SchemaId, Payload) of
{ok, _} ->
ok;
%% jesse_database:error/0
{error, {database_error, Key, Reason}} ->
{error, format("Validation error: ~s ~s", [Key, Reason])};
%% jesse_error:error/0
{error, [{data_invalid, _Schema, Error, _Data, Path} | _]} ->
{error, format("Validation error: ~s ~s", [Path, Error])};
{error, [{schema_invalid, _Schema, Error} | _]} ->
{error, format("Validation error: schema_invalid ~s", [Error])};
{error, Reason} ->
{error, io_lib:format("Validation error: ~0p", [Reason])}
end.
%%--------------------------------------------------------------------
%% internal funcs
%% @doc support vars:
%% - ${application_priv}
feedvar(undefined) ->
undefined;
feedvar(Path) ->
binary_to_list(
emqx_placeholder:proc_tmpl(
emqx_placeholder:preproc_tmpl(Path),
#{application_priv => code:priv_dir(emqx_ocpp)}
)
).
schema_id(?OCPP_MSG_TYPE_ID_CALL, Action) when is_binary(Action) ->
emqx_config:get([gateway, ocpp, json_schema_id_prefix]) ++
binary_to_list(Action) ++
"Request";
schema_id(?OCPP_MSG_TYPE_ID_CALLRESULT, Action) when is_binary(Action) ->
emqx_config:get([gateway, ocpp, json_schema_id_prefix]) ++
binary_to_list(Action) ++
"Response".
format(Fmt, Args) ->
lists:flatten(io_lib:format(Fmt, Args)).

View File

@ -0,0 +1,51 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
-include_lib("emqx/include/emqx.hrl").
-include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.hrl").
all() ->
emqx_common_test_helpers:all(?MODULE).
init_per_suite(Conf) ->
emqx_ct_helpers:start_apps([emqx_gateway_ocpp], fun set_special_cfg/1),
Conf.
end_per_suite(_Config) ->
emqx_ct_helpers:stop_apps([emqx_gateway_ocpp]).
set_special_cfg(emqx) ->
application:set_env(emqx, allow_anonymous, true),
application:set_env(emqx, enable_acl_cache, false),
LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]),
application:set_env(
emqx,
plugins_loaded_file,
emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)
);
set_special_cfg(_App) ->
ok.
%%--------------------------------------------------------------------
%% Testcases
%%---------------------------------------------------------------------

View File

@ -0,0 +1,38 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_conf_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
all() -> emqx_common_test_helpers:all(?MODULE).
init_per_suite(Conf) ->
Conf.
end_per_suite(_Conf) ->
ok.
%%--------------------------------------------------------------------
%% cases
%%--------------------------------------------------------------------
t_load_unload(_) ->
ok.
t_get_env(_) ->
ok.

View File

@ -0,0 +1,38 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_frame_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
-include_lib("emqx/include/emqx.hrl").
-include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.hrl").
all() ->
emqx_common_test_helpers:all(?MODULE).
init_per_suite(Conf) ->
Conf.
end_per_suite(_Config) ->
ok.
%%--------------------------------------------------------------------
%% cases
%%---------------------------------------------------------------------

View File

@ -0,0 +1,61 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_ocpp_keepalive_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
-include_lib("eunit/include/eunit.hrl").
all() -> emqx_common_test_helpers:all(?MODULE).
t_check(_) ->
Keepalive = emqx_ocpp_keepalive:init(60),
?assertEqual(60, emqx_ocpp_keepalive:info(interval, Keepalive)),
?assertEqual(0, emqx_ocpp_keepalive:info(statval, Keepalive)),
?assertEqual(0, emqx_ocpp_keepalive:info(repeat, Keepalive)),
?assertEqual(1, emqx_ocpp_keepalive:info(max_repeat, Keepalive)),
Info = emqx_ocpp_keepalive:info(Keepalive),
?assertEqual(
#{
interval => 60,
statval => 0,
repeat => 0,
max_repeat => 1
},
Info
),
{ok, Keepalive1} = emqx_ocpp_keepalive:check(1, Keepalive),
?assertEqual(1, emqx_ocpp_keepalive:info(statval, Keepalive1)),
?assertEqual(0, emqx_ocpp_keepalive:info(repeat, Keepalive1)),
{ok, Keepalive2} = emqx_ocpp_keepalive:check(1, Keepalive1),
?assertEqual(1, emqx_ocpp_keepalive:info(statval, Keepalive2)),
?assertEqual(1, emqx_ocpp_keepalive:info(repeat, Keepalive2)),
?assertEqual({error, timeout}, emqx_ocpp_keepalive:check(1, Keepalive2)).
t_check_max_repeat(_) ->
Keepalive = emqx_ocpp_keepalive:init(60, 2),
{ok, Keepalive1} = emqx_ocpp_keepalive:check(1, Keepalive),
?assertEqual(1, emqx_ocpp_keepalive:info(statval, Keepalive1)),
?assertEqual(0, emqx_ocpp_keepalive:info(repeat, Keepalive1)),
{ok, Keepalive2} = emqx_ocpp_keepalive:check(1, Keepalive1),
?assertEqual(1, emqx_ocpp_keepalive:info(statval, Keepalive2)),
?assertEqual(1, emqx_ocpp_keepalive:info(repeat, Keepalive2)),
{ok, Keepalive3} = emqx_ocpp_keepalive:check(1, Keepalive2),
?assertEqual(1, emqx_ocpp_keepalive:info(statval, Keepalive3)),
?assertEqual(2, emqx_ocpp_keepalive:info(repeat, Keepalive3)),
?assertEqual({error, timeout}, emqx_ocpp_keepalive:check(1, Keepalive3)).

View File

@ -126,7 +126,8 @@
emqx_dashboard_rbac,
emqx_dashboard_sso,
emqx_audit,
emqx_gateway_gbt32960
emqx_gateway_gbt32960,
emqx_gateway_ocpp
],
%% must always be of type `load'
ce_business_apps =>

View File

@ -216,7 +216,8 @@ defmodule EMQXUmbrella.MixProject do
:emqx_dashboard_rbac,
:emqx_dashboard_sso,
:emqx_audit,
:emqx_gateway_gbt32960
:emqx_gateway_gbt32960,
:emqx_gateway_ocpp
])
end

View File

@ -112,6 +112,7 @@ is_community_umbrella_app("apps/emqx_dashboard_rbac") -> false;
is_community_umbrella_app("apps/emqx_dashboard_sso") -> false;
is_community_umbrella_app("apps/emqx_audit") -> false;
is_community_umbrella_app("apps/emqx_gateway_gbt32960") -> false;
is_community_umbrella_app("apps/emqx_gateway_ocpp") -> false;
is_community_umbrella_app(_) -> true.
is_jq_supported() ->

View File

@ -0,0 +1,28 @@
##--------------------------------------------------------------------
## Gateway GB/T 32960
##
## Add a GB/T 32960 gateway
##--------------------------------------------------------------------
## Note: This is an example of how to configure this feature
## you should copy and paste the below data into the emqx.conf for working
gateway.gbt32960 {
## When publishing or subscribing, prefix all topics with a mountpoint string.
## It's a way that you can use to implement isolation of message routing between different
## gateway protocols
mountpoint = "gbt32960/"
## Re-send time interval
retry_interval = "8s"
## Re-send max times
max_retry_times = 3
## Max message queue length
message_queue_len = 10
listeners.tcp.default {
bind = "0.0.0.0:7325"
}
}

View File

@ -0,0 +1,64 @@
##--------------------------------------------------------------------
## Gateway OCPP
##
## Add a OCPP-J gateway
##--------------------------------------------------------------------
## Note: This is an example of how to configure this feature
## you should copy and paste the below data into the emqx.conf for working
gateway.ocpp {
## When publishing or subscribing, prefix all topics with a mountpoint string.
## It's a way that you can use to implement isolation of message routing between different
## gateway protocols
mountpoint = "ocpp/"
## The default Heartbeat time interval
default_heartbeat_interval = "60s"
## The backoff for hearbeat checking times
heartbeat_checking_times_backoff = 1
## Whether to enable message format legality checking.
## EMQX checks the message format of the upstream and dnstream against the
## format defined in json-schema.
## When the check fails, emqx will reply with a corresponding answer message.
##
## Enum with:
## - all: check all messages
## - upstream_only: check upstream messages only
## - dnstream_only: check dnstream messages only
## - disable: don't check any messages
message_format_checking = disable
## Upload stream topic to notify third-party system whats messges/events
## reported by Charge Point
##
## Avaiable placeholders:
## - cid: Charge Point ID
## - clientid: Equal to Charge Point ID
## - action: Message Name in OCPP
upstream {
topic = "cp/${clientid}"
## UpStream topic override mapping by Message Name
topic_override_mapping {
#"BootNotification" = "cp/${clientid}/Notify/BootNotification"
}
reply_topic = "cp/${clientid}/Reply"
error_topic = "cp/${clientid}/Reply"
}
dnstream {
## Download stream topic to receive request/control messages from third-party
## system.
##
## This value is a wildcard topic name that subscribed by every connected Charge
## Point.
topic = "cs/${clientid}"
}
listeners.ws.default {
bind = "0.0.0.0:33033"
websocket.path = "/ocpp"
}
}

View File

@ -114,4 +114,84 @@ udp_listener_udp_opts.desc:
udp_listeners.desc:
"""Settings for the UDP listeners."""
fields_ws_opts_path.desc:
"""WebSocket's MQTT protocol path. So the address of EMQX Broker's WebSocket is:
<code>ws://{ip}:{port}/mqtt</code>"""
fields_ws_opts_path.label:
"""WS MQTT Path"""
fields_ws_opts_piggyback.desc:
"""Whether a WebSocket message is allowed to contain multiple MQTT packets."""
fields_ws_opts_piggyback.label:
"""MQTT Piggyback"""
fields_ws_opts_compress.desc:
"""If <code>true</code>, compress WebSocket messages using <code>zlib</code>.<br/>
The configuration items under <code>deflate_opts</code> belong to the compression-related parameter configuration."""
fields_ws_opts_compress.label:
"""Ws compress"""
fields_ws_opts_idle_timeout.desc:
"""Close transport-layer connections from the clients that have not sent MQTT CONNECT message within this interval."""
fields_ws_opts_idle_timeout.label:
"""WS idle timeout"""
fields_ws_opts_max_frame_size.desc:
"""The maximum length of a single MQTT packet."""
fields_ws_opts_max_frame_size.label:
"""Max frame size"""
fields_ws_opts_fail_if_no_subprotocol.desc:
"""If <code>true</code>, the server will return an error when
the client does not carry the <code>Sec-WebSocket-Protocol</code> field.
<br/>Note: WeChat applet needs to disable this verification."""
fields_ws_opts_fail_if_no_subprotocol.label:
"""Fail if no subprotocol"""
fields_ws_opts_supported_subprotocols.desc:
"""Comma-separated list of supported subprotocols."""
fields_ws_opts_supported_subprotocols.label:
"""Supported subprotocols"""
fields_ws_opts_check_origin_enable.desc:
"""If <code>true</code>, <code>origin</code> HTTP header will be
validated against the list of allowed origins configured in <code>check_origins</code>
parameter."""
fields_ws_opts_check_origin_enable.label:
"""Check origin"""
fields_ws_opts_allow_origin_absence.desc:
"""If <code>false</code> and <code>check_origin_enable</code> is
<code>true</code>, the server will reject requests that don't have <code>origin</code>
HTTP header."""
fields_ws_opts_allow_origin_absence.label:
"""Allow origin absence"""
fields_ws_opts_check_origins.desc:
"""List of allowed origins.<br/>See <code>check_origin_enable</code>."""
fields_ws_opts_check_origins.label:
"""Allowed origins"""
fields_ws_opts_proxy_port_header.desc:
"""HTTP header used to pass information about the client port. Relevant when the EMQX cluster is deployed behind a load-balancer."""
fields_ws_opts_proxy_port_header.label:
"""Proxy port header"""
fields_ws_opts_proxy_address_header.desc:
"""HTTP header used to pass information about the client IP address.
Relevant when the EMQX cluster is deployed behind a load-balancer."""
fields_ws_opts_proxy_address_header.label:
"""Proxy address header"""
}

View File

@ -0,0 +1,53 @@
emqx_ocpp_schema {
default_heartbeat_interval.desc:
"""The default Heartbeat time interval"""
heartbeat_checking_times_backoff.desc:
"""The backoff for heartbeat checking times"""
message_format_checking.desc:
"""Whether to enable message format legality checking.
EMQX checks the message format of the upload stream and download stream against the
format defined in json-schema.
When the check fails, emqx will reply with a corresponding answer message.
The checking strategy can be one of the following values:
- <code>all</code>: check all messages
- <code>upstream_only</code>: check upload stream messages only
- <code>dnstream_only</code>: check download stream messages only
- <code>disable</code>: don't check any messages"""
upstream_topic.desc:
"""The topic for Upload stream Call Request messages."""
upstream_topic_override_mapping.desc:
"""Upload stream topic override mapping by Message Name."""
upstream_reply_topic.desc:
"""The topic for Upload stream Reply messages."""
upstream_error_topic.desc:
"""The topic for Upload stream error topic."""
dnstream_topic.desc:
"""Download stream topic to receive request/control messages from third-party system.
This value is a wildcard topic name that subscribed by every connected Charge Point."""
dnstream_max_mqueue_len.desc:
"""The maximum message queue length for download stream message delivery."""
json_schema_dir.desc:
"""JSON Schema directory for OCPP message definitions.
Default: ${application}/priv/schemas"""
json_schema_id_prefix.desc:
"""The ID prefix for the OCPP message schemas."""
ws.desc:
"""Websocket listener."""
wss.desc:
"""Websocket over TLS listener."""
}

View File

@ -289,3 +289,8 @@ Keyspace
OpenTSDB
saml
idp
ocpp
OCPP
dnstream
upstream
priv