From d9833e63409ac94ade2776cf99eb5bf1af7df802 Mon Sep 17 00:00:00 2001 From: anlicheng <244108715@qq.com> Date: Wed, 13 May 2026 14:37:51 +0800 Subject: [PATCH] init project --- .args.yaml | 26 + .gitignore | 20 + .version | 1 + Dockerfile | 3 + LICENSE | 191 +++ README.md | 9 + apps/power_gateway/include/emqtt.hrl | 535 +++++++ apps/power_gateway/include/power_gateway.hrl | 0 apps/power_gateway/src/efka_client.erl | 431 ++++++ apps/power_gateway/src/emqtt/emqtt.erl | 1319 +++++++++++++++++ apps/power_gateway/src/emqtt/emqtt_frame.erl | 738 +++++++++ apps/power_gateway/src/emqtt/emqtt_props.erl | 172 +++ apps/power_gateway/src/emqtt/emqtt_sock.erl | 120 ++ apps/power_gateway/src/http_protocol.erl | 92 ++ apps/power_gateway/src/power_device.erl | 177 +++ apps/power_gateway/src/power_device_sup.erl | 80 + apps/power_gateway/src/power_gateway.app.src | 22 + apps/power_gateway/src/power_gateway_app.erl | 44 + apps/power_gateway/src/power_gateway_args.erl | 159 ++ .../src/power_gateway_mqtt_subscriber.erl | 195 +++ apps/power_gateway/src/power_gateway_sup.erl | 103 ++ apps/power_gateway/src/power_logger.erl | 169 +++ boot.sh | 3 + build.sh | 4 + config/sys-dev.config | 66 + config/sys-prod.config | 66 + config/vm.args | 6 + docs/recevie_data.md | 42 + docs/灯联网网关推送规格书.pdf | Bin 0 -> 147387 bytes rebar.config | 46 + rebar.lock | 69 + run | 5 + stop.sh | 3 + 33 files changed, 4916 insertions(+) create mode 100644 .args.yaml create mode 100644 .gitignore create mode 100644 .version create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/power_gateway/include/emqtt.hrl create mode 100644 apps/power_gateway/include/power_gateway.hrl create mode 100644 apps/power_gateway/src/efka_client.erl create mode 100644 apps/power_gateway/src/emqtt/emqtt.erl create mode 100644 apps/power_gateway/src/emqtt/emqtt_frame.erl create mode 100644 apps/power_gateway/src/emqtt/emqtt_props.erl create mode 100644 apps/power_gateway/src/emqtt/emqtt_sock.erl create mode 100644 apps/power_gateway/src/http_protocol.erl create mode 100644 apps/power_gateway/src/power_device.erl create mode 100644 apps/power_gateway/src/power_device_sup.erl create mode 100644 apps/power_gateway/src/power_gateway.app.src create mode 100644 apps/power_gateway/src/power_gateway_app.erl create mode 100644 apps/power_gateway/src/power_gateway_args.erl create mode 100644 apps/power_gateway/src/power_gateway_mqtt_subscriber.erl create mode 100644 apps/power_gateway/src/power_gateway_sup.erl create mode 100644 apps/power_gateway/src/power_logger.erl create mode 100755 boot.sh create mode 100755 build.sh create mode 100644 config/sys-dev.config create mode 100644 config/sys-prod.config create mode 100644 config/vm.args create mode 100644 docs/recevie_data.md create mode 100644 docs/灯联网网关推送规格书.pdf create mode 100644 rebar.config create mode 100644 rebar.lock create mode 100755 run create mode 100755 stop.sh diff --git a/.args.yaml b/.args.yaml new file mode 100644 index 0000000..a96a1c7 --- /dev/null +++ b/.args.yaml @@ -0,0 +1,26 @@ +metrics: + - + key: serial + type: string + label: "智慧照明设备id" + config: + default: "" + maxlen: 20 + - + key: device_uuid + type: string + label: "设备的uuid" + config: + default: "" + maxlen: 50 + +boot: "./boot.sh" +# stop can be used for execute stop action +stop: "./stop.sh" + +# coll / collect for collection, has data upload +# stream has transform +# up / upload for data upload +service_type: "coll" + +run_as_root: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acfddff --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.rebar3 +_* +.eunit +*.o +*.beam +*.plt +*.swp +*.swo +.erlang.cookie +ebin +log +erl_crash.dump +.rebar +logs +_build +.idea +*.iml +rebar3.crashdump +*~ +config/sys.config diff --git a/.version b/.version new file mode 100644 index 0000000..5a59323 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +demo1-1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e15c39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM erlang:25.3 + +CMD /data/power_gateway/bin/power_gateway foreground \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc12b01 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2023, anlicheng . + + 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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1af784d --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +power_gateway +===== + +An OTP application + +Build +----- + + $ rebar3 compile diff --git a/apps/power_gateway/include/emqtt.hrl b/apps/power_gateway/include/emqtt.hrl new file mode 100644 index 0000000..48cc014 --- /dev/null +++ b/apps/power_gateway/include/emqtt.hrl @@ -0,0 +1,535 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 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. +%%-------------------------------------------------------------------- + +-ifndef(EMQTT_HRL). +-define(EMQTT_HRL, true). + +%%-------------------------------------------------------------------- +%% MQTT Protocol Version and Names +%%-------------------------------------------------------------------- + +-define(MQTT_PROTO_V3, 3). +-define(MQTT_PROTO_V4, 4). +-define(MQTT_PROTO_V5, 5). + +-define(PROTOCOL_NAMES, [ + {?MQTT_PROTO_V3, <<"MQIsdp">>}, + {?MQTT_PROTO_V4, <<"MQTT">>}, + {?MQTT_PROTO_V5, <<"MQTT">>}]). + +%%-------------------------------------------------------------------- +%% MQTT QoS Levels +%%-------------------------------------------------------------------- + +-define(QOS_0, 0). %% At most once +-define(QOS_1, 1). %% At least once +-define(QOS_2, 2). %% Exactly once + +-define(IS_QOS(I), (I >= ?QOS_0 andalso I =< ?QOS_2)). + +-define(QOS_I(Name), + begin + (case Name of + ?QOS_0 -> ?QOS_0; + qos0 -> ?QOS_0; + at_most_once -> ?QOS_0; + ?QOS_1 -> ?QOS_1; + qos1 -> ?QOS_1; + at_least_once -> ?QOS_1; + ?QOS_2 -> ?QOS_2; + qos2 -> ?QOS_2; + exactly_once -> ?QOS_2 + end) + end). + +-define(IS_QOS_NAME(I), + (I =:= qos0 orelse I =:= at_most_once orelse + I =:= qos1 orelse I =:= at_least_once orelse + I =:= qos2 orelse I =:= exactly_once)). + +%%-------------------------------------------------------------------- +%% Maximum ClientId Length. +%%-------------------------------------------------------------------- + +-define(MAX_CLIENTID_LEN, 65535). + +%%-------------------------------------------------------------------- +%% MQTT Control Packet Types +%%-------------------------------------------------------------------- + +-define(RESERVED, 0). %% Reserved +-define(CONNECT, 1). %% Client request to connect to Server +-define(CONNACK, 2). %% Server to Client: Connect acknowledgment +-define(PUBLISH, 3). %% Publish message +-define(PUBACK, 4). %% Publish acknowledgment +-define(PUBREC, 5). %% Publish received (assured delivery part 1) +-define(PUBREL, 6). %% Publish release (assured delivery part 2) +-define(PUBCOMP, 7). %% Publish complete (assured delivery part 3) +-define(SUBSCRIBE, 8). %% Client subscribe request +-define(SUBACK, 9). %% Server Subscribe acknowledgment +-define(UNSUBSCRIBE, 10). %% Unsubscribe request +-define(UNSUBACK, 11). %% Unsubscribe acknowledgment +-define(PINGREQ, 12). %% PING request +-define(PINGRESP, 13). %% PING response +-define(DISCONNECT, 14). %% Client or Server is disconnecting +-define(AUTH, 15). %% Authentication exchange + +-define(TYPE_NAMES, [ + 'CONNECT', + 'CONNACK', + 'PUBLISH', + 'PUBACK', + 'PUBREC', + 'PUBREL', + 'PUBCOMP', + 'SUBSCRIBE', + 'SUBACK', + 'UNSUBSCRIBE', + 'UNSUBACK', + 'PINGREQ', + 'PINGRESP', + 'DISCONNECT', + 'AUTH']). + +%%-------------------------------------------------------------------- +%% MQTT V3.1.1 Connect Return Codes +%%-------------------------------------------------------------------- + +-define(CONNACK_ACCEPT, 0). %% Connection accepted +-define(CONNACK_PROTO_VER, 1). %% Unacceptable protocol version +-define(CONNACK_INVALID_ID, 2). %% Client Identifier is correct UTF-8 but not allowed by the Server +-define(CONNACK_SERVER, 3). %% Server unavailable +-define(CONNACK_CREDENTIALS, 4). %% Username or password is malformed +-define(CONNACK_AUTH, 5). %% Client is not authorized to connect + +%%-------------------------------------------------------------------- +%% MQTT V5.0 Reason Codes +%%-------------------------------------------------------------------- + +-define(RC_SUCCESS, 16#00). +-define(RC_NORMAL_DISCONNECTION, 16#00). +-define(RC_GRANTED_QOS_0, 16#00). +-define(RC_GRANTED_QOS_1, 16#01). +-define(RC_GRANTED_QOS_2, 16#02). +-define(RC_DISCONNECT_WITH_WILL_MESSAGE, 16#04). +-define(RC_NO_MATCHING_SUBSCRIBERS, 16#10). +-define(RC_NO_SUBSCRIPTION_EXISTED, 16#11). +-define(RC_CONTINUE_AUTHENTICATION, 16#18). +-define(RC_RE_AUTHENTICATE, 16#19). +-define(RC_UNSPECIFIED_ERROR, 16#80). +-define(RC_MALFORMED_PACKET, 16#81). +-define(RC_PROTOCOL_ERROR, 16#82). +-define(RC_IMPLEMENTATION_SPECIFIC_ERROR, 16#83). +-define(RC_UNSUPPORTED_PROTOCOL_VERSION, 16#84). +-define(RC_CLIENT_IDENTIFIER_NOT_VALID, 16#85). +-define(RC_BAD_USER_NAME_OR_PASSWORD, 16#86). +-define(RC_NOT_AUTHORIZED, 16#87). +-define(RC_SERVER_UNAVAILABLE, 16#88). +-define(RC_SERVER_BUSY, 16#89). +-define(RC_BANNED, 16#8A). +-define(RC_SERVER_SHUTTING_DOWN, 16#8B). +-define(RC_BAD_AUTHENTICATION_METHOD, 16#8C). +-define(RC_KEEP_ALIVE_TIMEOUT, 16#8D). +-define(RC_SESSION_TAKEN_OVER, 16#8E). +-define(RC_TOPIC_FILTER_INVALID, 16#8F). +-define(RC_TOPIC_NAME_INVALID, 16#90). +-define(RC_PACKET_IDENTIFIER_IN_USE, 16#91). +-define(RC_PACKET_IDENTIFIER_NOT_FOUND, 16#92). +-define(RC_RECEIVE_MAXIMUM_EXCEEDED, 16#93). +-define(RC_TOPIC_ALIAS_INVALID, 16#94). +-define(RC_PACKET_TOO_LARGE, 16#95). +-define(RC_MESSAGE_RATE_TOO_HIGH, 16#96). +-define(RC_QUOTA_EXCEEDED, 16#97). +-define(RC_ADMINISTRATIVE_ACTION, 16#98). +-define(RC_PAYLOAD_FORMAT_INVALID, 16#99). +-define(RC_RETAIN_NOT_SUPPORTED, 16#9A). +-define(RC_QOS_NOT_SUPPORTED, 16#9B). +-define(RC_USE_ANOTHER_SERVER, 16#9C). +-define(RC_SERVER_MOVED, 16#9D). +-define(RC_SHARED_SUBSCRIPTIONS_NOT_SUPPORTED, 16#9E). +-define(RC_CONNECTION_RATE_EXCEEDED, 16#9F). +-define(RC_MAXIMUM_CONNECT_TIME, 16#A0). +-define(RC_SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED, 16#A1). +-define(RC_WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED, 16#A2). + +%%-------------------------------------------------------------------- +%% Maximum MQTT Packet ID and Length +%%-------------------------------------------------------------------- + +-define(MAX_PACKET_ID, 16#ffff). +-define(MAX_PACKET_SIZE, 16#fffffff). + +%%-------------------------------------------------------------------- +%% MQTT Frame Mask +%%-------------------------------------------------------------------- + +-define(HIGHBIT, 2#10000000). +-define(LOWBITS, 2#01111111). + +%%-------------------------------------------------------------------- +%% MQTT Packet Fixed Header +%%-------------------------------------------------------------------- + +-record(mqtt_packet_header, { + type = ?RESERVED, + dup = false, + qos = ?QOS_0, + retain = false + }). + +%%-------------------------------------------------------------------- +%% MQTT Packets +%%-------------------------------------------------------------------- + +-define(DEFAULT_SUBOPTS, #{rh => 0, %% Retain Handling + rap => 0, %% Retain as Publish + nl => 0, %% No Local + qos => 0 %% QoS + }). + +-record(mqtt_packet_connect, { + proto_name = <<"MQTT">>, + proto_ver = ?MQTT_PROTO_V4, + is_bridge = false, + clean_start = true, + will_flag = false, + will_qos = ?QOS_0, + will_retain = false, + keepalive = 0, + properties = undefined, + clientid = <<>>, + will_props = undefined, + will_topic = undefined, + will_payload = undefined, + username = undefined, + password = undefined + }). + +-record(mqtt_packet_connack, { + ack_flags, + reason_code, + properties + }). + +-record(mqtt_packet_publish, { + topic_name, + packet_id, + properties + }). + +-record(mqtt_packet_puback, { + packet_id, + reason_code, + properties + }). + +-record(mqtt_packet_subscribe, { + packet_id, + properties, + topic_filters + }). + +-record(mqtt_packet_suback, { + packet_id, + properties, + reason_codes + }). + +-record(mqtt_packet_unsubscribe, { + packet_id, + properties, + topic_filters + }). + +-record(mqtt_packet_unsuback, { + packet_id, + properties, + reason_codes + }). + +-record(mqtt_packet_disconnect, { + reason_code, + properties + }). + +-record(mqtt_packet_auth, { + reason_code, + properties + }). + +%%-------------------------------------------------------------------- +%% MQTT Message +%%-------------------------------------------------------------------- + +-record(mqtt_msg, { + qos = ?QOS_0 :: emqtt:qos(), + retain = false :: boolean(), + dup = false :: boolean(), + packet_id :: emqtt:packet_id(), + topic :: emqtt:topic(), + props :: emqtt:properties(), + payload :: binary() + }). + +%%-------------------------------------------------------------------- +%% MQTT Control Packet +%%-------------------------------------------------------------------- + +-record(mqtt_packet, { + header :: #mqtt_packet_header{}, + variable :: #mqtt_packet_connect{} + | #mqtt_packet_connack{} + | #mqtt_packet_publish{} + | #mqtt_packet_puback{} + | #mqtt_packet_subscribe{} + | #mqtt_packet_suback{} + | #mqtt_packet_unsubscribe{} + | #mqtt_packet_unsuback{} + | #mqtt_packet_disconnect{} + | #mqtt_packet_auth{} + | pos_integer() + | undefined, + payload :: binary() | undefined + }). + +%%-------------------------------------------------------------------- +%% MQTT Packet Match +%%-------------------------------------------------------------------- + +-define(CONNECT_PACKET(Var), + #mqtt_packet{header = #mqtt_packet_header{type = ?CONNECT}, + variable = Var}). + +-define(CONNACK_PACKET(ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?CONNACK}, + variable = #mqtt_packet_connack{ack_flags = 0, + reason_code = ReasonCode} + }). + +-define(CONNACK_PACKET(ReasonCode, SessPresent), + #mqtt_packet{header = #mqtt_packet_header{type = ?CONNACK}, + variable = #mqtt_packet_connack{ack_flags = SessPresent, + reason_code = ReasonCode} + }). + +-define(CONNACK_PACKET(ReasonCode, SessPresent, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?CONNACK}, + variable = #mqtt_packet_connack{ack_flags = SessPresent, + reason_code = ReasonCode, + properties = Properties} + }). + +-define(AUTH_PACKET(), + #mqtt_packet{header = #mqtt_packet_header{type = ?AUTH}, + variable = #mqtt_packet_auth{reason_code = 0} + }). + +-define(AUTH_PACKET(ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?AUTH}, + variable = #mqtt_packet_auth{reason_code = ReasonCode} + }). + +-define(AUTH_PACKET(ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?AUTH}, + variable = #mqtt_packet_auth{reason_code = ReasonCode, + properties = Properties} + }). + +-define(PUBLISH_PACKET(QoS), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, qos = QoS}}). + +-define(PUBLISH_PACKET(QoS, PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, + qos = QoS}, + variable = #mqtt_packet_publish{packet_id = PacketId} + }). + +-define(PUBLISH_PACKET(QoS, Topic, PacketId, Payload), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, + qos = QoS}, + variable = #mqtt_packet_publish{topic_name = Topic, + packet_id = PacketId}, + payload = Payload + }). + +-define(PUBLISH_PACKET(QoS, Topic, PacketId, Properties, Payload), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, + qos = QoS}, + variable = #mqtt_packet_publish{topic_name = Topic, + packet_id = PacketId, + properties = Properties}, + payload = Payload + }). + +-define(PUBACK_PACKET(PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBACK}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = 0} + }). + +-define(PUBACK_PACKET(PacketId, ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBACK}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode} + }). + +-define(PUBACK_PACKET(PacketId, ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBACK}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties} + }). + +-define(PUBREC_PACKET(PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREC}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = 0} + }). + +-define(PUBREC_PACKET(PacketId, ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREC}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode} + }). + +-define(PUBREC_PACKET(PacketId, ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREC}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties} + }). + +-define(PUBREL_PACKET(PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREL, + qos = ?QOS_1}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = 0} + }). + +-define(PUBREL_PACKET(PacketId, ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREL, + qos = ?QOS_1}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode} + }). + +-define(PUBREL_PACKET(PacketId, ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBREL, + qos = ?QOS_1}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties} + }). + +-define(PUBCOMP_PACKET(PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBCOMP}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = 0} + }). + +-define(PUBCOMP_PACKET(PacketId, ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBCOMP}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode} + }). + +-define(PUBCOMP_PACKET(PacketId, ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBCOMP}, + variable = #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties} + }). + +-define(SUBSCRIBE_PACKET(PacketId, TopicFilters), + #mqtt_packet{header = #mqtt_packet_header{type = ?SUBSCRIBE, + qos = ?QOS_1}, + variable = #mqtt_packet_subscribe{packet_id = PacketId, + topic_filters = TopicFilters} + }). + +-define(SUBSCRIBE_PACKET(PacketId, Properties, TopicFilters), + #mqtt_packet{header = #mqtt_packet_header{type = ?SUBSCRIBE, + qos = ?QOS_1}, + variable = #mqtt_packet_subscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters} + }). + +-define(SUBACK_PACKET(PacketId, ReasonCodes), + #mqtt_packet{header = #mqtt_packet_header{type = ?SUBACK}, + variable = #mqtt_packet_suback{packet_id = PacketId, + reason_codes = ReasonCodes} + }). + +-define(SUBACK_PACKET(PacketId, Properties, ReasonCodes), + #mqtt_packet{header = #mqtt_packet_header{type = ?SUBACK}, + variable = #mqtt_packet_suback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes} + }). + +-define(UNSUBSCRIBE_PACKET(PacketId, TopicFilters), + #mqtt_packet{header = #mqtt_packet_header{type = ?UNSUBSCRIBE, + qos = ?QOS_1}, + variable = #mqtt_packet_unsubscribe{packet_id = PacketId, + topic_filters = TopicFilters} + }). + +-define(UNSUBSCRIBE_PACKET(PacketId, Properties, TopicFilters), + #mqtt_packet{header = #mqtt_packet_header{type = ?UNSUBSCRIBE, + qos = ?QOS_1}, + variable = #mqtt_packet_unsubscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters} + }). + +-define(UNSUBACK_PACKET(PacketId), + #mqtt_packet{header = #mqtt_packet_header{type = ?UNSUBACK}, + variable = #mqtt_packet_unsuback{packet_id = PacketId} + }). + +-define(UNSUBACK_PACKET(PacketId, ReasonCodes), + #mqtt_packet{header = #mqtt_packet_header{type = ?UNSUBACK}, + variable = #mqtt_packet_unsuback{packet_id = PacketId, + reason_codes = ReasonCodes} + }). + +-define(UNSUBACK_PACKET(PacketId, Properties, ReasonCodes), + #mqtt_packet{header = #mqtt_packet_header{type = ?UNSUBACK}, + variable = #mqtt_packet_unsuback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes} + }). + +-define(DISCONNECT_PACKET(), + #mqtt_packet{header = #mqtt_packet_header{type = ?DISCONNECT}, + variable = #mqtt_packet_disconnect{reason_code = 0} + }). + +-define(DISCONNECT_PACKET(ReasonCode), + #mqtt_packet{header = #mqtt_packet_header{type = ?DISCONNECT}, + variable = #mqtt_packet_disconnect{reason_code = ReasonCode} + }). + +-define(DISCONNECT_PACKET(ReasonCode, Properties), + #mqtt_packet{header = #mqtt_packet_header{type = ?DISCONNECT}, + variable = #mqtt_packet_disconnect{reason_code = ReasonCode, + properties = Properties} + }). + +-define(PACKET(Type), #mqtt_packet{header = #mqtt_packet_header{type = Type}}). + +-endif. diff --git a/apps/power_gateway/include/power_gateway.hrl b/apps/power_gateway/include/power_gateway.hrl new file mode 100644 index 0000000..e69de29 diff --git a/apps/power_gateway/src/efka_client.erl b/apps/power_gateway/src/efka_client.erl new file mode 100644 index 0000000..47dd03b --- /dev/null +++ b/apps/power_gateway/src/efka_client.erl @@ -0,0 +1,431 @@ +%%%------------------------------------------------------------------- +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 28. 8月 2023 15:39 +%%%------------------------------------------------------------------- +-module(efka_client). +-author("aresei"). + +-behaviour(gen_server). + +%% 消息包 +-record(efka_packet, { + packet_id :: integer(), + type :: integer(), + message :: any() +}). + +%% 请求超时时间 +-define(EFKA_REQUEST_TIMEOUT, 5000). + +%% 消息类型 + +%% 服务注册 +-define(PACKET_TYPE_REGISTER, 16). +%% 上传数据 +-define(PACKET_TYPE_METRIC_DATA, 3). +%% 调用其他微服务 +-define(PACKET_TYPE_INVOKE, 4). +%% 消息响应 +-define(PACKET_TYPE_RESPONSE, 7). +%% efka下发给微服务参数 +-define(PACKET_TYPE_PUSH_PARAM, 5). +%% efka下发给微服务采集项 +-define(PACKET_TYPE_PUSH_METRIC, 6). +%% 设备状态轮询: 增加日期: 2025-4-16 +-define(PACKET_TYPE_POLL, 20). +%% 微服务给efka发送log消息 +-define(PACKET_TYPE_LOG, 9). +%% 微服务从efka获取自身的采集项 +-define(PACKET_TYPE_REQUEST_METRIC, 10). +%% 微服务从efka获取自身的参数 +-define(PACKET_TYPE_REQUEST_PARAM, 12). +%% efka向微服务发送stream-call消息 +-define(PACKET_TYPE_PUSH_STREAM_CALL, 11). +%% 微服务事件上报 +-define(PACKET_TYPE_EVENT, 15). + +%% API +-export([start_link/3]). +-export([device_offline/1, device_online/1]). +-export([send_metric_data/2, invoke_service/3, send_log/1, request_metric/0, request_param/0, send_event/2]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-record(state, { + packet_id = 0 :: integer(), + host :: string(), + port :: integer(), + %% 请求后未完成的请求 + inflight = #{} :: map(), + socket :: gen_tcp:socket() +}). + +-spec send_metric_data(Fields :: list(), Tags :: #{}) -> {ok, Result :: any()} | {error, Reason :: any()}. +send_metric_data(Fields, Tags) when is_list(Fields), is_map(Tags) -> + {ok, Ref} = gen_server:call(?MODULE, {send_metric_data, self(), Fields, Tags}), + await_reply(Ref, ?EFKA_REQUEST_TIMEOUT). + +-spec invoke_service(ToService :: binary(), Message :: map(), Timeout :: integer()) -> + {ok, Result :: any()} | {error, Reason :: any()}. +invoke_service(ToService, Message, Timeout) when is_binary(ToService), is_map(Message), is_integer(Timeout) -> + {ok, Ref} = gen_server:call(?MODULE, {invoke_service, self(), ToService, Message, Timeout}), + await_reply(Ref, ?EFKA_REQUEST_TIMEOUT). + +-spec send_log(Message :: binary() | map()) -> no_return(). +send_log(Message) when is_binary(Message); is_map(Message) -> + gen_server:cast(?MODULE, {send_log, Message}). + +%% efka_server为了统一,r对象为字符串;需要2次json_decode +-spec request_metric() -> {ok, Result :: list()} | {error, Reason :: any()}. +request_metric() -> + {ok, Ref} = gen_server:call(?MODULE, {request_metric, self()}), + case await_reply(Ref, ?EFKA_REQUEST_TIMEOUT) of + {ok, Reply} -> + {ok, jiffy:decode(Reply, [return_maps])}; + Error -> + Error + end. + +-spec request_param() -> {ok, Result :: map()} | {error, Reason :: any()}. +request_param() -> + {ok, Ref} = gen_server:call(?MODULE, {request_param, self()}), + case await_reply(Ref, ?EFKA_REQUEST_TIMEOUT) of + {ok, Reply} -> + {ok, jiffy:decode(Reply, [return_maps])}; + Error -> + Error + end. + +-spec device_offline(DeviceUUID :: binary()) -> no_return(). +device_offline(DeviceUUID) when is_binary(DeviceUUID) -> + send_event(1, #{<<"device_uuid">> => DeviceUUID, <<"status">> => 0}). + +-spec device_online(DeviceUUID :: binary()) -> no_return(). +device_online(DeviceUUID) when is_binary(DeviceUUID) -> + send_event(1, #{<<"device_uuid">> => DeviceUUID, <<"status">> => 1}). + +-spec send_event(EventType :: integer(), Params :: binary() | map()) -> no_return(). +send_event(EventType, Params) when is_integer(EventType), is_binary(Params); is_map(Params) -> + gen_server:cast(?MODULE, {send_event, EventType, Params}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec await_reply(Ref :: reference(), Timeout :: integer()) -> {ok, Result :: any()} | {error, Reason :: any()}. +await_reply(Ref, Timeout) when is_reference(Ref), is_integer(Timeout) -> + receive + {response, Ref, {ok, Reply}} -> + {ok, Reply}; + {response, Ref, {error, Reason}} -> + {error, Reason} + after + Timeout -> + {error, timeout} + end. + +%% @doc Spawns the server and registers the local name (unique) +-spec(start_link(RegisterName :: binary(), Host :: string(), Port :: integer()) -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link(RegisterName, Host, Port) when is_binary(RegisterName), is_list(Host), is_integer(Port) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [RegisterName, Host, Port], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%% @private +%% @doc Initializes the server +-spec(init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore). +init([RegisterName, Host, Port]) -> + {ok, Socket} = gen_tcp:connect(Host, Port, [binary, {packet, 4}, {active, true}]), + ok = gen_tcp:controlling_process(Socket, self()), + case do_register(RegisterName, Socket) of + ok -> + {ok, #state{packet_id = 1, host = Host, port = Port, socket = Socket}}; + {error, Reason} -> + {stop, Reason} + end. + +%% 执行到efka服务器的注册 +do_register(RegisterName, Socket) -> + PacketId = 0, + Body = #{<<"name">> => RegisterName}, + Packet = pack(PacketId, ?PACKET_TYPE_REGISTER, Body), + ok = gen_tcp:send(Socket, Packet), + receive + {tcp, Socket, Data} -> + RegisterPacket = unpack(Data), + lager:debug("[efka_client] get register reply packet: ~p", [RegisterPacket]), + case RegisterPacket of + #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_RESPONSE, message = #{<<"c">> := 1, <<"r">> := <<"ok">>}} -> + ok; + #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_RESPONSE, message = #{<<"c">> := 0, <<"e">> := Error}} -> + {error, Error}; + _ -> + {error, invalid_register_packet} + end + after + ?EFKA_REQUEST_TIMEOUT -> + {error, timeout} + end. + +%% @private +%% @doc Handling call messages +-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_call({send_metric_data, ReceiverPid, Fields, Tags}, _From, State = #state{socket = Socket, packet_id = PacketId, inflight = Inflight}) -> + Body = #{<<"data">> => Fields, <<"tag">> => Tags}, + Packet = pack(PacketId, ?PACKET_TYPE_METRIC_DATA, Body), + ok = gen_tcp:send(Socket, Packet), + Ref = make_ref(), + {reply, {ok, Ref}, State#state{packet_id = next_packet_id(PacketId), inflight = maps:put(PacketId, {Ref, ReceiverPid}, Inflight)}}; + +handle_call({invoke_service, ReceiverPid, ToService, Message, Timeout}, _From, State = #state{socket = Socket, packet_id = PacketId, inflight = Inflight}) -> + Body = #{ + <<"to">> => ToService, + <<"t">> => Timeout, + <<"m">> => Message + }, + Packet = pack(PacketId, ?PACKET_TYPE_INVOKE, Body), + ok = gen_tcp:send(Socket, Packet), + Ref = make_ref(), + {reply, {ok, Ref}, State#state{packet_id = next_packet_id(PacketId), inflight = maps:put(PacketId, {Ref, ReceiverPid}, Inflight)}}; + +handle_call({request_metric, ReceiverPid}, _From, State = #state{socket = Socket, packet_id = PacketId, inflight = Inflight}) -> + Packet = pack(PacketId, ?PACKET_TYPE_REQUEST_METRIC), + ok = gen_tcp:send(Socket, Packet), + Ref = make_ref(), + {reply, {ok, Ref}, State#state{packet_id = next_packet_id(PacketId), inflight = maps:put(PacketId, {Ref, ReceiverPid}, Inflight)}}; + +handle_call({request_param, ReceiverPid}, _From, State = #state{socket = Socket, packet_id = PacketId, inflight = Inflight}) -> + Packet = pack(PacketId, ?PACKET_TYPE_REQUEST_PARAM), + ok = gen_tcp:send(Socket, Packet), + Ref = make_ref(), + {reply, {ok, Ref}, State#state{packet_id = next_packet_id(PacketId), inflight = maps:put(PacketId, {Ref, ReceiverPid}, Inflight)}}. + +%% @private +%% @doc Handling cast messages +-spec(handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_cast({send_log, Message}, State = #state{socket = Socket, packet_id = PacketId}) -> + Body = #{<<"l">> => Message}, + Packet = pack(PacketId, ?PACKET_TYPE_LOG, Body), + ok = gen_tcp:send(Socket, Packet), + + {noreply, State#state{packet_id = next_packet_id(PacketId)}}; + +handle_cast({send_event, EventType, Params}, State = #state{socket = Socket}) -> + Body = #{ + <<"event_type">> => EventType, + <<"params">> => Params + }, + Packet = pack(0, ?PACKET_TYPE_EVENT, Body), + ok = gen_tcp:send(Socket, Packet), + {noreply, State}; + +handle_cast(_Info, State = #state{}) -> + {noreply, State}. + +%% @private +%% @doc Handling all non call/cast messages +-spec(handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_info({tcp, Socket, Data}, State = #state{socket = Socket}) -> + Packet = unpack(Data), + self() ! {handle_packet, Packet}, + {noreply, State}; +handle_info({tcp_closed, Socket}, State = #state{socket = Socket}) -> + {stop, tcp_closed, State}; + +%% 收到请求的响应 +handle_info({handle_packet, #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_RESPONSE, message = Message}}, State = #state{inflight = Inflight}) -> + case maps:take(PacketId, Inflight) of + error -> + {noreply, State}; + {{Ref, ReceiverPid}, NInflight} -> + case Message of + #{<<"c">> := 1, <<"r">> := Result} -> + ReceiverPid ! {response, Ref, {ok, Result}}; + #{<<"c">> := 0, <<"e">> := Error} -> + ReceiverPid ! {response, Ref, {error, Error}} + end, + {noreply, State#state{inflight = NInflight}} + end; + +%% 收到efka推送的参数设置 +handle_info({handle_packet, #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_PUSH_PARAM, message = Params}}, + State = #state{socket = Socket}) when is_map(Params) -> + + Message = case handle_param(Params) of + ok -> + #{<<"c">> => 1, <<"r">> => <<"ok">>}; + {error, Reason} -> + #{<<"c">> => 0, <<"e">> => Reason} + end, + Packet = pack(PacketId, ?PACKET_TYPE_RESPONSE, Message), + ok = gen_tcp:send(Socket, Packet), + + {noreply, State}; + +%% 收到efka推送的采集项消息 +handle_info({handle_packet, #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_PUSH_METRIC, message = Metrics}}, + State = #state{socket = Socket}) when is_list(Metrics) -> + + Message = case handle_metric(Metrics) of + ok -> + #{<<"c">> => 1, <<"r">> => <<"ok">>}; + {error, Reason} when is_binary(Reason) -> + #{<<"c">> => 0, <<"e">> => Reason} + end, + Packet = pack(PacketId, ?PACKET_TYPE_RESPONSE, Message), + ok = gen_tcp:send(Socket, Packet), + + {noreply, State}; + +%% 收到设备状态的轮询请求 +handle_info({handle_packet, #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_POLL, message = Command}}, State = #state{socket = Socket}) -> + Message = handle_poll_command(Command), + Packet = pack(PacketId, ?PACKET_TYPE_RESPONSE, Message), + ok = gen_tcp:send(Socket, Packet), + + {noreply, State}; + +%% 收到efka的stream-call消息 +handle_info({handle_packet, #efka_packet{packet_id = PacketId, type = ?PACKET_TYPE_PUSH_STREAM_CALL, + message = Msg = #{<<"service_name">> := ServiceName, <<"data">> := Data, <<"tag">> := Tag}}}, State = #state{socket = Socket}) -> + + Message = case handle_stream_call(ServiceName, Data, Tag) of + {continue, NewServiceName, NewData, NewTag} -> + #{ + <<"c">> => 1, + <<"r">> => Msg#{<<"service_name">> := NewServiceName, <<"data">> => NewData, <<"tag">> => NewTag}, + <<"k">> => true + }; + %% 处理到当前节点为止,不继续往下传递 + {break, NewServiceName, NewData, NewTag} -> + #{ + <<"c">> => 1, + <<"r">> => Msg#{<<"service_name">> := NewServiceName, <<"data">> => NewData, <<"tag">> => NewTag}, + <<"k">> => false + }; + error -> + #{ + <<"c">> => 0, + <<"r">> => Msg, + <<"k">> => true + } + end, + Packet = pack(PacketId, ?PACKET_TYPE_RESPONSE, Message), + ok = gen_tcp:send(Socket, Packet), + + {noreply, State}; + +%% 其他消息为非法消息 +handle_info({handle_packet, _Packet}, State = #state{}) -> + {noreply, State}. + +%% @private +%% @doc This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term()). +terminate(_Reason, _State = #state{socket = Socket}) -> + gen_tcp:close(Socket), + ok. + +%% @private +%% @doc Convert process state when code is changed +-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}). +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec next_packet_id(PacketId :: integer()) -> NextPacketId :: integer(). +next_packet_id(PacketId) when PacketId >= 65535 -> + 0; +next_packet_id(PacketId) -> + PacketId + 1. + +-spec pack(PacketId :: integer(), Type :: integer()) -> binary(). +pack(PacketId, Type) when is_integer(PacketId), is_integer(Type) -> + <>. + +-spec pack(PacketId :: integer(), Type :: integer(), Body :: map()) -> binary(). +pack(PacketId, Type, Body) when is_integer(PacketId), is_integer(Type), is_map(Body) -> + Message = iolist_to_binary(jiffy:encode(Body, [force_utf8])), + <>. + +-spec unpack(binary()) -> #efka_packet{}. +unpack(<>) -> + Message = catch jiffy:decode(Body, [return_maps]), + #efka_packet{packet_id = PacketId, type = Type, message = Message}. + +%%%=================================================================== +%%% simple callbacks +%%%=================================================================== + +handle_poll_command(#{<<"device_uuid">> := DeviceUUID, <<"command">> := <<"query_status">>}) when is_binary(DeviceUUID) -> + case power_device:get_pid(DeviceUUID) of + undefined -> + #{ + <<"c">> => 1, + <<"r">> => #{ + <<"edge_status">> => -1, + <<"message">> => <<"设备信息不存在"/utf8>> + } + }; + Pid -> + StatusMap = #{ + 0 => <<"离线"/utf8>>, + 1 => <<"在线"/utf8>> + }, + {ok, Status} = power_device:poll_status(Pid), + #{ + <<"c">> => 1, + <<"r">> => #{ + <<"edge_status">> => Status, + <<"message">> => maps:get(Status, StatusMap) + } + } + end. + +-spec handle_param(Params :: map()) -> ok | {error, Reason :: binary()}. +handle_param(Params) when is_map(Params) -> + power_gateway_args:push_param(Params), + ok. + +-spec handle_metric(Metric :: list()) -> ok | {error, Reason :: binary()}. +handle_metric(Metric) when is_list(Metric) -> + power_gateway_args:push_metric(Metric), + ok. + +-spec handle_stream_call(ServiceName :: binary(), Fields :: list(), Tag :: map()) -> + {continue, NewServiceName :: binary(), NewFields :: list(), NewTag :: map()} + | {break, NewServiceName :: binary(), NewFields :: list(), NewTag :: map()} + | error. +handle_stream_call(ServiceName, Fields, Tag) when is_binary(ServiceName), is_list(Fields), is_map(Tag) -> + {continue, ServiceName, Fields, Tag}. \ No newline at end of file diff --git a/apps/power_gateway/src/emqtt/emqtt.erl b/apps/power_gateway/src/emqtt/emqtt.erl new file mode 100644 index 0000000..586c828 --- /dev/null +++ b/apps/power_gateway/src/emqtt/emqtt.erl @@ -0,0 +1,1319 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 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(emqtt). + +-behaviour(gen_statem). + +-include("emqtt.hrl"). + +-export([start_link/0, start_link/1]). + +-export([connect/1, connect/2, disconnect/1, disconnect/2, disconnect/3]). + +-export([ping/1]). + +%% PubSub +-export([ subscribe/2, subscribe/3, subscribe/4, publish/2, publish/3, publish/4, publish/5, unsubscribe/2, unsubscribe/3]). + +%% Puback... +-export([puback/2, puback/3, puback/4, pubrec/2, pubrec/3, pubrec/4, pubrel/2, pubrel/3, pubrel/4, pubcomp/2, pubcomp/3, pubcomp/4 ]). + +-export([subscriptions/1]). + +-export([info/1, stop/1]). + +%% For test cases +-export([ pause/1, resume/1 ]). + +-export([ initialized/3, waiting_for_connack/3, connected/3, inflight_full/3, random_client_id/0, reason_code_name/1 ]). + +-export([ init/1, callback_mode/0, handle_event/4, terminate/3, code_change/4 ]). + +-export_type([host/0 , option/0 , properties/0 , payload/0 , pubopt/0 , subopt/0 , mqtt_msg/0 , client/0]). + +-type(host() :: inet:ip_address() | inet:hostname()). + +%% Message handler is a set of callbacks defined to handle MQTT messages +%% as well as the disconnect event. +-define(NO_MSG_HDLR, undefined). + +-type(mfas() :: {module(), atom(), list()} | {function(), list()}). + +-type(msg_handler() :: #{puback := fun((_) -> any()) | mfas(), + publish := fun((emqx_types:message()) -> any()) | mfas(), + disconnected := fun(({reason_code(), _Properties :: term()}) -> any()) | mfas() + }). + +-type(option() :: {name, atom()} + | {owner, pid()} + | {msg_handler, msg_handler()} + | {host, host()} + | {hosts, [{host(), inet:port_number()}]} + | {port, inet:port_number()} + | {tcp_opts, [gen_tcp:option()]} + | {ssl, boolean()} + | {ssl_opts, [ssl:ssl_option()]} + | {ws_path, string()} + | {connect_timeout, pos_integer()} + | {bridge_mode, boolean()} + | {clientid, iodata()} + | {clean_start, boolean()} + | {username, iodata()} + | {password, iodata()} + | {proto_ver, v3 | v4 | v5} + | {keepalive, non_neg_integer()} + | {max_inflight, pos_integer()} + | {retry_interval, timeout()} + | {will_topic, iodata()} + | {will_payload, iodata()} + | {will_retain, boolean()} + | {will_qos, qos()} + | {will_props, properties()} + | {auto_ack, boolean()} + | {ack_timeout, pos_integer()} + | {force_ping, boolean()} + | {properties, properties()}). + +-type(maybe(T) :: undefined | T). +-type(topic() :: binary()). +-type(payload() :: iodata()). +-type(packet_id() :: 0..16#FFFF). +-type(reason_code() :: 0..16#FF). +-type(properties() :: #{atom() => term()}). +-type(version() :: ?MQTT_PROTO_V3 + | ?MQTT_PROTO_V4 + | ?MQTT_PROTO_V5). +-type(qos() :: ?QOS_0 | ?QOS_1 | ?QOS_2). +-type(qos_name() :: qos0 | at_most_once | + qos1 | at_least_once | + qos2 | exactly_once). +-type(pubopt() :: {retain, boolean()} + | {qos, qos() | qos_name()}). +-type(subopt() :: {rh, 0 | 1 | 2} + | {rap, boolean()} + | {nl, boolean()} + | {qos, qos() | qos_name()}). + +-type(subscribe_ret() :: + {ok, properties(), [reason_code()]} | {error, term()}). + +-type(client() :: pid() | atom()). + +-opaque(mqtt_msg() :: #mqtt_msg{}). + +-record(state, { + name :: atom(), + owner :: pid(), + msg_handler :: ?NO_MSG_HDLR | msg_handler(), + host :: host(), + port :: inet:port_number(), + hosts :: [{host(), inet:port_number()}], + socket :: inet:socket() | pid(), + sock_opts :: [emqtt_sock:option()|emqtt_ws:option()], + connect_timeout :: pos_integer(), + bridge_mode :: boolean(), + clientid :: binary(), + clean_start :: boolean(), + username :: maybe(binary()), + password :: maybe(binary()), + proto_ver :: version(), + proto_name :: iodata(), + keepalive :: non_neg_integer(), + keepalive_timer :: maybe(reference()), + force_ping :: boolean(), + paused :: boolean(), + will_flag :: boolean(), + will_msg :: mqtt_msg(), + properties :: properties(), + pending_calls :: list(), + subscriptions :: map(), + max_inflight :: infinity | pos_integer(), + inflight :: #{packet_id() => term()}, + awaiting_rel :: map(), + auto_ack :: boolean(), + ack_timeout :: pos_integer(), + ack_timer :: reference(), + retry_interval :: pos_integer(), + retry_timer :: reference(), + session_present :: boolean(), + last_packet_id :: packet_id(), + parse_state :: emqtt_frame:parse_state() + }). + +-record(call, { + id, + from, + req, + ts +}). + +%% Default timeout +-define(DEFAULT_KEEPALIVE, 60). +-define(DEFAULT_RETRY_INTERVAL, 30000). +-define(DEFAULT_ACK_TIMEOUT, 30000). +-define(DEFAULT_CONNECT_TIMEOUT, 60000). + +-define(PROPERTY(Name, Val), #state{properties = #{Name := Val}}). + +-define(WILL_MSG(QoS, Retain, Topic, Props, Payload), + #mqtt_msg{qos = QoS, + retain = Retain, + topic = Topic, + props = Props, + payload = Payload + }). + +-define(NO_CLIENT_ID, <<>>). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +-spec(start_link() -> gen_statem:start_ret()). +start_link() -> start_link([]). + +-spec(start_link(map() | [option()]) -> gen_statem:start_ret()). +start_link(Options) when is_map(Options) -> + start_link(maps:to_list(Options)); +start_link(Options) when is_list(Options) -> + ok = emqtt_props:validate( + proplists:get_value(properties, Options, #{})), + case proplists:get_value(name, Options) of + undefined -> + gen_statem:start_link(?MODULE, [with_owner(Options)], []); + Name when is_atom(Name) -> + gen_statem:start_link({local, Name}, ?MODULE, [with_owner(Options)], []) + end. + +with_owner(Options) -> + case proplists:get_value(owner, Options) of + Owner when is_pid(Owner) -> + Options; + undefined -> + [{owner, self()} | Options] + end. + +-spec(connect(client()) -> {ok, properties()} | {error, term()}). +connect(Client) -> + connect(Client, infinity). +connect(Client, Timeout) -> + gen_statem:call(Client, connect, Timeout). + +-spec(subscribe(client(), topic() | {topic(), qos() | qos_name() | [subopt()]} | [{topic(), qos()}]) + -> subscribe_ret()). +subscribe(Client, Topic) when is_binary(Topic) -> + subscribe(Client, {Topic, ?QOS_0}); +subscribe(Client, {Topic, QoS}) when is_binary(Topic), is_atom(QoS) -> + subscribe(Client, {Topic, ?QOS_I(QoS)}); +subscribe(Client, {Topic, QoS}) when is_binary(Topic), ?IS_QOS(QoS) -> + subscribe(Client, [{Topic, ?QOS_I(QoS)}]); +subscribe(Client, Topics) when is_list(Topics) -> + subscribe(Client, #{}, lists:map( + fun({Topic, QoS}) when is_binary(Topic), is_atom(QoS) -> + {Topic, [{qos, ?QOS_I(QoS)}]}; + ({Topic, QoS}) when is_binary(Topic), ?IS_QOS(QoS) -> + {Topic, [{qos, ?QOS_I(QoS)}]}; + ({Topic, Opts}) when is_binary(Topic), is_list(Opts) -> + {Topic, Opts} + end, Topics)). + +-spec(subscribe(client(), topic(), qos() | qos_name() | [subopt()]) -> + subscribe_ret(); + (client(), properties(), [{topic(), qos() | [subopt()]}]) -> + subscribe_ret()). +subscribe(Client, Topic, QoS) when is_binary(Topic), is_atom(QoS) -> + subscribe(Client, Topic, ?QOS_I(QoS)); +subscribe(Client, Topic, QoS) when is_binary(Topic), ?IS_QOS(QoS) -> + subscribe(Client, Topic, [{qos, QoS}]); +subscribe(Client, Topic, Opts) when is_binary(Topic), is_list(Opts) -> + subscribe(Client, #{}, [{Topic, Opts}]); +subscribe(Client, Properties, Topics) when is_map(Properties), is_list(Topics) -> + Topics1 = [{Topic, parse_subopt(Opts)} || {Topic, Opts} <- Topics], + gen_statem:call(Client, {subscribe, Properties, Topics1}). + +-spec(subscribe(client(), properties(), topic(), qos() | qos_name() | [subopt()]) + -> subscribe_ret()). +subscribe(Client, Properties, Topic, QoS) + when is_map(Properties), is_binary(Topic), is_atom(QoS) -> + subscribe(Client, Properties, Topic, ?QOS_I(QoS)); +subscribe(Client, Properties, Topic, QoS) + when is_map(Properties), is_binary(Topic), ?IS_QOS(QoS) -> + subscribe(Client, Properties, Topic, [{qos, QoS}]); +subscribe(Client, Properties, Topic, Opts) + when is_map(Properties), is_binary(Topic), is_list(Opts) -> + subscribe(Client, Properties, [{Topic, Opts}]). + +parse_subopt(Opts) -> + parse_subopt(Opts, #{rh => 0, rap => 0, nl => 0, qos => ?QOS_0}). + +parse_subopt([], Result) -> + Result; +parse_subopt([{rh, I} | Opts], Result) when I >= 0, I =< 2 -> + parse_subopt(Opts, Result#{rh := I}); +parse_subopt([{rap, true} | Opts], Result) -> + parse_subopt(Opts, Result#{rap := 1}); +parse_subopt([{rap, false} | Opts], Result) -> + parse_subopt(Opts, Result#{rap := 0}); +parse_subopt([{nl, true} | Opts], Result) -> + parse_subopt(Opts, Result#{nl := 1}); +parse_subopt([{nl, false} | Opts], Result) -> + parse_subopt(Opts, Result#{nl := 0}); +parse_subopt([{qos, QoS} | Opts], Result) -> + parse_subopt(Opts, Result#{qos := ?QOS_I(QoS)}); +parse_subopt([_ | Opts], Result) -> + parse_subopt(Opts, Result). + +-spec(publish(client(), topic(), payload()) -> ok | {error, term()}). +publish(Client, Topic, Payload) when is_binary(Topic) -> + publish(Client, #mqtt_msg{topic = Topic, qos = ?QOS_0, payload = iolist_to_binary(Payload)}). + +-spec(publish(client(), topic(), payload(), qos() | qos_name() | [pubopt()]) + -> ok | {ok, packet_id()} | {error, term()}). +publish(Client, Topic, Payload, QoS) when is_binary(Topic), is_atom(QoS) -> + publish(Client, Topic, Payload, [{qos, ?QOS_I(QoS)}]); +publish(Client, Topic, Payload, QoS) when is_binary(Topic), ?IS_QOS(QoS) -> + publish(Client, Topic, Payload, [{qos, QoS}]); +publish(Client, Topic, Payload, Opts) when is_binary(Topic), is_list(Opts) -> + publish(Client, Topic, #{}, Payload, Opts). + +-spec(publish(client(), topic(), properties(), payload(), [pubopt()]) + -> ok | {ok, packet_id()} | {error, term()}). +publish(Client, Topic, Properties, Payload, Opts) + when is_binary(Topic), is_map(Properties), is_list(Opts) -> + ok = emqtt_props:validate(Properties), + Retain = proplists:get_bool(retain, Opts), + QoS = ?QOS_I(proplists:get_value(qos, Opts, ?QOS_0)), + publish(Client, #mqtt_msg{qos = QoS, + retain = Retain, + topic = Topic, + props = Properties, + payload = iolist_to_binary(Payload)}). + +-spec(publish(client(), #mqtt_msg{}) -> ok | {ok, packet_id()} | {error, term()}). +publish(Client, Msg) -> + gen_statem:call(Client, {publish, Msg}). + +-spec(unsubscribe(client(), topic() | [topic()]) -> subscribe_ret()). +unsubscribe(Client, Topic) when is_binary(Topic) -> + unsubscribe(Client, [Topic]); +unsubscribe(Client, Topics) when is_list(Topics) -> + unsubscribe(Client, #{}, Topics). + +-spec(unsubscribe(client(), properties(), topic() | [topic()]) -> subscribe_ret()). +unsubscribe(Client, Properties, Topic) when is_map(Properties), is_binary(Topic) -> + unsubscribe(Client, Properties, [Topic]); +unsubscribe(Client, Properties, Topics) when is_map(Properties), is_list(Topics) -> + gen_statem:call(Client, {unsubscribe, Properties, Topics}). + +-spec(ping(client()) -> pong). +ping(Client) -> + gen_statem:call(Client, ping). + +-spec(disconnect(client()) -> ok). +disconnect(Client) -> + disconnect(Client, ?RC_SUCCESS). + +-spec(disconnect(client(), reason_code()) -> ok). +disconnect(Client, ReasonCode) -> + disconnect(Client, ReasonCode, #{}). + +-spec(disconnect(client(), reason_code(), properties()) -> ok). +disconnect(Client, ReasonCode, Properties) -> + gen_statem:call(Client, {disconnect, ReasonCode, Properties}). + +%%-------------------------------------------------------------------- +%% For test cases +%%-------------------------------------------------------------------- + +puback(Client, PacketId) when is_integer(PacketId) -> + puback(Client, PacketId, ?RC_SUCCESS). +puback(Client, PacketId, ReasonCode) + when is_integer(PacketId), is_integer(ReasonCode) -> + puback(Client, PacketId, ReasonCode, #{}). +puback(Client, PacketId, ReasonCode, Properties) + when is_integer(PacketId), is_integer(ReasonCode), is_map(Properties) -> + gen_statem:cast(Client, {puback, PacketId, ReasonCode, Properties}). + +pubrec(Client, PacketId) when is_integer(PacketId) -> + pubrec(Client, PacketId, ?RC_SUCCESS). +pubrec(Client, PacketId, ReasonCode) + when is_integer(PacketId), is_integer(ReasonCode) -> + pubrec(Client, PacketId, ReasonCode, #{}). +pubrec(Client, PacketId, ReasonCode, Properties) + when is_integer(PacketId), is_integer(ReasonCode), is_map(Properties) -> + gen_statem:cast(Client, {pubrec, PacketId, ReasonCode, Properties}). + +pubrel(Client, PacketId) when is_integer(PacketId) -> + pubrel(Client, PacketId, ?RC_SUCCESS). +pubrel(Client, PacketId, ReasonCode) + when is_integer(PacketId), is_integer(ReasonCode) -> + pubrel(Client, PacketId, ReasonCode, #{}). +pubrel(Client, PacketId, ReasonCode, Properties) + when is_integer(PacketId), is_integer(ReasonCode), is_map(Properties) -> + gen_statem:cast(Client, {pubrel, PacketId, ReasonCode, Properties}). + +pubcomp(Client, PacketId) when is_integer(PacketId) -> + pubcomp(Client, PacketId, ?RC_SUCCESS). +pubcomp(Client, PacketId, ReasonCode) + when is_integer(PacketId), is_integer(ReasonCode) -> + pubcomp(Client, PacketId, ReasonCode, #{}). +pubcomp(Client, PacketId, ReasonCode, Properties) + when is_integer(PacketId), is_integer(ReasonCode), is_map(Properties) -> + gen_statem:cast(Client, {pubcomp, PacketId, ReasonCode, Properties}). + +subscriptions(Client) -> + gen_statem:call(Client, subscriptions). + +info(Client) -> + gen_statem:call(Client, info). + +stop(Client) -> + gen_statem:call(Client, stop). + +pause(Client) -> + gen_statem:call(Client, pause). + +resume(Client) -> + gen_statem:call(Client, resume). + +%%-------------------------------------------------------------------- +%% gen_statem callbacks +%%-------------------------------------------------------------------- + +init([Options]) -> + process_flag(trap_exit, true), + ClientId = case {proplists:get_value(proto_ver, Options, v4), + proplists:get_value(clientid, Options)} of + {v5, undefined} -> ?NO_CLIENT_ID; + {_ver, undefined} -> random_client_id(); + {_ver, Id} -> iolist_to_binary(Id) + end, + State = init(Options, #state{host = {127,0,0,1}, + port = 1883, + hosts = [], + sock_opts = [], + bridge_mode = false, + clientid = ClientId, + clean_start = true, + proto_ver = ?MQTT_PROTO_V4, + proto_name = <<"MQTT">>, + keepalive = ?DEFAULT_KEEPALIVE, + force_ping = false, + paused = false, + will_flag = false, + will_msg = #mqtt_msg{}, + pending_calls = [], + subscriptions = #{}, + max_inflight = infinity, + inflight = #{}, + awaiting_rel = #{}, + properties = #{}, + auto_ack = true, + ack_timeout = ?DEFAULT_ACK_TIMEOUT, + retry_interval = ?DEFAULT_RETRY_INTERVAL, + connect_timeout = ?DEFAULT_CONNECT_TIMEOUT, + last_packet_id = 1 + }), + {ok, initialized, init_parse_state(State)}. + +random_client_id() -> + rand:seed(exsplus, erlang:timestamp()), + I1 = rand:uniform(round(math:pow(2, 48))) - 1, + I2 = rand:uniform(round(math:pow(2, 32))) - 1, + {ok, Host} = inet:gethostname(), + RandId = io_lib:format("~12.16.0b~8.16.0b", [I1, I2]), + iolist_to_binary(["emqtt-", Host, "-", RandId]). + +init([], State) -> + State; +init([{name, Name} | Opts], State) -> + init(Opts, State#state{name = Name}); +init([{owner, Owner} | Opts], State) when is_pid(Owner) -> + link(Owner), + init(Opts, State#state{owner = Owner}); +init([{msg_handler, Hdlr} | Opts], State) -> + init(Opts, State#state{msg_handler = Hdlr}); +init([{host, Host} | Opts], State) -> + init(Opts, State#state{host = Host}); +init([{port, Port} | Opts], State) -> + init(Opts, State#state{port = Port}); +init([{hosts, Hosts} | Opts], State) -> + Hosts1 = + lists:foldl(fun({Host, Port}, Acc) -> + [{Host, Port}|Acc]; + (Host, Acc) -> + [{Host, 1883}|Acc] + end, [], Hosts), + init(Opts, State#state{hosts = Hosts1}); +init([{tcp_opts, TcpOpts} | Opts], State = #state{sock_opts = SockOpts}) -> + init(Opts, State#state{sock_opts = merge_opts(SockOpts, TcpOpts)}); +init([{ssl, EnableSsl} | Opts], State) -> + case lists:keytake(ssl_opts, 1, Opts) of + {value, SslOpts, WithOutSslOpts} -> + init([SslOpts, {ssl, EnableSsl}| WithOutSslOpts], State); + false -> + init([{ssl_opts, []}, {ssl, EnableSsl}| Opts], State) + end; +init([{ssl_opts, SslOpts} | Opts], State = #state{sock_opts = SockOpts}) -> + case lists:keytake(ssl, 1, Opts) of + {value, {ssl, true}, WithOutEnableSsl} -> + ok = ssl:start(), + SockOpts1 = merge_opts(SockOpts, [{ssl_opts, SslOpts}]), + init(WithOutEnableSsl, State#state{sock_opts = SockOpts1}); + {value, {ssl, false}, WithOutEnableSsl} -> + init(WithOutEnableSsl, State); + false -> + init(Opts, State) + end; +init([{ws_path, Path} | Opts], State = #state{sock_opts = SockOpts}) -> + init(Opts, State#state{sock_opts = [{ws_path, Path}|SockOpts]}); +init([{clientid, ClientId} | Opts], State) -> + init(Opts, State#state{clientid = iolist_to_binary(ClientId)}); +init([{clean_start, CleanStart} | Opts], State) when is_boolean(CleanStart) -> + init(Opts, State#state{clean_start = CleanStart}); +init([{username, Username} | Opts], State) -> + init(Opts, State#state{username = iolist_to_binary(Username)}); +init([{password, Password} | Opts], State) -> + init(Opts, State#state{password = iolist_to_binary(Password)}); +init([{keepalive, Secs} | Opts], State) -> + init(Opts, State#state{keepalive = Secs}); +init([{proto_ver, v3} | Opts], State) -> + init(Opts, State#state{proto_ver = ?MQTT_PROTO_V3, + proto_name = <<"MQIsdp">>}); +init([{proto_ver, v4} | Opts], State) -> + init(Opts, State#state{proto_ver = ?MQTT_PROTO_V4, + proto_name = <<"MQTT">>}); +init([{proto_ver, v5} | Opts], State) -> + init(Opts, State#state{proto_ver = ?MQTT_PROTO_V5, + proto_name = <<"MQTT">>}); +init([{will_topic, Topic} | Opts], State = #state{will_msg = WillMsg}) -> + WillMsg1 = init_will_msg({topic, Topic}, WillMsg), + init(Opts, State#state{will_flag = true, will_msg = WillMsg1}); +init([{will_props, Properties} | Opts], State = #state{will_msg = WillMsg}) -> + init(Opts, State#state{will_msg = init_will_msg({props, Properties}, WillMsg)}); +init([{will_payload, Payload} | Opts], State = #state{will_msg = WillMsg}) -> + init(Opts, State#state{will_msg = init_will_msg({payload, Payload}, WillMsg)}); +init([{will_retain, Retain} | Opts], State = #state{will_msg = WillMsg}) -> + init(Opts, State#state{will_msg = init_will_msg({retain, Retain}, WillMsg)}); +init([{will_qos, QoS} | Opts], State = #state{will_msg = WillMsg}) -> + init(Opts, State#state{will_msg = init_will_msg({qos, QoS}, WillMsg)}); +init([{connect_timeout, Timeout}| Opts], State) -> + init(Opts, State#state{connect_timeout = timer:seconds(Timeout)}); +init([{ack_timeout, Timeout}| Opts], State) -> + init(Opts, State#state{ack_timeout = timer:seconds(Timeout)}); +init([force_ping | Opts], State) -> + init(Opts, State#state{force_ping = true}); +init([{force_ping, ForcePing} | Opts], State) when is_boolean(ForcePing) -> + init(Opts, State#state{force_ping = ForcePing}); +init([{properties, Properties} | Opts], State = #state{properties = InitProps}) -> + init(Opts, State#state{properties = maps:merge(InitProps, Properties)}); +init([{max_inflight, infinity} | Opts], State) -> + init(Opts, State#state{max_inflight = infinity, + inflight = #{}}); +init([{max_inflight, I} | Opts], State) when is_integer(I) -> + init(Opts, State#state{max_inflight = I, + inflight = #{}}); +init([auto_ack | Opts], State) -> + init(Opts, State#state{auto_ack = true}); +init([{auto_ack, AutoAck} | Opts], State) when is_boolean(AutoAck) -> + init(Opts, State#state{auto_ack = AutoAck}); +init([{retry_interval, I} | Opts], State) -> + init(Opts, State#state{retry_interval = timer:seconds(I)}); +init([{bridge_mode, Mode} | Opts], State) when is_boolean(Mode) -> + init(Opts, State#state{bridge_mode = Mode}); +init([_Opt | Opts], State) -> + init(Opts, State). + +init_will_msg({topic, Topic}, WillMsg) -> + WillMsg#mqtt_msg{topic = iolist_to_binary(Topic)}; +init_will_msg({props, Props}, WillMsg) -> + WillMsg#mqtt_msg{props = Props}; +init_will_msg({payload, Payload}, WillMsg) -> + WillMsg#mqtt_msg{payload = iolist_to_binary(Payload)}; +init_will_msg({retain, Retain}, WillMsg) when is_boolean(Retain) -> + WillMsg#mqtt_msg{retain = Retain}; +init_will_msg({qos, QoS}, WillMsg) -> + WillMsg#mqtt_msg{qos = ?QOS_I(QoS)}. + +init_parse_state(State = #state{proto_ver = Ver, properties = Properties}) -> + MaxSize = maps:get('Maximum-Packet-Size', Properties, ?MAX_PACKET_SIZE), + ParseState = emqtt_frame:initial_parse_state( + #{max_size => MaxSize, version => Ver}), + State#state{parse_state = ParseState}. + +merge_opts(Defaults, Options) -> + lists:foldl( + fun({Opt, Val}, Acc) -> + lists:keystore(Opt, 1, Acc, {Opt, Val}); + (Opt, Acc) -> + lists:usort([Opt | Acc]) + end, Defaults, Options). + +callback_mode() -> state_functions. + +initialized({call, From}, connect, State = #state{sock_opts = SockOpts, connect_timeout = Timeout}) -> + case sock_connect(hosts(State), SockOpts, Timeout) of + {ok, Sock} -> + case mqtt_connect(run_sock(State#state{socket = Sock})) of + {ok, NewState} -> + {next_state, waiting_for_connack, + add_call(new_call(connect, From), NewState), [Timeout]}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + Error = {error, Reason} -> + {stop_and_reply, {shutdown, Reason}, [{reply, From, Error}]} + end; + +initialized(EventType, EventContent, State) -> + handle_event(EventType, EventContent, initialized, State). + +mqtt_connect(State = #state{clientid = ClientId, + clean_start = CleanStart, + bridge_mode = IsBridge, + username = Username, + password = Password, + proto_ver = ProtoVer, + proto_name = ProtoName, + keepalive = KeepAlive, + will_flag = WillFlag, + will_msg = WillMsg, + properties = Properties}) -> + ?WILL_MSG(WillQoS, WillRetain, WillTopic, WillProps, WillPayload) = WillMsg, + ConnProps = emqtt_props:filter(?CONNECT, Properties), + send(?CONNECT_PACKET( + #mqtt_packet_connect{proto_ver = ProtoVer, + proto_name = ProtoName, + is_bridge = IsBridge, + clean_start = CleanStart, + will_flag = WillFlag, + will_qos = WillQoS, + will_retain = WillRetain, + keepalive = KeepAlive, + properties = ConnProps, + clientid = ClientId, + will_props = WillProps, + will_topic = WillTopic, + will_payload = WillPayload, + username = Username, + password = Password}), State). + +waiting_for_connack(cast, ?CONNACK_PACKET(?RC_SUCCESS, + SessPresent, + Properties), + State = #state{properties = AllProps, + clientid = ClientId}) -> + case take_call(connect, State) of + {value, #call{from = From}, State1} -> + AllProps1 = case Properties of + undefined -> AllProps; + _ -> maps:merge(AllProps, Properties) + end, + Reply = {ok, Properties}, + State2 = State1#state{clientid = assign_id(ClientId, AllProps1), + properties = AllProps1, + session_present = SessPresent}, + {next_state, connected, ensure_keepalive_timer(State2), + [{reply, From, Reply}]}; + false -> + {stop, bad_connack} + end; + +waiting_for_connack(cast, ?CONNACK_PACKET(ReasonCode, + _SessPresent, + Properties), + State = #state{proto_ver = ProtoVer}) -> + Reason = reason_code_name(ReasonCode, ProtoVer), + case take_call(connect, State) of + {value, #call{from = From}, _State} -> + Reply = {error, {Reason, Properties}}, + {stop_and_reply, {shutdown, Reason}, [{reply, From, Reply}]}; + false -> {stop, connack_error} + end; + +waiting_for_connack(timeout, _Timeout, State) -> + case take_call(connect, State) of + {value, #call{from = From}, _State} -> + Reply = {error, connack_timeout}, + {stop_and_reply, connack_timeout, [{reply, From, Reply}]}; + false -> {stop, connack_timeout} + end; + +waiting_for_connack(EventType, EventContent, State) -> + case take_call(connect, State) of + {value, #call{from = From}, _State} -> + case handle_event(EventType, EventContent, waiting_for_connack, State) of + {stop, Reason, State} -> + Reply = {error, {Reason, EventContent}}, + {stop_and_reply, Reason, [{reply, From, Reply}]}; + StateCallbackResult -> + StateCallbackResult + end; + false -> + {stop, connack_timeout} + end. + +connected({call, From}, subscriptions, #state{subscriptions = Subscriptions}) -> + {keep_state_and_data, [{reply, From, maps:to_list(Subscriptions)}]}; + +connected({call, From}, info, State) -> + Info = lists:zip(record_info(fields, state), tl(tuple_to_list(State))), + {keep_state_and_data, [{reply, From, Info}]}; + +connected({call, From}, pause, State) -> + {keep_state, State#state{paused = true}, [{reply, From, ok}]}; + +connected({call, From}, resume, State) -> + {keep_state, State#state{paused = false}, [{reply, From, ok}]}; + +connected({call, From}, clientid, #state{clientid = ClientId}) -> + {keep_state_and_data, [{reply, From, ClientId}]}; + +connected({call, From}, SubReq = {subscribe, Properties, Topics}, + State = #state{last_packet_id = PacketId, subscriptions = Subscriptions}) -> + case send(?SUBSCRIBE_PACKET(PacketId, Properties, Topics), State) of + {ok, NewState} -> + Call = new_call({subscribe, PacketId}, From, SubReq), + Subscriptions1 = + lists:foldl(fun({Topic, Opts}, Acc) -> + maps:put(Topic, Opts, Acc) + end, Subscriptions, Topics), + {keep_state, ensure_ack_timer(add_call(Call,NewState#state{subscriptions = Subscriptions1}))}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + +connected({call, From}, {publish, Msg = #mqtt_msg{qos = ?QOS_0}}, State) -> + case send(Msg, State) of + {ok, NewState} -> + {keep_state, NewState, [{reply, From, ok}]}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + +connected({call, From}, {publish, Msg = #mqtt_msg{qos = QoS}}, + State = #state{inflight = Inflight, last_packet_id = PacketId}) + when (QoS =:= ?QOS_1); (QoS =:= ?QOS_2) -> + Msg1 = Msg#mqtt_msg{packet_id = PacketId}, + case send(Msg1, State) of + {ok, NewState} -> + Inflight1 = maps:put(PacketId, {publish, Msg1, os:timestamp()}, Inflight), + State1 = ensure_retry_timer(NewState#state{inflight = Inflight1}), + Actions = [{reply, From, {ok, PacketId}}], + case is_inflight_full(State1) of + true -> {next_state, inflight_full, State1, Actions}; + false -> {keep_state, State1, Actions} + end; + {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, {error, {PacketId, Reason}}}]} + end; + +connected({call, From}, UnsubReq = {unsubscribe, Properties, Topics}, + State = #state{last_packet_id = PacketId}) -> + case send(?UNSUBSCRIBE_PACKET(PacketId, Properties, Topics), State) of + {ok, NewState} -> + Call = new_call({unsubscribe, PacketId}, From, UnsubReq), + {keep_state, ensure_ack_timer(add_call(Call, NewState))}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + +connected({call, From}, ping, State) -> + case send(?PACKET(?PINGREQ), State) of + {ok, NewState} -> + Call = new_call(ping, From), + {keep_state, ensure_ack_timer(add_call(Call, NewState))}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + +connected({call, From}, {disconnect, ReasonCode, Properties}, State) -> + case send(?DISCONNECT_PACKET(ReasonCode, Properties), State) of + {ok, NewState} -> + {stop_and_reply, normal, [{reply, From, ok}], NewState}; + Error = {error, Reason} -> + {stop_and_reply, Reason, [{reply, From, Error}]} + end; + +connected(cast, {puback, PacketId, ReasonCode, Properties}, State) -> + send_puback(?PUBACK_PACKET(PacketId, ReasonCode, Properties), State); + +connected(cast, {pubrec, PacketId, ReasonCode, Properties}, State) -> + send_puback(?PUBREC_PACKET(PacketId, ReasonCode, Properties), State); + +connected(cast, {pubrel, PacketId, ReasonCode, Properties}, State) -> + send_puback(?PUBREL_PACKET(PacketId, ReasonCode, Properties), State); + +connected(cast, {pubcomp, PacketId, ReasonCode, Properties}, State) -> + send_puback(?PUBCOMP_PACKET(PacketId, ReasonCode, Properties), State); + +connected(cast, ?PUBLISH_PACKET(_QoS, _PacketId), #state{paused = true}) -> + keep_state_and_data; + +connected(cast, Packet = ?PUBLISH_PACKET(?QOS_0, _PacketId), State) -> + {keep_state, deliver(packet_to_msg(Packet), State)}; + +connected(cast, Packet = ?PUBLISH_PACKET(?QOS_1, _PacketId), State) -> + publish_process(?QOS_1, Packet, State); + +connected(cast, Packet = ?PUBLISH_PACKET(?QOS_2, _PacketId), State) -> + publish_process(?QOS_2, Packet, State); + +connected(cast, ?PUBACK_PACKET(_PacketId, _ReasonCode, _Properties) = PubAck, State) -> + {keep_state, delete_inflight(PubAck, State)}; + +connected(cast, ?PUBREC_PACKET(PacketId), State = #state{inflight = Inflight}) -> + NState = case maps:find(PacketId, Inflight) of + {ok, {publish, _Msg, _Ts}} -> + Inflight1 = maps:put(PacketId, {pubrel, PacketId, os:timestamp()}, Inflight), + State#state{inflight = Inflight1}; + {ok, {pubrel, _Ref, _Ts}} -> + lager:notice("[emqtt] Duplicated PUBREC Packet: ~p, client_id: ~p", [PacketId, State#state.clientid]), + State; + error -> + lager:warning("[emqtt] Unexpected PUBREC Packet: ~p, client_id: ~p", [PacketId, State#state.clientid]), + State + end, + send_puback(?PUBREL_PACKET(PacketId), NState); + +connected(cast, ?PUBREC_PACKET(PacketId, ReasonCode), State) -> + lager:notice("[emqtt] Duplicated PUBREC Packet: ~p, reason_code: ~p, client_id: ~p", [PacketId, ReasonCode, State#state.clientid]), + keep_state_and_data; + +%%TODO::... if auto_ack is false, should we take PacketId from the map? +connected(cast, ?PUBREL_PACKET(PacketId), + State = #state{awaiting_rel = AwaitingRel, auto_ack = AutoAck}) -> + case maps:take(PacketId, AwaitingRel) of + {Packet, AwaitingRel1} -> + NewState = deliver(packet_to_msg(Packet), State#state{awaiting_rel = AwaitingRel1}), + case AutoAck of + true -> send_puback(?PUBCOMP_PACKET(PacketId), NewState); + false -> {keep_state, NewState} + end; + error -> + lager:warning("[emqtt] Unexpected PUBREL: ~p, client_id: ~p", [PacketId, State#state.clientid]), + keep_state_and_data + end; + +connected(cast, ?PUBCOMP_PACKET(_PacketId, _ReasonCode, _Properties) = PubComp, State) -> + {keep_state, delete_inflight(PubComp, State)}; + +connected(cast, ?SUBACK_PACKET(PacketId, Properties, ReasonCodes), + State = #state{subscriptions = _Subscriptions}) -> + case take_call({subscribe, PacketId}, State) of + {value, #call{from = From}, NewState} -> + %%TODO: Merge reason codes to subscriptions? + Reply = {ok, Properties, ReasonCodes}, + {keep_state, NewState, [{reply, From, Reply}]}; + false -> + keep_state_and_data + end; + +connected(cast, ?UNSUBACK_PACKET(PacketId, Properties, ReasonCodes), + State = #state{subscriptions = Subscriptions}) -> + case take_call({unsubscribe, PacketId}, State) of + {value, #call{from = From, req = {_, _, Topics}}, NewState} -> + Subscriptions1 = + lists:foldl(fun(Topic, Acc) -> + maps:remove(Topic, Acc) + end, Subscriptions, Topics), + {keep_state, NewState#state{subscriptions = Subscriptions1}, + [{reply, From, {ok, Properties, ReasonCodes}}]}; + false -> + keep_state_and_data + end; + +connected(cast, ?PACKET(?PINGRESP), #state{pending_calls = []}) -> + keep_state_and_data; +connected(cast, ?PACKET(?PINGRESP), State) -> + case take_call(ping, State) of + {value, #call{from = From}, NewState} -> + {keep_state, NewState, [{reply, From, pong}]}; + false -> + keep_state_and_data + end; + +connected(cast, ?DISCONNECT_PACKET(ReasonCode, Properties), State) -> + {stop, {disconnected, ReasonCode, Properties}, State}; + +connected(info, {timeout, _TRef, keepalive}, State = #state{force_ping = true}) -> + case send(?PACKET(?PINGREQ), State) of + {ok, NewState} -> + {keep_state, ensure_keepalive_timer(NewState)}; + Error -> {stop, Error} + end; + +connected(info, {timeout, TRef, keepalive}, State = #state{socket = Sock, paused = Paused, keepalive_timer = TRef}) -> + case (not Paused) andalso should_ping(Sock) of + true -> + case send(?PACKET(?PINGREQ), State) of + {ok, NewState} -> + {ok, [{send_oct, Val}]} = emqtt_sock:getstat(Sock, [send_oct]), + put(send_oct, Val), + {keep_state, ensure_keepalive_timer(NewState), [hibernate]}; + Error -> {stop, Error} + end; + false -> + {keep_state, ensure_keepalive_timer(State), [hibernate]}; + {error, Reason} -> + {stop, Reason} + end; + +connected(info, {timeout, TRef, ack}, State = #state{ack_timer = TRef, + ack_timeout = Timeout, + pending_calls = Calls}) -> + NewState = State#state{ack_timer = undefined, + pending_calls = timeout_calls(Timeout, Calls)}, + {keep_state, ensure_ack_timer(NewState)}; + +connected(info, {timeout, TRef, retry}, State = #state{retry_timer = TRef, inflight = Inflight}) -> + case maps:size(Inflight) == 0 of + true -> {keep_state, State#state{retry_timer = undefined}}; + false -> retry_send(State) + end; + +connected(EventType, EventContent, Data) -> + handle_event(EventType, EventContent, connected, Data). + +inflight_full({call, _From}, {publish, #mqtt_msg{qos = QoS}}, _State) when (QoS =:= ?QOS_1); (QoS =:= ?QOS_2) -> + {keep_state_and_data, [postpone]}; +inflight_full(cast, ?PUBACK_PACKET(_PacketId, _ReasonCode, _Properties) = PubAck, State) -> + delete_inflight_when_full(PubAck, State); +inflight_full(cast, ?PUBCOMP_PACKET(_PacketId, _ReasonCode, _Properties) = PubComp, State) -> + delete_inflight_when_full(PubComp, State); +inflight_full(EventType, EventContent, Data) -> + %% inflight_full is a sub-state of connected state, + %% delegate all other events to connected state. + connected(EventType, EventContent, Data). + +handle_event({call, From}, stop, _StateName, _State) -> + {stop_and_reply, normal, [{reply, From, ok}]}; + +handle_event(info, {TcpOrSsL, _Sock, Data}, _StateName, State) when TcpOrSsL =:= tcp; TcpOrSsL =:= ssl -> + %lager:debug("[emqtt] RECV Data: ~p, client_id: ~p", [Data, State#state.clientid]), + process_incoming(Data, [], run_sock(State)); + +handle_event(info, {Error, _Sock, Reason}, _StateName, State) when Error =:= tcp_error; Error =:= ssl_error -> + lager:error("[emqtt] The connection error occured ~p, reason:~p, client_id: ~p", [Error, Reason, State#state.clientid]), + {stop, {shutdown, Reason}, State}; + +handle_event(info, {Closed, _Sock}, _StateName, State) when Closed =:= tcp_closed; Closed =:= ssl_closed -> + lager:debug("[emqtt] sokcet closed: ~p, client_id: ~p", [Closed, State#state.clientid]), + {stop, {shutdown, Closed}, State}; + +handle_event(info, {'EXIT', Owner, Reason}, _, State = #state{owner = Owner}) -> + lager:warning("[emqtt] Got EXIT from owner, Reason: ~p, client_id: ~p", [Reason, State#state.clientid]), + {stop, {shutdown, Reason}, State}; + +handle_event(info, {inet_reply, _Sock, ok}, _, _State) -> + keep_state_and_data; + +handle_event(info, {inet_reply, _Sock, {error, Reason}}, _, State) -> + lager:error("[emqtt] Got tcp error: ~p, client_id: ~p", [Reason, State#state.clientid]), + {stop, {shutdown, Reason}, State}; + +handle_event(info, EventContent = {'EXIT', Pid, Reason}, StateName, State) -> + lager:warning("[emqtt] State: ~s, Unexpected Event: (info, ~p), from pid: ~p, client_id: ~p", [StateName, EventContent, Pid, State#state.clientid]), + {stop, {shutdown, Reason}, State}; + +handle_event(EventType, EventContent, StateName, State) -> + lager:error("[emqtt] State: ~s, Unexpected Event: (~p, ~p), client_id: ~p", [StateName, EventType, EventContent, State#state.clientid]), + keep_state_and_data. + +%% Mandatory callback functions +terminate(Reason, _StateName, State = #state{socket = Socket}) -> + case Reason of + {disconnected, ReasonCode, Properties} -> + %% backward compatible + ok = eval_msg_handler(State, disconnected, {ReasonCode, Properties}); + _ -> + ok = eval_msg_handler(State, disconnected, Reason) + end, + case Socket =:= undefined of + true -> ok; + _ -> emqtt_sock:close(Socket) + end. + +code_change(_Vsn, State, Data, _Extra) -> + {ok, State, Data}. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +should_ping(Sock) -> + case emqtt_sock:getstat(Sock, [send_oct]) of + {ok, [{send_oct, Val}]} -> + OldVal = get(send_oct), put(send_oct, Val), + OldVal == undefined orelse OldVal == Val; + Error = {error, _Reason} -> + Error + end. + +is_inflight_full(#state{max_inflight = infinity}) -> + false; +is_inflight_full(#state{max_inflight = MaxLimit, inflight = Inflight}) -> + maps:size(Inflight) >= MaxLimit. + +delete_inflight(?PUBACK_PACKET(PacketId, ReasonCode, Properties), + State = #state{inflight = Inflight}) -> + case maps:find(PacketId, Inflight) of + {ok, {publish, #mqtt_msg{packet_id = PacketId}, _Ts}} -> + ok = eval_msg_handler(State, puback, #{packet_id => PacketId, + reason_code => ReasonCode, + properties => Properties}), + State#state{inflight = maps:remove(PacketId, Inflight)}; + error -> + lager:warning("[emqtt] Unexpected PUBACK: ~p, client_id: ~p", [PacketId, State#state.clientid]), + State + end; +delete_inflight(?PUBCOMP_PACKET(PacketId, ReasonCode, Properties), + State = #state{inflight = Inflight}) -> + case maps:find(PacketId, Inflight) of + {ok, {pubrel, _PacketId, _Ts}} -> + ok = eval_msg_handler(State, puback, #{packet_id => PacketId, + reason_code => ReasonCode, + properties => Properties}), + State#state{inflight = maps:remove(PacketId, Inflight)}; + error -> + lager:warning("[emqtt] Unexpected PUBCOMP Packet: ~p, client_id: ~p", [PacketId, State#state.clientid]), + State + end. + +delete_inflight_when_full(Packet, State) -> + State1 = delete_inflight(Packet, State), + case is_inflight_full(State1) of + true -> + {keep_state, State1}; + false -> + {next_state, connected, State1} + end. + +assign_id(?NO_CLIENT_ID, Props) -> + case maps:find('Assigned-Client-Identifier', Props) of + {ok, Value} -> + Value; + _ -> + error(bad_client_id) + end; +assign_id(Id, _Props) -> + Id. + +publish_process(?QOS_1, Packet = ?PUBLISH_PACKET(?QOS_1, PacketId), + State0 = #state{auto_ack = AutoAck}) -> + State = deliver(packet_to_msg(Packet), State0), + case AutoAck of + true -> + send_puback(?PUBACK_PACKET(PacketId), State); + false -> + {keep_state, State} + end; +publish_process(?QOS_2, Packet = ?PUBLISH_PACKET(?QOS_2, PacketId), + State = #state{awaiting_rel = AwaitingRel}) -> + case send_puback(?PUBREC_PACKET(PacketId), State) of + {keep_state, NewState} -> + AwaitingRel1 = maps:put(PacketId, Packet, AwaitingRel), + {keep_state, NewState#state{awaiting_rel = AwaitingRel1}}; + Stop -> + Stop + end. + +ensure_keepalive_timer(State = ?PROPERTY('Server-Keep-Alive', Secs)) -> + ensure_keepalive_timer(timer:seconds(Secs), State#state{keepalive = Secs}); +ensure_keepalive_timer(State = #state{keepalive = 0}) -> + State; +ensure_keepalive_timer(State = #state{keepalive = I}) -> + ensure_keepalive_timer(timer:seconds(I), State). +ensure_keepalive_timer(I, State) when is_integer(I) -> + State#state{keepalive_timer = erlang:start_timer(I, self(), keepalive)}. + +new_call(Id, From) -> + new_call(Id, From, undefined). +new_call(Id, From, Req) -> + #call{id = Id, from = From, req = Req, ts = os:timestamp()}. + +add_call(Call, Data = #state{pending_calls = Calls}) -> + Data#state{pending_calls = [Call | Calls]}. + +take_call(Id, Data = #state{pending_calls = Calls}) -> + case lists:keytake(Id, #call.id, Calls) of + {value, Call, Left} -> + {value, Call, Data#state{pending_calls = Left}}; + false -> false + end. + +timeout_calls(Timeout, Calls) -> + timeout_calls(os:timestamp(), Timeout, Calls). +timeout_calls(Now, Timeout, Calls) -> + lists:foldl(fun(C = #call{from = From, ts = Ts}, Acc) -> + case (timer:now_diff(Now, Ts) div 1000) >= Timeout of + true -> + gen_statem:reply(From, {error, ack_timeout}), + Acc; + false -> [C | Acc] + end + end, [], Calls). + +ensure_ack_timer(State = #state{ack_timer = undefined, + ack_timeout = Timeout, + pending_calls = Calls}) when length(Calls) > 0 -> + State#state{ack_timer = erlang:start_timer(Timeout, self(), ack)}; +ensure_ack_timer(State) -> State. + +ensure_retry_timer(State = #state{retry_interval = Interval}) -> + do_ensure_retry_timer(Interval, State). + +do_ensure_retry_timer(Interval, State = #state{retry_timer = undefined}) + when Interval > 0 -> + State#state{retry_timer = erlang:start_timer(Interval, self(), retry)}; +do_ensure_retry_timer(_Interval, State) -> + State. + +retry_send(State = #state{inflight = Inflight}) -> + SortFun = fun({_, _, Ts1}, {_, _, Ts2}) -> Ts1 < Ts2 end, + Msgs = lists:sort(SortFun, maps:values(Inflight)), + retry_send(Msgs, os:timestamp(), State ). + +retry_send([], _Now, State) -> + {keep_state, ensure_retry_timer(State)}; +retry_send([{Type, Msg, Ts} | Msgs], Now, State = #state{retry_interval = Interval}) -> + Diff = timer:now_diff(Now, Ts) div 1000, %% micro -> ms + case (Diff >= Interval) of + true -> case retry_send(Type, Msg, Now, State) of + {ok, NewState} -> retry_send(Msgs, Now, NewState); + {error, Error} -> {stop, Error} + end; + false -> {keep_state, do_ensure_retry_timer(Interval - Diff, State)} + end. + +retry_send(publish, Msg = #mqtt_msg{qos = QoS, packet_id = PacketId}, + Now, State = #state{inflight = Inflight}) -> + Msg1 = Msg#mqtt_msg{dup = (QoS =:= ?QOS_1)}, + case send(Msg1, State) of + {ok, NewState} -> + Inflight1 = maps:put(PacketId, {publish, Msg1, Now}, Inflight), + {ok, NewState#state{inflight = Inflight1}}; + Error = {error, _Reason} -> + Error + end; +retry_send(pubrel, PacketId, Now, State = #state{inflight = Inflight}) -> + case send(?PUBREL_PACKET(PacketId), State) of + {ok, NewState} -> + Inflight1 = maps:put(PacketId, {pubrel, PacketId, Now}, Inflight), + {ok, NewState#state{inflight = Inflight1}}; + Error = {error, _Reason} -> + Error + end. + +deliver(#mqtt_msg{qos = QoS, dup = Dup, retain = Retain, packet_id = PacketId, + topic = Topic, props = Props, payload = Payload}, + State) -> + Msg = #{qos => QoS, dup => Dup, retain => Retain, packet_id => PacketId, + topic => Topic, properties => Props, payload => Payload, + client_pid => self()}, + ok = eval_msg_handler(State, publish, Msg), + State. + +eval_msg_handler(#state{msg_handler = ?NO_MSG_HDLR, + owner = Owner}, + disconnected, {ReasonCode, Properties}) when is_integer(ReasonCode) -> + %% Special handling for disconnected message when there is no handler callback + Owner ! {disconnected, ReasonCode, Properties}, + ok; +eval_msg_handler(#state{msg_handler = ?NO_MSG_HDLR}, + disconnected, _OtherReason) -> + %% do nothing to be backward compatible + ok; +eval_msg_handler(#state{msg_handler = ?NO_MSG_HDLR, + owner = Owner}, Kind, Msg) -> + Owner ! {Kind, Msg}, + ok; +eval_msg_handler(#state{msg_handler = Handler}, Kind, Msg) -> + F = maps:get(Kind, Handler), + _ = apply_handler_function(F, Msg), + ok. + +apply_handler_function(F, Msg) + when is_function(F) -> + erlang:apply(F, [Msg]); +apply_handler_function({F, A}, Msg) + when is_function(F), + is_list(A) -> + erlang:apply(F, [Msg] ++ A); +apply_handler_function({M, F, A}, Msg) + when is_atom(M), + is_atom(F), + is_list(A) -> + erlang:apply(M, F, [Msg] ++ A). + +packet_to_msg(#mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, + dup = Dup, + qos = QoS, + retain = R}, + variable = #mqtt_packet_publish{topic_name = Topic, + packet_id = PacketId, + properties = Props}, + payload = Payload}) -> + #mqtt_msg{qos = QoS, retain = R, dup = Dup, packet_id = PacketId, + topic = Topic, props = Props, payload = Payload}. + +msg_to_packet(#mqtt_msg{qos = QoS, dup = Dup, retain = Retain, packet_id = PacketId, + topic = Topic, props = Props, payload = Payload}) -> + #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, + qos = QoS, + retain = Retain, + dup = Dup}, + variable = #mqtt_packet_publish{topic_name = Topic, + packet_id = PacketId, + properties = Props}, + payload = Payload}. + +%%-------------------------------------------------------------------- +%% Socket Connect/Send + +sock_connect(Hosts, SockOpts, Timeout) -> + sock_connect(Hosts, SockOpts, Timeout, {error, no_hosts}). + +sock_connect([], _SockOpts, _Timeout, LastErr) -> + LastErr; +sock_connect([{Host, Port} | Hosts], SockOpts, Timeout, _LastErr) -> + case emqtt_sock:connect(Host, Port, SockOpts, Timeout) of + {ok, SockOrPid} -> + {ok, SockOrPid}; + Error = {error, _Reason} -> + sock_connect(Hosts, SockOpts, Timeout, Error) + end. + +hosts(#state{hosts = [], host = Host, port = Port}) -> + [{Host, Port}]; +hosts(#state{hosts = Hosts}) -> Hosts. + +send_puback(Packet, State) -> + case send(Packet, State) of + {ok, NewState} -> {keep_state, NewState}; + {error, Reason} -> {stop, {shutdown, Reason}} + end. + +send(Msg, State) when is_record(Msg, mqtt_msg) -> + send(msg_to_packet(Msg), State); + +send(Packet, State = #state{socket = Sock, proto_ver = Ver}) + when is_record(Packet, mqtt_packet) -> + Data = emqtt_frame:serialize(Packet, Ver), + case emqtt_sock:send(Sock, Data) of + ok -> + {ok, bump_last_packet_id(State)}; + Error -> + Error + end. + +run_sock(State = #state{socket = Sock}) -> + emqtt_sock:setopts(Sock, [{active, once}]), State. + +%%-------------------------------------------------------------------- +%% Process incomming + +process_incoming(<<>>, Packets, State) -> + {keep_state, State, next_events(Packets)}; + +process_incoming(Bytes, Packets, State = #state{parse_state = ParseState}) -> + try emqtt_frame:parse(Bytes, ParseState) of + {ok, Packet, Rest, NParseState} -> + process_incoming(Rest, [Packet|Packets], State#state{parse_state = NParseState}); + {more, NParseState} -> + {keep_state, State#state{parse_state = NParseState}, next_events(Packets)} + catch + error:Error -> + {stop, Error} + end. + +-compile({inline, [next_events/1]}). +next_events([]) -> []; +next_events([Packet]) -> + {next_event, cast, Packet}; +next_events(Packets) -> + [{next_event, cast, Packet} || Packet <- lists:reverse(Packets)]. + +%%-------------------------------------------------------------------- +%% packet_id generation + +bump_last_packet_id(State = #state{last_packet_id = Id}) -> + State#state{last_packet_id = next_packet_id(Id)}. + +-spec next_packet_id(packet_id()) -> packet_id(). +next_packet_id(?MAX_PACKET_ID) -> + 1; +next_packet_id(Id) -> + Id + 1. + +%%-------------------------------------------------------------------- +%% ReasonCode Name + +reason_code_name(I, Ver) when Ver >= ?MQTT_PROTO_V5 -> + reason_code_name(I); +reason_code_name(0, _Ver) -> connection_accepted; +reason_code_name(1, _Ver) -> unacceptable_protocol_version; +reason_code_name(2, _Ver) -> client_identifier_not_valid; +reason_code_name(3, _Ver) -> server_unavaliable; +reason_code_name(4, _Ver) -> malformed_username_or_password; +reason_code_name(5, _Ver) -> unauthorized_client; +reason_code_name(_, _Ver) -> unknown_error. + +reason_code_name(16#00) -> success; +reason_code_name(16#01) -> granted_qos1; +reason_code_name(16#02) -> granted_qos2; +reason_code_name(16#04) -> disconnect_with_will_message; +reason_code_name(16#10) -> no_matching_subscribers; +reason_code_name(16#11) -> no_subscription_existed; +reason_code_name(16#18) -> continue_authentication; +reason_code_name(16#19) -> re_authenticate; +reason_code_name(16#80) -> unspecified_error; +reason_code_name(16#81) -> malformed_Packet; +reason_code_name(16#82) -> protocol_error; +reason_code_name(16#83) -> implementation_specific_error; +reason_code_name(16#84) -> unsupported_protocol_version; +reason_code_name(16#85) -> client_identifier_not_valid; +reason_code_name(16#86) -> bad_username_or_password; +reason_code_name(16#87) -> not_authorized; +reason_code_name(16#88) -> server_unavailable; +reason_code_name(16#89) -> server_busy; +reason_code_name(16#8A) -> banned; +reason_code_name(16#8B) -> server_shutting_down; +reason_code_name(16#8C) -> bad_authentication_method; +reason_code_name(16#8D) -> keepalive_timeout; +reason_code_name(16#8E) -> session_taken_over; +reason_code_name(16#8F) -> topic_filter_invalid; +reason_code_name(16#90) -> topic_name_invalid; +reason_code_name(16#91) -> packet_identifier_inuse; +reason_code_name(16#92) -> packet_identifier_not_found; +reason_code_name(16#93) -> receive_maximum_exceeded; +reason_code_name(16#94) -> topic_alias_invalid; +reason_code_name(16#95) -> packet_too_large; +reason_code_name(16#96) -> message_rate_too_high; +reason_code_name(16#97) -> quota_exceeded; +reason_code_name(16#98) -> administrative_action; +reason_code_name(16#99) -> payload_format_invalid; +reason_code_name(16#9A) -> retain_not_supported; +reason_code_name(16#9B) -> qos_not_supported; +reason_code_name(16#9C) -> use_another_server; +reason_code_name(16#9D) -> server_moved; +reason_code_name(16#9E) -> shared_subscriptions_not_supported; +reason_code_name(16#9F) -> connection_rate_exceeded; +reason_code_name(16#A0) -> maximum_connect_time; +reason_code_name(16#A1) -> subscription_identifiers_not_supported; +reason_code_name(16#A2) -> wildcard_subscriptions_not_supported; +reason_code_name(_Code) -> unknown_error. diff --git a/apps/power_gateway/src/emqtt/emqtt_frame.erl b/apps/power_gateway/src/emqtt/emqtt_frame.erl new file mode 100644 index 0000000..96393f3 --- /dev/null +++ b/apps/power_gateway/src/emqtt/emqtt_frame.erl @@ -0,0 +1,738 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 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(emqtt_frame). + +-include("emqtt.hrl"). + +-export([initial_parse_state/0, initial_parse_state/1]). + +-export([parse/1, parse/2, serialize_fun/0, serialize_fun/1, serialize/1, serialize/2 ]). + +-export_type([options/0, parse_state/0, parse_result/0, serialize_fun/0]). + +-type(version() :: ?MQTT_PROTO_V3 + | ?MQTT_PROTO_V4 + | ?MQTT_PROTO_V5). + +-type(options() :: #{strict_mode => boolean(), + max_size => 1..?MAX_PACKET_SIZE, + version => version()}). + +-opaque(parse_state() :: {none, options()} | cont_fun()). + +-opaque(parse_result() :: {more, cont_fun()} + | {ok, #mqtt_packet{}, binary(), parse_state()}). + +-type(cont_fun() :: fun((binary()) -> parse_result())). + +-type(serialize_fun() :: fun((emqx_types:packet()) -> iodata())). + +-define(none(Options), {none, Options}). + +-define(DEFAULT_OPTIONS, + #{strict_mode => false, + max_size => ?MAX_PACKET_SIZE, + version => ?MQTT_PROTO_V4 + }). + +%%-------------------------------------------------------------------- +%% Init Parse State +%%-------------------------------------------------------------------- + +-spec(initial_parse_state() -> {none, options()}). +initial_parse_state() -> + initial_parse_state(#{}). + +-spec(initial_parse_state(options()) -> {none, options()}). +initial_parse_state(Options) when is_map(Options) -> + ?none(merge_opts(Options)). + +%% @pivate +merge_opts(Options) -> + maps:merge(?DEFAULT_OPTIONS, Options). + +%%-------------------------------------------------------------------- +%% Parse MQTT Frame +%%-------------------------------------------------------------------- + +-spec(parse(binary()) -> parse_result()). +parse(Bin) -> + parse(Bin, initial_parse_state()). + +-spec(parse(binary(), parse_state()) -> parse_result()). +parse(<<>>, {none, Options}) -> + {more, fun(Bin) -> parse(Bin, {none, Options}) end}; +parse(<>, + {none, Options = #{strict_mode := StrictMode}}) -> + %% Validate header if strict mode. + StrictMode andalso validate_header(Type, Dup, QoS, Retain), + Header = #mqtt_packet_header{type = Type, + dup = bool(Dup), + qos = QoS, + retain = bool(Retain) + }, + Header1 = case fixqos(Type, QoS) of + QoS -> Header; + FixedQoS -> Header#mqtt_packet_header{qos = FixedQoS} + end, + parse_remaining_len(Rest, Header1, Options); +parse(Bin, Cont) when is_binary(Bin), is_function(Cont) -> + Cont(Bin). + +parse_remaining_len(<<>>, Header, Options) -> + {more, fun(Bin) -> parse_remaining_len(Bin, Header, Options) end}; +parse_remaining_len(Rest, Header, Options) -> + parse_remaining_len(Rest, Header, 1, 0, Options). + +parse_remaining_len(_Bin, _Header, _Multiplier, Length, #{max_size := MaxSize}) when Length > MaxSize -> + error(frame_too_large); +parse_remaining_len(<<>>, Header, Multiplier, Length, Options) -> + {more, fun(Bin) -> parse_remaining_len(Bin, Header, Multiplier, Length, Options) end}; +%% Match DISCONNECT without payload +parse_remaining_len(<<0:8, Rest/binary>>, Header = #mqtt_packet_header{type = ?DISCONNECT}, 1, 0, Options) -> + Packet = packet(Header, #mqtt_packet_disconnect{reason_code = ?RC_SUCCESS}), + {ok, Packet, Rest, ?none(Options)}; +%% Match PINGREQ. +parse_remaining_len(<<0:8, Rest/binary>>, Header, 1, 0, Options) -> + parse_frame(Rest, Header, 0, Options); +%% Match PUBACK, PUBREC, PUBREL, PUBCOMP, UNSUBACK... +parse_remaining_len(<<0:1, 2:7, Rest/binary>>, Header, 1, 0, Options) -> + parse_frame(Rest, Header, 2, Options); +parse_remaining_len(<<1:1, Len:7, Rest/binary>>, Header, Multiplier, Value, Options) -> + parse_remaining_len(Rest, Header, Multiplier * ?HIGHBIT, Value + Len * Multiplier, Options); +parse_remaining_len(<<0:1, Len:7, Rest/binary>>, Header, Multiplier, Value, + Options = #{max_size := MaxSize}) -> + FrameLen = Value + Len * Multiplier, + if + FrameLen > MaxSize -> + error(frame_too_large); + true -> + parse_frame(Rest, Header, FrameLen, Options) + end. + +parse_frame(Bin, Header, 0, Options) -> + {ok, packet(Header), Bin, ?none(Options)}; + +parse_frame(Bin, Header, Length, Options) -> + case Bin of + <> -> + case parse_packet(Header, FrameBin, Options) of + {Variable, Payload} -> + {ok, packet(Header, Variable, Payload), Rest, ?none(Options)}; + Variable = #mqtt_packet_connect{proto_ver = Ver} -> + {ok, packet(Header, Variable), Rest, ?none(Options#{version := Ver})}; + Variable -> + {ok, packet(Header, Variable), Rest, ?none(Options)} + end; + TooShortBin -> + {more, fun(BinMore) -> + parse_frame(<>, Header, Length, Options) + end} + end. + +-compile({inline, [packet/1, packet/2, packet/3]}). +packet(Header) -> + #mqtt_packet{header = Header}. +packet(Header, Variable) -> + #mqtt_packet{header = Header, variable = Variable}. +packet(Header, Variable, Payload) -> + #mqtt_packet{header = Header, variable = Variable, payload = Payload}. + +parse_packet(#mqtt_packet_header{type = ?CONNECT}, FrameBin, _Options) -> + {ProtoName, Rest} = parse_utf8_string(FrameBin), + <> = Rest, + % Note: Crash when reserved flag doesn't equal to 0, there is no strict + % compliance with the MQTT5.0. + <> = Rest1, + + {Properties, Rest3} = parse_properties(Rest2, ProtoVer), + {ClientId, Rest4} = parse_utf8_string(Rest3), + ConnPacket = #mqtt_packet_connect{proto_name = ProtoName, + proto_ver = ProtoVer, + is_bridge = (BridgeTag =:= 8), + clean_start = bool(CleanStart), + will_flag = bool(WillFlag), + will_qos = WillQoS, + will_retain = bool(WillRetain), + keepalive = KeepAlive, + properties = Properties, + clientid = ClientId + }, + {ConnPacket1, Rest5} = parse_will_message(ConnPacket, Rest4), + {Username, Rest6} = parse_utf8_string(Rest5, bool(UsernameFlag)), + {Passsword, <<>>} = parse_utf8_string(Rest6, bool(PasswordFlag)), + ConnPacket1#mqtt_packet_connect{username = Username, password = Passsword}; + +parse_packet(#mqtt_packet_header{type = ?CONNACK}, + <>, #{version := Ver}) -> + {Properties, <<>>} = parse_properties(Rest, Ver), + #mqtt_packet_connack{ack_flags = AckFlags, + reason_code = ReasonCode, + properties = Properties + }; + +parse_packet(#mqtt_packet_header{type = ?PUBLISH, qos = QoS}, Bin, + #{strict_mode := StrictMode, version := Ver}) -> + {TopicName, Rest} = parse_utf8_string(Bin), + {PacketId, Rest1} = case QoS of + ?QOS_0 -> {undefined, Rest}; + _ -> parse_packet_id(Rest) + end, + (PacketId =/= undefined) andalso + StrictMode andalso validate_packet_id(PacketId), + {Properties, Payload} = parse_properties(Rest1, Ver), + Publish = #mqtt_packet_publish{topic_name = TopicName, + packet_id = PacketId, + properties = Properties + }, + {Publish, Payload}; + +parse_packet(#mqtt_packet_header{type = PubAck}, <>, #{strict_mode := StrictMode}) + when ?PUBACK =< PubAck, PubAck =< ?PUBCOMP -> + StrictMode andalso validate_packet_id(PacketId), + #mqtt_packet_puback{packet_id = PacketId, reason_code = 0}; + +parse_packet(#mqtt_packet_header{type = PubAck}, <>, + #{strict_mode := StrictMode, version := Ver = ?MQTT_PROTO_V5}) + when ?PUBACK =< PubAck, PubAck =< ?PUBCOMP -> + StrictMode andalso validate_packet_id(PacketId), + {Properties, <<>>} = parse_properties(Rest, Ver), + #mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties + }; + +parse_packet(#mqtt_packet_header{type = ?SUBSCRIBE}, <>, + #{strict_mode := StrictMode, version := Ver}) -> + StrictMode andalso validate_packet_id(PacketId), + {Properties, Rest1} = parse_properties(Rest, Ver), + TopicFilters = parse_topic_filters(subscribe, Rest1), + ok = validate_subqos([QoS || {_, #{qos := QoS}} <- TopicFilters]), + #mqtt_packet_subscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters + }; + +parse_packet(#mqtt_packet_header{type = ?SUBACK}, <>, + #{strict_mode := StrictMode, version := Ver}) -> + StrictMode andalso validate_packet_id(PacketId), + {Properties, Rest1} = parse_properties(Rest, Ver), + ReasonCodes = parse_reason_codes(Rest1), + #mqtt_packet_suback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes + }; + +parse_packet(#mqtt_packet_header{type = ?UNSUBSCRIBE}, <>, + #{strict_mode := StrictMode, version := Ver}) -> + StrictMode andalso validate_packet_id(PacketId), + {Properties, Rest1} = parse_properties(Rest, Ver), + TopicFilters = parse_topic_filters(unsubscribe, Rest1), + #mqtt_packet_unsubscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters + }; + +parse_packet(#mqtt_packet_header{type = ?UNSUBACK}, <>, + #{strict_mode := StrictMode}) -> + StrictMode andalso validate_packet_id(PacketId), + #mqtt_packet_unsuback{packet_id = PacketId}; + +parse_packet(#mqtt_packet_header{type = ?UNSUBACK}, <>, + #{strict_mode := StrictMode, version := Ver}) -> + StrictMode andalso validate_packet_id(PacketId), + {Properties, Rest1} = parse_properties(Rest, Ver), + ReasonCodes = parse_reason_codes(Rest1), + #mqtt_packet_unsuback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes + }; + +parse_packet(#mqtt_packet_header{type = ?DISCONNECT}, <>, + #{version := ?MQTT_PROTO_V5}) -> + {Properties, <<>>} = parse_properties(Rest, ?MQTT_PROTO_V5), + #mqtt_packet_disconnect{reason_code = ReasonCode, + properties = Properties + }; + +parse_packet(#mqtt_packet_header{type = ?AUTH}, <>, + #{version := ?MQTT_PROTO_V5}) -> + {Properties, <<>>} = parse_properties(Rest, ?MQTT_PROTO_V5), + #mqtt_packet_auth{reason_code = ReasonCode, properties = Properties}. + +parse_will_message(Packet = #mqtt_packet_connect{will_flag = true, + proto_ver = Ver}, Bin) -> + {Props, Rest} = parse_properties(Bin, Ver), + {Topic, Rest1} = parse_utf8_string(Rest), + {Payload, Rest2} = parse_binary_data(Rest1), + {Packet#mqtt_packet_connect{will_props = Props, + will_topic = Topic, + will_payload = Payload + }, Rest2}; +parse_will_message(Packet, Bin) -> {Packet, Bin}. + +-compile({inline, [parse_packet_id/1]}). +parse_packet_id(<>) -> + {PacketId, Rest}. + +parse_properties(Bin, Ver) when Ver =/= ?MQTT_PROTO_V5 -> + {undefined, Bin}; +%% TODO: version mess? +parse_properties(<<>>, ?MQTT_PROTO_V5) -> + {#{}, <<>>}; +parse_properties(<<0, Rest/binary>>, ?MQTT_PROTO_V5) -> + {#{}, Rest}; +parse_properties(Bin, ?MQTT_PROTO_V5) -> + {Len, Rest} = parse_variable_byte_integer(Bin), + <> = Rest, + {parse_property(PropsBin, #{}), Rest1}. + +parse_property(<<>>, Props) -> + Props; +parse_property(<<16#01, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Payload-Format-Indicator' => Val}); +parse_property(<<16#02, Val:32/big, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Message-Expiry-Interval' => Val}); +parse_property(<<16#03, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Content-Type' => Val}); +parse_property(<<16#08, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Response-Topic' => Val}); +parse_property(<<16#09, Len:16/big, Val:Len/binary, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Correlation-Data' => Val}); +parse_property(<<16#0B, Bin/binary>>, Props) -> + {Val, Rest} = parse_variable_byte_integer(Bin), + parse_property(Rest, Props#{'Subscription-Identifier' => Val}); +parse_property(<<16#11, Val:32/big, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Session-Expiry-Interval' => Val}); +parse_property(<<16#12, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Assigned-Client-Identifier' => Val}); +parse_property(<<16#13, Val:16, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Server-Keep-Alive' => Val}); +parse_property(<<16#15, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Authentication-Method' => Val}); +parse_property(<<16#16, Len:16/big, Val:Len/binary, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Authentication-Data' => Val}); +parse_property(<<16#17, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Request-Problem-Information' => Val}); +parse_property(<<16#18, Val:32, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Will-Delay-Interval' => Val}); +parse_property(<<16#19, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Request-Response-Information' => Val}); +parse_property(<<16#1A, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Response-Information' => Val}); +parse_property(<<16#1C, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Server-Reference' => Val}); +parse_property(<<16#1F, Bin/binary>>, Props) -> + {Val, Rest} = parse_utf8_string(Bin), + parse_property(Rest, Props#{'Reason-String' => Val}); +parse_property(<<16#21, Val:16/big, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Receive-Maximum' => Val}); +parse_property(<<16#22, Val:16/big, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Topic-Alias-Maximum' => Val}); +parse_property(<<16#23, Val:16/big, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Topic-Alias' => Val}); +parse_property(<<16#24, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Maximum-QoS' => Val}); +parse_property(<<16#25, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Retain-Available' => Val}); +parse_property(<<16#26, Bin/binary>>, Props) -> + {Pair, Rest} = parse_utf8_pair(Bin), + case maps:find('User-Property', Props) of + {ok, UserProps} -> + UserProps1 = lists:append(UserProps, [Pair]), + parse_property(Rest, Props#{'User-Property' := UserProps1}); + error -> + parse_property(Rest, Props#{'User-Property' => [Pair]}) + end; +parse_property(<<16#27, Val:32, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Maximum-Packet-Size' => Val}); +parse_property(<<16#28, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Wildcard-Subscription-Available' => Val}); +parse_property(<<16#29, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Subscription-Identifier-Available' => Val}); +parse_property(<<16#2A, Val, Bin/binary>>, Props) -> + parse_property(Bin, Props#{'Shared-Subscription-Available' => Val}). + +parse_variable_byte_integer(Bin) -> + parse_variable_byte_integer(Bin, 1, 0). +parse_variable_byte_integer(<<1:1, Len:7, Rest/binary>>, Multiplier, Value) -> + parse_variable_byte_integer(Rest, Multiplier * ?HIGHBIT, Value + Len * Multiplier); +parse_variable_byte_integer(<<0:1, Len:7, Rest/binary>>, Multiplier, Value) -> + {Value + Len * Multiplier, Rest}. + +parse_topic_filters(subscribe, Bin) -> + [{Topic, #{rh => Rh, rap => Rap, nl => Nl, qos => QoS}} + || <> <= Bin]; + +parse_topic_filters(unsubscribe, Bin) -> + [Topic || <> <= Bin]. + +parse_reason_codes(Bin) -> + [Code || <> <= Bin]. + +parse_utf8_pair(<>) -> + {{Key, Val}, Rest}. + +parse_utf8_string(Bin, false) -> + {undefined, Bin}; +parse_utf8_string(Bin, true) -> + parse_utf8_string(Bin). + +parse_utf8_string(<>) -> + {Str, Rest}. + +parse_binary_data(<>) -> + {Data, Rest}. + +%%-------------------------------------------------------------------- +%% Serialize MQTT Packet +%%-------------------------------------------------------------------- + +serialize_fun() -> serialize_fun(?DEFAULT_OPTIONS). + +serialize_fun(#mqtt_packet_connect{proto_ver = ProtoVer, properties = ConnProps}) -> + MaxSize = get_property('Maximum-Packet-Size', ConnProps, ?MAX_PACKET_SIZE), + serialize_fun(#{version => ProtoVer, max_size => MaxSize}); + +serialize_fun(#{version := Ver, max_size := MaxSize}) -> + fun(Packet) -> + IoData = serialize(Packet, Ver), + case is_too_large(IoData, MaxSize) of + true -> <<>>; + false -> IoData + end + end. + +-spec(serialize(#mqtt_packet{}) -> iodata()). +serialize(Packet) -> serialize(Packet, ?MQTT_PROTO_V4). + +-spec(serialize(#mqtt_packet{}, version()) -> iodata()). +serialize(#mqtt_packet{header = Header, + variable = Variable, + payload = Payload}, Ver) -> + serialize(Header, serialize_variable(Variable, Ver), serialize_payload(Payload)). + +serialize(#mqtt_packet_header{type = Type, + dup = Dup, + qos = QoS, + retain = Retain + }, VariableBin, PayloadBin) + when ?CONNECT =< Type andalso Type =< ?AUTH -> + Len = iolist_size(VariableBin) + iolist_size(PayloadBin), + [<>, + serialize_remaining_len(Len), VariableBin, PayloadBin]. + +serialize_variable(#mqtt_packet_connect{ + proto_name = ProtoName, + proto_ver = ProtoVer, + is_bridge = IsBridge, + clean_start = CleanStart, + will_flag = WillFlag, + will_qos = WillQoS, + will_retain = WillRetain, + keepalive = KeepAlive, + properties = Properties, + clientid = ClientId, + will_props = WillProps, + will_topic = WillTopic, + will_payload = WillPayload, + username = Username, + password = Password}, _Ver) -> + [serialize_binary_data(ProtoName), + <<(case IsBridge of + true -> 16#80 + ProtoVer; + false -> ProtoVer + end):8, + (flag(Username)):1, + (flag(Password)):1, + (flag(WillRetain)):1, + WillQoS:2, + (flag(WillFlag)):1, + (flag(CleanStart)):1, + 0:1, + KeepAlive:16/big-unsigned-integer>>, + serialize_properties(Properties, ProtoVer), + serialize_utf8_string(ClientId), + case WillFlag of + true -> [serialize_properties(WillProps, ProtoVer), + serialize_utf8_string(WillTopic), + serialize_binary_data(WillPayload)]; + false -> <<>> + end, + serialize_utf8_string(Username, true), + serialize_utf8_string(Password, true)]; + +serialize_variable(#mqtt_packet_connack{ack_flags = AckFlags, + reason_code = ReasonCode, + properties = Properties}, Ver) -> + [AckFlags, ReasonCode, serialize_properties(Properties, Ver)]; + +serialize_variable(#mqtt_packet_publish{topic_name = TopicName, + packet_id = PacketId, + properties = Properties}, Ver) -> + [serialize_utf8_string(TopicName), + if + PacketId =:= undefined -> <<>>; + true -> <> + end, + serialize_properties(Properties, Ver)]; + +serialize_variable(#mqtt_packet_puback{packet_id = PacketId}, Ver) + when Ver == ?MQTT_PROTO_V3; Ver == ?MQTT_PROTO_V4 -> + <>; +serialize_variable(#mqtt_packet_puback{packet_id = PacketId, + reason_code = ReasonCode, + properties = Properties + }, + Ver = ?MQTT_PROTO_V5) -> + [<>, ReasonCode, + serialize_properties(Properties, Ver)]; + +serialize_variable(#mqtt_packet_subscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters}, Ver) -> + [<>, serialize_properties(Properties, Ver), + serialize_topic_filters(subscribe, TopicFilters, Ver)]; + +serialize_variable(#mqtt_packet_suback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes}, Ver) -> + [<>, serialize_properties(Properties, Ver), + serialize_reason_codes(ReasonCodes)]; + +serialize_variable(#mqtt_packet_unsubscribe{packet_id = PacketId, + properties = Properties, + topic_filters = TopicFilters}, Ver) -> + [<>, serialize_properties(Properties, Ver), + serialize_topic_filters(unsubscribe, TopicFilters, Ver)]; + +serialize_variable(#mqtt_packet_unsuback{packet_id = PacketId, + properties = Properties, + reason_codes = ReasonCodes}, Ver) -> + [<>, serialize_properties(Properties, Ver), + serialize_reason_codes(ReasonCodes)]; + +serialize_variable(#mqtt_packet_disconnect{}, Ver) + when Ver == ?MQTT_PROTO_V3; Ver == ?MQTT_PROTO_V4 -> + <<>>; + +serialize_variable(#mqtt_packet_disconnect{reason_code = ReasonCode, + properties = Properties}, + Ver = ?MQTT_PROTO_V5) -> + [ReasonCode, serialize_properties(Properties, Ver)]; +serialize_variable(#mqtt_packet_disconnect{}, _Ver) -> + <<>>; + +serialize_variable(#mqtt_packet_auth{reason_code = ReasonCode, + properties = Properties}, + Ver = ?MQTT_PROTO_V5) -> + [ReasonCode, serialize_properties(Properties, Ver)]; + +serialize_variable(PacketId, ?MQTT_PROTO_V3) when is_integer(PacketId) -> + <>; +serialize_variable(PacketId, ?MQTT_PROTO_V4) when is_integer(PacketId) -> + <>; +serialize_variable(undefined, _Ver) -> + <<>>. + +serialize_payload(undefined) -> <<>>; +serialize_payload(Bin) -> Bin. + +serialize_properties(_Props, Ver) when Ver =/= ?MQTT_PROTO_V5 -> + <<>>; +serialize_properties(Props, ?MQTT_PROTO_V5) -> + serialize_properties(Props). + +serialize_properties(undefined) -> + <<0>>; +serialize_properties(Props) when map_size(Props) == 0 -> + <<0>>; +serialize_properties(Props) when is_map(Props) -> + Bin = << <<(serialize_property(Prop, Val))/binary>> || {Prop, Val} <- maps:to_list(Props) >>, + [serialize_variable_byte_integer(byte_size(Bin)), Bin]. + +serialize_property(_, undefined) -> + <<>>; +serialize_property('Payload-Format-Indicator', Val) -> + <<16#01, Val>>; +serialize_property('Message-Expiry-Interval', Val) -> + <<16#02, Val:32/big>>; +serialize_property('Content-Type', Val) -> + <<16#03, (serialize_utf8_string(Val))/binary>>; +serialize_property('Response-Topic', Val) -> + <<16#08, (serialize_utf8_string(Val))/binary>>; +serialize_property('Correlation-Data', Val) -> + <<16#09, (byte_size(Val)):16, Val/binary>>; +serialize_property('Subscription-Identifier', Val) -> + <<16#0B, (serialize_variable_byte_integer(Val))/binary>>; +serialize_property('Session-Expiry-Interval', Val) -> + <<16#11, Val:32/big>>; +serialize_property('Assigned-Client-Identifier', Val) -> + <<16#12, (serialize_utf8_string(Val))/binary>>; +serialize_property('Server-Keep-Alive', Val) -> + <<16#13, Val:16/big>>; +serialize_property('Authentication-Method', Val) -> + <<16#15, (serialize_utf8_string(Val))/binary>>; +serialize_property('Authentication-Data', Val) -> + <<16#16, (iolist_size(Val)):16, Val/binary>>; +serialize_property('Request-Problem-Information', Val) -> + <<16#17, Val>>; +serialize_property('Will-Delay-Interval', Val) -> + <<16#18, Val:32/big>>; +serialize_property('Request-Response-Information', Val) -> + <<16#19, Val>>; +serialize_property('Response-Information', Val) -> + <<16#1A, (serialize_utf8_string(Val))/binary>>; +serialize_property('Server-Reference', Val) -> + <<16#1C, (serialize_utf8_string(Val))/binary>>; +serialize_property('Reason-String', Val) -> + <<16#1F, (serialize_utf8_string(Val))/binary>>; +serialize_property('Receive-Maximum', Val) -> + <<16#21, Val:16/big>>; +serialize_property('Topic-Alias-Maximum', Val) -> + <<16#22, Val:16/big>>; +serialize_property('Topic-Alias', Val) -> + <<16#23, Val:16/big>>; +serialize_property('Maximum-QoS', Val) -> + <<16#24, Val>>; +serialize_property('Retain-Available', Val) -> + <<16#25, Val>>; +serialize_property('User-Property', {Key, Val}) -> + <<16#26, (serialize_utf8_pair({Key, Val}))/binary>>; +serialize_property('User-Property', Props) when is_list(Props) -> + << <<(serialize_property('User-Property', {Key, Val}))/binary>> + || {Key, Val} <- Props >>; +serialize_property('Maximum-Packet-Size', Val) -> + <<16#27, Val:32/big>>; +serialize_property('Wildcard-Subscription-Available', Val) -> + <<16#28, Val>>; +serialize_property('Subscription-Identifier-Available', Val) -> + <<16#29, Val>>; +serialize_property('Shared-Subscription-Available', Val) -> + <<16#2A, Val>>. + +serialize_topic_filters(subscribe, TopicFilters, ?MQTT_PROTO_V5) -> + << <<(serialize_utf8_string(Topic))/binary, + ?RESERVED:2, Rh:2, (flag(Rap)):1,(flag(Nl)):1, QoS:2 >> + || {Topic, #{rh := Rh, rap := Rap, nl := Nl, qos := QoS}} <- TopicFilters >>; + +serialize_topic_filters(subscribe, TopicFilters, _Ver) -> + << <<(serialize_utf8_string(Topic))/binary, ?RESERVED:6, QoS:2>> + || {Topic, #{qos := QoS}} <- TopicFilters >>; + +serialize_topic_filters(unsubscribe, TopicFilters, _Ver) -> + << <<(serialize_utf8_string(Topic))/binary>> || Topic <- TopicFilters >>. + +serialize_reason_codes(undefined) -> + <<>>; +serialize_reason_codes(ReasonCodes) when is_list(ReasonCodes) -> + << <> || Code <- ReasonCodes >>. + +serialize_utf8_pair({Name, Value}) -> + << (serialize_utf8_string(Name))/binary, (serialize_utf8_string(Value))/binary >>. + +serialize_binary_data(Bin) -> + [<<(byte_size(Bin)):16/big-unsigned-integer>>, Bin]. + +serialize_utf8_string(undefined, false) -> + error(utf8_string_undefined); +serialize_utf8_string(undefined, true) -> + <<>>; +serialize_utf8_string(String, _AllowNull) -> + serialize_utf8_string(String). + +serialize_utf8_string(String) -> + StringBin = unicode:characters_to_binary(String), + Len = byte_size(StringBin), + true = (Len =< 16#ffff), + <>. + +serialize_remaining_len(I) -> + serialize_variable_byte_integer(I). + +serialize_variable_byte_integer(N) when N =< ?LOWBITS -> + <<0:1, N:7>>; +serialize_variable_byte_integer(N) -> + <<1:1, (N rem ?HIGHBIT):7, (serialize_variable_byte_integer(N div ?HIGHBIT))/binary>>. + +%% Is the frame too large? +-spec(is_too_large(iodata(), pos_integer()) -> boolean()). +is_too_large(IoData, MaxSize) -> + iolist_size(IoData) >= MaxSize. + +get_property(_Key, undefined, Default) -> + Default; +get_property(Key, Props, Default) -> + maps:get(Key, Props, Default). + +%% Validate header if sctrict mode. See: mqtt-v5.0: 2.1.3 Flags +validate_header(?CONNECT, 0, 0, 0) -> ok; +validate_header(?CONNACK, 0, 0, 0) -> ok; +validate_header(?PUBLISH, 0, ?QOS_0, _) -> ok; +validate_header(?PUBLISH, _, ?QOS_1, _) -> ok; +validate_header(?PUBLISH, 0, ?QOS_2, _) -> ok; +validate_header(?PUBACK, 0, 0, 0) -> ok; +validate_header(?PUBREC, 0, 0, 0) -> ok; +validate_header(?PUBREL, 0, 1, 0) -> ok; +validate_header(?PUBCOMP, 0, 0, 0) -> ok; +validate_header(?SUBSCRIBE, 0, 1, 0) -> ok; +validate_header(?SUBACK, 0, 0, 0) -> ok; +validate_header(?UNSUBSCRIBE, 0, 1, 0) -> ok; +validate_header(?UNSUBACK, 0, 0, 0) -> ok; +validate_header(?PINGREQ, 0, 0, 0) -> ok; +validate_header(?PINGRESP, 0, 0, 0) -> ok; +validate_header(?DISCONNECT, 0, 0, 0) -> ok; +validate_header(?AUTH, 0, 0, 0) -> ok; +validate_header(_Type, _Dup, _QoS, _Rt) -> error(bad_frame_header). + +-compile({inline, [validate_packet_id/1]}). +validate_packet_id(0) -> error(bad_packet_id); +validate_packet_id(_) -> ok. + +validate_subqos([3|_]) -> error(bad_subqos); +validate_subqos([_|T]) -> validate_subqos(T); +validate_subqos([]) -> ok. + +bool(0) -> false; +bool(1) -> true. + +flag(undefined) -> ?RESERVED; +flag(false) -> 0; +flag(true) -> 1; +flag(X) when is_integer(X) -> X; +flag(B) when is_binary(B) -> 1. + +fixqos(?PUBREL, 0) -> 1; +fixqos(?SUBSCRIBE, 0) -> 1; +fixqos(?UNSUBSCRIBE, 0) -> 1; +fixqos(_Type, QoS) -> QoS. + diff --git a/apps/power_gateway/src/emqtt/emqtt_props.erl b/apps/power_gateway/src/emqtt/emqtt_props.erl new file mode 100644 index 0000000..a30f037 --- /dev/null +++ b/apps/power_gateway/src/emqtt/emqtt_props.erl @@ -0,0 +1,172 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 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. +%%-------------------------------------------------------------------- + +%% @doc MQTT5 Properties +-module(emqtt_props). + +-include("emqtt.hrl"). + +-export([id/1, name/1, filter/2, validate/1]). + +%% For tests +-export([all/0]). + +-type(prop_name() :: atom()). +-type(prop_id() :: pos_integer()). + +-define(PROPS_TABLE, + #{16#01 => {'Payload-Format-Indicator', 'Byte', [?PUBLISH]}, + 16#02 => {'Message-Expiry-Interval', 'Four-Byte-Integer', [?PUBLISH]}, + 16#03 => {'Content-Type', 'UTF8-Encoded-String', [?PUBLISH]}, + 16#08 => {'Response-Topic', 'UTF8-Encoded-String', [?PUBLISH]}, + 16#09 => {'Correlation-Data', 'Binary-Data', [?PUBLISH]}, + 16#0B => {'Subscription-Identifier', 'Variable-Byte-Integer', [?PUBLISH, ?SUBSCRIBE]}, + 16#11 => {'Session-Expiry-Interval', 'Four-Byte-Integer', [?CONNECT, ?CONNACK, ?DISCONNECT]}, + 16#12 => {'Assigned-Client-Identifier', 'UTF8-Encoded-String', [?CONNACK]}, + 16#13 => {'Server-Keep-Alive', 'Two-Byte-Integer', [?CONNACK]}, + 16#15 => {'Authentication-Method', 'UTF8-Encoded-String', [?CONNECT, ?CONNACK, ?AUTH]}, + 16#16 => {'Authentication-Data', 'Binary-Data', [?CONNECT, ?CONNACK, ?AUTH]}, + 16#17 => {'Request-Problem-Information', 'Byte', [?CONNECT]}, + 16#18 => {'Will-Delay-Interval', 'Four-Byte-Integer', ['WILL']}, + 16#19 => {'Request-Response-Information', 'Byte', [?CONNECT]}, + 16#1A => {'Response-Information', 'UTF8-Encoded-String', [?CONNACK]}, + 16#1C => {'Server-Reference', 'UTF8-Encoded-String', [?CONNACK, ?DISCONNECT]}, + 16#1F => {'Reason-String', 'UTF8-Encoded-String', [?CONNACK, ?DISCONNECT, ?PUBACK, + ?PUBREC, ?PUBREL, ?PUBCOMP, + ?SUBACK, ?UNSUBACK, ?AUTH]}, + 16#21 => {'Receive-Maximum', 'Two-Byte-Integer', [?CONNECT, ?CONNACK]}, + 16#22 => {'Topic-Alias-Maximum', 'Two-Byte-Integer', [?CONNECT, ?CONNACK]}, + 16#23 => {'Topic-Alias', 'Two-Byte-Integer', [?PUBLISH]}, + 16#24 => {'Maximum-QoS', 'Byte', [?CONNACK]}, + 16#25 => {'Retain-Available', 'Byte', [?CONNACK]}, + 16#26 => {'User-Property', 'UTF8-String-Pair', 'ALL'}, + 16#27 => {'Maximum-Packet-Size', 'Four-Byte-Integer', [?CONNECT, ?CONNACK]}, + 16#28 => {'Wildcard-Subscription-Available', 'Byte', [?CONNACK]}, + 16#29 => {'Subscription-Identifier-Available', 'Byte', [?CONNACK]}, + 16#2A => {'Shared-Subscription-Available', 'Byte', [?CONNACK]} + }). + +-spec(id(prop_name()) -> prop_id()). +id('Payload-Format-Indicator') -> 16#01; +id('Message-Expiry-Interval') -> 16#02; +id('Content-Type') -> 16#03; +id('Response-Topic') -> 16#08; +id('Correlation-Data') -> 16#09; +id('Subscription-Identifier') -> 16#0B; +id('Session-Expiry-Interval') -> 16#11; +id('Assigned-Client-Identifier') -> 16#12; +id('Server-Keep-Alive') -> 16#13; +id('Authentication-Method') -> 16#15; +id('Authentication-Data') -> 16#16; +id('Request-Problem-Information') -> 16#17; +id('Will-Delay-Interval') -> 16#18; +id('Request-Response-Information') -> 16#19; +id('Response-Information') -> 16#1A; +id('Server-Reference') -> 16#1C; +id('Reason-String') -> 16#1F; +id('Receive-Maximum') -> 16#21; +id('Topic-Alias-Maximum') -> 16#22; +id('Topic-Alias') -> 16#23; +id('Maximum-QoS') -> 16#24; +id('Retain-Available') -> 16#25; +id('User-Property') -> 16#26; +id('Maximum-Packet-Size') -> 16#27; +id('Wildcard-Subscription-Available') -> 16#28; +id('Subscription-Identifier-Available') -> 16#29; +id('Shared-Subscription-Available') -> 16#2A; +id(Name) -> error({bad_property, Name}). + +-spec(name(prop_id()) -> prop_name()). +name(16#01) -> 'Payload-Format-Indicator'; +name(16#02) -> 'Message-Expiry-Interval'; +name(16#03) -> 'Content-Type'; +name(16#08) -> 'Response-Topic'; +name(16#09) -> 'Correlation-Data'; +name(16#0B) -> 'Subscription-Identifier'; +name(16#11) -> 'Session-Expiry-Interval'; +name(16#12) -> 'Assigned-Client-Identifier'; +name(16#13) -> 'Server-Keep-Alive'; +name(16#15) -> 'Authentication-Method'; +name(16#16) -> 'Authentication-Data'; +name(16#17) -> 'Request-Problem-Information'; +name(16#18) -> 'Will-Delay-Interval'; +name(16#19) -> 'Request-Response-Information'; +name(16#1A) -> 'Response-Information'; +name(16#1C) -> 'Server-Reference'; +name(16#1F) -> 'Reason-String'; +name(16#21) -> 'Receive-Maximum'; +name(16#22) -> 'Topic-Alias-Maximum'; +name(16#23) -> 'Topic-Alias'; +name(16#24) -> 'Maximum-QoS'; +name(16#25) -> 'Retain-Available'; +name(16#26) -> 'User-Property'; +name(16#27) -> 'Maximum-Packet-Size'; +name(16#28) -> 'Wildcard-Subscription-Available'; +name(16#29) -> 'Subscription-Identifier-Available'; +name(16#2A) -> 'Shared-Subscription-Available'; +name(Id) -> error({unsupported_property, Id}). + +filter(PacketType, Props) when is_map(Props) -> + maps:from_list(filter(PacketType, maps:to_list(Props))); + +filter(PacketType, Props) when ?CONNECT =< PacketType, PacketType =< ?AUTH, is_list(Props) -> + Filter = fun(Name) -> + case maps:find(id(Name), ?PROPS_TABLE) of + {ok, {Name, _Type, 'ALL'}} -> + true; + {ok, {Name, _Type, AllowedTypes}} -> + lists:member(PacketType, AllowedTypes); + error -> false + end + end, + [Prop || Prop = {Name, _} <- Props, Filter(Name)]. + +validate(Props) when is_map(Props) -> + lists:foreach(fun validate_prop/1, maps:to_list(Props)). + +validate_prop(Prop = {Name, Val}) -> + case maps:find(id(Name), ?PROPS_TABLE) of + {ok, {Name, Type, _}} -> + validate_value(Type, Val) + orelse error(bad_property, Prop); + error -> + error({bad_property, Prop}) + end. + +validate_value('Byte', Val) -> + is_integer(Val) andalso Val =< 16#FF; +validate_value('Two-Byte-Integer', Val) -> + is_integer(Val) andalso 0 =< Val andalso Val =< 16#FFFF; +validate_value('Four-Byte-Integer', Val) -> + is_integer(Val) andalso 0 =< Val andalso Val =< 16#FFFFFFFF; +validate_value('Variable-Byte-Integer', Val) -> + is_integer(Val) andalso 0 =< Val andalso Val =< 16#7FFFFFFF; +validate_value('UTF8-String-Pair', {Name, Val}) -> + validate_value('UTF8-Encoded-String', Name) + andalso validate_value('UTF8-Encoded-String', Val); +validate_value('UTF8-String-Pair', Pairs) when is_list(Pairs) -> + lists:foldl(fun(Pair, OK) -> + OK andalso validate_value('UTF8-String-Pair', Pair) + end, true, Pairs); +validate_value('UTF8-Encoded-String', Val) -> + is_binary(Val); +validate_value('Binary-Data', Val) -> + is_binary(Val); +validate_value(_Type, _Val) -> false. + +-spec(all() -> map()). +all() -> ?PROPS_TABLE. + diff --git a/apps/power_gateway/src/emqtt/emqtt_sock.erl b/apps/power_gateway/src/emqtt/emqtt_sock.erl new file mode 100644 index 0000000..05b234f --- /dev/null +++ b/apps/power_gateway/src/emqtt/emqtt_sock.erl @@ -0,0 +1,120 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 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(emqtt_sock). + +-export([connect/4, send/2, recv/2, close/1 ]). + +-export([ sockname/1, setopts/2, getstat/2 ]). + +-record(ssl_socket, { + tcp, + ssl +}). + +-type(socket() :: inet:socket() | #ssl_socket{}). + +-type(sockname() :: {inet:ip_address(), inet:port_number()}). + +-type(option() :: gen_tcp:connect_option() | {ssl_opts, [ssl:ssl_option()]}). + +-export_type([socket/0, option/0]). + +-define(DEFAULT_TCP_OPTIONS, [binary, {packet, raw}, {active, false}, + {nodelay, true}]). + +-spec(connect(inet:ip_address() | inet:hostname(), + inet:port_number(), [option()], timeout()) + -> {ok, socket()} | {error, term()}). +connect(Host, Port, SockOpts, Timeout) -> + TcpOpts = merge_opts(?DEFAULT_TCP_OPTIONS, + lists:keydelete(ssl_opts, 1, SockOpts)), + case gen_tcp:connect(Host, Port, TcpOpts, Timeout) of + {ok, Sock} -> + case lists:keyfind(ssl_opts, 1, SockOpts) of + {ssl_opts, SslOpts} -> + ssl_upgrade(Sock, SslOpts, Timeout); + false -> + {ok, Sock} + end; + {error, Reason} -> + {error, Reason} + end. + +ssl_upgrade(Sock, SslOpts, Timeout) -> + TlsVersions = proplists:get_value(versions, SslOpts, []), + Ciphers = proplists:get_value(ciphers, SslOpts, default_ciphers(TlsVersions)), + SslOpts2 = merge_opts(SslOpts, [{ciphers, Ciphers}]), + case ssl:connect(Sock, SslOpts2, Timeout) of + {ok, SslSock} -> + ok = ssl:controlling_process(SslSock, self()), + {ok, #ssl_socket{tcp = Sock, ssl = SslSock}}; + {error, Reason} -> + {error, Reason} + end. + +-spec(send(socket(), iodata()) -> ok | {error, einval | closed}). +send(Sock, Data) when is_port(Sock) -> + gen_tcp:send(Sock, Data); +send(#ssl_socket{ssl = SslSock}, Data) -> + ssl:send(SslSock, Data). + +-spec(recv(socket(), non_neg_integer()) + -> {ok, iodata()} | {error, closed | inet:posix()}). +recv(Sock, Length) when is_port(Sock) -> + gen_tcp:recv(Sock, Length); +recv(#ssl_socket{ssl = SslSock}, Length) -> + ssl:recv(SslSock, Length). + +-spec(close(socket()) -> ok). +close(Sock) when is_port(Sock) -> + gen_tcp:close(Sock); +close(#ssl_socket{ssl = SslSock}) -> + ssl:close(SslSock). + +-spec(setopts(socket(), [gen_tcp:option() | ssl:socketoption()]) -> ok). +setopts(Sock, Opts) when is_port(Sock) -> + inet:setopts(Sock, Opts); +setopts(#ssl_socket{ssl = SslSock}, Opts) -> + ssl:setopts(SslSock, Opts). + +-spec(getstat(socket(), [atom()]) + -> {ok, [{atom(), integer()}]} | {error, term()}). +getstat(Sock, Options) when is_port(Sock) -> + inet:getstat(Sock, Options); +getstat(#ssl_socket{tcp = Sock}, Options) -> + inet:getstat(Sock, Options). + +-spec(sockname(socket()) -> {ok, sockname()} | {error, term()}). +sockname(Sock) when is_port(Sock) -> + inet:sockname(Sock); +sockname(#ssl_socket{ssl = SslSock}) -> + ssl:sockname(SslSock). + +-spec(merge_opts(list(), list()) -> list()). +merge_opts(Defaults, Options) -> + lists:foldl( + fun({Opt, Val}, Acc) -> + lists:keystore(Opt, 1, Acc, {Opt, Val}); + (Opt, Acc) -> + lists:usort([Opt | Acc]) + end, Defaults, Options). + +default_ciphers(TlsVersions) -> + lists:foldl( + fun(TlsVer, Ciphers) -> + Ciphers ++ ssl:cipher_suites(all, TlsVer) + end, [], TlsVersions). \ No newline at end of file diff --git a/apps/power_gateway/src/http_protocol.erl b/apps/power_gateway/src/http_protocol.erl new file mode 100644 index 0000000..16e2ad4 --- /dev/null +++ b/apps/power_gateway/src/http_protocol.erl @@ -0,0 +1,92 @@ +%%%------------------------------------------------------------------- +%%% @author licheng5 +%%% @copyright (C) 2020, +%%% @doc +%%% +%%% @end +%%% Created : 26. 4月 2020 3:36 下午 +%%%------------------------------------------------------------------- +-module(http_protocol). +-author("licheng5"). + +%% API +-export([init/2]). +-export([json_data/1, json_error/2]). + +init(Req0, Opts = [Mod|_]) -> + Method = binary_to_list(cowboy_req:method(Req0)), + Path = binary_to_list(cowboy_req:path(Req0)), + + QueryStr = cowboy_req:qs(Req0), + {ok, Body, Req1} = parse_body(Req0), + + try Mod:handle_request(Method, Path, QueryStr, Body) of + {ok, StatusCode, Resp} -> + AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>), + Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of + true -> + Resp1 = zlib:gzip(Resp), + cowboy_req:reply(StatusCode, #{ + <<"Content-Type">> => <<"application/json;charset=utf-8">>, + <<"Content-Encoding">> => <<"gzip">> + }, Resp1, Req1); + false -> + cowboy_req:reply(StatusCode, #{ + <<"Content-Type">> => <<"application/json;charset=utf-8">> + }, Resp, Req1) + end, + {ok, Req2, Opts} + catch + throw:Error -> + ErrResp = json_error(-1, Error), + Req2 = cowboy_req:reply(404, #{ + <<"Content-Type">> => <<"application/json;charset=utf-8">> + }, ErrResp, Req1), + {ok, Req2, Opts}; + _:Error:Stack -> + lager:warning("[http_handler] get error: ~p, stack: ~p", [Error, Stack]), + Req2 = cowboy_req:reply(500, #{ + <<"Content-Type">> => <<"text/html;charset=utf-8">> + }, <<"Internal Server Error">>, Req1), + {ok, Req2, Opts} + end. + +%% 判断是否支持gzip +supported_gzip(AcceptEncoding) when is_binary(AcceptEncoding) -> + binary:match(AcceptEncoding, <<"gzip">>) =/= nomatch. + +parse_body(Req0) -> + ContentType = cowboy_req:header(<<"content-type">>, Req0), + case ContentType of + <<"application/x-www-form-urlencoded">> -> + {ok, PostParams0, Req1} = cowboy_req:read_urlencoded_body(Req0), + PostParams = maps:from_list(PostParams0), + {ok, jiffy:encode(PostParams, [force_utf8]), Req1}; + _ -> + {ok, Body, Req1} = read_body(Req0), + {ok, Body, Req1} + end. + +%% 读取请求体 +read_body(Req) -> + read_body(Req, <<>>). +read_body(Req, AccData) -> + case cowboy_req:read_body(Req) of + {ok, Data, Req1} -> + {ok, <>, Req1}; + {more, Data, Req1} -> + read_body(Req1, <>) + end. + +json_data(Data) -> + jiffy:encode(#{ + <<"result">> => Data + }, [force_utf8]). + +json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> + jiffy:encode(#{ + <<"error">> => #{ + <<"code">> => ErrCode, + <<"message">> => ErrMessage + } + }, [force_utf8]). \ No newline at end of file diff --git a/apps/power_gateway/src/power_device.erl b/apps/power_gateway/src/power_device.erl new file mode 100644 index 0000000..c3b061d --- /dev/null +++ b/apps/power_gateway/src/power_device.erl @@ -0,0 +1,177 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2024, +%%% @doc +%%% +%%% @end +%%% Created : 04. 9月 2024 16:13 +%%%------------------------------------------------------------------- +-module(power_device). +-author("anlicheng"). + +-behaviour(gen_server). + +%% API +-export([start_link/2]). +-export([get_pid/1, get_name/1]). +-export([metric_data/2, poll_status/1]). + +-define(UNIT0, 16#10). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-record(state, { + device_uuid :: binary(), + heartbeat_ticker :: integer(), + %% 消息计数器 + data_counter = 0, + %% 状态,1: 在线,0: 离线 + status = 0 +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec get_pid(DeviceUUID :: binary()) -> undefined | pid(). +get_pid(DeviceUUID) when is_binary(DeviceUUID) -> + whereis(get_name(DeviceUUID)). + +-spec get_name(DeviceUUID :: binary()) -> atom(). +get_name(DeviceUUID) when is_binary(DeviceUUID) -> + binary_to_atom(<<"power_device:", DeviceUUID/binary>>). + +-spec metric_data(Pid :: pid(), Message :: binary()) -> no_return(). +metric_data(Pid, Message) when is_pid(Pid), is_binary(Message) -> + gen_server:cast(Pid, {metric_data, Message}). + +%% 查询设备的当前状态, 1: 在线,0: 离线 +-spec poll_status(Pid :: pid()) -> {ok, Status :: integer()}. +poll_status(Pid) when is_pid(Pid) -> + gen_server:call(Pid, poll_status). + +%% @doc Spawns the server and registers the local name (unique) +-spec(start_link(Name :: atom(), DeviceUUID :: binary()) -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link(Name, DeviceUUID) when is_atom(Name), is_binary(DeviceUUID) -> + gen_server:start_link({local, get_name(DeviceUUID)}, ?MODULE, [DeviceUUID], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%% @private +%% @doc Initializes the server +-spec(init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore). +init([DeviceUUID]) -> + {ok, HeartbeatTicker0} = application:get_env(power_gateway, heartbeat_ticker), + HeartbeatTicker = HeartbeatTicker0 * 1000, + + erlang:start_timer(HeartbeatTicker, self(), heartbeat_ticker), + {ok, #state{device_uuid = DeviceUUID, heartbeat_ticker = HeartbeatTicker, status = 0}}. + +%% @private +%% @doc Handling call messages +-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_call(poll_status, _From, State = #state{status = Status}) -> + {reply, {ok, Status}, State}; +handle_call(_Request, _From, State = #state{}) -> + {reply, ok, State}. + +%% @private +%% @doc Handling cast messages +-spec(handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_cast({metric_data, Message}, State = #state{device_uuid = DeviceUUID, data_counter = DataCounter, status = Status}) -> + case catch jiffy:decode(Message, [return_maps]) of + #{<<"properties">> := Props0} -> + Props = lists:map(fun(Fields) -> transform(Fields#{<<"device_uuid">> => DeviceUUID}) end, Props0), + Info = iolist_to_binary(jiffy:encode(Props, [force_utf8])), + case catch efka_client:send_metric_data(Props, #{}) of + {ok, _} -> + power_logger:write([<<"OK">>, Info]); + _ -> + power_logger:write([<<"ERROR">>, Info]) + end, + + %% 如果设备当前是离线状态,则需要发送上线消息 + Status == 0 andalso efka_client:device_online(DeviceUUID), + + {noreply, State#state{data_counter = DataCounter + 1, status = 1}}; + M when is_map(M) -> + lager:notice("[power_device] invalid map: ~p", [M]); + Error -> + lager:notice("[power_device] jiffy decode error: ~p", [Error]), + {noreply, State} + end. + +%% @private +%% @doc Handling all non call/cast messages +-spec(handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_info({timeout, _, heartbeat_ticker}, State = #state{device_uuid = DeviceUUID, heartbeat_ticker = HeartbeatTicker, data_counter = DataCounter, status = Status}) -> + erlang:start_timer(HeartbeatTicker, self(), heartbeat_ticker), + case DataCounter > 0 of + true -> + {noreply, State}; + false -> + Status == 1 andalso efka_client:device_offline(DeviceUUID), + {noreply, State#state{status = 0}} + end. + +%% @private +%% @doc This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term()). +terminate(_Reason, _State = #state{}) -> + ok. + +%% @private +%% @doc Convert process state when code is changed +-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}). +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +%% 网关上来的数据写反了: value是类型,unit才是数值 +%% [{"value":"int","unit":44081,"type":"AI","timestamp":1730799797,"name":"使用次数","label":"次","key":"use_times","device_uuid":"30409239002349977617259608405456"}] +-spec transform(Prop :: map()) -> map(). +transform(Prop = #{<<"key">> := <<"total_power">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"总能耗"/utf8>>, <<"value">> => Unit, <<"unit">> => 16#02}; +transform(Prop = #{<<"key">> := <<"total_runtime">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"总运行时间"/utf8>>, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"key">> := <<"use_times">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"使用次数"/utf8>>, <<"label">> => <<"次"/utf8>>, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"key">> := <<"light_switch">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"开关"/utf8>>, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"key">> := <<"light_brightness">>, <<"unit">> := Unit}) when is_integer(Unit) -> + Label = iolist_to_binary([<<"亮度设置为:"/utf8>>, integer_to_binary(Unit)]), + Prop#{<<"name">> => <<"亮度"/utf8>>, <<"label">> => Label, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"key">> := <<"light_change_time">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"变亮变暗时间"/utf8>>, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"key">> := <<"light_status">>, <<"unit">> := Unit}) -> + Prop#{<<"name">> => <<"是否损坏"/utf8>>, <<"value">> => Unit, <<"unit">> => ?UNIT0}; +transform(Prop = #{<<"unit">> := Unit}) -> + Prop#{<<"value">> => Unit, <<"unit">> => ?UNIT0}. \ No newline at end of file diff --git a/apps/power_gateway/src/power_device_sup.erl b/apps/power_gateway/src/power_device_sup.erl new file mode 100644 index 0000000..8af11bb --- /dev/null +++ b/apps/power_gateway/src/power_device_sup.erl @@ -0,0 +1,80 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2024, +%%% @doc +%%% +%%% @end +%%% Created : 04. 9月 2024 15:39 +%%%------------------------------------------------------------------- +-module(power_device_sup). +-author("anlicheng"). + +-behaviour(supervisor). + +%% API +-export([start_link/0]). +-export([ensure_device_started/1, delete_device/1]). + +%% Supervisor callbacks +-export([init/1]). + +-define(SERVER, ?MODULE). + +%%%=================================================================== +%%% API functions +%%%=================================================================== + +%% @doc Starts the supervisor +-spec(start_link() -> {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%%%=================================================================== +%%% Supervisor callbacks +%%%=================================================================== + +%% @private +%% @doc Whenever a supervisor is started using supervisor:start_link/[2,3], +%% this function is called by the new process to find out about +%% restart strategy, maximum restart frequency and child +%% specifications. +init([]) -> + SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600}, + + {ok, {SupFlags, []}}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec ensure_device_started(DeviceUUID :: binary()) -> {ok, Pid :: pid()} | {error, Reason :: any()}. +ensure_device_started(DeviceUUID) when is_binary(DeviceUUID) -> + case power_device:get_pid(DeviceUUID) of + DevicePid when is_pid(DevicePid) -> + {ok, DevicePid}; + undefined -> + case supervisor:start_child(?MODULE, child_spec(DeviceUUID)) of + {ok, Pid} when is_pid(Pid) -> + {ok, Pid}; + {error, {'already_started', Pid}} when is_pid(Pid) -> + {ok, Pid}; + {error, Error} -> + {error, Error} + end + end. + +delete_device(DeviceUUID) when is_binary(DeviceUUID) -> + Id = power_device:get_name(DeviceUUID), + ok = supervisor:terminate_child(?MODULE, Id), + supervisor:delete_child(?MODULE, Id). + +child_spec(DeviceUUID) when is_binary(DeviceUUID) -> + Name = power_device:get_name(DeviceUUID), + #{ + id => Name, + start => {power_device, start_link, [Name, DeviceUUID]}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['power_device'] + }. \ No newline at end of file diff --git a/apps/power_gateway/src/power_gateway.app.src b/apps/power_gateway/src/power_gateway.app.src new file mode 100644 index 0000000..8281d8f --- /dev/null +++ b/apps/power_gateway/src/power_gateway.app.src @@ -0,0 +1,22 @@ +{application, power_gateway, + [{description, "An OTP application"}, + {vsn, "1.0"}, + {registered, []}, + {mod, {power_gateway_app, []}}, + {applications, + [ + hackney, + sync, + jiffy, + ranch, + cowboy, + lager, + kernel, + stdlib + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache-2.0"]}, + {links, []} + ]}. diff --git a/apps/power_gateway/src/power_gateway_app.erl b/apps/power_gateway/src/power_gateway_app.erl new file mode 100644 index 0000000..111832a --- /dev/null +++ b/apps/power_gateway/src/power_gateway_app.erl @@ -0,0 +1,44 @@ +%%%------------------------------------------------------------------- +%% @doc power_gateway public API +%% @end +%%%------------------------------------------------------------------- + +-module(power_gateway_app). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + SupResult = power_gateway_sup:start_link(), + start_http_server(), + SupResult. + +stop(_State) -> + ok. + +%% internal functions + +start_http_server() -> + {ok, Props} = application:get_env(power_gateway, http_server), + Acceptors = proplists:get_value(acceptors, Props, 50), + MaxConnections = proplists:get_value(max_connections, Props, 10240), + Backlog = proplists:get_value(backlog, Props, 1024), + Port = proplists:get_value(port, Props), + + Dispatcher = cowboy_router:compile([ + {'_', [ + %{"/api/[...]", http_protocol, [http_push_handler]} + ]} + ]), + + TransOpts = [ + {port, Port}, + {num_acceptors, Acceptors}, + {backlog, Backlog}, + {max_connections, MaxConnections} + ], + + {ok, Pid} = cowboy:start_clear(http_listener, TransOpts, #{env => #{dispatch => Dispatcher}}), + + lager:debug("[iot_app] the http server start at: ~p, pid is: ~p", [Port, Pid]). diff --git a/apps/power_gateway/src/power_gateway_args.erl b/apps/power_gateway/src/power_gateway_args.erl new file mode 100644 index 0000000..ac3a146 --- /dev/null +++ b/apps/power_gateway/src/power_gateway_args.erl @@ -0,0 +1,159 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 06. 9月 2023 16:37 +%%%------------------------------------------------------------------- +-module(power_gateway_args). +-author("aresei"). + +-behaviour(gen_server). + +%% API +-export([start_link/0]). +-export([get_metric/0, get_param/0, push_metric/1, push_param/1, get_device_uuid/1]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-define(SERVER, ?MODULE). + +-record(state, { + %% #{device_id => device_uuid}, 下发的时候是:serial + metrics = #{}, + param = #{} +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec get_param() -> {ok, Param :: #{}}. +get_param() -> + gen_server:call(?MODULE, get_param). + +-spec get_metric() -> {ok, Metric :: map()}. +get_metric() -> + gen_server:call(?MODULE, get_metric). + +-spec get_device_uuid(DeviceId :: binary()) -> error | {ok, DeviceUUID :: binary()}. +get_device_uuid(DeviceId) when is_binary(DeviceId) -> + gen_server:call(?MODULE, {get_device_uuid, DeviceId}). + +-spec push_param(Param :: map()) -> no_return(). +push_param(Param) when is_map(Param) -> + gen_server:cast(?MODULE, {push_param, Param}). + +-spec push_metric(Metrics :: list()) -> no_return(). +push_metric(Metrics) when is_list(Metrics) -> + gen_server:cast(?MODULE, {push_metric, Metrics}). + +%% @doc Spawns the server and registers the local name (unique) +-spec(start_link() -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%% @private +%% @doc Initializes the server +-spec(init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore). +init([]) -> + {ok, Metrics} = efka_client:request_metric(), + try convert_metric(Metrics) of + {ok, MetricMap} -> + lager:debug("[power_gateway_args] init load metric_map: ~p", [MetricMap]), + {ok, Param} = efka_client:request_param(), + + {ok, #state{metrics = MetricMap, param = Param}} + catch + _:Error:Stack-> + lager:warning("[power_gateway_args] request_metric get error: ~p, stack: ~p", [Error, Stack]), + {ok, #state{metrics = #{}, param = #{}}} + end. + +%% @private +%% @doc Handling call messages +-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_call(get_param, _From, State = #state{param = Param}) -> + {reply, {ok, Param}, State}; +handle_call({get_device_uuid, DeviceId}, _From, State = #state{metrics = Metrics}) when is_map(Metrics) -> + Result = maps:find(DeviceId, Metrics), + {reply, Result, State}; +handle_call({get_device_uuid, _DeviceId}, _From, State) -> + {reply, error, State}; + +handle_call(get_metric, _From, State = #state{metrics = Metrics}) -> + {reply, {ok, Metrics}, State}. + +%% @private +%% @doc Handling cast messages +-spec(handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_cast({push_param, Param}, State = #state{}) -> + {noreply, State#state{param = Param}}; +handle_cast({push_metric, Metrics}, State = #state{}) -> + try convert_metric(Metrics) of + {ok, MetricMap} -> + lager:debug("[power_gateway_args] push metric_map: ~p", [MetricMap]), + {noreply, State#state{metrics = MetricMap}} + catch _:_ -> + {noreply, State} + end. + +%% @private +%% @doc Handling all non call/cast messages +-spec(handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_info(_Info, State = #state{}) -> + {noreply, State}. + +%% @private +%% @doc This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term()). +terminate(_Reason, _State = #state{}) -> + ok. + +%% @private +%% @doc Convert process state when code is changed +-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}). +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec convert_metric(Metrics :: list()) -> {ok, map()}. +convert_metric(Metrics) when is_list(Metrics) -> + MetricTuples = lists:flatmap(fun + (#{<<"serial">> := DeviceId, <<"device_uuid">> := DeviceUUID}) -> + [{DeviceId, DeviceUUID}]; + (_) -> + [] + end, Metrics), + {ok, maps:from_list(MetricTuples)}. \ No newline at end of file diff --git a/apps/power_gateway/src/power_gateway_mqtt_subscriber.erl b/apps/power_gateway/src/power_gateway_mqtt_subscriber.erl new file mode 100644 index 0000000..db48e59 --- /dev/null +++ b/apps/power_gateway/src/power_gateway_mqtt_subscriber.erl @@ -0,0 +1,195 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% 1. 需要考虑集群部署的相关问题,上行的数据可能在集群中共享 +%%% 2. host进程不能直接去监听topic,这样涉及到新增和下线的很多问题 +%%% @end +%%% Created : 12. 3月 2023 21:27 +%%%------------------------------------------------------------------- +-module(power_gateway_mqtt_subscriber). +-author("aresei"). + +-behaviour(gen_server). + +%% API +-export([start_link/0]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-define(SERVER, ?MODULE). + +%% 需要订阅的主题信息 +-define(Topics,[ + {<<"/lighting/+/data">>, 2} +]). + +-record(state, { + conn_pid :: pid() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +%% @doc Spawns the server and registers the local name (unique) +-spec(start_link() -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%% @private +%% @doc Initializes the server +-spec(init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore). +init([]) -> + %% 建立到emqx服务器的连接 + Opts = emqx_opts(<<"power-subscriber">>), + lager:debug("[opts] is: ~p", [Opts]), + case emqtt:start_link(Opts) of + {ok, ConnPid} -> + lager:debug("[mqtt_subscriber] start conntecting, pid: ~p", [ConnPid]), + {ok, _} = emqtt:connect(ConnPid), + lager:debug("[mqtt_subscriber] connect success"), + + %% 监听和设备的全部事件 + SubscribeResult = emqtt:subscribe(ConnPid, ?Topics), + lager:debug("[mqtt_subscriber] subscribe topics: ~p, result is: ~p", [?Topics, SubscribeResult]), + + {ok, #state{conn_pid = ConnPid}}; + ignore -> + lager:debug("[mqtt_subscriber] connect emqx get ignore"), + {stop, ignore}; + {error, Reason} -> + lager:debug("[mqtt_subscriber] connect emqx get error: ~p", [Reason]), + {stop, Reason} + end. + +%% @private +%% @doc Handling call messages +-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_call(_Info, _From, State = #state{conn_pid = _ConnPid}) -> + {reply, ok, State}. + +%% @private +%% @doc Handling cast messages +-spec(handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_cast(_Request, State = #state{}) -> + {noreply, State}. + +%% @private +%% @doc Handling all non call/cast messages +-spec(handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_info({disconnect, ReasonCode, Properties}, State = #state{}) -> + lager:debug("[mqtt_subscriber] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), + {stop, disconnected, State}; +%% 必须要做到消息的快速分发,数据的json反序列需要在host进程进行 +handle_info({publish, #{packet_id := _PacketId, payload := Payload, qos := Qos, topic := Topic}}, State = #state{conn_pid = _ConnPid}) -> + lager:debug("[mqtt_subscriber] Recv a topic: ~p, publish packet: ~p, qos: ~p", [Topic, Payload, Qos]), + %% 将消息分发到对应的publisher进程去处理, 提高消息的处理速度; topic格式: /lighting/C0AA213BD7F0/data + case Topic of + <<"/lighting/", DeviceInfo/binary>> -> + [DeviceMac, <<"data">>] = binary:split(DeviceInfo, <<"/">>), + dispatch(DeviceMac, Payload); + _ -> + ok + end, + {noreply, State}; +handle_info({puback, Packet = #{packet_id := _PacketId}}, State = #state{}) -> + lager:debug("[mqtt_subscriber] receive puback packet: ~p", [Packet]), + {noreply, State}; + +handle_info(Info, State = #state{}) -> + lager:debug("[mqtt_subscriber] get info: ~p", [Info]), + {noreply, State}. + +%% @private +%% @doc This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term()). +terminate(Reason, _State = #state{conn_pid = ConnPid}) when is_pid(ConnPid) -> + %% 取消topic的订阅 + TopicNames = lists:map(fun({Name, _}) -> Name end, ?Topics), + {ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames), + + ok = emqtt:disconnect(ConnPid), + lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), + ok; +terminate(Reason, _State) -> + lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), + ok. + +%% @private +%% @doc Convert process state when code is changed +-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}). +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec emqx_opts(ClientSuffix :: binary()) -> list(). +emqx_opts(ClientSuffix) when is_binary(ClientSuffix) -> + %% 建立到emqx服务器的连接 + {ok, Props} = application:get_env(power_gateway, emqx_server), + EMQXHost = proplists:get_value(host, Props), + EMQXPort = proplists:get_value(port, Props, 1883), + Username = proplists:get_value(username, Props), + Password = proplists:get_value(password, Props), + RetryInterval = proplists:get_value(retry_interval, Props, 5), + Keepalive = proplists:get_value(keepalive, Props, 86400), + + Node = atom_to_binary(node()), + ClientId = <<"mqtt-client-", Node/binary, "-", ClientSuffix/binary>>, + [ + {clientid, ClientId}, + {host, EMQXHost}, + {port, EMQXPort}, + {owner, self()}, + {tcp_opts, []}, + {username, Username}, + {password, Password}, + {keepalive, Keepalive}, + {auto_ack, true}, + {proto_ver, v3}, + {retry_interval, RetryInterval} + ]. + +-spec dispatch(DeviceMac :: binary(), Message :: binary()) -> no_return(). +dispatch(DeviceMac, Message) when is_binary(DeviceMac), is_binary(Message) -> + case power_gateway_args:get_device_uuid(DeviceMac) of + error -> + lager:notice("[mqtt_subscriber] device_mac: ~p, device_uuid not found", [DeviceMac]); + {ok, DeviceUUID} -> + case power_device_sup:ensure_device_started(DeviceUUID) of + {ok, DevicePid} -> + power_device:metric_data(DevicePid, Message); + {error, Reason} -> + lager:notice("[mqtt_subscriber] start device get error: ~p", [Reason]) + end + end. \ No newline at end of file diff --git a/apps/power_gateway/src/power_gateway_sup.erl b/apps/power_gateway/src/power_gateway_sup.erl new file mode 100644 index 0000000..3b82ec2 --- /dev/null +++ b/apps/power_gateway/src/power_gateway_sup.erl @@ -0,0 +1,103 @@ +%%%------------------------------------------------------------------- +%% @doc power_gateway top level supervisor. +%% @end +%%%------------------------------------------------------------------- + +-module(power_gateway_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%% sup_flags() = #{strategy => strategy(), % optional +%% intensity => non_neg_integer(), % optional +%% period => pos_integer()} % optional +%% child_spec() = #{id => child_id(), % mandatory +%% start => mfargs(), % mandatory +%% restart => restart(), % optional +%% shutdown => shutdown(), % optional +%% type => worker(), % optional +%% modules => modules()} % optional +init([]) -> + SupFlags = #{strategy => one_for_one, + intensity => 1000, + period => 3600}, + + {ok, EfkaServer} = application:get_env(power_gateway, efka_server), + Host = proplists:get_value(host, EfkaServer), + Port = proplists:get_value(port, EfkaServer), + + %% 获取配置的名称 + RegisterName = read_service_name(), + + ChildSpecs = [ + #{ + id => 'efka_client', + start => {'efka_client', start_link, [RegisterName, Host, Port]}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['efka_client'] + }, + + #{ + id => 'power_logger', + start => {'power_logger', start_link, ["device_data"]}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['power_logger'] + }, + + #{ + id => 'power_gateway_args', + start => {'power_gateway_args', start_link, []}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['power_gateway_args'] + }, + + #{ + id => 'power_device_sup', + start => {'power_device_sup', start_link, []}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['power_device_sup'] + }, + + #{ + id => 'power_gateway_mqtt_subscriber', + start => {'power_gateway_mqtt_subscriber', start_link, []}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['power_gateway_mqtt_subscriber'] + } + ], + + {ok, {SupFlags, ChildSpecs}}. + +%% internal functions + +%% 获取根目录下的配置文件 +read_service_name() -> + %% 获取配置的名称 + RootDir = code:root_dir(), + VersionFile = RootDir ++ "/.version", + lager:debug("[gateway_sup] version path is: ~p", [VersionFile]), + case file:read_file(VersionFile) of + {ok, RegisterName0} -> + string:trim(RegisterName0); + {error, Reason} -> + lager:warning("[power_app] read .version file get error: ~p", [Reason]), + <<"power_gateway">> + end. \ No newline at end of file diff --git a/apps/power_gateway/src/power_logger.erl b/apps/power_gateway/src/power_logger.erl new file mode 100644 index 0000000..e78cb2f --- /dev/null +++ b/apps/power_gateway/src/power_logger.erl @@ -0,0 +1,169 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 07. 9月 2023 17:07 +%%%------------------------------------------------------------------- +-module(power_logger). +-author("aresei"). + +-behaviour(gen_server). + +%% API +-export([start_link/1, write/1]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-define(SERVER, ?MODULE). + +-record(state, { + file_name :: string(), + date :: calendar:date(), + file +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +write(Data) -> + gen_server:cast(?SERVER, {write, Data}). + +%% @doc Spawns the server and registers the local name (unique) +-spec(start_link(FileName :: string()) -> + {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). +start_link(FileName) when is_list(FileName) -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [FileName], []). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +%% @private +%% @doc Initializes the server +-spec(init(Args :: term()) -> + {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | + {stop, Reason :: term()} | ignore). +init([FileName]) -> + ensure_dir(), + FilePath = make_file(FileName, erlang:date()), + {ok, File} = file:open(FilePath, [append, binary]), + + {ok, #state{file = File, file_name = FileName, date = get_date()}}. + +%% @private +%% @doc Handling call messages +-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()}, + State :: #state{}) -> + {reply, Reply :: term(), NewState :: #state{}} | + {reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} | + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_call(_Request, _From, State = #state{}) -> + {reply, ok, State}. + +%% @private +%% @doc Handling cast messages +-spec(handle_cast(Request :: term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_cast({write, Data}, State = #state{file = OldFile, file_name = FileName, date = Date}) -> + Line = iolist_to_binary([time_prefix(), <<" ">>, format(Data), <<$\n>>]), + NDate = erlang:date(), + case maybe_new_file(Date, NDate) of + true -> + file:close(OldFile), + + FilePath = make_file(FileName, NDate), + {ok, File} = file:open(FilePath, [append, binary]), + ok = file:write(File, Line), + %% 清理历史的文件, 单位: 天 + delete_old_files(FileName, 10), + + {noreply, State#state{file = File, date = get_date()}}; + false -> + ok = file:write(OldFile, Line), + {noreply, State} + end. + +%% @private +%% @doc Handling all non call/cast messages +-spec(handle_info(Info :: timeout() | term(), State :: #state{}) -> + {noreply, NewState :: #state{}} | + {noreply, NewState :: #state{}, timeout() | hibernate} | + {stop, Reason :: term(), NewState :: #state{}}). +handle_info(_Info, State = #state{}) -> + {noreply, State}. + +%% @private +%% @doc This function is called by a gen_server when it is about to +%% terminate. It should be the opposite of Module:init/1 and do any +%% necessary cleaning up. When it returns, the gen_server terminates +%% with Reason. The return value is ignored. +-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), + State :: #state{}) -> term()). +terminate(_Reason, _State = #state{}) -> + ok. + +%% @private +%% @doc Convert process state when code is changed +-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{}, + Extra :: term()) -> + {ok, NewState :: #state{}} | {error, Reason :: term()}). +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +format(Data) when is_binary(Data) -> + iolist_to_binary(Data); +format(Items) when is_list(Items) -> + iolist_to_binary(lists:join(<<"\t">>, Items)). + +time_prefix() -> + {{Y, M, D}, {H, I, S}} = calendar:local_time(), + iolist_to_binary(io_lib:format("[~b-~2..0b-~2..0b ~2..0b:~2..0b:~2..0b]", [Y, M, D, H, I, S])). + +-spec make_file(LogFile :: string(), {Year :: integer(), Month :: integer(), Day :: integer()}) -> string(). +make_file(LogFile, {Year, Month, Day}) when is_list(LogFile) -> + Suffix = io_lib:format("~b~2..0b~2..0b", [Year, Month, Day]), + RootDir = code:root_dir() ++ "/log/", + lists:flatten(RootDir ++ LogFile ++ "." ++ Suffix). + +ensure_dir() -> + RootDir = code:root_dir() ++ "/log/", + case filelib:is_dir(RootDir) of + true -> + ok; + false -> + ok = file:make_dir(RootDir) + end. + +%% 获取日期信息 +-spec get_date() -> Date :: calendar:date(). +get_date() -> + {Date, _} = calendar:local_time(), + Date. + +%% 通过日志判断是否需要生成新的日志文件 +-spec maybe_new_file(Date :: calendar:date(), Date0 :: calendar:date()) -> boolean(). +maybe_new_file({Y, M, D}, {Y0, M0, D0}) -> + not (Y =:= Y0 andalso M =:= M0 andalso D =:= D0). + +-spec delete_old_files(FileName :: string(), Days :: integer()) -> no_return(). +delete_old_files(FileName, Days) when is_list(FileName), is_integer(Days) -> + Seconds0 = calendar:datetime_to_gregorian_seconds(calendar:local_time()), + Seconds = Seconds0 - Days * 86400, + lists:foreach(fun(Day) -> + {Date, _} = calendar:gregorian_seconds_to_datetime(Seconds - Day * 86400), + FilePath = make_file(FileName, Date), + filelib:is_file(FilePath) andalso file:delete(FilePath) + end, lists:seq(1, 10)). \ No newline at end of file diff --git a/boot.sh b/boot.sh new file mode 100755 index 0000000..63c3836 --- /dev/null +++ b/boot.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker run -e TZ=Asia/Shanghai --hostname=power_gateway --net=host --restart=always -v /data/docker/power_gateway/:/data/power_gateway/ power_gateway:latest \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4c6e711 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +rebar3 compile && rebar3 release && rebar3 tar + +tar -czvf power_gateway.tgz boot.sh stop.sh Dockerfile .args.yaml \ No newline at end of file diff --git a/config/sys-dev.config b/config/sys-dev.config new file mode 100644 index 0000000..d694452 --- /dev/null +++ b/config/sys-dev.config @@ -0,0 +1,66 @@ +[ + {power_gateway, [ + + {http_server, [ + {port, 18082}, + {acceptors, 500}, + {max_connections, 10240}, + {backlog, 10240} + ]}, + + %% 离线判断时间间隔,单位:(秒) + {heartbeat_ticker, 120}, + + {emqx_server, [ + {host, "39.98.184.67"}, + {port, 1883}, + {tcp_opts, []}, + {username, "test"}, + {password, "test1234"}, + {keepalive, 86400}, + {retry_interval, 5} + ]}, + + {efka_server, [ + {host, "39.98.184.67"}, + {port, 3361} + ]} + ]}, + + %% 系统日志配置,系统日志为lager, 支持日志按日期自动分割 + {lager, [ + {colored, true}, + %% Whether to write a crash log, and where. Undefined means no crash logger. + {crash_log, "trade_hub.crash.log"}, + %% Maximum size in bytes of events in the crash log - defaults to 65536 + {crash_log_msg_size, 65536}, + %% Maximum size of the crash log in bytes, before its rotated, set + %% to 0 to disable rotation - default is 0 + {crash_log_size, 10485760}, + %% What time to rotate the crash log - default is no time + %% rotation. See the README for a description of this format. + {crash_log_date, "$D0"}, + %% Number of rotated crash logs to keep, 0 means keep only the + %% current one - default is 0 + {crash_log_count, 5}, + %% Whether to redirect error_logger messages into lager - defaults to true + {error_logger_redirect, true}, + + %% How big the gen_event mailbox can get before it is switched into sync mode + {async_threshold, 20}, + %% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold' + %% to async_threshold - async_threshold_window + {async_threshold_window, 5}, + + {handlers, [ + %% debug | info | warning | error, 日志级别 + {lager_console_backend, debug}, + {lager_file_backend, [{file, "debug.log"}, {level, debug}, {size, 314572800}]}, + {lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]}, + {lager_file_backend, [{file, "notice.log"}, {level, notice}, {size, 314572800}]}, + {lager_file_backend, [{file, "warning.log"}, {level, warning}, {size, 314572800}]}, + {lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]} + ]} + ]} + +]. diff --git a/config/sys-prod.config b/config/sys-prod.config new file mode 100644 index 0000000..fd38ed9 --- /dev/null +++ b/config/sys-prod.config @@ -0,0 +1,66 @@ +[ + {power_gateway, [ + + {http_server, [ + {port, 18082}, + {acceptors, 500}, + {max_connections, 10240}, + {backlog, 10240} + ]}, + + %% 离线判断时间间隔,单位:(秒) + {heartbeat_ticker, 5400}, + + {emqx_server, [ + {host, "172.30.37.212"}, + {port, 1883}, + {tcp_opts, []}, + {username, "power_gateway"}, + {password, "PqRsTuVwXyZ!@#xy"}, + {keepalive, 86400}, + {retry_interval, 5} + ]}, + + {efka_server, [ + {host, "localhost"}, + {port, 3361} + ]} + ]}, + + %% 系统日志配置,系统日志为lager, 支持日志按日期自动分割 + {lager, [ + {colored, true}, + %% Whether to write a crash log, and where. Undefined means no crash logger. + {crash_log, "trade_hub.crash.log"}, + %% Maximum size in bytes of events in the crash log - defaults to 65536 + {crash_log_msg_size, 65536}, + %% Maximum size of the crash log in bytes, before its rotated, set + %% to 0 to disable rotation - default is 0 + {crash_log_size, 10485760}, + %% What time to rotate the crash log - default is no time + %% rotation. See the README for a description of this format. + {crash_log_date, "$D0"}, + %% Number of rotated crash logs to keep, 0 means keep only the + %% current one - default is 0 + {crash_log_count, 5}, + %% Whether to redirect error_logger messages into lager - defaults to true + {error_logger_redirect, true}, + + %% How big the gen_event mailbox can get before it is switched into sync mode + {async_threshold, 20}, + %% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold' + %% to async_threshold - async_threshold_window + {async_threshold_window, 5}, + + {handlers, [ + %% debug | info | warning | error, 日志级别 + % {lager_console_backend, debug}, + {lager_file_backend, [{file, "debug.log"}, {level, debug}, {size, 314572800}]}, + {lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]}, + {lager_file_backend, [{file, "notice.log"}, {level, notice}, {size, 314572800}]}, + {lager_file_backend, [{file, "warning.log"}, {level, warning}, {size, 314572800}]}, + {lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]} + ]} + ]} + +]. diff --git a/config/vm.args b/config/vm.args new file mode 100644 index 0000000..bd04bda --- /dev/null +++ b/config/vm.args @@ -0,0 +1,6 @@ +-sname power_gateway + +-setcookie power_gateway_cookie + ++K true ++A30 diff --git a/docs/recevie_data.md b/docs/recevie_data.md new file mode 100644 index 0000000..8d62a68 --- /dev/null +++ b/docs/recevie_data.md @@ -0,0 +1,42 @@ +## 接受到的数据类型包括 +### total_power +```json + { + "value": 30074, + "unit": 2, + "type": "AI", + "timestamp": 1763395200, + "name": "总能耗", + "label": "W.h", + "key": "total_power", + "device_uuid": "33806774076388556817340611953938" + } +``` + +### total_runtime +```json + { + "value": 65, + "unit": 16, + "type": "AI", + "timestamp": 1763395200, + "name": "总运行时间", + "label": "h", + "key": "total_runtime", + "device_uuid": "33806774076388556817340611953938" + } +``` + +### use_times +```json + { + "value": 15301, + "unit": 16, + "type": "AI", + "timestamp": 1763395200, + "name": "使用次数", + "label": "次", + "key": "use_times", + "device_uuid": "33806741326004633617340611173101" + } +``` \ No newline at end of file diff --git a/docs/灯联网网关推送规格书.pdf b/docs/灯联网网关推送规格书.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d8ef066ed1fab8e7a4b33cc77b0bbfcf5cf29666 GIT binary patch literal 147387 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 Q;{g#@e%UfZGb8!`0uC##R{#J2 literal 0 HcmV?d00001 diff --git a/rebar.config b/rebar.config new file mode 100644 index 0000000..19ac816 --- /dev/null +++ b/rebar.config @@ -0,0 +1,46 @@ +{erl_opts, [debug_info]}. +{deps, [ + {poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}}, + {hackney, ".*", {git, "https://github.com/benoitc/hackney.git", {tag, "1.16.0"}}}, + {sync, ".*", {git, "https://github.com/rustyio/sync.git", {branch, "master"}}}, + {jiffy, ".*", {git, "https://github.com/davisp/jiffy.git", {tag, "1.1.1"}}}, + {mysql, ".*", {git, "https://github.com/mysql-otp/mysql-otp", {tag, "1.8.0"}}}, + {cowboy, ".*", {git, "https://github.com/ninenines/cowboy.git", {tag, "2.10.0"}}}, + {parse_trans, ".*", {git, "https://github.com/uwiger/parse_trans", {tag, "3.0.0"}}}, + {lager, ".*", {git,"https://github.com/erlang-lager/lager.git", {tag, "3.9.2"}}} +]}. + +{relx, [{release, {power_gateway, "1.0"}, + [power_gateway, + sasl]}, + + {mode, dev}, + {include_erts, false}, + + %% automatically picked up if the files + %% exist but can be set manually, which + %% is required if the names aren't exactly + %% sys.config and vm.args + {sys_config, "./config/sys.config"}, + {vm_args, "./config/vm.args"} + + %% the .src form of the configuration files do + %% not require setting RELX_REPLACE_OS_VARS + %% {sys_config_src, "./config/sys.config.src"}, + %% {vm_args_src, "./config/vm.args.src"} +]}. + +{profiles, [{prod, [{relx, + [%% prod is the default mode when prod + %% profile is used, so does not have + %% to be explicitly included like this + {mode, prod} + + %% use minimal mode to exclude ERTS + %% {mode, minimal} + ] + }]}]}. + +{erl_opts, [{parse_transform,lager_transform}]}. + +{rebar_packages_cdn, "https://hexpm.upyun.com"}. diff --git a/rebar.lock b/rebar.lock new file mode 100644 index 0000000..aa39890 --- /dev/null +++ b/rebar.lock @@ -0,0 +1,69 @@ +{"1.2.0", +[{<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.2">>},1}, + {<<"cowboy">>, + {git,"https://github.com/ninenines/cowboy.git", + {ref,"9e600f6c1df3c440bc196b66ebbc005d70107217"}}, + 0}, + {<<"cowlib">>, + {git,"https://github.com/ninenines/cowlib", + {ref,"cc04201c1d0e1d5603cd1cde037ab729b192634c"}}, + 1}, + {<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1}, + {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, + {<<"hackney">>, + {git,"https://github.com/benoitc/hackney.git", + {ref,"f3e9292db22c807e73f57a8422402d6b423ddf5f"}}, + 0}, + {<<"idna">>,{pkg,<<"idna">>,<<"6.0.1">>},1}, + {<<"jiffy">>, + {git,"https://github.com/davisp/jiffy.git", + {ref,"9ea1b35b6e60ba21dfd4adbd18e7916a831fd7d4"}}, + 0}, + {<<"lager">>, + {git,"https://github.com/erlang-lager/lager.git", + {ref,"459a3b2cdd9eadd29e5a7ce5c43932f5ccd6eb88"}}, + 0}, + {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, + {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},1}, + {<<"mysql">>, + {git,"https://github.com/mysql-otp/mysql-otp", + {ref,"caf5ff96c677a8fe0ce6f4082bc036c8fd27dd62"}}, + 0}, + {<<"parse_trans">>, + {git,"https://github.com/uwiger/parse_trans", + {ref,"6f3645afb43c7c57d61b54ef59aecab288ce1013"}}, + 0}, + {<<"poolboy">>, + {git,"https://github.com/devinus/poolboy.git", + {ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}}, + 0}, + {<<"ranch">>, + {git,"https://github.com/ninenines/ranch", + {ref,"a692f44567034dacf5efcaa24a24183788594eb7"}}, + 1}, + {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.6">>},1}, + {<<"sync">>, + {git,"https://github.com/rustyio/sync.git", + {ref,"7dc303ed4ce8d26db82e171dbbd7c41067852c65"}}, + 0}, + {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.5.0">>},2}]}. +[ +{pkg_hash,[ + {<<"certifi">>, <<"B7CFEAE9D2ED395695DD8201C57A2D019C0C43ECAF8B8BCB9320B40D6662F340">>}, + {<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>}, + {<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>}, + {<<"idna">>, <<"1D038FB2E7668CE41FBF681D2C45902E52B3CB9E9C77B55334353B222C2EE50C">>}, + {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, + {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>}, + {<<"ssl_verify_fun">>, <<"CF344F5692C82D2CD7554F5EC8FD961548D4FD09E7D22F5B62482E5AEAEBD4B0">>}, + {<<"unicode_util_compat">>, <<"8516502659002CEC19E244EBD90D312183064BE95025A319A6C7E89F4BCCD65B">>}]}, +{pkg_hash_ext,[ + {<<"certifi">>, <<"3B3B5F36493004AC3455966991EAF6E768CE9884693D9968055AEEEB1E575040">>}, + {<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>}, + {<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>}, + {<<"idna">>, <<"A02C8A1C4FD601215BB0B0324C8A6986749F807CE35F25449EC9E69758708122">>}, + {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, + {<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>}, + {<<"ssl_verify_fun">>, <<"BDB0D2471F453C88FF3908E7686F86F9BE327D065CC1EC16FA4540197EA04680">>}, + {<<"unicode_util_compat">>, <<"D48D002E15F5CC105A696CF2F1BBB3FC72B4B770A184D8420C8DB20DA2674B38">>}]} +]. diff --git a/run b/run new file mode 100755 index 0000000..90ad2be --- /dev/null +++ b/run @@ -0,0 +1,5 @@ +#! /bin/sh +rebar3 compile +rebar3 release + +_build/default/rel/power_gateway/bin/power_gateway console diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..397f238 --- /dev/null +++ b/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker ps -a | grep "power_gateway:latest" | awk -F " " '{print $1}' | xargs docker stop | xargs docker rm \ No newline at end of file