diff --git a/apps/emqx_gateway/src/emqx_gateway_schema.erl b/apps/emqx_gateway/src/emqx_gateway_schema.erl index c7c5dfd90..607cac27d 100644 --- a/apps/emqx_gateway/src/emqx_gateway_schema.erl +++ b/apps/emqx_gateway/src/emqx_gateway_schema.erl @@ -56,6 +56,8 @@ -export([mountpoint/0, mountpoint/1, gateway_common_options/0, gateway_schema/1, gateway_names/0]). +-export([ws_listener/2, wss_listener/2]). + namespace() -> gateway. tags() -> @@ -250,6 +252,137 @@ mountpoint(Default) -> } ). +ws_listener(DefaultPath, DefaultSubProtocols) when + is_binary(DefaultPath), is_binary(DefaultSubProtocols) +-> + [ + {acceptors, sc(integer(), #{default => 16, desc => ?DESC(tcp_listener_acceptors)})} + ] ++ + ws_opts(DefaultPath, DefaultSubProtocols) ++ + tcp_opts() ++ + proxy_protocol_opts() ++ + common_listener_opts(). + +wss_listener(DefaultPath, DefaultSubProtocols) when + is_binary(DefaultPath), is_binary(DefaultSubProtocols) +-> + ws_listener(DefaultPath, DefaultSubProtocols) ++ + [ + {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) -> + [ + {"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("deflate_opts"), + #{} + )} + ]. + common_listener_opts() -> [ {enable, @@ -328,7 +461,7 @@ proxy_protocol_opts() -> sc( duration(), #{ - default => <<"15s">>, + default => <<"3s">>, desc => ?DESC(tcp_listener_proxy_protocol_timeout) } )} @@ -337,7 +470,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}} -> diff --git a/apps/emqx_gateway_gbt32960/src/emqx_gbt32960_frame.erl b/apps/emqx_gateway_gbt32960/src/emqx_gbt32960_frame.erl index 8fd95a6b5..f43749a7c 100644 --- a/apps/emqx_gateway_gbt32960/src/emqx_gbt32960_frame.erl +++ b/apps/emqx_gateway_gbt32960/src/emqx_gbt32960_frame.erl @@ -798,9 +798,11 @@ format(Msg) -> io_lib:format("~p", [Msg]). type(_) -> + %% TODO: gbt32960. is_message(#frame{}) -> + %% TODO: true; is_message(_) -> false. diff --git a/apps/emqx_gateway_ocpp/.gitignore b/apps/emqx_gateway_ocpp/.gitignore new file mode 100644 index 000000000..1d76e717f --- /dev/null +++ b/apps/emqx_gateway_ocpp/.gitignore @@ -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 diff --git a/apps/emqx_gateway_ocpp/README-cn.md b/apps/emqx_gateway_ocpp/README-cn.md new file mode 100644 index 000000000..467ab5b8e --- /dev/null +++ b/apps/emqx_gateway_ocpp/README-cn.md @@ -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 ID:Charge 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: +``` diff --git a/apps/emqx_gateway_ocpp/README.md b/apps/emqx_gateway_ocpp/README.md new file mode 100644 index 000000000..fb1041861 --- /dev/null +++ b/apps/emqx_gateway_ocpp/README.md @@ -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 diff --git a/apps/emqx_gateway_ocpp/include/emqx_ocpp.hrl b/apps/emqx_gateway_ocpp/include/emqx_ocpp.hrl new file mode 100644 index 000000000..6e0420f42 --- /dev/null +++ b/apps/emqx_gateway_ocpp/include/emqx_ocpp.hrl @@ -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 +}). diff --git a/apps/emqx_gateway_ocpp/priv/schemas/Authorize.json b/apps/emqx_gateway_ocpp/priv/schemas/Authorize.json new file mode 100644 index 000000000..cf7869027 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/Authorize.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/AuthorizeResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/AuthorizeResponse.json new file mode 100644 index 000000000..e76940674 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/AuthorizeResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/BootNotification.json b/apps/emqx_gateway_ocpp/priv/schemas/BootNotification.json new file mode 100644 index 000000000..13f145580 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/BootNotification.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/BootNotificationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/BootNotificationResponse.json new file mode 100644 index 000000000..9c5a7ee28 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/BootNotificationResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/CancelReservation.json b/apps/emqx_gateway_ocpp/priv/schemas/CancelReservation.json new file mode 100644 index 000000000..4cafe4027 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/CancelReservation.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/CancelReservationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/CancelReservationResponse.json new file mode 100644 index 000000000..28f604658 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/CancelReservationResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailability.json b/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailability.json new file mode 100644 index 000000000..b67a7d2ef --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailability.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailabilityResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailabilityResponse.json new file mode 100644 index 000000000..7aa9b87f5 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailabilityResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfiguration.json b/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfiguration.json new file mode 100644 index 000000000..5e0c61cc2 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfiguration.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfigurationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfigurationResponse.json new file mode 100644 index 000000000..4c31c6a47 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ChangeConfigurationResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ClearCache.json b/apps/emqx_gateway_ocpp/priv/schemas/ClearCache.json new file mode 100644 index 000000000..50f7d8c74 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ClearCache.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ClearCacheResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ClearCacheResponse.json new file mode 100644 index 000000000..b1e8917eb --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ClearCacheResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfile.json b/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfile.json new file mode 100644 index 000000000..f4d1c537e --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfile.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfileResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfileResponse.json new file mode 100644 index 000000000..b835ba720 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfileResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/DataTransfer.json b/apps/emqx_gateway_ocpp/priv/schemas/DataTransfer.json new file mode 100644 index 000000000..16d4a55dc --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/DataTransfer.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/DataTransferResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/DataTransferResponse.json new file mode 100644 index 000000000..490cd9e25 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/DataTransferResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotification.json b/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotification.json new file mode 100644 index 000000000..468094abe --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotification.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotificationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotificationResponse.json new file mode 100644 index 000000000..5448c83fe --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotificationResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotification.json b/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotification.json new file mode 100644 index 000000000..1842e4b1c --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotification.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotificationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotificationResponse.json new file mode 100644 index 000000000..d3015f096 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotificationResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeSchedule.json b/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeSchedule.json new file mode 100644 index 000000000..002cd5441 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeSchedule.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeScheduleResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeScheduleResponse.json new file mode 100644 index 000000000..7fd5a2f8e --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetCompositeScheduleResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetConfiguration.json b/apps/emqx_gateway_ocpp/priv/schemas/GetConfiguration.json new file mode 100644 index 000000000..c5682a90f --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetConfiguration.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetConfigurationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/GetConfigurationResponse.json new file mode 100644 index 000000000..eaaa4561f --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetConfigurationResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnostics.json b/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnostics.json new file mode 100644 index 000000000..227ceb91f --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnostics.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnosticsResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnosticsResponse.json new file mode 100644 index 000000000..62c229b31 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetDiagnosticsResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersion.json b/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersion.json new file mode 100644 index 000000000..1e4cf5f3b --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersion.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersionResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersionResponse.json new file mode 100644 index 000000000..e95d70f6e --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersionResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/Heartbeat.json b/apps/emqx_gateway_ocpp/priv/schemas/Heartbeat.json new file mode 100644 index 000000000..836016cde --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/Heartbeat.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/HeartbeatResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/HeartbeatResponse.json new file mode 100644 index 000000000..6efbdebdf --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/HeartbeatResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/MeterValues.json b/apps/emqx_gateway_ocpp/priv/schemas/MeterValues.json new file mode 100644 index 000000000..9b3e2b513 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/MeterValues.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/MeterValuesResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/MeterValuesResponse.json new file mode 100644 index 000000000..2c721aa43 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/MeterValuesResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransaction.json b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransaction.json new file mode 100644 index 000000000..f6e62def5 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransaction.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransactionResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransactionResponse.json new file mode 100644 index 000000000..6a5b35cec --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransactionResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransaction.json b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransaction.json new file mode 100644 index 000000000..ee8945806 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransaction.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransactionResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransactionResponse.json new file mode 100644 index 000000000..a34f1306d --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransactionResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ReserveNow.json b/apps/emqx_gateway_ocpp/priv/schemas/ReserveNow.json new file mode 100644 index 000000000..e376f965a --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ReserveNow.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ReserveNowResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ReserveNowResponse.json new file mode 100644 index 000000000..cec50a907 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ReserveNowResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/Reset.json b/apps/emqx_gateway_ocpp/priv/schemas/Reset.json new file mode 100644 index 000000000..bb96eab64 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/Reset.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/ResetResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/ResetResponse.json new file mode 100644 index 000000000..3e5cdab6a --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/ResetResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/SendLocalList.json b/apps/emqx_gateway_ocpp/priv/schemas/SendLocalList.json new file mode 100644 index 000000000..bbad208b8 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/SendLocalList.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/SendLocalListResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/SendLocalListResponse.json new file mode 100644 index 000000000..b2d90c70f --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/SendLocalListResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfile.json b/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfile.json new file mode 100644 index 000000000..b4fea818e --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfile.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfileResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfileResponse.json new file mode 100644 index 000000000..efa608266 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfileResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StartTransaction.json b/apps/emqx_gateway_ocpp/priv/schemas/StartTransaction.json new file mode 100644 index 000000000..fbf459042 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StartTransaction.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StartTransactionResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/StartTransactionResponse.json new file mode 100644 index 000000000..7ac56db44 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StartTransactionResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StatusNotification.json b/apps/emqx_gateway_ocpp/priv/schemas/StatusNotification.json new file mode 100644 index 000000000..96b9ecae5 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StatusNotification.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StatusNotificationResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/StatusNotificationResponse.json new file mode 100644 index 000000000..4026341d2 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StatusNotificationResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StopTransaction.json b/apps/emqx_gateway_ocpp/priv/schemas/StopTransaction.json new file mode 100644 index 000000000..f79c6a236 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StopTransaction.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/StopTransactionResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/StopTransactionResponse.json new file mode 100644 index 000000000..dddbac0dc --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/StopTransactionResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessage.json b/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessage.json new file mode 100644 index 000000000..29f5b88db --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessage.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessageResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessageResponse.json new file mode 100644 index 000000000..6342257f5 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/TriggerMessageResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnector.json b/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnector.json new file mode 100644 index 000000000..ffbce29f8 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnector.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnectorResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnectorResponse.json new file mode 100644 index 000000000..8a5371f11 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/UnlockConnectorResponse.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmware.json b/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmware.json new file mode 100644 index 000000000..af8172c39 --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmware.json @@ -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" + ] +} diff --git a/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmwareResponse.json b/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmwareResponse.json new file mode 100644 index 000000000..bd81ca88f --- /dev/null +++ b/apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmwareResponse.json @@ -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 +} diff --git a/apps/emqx_gateway_ocpp/rebar.config b/apps/emqx_gateway_ocpp/rebar.config new file mode 100644 index 000000000..a97138dfa --- /dev/null +++ b/apps/emqx_gateway_ocpp/rebar.config @@ -0,0 +1,3 @@ +{deps, [ + {jesse, "1.7.0"} +]}. diff --git a/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.app.src b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.app.src new file mode 100644 index 000000000..4ba28a40d --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.app.src @@ -0,0 +1,9 @@ +{application, emqx_gateway_ocpp, [ + {description, "OCPP-J 1.6 Gateway for EMQX"}, + {vsn, "5.0.0"}, + {registered, []}, + {applications, [kernel, stdlib, jesse, emqx, emqx_gateway]}, + {env, []}, + {modules, []}, + {links, []} +]}. diff --git a/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.appup.src b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.appup.src new file mode 100644 index 000000000..454a37b02 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.appup.src @@ -0,0 +1,19 @@ +%% -*- mode: erlang -*- +{VSN, + [{"4.4.1",[ + {load_module,emqx_ocpp_connection,brutal_purge,soft_purge,[]} + ]}, + {"4.4.0",[ + {load_module,emqx_ocpp_connection,brutal_purge,soft_purge,[]} + ]}, + {<<".*">>, []} + ], + [{"4.4.1",[ + {load_module,emqx_ocpp_connection,brutal_purge,soft_purge,[]} + ]}, + {"4.4.0",[ + {load_module,emqx_ocpp_connection,brutal_purge,soft_purge,[]} + ]}, + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.erl b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.erl new file mode 100644 index 000000000..68a374d9d --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.erl @@ -0,0 +1,101 @@ +%%-------------------------------------------------------------------- +%% 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 + }, + 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). diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_channel.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_channel.erl new file mode 100644 index 000000000..53c3a22ef --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_channel.erl @@ -0,0 +1,874 @@ +%%-------------------------------------------------------------------- +%% 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(emqx_session:session()), + %% 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() :: emqx_ocpp_frame:frame() | 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{session = Session}) -> + emqx_utils:maybe_apply(fun emqx_session:info/1, Session); +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(timers, #channel{timers = Timers}) -> + Timers. + +-spec stats(channel()) -> emqx_types:stats(). +stats(#channel{session = Session}) -> + lists:append(emqx_session:stats(Session), emqx_pd:get_counters(?CHANNEL_METRICS)). + +%%-------------------------------------------------------------------- +%% Init the channel +%%-------------------------------------------------------------------- + +-spec init(emqx_types:conninfo(), proplists:proplist()) -> 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 + }, + conninfo = #{proto_ver := ProtoVer} + } +) when + is_map(Frame) +-> + Topic = upstream_topic(Frame, Channel), + 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), + emqx_placeholder:proc_tmpl( + emqx_ocpp_conf:uptopic(Action), + Vars#{action => Action} + ); + ?OCPP_MSG_TYPE_ID_CALLRESULT -> + emqx_placeholder:proc_tmpl(emqx_ocpp_conf:up_reply_topic(), Vars); + ?OCPP_MSG_TYPE_ID_CALLERROR -> + emqx_placeholder: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}), + {noreply, 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} = ClientInfo, + session = Session + } +) -> + TopicTokens = emqx_ocpp_conf:dntopic(), + SubOpts = #{rh => 0, rap => 0, nl => 0, qos => ?QOS_1}, + Topic = emqx_placeholder:proc_tmpl( + TopicTokens, + #{ + clientid => ClientId, + cid => ClientId + } + ), + {ok, NSession} = emqx_session:subscribe( + ClientInfo, + Topic, + SubOpts, + Session + ), + Channel#channel{session = NSession}. + +%%-------------------------------------------------------------------- +%% 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{session = undefined}) -> + ok; +run_terminate_hook(Reason, #channel{clientinfo = ClientInfo, session = Session}) -> + emqx_session:terminate(ClientInfo, Reason, Session). + +%%-------------------------------------------------------------------- +%% 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 = #{clientid := ClientId} + } +) -> + NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, + ok = run_hooks('client.disconnected', [ClientInfo, Reason, NConnInfo]), + emqx_cm:unregister_channel(ClientId), + 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}. + +%%-------------------------------------------------------------------- +%% For CT tests +%%-------------------------------------------------------------------- + +set_field(Name, Value, Channel) -> + Pos = emqx_utils:index_of(Name, record_info(fields, channel)), + setelement(Pos + 1, Channel, Value). diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_conf.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_conf.erl new file mode 100644 index 000000000..34e8ba763 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_conf.erl @@ -0,0 +1,153 @@ +%%-------------------------------------------------------------------- +%% 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([ + load/1, + unload/0, + get_env/1, + get_env/2 +]). + +-export([ + default_heartbeat_interval/0, + heartbeat_checking_times_backoff/0, + retry_interval/0, + awaiting_timeout/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(Key), {?MODULE, Key}). + +load(Confs) -> + lists:foreach(fun({K, V}) -> store(K, V) end, Confs). + +get_env(K) -> + get_env(K, undefined). + +get_env(K, Default) -> + try + persistent_term:get(?KEY(K)) + catch + error:badarg -> + Default + end. + +-spec default_heartbeat_interval() -> pos_integer(). +default_heartbeat_interval() -> + get_env(default_heartbeat_interval, 600). + +-spec heartbeat_checking_times_backoff() -> pos_integer(). +heartbeat_checking_times_backoff() -> + get_env(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 awaiting_timeout() -> pos_integer(). +awaiting_timeout() -> + upstream(awaiting_timeout, 30). + +-spec message_format_checking() -> + all + | upstream_only + | dnstream_only + | disable. +message_format_checking() -> + get_env(message_format_checking, all). + +uptopic(Action) -> + Topic = upstream(topic), + Mapping = upstream(mapping, #{}), + maps:get(Action, Mapping, Topic). + +up_reply_topic() -> + upstream(reply_topic). + +up_error_topic() -> + upstream(error_topic). + +dntopic() -> + dnstream(topic). + +-spec unload() -> ok. +unload() -> + lists:foreach( + fun + ({?KEY(K), _}) -> persistent_term:erase(?KEY(K)); + (_) -> ok + end, + persistent_term:get() + ). + +%%-------------------------------------------------------------------- +%% internal funcs +%%-------------------------------------------------------------------- + +dnstream(K) -> + dnstream(K, undefined). + +dnstream(K, Def) -> + L = get_env(dnstream, []), + proplists:get_value(K, L, Def). + +upstream(K) -> + upstream(K, undefined). + +upstream(K, Def) -> + L = get_env(upstream, []), + proplists:get_value(K, L, Def). + +store(upstream, L) -> + L1 = preproc([topic, reply_topic, error_topic], L), + Mapping = proplists:get_value(mapping, L1, #{}), + NMappings = maps:map( + fun(_, V) -> emqx_placeholder:preproc_tmpl(V) end, + Mapping + ), + L2 = lists:keyreplace(mapping, 1, L1, {mapping, NMappings}), + persistent_term:put(?KEY(upstream), L2); +store(dnstream, L) -> + L1 = preproc([topic], L), + persistent_term:put(?KEY(dnstream), L1); +store(K, V) -> + persistent_term:put(?KEY(K), V). + +preproc([], L) -> + L; +preproc([Key | More], L) -> + Val0 = proplists:get_value(Key, L), + Val = emqx_placeholder:preproc_tmpl(Val0), + preproc(More, lists:keyreplace(Key, 1, L, {Key, Val})). diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_connection.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_connection.erl new file mode 100644 index 000000000..0a97f92a9 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_connection.erl @@ -0,0 +1,890 @@ +%%-------------------------------------------------------------------- +%% 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]}). + +%%-------------------------------------------------------------------- +%% 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 = proplists:get_value(idle_timeout, Opts, 7200000), + MaxFrameSize = + case proplists:get_value(max_frame_size, Opts, 0) of + 0 -> infinity; + I -> I + end, + Compress = proplists:get_bool(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 = proplists:get_value(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 + proplists:get_bool(proxy_protocol, Opts) 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 = proplists:get_value(supported_subprotocols, Opts), + FailIfNoSubprotocol = proplists:get_value(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 = proplists:get_value(ocpp_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 proplists:get_bool(allow_origin_absence, Opts) of + true -> ok; + false -> {error, origin_header_cannot_be_absent} + end; + Value -> + Origins = proplists:get_value(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 proplists:get_bool(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{channel = Channel}) -> + ClientId = emqx_ocpp_channel:info(clientid, Channel), + emqx_cm:insert_channel_info(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{ + channel = Channel, + stats_timer = TRef + } +) -> + ClientId = emqx_ocpp_channel:info(clientid, Channel), + emqx_cm:set_chan_stats(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(proplists:get_value(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(proplists:get_value(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). diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_frame.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_frame.erl new file mode 100644 index 000000000..d404067e1 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_frame.erl @@ -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. diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_keepalive.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_keepalive.erl new file mode 100644 index 000000000..534b88822 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_keepalive.erl @@ -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}. diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_schema.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_schema.erl new file mode 100644 index 000000000..a30f17b5d --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_schema.erl @@ -0,0 +1,172 @@ +%%-------------------------------------------------------------------- +%% 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 => true, + desc => ?DESC(heartbeat_checking_times_backoff) + } + )}, + {upstream, sc(ref(upstream), #{desc => ?DESC(upstream)})}, + {dnstream, sc(ref(dnstream), #{desc => ?DESC(dnstream)})}, + {message_format_checking, + sc( + hoconsc:union([all, upstream_only, dnstream_only, disable]), + #{ + default => all, + 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(listeners), #{desc => ?DESC(listeners)})} + ] ++ emqx_gateway_schema:gateway_common_options(); +fields(listeners) -> + DefaultPath = <<"/ocpp">>, + SubProtocols = <<"ocpp1.6, ocpp2.0">>, + [ + {ws, emqx_gateway_schema:ws_listener(DefaultPath, SubProtocols)}, + {wss, emqx_gateway_schema:wss_listener(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(string(), 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(_) -> + undefined. + +%%-------------------------------------------------------------------- +%% internal functions + +sc(Type, Meta) -> + hoconsc:mk(Type, Meta). + +ref(Field) -> + hoconsc:ref(?MODULE, Field). diff --git a/apps/emqx_gateway_ocpp/src/emqx_ocpp_schemas.erl b/apps/emqx_gateway_ocpp/src/emqx_ocpp_schemas.erl new file mode 100644 index 000000000..fc5cc32e7 --- /dev/null +++ b/apps/emqx_gateway_ocpp/src/emqx_ocpp_schemas.erl @@ -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_ocpp_conf:get_env(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_ocpp_conf:get_env(json_schema_id_prefix) ++ + binary_to_list(Action) ++ + "Request"; +schema_id(?OCPP_MSG_TYPE_ID_CALLRESULT, Action) when is_binary(Action) -> + emqx_ocpp_conf:get_env(json_schema_id_prefix) ++ + binary_to_list(Action) ++ + "Response". + +format(Fmt, Args) -> + lists:flatten(io_lib:format(Fmt, Args)). diff --git a/apps/emqx_gateway_ocpp/test/emqx_ocpp_SUITE.erl b/apps/emqx_gateway_ocpp/test/emqx_ocpp_SUITE.erl new file mode 100644 index 000000000..b8b1ebb48 --- /dev/null +++ b/apps/emqx_gateway_ocpp/test/emqx_ocpp_SUITE.erl @@ -0,0 +1,52 @@ +%%-------------------------------------------------------------------- +%% 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_tcp.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Conf) -> + emqx_ct_helpers:start_apps([emqx_ocpp], fun set_special_cfg/1), + Conf. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_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 +%%--------------------------------------------------------------------- diff --git a/apps/emqx_gateway_ocpp/test/emqx_ocpp_conf_SUITE.erl b/apps/emqx_gateway_ocpp/test/emqx_ocpp_conf_SUITE.erl new file mode 100644 index 000000000..88acb23f8 --- /dev/null +++ b/apps/emqx_gateway_ocpp/test/emqx_ocpp_conf_SUITE.erl @@ -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_ct:all(?MODULE). + +init_per_suite(Conf) -> + Conf. + +end_per_suite(_Conf) -> + ok. + +%%-------------------------------------------------------------------- +%% cases +%%-------------------------------------------------------------------- + +t_load_unload(_) -> + ok. + +t_get_env(_) -> + ok. diff --git a/apps/emqx_gateway_ocpp/test/emqx_ocpp_frame_SUITE.erl b/apps/emqx_gateway_ocpp/test/emqx_ocpp_frame_SUITE.erl new file mode 100644 index 000000000..709527176 --- /dev/null +++ b/apps/emqx_gateway_ocpp/test/emqx_ocpp_frame_SUITE.erl @@ -0,0 +1,39 @@ +%%-------------------------------------------------------------------- +%% 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_tcp.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Conf) -> + Conf. + +end_per_suite(_Config) -> + ok. + +%%-------------------------------------------------------------------- +%% cases +%%--------------------------------------------------------------------- diff --git a/apps/emqx_gateway_ocpp/test/emqx_ocpp_keepalive_SUITE.erl b/apps/emqx_gateway_ocpp/test/emqx_ocpp_keepalive_SUITE.erl new file mode 100644 index 000000000..875af4fb2 --- /dev/null +++ b/apps/emqx_gateway_ocpp/test/emqx_ocpp_keepalive_SUITE.erl @@ -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_ct: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)). diff --git a/apps/emqx_machine/priv/reboot_lists.eterm b/apps/emqx_machine/priv/reboot_lists.eterm index cb612d3a1..bb9fc91a6 100644 --- a/apps/emqx_machine/priv/reboot_lists.eterm +++ b/apps/emqx_machine/priv/reboot_lists.eterm @@ -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 => diff --git a/mix.exs b/mix.exs index b4652c370..31c066207 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/rebar.config.erl b/rebar.config.erl index 2cb74a909..3fd682e63 100644 --- a/rebar.config.erl +++ b/rebar.config.erl @@ -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() -> diff --git a/rel/config/ee-examples/gateway.ocpp.conf.example b/rel/config/ee-examples/gateway.ocpp.conf.example new file mode 100644 index 000000000..60f1d7839 --- /dev/null +++ b/rel/config/ee-examples/gateway.ocpp.conf.example @@ -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" + path = "/ocpp" + } +}