Quellcode durchsuchen

feat: port the ocpp gateway from version 4

JianBo He vor 2 Jahren
Ursprung
Commit
7cab269e0b
81 geänderte Dateien mit 5254 neuen und 4 gelöschten Zeilen
  1. 134 2
      apps/emqx_gateway/src/emqx_gateway_schema.erl
  2. 2 0
      apps/emqx_gateway_gbt32960/src/emqx_gbt32960_frame.erl
  3. 23 0
      apps/emqx_gateway_ocpp/.gitignore
  4. 175 0
      apps/emqx_gateway_ocpp/README-cn.md
  5. 94 0
      apps/emqx_gateway_ocpp/README.md
  6. 101 0
      apps/emqx_gateway_ocpp/include/emqx_ocpp.hrl
  7. 16 0
      apps/emqx_gateway_ocpp/priv/schemas/Authorize.json
  8. 40 0
      apps/emqx_gateway_ocpp/priv/schemas/AuthorizeResponse.json
  9. 49 0
      apps/emqx_gateway_ocpp/priv/schemas/BootNotification.json
  10. 30 0
      apps/emqx_gateway_ocpp/priv/schemas/BootNotificationResponse.json
  11. 15 0
      apps/emqx_gateway_ocpp/priv/schemas/CancelReservation.json
  12. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/CancelReservationResponse.json
  13. 24 0
      apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailability.json
  14. 21 0
      apps/emqx_gateway_ocpp/priv/schemas/ChangeAvailabilityResponse.json
  15. 21 0
      apps/emqx_gateway_ocpp/priv/schemas/ChangeConfiguration.json
  16. 22 0
      apps/emqx_gateway_ocpp/priv/schemas/ChangeConfigurationResponse.json
  17. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/ClearCache.json
  18. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/ClearCacheResponse.json
  19. 27 0
      apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfile.json
  20. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/ClearChargingProfileResponse.json
  21. 23 0
      apps/emqx_gateway_ocpp/priv/schemas/DataTransfer.json
  22. 25 0
      apps/emqx_gateway_ocpp/priv/schemas/DataTransferResponse.json
  23. 22 0
      apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotification.json
  24. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/DiagnosticsStatusNotificationResponse.json
  25. 25 0
      apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotification.json
  26. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/FirmwareStatusNotificationResponse.json
  27. 27 0
      apps/emqx_gateway_ocpp/priv/schemas/GetCompositeSchedule.json
  28. 79 0
      apps/emqx_gateway_ocpp/priv/schemas/GetCompositeScheduleResponse.json
  29. 16 0
      apps/emqx_gateway_ocpp/priv/schemas/GetConfiguration.json
  30. 40 0
      apps/emqx_gateway_ocpp/priv/schemas/GetConfigurationResponse.json
  31. 30 0
      apps/emqx_gateway_ocpp/priv/schemas/GetDiagnostics.json
  32. 13 0
      apps/emqx_gateway_ocpp/priv/schemas/GetDiagnosticsResponse.json
  33. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersion.json
  34. 15 0
      apps/emqx_gateway_ocpp/priv/schemas/GetLocalListVersionResponse.json
  35. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/Heartbeat.json
  36. 16 0
      apps/emqx_gateway_ocpp/priv/schemas/HeartbeatResponse.json
  37. 151 0
      apps/emqx_gateway_ocpp/priv/schemas/MeterValues.json
  38. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/MeterValuesResponse.json
  39. 127 0
      apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransaction.json
  40. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/RemoteStartTransactionResponse.json
  41. 15 0
      apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransaction.json
  42. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/RemoteStopTransactionResponse.json
  43. 33 0
      apps/emqx_gateway_ocpp/priv/schemas/ReserveNow.json
  44. 23 0
      apps/emqx_gateway_ocpp/priv/schemas/ReserveNowResponse.json
  45. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/Reset.json
  46. 20 0
      apps/emqx_gateway_ocpp/priv/schemas/ResetResponse.json
  47. 68 0
      apps/emqx_gateway_ocpp/priv/schemas/SendLocalList.json
  48. 22 0
      apps/emqx_gateway_ocpp/priv/schemas/SendLocalListResponse.json
  49. 124 0
      apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfile.json
  50. 21 0
      apps/emqx_gateway_ocpp/priv/schemas/SetChargingProfileResponse.json
  51. 32 0
      apps/emqx_gateway_ocpp/priv/schemas/StartTransaction.json
  52. 44 0
      apps/emqx_gateway_ocpp/priv/schemas/StartTransactionResponse.json
  53. 70 0
      apps/emqx_gateway_ocpp/priv/schemas/StatusNotification.json
  54. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/StatusNotificationResponse.json
  55. 176 0
      apps/emqx_gateway_ocpp/priv/schemas/StopTransaction.json
  56. 37 0
      apps/emqx_gateway_ocpp/priv/schemas/StopTransactionResponse.json
  57. 27 0
      apps/emqx_gateway_ocpp/priv/schemas/TriggerMessage.json
  58. 21 0
      apps/emqx_gateway_ocpp/priv/schemas/TriggerMessageResponse.json
  59. 15 0
      apps/emqx_gateway_ocpp/priv/schemas/UnlockConnector.json
  60. 21 0
      apps/emqx_gateway_ocpp/priv/schemas/UnlockConnectorResponse.json
  61. 27 0
      apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmware.json
  62. 8 0
      apps/emqx_gateway_ocpp/priv/schemas/UpdateFirmwareResponse.json
  63. 3 0
      apps/emqx_gateway_ocpp/rebar.config
  64. 9 0
      apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.app.src
  65. 19 0
      apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.appup.src
  66. 101 0
      apps/emqx_gateway_ocpp/src/emqx_gateway_ocpp.erl
  67. 874 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_channel.erl
  68. 153 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_conf.erl
  69. 890 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_connection.erl
  70. 167 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_frame.erl
  71. 118 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_keepalive.erl
  72. 172 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_schema.erl
  73. 106 0
      apps/emqx_gateway_ocpp/src/emqx_ocpp_schemas.erl
  74. 52 0
      apps/emqx_gateway_ocpp/test/emqx_ocpp_SUITE.erl
  75. 38 0
      apps/emqx_gateway_ocpp/test/emqx_ocpp_conf_SUITE.erl
  76. 39 0
      apps/emqx_gateway_ocpp/test/emqx_ocpp_frame_SUITE.erl
  77. 61 0
      apps/emqx_gateway_ocpp/test/emqx_ocpp_keepalive_SUITE.erl
  78. 2 1
      apps/emqx_machine/priv/reboot_lists.eterm
  79. 2 1
      mix.exs
  80. 1 0
      rebar.config.erl
  81. 64 0
      rel/config/ee-examples/gateway.ocpp.conf.example

+ 134 - 2
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}} ->

+ 2 - 0
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.

+ 23 - 0
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

+ 175 - 0
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:
+```

+ 94 - 0
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

+ 101 - 0
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
+}).

+ 16 - 0
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"
+    ]
+}

+ 40 - 0
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"
+    ]
+}

+ 49 - 0
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"
+    ]
+}

+ 30 - 0
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"
+    ]
+}

+ 15 - 0
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"
+    ]
+}

+ 20 - 0
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"
+    ]
+}

+ 24 - 0
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"
+    ]
+}

+ 21 - 0
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"
+    ]
+}

+ 21 - 0
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"
+    ]
+}

+ 22 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 20 - 0
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"
+    ]
+}

+ 27 - 0
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
+}

+ 20 - 0
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"
+    ]
+}

+ 23 - 0
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"
+    ]
+}

+ 25 - 0
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"
+    ]
+}

+ 22 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 25 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 27 - 0
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"
+    ]
+}

+ 79 - 0
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"
+    ]
+}

+ 16 - 0
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
+}

+ 40 - 0
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
+}

+ 30 - 0
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"
+    ]
+}

+ 13 - 0
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
+}

+ 8 - 0
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
+}

+ 15 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 16 - 0
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"
+    ]
+}

+ 151 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 127 - 0
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"
+    ]
+}

+ 20 - 0
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"
+    ]
+}

+ 15 - 0
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"
+    ]
+}

+ 20 - 0
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"
+    ]
+}

+ 33 - 0
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"
+    ]
+}

+ 23 - 0
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"
+    ]
+}

+ 20 - 0
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"
+    ]
+}

+ 20 - 0
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"
+    ]
+}

+ 68 - 0
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"
+    ]
+}

+ 22 - 0
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"
+    ]
+}

+ 124 - 0
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"
+    ]
+}

+ 21 - 0
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"
+    ]
+}

+ 32 - 0
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"
+    ]
+}

+ 44 - 0
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"
+    ]
+}

+ 70 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 176 - 0
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"
+    ]
+}

+ 37 - 0
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
+}

+ 27 - 0
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"
+    ]
+}

+ 21 - 0
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"
+    ]
+}

+ 15 - 0
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"
+    ]
+}

+ 21 - 0
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"
+    ]
+}

+ 27 - 0
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"
+    ]
+}

+ 8 - 0
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
+}

+ 3 - 0
apps/emqx_gateway_ocpp/rebar.config

@@ -0,0 +1,3 @@
+{deps, [
+    {jesse, "1.7.0"}
+]}.

+ 9 - 0
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, []}
+]}.

+ 19 - 0
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,[]}
+   ]},
+  {<<".*">>, []}
+ ]
+}.

+ 101 - 0
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).

+ 874 - 0
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).

+ 153 - 0
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})).

+ 890 - 0
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).

+ 167 - 0
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.

+ 118 - 0
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}.

+ 172 - 0
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).

+ 106 - 0
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)).

+ 52 - 0
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
+%%---------------------------------------------------------------------

+ 38 - 0
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.

+ 39 - 0
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
+%%---------------------------------------------------------------------

+ 61 - 0
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)).

+ 2 - 1
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 =>

+ 2 - 1
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
 

+ 1 - 0
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() ->

+ 64 - 0
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"
+    }
+}