拆分独立的enpoint

This commit is contained in:
anlicheng 2026-05-30 15:25:40 +08:00
parent f96dd3fea7
commit 3defc05815
84 changed files with 6999 additions and 3142 deletions

View File

@ -27,9 +27,9 @@
%% {"result": Data} %% {"result": Data}
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_data(Data) -> json_data(Data) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"result">> => Data <<"result">> => Data
}, [force_utf8]). })).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% @doc %% @doc
@ -42,12 +42,12 @@ json_data(Data) ->
%% } %% }
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"error">> => #{ <<"error">> => #{
<<"code">> => ErrCode, <<"code">> => ErrCode,
<<"message">> => ErrMessage <<"message">> => ErrMessage
} }
}, [force_utf8]). })).
``` ```
### 📘 返回格式说明 ### 📘 返回格式说明
@ -157,7 +157,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
| 参数名 | 类型 | 必填 | 说明 | | 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------| |--------|------|------|------|
| uuid | binary (string) | ✅ | 主机唯一标识符 | | uuid | binary (string) | ✅ | 主机唯一标识符 |
| task_id | integer | ✅ | 任务 ID | | task_id | integer | ✅ | 任务 ID,和 uuid 一起作为部署任务唯一标识 |
| config | map | ✅ | 部署配置内容 | | config | map | ✅ | 部署配置内容 |
#### 响应参数 #### 响应参数
@ -255,13 +255,16 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
#### 示例响应 #### 示例响应
```json ```json
{ {
"result": { "data": "ok"
"task_id": 1001,
"status": "deployed"
}
} }
``` ```
部署过程实时日志通过 SSE 获取:
```http
GET /event_stream?uuid=qbxmjyzrkpntfgswaevodhluicqzxplkm&task_id=1
```
#### 错误响应 #### 错误响应
```json ```json
{ {
@ -475,9 +478,9 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
%% {"result": Data} %% {"result": Data}
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_data(Data) -> json_data(Data) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"result">> => Data <<"result">> => Data
}, [force_utf8]). })).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% @doc %% @doc
@ -490,12 +493,12 @@ json_data(Data) ->
%% } %% }
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"error">> => #{ <<"error">> => #{
<<"code">> => ErrCode, <<"code">> => ErrCode,
<<"message">> => ErrMessage <<"message">> => ErrMessage
} }
}, [force_utf8]). })).
``` ```
### 📘 返回格式说明 ### 📘 返回格式说明
@ -909,9 +912,9 @@ Content-Type: application/json
%% {"result": Data} %% {"result": Data}
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_data(Data) -> json_data(Data) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"result">> => Data <<"result">> => Data
}, [force_utf8]). })).
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% @doc %% @doc
@ -924,12 +927,12 @@ json_data(Data) ->
%% } %% }
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"error">> => #{ <<"error">> => #{
<<"code">> => ErrCode, <<"code">> => ErrCode,
<<"message">> => ErrMessage <<"message">> => ErrMessage
} }
}, [force_utf8]). })).
``` ```
### 📘 返回格式说明 ### 📘 返回格式说明
@ -1079,7 +1082,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
| 参数名 | 类型 | 必填 | 说明 | | 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------| |--------|------|------|------|
| uuid | binary (string) | ✅ | 主机唯一标识符 | | uuid | binary (string) | ✅ | 主机唯一标识符 |
| auth | boolean | ✅ | `true` 激活, `false` 取消激活 | | auth | boolean | ✅ | `true` 激活, `false` 取消激活。该操作只修改 iot 本地和持久化授权状态,不通知 efkaefka 连接可继续保持在线,数据是否处理由 iot_host 状态决定。 |
#### 响应参数 #### 响应参数
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |

View File

@ -0,0 +1,24 @@
{application, endpoint,
[{description, "Endpoint OTP application"},
{vsn, "0.1.0"},
{registered, [
endpoint_sup,
endpoint_adapter_sup,
endpoint_subscription,
endpoint_log
]},
{mod, {endpoint_app, []}},
{applications, [
emqtt,
brod,
hackney,
gproc,
crypto,
kernel,
stdlib
]},
{env, []},
{modules, []},
{licenses, ["Apache 2.0"]},
{links, []}
]}.

View File

@ -31,32 +31,32 @@ start_link(Endpoint = #endpoint{id = Id, config = #kafka_endpoint{}}) ->
LocalName = get_name(Id), LocalName = get_name(Id),
endpoint_kafka:start_link(LocalName, Endpoint). endpoint_kafka:start_link(LocalName, Endpoint).
-spec get_name(Id :: integer()) -> atom(). -spec get_name(Id :: integer()) -> term().
get_name(Id) when is_integer(Id) -> get_name(Id) when is_integer(Id) ->
list_to_atom("endpoint:" ++ integer_to_list(Id)). {endpoint, Id}.
-spec get_pid(Id :: integer()) -> undefined | pid(). -spec get_pid(Id :: integer()) -> undefined | pid().
get_pid(Id) when is_integer(Id) -> get_pid(Id) when is_integer(Id) ->
whereis(get_name(Id)). gproc:whereis_name({n, l, get_name(Id)}).
-spec get_alias_name(Name :: binary()) -> atom(). -spec get_alias_name(Name :: binary()) -> term().
get_alias_name(Name) when is_binary(Name) -> get_alias_name(Name) when is_binary(Name) ->
list_to_atom("endpoint:" ++ binary_to_list(Name)). {endpoint_alias, Name}.
-spec get_alias_pid(Name :: binary()) -> undefined | pid(). -spec get_alias_pid(Name :: binary()) -> undefined | pid().
get_alias_pid(Name) when is_binary(Name) -> get_alias_pid(Name) when is_binary(Name) ->
gproc:whereis_name({n, l, get_alias_name(Name)}). gproc:whereis_name({n, l, get_alias_name(Name)}).
-spec forward(Pid :: pid(), Metric :: binary()) -> no_return(). -spec forward(Pid :: pid(), Metric :: binary()) -> ok.
forward(Pid, Metric) when is_pid(Pid), is_binary(Metric) -> forward(Pid, Metric) when is_pid(Pid), is_binary(Metric) ->
gen_server:cast(Pid, {forward, Metric}). gen_server:cast(Pid, {forward, Metric}).
reload(Pid, NEndpoint = #endpoint{}) when is_pid(Pid) -> reload(Pid, NEndpoint = #endpoint{}) when is_pid(Pid) ->
gen_statem:cast(Pid, {reload, NEndpoint}). gen_server:cast(Pid, {reload, NEndpoint}).
-spec clean_up(Pid :: pid()) -> ok. -spec clean_up(Pid :: pid()) -> ok.
clean_up(Pid) when is_pid(Pid) -> clean_up(Pid) when is_pid(Pid) ->
gen_server:call(Pid, clean_up, 5000). gen_server:cast(Pid, cleanup).
-spec get_protocol(Endpoint :: #endpoint{}) -> atom(). -spec get_protocol(Endpoint :: #endpoint{}) -> atom().
get_protocol(#endpoint{config = #http_endpoint{}}) -> get_protocol(#endpoint{config = #http_endpoint{}}) ->
@ -68,7 +68,7 @@ get_protocol(#endpoint{config = #kafka_endpoint{}}) ->
-spec is_support(Protocol :: atom()) -> boolean(). -spec is_support(Protocol :: atom()) -> boolean().
is_support(Protocol) when is_atom(Protocol) -> is_support(Protocol) when is_atom(Protocol) ->
{ok, Props} = application:get_env(iot, endpoints), {ok, Props} = application:get_env(endpoint, endpoints),
SupportProtocols = proplists:get_value(support_protocols, Props, []), SupportProtocols = proplists:get_value(support_protocols, Props, []),
lists:member(Protocol, SupportProtocols). lists:member(Protocol, SupportProtocols).
@ -92,16 +92,27 @@ endpoint_record(#{<<"id">> := Id, <<"matcher">> := Matcher, <<"title">> := Title
-spec parse_config(Protocol :: binary(), Config :: map()) -> {ok, #mqtt_endpoint{} | #kafka_endpoint{} | #http_endpoint{}} | {error, Errors :: [Error :: binary()]}. -spec parse_config(Protocol :: binary(), Config :: map()) -> {ok, #mqtt_endpoint{} | #kafka_endpoint{} | #http_endpoint{}} | {error, Errors :: [Error :: binary()]}.
parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id">> := ClientId, <<"username">> := Username, <<"password">> := Password, <<"topic">> := Topic, <<"qos">> := Qos}) -> parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id">> := ClientId, <<"username">> := Username, <<"password">> := Password, <<"topic">> := Topic, <<"qos">> := Qos}) ->
Port = if is_binary(Port0) -> binary_to_integer(Port0); is_integer(Port0) -> Port0 end, {Port, PortErrors} = case parse_integer(Port0) of
{ok, ParsedPort} ->
{ParsedPort, []};
error ->
{undefined, [<<"port invalid">>]}
end,
Errors = lists:filtermap(fun(Term) -> CheckTerms = case Port of
undefined ->
[{host, Host}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}];
_ ->
[{host, Host}, {port, Port}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}]
end,
Errors = PortErrors ++ lists:filtermap(fun(Term) ->
case check_mqtt_argument(Term) of case check_mqtt_argument(Term) of
ok -> ok ->
false; false;
{error, Error} -> {error, Error} ->
{true, Error} {true, Error}
end end
end, [{host, Host}, {port, Port}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}]), end, CheckTerms),
case Errors =:= [] of case Errors =:= [] of
true -> true ->
{ok, #mqtt_endpoint{ {ok, #mqtt_endpoint{
@ -118,11 +129,24 @@ parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id
end; end;
parse_config(<<"http">>, C = #{<<"url">> := Url, <<"pool_size">> := PoolSize}) -> parse_config(<<"http">>, C = #{<<"url">> := Url, <<"pool_size">> := PoolSize}) ->
Token = maps:get(<<"token">>, C, <<>>), Token = maps:get(<<"token">>, C, <<>>),
{ok, #http_endpoint{ Errors = lists:filtermap(fun(Term) ->
url = Url, case check_http_argument(Term) of
token = Token, ok ->
pool_size = PoolSize false;
}}; {error, Error} ->
{true, Error}
end
end, [{url, Url}, {token, Token}, {pool_size, PoolSize}]),
case Errors =:= [] of
true ->
{ok, #http_endpoint{
url = Url,
token = Token,
pool_size = PoolSize
}};
false ->
{error, Errors}
end;
parse_config(<<"kafka">>, #{<<"sasl_config">> := #{<<"username">> := Username, <<"password">> := Password, <<"mechanism">> := Mechanism0}, <<"bootstrap_servers">> := BootstrapServers, <<"topic">> := Topic}) -> parse_config(<<"kafka">>, #{<<"sasl_config">> := #{<<"username">> := Username, <<"password">> := Password, <<"mechanism">> := Mechanism0}, <<"bootstrap_servers">> := BootstrapServers, <<"topic">> := Topic}) ->
Errors = lists:filtermap(fun(Term) -> Errors = lists:filtermap(fun(Term) ->
case check_kafka_argument(Term) of case check_kafka_argument(Term) of
@ -168,8 +192,8 @@ parse_config(_, _) ->
-spec parse_kafka_bootstrap_servers(BootstrapServers :: [binary()]) -> Servers :: [{Host :: string(), Port :: integer()}]. -spec parse_kafka_bootstrap_servers(BootstrapServers :: [binary()]) -> Servers :: [{Host :: string(), Port :: integer()}].
parse_kafka_bootstrap_servers(BootstrapServers) when is_list(BootstrapServers) -> parse_kafka_bootstrap_servers(BootstrapServers) when is_list(BootstrapServers) ->
lists:map(fun(S) -> lists:map(fun(S) ->
[Host0, Port0] = binary:split(S, <<":">>), {ok, Host, Port} = parse_kafka_bootstrap_server(S),
{binary_to_list(Host0), binary_to_integer(Port0)} {Host, Port}
end, BootstrapServers). end, BootstrapServers).
-spec parse_kafka_mechanism(Mechanism0 :: binary()) -> atom(). -spec parse_kafka_mechanism(Mechanism0 :: binary()) -> atom().
@ -218,17 +242,10 @@ check_kafka_argument({bootstrap_servers, BootstrapServers}) ->
case is_list(BootstrapServers) andalso length(BootstrapServers) > 0 of case is_list(BootstrapServers) andalso length(BootstrapServers) > 0 of
true -> true ->
InvalidServers = lists:filtermap(fun(S) -> InvalidServers = lists:filtermap(fun(S) ->
case binary:split(S, <<":">>) of case parse_kafka_bootstrap_server(S) of
[Host0, Port0] -> {ok, _Host, _Port} ->
Host = binary_to_list(Host0), false;
Port = binary_to_integer(Port0), error ->
case not string:is_empty(Host) andalso (is_integer(Port) andalso Port > 0) of
true ->
false;
false ->
{true, S}
end;
_ ->
{true, S} {true, S}
end end
end, BootstrapServers), end, BootstrapServers),
@ -243,6 +260,29 @@ check_kafka_argument({bootstrap_servers, BootstrapServers}) ->
{error, <<"bootstrap_servers is empty">>} {error, <<"bootstrap_servers is empty">>}
end. end.
-spec check_http_argument(tuple()) -> ok | {error, Reason :: binary()}.
check_http_argument({url, Url}) ->
case is_binary(Url) andalso Url /= <<>> of
true ->
ok;
false ->
{error, <<"url is empty">>}
end;
check_http_argument({token, Token}) ->
case is_binary(Token) of
true ->
ok;
false ->
{error, <<"token invalid">>}
end;
check_http_argument({pool_size, PoolSize}) ->
case is_integer(PoolSize) andalso PoolSize > 0 of
true ->
ok;
false ->
{error, <<"pool_size invalid">>}
end.
-spec check_mqtt_argument(tuple()) -> ok | {error, Reason :: binary()}. -spec check_mqtt_argument(tuple()) -> ok | {error, Reason :: binary()}.
check_mqtt_argument({host, Host}) -> check_mqtt_argument({host, Host}) ->
case Host /= <<>> of case Host /= <<>> of
@ -286,3 +326,34 @@ check_mqtt_argument({qos, Qos}) ->
false -> false ->
{error, <<"qos invalid">>} {error, <<"qos invalid">>}
end. end.
-spec parse_integer(term()) -> {ok, integer()} | error.
parse_integer(Value) when is_integer(Value) ->
{ok, Value};
parse_integer(Value) when is_binary(Value) ->
try binary_to_integer(Value) of
Int ->
{ok, Int}
catch
_:_ ->
error
end;
parse_integer(_) ->
error.
-spec parse_kafka_bootstrap_server(term()) -> {ok, string(), integer()} | error.
parse_kafka_bootstrap_server(Server) when is_binary(Server) ->
case binary:split(Server, <<":">>) of
[Host0, Port0] ->
Host = binary_to_list(Host0),
case parse_integer(Port0) of
{ok, Port} when Host =/= [], Port > 0 ->
{ok, Host, Port};
_ ->
error
end;
_ ->
error
end;
parse_kafka_bootstrap_server(_) ->
error.

View File

@ -3,13 +3,14 @@
%% @end %% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(endpoint_sup). -module(endpoint_adapter_sup).
-behaviour(supervisor). -behaviour(supervisor).
-include("endpoint.hrl"). -include("endpoint.hrl").
-export([start_link/0]). -export([start_link/0]).
-export([ensured_endpoint_started/1, delete_endpoint/1]). -export([ensured_endpoint_started/1, delete_endpoint/1]).
-export([start_endpoints/1]).
-export([init/1]). -export([init/1]).
@ -29,21 +30,15 @@ start_link() ->
%% modules => modules()} % optional %% modules => modules()} % optional
init([]) -> init([]) ->
SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600}, SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600},
Endpoints = iot_api:get_all_endpoints(), {ok, {SupFlags, []}}.
ChildSpecs = lists:filtermap(fun(EndpointInfo) ->
case endpoint:endpoint_record(EndpointInfo) of -spec start_endpoints(Endpoints :: list()) -> ok.
error -> start_endpoints(Endpoints) when is_list(Endpoints) ->
false; lists:foreach(fun(Endpoint) ->
{ok, Endpoint} -> Spec = child_spec(Endpoint),
case endpoint:is_support(endpoint:get_protocol(Endpoint)) of {ok, _} = supervisor:start_child(?MODULE, Spec)
true -> end, Endpoints),
{true, child_spec(Endpoint)}; ok.
false ->
false
end
end
end, Endpoints),
{ok, {SupFlags, ChildSpecs}}.
-spec ensured_endpoint_started(Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | {error, Reason :: any()}. -spec ensured_endpoint_started(Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | {error, Reason :: any()}.
ensured_endpoint_started(Endpoint = #endpoint{}) -> ensured_endpoint_started(Endpoint = #endpoint{}) ->
@ -59,8 +54,24 @@ ensured_endpoint_started(Endpoint = #endpoint{}) ->
-spec delete_endpoint(Id :: integer()) -> ok | {error, Reason :: any()}. -spec delete_endpoint(Id :: integer()) -> ok | {error, Reason :: any()}.
delete_endpoint(Id) when is_integer(Id) -> delete_endpoint(Id) when is_integer(Id) ->
Name = endpoint:get_name(Id), Name = endpoint:get_name(Id),
supervisor:terminate_child(?MODULE, Name), case supervisor:terminate_child(?MODULE, Name) of
supervisor:delete_child(?MODULE, Name). ok ->
delete_endpoint_child(Name);
{error, not_found} ->
delete_endpoint_child(Name);
{error, Reason} ->
{error, Reason}
end.
delete_endpoint_child(Name) ->
case supervisor:delete_child(?MODULE, Name) of
ok ->
ok;
{error, not_found} ->
ok;
{error, Reason} ->
{error, Reason}
end.
child_spec(Endpoint = #endpoint{id = Id}) -> child_spec(Endpoint = #endpoint{id = Id}) ->
Name = endpoint:get_name(Id), Name = endpoint:get_name(Id),

View File

@ -0,0 +1,15 @@
%%%-------------------------------------------------------------------
%% @doc endpoint application entrypoint.
%% @end
%%%-------------------------------------------------------------------
-module(endpoint_app).
-behaviour(application).
-export([start/2, stop/1]).
start(_StartType, _StartArgs) ->
endpoint_sup:start_link().
stop(_State) ->
ok.

View File

@ -0,0 +1,304 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_buffer).
-include("endpoint.hrl").
%%
-define(RETRY_INTERVAL, 5000).
%%
-define(MAX_RETRY_TIMES, 3).
%% endpoint_outbox
-define(MAX_PAYLOAD_BYTES, 32 * 1024).
-export([new/2, append/2, append_only/2, trigger_next/1, trigger_n/1, handle_timeout/4, cleanup/1, recover_inflight/1, ack/2, stat/1, resize/2]).
-export_type([buffer/0]).
-type flight_source() :: outbox | memory.
-type timer_entry() :: {reference(), binary(), non_neg_integer(), flight_source()}.
-record(buffer, {
endpoint :: #endpoint{},
outbox :: endpoint_outbox:outbox(),
%% ack #{Id => {TimerRef, Payload, RetryTimes, Source}}
timer_map = #{} :: #{integer() => timer_entry()},
%%
window_size = 10,
%%
flight_num = 0,
%%
acc_num = 0
}).
-type buffer() :: #buffer{}.
-spec new(Endpoint :: #endpoint{}, WindowSize :: integer()) -> Buffer :: #buffer{}.
new(Endpoint = #endpoint{id = Id}, WindowSize) when is_integer(WindowSize), WindowSize > 0 ->
%%
{ok, Endpoints} = application:get_env(endpoint, endpoints),
RootDir = proplists:get_value(root_dir, Endpoints),
OutboxDir = filename:join(RootDir, integer_to_list(Id)),
{ok, Outbox} = endpoint_outbox:open(OutboxDir, #{}),
#buffer{outbox = Outbox, endpoint = Endpoint, window_size = WindowSize}.
-spec append(Payload :: binary(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
append(Payload, Buffer = #buffer{}) when is_binary(Payload) ->
case validate_payload_size(Payload, Buffer) of
ok ->
%% Persist before dispatch so a VM crash does not lose in-flight data.
case append_to_outbox(Payload, Buffer) of
{ok, NBuffer} ->
trigger_next(NBuffer);
{dropped, NBuffer} ->
trigger_next(NBuffer);
{error, NBuffer} ->
NBuffer
end;
error ->
Buffer
end.
-spec append_only(Payload :: binary(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
append_only(Payload, Buffer = #buffer{}) when is_binary(Payload) ->
case validate_payload_size(Payload, Buffer) of
ok ->
case append_to_outbox(Payload, Buffer) of
{ok, NBuffer} ->
NBuffer;
{dropped, NBuffer} ->
NBuffer;
{error, NBuffer} ->
NBuffer
end;
error ->
Buffer
end.
-spec trigger_n(Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
trigger_n(Buffer = #buffer{window_size = WindowSize}) ->
%% window_size
lists:foldl(fun(_, Buffer0) -> trigger_next(Buffer0) end, Buffer, lists:seq(1, WindowSize)).
%%
-spec trigger_next(Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
trigger_next(Buffer = #buffer{outbox = Outbox, flight_num = FlightNum, window_size = WindowSize}) ->
case FlightNum < WindowSize of
false ->
Buffer;
true ->
case endpoint_outbox:next(Outbox) of
eof ->
Buffer;
{ok, Id, Payload, NOutbox} ->
ReceiverPid = self(),
ReceiverPid ! {next_data, Id, Payload},
schedule_retry(Id, Payload, 0, outbox, Buffer#buffer{outbox = NOutbox, flight_num = FlightNum + 1});
{error, Reason} ->
logger:warning("[endpoint_buffer] read next outbox failed, endpoint_id: ~p, reason: ~p",
[buffer_endpoint_id(Buffer), Reason]),
Buffer
end
end.
-spec handle_timeout(reference(), integer(), binary(), #buffer{}) -> #buffer{}.
handle_timeout(TimerRef, Id, _Payload, Buffer = #buffer{timer_map = TimerMap})
when is_reference(TimerRef), is_integer(Id) ->
case maps:take(Id, TimerMap) of
{{TimerRef, Payload, RetryTimes, Source}, NTimerMap} when RetryTimes < ?MAX_RETRY_TIMES ->
logger:warning("[endpoint_buffer] retry message, endpoint_id: ~p, id: ~p, retry: ~p/~p",
[buffer_endpoint_id(Buffer), Id, RetryTimes + 1, ?MAX_RETRY_TIMES]),
ReceiverPid = self(),
ReceiverPid ! {next_data, Id, Payload},
schedule_retry(Id, Payload, RetryTimes + 1, Source, Buffer#buffer{timer_map = NTimerMap});
{{TimerRef, Payload, RetryTimes, outbox}, NTimerMap} ->
logger:warning("[endpoint_buffer] drop message after retries exhausted, endpoint_id: ~p, id: ~p, retries: ~p",
[buffer_endpoint_id(Buffer), Id, RetryTimes]),
drop_outbox_message(Id, Payload, RetryTimes, Buffer#buffer{timer_map = NTimerMap});
{{TimerRef, _, RetryTimes, memory}, NTimerMap} ->
logger:warning("[endpoint_buffer] drop memory message after retries exhausted, endpoint_id: ~p, id: ~p, retries: ~p",
[buffer_endpoint_id(Buffer), Id, RetryTimes]),
drop_memory_message(Buffer#buffer{timer_map = NTimerMap});
{{_OtherTimerRef, _OtherPayload, _RetryTimes, _Source}, _NTimerMap} ->
Buffer;
error ->
Buffer
end.
-spec ack(Id :: integer(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
ack(Id, Buffer = #buffer{timer_map = TimerMap, outbox = Outbox, acc_num = AccNum, flight_num = FlightNum}) when is_integer(Id) ->
case maps:take(Id, TimerMap) of
{{TimerRef, _Payload, _RetryTimes, memory}, NTimerMap} ->
_ = erlang:cancel_timer(TimerRef),
NBuffer = Buffer#buffer{
timer_map = NTimerMap,
acc_num = AccNum + 1,
flight_num = max(FlightNum - 1, 0)
},
trigger_next(NBuffer);
{{TimerRef, Payload, RetryTimes, outbox}, NTimerMap} ->
_ = erlang:cancel_timer(TimerRef),
case endpoint_outbox:ack(Id, Outbox) of
{ok, NOutbox} ->
NBuffer = Buffer#buffer{
timer_map = NTimerMap,
outbox = NOutbox,
acc_num = AccNum + 1,
flight_num = max(FlightNum - 1, 0)
},
trigger_next(NBuffer);
{error, Reason} ->
logger:warning("[endpoint_buffer] ack outbox failed, endpoint_id: ~p, id: ~p, reason: ~p",
[buffer_endpoint_id(Buffer), Id, Reason]),
schedule_retry(Id, Payload, RetryTimes, outbox, Buffer#buffer{timer_map = NTimerMap})
end;
error ->
Buffer
end.
%%
-spec stat(Buffer :: #buffer{}) -> map().
stat(#buffer{acc_num = AccNum, outbox = Outbox, flight_num = FlightNum, timer_map = TimerMap}) ->
OutboxStat = endpoint_outbox:stat(Outbox),
WriteSeq = maps:get(write_seq, OutboxStat, 0),
AckedSeq = maps:get(acked_seq, OutboxStat, 0),
OutboxFlightNum = count_inflight(outbox, TimerMap),
MemoryFlightNum = count_inflight(memory, TimerMap),
QueueNum = max(WriteSeq - AckedSeq - OutboxFlightNum, 0),
OutboxStat#{
<<"acc_num">> => AccNum,
<<"queue_num">> => QueueNum,
<<"inflight_num">> => FlightNum,
<<"outbox_inflight_num">> => OutboxFlightNum,
<<"memory_inflight_num">> => MemoryFlightNum
}.
-spec cleanup(Buffer :: #buffer{}) -> #buffer{}.
cleanup(Buffer = #buffer{timer_map = TimerMap}) ->
cancel_timers(TimerMap),
NBuffer0 = persist_memory_inflight(Buffer),
reset_reader_after_recover(NBuffer0#buffer{timer_map = #{}, flight_num = 0}).
-spec recover_inflight(Buffer :: #buffer{}) -> #buffer{}.
recover_inflight(Buffer = #buffer{timer_map = TimerMap}) ->
cancel_timers(TimerMap),
NBuffer0 = persist_memory_inflight(Buffer),
case endpoint_outbox:reset_reader(NBuffer0#buffer.outbox) of
{ok, NOutbox} ->
NBuffer0#buffer{outbox = NOutbox, timer_map = #{}, flight_num = 0}
end.
-spec resize(Buffer :: #buffer{}, WindowSize :: integer()) -> #buffer{}.
resize(Buffer = #buffer{}, WindowSize) when is_integer(WindowSize), WindowSize > 0 ->
trigger_n(Buffer#buffer{window_size = WindowSize}).
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec buffer_endpoint_id(buffer()) -> integer().
buffer_endpoint_id(#buffer{endpoint = #endpoint{id = Id}}) ->
Id.
-spec validate_payload_size(binary(), buffer()) -> ok | error.
validate_payload_size(Payload, Buffer) ->
case byte_size(Payload) =< ?MAX_PAYLOAD_BYTES of
true ->
ok;
false ->
logger:warning("[endpoint_buffer] payload too large, endpoint_id: ~p, size: ~p, max_size: ~p",
[buffer_endpoint_id(Buffer), byte_size(Payload), ?MAX_PAYLOAD_BYTES]),
error
end.
-spec append_to_outbox(binary(), buffer()) -> {ok | dropped | error, buffer()}.
append_to_outbox(Payload, Buffer = #buffer{outbox = Outbox}) ->
case endpoint_outbox:append(Payload, Outbox) of
{ok, _Seq, NOutbox} ->
{ok, Buffer#buffer{outbox = NOutbox}};
{dropped, capacity_reached, NOutbox} ->
logger:warning("[endpoint_buffer] outbox capacity reached, endpoint_id: ~p", [buffer_endpoint_id(Buffer)]),
{dropped, Buffer#buffer{outbox = NOutbox}};
{error, Reason} ->
logger:warning("[endpoint_buffer] append outbox failed, endpoint_id: ~p, reason: ~p",
[buffer_endpoint_id(Buffer), Reason]),
{error, Buffer}
end.
-spec schedule_retry(integer(), binary(), non_neg_integer(), flight_source(), buffer()) -> buffer().
schedule_retry(Id, Payload, RetryTimes, Source, Buffer = #buffer{timer_map = TimerMap})
when is_integer(Id), is_binary(Payload), is_integer(RetryTimes), RetryTimes >= 0 ->
TimerRef = erlang:start_timer(?RETRY_INTERVAL, self(), {endpoint_buffer_retry, Id, Payload}),
Buffer#buffer{timer_map = maps:put(Id, {TimerRef, Payload, RetryTimes, Source}, TimerMap)}.
-spec cancel_timers(#{integer() => timer_entry()}) -> ok.
cancel_timers(TimerMap) ->
lists:foreach(fun({_Id, {TimerRef, _Payload, _RetryTimes, _Source}}) ->
_ = erlang:cancel_timer(TimerRef),
ok
end, maps:to_list(TimerMap)),
ok.
-spec drop_outbox_message(integer(), binary(), non_neg_integer(), buffer()) -> buffer().
drop_outbox_message(Id, Payload, RetryTimes, Buffer = #buffer{outbox = Outbox, acc_num = AccNum, flight_num = FlightNum}) ->
case endpoint_outbox:ack(Id, Outbox) of
{ok, NOutbox} ->
trigger_next(Buffer#buffer{
outbox = NOutbox,
acc_num = AccNum + 1,
flight_num = max(FlightNum - 1, 0)
});
{error, Reason} ->
logger:warning("[endpoint_buffer] ack dropped message failed, endpoint_id: ~p, id: ~p, reason: ~p",
[buffer_endpoint_id(Buffer), Id, Reason]),
schedule_retry(Id, Payload, RetryTimes, outbox, Buffer)
end.
-spec drop_memory_message(buffer()) -> buffer().
drop_memory_message(Buffer = #buffer{acc_num = AccNum, flight_num = FlightNum}) ->
trigger_next(Buffer#buffer{
acc_num = AccNum + 1,
flight_num = max(FlightNum - 1, 0)
}).
-spec persist_memory_inflight(buffer()) -> buffer().
persist_memory_inflight(Buffer = #buffer{timer_map = TimerMap}) ->
MemoryInflight = lists:sort(
fun({IdA, _PayloadA}, {IdB, _PayloadB}) -> IdA > IdB end,
[{Id, Payload} || {Id, {_TimerRef, Payload, _RetryTimes, memory}} <- maps:to_list(TimerMap)]
),
lists:foldl(fun({_Id, Payload}, AccBuffer) ->
case append_to_outbox(Payload, AccBuffer) of
{ok, NBuffer} ->
NBuffer;
{dropped, NBuffer} ->
NBuffer;
{error, NBuffer} ->
NBuffer
end
end, Buffer, MemoryInflight).
-spec reset_reader_after_recover(buffer()) -> buffer().
reset_reader_after_recover(Buffer = #buffer{outbox = Outbox}) ->
case endpoint_outbox:reset_reader(Outbox) of
{ok, NOutbox} ->
Buffer#buffer{outbox = NOutbox}
end.
-spec count_inflight(flight_source(), #{integer() => timer_entry()}) -> non_neg_integer().
count_inflight(Source, TimerMap) ->
maps:fold(fun(_Id, {_TimerRef, _Payload, _RetryTimes, EntrySource}, Acc) ->
case EntrySource =:= Source of
true ->
Acc + 1;
false ->
Acc
end
end, 0, TimerMap).

View File

@ -30,10 +30,10 @@
%%%=================================================================== %%%===================================================================
%% @doc Spawns the server and registers the local name (unique) %% @doc Spawns the server and registers the local name (unique)
-spec(start_link(LocalName :: atom(), Endpoint :: #endpoint{}) -> -spec(start_link(LocalName :: term(), Endpoint :: #endpoint{}) ->
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}). {ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_atom(LocalName) -> start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) ->
gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []). gen_server:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []).
%%%=================================================================== %%%===================================================================
%%% gen_server callbacks %%% gen_server callbacks
@ -44,9 +44,10 @@ start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_a
-spec(init(Args :: term()) -> -spec(init(Args :: term()) ->
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
{stop, Reason :: term()} | ignore). {stop, Reason :: term()} | ignore).
init([Endpoint = #endpoint{matcher = Matcher}]) -> init([Endpoint = #endpoint{matcher = Matcher, config = #http_endpoint{pool_size = PoolSize}}]) ->
ok = endpoint_util:set_metadata(),
endpoint_subscription:subscribe(Matcher, self()), endpoint_subscription:subscribe(Matcher, self()),
Buffer = endpoint_buffer:new(Endpoint, 10), Buffer = endpoint_buffer:new(Endpoint, PoolSize),
{ok, #state{endpoint = Endpoint, buffer = Buffer}}. {ok, #state{endpoint = Endpoint, buffer = Buffer}}.
%% @private %% @private
@ -69,20 +70,17 @@ handle_call(get_stat, _From, State = #state{buffer = Buffer}) ->
{noreply, NewState :: #state{}} | {noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_cast({forward, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{token = Token}}}) -> handle_cast({forward, Metric}, State = #state{buffer = Buffer}) ->
Tuple = case is_binary(Token) andalso Token /= <<>> of NBuffer = endpoint_buffer:append(Metric, Buffer),
true ->
Sign = iot_util:sha256(erlang:iolist_to_binary([Token, Metric, Token])),
{Metric, Sign};
false ->
{Metric, <<>>}
end,
NBuffer = endpoint_buffer:append(Tuple, Buffer),
{noreply, State#state{buffer = NBuffer}}; {noreply, State#state{buffer = NBuffer}};
handle_cast({reload, NEndpoint = #endpoint{matcher = NMatcher, config = #http_endpoint{pool_size = PoolSize}}}, State = #state{endpoint = #endpoint{matcher = Matcher}, buffer = Buffer}) ->
ensure_subscription(Matcher, NMatcher),
NBuffer = endpoint_buffer:resize(Buffer, PoolSize),
{noreply, State#state{endpoint = NEndpoint, buffer = NBuffer}};
handle_cast(cleanup, State = #state{buffer = Buffer}) -> handle_cast(cleanup, State = #state{buffer = Buffer}) ->
endpoint_buffer:cleanup(Buffer), NBuffer = endpoint_buffer:cleanup(Buffer),
{noreply, State}. {noreply, State#state{buffer = NBuffer}}.
%% @private %% @private
%% @doc Handling all non call/cast messages %% @doc Handling all non call/cast messages
@ -90,32 +88,25 @@ handle_cast(cleanup, State = #state{buffer = Buffer}) ->
{noreply, NewState :: #state{}} | {noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_info({next_data, Id, {Metric, Sign}}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url}}}) -> handle_info({next_data, Id, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url, token = Token}}}) ->
BaseHeaders = [{<<"Content-Type">>, <<"application/json">>}], Headers = [{<<"Content-Type">>, <<"application/json">>}] ++ patch_headers(Metric, Token),
ExtraHeaders = if
Sign =:= <<>> -> [];
true -> [{<<"X-Signature">>, Sign}]
end,
Headers = BaseHeaders ++ ExtraHeaders,
case hackney:request(post, Url, Headers, Metric) of case hackney:request(post, Url, Headers, Metric) of
{ok, 200, _, ClientRef} -> {ok, HttpCode, _, ClientRef} when HttpCode >= 200, HttpCode < 300 ->
{ok, RespBody} = hackney:body(ClientRef), RespBody = read_response_body(ClientRef),
hackney:close(ClientRef), logger:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]),
lager:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]),
NBuffer = endpoint_buffer:ack(Id, Buffer), NBuffer = endpoint_buffer:ack(Id, Buffer),
{noreply, State#state{buffer = NBuffer}}; {noreply, State#state{buffer = NBuffer}};
{ok, HttpCode, _, ClientRef} -> {ok, HttpCode, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), RespBody = read_response_body(ClientRef),
hackney:close(ClientRef), logger:warning("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]),
lager:debug("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]), {noreply, State};
NBuffer = endpoint_buffer:ack(Id, Buffer),
{noreply, State#state{buffer = NBuffer}};
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_http] url: ~p, get error: ~p", [Url, Reason]), logger:warning("[endpoint_http] url: ~p, get error: ~p", [Url, Reason]),
{noreply, State} {noreply, State}
end. end;
handle_info({timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer),
{noreply, State#state{buffer = NBuffer}}.
%% @private %% @private
%% @doc This function is called by a gen_server when it is about to %% @doc This function is called by a gen_server when it is about to
@ -124,7 +115,8 @@ handle_info({next_data, Id, {Metric, Sign}}, State = #state{buffer = Buffer, end
%% with Reason. The return value is ignored. %% with Reason. The return value is ignored.
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), -spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
State :: #state{}) -> term()). State :: #state{}) -> term()).
terminate(_Reason, _State = #state{}) -> terminate(_Reason, #state{buffer = Buffer}) ->
_ = endpoint_buffer:cleanup(Buffer),
ok. ok.
%% @private %% @private
@ -138,3 +130,28 @@ code_change(_OldVsn, State = #state{}, _Extra) ->
%%%=================================================================== %%%===================================================================
%%% Internal functions %%% Internal functions
%%%=================================================================== %%%===================================================================
-spec ensure_subscription(binary(), binary()) -> ok.
ensure_subscription(Matcher, Matcher) ->
ok;
ensure_subscription(Matcher, NMatcher) ->
ok = endpoint_subscription:unsubscribe(Matcher, self()),
endpoint_subscription:subscribe(NMatcher, self()).
-spec read_response_body(reference()) -> binary() | term().
read_response_body(ClientRef) ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
hackney:close(ClientRef),
RespBody;
{error, Reason} ->
hackney:close(ClientRef),
Reason
end.
-spec patch_headers(Metric :: binary(), any()) -> list().
patch_headers(Metric, Token) when is_binary(Token), Token /= <<>> ->
Sign = endpoint_util:sha256(erlang:iolist_to_binary([Token, Metric, Token])),
[{<<"X-Signature">>, Sign}];
patch_headers(_, _) ->
[].

View File

@ -0,0 +1,261 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_kafka).
-include("endpoint.hrl").
-behaviour(gen_statem).
%% API
-export([start_link/2]).
%% gen_statem callbacks
-export([callback_mode/0, init/1, terminate/3, code_change/4]).
-export([disconnected/3, connected/3]).
%%
-define(RETRY_INTERVAL, 5000).
-record(state, {
endpoint :: #endpoint{},
buffer :: endpoint_buffer:buffer(),
client_id :: atom(),
client_pid :: undefined | pid()
}).
-type kafka_state() :: disconnected | connected.
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link(LocalName :: term(), Endpoint :: #endpoint{}) ->
{ok, pid()} | ignore | {error, term()}.
start_link(LocalName, Endpoint = #endpoint{}) ->
gen_statem:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
-spec callback_mode() -> state_functions.
callback_mode() ->
state_functions.
-spec init(term()) -> gen_statem:init_result(kafka_state(), #state{}).
init([Endpoint = #endpoint{id = Id, matcher = Matcher}]) ->
ok = endpoint_util:set_metadata(),
erlang:process_flag(trap_exit, true),
ok = endpoint_subscription:subscribe(Matcher, self()),
Buffer = endpoint_buffer:new(Endpoint, 10),
ClientId = list_to_atom("brod_client:" ++ integer_to_list(Id)),
{ok, disconnected, #state{endpoint = Endpoint, buffer = Buffer, client_id = ClientId},
[{state_timeout, 0, connect}]}.
-spec disconnected(gen_statem:event_type(), term(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
disconnected({call, From}, get_stat, State = #state{buffer = Buffer}) ->
reply_stat(From, Buffer, State);
disconnected(cast, {forward, Metric}, State = #state{buffer = Buffer}) ->
store_metric(Metric, Buffer, State);
disconnected(cast, cleanup, State = #state{buffer = Buffer}) ->
cleanup_buffer(Buffer, State);
disconnected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}},
State = #state{endpoint = #endpoint{matcher = Matcher}, client_id = ClientId}) ->
reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State);
disconnected(state_timeout, connect, State) ->
try_connect(State);
disconnected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer),
{keep_state, State#state{buffer = NBuffer}};
disconnected(info, {next_data, _Id, _Tuple}, State) ->
{keep_state, State};
disconnected(info, {ack, Id}, State = #state{buffer = Buffer}) ->
ack_buffer(Id, Buffer, State);
disconnected(info, Info, State) ->
unknown_info(Info, disconnected, State);
disconnected(EventType, EventContent, State) ->
unknown_event(EventType, EventContent, disconnected, State).
-spec connected(gen_statem:event_type(), term(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
connected({call, From}, get_stat, State = #state{buffer = Buffer}) ->
reply_stat(From, Buffer, State);
connected(cast, {forward, Metric}, State = #state{buffer = Buffer}) ->
forward_metric(Metric, Buffer, State);
connected(cast, cleanup, State = #state{buffer = Buffer}) ->
cleanup_buffer(Buffer, State);
connected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}},
State = #state{endpoint = #endpoint{matcher = Matcher}, client_id = ClientId}) ->
reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State);
connected(info, {next_data, Id, Metric},
State = #state{
buffer = Buffer,
client_pid = ClientPid,
client_id = ClientId,
endpoint = #endpoint{config = #kafka_endpoint{topic = Topic}}
}) ->
ReceiverPid = self(),
AckCb = fun(Partition, BaseOffset) ->
logger:debug("[endpoint_kafka] ack partion: ~p, offset: ~p", [Partition, BaseOffset]),
ReceiverPid ! {ack, Id}
end,
case catch brod:produce_cb(ClientPid, Topic, random, <<>>, Metric, AckCb) of
{ok, _CallRef} ->
{keep_state, State};
{ok, _CallRef, _ProducerPid} ->
{keep_state, State};
{error, Reason} ->
logger:warning("[endpoint_kafka] produce topic: ~p, get error: ~p", [Topic, Reason]),
stop_kafka_client(ClientId),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]};
{'EXIT', Reason} ->
logger:warning("[endpoint_kafka] produce topic: ~p, exit with reason: ~p", [Topic, Reason]),
stop_kafka_client(ClientId),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]}
end;
connected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer),
{keep_state, State#state{buffer = NBuffer}};
connected(info, {ack, Id}, State = #state{buffer = Buffer}) ->
ack_buffer(Id, Buffer, State);
connected(info, {'EXIT', ClientPid, Reason},
State = #state{client_pid = ClientPid, endpoint = #endpoint{title = Title}, buffer = Buffer}) ->
logger:warning("[endpoint_kafka] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]};
connected(info, Info, State) ->
unknown_info(Info, connected, State);
connected(EventType, EventContent, State) ->
unknown_event(EventType, EventContent, connected, State).
-spec terminate(term(), kafka_state(), #state{}) -> term().
terminate(Reason, _StateName, #state{endpoint = #endpoint{title = Title}, buffer = Buffer, client_id = ClientId}) ->
logger:debug("[endpoint_kafka] endpoint: ~p, terminate with reason: ~p", [Title, Reason]),
stop_kafka_client(ClientId),
endpoint_buffer:cleanup(Buffer),
ok.
-spec code_change(term() | {down, term()}, kafka_state(), #state{}, term()) ->
{ok, kafka_state(), #state{}} | {error, term()}.
code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
{ok, StateName, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec reply_stat(gen_statem:from(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
reply_stat(From, Buffer, State) ->
Stat = endpoint_buffer:stat(Buffer),
{keep_state, State, [{reply, From, {ok, Stat}}]}.
-spec forward_metric(binary(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
forward_metric(Metric, Buffer, State) ->
NBuffer = endpoint_buffer:append(Metric, Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec store_metric(binary(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
store_metric(Metric, Buffer, State) ->
NBuffer = endpoint_buffer:append_only(Metric, Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec cleanup_buffer(endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
cleanup_buffer(Buffer, _State) ->
NBuffer = endpoint_buffer:cleanup(Buffer),
{keep_state, _State#state{buffer = NBuffer}}.
-spec ack_buffer(integer(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
ack_buffer(Id, Buffer, State) ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec reload_endpoint(binary(), binary(), atom(), #endpoint{}, #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State = #state{}) ->
ensure_subscription(Matcher, NMatcher),
stop_kafka_client(ClientId),
NBuffer = endpoint_buffer:recover_inflight(State#state.buffer),
{next_state, disconnected, State#state{endpoint = NEndpoint, client_pid = undefined, buffer = NBuffer},
[{state_timeout, 0, connect}]}.
-spec try_connect(#state{}) -> gen_statem:event_handler_result(kafka_state(), #state{}).
try_connect(State = #state{
buffer = Buffer,
client_id = ClientId,
endpoint = #endpoint{
title = Title,
config = #kafka_endpoint{
sasl_config = SaslConfig,
bootstrap_servers = BootstrapServers,
topic = Topic
}
}
}) ->
logger:debug("[endpoint_kafka] endpoint: ~p, create postman", [Title]),
BaseConfig = [
{reconnect_cool_down_seconds, 5},
{socket_options, [{keepalive, true}]}
],
ClientConfig = case SaslConfig of
{Mechanism, Username, Password} ->
[{sasl, {Mechanism, Username, Password}} | BaseConfig];
undefined ->
BaseConfig
end,
case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of
{ok, ClientPid} ->
case brod:start_producer(ClientId, Topic, []) of
ok ->
NBuffer = endpoint_buffer:trigger_n(Buffer),
{next_state, connected, State#state{buffer = NBuffer, client_pid = ClientPid}};
{error, Reason} ->
logger:debug("[endpoint_kafka] start_producer: ~p, get error: ~p", [ClientId, Reason]),
stop_kafka_client(ClientId),
{keep_state, State#state{client_pid = undefined},
[{state_timeout, ?RETRY_INTERVAL, connect}]}
end;
Error ->
logger:debug("[endpoint_kafka] start_client: ~p, get error: ~p", [ClientId, Error]),
{keep_state, State#state{client_pid = undefined},
[{state_timeout, ?RETRY_INTERVAL, connect}]}
end.
-spec unknown_info(term(), kafka_state(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
unknown_info(Info, StateName, State) ->
logger:warning("[endpoint_kafka] unknown message: ~p, status: ~p", [Info, StateName]),
{keep_state, State}.
-spec unknown_event(gen_statem:event_type(), term(), kafka_state(), #state{}) ->
gen_statem:event_handler_result(kafka_state(), #state{}).
unknown_event(EventType, EventContent, StateName, State) ->
logger:warning("[endpoint_kafka] unknown event: ~p, content: ~p, status: ~p", [EventType, EventContent, StateName]),
{keep_state, State}.
-spec ensure_subscription(binary(), binary()) -> ok.
ensure_subscription(Matcher, Matcher) ->
ok;
ensure_subscription(Matcher, NMatcher) ->
ok = endpoint_subscription:unsubscribe(Matcher, self()),
endpoint_subscription:subscribe(NMatcher, self()).
-spec stop_kafka_client(atom()) -> ok.
stop_kafka_client(ClientId) when is_atom(ClientId) ->
_ = catch brod:stop_client(ClientId),
ok.

View File

@ -0,0 +1,227 @@
%%%-------------------------------------------------------------------
%%% @doc Endpoint diagnostic log.
%%%
%%% publish
%%% endpoint_subscription
%%%-------------------------------------------------------------------
-module(endpoint_log).
-behaviour(gen_server).
%% API
-export([start_link/0, unmatched_publish/2]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
-define(LOG_NAME, endpoint_unmatched_publish_log).
-define(DEFAULT_PATH_TEMPLATE, "${endpoint_root}/endpoint_log/unmatched_publish.log").
-define(DEFAULT_ENDPOINT_ROOT_DIR, "log").
-define(DEFAULT_MAX_BYTES, 10485760).
-define(DEFAULT_MAX_FILES, 10).
-define(MAX_LOG_CONTENT_BYTES, 32 * 1024).
-record(state, {
enabled = false :: boolean(),
log_name = ?LOG_NAME :: atom()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
-spec unmatched_publish(RouteKey :: binary(), Content :: binary()) -> ok.
unmatched_publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) ->
case erlang:whereis(?SERVER) of
undefined ->
ok;
Pid when is_pid(Pid) ->
gen_server:cast(Pid, {unmatched_publish, RouteKey, Content}),
ok
end.
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
-spec init(term()) -> {ok, #state{}}.
init([]) ->
ok = endpoint_util:set_metadata(),
case open_log() of
ok ->
{ok, #state{enabled = true}};
{error, Reason} ->
logger:warning("[endpoint_log] open disk_log failed, reason: ~p", [Reason]),
{ok, #state{enabled = false}}
end.
-spec handle_call(term(), gen_server:from(), #state{}) ->
{reply, ok, #state{}}.
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast({unmatched_publish, RouteKey, Content}, State = #state{enabled = true, log_name = LogName}) ->
Entry = encode_unmatched_publish(RouteKey, Content),
case disk_log:blog(LogName, Entry) of
ok ->
{noreply, State};
{error, Reason} ->
logger:warning("[endpoint_log] write unmatched publish log failed, reason: ~p", [Reason]),
{noreply, State#state{enabled = false}}
end;
handle_cast({unmatched_publish, _RouteKey, _Content}, State) ->
{noreply, State};
handle_cast(_Request, State) ->
{noreply, State}.
-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
handle_info(Info, State) ->
logger:debug("[endpoint_log] unknown info: ~p", [Info]),
{noreply, State}.
-spec terminate(term(), #state{}) -> ok.
terminate(_Reason, #state{enabled = true, log_name = LogName}) ->
_ = disk_log:close(LogName),
ok;
terminate(_Reason, _State) ->
ok.
-spec code_change(term() | {down, term()}, #state{}, term()) ->
{ok, #state{}} | {error, term()}.
code_change(_OldVsn, State = #state{}, _Extra) ->
{ok, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec open_log() -> ok | {error, term()}.
open_log() ->
Config = log_config(),
Path = log_path(Config),
ok = filelib:ensure_dir(Path),
case disk_log:open([
{name, ?LOG_NAME},
{file, Path},
{type, wrap},
{format, external},
{size, {config_pos_integer(max_bytes, Config, ?DEFAULT_MAX_BYTES),
config_pos_integer(max_files, Config, ?DEFAULT_MAX_FILES)}},
{linkto, self()},
{repair, true}
]) of
{ok, ?LOG_NAME} ->
ok;
{repaired, ?LOG_NAME, _Recovered, _BadBytes} ->
ok;
{error, Reason} ->
{error, Reason}
end.
-spec log_config() -> proplists:proplist().
log_config() ->
case application:get_env(endpoint, endpoint_log) of
{ok, Config} when is_list(Config) ->
Config;
_ ->
[]
end.
-spec log_path(proplists:proplist()) -> file:filename_all().
log_path(Config) ->
Template = proplists:get_value(path, Config, ?DEFAULT_PATH_TEMPLATE),
expand_path_template(Template).
-spec endpoint_root_dir() -> file:filename_all().
endpoint_root_dir() ->
case application:get_env(endpoint, endpoints) of
{ok, Endpoints} ->
proplists:get_value(root_dir, Endpoints, ?DEFAULT_ENDPOINT_ROOT_DIR);
undefined ->
?DEFAULT_ENDPOINT_ROOT_DIR
end.
-spec config_pos_integer(atom(), proplists:proplist(), pos_integer()) -> pos_integer().
config_pos_integer(Key, Config, Default) ->
case proplists:get_value(Key, Config, Default) of
Value when is_integer(Value), Value > 0 ->
Value;
_ ->
Default
end.
-spec expand_path_template(file:filename_all()) -> file:filename_all().
expand_path_template(Template) ->
TemplateBin = iolist_to_binary(Template),
Vars = path_vars(),
Expanded = lists:foldl(fun({Name, Value}, Acc) ->
replace_path_var(Name, Value, Acc)
end, TemplateBin, Vars),
binary_to_list(Expanded).
-spec path_vars() -> [{binary(), binary()}].
path_vars() ->
{{Year, Month, Day}, {Hour, _Minute, _Second}} = calendar:local_time(),
[
{<<"endpoint_root">>, iolist_to_binary(endpoint_root_dir())},
{<<"date">>, format_date(Year, Month, Day)},
{<<"year">>, integer_to_binary(Year)},
{<<"month">>, two_digits(Month)},
{<<"day">>, two_digits(Day)},
{<<"hour">>, two_digits(Hour)},
{<<"node">>, atom_to_binary(node(), utf8)}
].
-spec replace_path_var(binary(), binary(), binary()) -> binary().
replace_path_var(Name, Value, Template) ->
Template0 = binary:replace(Template, <<"${", Name/binary, "}">>, Value, [global]),
binary:replace(Template0, <<"{", Name/binary, "}">>, Value, [global]).
-spec encode_unmatched_publish(binary(), binary()) -> binary().
encode_unmatched_publish(RouteKey, Content) ->
{LoggedContent, Truncated} = maybe_truncate(Content),
iolist_to_binary([
format_local_time(), <<"\t">>,
<<"unmatched_publish">>, <<"\t">>,
RouteKey, <<"\t">>,
integer_to_binary(byte_size(Content)), <<"\t">>,
boolean_to_binary(Truncated), <<"\t">>,
base64:encode(LoggedContent), <<"\n">>
]).
-spec boolean_to_binary(boolean()) -> binary().
boolean_to_binary(true) ->
<<"true">>;
boolean_to_binary(false) ->
<<"false">>.
-spec format_local_time() -> binary().
format_local_time() ->
MilliSeconds = erlang:system_time(millisecond) rem 1000,
{{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(),
iolist_to_binary(io_lib:format(
"~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B.~3..0B",
[Year, Month, Day, Hour, Minute, Second, MilliSeconds]
)).
-spec format_date(integer(), integer(), integer()) -> binary().
format_date(Year, Month, Day) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Year, Month, Day])).
-spec two_digits(integer()) -> binary().
two_digits(Value) ->
iolist_to_binary(io_lib:format("~2..0B", [Value])).
-spec maybe_truncate(binary()) -> {binary(), boolean()}.
maybe_truncate(Content) when byte_size(Content) =< ?MAX_LOG_CONTENT_BYTES ->
{Content, false};
maybe_truncate(Content) ->
MaxBytes = ?MAX_LOG_CONTENT_BYTES,
<<LoggedContent:MaxBytes/binary, _/binary>> = Content,
{LoggedContent, true}.

View File

@ -0,0 +1,285 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_mqtt).
-include("endpoint.hrl").
-behaviour(gen_statem).
%% API
-export([start_link/2]).
%% gen_statem callbacks
-export([callback_mode/0, init/1, terminate/3, code_change/4]).
-export([disconnected/3, connected/3]).
%%
-define(RETRY_INTERVAL, 15000).
-record(state, {
endpoint :: #endpoint{},
buffer :: endpoint_buffer:buffer(),
conn_pid :: undefined | pid(),
%% , #{PacketId :: integer() => Id :: integer()}
inflight = #{}
}).
-type mqtt_state() :: disconnected | connected.
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link(LocalName :: term(), Endpoint :: #endpoint{}) ->
{ok, pid()} | ignore | {error, term()}.
start_link(LocalName, Endpoint = #endpoint{}) ->
gen_statem:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
-spec callback_mode() -> state_functions.
callback_mode() ->
state_functions.
-spec init(term()) -> gen_statem:init_result(mqtt_state(), #state{}).
init([Endpoint = #endpoint{matcher = Matcher}]) ->
ok = endpoint_util:set_metadata(),
erlang:process_flag(trap_exit, true),
ok = endpoint_subscription:subscribe(Matcher, self()),
Buffer = endpoint_buffer:new(Endpoint, 10),
{ok, disconnected, #state{endpoint = Endpoint, buffer = Buffer}, [{next_event, internal, connect}]}.
-spec disconnected(gen_statem:event_type(), term(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
disconnected({call, From}, get_stat, State = #state{buffer = Buffer}) ->
reply_stat(From, Buffer, State);
disconnected(cast, {forward, Metric}, State = #state{buffer = Buffer}) ->
store_metric(Metric, Buffer, State);
disconnected(cast, cleanup, State = #state{buffer = Buffer}) ->
cleanup_buffer(Buffer, State);
disconnected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}},
State = #state{endpoint = #endpoint{matcher = Matcher}, conn_pid = ConnPid}) ->
reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State);
disconnected(internal, connect, State) ->
{keep_state, State, [{next_event, internal, do_connect}]};
disconnected(state_timeout, connect, State) ->
{keep_state, State, [{next_event, internal, do_connect}]};
disconnected(internal, do_connect,
State = #state{
buffer = Buffer,
endpoint = #endpoint{
title = Title,
config = #mqtt_endpoint{
host = Host,
port = Port,
username = Username,
password = Password,
client_id = ClientId
}
}
}) ->
logger:debug("[endpoint_mqtt] endpoint: ~ts, create postman", [Title]),
Opts = [
{owner, self()},
{clientid, ClientId},
{host, binary_to_list(Host)},
{port, Port},
{tcp_opts, []},
{username, binary_to_list(Username)},
{password, binary_to_list(Password)},
{keepalive, 86400},
{auto_ack, true},
{connect_timeout, 5000},
{proto_ver, v5},
{retry_interval, 5000}
],
case connect_mqtt(Opts) of
{ok, ConnPid} ->
logger:debug("[endpoint_mqtt] connect success, pid: ~p", [ConnPid]),
NBuffer = endpoint_buffer:trigger_n(Buffer),
{next_state, connected, State#state{conn_pid = ConnPid, buffer = NBuffer}};
{error, Reason} ->
logger:warning("[endpoint_mqtt] connect get error: ~p", [Reason]),
{keep_state, State#state{conn_pid = undefined, inflight = #{}},
[{state_timeout, ?RETRY_INTERVAL, connect}]}
end;
disconnected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer),
{keep_state, State#state{buffer = NBuffer}};
disconnected(info, {next_data, _Id, _Tuple}, State) ->
{keep_state, State};
disconnected(info, {'EXIT', ConnPid, Reason},
State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid}) ->
logger:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]),
{keep_state, State#state{conn_pid = undefined, inflight = #{}},
[{state_timeout, ?RETRY_INTERVAL, connect}]};
disconnected(info, Info, State) ->
unknown_info(Info, disconnected, State);
disconnected(EventType, EventContent, State) ->
unknown_event(EventType, EventContent, disconnected, State).
-spec connected(gen_statem:event_type(), term(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
connected({call, From}, get_stat, State = #state{buffer = Buffer}) ->
reply_stat(From, Buffer, State);
connected(cast, {forward, Metric}, State = #state{buffer = Buffer}) ->
forward_metric(Metric, Buffer, State);
connected(cast, cleanup, State = #state{buffer = Buffer}) ->
cleanup_buffer(Buffer, State);
connected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}},
State = #state{endpoint = #endpoint{matcher = Matcher}, conn_pid = ConnPid}) ->
reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State);
connected(internal, connect, State) ->
{keep_state, State};
connected(info, {next_data, Id, Metric},
State = #state{
conn_pid = ConnPid,
buffer = Buffer,
inflight = InFlight,
endpoint = #endpoint{config = #mqtt_endpoint{topic = Topic, qos = Qos}}
}) ->
logger:debug("[endpoint_mqtt] will publish topic: ~p, metric: ~p, qos: ~p", [Topic, Metric, Qos]),
case emqtt:publish(ConnPid, Topic, #{}, Metric, [{qos, Qos}, {retain, true}]) of
ok ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{keep_state, State#state{buffer = NBuffer}};
{ok, PacketId} ->
{keep_state, State#state{inflight = maps:put(PacketId, Id, InFlight)}};
{error, Reason} ->
logger:warning("[endpoint_mqtt] send message to topic: ~p, get error: ~p", [Topic, Reason]),
stop_mqtt_conn(ConnPid),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]}
end;
connected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer),
{keep_state, State#state{buffer = NBuffer}};
connected(info, {disconnected, ReasonCode, Properties},
State = #state{conn_pid = ConnPid, buffer = Buffer}) ->
logger:debug("[endpoint_mqtt] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]),
stop_mqtt_conn(ConnPid),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]};
connected(info, {publish, Message = #{packet_id := _PacketId, payload := Payload}}, State) ->
logger:debug("[endpoint_mqtt] Recv a publish packet: ~p, payload: ~p", [Message, Payload]),
{keep_state, State};
connected(info, {puback, #{packet_id := PacketId}},
State = #state{inflight = Inflight, buffer = Buffer}) ->
case maps:take(PacketId, Inflight) of
{Id, RestInflight} ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{keep_state, State#state{buffer = NBuffer, inflight = RestInflight}};
error ->
{keep_state, State}
end;
connected(info, {'EXIT', ConnPid, Reason},
State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid, buffer = Buffer}) ->
logger:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]),
NBuffer = endpoint_buffer:recover_inflight(Buffer),
{next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer},
[{state_timeout, ?RETRY_INTERVAL, connect}]};
connected(info, Info, State) ->
unknown_info(Info, connected, State);
connected(EventType, EventContent, State) ->
unknown_event(EventType, EventContent, connected, State).
-spec terminate(term(), mqtt_state(), #state{}) -> term().
terminate(Reason, _StateName, #state{endpoint = #endpoint{title = Title}, buffer = Buffer, conn_pid = ConnPid}) ->
logger:debug("[endpoint_mqtt] endpoint: ~p, terminate with reason: ~p", [Title, Reason]),
stop_mqtt_conn(ConnPid),
endpoint_buffer:cleanup(Buffer),
ok.
-spec code_change(term() | {down, term()}, mqtt_state(), #state{}, term()) ->
{ok, mqtt_state(), #state{}} | {error, term()}.
code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
{ok, StateName, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec reply_stat(gen_statem:from(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
reply_stat(From, Buffer, State) ->
Stat = endpoint_buffer:stat(Buffer),
{keep_state, State, [{reply, From, {ok, Stat}}]}.
-spec forward_metric(binary(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
forward_metric(Metric, Buffer, State) ->
NBuffer = endpoint_buffer:append(Metric, Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec store_metric(binary(), endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
store_metric(Metric, Buffer, State) ->
NBuffer = endpoint_buffer:append_only(Metric, Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec cleanup_buffer(endpoint_buffer:buffer(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
cleanup_buffer(Buffer, State) ->
NBuffer = endpoint_buffer:cleanup(Buffer),
{keep_state, State#state{buffer = NBuffer}}.
-spec reload_endpoint(binary(), binary(), undefined | pid(), #endpoint{}, #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State = #state{}) ->
ensure_subscription(Matcher, NMatcher),
stop_mqtt_conn(ConnPid),
NBuffer = endpoint_buffer:recover_inflight(State#state.buffer),
{next_state, disconnected, State#state{endpoint = NEndpoint, conn_pid = undefined, inflight = #{}, buffer = NBuffer},
[{next_event, internal, do_connect}]}.
-spec unknown_info(term(), mqtt_state(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
unknown_info(Info, StateName, State) ->
logger:warning("[endpoint_mqtt] unknown message: ~p, status: ~p", [Info, StateName]),
{keep_state, State}.
-spec unknown_event(gen_statem:event_type(), term(), mqtt_state(), #state{}) ->
gen_statem:event_handler_result(mqtt_state(), #state{}).
unknown_event(EventType, EventContent, StateName, State) ->
logger:warning("[endpoint_mqtt] unknown event: ~p, content: ~p, status: ~p", [EventType, EventContent, StateName]),
{keep_state, State}.
-spec ensure_subscription(binary(), binary()) -> ok.
ensure_subscription(Matcher, Matcher) ->
ok;
ensure_subscription(Matcher, NMatcher) ->
ok = endpoint_subscription:unsubscribe(Matcher, self()),
endpoint_subscription:subscribe(NMatcher, self()).
-spec connect_mqtt(list()) -> {ok, pid()} | {error, term()}.
connect_mqtt(Opts) ->
try
{ok, ConnPid} = emqtt:start_link(Opts),
logger:debug("[endpoint_mqtt] start connect, options: ~p", [Opts]),
case emqtt:connect(ConnPid, 5000) of
{ok, _} ->
{ok, ConnPid};
{error, Reason} ->
stop_mqtt_conn(ConnPid),
{error, Reason}
end
catch
_:Error ->
{error, Error}
end.
-spec stop_mqtt_conn(undefined | pid()) -> ok.
stop_mqtt_conn(undefined) ->
ok;
stop_mqtt_conn(ConnPid) when is_pid(ConnPid) ->
_ = catch emqtt:stop(ConnPid),
ok.

View File

@ -0,0 +1,953 @@
%%%-------------------------------------------------------------------
%%% @author Codex
%%% @doc
%%% endpoint outbox
%%% - endpoint
%%% - segment
%%% - metadata ack
%%% - inflight acked_seq + 1
%%%-------------------------------------------------------------------
-module(endpoint_outbox).
-export([open/2, close/1, append/2, ack/2, next/1, reset_reader/1, stat/1]).
-export_type([outbox/0]).
-record(segment, {
id :: pos_integer(),
path :: file:filename_all(),
start_seq = 0 :: non_neg_integer(),
end_seq = 0 :: non_neg_integer(),
records = 0 :: non_neg_integer(),
bytes = 0 :: non_neg_integer()
}).
-record(outbox_writer, {
segment = 1 :: pos_integer(),
next_seq = 1 :: pos_integer(),
fd = undefined :: undefined | file:fd()
}).
-record(outbox_reader, {
segment :: undefined | pos_integer(),
offset = 0 :: non_neg_integer(),
fd = undefined :: undefined | file:fd()
}).
-record(outbox_checkpoint, {
acked_segment = 1 :: pos_integer(),
acked_seq = 0 :: non_neg_integer(),
pending_acks = [] :: [pos_integer()]
}).
-record(outbox, {
dir :: file:filename_all(),
metadata_path :: file:filename_all(),
max_records = 500000 :: pos_integer(),
max_bytes = 134217728 :: pos_integer(),
max_segments = 10 :: pos_integer(),
segments = [] :: [#segment{}],
writer = #outbox_writer{} :: #outbox_writer{},
reader = #outbox_reader{} :: #outbox_reader{},
checkpoint = #outbox_checkpoint{} :: #outbox_checkpoint{}
}).
-type outbox() :: #outbox{}.
-define(METADATA_FILE, "metadata.json").
-define(SEGMENT_EXT, ".log").
-define(MAX_PAYLOAD_BYTES, 32 * 1024).
-define(RECORD_MAGIC, <<"EPOBOX01">>).
-define(RECORD_VERSION, 1).
-define(RECORD_HEADER_BYTES, 21).
-define(RECORD_FOOTER_BYTES, 4).
-define(RECORD_OVERHEAD_BYTES, ?RECORD_HEADER_BYTES + ?RECORD_FOOTER_BYTES).
-define(RESYNC_READ_BYTES, 4096).
%%%===================================================================
%%% API
%%%===================================================================
%% Opts
%% - max_records => pos_integer()
%% segment 500000
%% - max_bytes => pos_integer()
%% segment 134217728
%% - max_segments => pos_integer()
%% segment 10
-spec open(file:filename_all(), map()) -> {ok, outbox()} | {error, term()}.
open(Dir, Opts) when is_map(Opts) ->
ok = ensure_dir(Dir),
MetadataPath = filename:join(Dir, ?METADATA_FILE),
Segments = load_segments(Dir),
Meta = read_metadata(MetadataPath),
LastSegmentId = last_segment_id(Segments, maps:get(write_segment, Meta, 1)),
LastSeq = last_seq(Segments, maps:get(write_seq, Meta, 0)),
AckedSeq = min(maps:get(acked_seq, Meta, 0), LastSeq),
AckedSegment = segment_id_for_seq(Segments, AckedSeq, maps:get(acked_segment, Meta, 1)),
Outbox0 = #outbox{
dir = Dir,
metadata_path = MetadataPath,
max_records = maps:get(max_records, Opts, 500000),
max_bytes = maps:get(max_bytes, Opts, 134217728),
max_segments = maps:get(max_segments, Opts, 10),
writer = #outbox_writer{
segment = LastSegmentId,
next_seq = LastSeq + 1
},
checkpoint = #outbox_checkpoint{
acked_segment = AckedSegment,
acked_seq = AckedSeq
},
segments = Segments
},
{ReadSegment, ReadOffset} = locate_reader(Outbox0),
Outbox1 = Outbox0#outbox{
reader = #outbox_reader{
segment = ReadSegment,
offset = ReadOffset
}
},
maybe
{ok, Outbox} ?= open_fds(Outbox1),
ok ?= persist_metadata(Outbox),
{ok, Outbox}
else
{error, Reason} ->
{error, Reason}
end.
-spec close(outbox()) -> ok.
close(Outbox = #outbox{}) ->
_ = close_open_fds(Outbox),
ok.
-spec append(binary(), outbox()) ->
{ok, Seq :: pos_integer(), outbox()} | {dropped, capacity_reached, outbox()} | {error, term()}.
append(Payload, Outbox = #outbox{}) when is_binary(Payload) ->
case validate_payload_size(Payload) of
ok ->
RecordBytes = encoded_record_size(Payload),
case ensure_writable_segment(RecordBytes, Outbox) of
{drop, NOutbox} ->
{dropped, capacity_reached, NOutbox};
{ok, SegmentId, NOutbox0} ->
maybe
{ok, NOutbox1} ?= ensure_write_fd(SegmentId, NOutbox0),
Writer1 = NOutbox1#outbox.writer,
Seq = Writer1#outbox_writer.next_seq,
SegmentPath = segment_path(NOutbox1#outbox.dir, SegmentId),
ok ?= append_record(Writer1#outbox_writer.fd, Seq, Payload),
Segments = update_written_segment(
NOutbox1#outbox.segments, SegmentId, SegmentPath, Seq, RecordBytes),
NOutbox2 = NOutbox1#outbox{
writer = Writer1#outbox_writer{
segment = SegmentId,
next_seq = Seq + 1
},
segments = Segments
},
{ok, NOutbox} ?= maybe_reset_reader_after_append(Outbox, NOutbox2),
ok ?= persist_metadata(NOutbox),
{ok, Seq, NOutbox}
else
{error, Reason} ->
{error, Reason}
end
end;
{error, Reason} ->
{error, Reason}
end.
-spec ack(pos_integer(), outbox()) -> {ok, outbox()} | {error, term()}.
ack(Seq, Outbox = #outbox{checkpoint = Checkpoint}) when is_integer(Seq), Seq > 0 ->
AckedSeq = Checkpoint#outbox_checkpoint.acked_seq,
case Seq =< AckedSeq of
true ->
{ok, Outbox};
false ->
PendingAcks0 = ordsets:add_element(Seq, Checkpoint#outbox_checkpoint.pending_acks),
{NackedSeq, PendingAcks} = advance_acked_seq(AckedSeq, PendingAcks0),
NackedSegment = segment_id_for_seq(
Outbox#outbox.segments,
NackedSeq,
Checkpoint#outbox_checkpoint.acked_segment),
Outbox0 = Outbox#outbox{
checkpoint = Checkpoint#outbox_checkpoint{
acked_seq = NackedSeq,
acked_segment = NackedSegment,
pending_acks = PendingAcks
}
},
maybe
{ok, Outbox1} ?= prune_acked_segments(Outbox0),
ok ?= maybe_persist_metadata(NackedSeq =/= AckedSeq, Outbox1),
{ok, Outbox1}
else
{error, Reason} ->
{error, Reason}
end
end.
-spec next(outbox()) ->
eof | {ok, Seq :: pos_integer(), Payload :: binary(), outbox()} | {error, term()}.
next(#outbox{reader = #outbox_reader{segment = undefined}}) ->
eof;
next(Outbox = #outbox{
reader = Reader,
segments = Segments
}) ->
SegmentId = Reader#outbox_reader.segment,
Offset = Reader#outbox_reader.offset,
case ensure_read_fd(Outbox) of
{ok, NOutbox0} ->
Reader0 = NOutbox0#outbox.reader,
case read_next_record(Reader0#outbox_reader.fd, Offset) of
eof ->
case next_segment_id(Segments, SegmentId) of
undefined ->
eof;
NextSegmentId ->
case switch_read_segment(NextSegmentId, 0, NOutbox0) of
{ok, NOutbox1} ->
next(NOutbox1);
{error, Reason} ->
{error, Reason}
end
end;
{ok, _RecordOffset, Seq, Payload, NextOffset} ->
{ok, Seq, Payload, NOutbox0#outbox{
reader = Reader0#outbox_reader{offset = NextOffset}
}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
-spec reset_reader(outbox()) -> {ok, outbox()}.
reset_reader(Outbox = #outbox{}) ->
{ReadSegment, ReadOffset} = locate_reader(Outbox),
switch_read_segment(ReadSegment, ReadOffset, Outbox).
-spec stat(outbox()) -> map().
stat(#outbox{
dir = Dir,
writer = #outbox_writer{
segment = WriteSegment,
next_seq = NextSeq
},
checkpoint = #outbox_checkpoint{
acked_segment = AckedSegment,
acked_seq = AckedSeq
},
segments = Segments
}) ->
#{
dir => Dir,
write_segment => WriteSegment,
write_seq => max(NextSeq - 1, 0),
acked_segment => AckedSegment,
acked_seq => AckedSeq,
segment_num => length(Segments),
pending_segment_num => pending_segment_count(Segments, AckedSeq)
}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec ensure_dir(file:filename_all()) -> ok.
ensure_dir(Dir) ->
ok = filelib:ensure_dir(filename:join(Dir, "dummy")),
ok.
-spec read_metadata(file:filename_all()) -> map().
read_metadata(Path) ->
case file:read_file(Path) of
{ok, Bin} ->
case catch json:decode(Bin) of
#{<<"write_segment">> := WriteSegment,
<<"write_seq">> := WriteSeq,
<<"acked_segment">> := AckedSegment,
<<"acked_seq">> := AckedSeq}
when is_integer(WriteSegment), is_integer(WriteSeq),
is_integer(AckedSegment), is_integer(AckedSeq) ->
#{
write_segment => WriteSegment,
write_seq => WriteSeq,
acked_segment => AckedSegment,
acked_seq => AckedSeq
};
_ ->
default_metadata()
end;
{error, enoent} ->
default_metadata();
{error, _} ->
default_metadata()
end.
-spec default_metadata() -> map().
default_metadata() ->
#{
write_segment => 1,
write_seq => 0,
acked_segment => 1,
acked_seq => 0
}.
-spec persist_metadata(outbox()) -> ok | {error, term()}.
persist_metadata(#outbox{
metadata_path = MetadataPath,
writer = #outbox_writer{
segment = WriteSegment,
next_seq = NextSeq
},
checkpoint = #outbox_checkpoint{
acked_segment = AckedSegment,
acked_seq = AckedSeq
}
}) ->
TmpPath = MetadataPath ++ ".tmp",
Metadata = #{
<<"write_segment">> => WriteSegment,
<<"write_seq">> => max(NextSeq - 1, 0),
<<"acked_segment">> => AckedSegment,
<<"acked_seq">> => AckedSeq
},
Bin = iolist_to_binary(json:encode(Metadata)),
ok = file:write_file(TmpPath, Bin),
ok = file:rename(TmpPath, MetadataPath),
ok.
-spec load_segments(file:filename_all()) -> [#segment{}].
load_segments(Dir) ->
Pattern = filename:join(Dir, "*" ++ ?SEGMENT_EXT),
SegmentFiles = lists:sort(filelib:wildcard(Pattern)),
lists:filtermap(fun(Path) ->
case parse_segment_id(filename:basename(Path)) of
{ok, Id} ->
case scan_segment(Path, Id) of
{ok, Segment = #segment{records = Records}} when Records > 0 ->
{true, Segment};
_ ->
false
end;
error ->
false
end
end, SegmentFiles).
-spec parse_segment_id(string()) -> {ok, pos_integer()} | error.
parse_segment_id(Filename) ->
case filename:extension(Filename) of
?SEGMENT_EXT ->
Base = filename:rootname(Filename, ?SEGMENT_EXT),
try
Id = list_to_integer(Base),
case Id > 0 of
true ->
{ok, Id};
false ->
error
end
catch
_:_ ->
error
end;
_ ->
error
end.
-spec scan_segment(file:filename_all(), pos_integer()) -> {ok, #segment{}} | {error, term()}.
scan_segment(Path, Id) ->
case file:open(Path, [read, raw, binary]) of
{ok, Fd} ->
try
scan_segment_records(Fd, 0, #segment{id = Id, path = Path})
after
ok = file:close(Fd)
end;
{error, Reason} ->
{error, Reason}
end.
-spec scan_segment_records(file:fd(), non_neg_integer(), #segment{}) -> {ok, #segment{}} | {error, term()}.
scan_segment_records(Fd, Offset, Segment = #segment{}) ->
case read_next_record(Fd, Offset) of
eof ->
{ok, Segment};
{ok, RecordOffset, Seq, _Payload, NextOffset} ->
RecordBytes = NextOffset - RecordOffset,
NextSegment = Segment#segment{
start_seq = case Segment#segment.records of
0 -> Seq;
_ -> Segment#segment.start_seq
end,
end_seq = Seq,
records = Segment#segment.records + 1,
bytes = Segment#segment.bytes + RecordBytes
},
scan_segment_records(Fd, NextOffset, NextSegment);
{error, Reason} ->
{error, Reason}
end.
-spec encoded_record_size(binary()) -> pos_integer().
encoded_record_size(Payload) ->
?RECORD_OVERHEAD_BYTES + byte_size(Payload).
-spec validate_payload_size(binary()) -> ok | {error, payload_too_large}.
validate_payload_size(Payload) when byte_size(Payload) =< ?MAX_PAYLOAD_BYTES ->
ok;
validate_payload_size(_Payload) ->
{error, payload_too_large}.
-spec ensure_writable_segment(pos_integer(), outbox()) ->
{ok, pos_integer(), outbox()} | {drop, outbox()}.
ensure_writable_segment(RecordBytes, Outbox = #outbox{
max_records = MaxRecords,
max_bytes = MaxBytes,
max_segments = MaxSegments,
segments = Segments,
writer = #outbox_writer{segment = WriteSegment},
checkpoint = #outbox_checkpoint{acked_seq = AckedSeq}
}) ->
case find_segment(Segments, WriteSegment) of
false ->
{ok, WriteSegment, Outbox};
#segment{records = Records, bytes = Bytes} ->
NeedRoll = Records > 0 andalso (Records >= MaxRecords orelse Bytes + RecordBytes > MaxBytes),
case NeedRoll of
false ->
{ok, WriteSegment, Outbox};
true ->
case pending_segment_count(Segments, AckedSeq) >= MaxSegments of
true ->
{drop, Outbox};
false ->
{ok, WriteSegment + 1, Outbox#outbox{
writer = ((Outbox#outbox.writer)#outbox_writer{
segment = WriteSegment + 1
})
}}
end
end
end.
-spec append_record(file:fd(), pos_integer(), binary()) -> ok | {error, term()}.
append_record(Fd, Seq, Payload) when Fd =/= undefined ->
PayloadSize = byte_size(Payload),
Crc = record_crc(?RECORD_VERSION, Seq, PayloadSize, Payload),
Bin = <<
?RECORD_MAGIC/binary,
?RECORD_VERSION:8,
Seq:64/unsigned-big-integer,
PayloadSize:32/unsigned-big-integer,
Payload/binary,
Crc:32/unsigned-big-integer
>>,
case file:write(Fd, Bin) of
ok ->
ok;
{error, Reason} ->
{error, Reason}
end.
-spec update_written_segment([#segment{}], pos_integer(), file:filename_all(), pos_integer(), pos_integer()) -> [#segment{}].
update_written_segment(Segments, SegmentId, Path, Seq, RecordBytes) ->
case take_segment(SegmentId, Segments) of
{undefined, Rest} ->
lists:sort(fun compare_segment/2, [
#segment{
id = SegmentId,
path = Path,
start_seq = Seq,
end_seq = Seq,
records = 1,
bytes = RecordBytes
} | Rest
]);
{Segment, Rest} ->
lists:sort(fun compare_segment/2, [
Segment#segment{
end_seq = Seq,
records = Segment#segment.records + 1,
bytes = Segment#segment.bytes + RecordBytes
} | Rest
])
end.
-spec compare_segment(#segment{}, #segment{}) -> boolean().
compare_segment(#segment{id = Id0}, #segment{id = Id1}) ->
Id0 =< Id1.
-spec find_segment([#segment{}], pos_integer()) -> false | #segment{}.
find_segment(Segments, SegmentId) ->
lists:keyfind(SegmentId, #segment.id, Segments).
-spec take_segment(pos_integer(), [#segment{}]) -> {undefined | #segment{}, [#segment{}]}.
take_segment(SegmentId, Segments) ->
case lists:partition(fun(#segment{id = Id}) -> Id =:= SegmentId end, Segments) of
{[Segment], Rest} ->
{Segment, Rest};
{[], Rest} ->
{undefined, Rest}
end.
-spec last_segment_id([#segment{}], pos_integer()) -> pos_integer().
last_segment_id([], Default) ->
Default;
last_segment_id(Segments, _Default) ->
(lists:last(Segments))#segment.id.
-spec last_seq([#segment{}], non_neg_integer()) -> non_neg_integer().
last_seq([], Default) ->
Default;
last_seq(Segments, _Default) ->
(lists:last(Segments))#segment.end_seq.
-spec segment_id_for_seq([#segment{}], non_neg_integer(), pos_integer()) -> pos_integer().
segment_id_for_seq(_Segments, 0, Default) ->
Default;
segment_id_for_seq(Segments, Seq, Default) ->
case lists:dropwhile(fun(#segment{end_seq = EndSeq}) -> EndSeq < Seq end, Segments) of
[#segment{id = SegmentId, start_seq = StartSeq, end_seq = EndSeq} | _]
when Seq >= StartSeq, Seq =< EndSeq ->
SegmentId;
_ ->
Default
end.
-spec pending_segment_count([#segment{}], non_neg_integer()) -> non_neg_integer().
pending_segment_count(Segments, AckedSeq) ->
length([Segment || Segment = #segment{end_seq = EndSeq} <- Segments, EndSeq > AckedSeq]).
-spec advance_acked_seq(non_neg_integer(), [pos_integer()]) -> {non_neg_integer(), [pos_integer()]}.
advance_acked_seq(AckedSeq, PendingAcks) ->
NextSeq = AckedSeq + 1,
case ordsets:is_element(NextSeq, PendingAcks) of
true ->
advance_acked_seq(NextSeq, ordsets:del_element(NextSeq, PendingAcks));
false ->
{AckedSeq, PendingAcks}
end.
-spec prune_acked_segments(outbox()) -> {ok, outbox()} | {error, term()}.
prune_acked_segments(Outbox = #outbox{
segments = Segments,
checkpoint = #outbox_checkpoint{acked_seq = AckedSeq}
}) ->
{DeleteSegments, KeepSegments} = lists:partition(fun(#segment{end_seq = EndSeq}) ->
EndSeq =< AckedSeq
end, Segments),
Outbox0 = detach_deleted_fds(DeleteSegments, Outbox#outbox{segments = KeepSegments}),
ok = lists:foldl(fun(#segment{path = Path}, ok) ->
case file:delete(Path) of
ok ->
ok;
{error, enoent} ->
ok;
{error, Reason} ->
throw({delete_segment_failed, Path, Reason})
end
end, ok, DeleteSegments),
{ok, reset_reader_after_prune(Outbox0)}.
-spec reset_reader_after_prune(outbox()) -> outbox().
reset_reader_after_prune(Outbox = #outbox{
reader = #outbox_reader{segment = ReadSegment},
segments = Segments
}) ->
case ReadSegment =:= undefined orelse lists:keymember(ReadSegment, #segment.id, Segments) of
true ->
Outbox;
false ->
case reset_reader(Outbox) of
{ok, NOutbox} ->
NOutbox
end
end.
-spec locate_reader(outbox()) -> {undefined | pos_integer(), non_neg_integer()}.
locate_reader(#outbox{
segments = Segments,
checkpoint = #outbox_checkpoint{acked_seq = AckedSeq}
}) ->
NextSeq = AckedSeq + 1,
case lists:dropwhile(fun(#segment{end_seq = EndSeq}) -> EndSeq < NextSeq end, Segments) of
[] ->
{undefined, 0};
[#segment{id = SegmentId, start_seq = StartSeq} = Segment | _] ->
case NextSeq =< StartSeq of
true ->
{SegmentId, 0};
false ->
case locate_offset_for_seq(Segment, NextSeq) of
{ok, Offset} ->
{SegmentId, Offset};
{error, _} ->
{SegmentId, 0}
end
end
end.
-spec locate_offset_for_seq(#segment{}, pos_integer()) -> {ok, non_neg_integer()} | {error, term()}.
locate_offset_for_seq(#segment{path = Path}, TargetSeq) ->
case file:open(Path, [read, raw, binary]) of
{ok, Fd} ->
try
locate_offset_loop(Fd, TargetSeq, 0)
after
ok = file:close(Fd)
end;
{error, Reason} ->
{error, Reason}
end.
-spec locate_offset_loop(file:fd(), pos_integer(), non_neg_integer()) ->
{ok, non_neg_integer()} | {error, term()}.
locate_offset_loop(Fd, TargetSeq, Offset) ->
case read_next_record(Fd, Offset) of
eof ->
{ok, Offset};
{ok, RecordOffset, Seq, _Payload, _NextOffset} when Seq >= TargetSeq ->
{ok, RecordOffset};
{ok, _RecordOffset, _Seq, _Payload, NextOffset} ->
locate_offset_loop(Fd, TargetSeq, NextOffset);
{error, Reason} ->
{error, Reason}
end.
-spec read_next_record(file:fd(), non_neg_integer()) ->
eof | {ok, non_neg_integer(), pos_integer(), binary(), non_neg_integer()} | {error, term()}.
read_next_record(Fd, Offset) ->
case read_record_at(Fd, Offset) of
eof ->
eof;
{ok, Seq, Payload, NextOffset} ->
{ok, Offset, Seq, Payload, NextOffset};
{error, _Reason} ->
resync_next_record(Fd, Offset + 1)
end.
-spec read_record_at(file:fd(), non_neg_integer()) ->
eof | {ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}.
read_record_at(Fd, Offset) ->
maybe
{ok, _} ?= file:position(Fd, Offset),
read_record(Fd, Offset)
else
{error, Reason} ->
{error, Reason}
end.
-spec read_record(file:fd(), non_neg_integer()) ->
eof | {ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}.
read_record(Fd, Offset) ->
case file:read(Fd, ?RECORD_HEADER_BYTES) of
eof ->
eof;
{ok, <<
Magic:8/binary,
Version:8,
Seq:64/unsigned-big-integer,
PayloadSize:32/unsigned-big-integer
>>} when Magic =:= ?RECORD_MAGIC, Version =:= ?RECORD_VERSION ->
maybe
ok ?= validate_read_payload_size(PayloadSize),
read_record_body(Fd, Offset, Seq, PayloadSize)
else
{error, Reason} ->
{error, Reason}
end;
{ok, Header} when byte_size(Header) < ?RECORD_HEADER_BYTES ->
{error, truncated_header};
{ok, _} ->
{error, invalid_record_header};
{error, Reason} ->
{error, Reason}
end.
-spec validate_read_payload_size(non_neg_integer()) -> ok | {error, payload_too_large}.
validate_read_payload_size(PayloadSize) when PayloadSize =< ?MAX_PAYLOAD_BYTES ->
ok;
validate_read_payload_size(_PayloadSize) ->
{error, payload_too_large}.
-spec read_record_body(file:fd(), non_neg_integer(), pos_integer(), non_neg_integer()) ->
{ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}.
read_record_body(Fd, Offset, Seq, PayloadSize) ->
case file:read(Fd, PayloadSize + ?RECORD_FOOTER_BYTES) of
{ok, <<Payload:PayloadSize/binary, StoredCrc:32/unsigned-big-integer>>} ->
ExpectedCrc = record_crc(?RECORD_VERSION, Seq, PayloadSize, Payload),
case StoredCrc =:= ExpectedCrc of
true ->
{ok, Seq, Payload, Offset + ?RECORD_OVERHEAD_BYTES + PayloadSize};
false ->
{error, invalid_record_crc}
end;
eof ->
{error, truncated_payload};
{ok, _} ->
{error, truncated_payload};
{error, Reason} ->
{error, Reason}
end.
-spec record_crc(byte(), pos_integer(), non_neg_integer(), binary()) -> non_neg_integer().
record_crc(Version, Seq, PayloadSize, Payload) ->
erlang:crc32(<<
Version:8,
Seq:64/unsigned-big-integer,
PayloadSize:32/unsigned-big-integer,
Payload/binary
>>).
-spec resync_next_record(file:fd(), non_neg_integer()) ->
eof | {ok, non_neg_integer(), pos_integer(), binary(), non_neg_integer()} | {error, term()}.
resync_next_record(Fd, SearchOffset) ->
case find_next_magic(Fd, SearchOffset) of
eof ->
eof;
{ok, RecordOffset} ->
case read_record_at(Fd, RecordOffset) of
eof ->
eof;
{ok, Seq, Payload, NextOffset} ->
{ok, RecordOffset, Seq, Payload, NextOffset};
{error, _Reason} ->
resync_next_record(Fd, RecordOffset + 1)
end;
{error, Reason} ->
{error, Reason}
end.
-spec find_next_magic(file:fd(), non_neg_integer()) ->
eof | {ok, non_neg_integer()} | {error, term()}.
find_next_magic(Fd, Offset) ->
maybe
{ok, _} ?= file:position(Fd, Offset),
find_next_magic_loop(Fd, Offset, <<>>)
else
{error, Reason} ->
{error, Reason}
end.
-spec find_next_magic_loop(file:fd(), non_neg_integer(), binary()) ->
eof | {ok, non_neg_integer()} | {error, term()}.
find_next_magic_loop(Fd, Offset, Tail) ->
case file:read(Fd, ?RESYNC_READ_BYTES) of
eof ->
eof;
{ok, Bin} ->
SearchBin = <<Tail/binary, Bin/binary>>,
BaseOffset = Offset - byte_size(Tail),
case binary:match(SearchBin, ?RECORD_MAGIC) of
{Pos, _Len} ->
{ok, BaseOffset + Pos};
nomatch ->
NextTail = trailing_bytes(SearchBin, byte_size(?RECORD_MAGIC) - 1),
find_next_magic_loop(Fd, Offset + byte_size(Bin), NextTail)
end;
{error, Reason} ->
{error, Reason}
end.
-spec trailing_bytes(binary(), non_neg_integer()) -> binary().
trailing_bytes(Bin, KeepBytes) ->
Size = byte_size(Bin),
case Size =< KeepBytes of
true ->
Bin;
false ->
binary:part(Bin, Size - KeepBytes, KeepBytes)
end.
-spec next_segment_id([#segment{}], pos_integer()) -> undefined | pos_integer().
next_segment_id(Segments, SegmentId) ->
case lists:dropwhile(fun(#segment{id = Id}) -> Id =< SegmentId end, Segments) of
[#segment{id = NextSegmentId} | _] ->
NextSegmentId;
[] ->
undefined
end.
-spec segment_path(file:filename_all(), pos_integer()) -> file:filename_all().
segment_path(Dir, SegmentId) ->
filename:join(Dir, io_lib:format("~6..0B~s", [SegmentId, ?SEGMENT_EXT])).
-spec open_fds(outbox()) -> {ok, outbox()} | {error, term()}.
open_fds(Outbox) ->
case maybe_open_write_fd(Outbox) of
{ok, Outbox0} ->
maybe
{ok, NOutbox} ?= maybe_open_read_fd(Outbox0),
{ok, NOutbox}
else
{error, Reason} ->
_ = close_open_fds(Outbox0),
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
-spec maybe_open_write_fd(outbox()) -> {ok, outbox()} | {error, term()}.
maybe_open_write_fd(Outbox = #outbox{
segments = Segments,
writer = #outbox_writer{
segment = WriteSegment,
fd = undefined
}
}) ->
case find_segment(Segments, WriteSegment) of
false ->
{ok, Outbox};
_ ->
ensure_write_fd(WriteSegment, Outbox)
end.
-spec maybe_open_read_fd(outbox()) -> {ok, outbox()} | {error, term()}.
maybe_open_read_fd(Outbox = #outbox{reader = #outbox_reader{segment = undefined}}) ->
{ok, Outbox};
maybe_open_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = undefined}}) ->
ensure_read_fd(Outbox);
maybe_open_read_fd(Outbox = #outbox{}) ->
{ok, Outbox}.
-spec ensure_write_fd(pos_integer(), outbox()) -> {ok, outbox()} | {error, term()}.
ensure_write_fd(SegmentId, Outbox = #outbox{
writer = #outbox_writer{
segment = SegmentId,
fd = Fd
}
})
when Fd =/= undefined ->
{ok, Outbox};
ensure_write_fd(SegmentId, Outbox = #outbox{dir = Dir}) ->
Writer0 = (Outbox#outbox.writer)#outbox_writer{segment = SegmentId},
Outbox0 = close_write_fd(Outbox#outbox{writer = Writer0}),
Path = segment_path(Dir, SegmentId),
case file:open(Path, [append, raw, binary]) of
{ok, Fd} ->
Writer1 = (Outbox0#outbox.writer)#outbox_writer{fd = Fd},
{ok, Outbox0#outbox{writer = Writer1}};
{error, Reason} ->
{error, Reason}
end.
-spec ensure_read_fd(outbox()) -> {ok, outbox()} | {error, term()}.
ensure_read_fd(Outbox = #outbox{reader = #outbox_reader{segment = undefined}}) ->
{ok, Outbox};
ensure_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = Fd}}) when Fd =/= undefined ->
{ok, Outbox};
ensure_read_fd(Outbox = #outbox{
reader = Reader,
dir = Dir
}) ->
SegmentId = Reader#outbox_reader.segment,
Offset = Reader#outbox_reader.offset,
Path = segment_path(Dir, SegmentId),
case open_read_fd(Path, Offset) of
{ok, Fd} ->
{ok, Outbox#outbox{
reader = Reader#outbox_reader{fd = Fd}
}};
{error, Reason} ->
{error, Reason}
end.
-spec switch_read_segment(undefined | pos_integer(), non_neg_integer(), outbox()) ->
{ok, outbox()} | {error, term()}.
switch_read_segment(undefined, _Offset, Outbox) ->
Reader0 = #outbox_reader{segment = undefined, offset = 0},
{ok, close_read_fd(Outbox#outbox{reader = Reader0})};
switch_read_segment(SegmentId, Offset, Outbox = #outbox{reader = Reader}) ->
ensure_read_fd(close_read_fd(Outbox#outbox{
reader = Reader#outbox_reader{
segment = SegmentId,
offset = Offset
}
})).
-spec maybe_reset_reader_after_append(outbox(), outbox()) -> {ok, outbox()} | {error, term()}.
maybe_reset_reader_after_append(#outbox{reader = #outbox_reader{segment = undefined}}, Outbox) ->
reset_reader(Outbox);
maybe_reset_reader_after_append(_PrevOutbox, Outbox) ->
{ok, Outbox}.
-spec detach_deleted_fds([#segment{}], outbox()) -> outbox().
detach_deleted_fds(DeleteSegments, Outbox = #outbox{
reader = #outbox_reader{segment = ReadSegment},
writer = #outbox_writer{segment = WriteSegment}
}) ->
DeleteIds = [SegmentId || #segment{id = SegmentId} <- DeleteSegments],
Outbox0 = case lists:member(ReadSegment, DeleteIds) of
true ->
close_read_fd(Outbox);
false ->
Outbox
end,
case lists:member(WriteSegment, DeleteIds) of
true ->
close_write_fd(Outbox0);
false ->
Outbox0
end.
-spec close_open_fds(outbox()) -> outbox().
close_open_fds(Outbox) ->
close_write_fd(close_read_fd(Outbox)).
-spec close_read_fd(outbox()) -> outbox().
close_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = undefined}}) ->
Outbox;
close_read_fd(Outbox = #outbox{reader = Reader}) ->
Fd = Reader#outbox_reader.fd,
_ = maybe_close_fd(Fd),
Outbox#outbox{reader = Reader#outbox_reader{fd = undefined}}.
-spec close_write_fd(outbox()) -> outbox().
close_write_fd(Outbox = #outbox{writer = #outbox_writer{fd = undefined}}) ->
Outbox;
close_write_fd(Outbox = #outbox{writer = Writer}) ->
Fd = Writer#outbox_writer.fd,
_ = maybe_close_fd(Fd),
Outbox#outbox{writer = Writer#outbox_writer{fd = undefined}}.
-spec maybe_close_fd(file:fd()) -> ok.
maybe_close_fd(Fd) ->
case file:close(Fd) of
ok ->
ok;
{error, badarg} ->
ok;
{error, terminated} ->
ok;
{error, _Reason} ->
ok
end.
-spec maybe_persist_metadata(boolean(), outbox()) -> ok | {error, term()}.
maybe_persist_metadata(true, Outbox) ->
persist_metadata(Outbox);
maybe_persist_metadata(false, _Outbox) ->
ok.
-spec open_read_fd(file:filename_all(), non_neg_integer()) -> {ok, file:fd()} | {error, term()}.
open_read_fd(Path, Offset) ->
case file:open(Path, [read, raw, binary]) of
{ok, Fd} ->
maybe
{ok, _} ?= file:position(Fd, Offset),
{ok, Fd}
else
{error, Reason} ->
ok = file:close(Fd),
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.

View File

@ -0,0 +1,457 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%% Endpoint
%%%
%%% publish
%%% exact wildcard
%%%
%%% <ul>
%%% <li> endpoint_subscription_exactpublish RouteKey
%%% ets:lookup/2</li>
%%% <li> ETS trietrie
%%% endpoint_subscription_trie_edge
%%% endpoint_subscription_trie_sub</li>
%%% <li>endpoint_subscription_reverse SubscriberPid
%%% unsubscribe DOWN </li>
%%% <li>endpoint_subscription_pid SubscriberPid monitor
%%% pid monitor </li>
%%% </ul>
%%%
%%% matcher root noderoot node id 0 trie 使
%%% integer node idsubscribe/unsubscribe gen_server ETS
%%% publish/2 ETS gen_server
%%%
%%%
%%%
%%% <ul>
%%% <li> segment <<"device/a/temp">>
%%% </li>
%%% <li><<"*">> segment
%%% <<"device/*/temp">> <<"device/a/temp">>
%%% <<"device/a/b/temp">></li>
%%% <li><<"+">> matcher
%%% segment <<"device/+">> <<"device/a">>
%%% <<"device/a/temp">> <<"device">></li>
%%% </ul>
%%%
%%% publish/2
%%%
%%% <ol>
%%% <li> exact RouteKey </li>
%%% <li> RouteKey "/" trie root
%%% segment <<"*">> </li>
%%% <li> plus segment
%%% exact </li>
%%% <li> exact trie SubscriberPid
%%% pid matcher </li>
%%% <li> endpoint:forward/2</li>
%%% </ol>
%%% @end
%%% Created : 07. 11 2025 16:27
%%%-------------------------------------------------------------------
-module(endpoint_subscription).
-author("anlicheng").
-behaviour(gen_server).
%% API
-export([start_link/0]).
-export([subscribe/2, unsubscribe/2, publish/2, get_subscribers/0]).
-export([is_valid_components/1, of_components/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
-define(EXACT_TAB, endpoint_subscription_exact).
-define(TRIE_EDGE_TAB, endpoint_subscription_trie_edge).
-define(TRIE_SUB_TAB, endpoint_subscription_trie_sub).
-define(REVERSE_TAB, endpoint_subscription_reverse).
-define(PID_TAB, endpoint_subscription_pid).
-define(ROOT_NODE, 0).
%%
-record(subscriber, {
topic :: binary(),
subscriber_pid :: pid(),
components = [],
monitor_ref :: undefined | reference(),
%%
%% 1. topic优先级别最高
%% 2. *
%% 3. +
order :: integer()
}).
-record(state, {
exact_tid :: ets:tid(),
edge_tid :: ets:tid(),
trie_sub_tid :: ets:tid(),
reverse_tid :: ets:tid(),
pid_tid :: ets:tid(),
next_node_id = 1 :: pos_integer()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec subscribe(Topic :: binary(), SubscriberPid :: pid()) -> ok | {error, Reason :: binary()}.
subscribe(Topic, SubscriberPid) when is_binary(Topic), is_pid(SubscriberPid) ->
gen_server:call(?SERVER, {subscribe, Topic, SubscriberPid}).
-spec unsubscribe(Topic :: binary(), SubscriberPid :: pid()) -> ok.
unsubscribe(Topic, SubscriberPid) when is_binary(Topic), is_pid(SubscriberPid) ->
gen_server:call(?SERVER, {unsubscribe, Topic, SubscriberPid}).
-spec get_subscribers() -> {ok, Subscribers :: list()}.
get_subscribers() ->
gen_server:call(?SERVER, get_subscribers).
-spec publish(RouteKey :: binary(), Content :: binary()) -> ok.
publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) ->
case ets:info(?EXACT_TAB) of
undefined ->
ok;
_ ->
MatchedSubscribers = match_route_key(RouteKey),
lists:foreach(fun(#subscriber{subscriber_pid = SubscriberPid}) ->
endpoint:forward(SubscriberPid, Content)
end, MatchedSubscribers),
maybe_log_unmatched_publish(RouteKey, Content, MatchedSubscribers),
logger:debug("[endpoint_subscription] route_key: ~p, match_count: ~p", [RouteKey, length(MatchedSubscribers)]),
ok
end.
%% @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 = endpoint_util:set_metadata(),
ExactTid = ets:new(?EXACT_TAB, [named_table, protected, bag, {read_concurrency, true}]),
EdgeTid = ets:new(?TRIE_EDGE_TAB, [named_table, protected, set, {read_concurrency, true}]),
TrieSubTid = ets:new(?TRIE_SUB_TAB, [named_table, protected, bag, {read_concurrency, true}]),
ReverseTid = ets:new(?REVERSE_TAB, [named_table, protected, bag]),
PidTid = ets:new(?PID_TAB, [named_table, protected, set]),
{ok, #state{
exact_tid = ExactTid,
edge_tid = EdgeTid,
trie_sub_tid = TrieSubTid,
reverse_tid = ReverseTid,
pid_tid = PidTid
}}.
%% @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{}}).
%% SubscriberPid只能订阅同一个topic一次
handle_call(get_subscribers, _From, State = #state{exact_tid = ExactTid, trie_sub_tid = TrieSubTid}) ->
Subscribers = exact_subscribers(ExactTid) ++ trie_subscribers(TrieSubTid),
{reply, {ok, Subscribers}, State};
handle_call({subscribe, Topic, SubscriberPid}, _From, State = #state{reverse_tid = ReverseTid}) ->
Components = of_components(Topic),
case is_valid_components(Components) of
true ->
case has_subscription(ReverseTid, Topic, SubscriberPid) of
true ->
{reply, ok, State};
false ->
{MonitorRef, State1} = ensure_pid_monitor(SubscriberPid, State),
Sub = #subscriber{
topic = Topic,
subscriber_pid = SubscriberPid,
components = Components,
monitor_ref = MonitorRef,
order = order_num(Components)
},
State2 = insert_subscription(Topic, Components, SubscriberPid, Sub, State1),
{reply, ok, State2}
end;
false ->
{reply, {error, <<"invalid topic name">>}, State}
end;
handle_call({unsubscribe, Topic, SubscriberPid}, _From, State = #state{reverse_tid = ReverseTid}) ->
Removed = [Reverse || Reverse = {SubscriberPid0, Topic0, _Kind, _NodeId, _Sub} <- ets:lookup(ReverseTid, SubscriberPid),
SubscriberPid =:= SubscriberPid0, Topic =:= Topic0],
lists:foreach(fun(Reverse) -> delete_subscription(Reverse, State) end, Removed),
State1 = release_pid_monitor(SubscriberPid, length(Removed), State),
{reply, ok, State1}.
%% @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({'DOWN', MonitorRef, process, SubscriberPid, Reason}, State = #state{reverse_tid = ReverseTid, pid_tid = PidTid}) ->
logger:debug("[endpoint_subscription] subscriber: ~p, down with reason: ~p", [SubscriberPid, Reason]),
Removed = ets:lookup(ReverseTid, SubscriberPid),
lists:foreach(fun(Reverse) -> delete_subscription(Reverse, State) end, Removed),
case ets:lookup(PidTid, SubscriberPid) of
[{SubscriberPid, MonitorRef, _Count}] ->
ets:delete(PidTid, SubscriberPid);
_ ->
ok
end,
{noreply, State};
handle_info(Info, State = #state{}) ->
logger:debug("[endpoint_subscription] get unknown 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{}) ->
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 match_route_key(binary()) -> [#subscriber{}].
match_route_key(RouteKey) when is_binary(RouteKey) ->
ExactSubs = [Sub || {_Topic, Sub} <- ets:lookup(?EXACT_TAB, RouteKey)],
TrieSubs = match_trie(of_components(RouteKey)),
Sorted = lists:sort(fun compare_subscriber/2, ExactSubs ++ TrieSubs),
dedupe_subscribers(Sorted).
-spec maybe_log_unmatched_publish(binary(), binary(), [#subscriber{}]) -> ok.
maybe_log_unmatched_publish(RouteKey, Content, []) ->
endpoint_log:unmatched_publish(RouteKey, Content);
maybe_log_unmatched_publish(_RouteKey, _Content, _MatchedSubscribers) ->
ok.
-spec of_components(Topic :: binary()) -> [binary()].
of_components(Topic) when is_binary(Topic) ->
binary:split(Topic, <<$/>>, [global]).
is_valid_components([]) ->
true;
is_valid_components([<<$+>>|T]) ->
length(T) =:= 0;
is_valid_components([<<$*>>|T]) ->
is_valid_components(T);
is_valid_components([_|T]) ->
is_valid_components(T).
-spec order_num(Components :: list()) -> integer().
order_num([]) ->
1;
order_num([<<$*>>|_]) ->
2;
order_num([<<$+>>|_]) ->
3;
order_num([_|Tail]) ->
order_num(Tail).
-spec has_subscription(ets:tid(), binary(), pid()) -> boolean().
has_subscription(ReverseTid, Topic, SubscriberPid) ->
Subscriptions = ets:lookup(ReverseTid, SubscriberPid),
lists:any(fun({SubscriberPid0, Topic0, _Kind, _NodeId, _Sub}) ->
Topic =:= Topic0 andalso SubscriberPid =:= SubscriberPid0
end, Subscriptions).
-spec compare_subscriber(#subscriber{}, #subscriber{}) -> boolean().
compare_subscriber(#subscriber{order = Order0, topic = Topic0}, #subscriber{order = Order1, topic = Topic1}) ->
case Order0 =:= Order1 of
true ->
Topic0 =< Topic1;
false ->
Order0 < Order1
end.
-spec dedupe_subscribers([#subscriber{}]) -> [#subscriber{}].
dedupe_subscribers(Subscribers) ->
{_, Result} = lists:foldl(fun(S = #subscriber{subscriber_pid = SubscriberPid}, {Seen, Acc}) ->
case sets:is_element(SubscriberPid, Seen) of
true ->
{Seen, Acc};
false ->
{sets:add_element(SubscriberPid, Seen), [S | Acc]}
end
end, {sets:new(), []}, Subscribers),
lists:reverse(Result).
-spec insert_subscription(binary(), [binary()], pid(), #subscriber{}, #state{}) -> #state{}.
insert_subscription(Topic, Components, SubscriberPid, Sub, State = #state{exact_tid = ExactTid, trie_sub_tid = TrieSubTid, reverse_tid = ReverseTid}) ->
case has_wildcard(Components) of
false ->
true = ets:insert(ExactTid, {Topic, Sub}),
true = ets:insert(ReverseTid, {SubscriberPid, Topic, exact, undefined, Sub}),
State;
true ->
{Kind, NodeId, State1} = insert_trie_subscription(Components, State),
true = ets:insert(TrieSubTid, {{NodeId, subscription_match_type(Kind)}, Sub}),
true = ets:insert(ReverseTid, {SubscriberPid, Topic, Kind, NodeId, Sub}),
State1
end.
-spec insert_trie_subscription([binary()], #state{}) -> {trie_exact | trie_plus, non_neg_integer(), #state{}}.
insert_trie_subscription(Components, State) ->
case Components of
[] ->
{trie_exact, ?ROOT_NODE, State};
_ ->
case lists:last(Components) of
<<"+">> ->
Prefix = lists:sublist(Components, length(Components) - 1),
{NodeId, State1} = ensure_path(Prefix, State),
{trie_plus, NodeId, State1};
_ ->
{NodeId, State1} = ensure_path(Components, State),
{trie_exact, NodeId, State1}
end
end.
-spec subscription_match_type(trie_exact | trie_plus) -> exact | plus.
subscription_match_type(trie_exact) ->
exact;
subscription_match_type(trie_plus) ->
plus.
-spec ensure_path([binary()], #state{}) -> {non_neg_integer(), #state{}}.
ensure_path(Components, State) ->
ensure_path(?ROOT_NODE, Components, State).
-spec ensure_path(non_neg_integer(), [binary()], #state{}) -> {non_neg_integer(), #state{}}.
ensure_path(NodeId, [], State) ->
{NodeId, State};
ensure_path(NodeId, [Segment | Rest], State = #state{edge_tid = EdgeTid, next_node_id = NextNodeId}) ->
EdgeKey = {NodeId, Segment},
case ets:lookup(EdgeTid, EdgeKey) of
[{EdgeKey, ChildNodeId}] ->
ensure_path(ChildNodeId, Rest, State);
[] ->
true = ets:insert(EdgeTid, {EdgeKey, NextNodeId}),
ensure_path(NextNodeId, Rest, State#state{next_node_id = NextNodeId + 1})
end.
-spec delete_subscription(tuple(), #state{}) -> ok.
delete_subscription(Reverse = {_SubscriberPid, Topic, exact, undefined, Sub}, #state{exact_tid = ExactTid, reverse_tid = ReverseTid}) ->
true = ets:delete_object(ExactTid, {Topic, Sub}),
true = ets:delete_object(ReverseTid, Reverse),
ok;
delete_subscription(Reverse = {_SubscriberPid, _Topic, Kind, NodeId, Sub}, #state{trie_sub_tid = TrieSubTid, reverse_tid = ReverseTid}) ->
true = ets:delete_object(TrieSubTid, {{NodeId, subscription_match_type(Kind)}, Sub}),
true = ets:delete_object(ReverseTid, Reverse),
ok.
-spec ensure_pid_monitor(pid(), #state{}) -> {reference(), #state{}}.
ensure_pid_monitor(SubscriberPid, State = #state{pid_tid = PidTid}) ->
case ets:lookup(PidTid, SubscriberPid) of
[{SubscriberPid, MonitorRef, Count}] ->
true = ets:insert(PidTid, {SubscriberPid, MonitorRef, Count + 1}),
{MonitorRef, State};
[] ->
MonitorRef = erlang:monitor(process, SubscriberPid),
true = ets:insert(PidTid, {SubscriberPid, MonitorRef, 1}),
{MonitorRef, State}
end.
-spec release_pid_monitor(pid(), non_neg_integer(), #state{}) -> #state{}.
release_pid_monitor(_SubscriberPid, 0, State) ->
State;
release_pid_monitor(SubscriberPid, RemovedCount, State = #state{pid_tid = PidTid}) ->
case ets:lookup(PidTid, SubscriberPid) of
[{SubscriberPid, MonitorRef, Count}] when Count =< RemovedCount ->
erlang:demonitor(MonitorRef, [flush]),
ets:delete(PidTid, SubscriberPid),
State;
[{SubscriberPid, MonitorRef, Count}] ->
true = ets:insert(PidTid, {SubscriberPid, MonitorRef, Count - RemovedCount}),
State;
[] ->
State
end.
-spec match_trie([binary()]) -> [#subscriber{}].
match_trie(Components) ->
match_trie([?ROOT_NODE], Components, []).
-spec match_trie([non_neg_integer()], [binary()], [#subscriber{}]) -> [#subscriber{}].
match_trie(NodeIds, [], Acc) ->
collect_trie_subscribers(NodeIds, exact) ++ Acc;
match_trie(NodeIds, [Segment | Rest], Acc) ->
PlusSubs = collect_trie_subscribers(NodeIds, plus),
NextNodeIds = next_trie_nodes(NodeIds, Segment),
match_trie(NextNodeIds, Rest, PlusSubs ++ Acc).
-spec collect_trie_subscribers([non_neg_integer()], exact | plus) -> [#subscriber{}].
collect_trie_subscribers(NodeIds, MatchType) ->
lists:flatmap(fun(NodeId) ->
[Sub || {{NodeId0, MatchType0}, Sub} <- ets:lookup(?TRIE_SUB_TAB, {NodeId, MatchType}),
NodeId0 =:= NodeId, MatchType0 =:= MatchType]
end, NodeIds).
-spec next_trie_nodes([non_neg_integer()], binary()) -> [non_neg_integer()].
next_trie_nodes(NodeIds, Segment) ->
lists:usort(lists:flatmap(fun(NodeId) ->
lookup_child(NodeId, Segment) ++ lookup_child(NodeId, <<"*">>)
end, NodeIds)).
-spec lookup_child(non_neg_integer(), binary()) -> [non_neg_integer()].
lookup_child(NodeId, Segment) ->
EdgeKey = {NodeId, Segment},
case ets:lookup(?TRIE_EDGE_TAB, EdgeKey) of
[{EdgeKey, ChildNodeId}] ->
[ChildNodeId];
[] ->
[]
end.
-spec has_wildcard([binary()]) -> boolean().
has_wildcard(Components) ->
lists:any(fun(Component) -> Component =:= <<"*">> orelse Component =:= <<"+">> end, Components).
-spec exact_subscribers(ets:tid()) -> [#subscriber{}].
exact_subscribers(ExactTid) ->
[Sub || {_Topic, Sub} <- ets:tab2list(ExactTid)].
-spec trie_subscribers(ets:tid()) -> [#subscriber{}].
trie_subscribers(TrieSubTid) ->
[Sub || {_Key, Sub} <- ets:tab2list(TrieSubTid)].

View File

@ -3,7 +3,7 @@
%% @end %% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(endpoint_sup_sup). -module(endpoint_sup).
-behaviour(supervisor). -behaviour(supervisor).
-include("endpoint.hrl"). -include("endpoint.hrl").
@ -28,6 +28,15 @@ start_link() ->
init([]) -> init([]) ->
SupFlags = #{strategy => one_for_all, intensity => 1000, period => 3600}, SupFlags = #{strategy => one_for_all, intensity => 1000, period => 3600},
ChildSpecs = [ ChildSpecs = [
#{
id => endpoint_log,
start => {'endpoint_log', start_link, []},
restart => permanent,
shutdown => 2000,
type => worker,
modules => ['endpoint_log']
},
#{ #{
id => endpoint_subscription, id => endpoint_subscription,
start => {'endpoint_subscription', start_link, []}, start => {'endpoint_subscription', start_link, []},
@ -38,12 +47,12 @@ init([]) ->
}, },
#{ #{
id => 'endpoint_sup', id => 'endpoint_adapter_sup',
start => {'endpoint_sup', start_link, []}, start => {'endpoint_adapter_sup', start_link, []},
restart => permanent, restart => permanent,
shutdown => 2000, shutdown => 2000,
type => supervisor, type => supervisor,
modules => ['endpoint_sup'] modules => ['endpoint_adapter_sup']
} }
], ],
{ok, {SupFlags, ChildSpecs}}. {ok, {SupFlags, ChildSpecs}}.

View File

@ -0,0 +1,93 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2026, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 11. 5 2026 20:16
%%%-------------------------------------------------------------------
-module(endpoint_tester).
-author("anlicheng").
-include("endpoint.hrl").
%% API
-export([test/1]).
%% http接口的测试
test(#http_endpoint{url = Url, pool_size = PoolSize}) when is_integer(PoolSize), PoolSize > 0 ->
Body = <<"">>,
ContentType = "application/json",
case httpc:request(post, {Url, [], ContentType, Body}, [], []) of
{ok, _} ->
ok;
{error, Reason} ->
{error, Reason}
end;
%% mqtt
test(#mqtt_endpoint{host = Host, port = Port, username = Username, password = Password}) ->
%% client
ClientId = "mqtt_client_test:" ++ iot_util:rand_bytes(16),
Opts = [
{owner, self()},
{clientid, ClientId},
{host, binary_to_list(Host)},
{port, Port},
{tcp_opts, []},
{username, binary_to_list(Username)},
{password, binary_to_list(Password)},
{keepalive, 86400},
{auto_ack, true},
{connect_timeout, 5000},
{proto_ver, v5},
{retry_interval, 5000}
],
case emqtt:start_link(Opts) of
{ok, ConnPid} ->
case catch emqtt:connect(ConnPid, 5000) of
{ok, _} ->
emqtt:stop(ConnPid),
ok;
{error, _Reason} ->
{error, <<"connect mqtt server failed">>};
_Error ->
emqtt:stop(ConnPid),
{error, <<"connect mqtt server failed">>}
end;
Other ->
logger:warning("[endpint_handler] test connect mqtt with options: ~p, get error: ~p", [Opts, Other]),
{error, <<"connect mqtt server failed">>}
end;
%% kafka
test(#kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}) ->
BaseConfig = [
{reconnect_cool_down_seconds, 5},
{socket_options, [{keepalive, true}]}
],
ClientConfig = case SaslConfig of
{Mechanism, Username, Password} ->
[{sasl, {Mechanism, Username, Password}}|BaseConfig];
undefined ->
BaseConfig
end,
ClientId = brod_client_test,
_ = catch brod:stop_client(ClientId),
case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of
{ok, _ClientPid} ->
case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of
ok ->
ok = brod:stop_client(ClientId),
ok;
{error, Reason} ->
logger:debug("[endpint_handler] start_producer: ~p, get error: ~p", [ClientId, Reason]),
_ = catch brod:stop_client(ClientId),
{error, <<"config kafka server failed">>}
end;
Error ->
logger:debug("[endpint_handler] start_client: ~p, get error: ~p", [ClientId, Error]),
{error, <<"config kafka server failed">>}
end.

View File

@ -0,0 +1,18 @@
%%%-------------------------------------------------------------------
%%% @doc Endpoint-local utility functions.
%%%-------------------------------------------------------------------
-module(endpoint_util).
-export([set_metadata/0, sha256/1]).
-spec set_metadata() -> ok.
set_metadata() ->
logger:set_process_metadata(#{domain => [endpoint]}).
-spec sha256(string() | binary()) -> binary().
sha256(Str) when is_list(Str) ->
sha256(unicode:characters_to_binary(Str));
sha256(Bin) when is_binary(Bin) ->
HashBin = crypto:hash(sha256, Bin),
HexStr = lists:flatten([io_lib:format("~2.16.0B", [B]) || B <- binary:bin_to_list(HashBin)]),
list_to_binary(string:lowercase(HexStr)).

View File

@ -0,0 +1,20 @@
%%%-------------------------------------------------------------------
%%% @author Codex
%%% @doc
%%%
%%% @end
%%%-------------------------------------------------------------------
-record(host_info, {
id :: integer(),
uuid :: binary(),
authorize_status :: integer(),
status :: integer()
}).
-record(device_info, {
id :: integer(),
host_id :: integer(),
device_uuid :: binary(),
status :: integer()
}).

View File

@ -13,6 +13,10 @@
-define(HOST_ONLINE, 1). -define(HOST_ONLINE, 1).
-define(HOST_NOT_JOINED, -1). -define(HOST_NOT_JOINED, -1).
%%
-define(HOST_DENIED, 0).
-define(HOST_AUTHORIZED, 1).
%% 线 %% 线
-define(DEVICE_OFFLINE, 0). -define(DEVICE_OFFLINE, 0).
-define(DEVICE_ONLINE, 1). -define(DEVICE_ONLINE, 1).
@ -22,52 +26,3 @@
-define(TASK_STATUS_INIT, -1). %% -define(TASK_STATUS_INIT, -1). %%
-define(TASK_STATUS_FAILED, 0). %% 线 -define(TASK_STATUS_FAILED, 0). %% 线
-define(TASK_STATUS_OK, 1). %% 线 -define(TASK_STATUS_OK, 1). %% 线
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%
-record(kv, {
key :: binary(),
val :: binary() | list() | map() | sets:set(),
expire_at = 0 :: integer(),
type :: atom()
}).
%% id生成器
-record(id_generator, {
tab :: atom(),
increment_id = 0 :: integer()
}).
%%
-record(option, {
success_num = 0,
fail_num = 0
}).
%%
-record(north_data, {
id = 0 :: integer(),
location_code :: binary(),
%% endpoint list: [{K, V}, {K1, V1}]
fields :: [{K :: binary(), V :: any()}],
timestamp = 0 :: integer()
}).
%%
-record(event_data, {
id = 0 :: integer(),
location_code :: binary(),
event_type :: integer(),
params :: map()
}).
%%
-record(post_data, {
id = 0 :: integer(),
location_code :: binary(),
%% endpoint list: [{K, V}, {K1, V1}]
body :: binary() | list()
}).

View File

@ -1,23 +1,17 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @author anlicheng %%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY> %%% @copyright (C) 2026, <COMPANY>
%%% @doc %%% @doc
%%% %%%
%%% @end %%% @end
%%% Created : 08. 5 2025 12:08 %%% Created : 24. 4 2026 16:54
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-author("anlicheng"). -author("anlicheng").
%% -record(efka_client, {
-record(service_config, { uuid :: binary(),
service_id :: binary(), token_hash :: binary(),
host_uuid :: binary(), salt :: binary(),
config_json = <<>> :: binary(), heartbeat_secret :: binary(),
%% timestamp = 0 :: integer()
last_config_json = <<>> :: binary(),
%% id
last_edit_user :: integer(),
%% 0: , 1:
update_ts = 0,
create_ts = 0
}). }).

View File

@ -1,86 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%% , 1: topic的pub/sub机制; 2. target的单点通讯和广播
%%% @end
%%% Created : 21. 4 2025 17:28
%%%-------------------------------------------------------------------
-author("anlicheng").
%% efka主动发起的消息体类型,
-define(PACKET_REQUEST, 16#01).
-define(PACKET_RESPONSE, 16#02).
%% efka主动发起不需要返回的数据
-define(PACKET_CAST, 16#03).
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%
-define(MESSAGE_AUTH_REQUEST, 16#01).
-define(MESSAGE_AUTH_REPLY, 16#02).
-define(MESSAGE_COMMAND, 16#03).
-define(MESSAGE_DEPLOY, 16#04).
-define(MESSAGE_PUB, 16#05).
-define(MESSAGE_DATA, 16#06).
-define(MESSAGE_EVENT, 16#07).
%% efka主动上报的event-stream流, : docker-create的实时处理逻辑上报
-define(MESSAGE_EVENT_STREAM, 16#08).
-define(MESSAGE_JSONRPC_REQUEST, 16#F0).
-define(MESSAGE_JSONRPC_REPLY, 16#F1).
%%%% ,
%%
-define(COMMAND_AUTH, 16#08).
-record(auth_request, {
uuid :: binary(),
username :: binary(),
salt :: binary(),
token :: binary(),
timestamp :: integer()
}).
-record(auth_reply, {
code :: integer(),
payload :: binary()
}).
-record(pub, {
topic :: binary(),
qos = 0 :: integer(),
content :: binary()
}).
-record(command, {
command_type :: integer(),
command :: binary()
}).
-record(jsonrpc_request, {
method :: binary(),
params = <<>> :: any()
}).
-record(jsonrpc_reply, {
result :: any() | undefined,
error :: any() | undefined
}).
-record(data, {
route_key :: binary(),
metric :: binary()
}).
-record(task_event_stream, {
task_id :: integer(),
type :: binary(),
stream :: binary()
}).

View File

@ -0,0 +1,16 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%%-------------------------------------------------------------------
-author("anlicheng").
%%
%% REQUEST:
%% REPLY: REQUEST
%% CAST:
-define(FRAME_REQUEST, 16#01).
-define(FRAME_REPLY, 16#02).
-define(FRAME_CAST, 16#03).

View File

@ -1,20 +0,0 @@
{"device_uuid":"20118448129232486417014256831677", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576081, "type":"AI", "unit":5, "value":314498}
{"device_uuid":"20118448129232486417014256831677", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":224.1}
{"device_uuid":"20118448129232486417014256831677", "key":"a_current", "label":"A", "name":"A相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.394}
{"device_uuid":"20118448129232486417014256831677", "key":"b_voltage", "label":"V", "name":"B相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":225.3}
{"device_uuid":"20118448129232486417014256831677", "key":"b_current", "label":"A", "name":"B相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.323}
{"device_uuid":"20118448129232486417014256831677", "key":"c_voltage", "label":"V", "name":"C相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":225}
{"device_uuid":"20118448129232486417014256831677", "key":"c_current", "label":"A", "name":"C相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.269}
{"device_uuid":"20118448129232486417014256831677", "key":"active_power", "label":"kW", "name":"瞬时总有功功率", "timestamp":1705576081, "type":"AI", "unit":23, "value":20.91}
{"device_uuid":"20118448129232486417014256831677", "key":"power_factor", "label":"", "name":"总功率因数", "timestamp":1705576081, "type":"AI", "unit":16, "value":0.994}
{"device_uuid":"20118448549921177617014256841701", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576038, "type":"AI", "unit":5, "value":299493.6}
{"device_uuid":"20118448549921177617014256841701", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225.9}
{"device_uuid":"20118448549921177617014256841701", "key":"a_current", "label":"A", "name":"A相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.318}
{"device_uuid":"20118448549921177617014256841701", "key":"b_voltage", "label":"V", "name":"B相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225.7}
{"device_uuid":"20118448549921177617014256841701", "key":"b_current", "label":"A", "name":"B相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.305}
{"device_uuid":"20118448549921177617014256841701", "key":"c_voltage", "label":"V", "name":"C相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225}
{"device_uuid":"20118448549921177617014256841701", "key":"c_current", "label":"A", "name":"C相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.3}
{"device_uuid":"20118448549921177617014256841701", "key":"active_power", "label":"kW", "name":"瞬时总有功功率", "timestamp":1705576038, "type":"AI", "unit":23, "value":15.056}
{"device_uuid":"20118448549921177617014256841701", "key":"power_factor", "label":"", "name":"总功率因数", "timestamp":1705576038, "type":"AI", "unit":16, "value":1}
{"device_uuid":"20118448970190438417014256851724", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576117, "type":"AI", "unit":5, "value":285011}
{"device_uuid":"20118448970190438417014256851724", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576117, "type":"AI", "unit":0, "value":225.1}

View File

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDwzCCAqugAwIBAgIUMhNyuVo6MieWs2I9LsyTTiDKtYgwDQYJKoZIhvcNAQEL
BQAwgYkxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAwDgYDVQQHDAdC
ZWlqaW5nMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV
BAMMCWFubGljaGVuZzEfMB0GCSqGSIb3DQEJARYQMjQ0MTA4NzE1QHFxLmNvbTAe
Fw0yNTA0MjEwMzUyNTlaFw0yNjA0MjEwMzUyNTlaMIGJMQswCQYDVQQGEwJDTjEQ
MA4GA1UECAwHQmVpamluZzEQMA4GA1UEBwwHQmVpamluZzEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAlhbmxpY2hlbmcxHzAdBgkq
hkiG9w0BCQEWEDI0NDEwODcxNUBxcS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDdxxyYG0zV2KzeiVH5AGj1X1h7vLAHVS8FGkPL2QBBqW5/PrJ/
z/sv3QJz6dB3ElOnk14GsY8lWk8uX/fjgNIPawN1G2/bCdMrlPOCEJEIOqZvzQWg
PLRPGjoZx+wjCM/H7h47KVr4GNbo8MakLJeg6QB3rEEIiPrhQIoq9N7AhcjbEvWR
NIxIQrpSqMCE2A5RoKUxAMum9rEoe/6PBw2GgEsQl5E6suRZw3wavg3aUU+6MMyx
iiKEA6fCD5gDqgSo/xjgDUWJIOTTrmW6RBTcWP9iBUjWnCovQQ9zc6CzDXRgfsz9
7uXQt9fuQEi++lcGnrsePjC99PnirCvmj/C/AgMBAAGjITAfMB0GA1UdDgQWBBT6
n4jUTWQNcastC5jPHuU7CDzLFjANBgkqhkiG9w0BAQsFAAOCAQEAZJ9fY2Z+4vKr
bBqwHmfBjEnGgS7L2mC7uS2x/x2meBRKlAlw5+nKdaUBccyI2baI3P1mh/iV72Wr
OTcwUwSS6gIOB7JeWSB0UT7KbEOJ1tM/1HYs5F9tOT94P4Adm2gcKY81UlJMfSNQ
WyWFLjWOk/5fUP42BmgbUIafTT9p+LeP6NOyEs6b4hGpF3q5L1QDwMUfASpOWtHn
O7VyBvFyCGkVchnorWJ3ZXPaa7hy+2ULOK/d9zH3xxq4LKRclAS5XAMWHuw6/+tu
CoxA+RPnaqHexPSzYlEElOT286FDyZHadjUDD0q/0Um92NUM3r+UrNEs/OHp7/nU
M3BVM7H6LQ==
-----END CERTIFICATE-----

View File

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICzzCCAbcCAQAwgYkxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAw
DgYDVQQHDAdCZWlqaW5nMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBM
dGQxEjAQBgNVBAMMCWFubGljaGVuZzEfMB0GCSqGSIb3DQEJARYQMjQ0MTA4NzE1
QHFxLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3HHJgbTNXY
rN6JUfkAaPVfWHu8sAdVLwUaQ8vZAEGpbn8+sn/P+y/dAnPp0HcSU6eTXgaxjyVa
Ty5f9+OA0g9rA3Ubb9sJ0yuU84IQkQg6pm/NBaA8tE8aOhnH7CMIz8fuHjspWvgY
1ujwxqQsl6DpAHesQQiI+uFAiir03sCFyNsS9ZE0jEhCulKowITYDlGgpTEAy6b2
sSh7/o8HDYaASxCXkTqy5FnDfBq+DdpRT7owzLGKIoQDp8IPmAOqBKj/GOANRYkg
5NOuZbpEFNxY/2IFSNacKi9BD3NzoLMNdGB+zP3u5dC31+5ASL76Vwaeux4+ML30
+eKsK+aP8L8CAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBHdTrwcS0Ip5XO/zJ/
wFZvxe8wcdMBaHjSRx09Lgy3V1L9d0DPOfuUP4LP8UCxniucA2JLugJO1wr/5nZA
FS9GcyCgbMKX/EskFkpuz72EA11WhyqFp/9nWZsxZB0t1cs3bUFeoFd2SE6QIo4N
UY+guZCcF8hVwppceOxyaUQ9cudAClH9JSMR1XoIwvv3X7FJkPhM12DvcsLr062S
fwG7/1h+VoSpAM/UYtBHBkU0MIVn1Gw6lJvkabUV0oYYBzl9ejsq1Qo2nUMdOEQY
SS/VhVLONMyMCIERt3/GJTXdrJ12VqawJACFwv8g2i6NxbQvbZpF7TpS8amduO8+
aRE6
-----END CERTIFICATE REQUEST-----

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdxxyYG0zV2Kze
iVH5AGj1X1h7vLAHVS8FGkPL2QBBqW5/PrJ/z/sv3QJz6dB3ElOnk14GsY8lWk8u
X/fjgNIPawN1G2/bCdMrlPOCEJEIOqZvzQWgPLRPGjoZx+wjCM/H7h47KVr4GNbo
8MakLJeg6QB3rEEIiPrhQIoq9N7AhcjbEvWRNIxIQrpSqMCE2A5RoKUxAMum9rEo
e/6PBw2GgEsQl5E6suRZw3wavg3aUU+6MMyxiiKEA6fCD5gDqgSo/xjgDUWJIOTT
rmW6RBTcWP9iBUjWnCovQQ9zc6CzDXRgfsz97uXQt9fuQEi++lcGnrsePjC99Pni
rCvmj/C/AgMBAAECggEAM4j6RwpI/4RbH1cvmjoTKbmfORmumfWceIYS7QKfAaMa
jy0Fk5fD2ep0kHTrwU+b6tvexJVsGxTyQ2d/lfkwVu7aHdNjWbXdwUnakAXDffML
C/3LaeHRUHRavfTsFXQNvHrDwaGphu9WuUiCEFJgZb7fIfAAKLiT+9XghXzjaj79
nGANBB++kfBGacfj2NMedCxsbZ++pD7DbTG4utnHmzgwB3WEEm2kIbt0XpuDSZqe
/tCA1gCOADjV17C31booT6xdoj+s/Se0YxY91JaZFkI5wTSDIhB9scZzh8piy8YQ
QaEaRUUYNbzdI1MyvgewT6wQLONNlytOOc9hYnPgAQKBgQDyzaublY2q+yzqCQ68
JSKNTCSFRwumJ1kmdfz/0QwzS363l7LFnfIb55h27q3vuiagPfRbi0GK/tVXhOe+
8mntNMoJJQJzowpy4ANqKfGndojXejE1s1ExpuGyqWV4MyFGiwAnnzyWRENw4laS
roY0OcynNuq++isgrVMPlDLAvwKBgQDp1OTh2wcSfhsaVbMjNVCOGD82OFK057JB
3C62Li2lCybFwcooypCyZthUdskY2Gf/mDdnpLSLvXr/7bO3IyLopA3WpnYqMBt+
7sP/VQJMoyobLFMnJh+N+CxwkhRNAib64054WKwiJcccJ5fplUvNjOLBgGx3a/3N
OIxx+L7QAQKBgQC+EK3zPuEFJVYFZk24jkE75oz4H6NIz6iD6PzBrH0mckShpwh0
la1+lo7NGw3hiRDPg3ATcTE/gziyKAHZgZ3V5+r3uZbvuoNlZWKG6oqWkr2QH8EB
znsSqRYoa15Y931m4Uyft5EP+CPq6+LlM+UuYMiJZP3hvaehDszkdxg7tQKBgGjv
vMPLCpJ2+2zHFvxu+ba7FOsdPain7ix2RpRFhwBxT7Yh8Lp7pZIaa20EXd0DiTCA
PCUMGmY345IlN6ixYQIsVXWGALOQIVbGijj1CnIgK05EhxCjoDeTL0ZZmDizBZFE
HzwM9zrf30o4TolqEbmuRzj1jDfPw/esMAMZ0XABAoGBANpjvUN0Qvy9WmLyPe6U
DBzIAUyyIL3mnCuHLCQ+dE6nBf5l20sVjTdIl/eLlvmD7dwT/D9Zsg32/c2NBCdY
DltKKZl2u9nuSJ1g8DWTZUQHkYSTeKPOoAl/5mbCgyTxzmmmtZF6MySKuif0rzwS
X2Rwou9zTpwgsEEmQ/RH4G94
-----END PRIVATE KEY-----

View File

@ -6,8 +6,9 @@
%%% @end %%% @end
%%% Created : 24. 12 2023 15:42 %%% Created : 24. 12 2023 15:42
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(iot_api). -module(iot_api_client).
-author("anlicheng"). -author("anlicheng").
-include("domain_model.hrl").
%% API %% API
-export([ai_event/1]). -export([ai_event/1]).
@ -31,20 +32,30 @@ get_all_hosts() ->
[] []
end. end.
-spec get_host_by_uuid(UUID :: binary()) -> undefined | {ok, HostInfo :: map()}. -spec get_host_by_uuid(UUID :: binary()) -> undefined | {ok, HostInfo :: #host_info{}}.
get_host_by_uuid(UUID) when is_binary(UUID) -> get_host_by_uuid(UUID) when is_binary(UUID) ->
case do_get("/get_host_by_uuid", [{<<"uuid">>, UUID}]) of case do_get("/get_host_by_uuid", [{<<"uuid">>, UUID}]) of
{ok, HostInfo} -> {ok, HostInfo} ->
{ok, HostInfo}; case host_info_record(HostInfo) of
{ok, Record} ->
{ok, Record};
error ->
undefined
end;
_ -> _ ->
undefined undefined
end. end.
-spec get_host_by_id(HostId :: integer()) -> undefined | {ok, HostInfo :: map()}. -spec get_host_by_id(HostId :: integer()) -> undefined | {ok, HostInfo :: #host_info{}}.
get_host_by_id(HostId) when is_integer(HostId) -> get_host_by_id(HostId) when is_integer(HostId) ->
case do_get("/get_host_by_id", [{<<"host_id">>, integer_to_binary(HostId)}]) of case do_get("/get_host_by_id", [{<<"host_id">>, integer_to_binary(HostId)}]) of
{ok, HostInfo} -> {ok, HostInfo} ->
{ok, HostInfo}; case host_info_record(HostInfo) of
{ok, Record} ->
{ok, Record};
error ->
undefined
end;
_ -> _ ->
undefined undefined
end. end.
@ -54,15 +65,27 @@ get_host_by_id(HostId) when is_integer(HostId) ->
change_host_status(UUID, NStatus) when is_binary(UUID), is_integer(NStatus) -> change_host_status(UUID, NStatus) when is_binary(UUID), is_integer(NStatus) ->
do_post("/change_host_status", #{<<"uuid">> => UUID, <<"new_status">> => NStatus}). do_post("/change_host_status", #{<<"uuid">> => UUID, <<"new_status">> => NStatus}).
-spec get_host_devices(HostId :: integer()) -> {ok, Devices :: [map()]} | {error, Reason::any()}. -spec get_host_devices(HostId :: integer()) -> {ok, Devices :: [#device_info{}]} | {error, Reason::any()}.
get_host_devices(HostId) when is_integer(HostId) -> get_host_devices(HostId) when is_integer(HostId) ->
do_get("/get_host_devices", [{<<"host_id">>, integer_to_binary(HostId)}]). case do_get("/get_host_devices", [{<<"host_id">>, integer_to_binary(HostId)}]) of
{ok, DeviceInfos} when is_list(DeviceInfos) ->
device_info_records(DeviceInfos);
{ok, _Other} ->
{error, invalid_device_infos};
Error ->
Error
end.
-spec get_device_by_uuid(DeviceUUID :: binary()) -> {ok, DeviceInfo :: map()} | undefined. -spec get_device_by_uuid(DeviceUUID :: binary()) -> {ok, DeviceInfo :: #device_info{}} | undefined.
get_device_by_uuid(DeviceUUID) when is_binary(DeviceUUID) -> get_device_by_uuid(DeviceUUID) when is_binary(DeviceUUID) ->
case do_get("/get_device_by_uuid", [{<<"device_uuid">>, DeviceUUID}]) of case do_get("/get_device_by_uuid", [{<<"device_uuid">>, DeviceUUID}]) of
{ok, DeviceInfo} -> {ok, DeviceInfo} ->
{ok, DeviceInfo}; case device_info_record(DeviceInfo) of
{ok, Record} ->
{ok, Record};
error ->
undefined
end;
_ -> _ ->
undefined undefined
end. end.
@ -106,18 +129,18 @@ ai_event(Id) when is_integer(Id) ->
<<"token">> => Token, <<"token">> => Token,
<<"id">> => Id <<"id">> => Id
}, },
Body = iolist_to_binary(jiffy:encode(ReqData, [force_utf8])), Body = iolist_to_binary(json:encode(ReqData)),
case hackney:request(post, Url, Headers, Body, [{pool, false}]) of case hackney:request(post, Url, Headers, Body, [{pool, false}]) of
{ok, 200, _, ClientRef} -> {ok, 200, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
lager:debug("[iot_api] send body: ~p, get error is: ~p", [Body, RespBody]), logger:debug("[iot_api_client] send body: ~p, get error is: ~p", [Body, RespBody]),
hackney:close(ClientRef); hackney:close(ClientRef);
{ok, HttpCode, _, ClientRef} -> {ok, HttpCode, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
hackney:close(ClientRef), hackney:close(ClientRef),
lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]); logger:warning("[iot_api_client] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]);
{error, Reason} -> {error, Reason} ->
lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, Reason]) logger:warning("[iot_api_client] send body: ~p, get error is: ~p", [Body, Reason])
end. end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@ -132,13 +155,13 @@ do_post(Path, Params) when is_list(Path), is_map(Params) ->
{<<"Accept">>, <<"application/json">>} {<<"Accept">>, <<"application/json">>}
], ],
Url = BaseUrl ++ Path, Url = BaseUrl ++ Path,
Body = iolist_to_binary(jiffy:encode(Params, [force_utf8])), Body = iolist_to_binary(json:encode(Params)),
case hackney:request(post, Url, Headers, Body, [{pool, false}]) of case hackney:request(post, Url, Headers, Body, [{pool, false}]) of
{ok, 200, _, ClientRef} -> {ok, 200, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
lager:debug("[iot_api] request url: ~p, send body: ~p, get response is: ~p", [Url, Body, RespBody]), logger:debug("[iot_api_client] request url: ~p, send body: ~p, get response is: ~p", [Url, Body, RespBody]),
hackney:close(ClientRef), hackney:close(ClientRef),
case catch jiffy:decode(RespBody, [return_maps]) of case catch json:decode(RespBody) of
#{<<"result">> := Result} -> #{<<"result">> := Result} ->
{ok, Result}; {ok, Result};
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} -> #{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
@ -151,10 +174,10 @@ do_post(Path, Params) when is_list(Path), is_map(Params) ->
{ok, HttpCode, _, ClientRef} -> {ok, HttpCode, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
hackney:close(ClientRef), hackney:close(ClientRef),
lager:warning("[iot_api] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, {HttpCode, RespBody}]), logger:warning("[iot_api_client] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, {HttpCode, RespBody}]),
{error, {HttpCode, RespBody}}; {error, {HttpCode, RespBody}};
{error, Reason} -> {error, Reason} ->
lager:warning("[iot_api] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, Reason]), logger:warning("[iot_api_client] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, Reason]),
{error, Reason} {error, Reason}
end. end.
@ -177,8 +200,8 @@ do_get(Path, Params) when is_list(Path), is_list(Params) ->
{ok, 200, _, ClientRef} -> {ok, 200, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
hackney:close(ClientRef), hackney:close(ClientRef),
lager:debug("[iot_api] url: ~p, get response is: ~p", [Url, RespBody]), logger:debug("[iot_api_client] url: ~p, get response is: ~p", [Url, RespBody]),
case catch jiffy:decode(RespBody, [return_maps]) of case catch json:decode(RespBody) of
#{<<"result">> := Result} -> #{<<"result">> := Result} ->
{ok, Result}; {ok, Result};
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} -> #{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
@ -191,9 +214,46 @@ do_get(Path, Params) when is_list(Path), is_list(Params) ->
{ok, HttpCode, _, ClientRef} -> {ok, HttpCode, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef), {ok, RespBody} = hackney:body(ClientRef),
hackney:close(ClientRef), hackney:close(ClientRef),
lager:warning("[iot_api] request url: ~p, get error is: ~p", [Url, {HttpCode, RespBody}]), logger:warning("[iot_api_client] request url: ~p, get error is: ~p", [Url, {HttpCode, RespBody}]),
{error, {HttpCode, RespBody}}; {error, {HttpCode, RespBody}};
{error, Reason} -> {error, Reason} ->
lager:warning("[iot_api] request url: ~p, get error is: ~p", [Url, Reason]), logger:warning("[iot_api_client] request url: ~p, get error is: ~p", [Url, Reason]),
{error, Reason} {error, Reason}
end. end.
-spec host_info_record(map()) -> {ok, #host_info{}} | error.
host_info_record(#{<<"id">> := Id, <<"uuid">> := UUID, <<"authorize_status">> := AuthorizeStatus, <<"status">> := Status})
when is_integer(Id), is_binary(UUID), is_integer(AuthorizeStatus), is_integer(Status) ->
{ok, #host_info{
id = Id,
uuid = UUID,
authorize_status = AuthorizeStatus,
status = Status
}};
host_info_record(_) ->
error.
-spec device_info_record(map()) -> {ok, #device_info{}} | error.
device_info_record(#{<<"id">> := Id, <<"host_id">> := HostId, <<"device_uuid">> := DeviceUUID, <<"status">> := Status})
when is_integer(Id), is_integer(HostId), is_binary(DeviceUUID), is_integer(Status) ->
{ok, #device_info{
id = Id,
host_id = HostId,
device_uuid = DeviceUUID,
status = Status
}};
device_info_record(_) ->
error.
-spec device_info_records([map()]) -> {ok, [#device_info{}]} | {error, invalid_device_info}.
device_info_records(DeviceInfos) ->
lists:foldr(fun(DeviceInfo, Acc) ->
case {device_info_record(DeviceInfo), Acc} of
{{ok, Record}, {ok, Records}} ->
{ok, [Record | Records]};
{error, _} ->
{error, invalid_device_info};
{_, Error = {error, _}} ->
Error
end
end, {ok, []}, DeviceInfos).

View File

@ -57,7 +57,7 @@ get_precision(Timestamp) when is_integer(Timestamp) ->
<<"ms">> <<"ms">>
end. end.
-spec write_data(Measurement :: binary(), Tags :: map(), FieldsList :: list(), Timestamp :: integer()) -> no_return(). -spec write_data(Measurement :: binary(), Tags :: map(), FieldsList :: list(), Timestamp :: integer()) -> term().
write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement), is_map(Tags), is_list(FieldsList), is_integer(Timestamp) -> write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement), is_map(Tags), is_list(FieldsList), is_integer(Timestamp) ->
%% key的选项 %% key的选项
NFieldsList = lists:filter(fun data_filter/1, FieldsList), NFieldsList = lists:filter(fun data_filter/1, FieldsList),
@ -76,12 +76,12 @@ write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement)
ok ok
end. end.
-spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Points :: list()) -> no_return(). -spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Points :: list()) -> ok.
write(Pid, Bucket, Org, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_list(Points) -> write(Pid, Bucket, Org, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_list(Points) ->
write(Pid, Bucket, Org, <<"ms">>, Points). write(Pid, Bucket, Org, <<"ms">>, Points).
%% Precision的值为: ms|ns|s; (ms) %% Precision的值为: ms|ns|s; (ms)
-spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Precision :: binary(), Points :: list()) -> no_return(). -spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Precision :: binary(), Points :: list()) -> ok.
write(Pid, Bucket, Org, Precision, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_binary(Precision), is_list(Points) -> write(Pid, Bucket, Org, Precision, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_binary(Precision), is_list(Points) ->
gen_server:cast(Pid, {write, Bucket, Org, Precision, Points}). gen_server:cast(Pid, {write, Bucket, Org, Precision, Points}).
@ -101,6 +101,7 @@ start_link(Opts) when is_list(Opts) ->
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
{stop, Reason :: term()} | ignore). {stop, Reason :: term()} | ignore).
init([InfluxProps]) -> init([InfluxProps]) ->
ok = iot_log:set_metadata(),
Token = proplists:get_value(token, InfluxProps), Token = proplists:get_value(token, InfluxProps),
Host = proplists:get_value(host, InfluxProps), Host = proplists:get_value(host, InfluxProps),
Port = proplists:get_value(port, InfluxProps), Port = proplists:get_value(port, InfluxProps),
@ -151,18 +152,18 @@ handle_cast({write, Bucket, Org, Precision, Points}, State = #state{host = Host,
query => Query query => Query
}), }),
lager:debug("[influx_client] url is: ~p, headers: ~p, body: ~ts", [Url, Headers, Body]), logger:debug("[influx_client] url is: ~p, headers: ~p, body: ~ts", [Url, Headers, Body]),
case hackney:request(post, Url, Headers, GZipBody, [{pool, false}]) of case hackney:request(post, Url, Headers, GZipBody, [{pool, false}]) of
{ok, StatusCode, _RespHeaders, ClientRef} -> {ok, StatusCode, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of case hackney:body(ClientRef) of
{ok, RespBody} -> {ok, RespBody} ->
lager:debug("[influx_client] status_code: ~p, response body is: ~p", [StatusCode, RespBody]); logger:debug("[influx_client] status_code: ~p, response body is: ~p", [StatusCode, RespBody]);
{error, Error} -> {error, Error} ->
lager:warning("[influx_client] status_code: ~p, error is: ~p", [Error]) logger:warning("[influx_client] status_code: ~p, error is: ~p", [Error])
end, end,
hackney:close(ClientRef); hackney:close(ClientRef);
{error, Reason} -> {error, Reason} ->
lager:warning("[influx_client] request result is: ~p", [Reason]) logger:warning("[influx_client] request result is: ~p", [Reason])
end, end,
{noreply, State}. {noreply, State}.

View File

@ -45,7 +45,7 @@ field_val(V) when is_float(V) ->
field_val(V) when is_binary(V) -> field_val(V) when is_binary(V) ->
<<$", V/binary, $">>; <<$", V/binary, $">>;
field_val(V) when is_list(V); is_map(V) -> field_val(V) when is_list(V); is_map(V) ->
S = base64:encode(iolist_to_binary(jiffy:encode(V, [force_utf8]))), S = base64:encode(iolist_to_binary(json:encode(V))),
<<$", "base64:", S/binary, $">>; <<$", "base64:", S/binary, $">>;
field_val(true) -> field_val(true) ->
<<"true">>; <<"true">>;
@ -56,4 +56,3 @@ as_list(L) when is_list(L) ->
L; L;
as_list(L) when is_map(L) -> as_list(L) when is_map(L) ->
maps:to_list(L). maps:to_list(L).

View File

@ -8,12 +8,12 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(endpoint_kafka_test). -module(endpoint_kafka_test).
-author("anlicheng"). -author("anlicheng").
-include("endpoint.hrl"). -include_lib("endpoint/include/endpoint.hrl").
%% API %% API
-export([start_test/0, test_consumer/0]). -export([start_manual/0, test_consumer/0]).
start_test() -> start_manual() ->
Name = endpoint:get_name(100), Name = endpoint:get_name(100),
{ok, Pid} = endpoint_kafka:start_link(Name, #endpoint{ {ok, Pid} = endpoint_kafka:start_link(Name, #endpoint{
id = 100, id = 100,
@ -29,10 +29,10 @@ start_test() ->
} }
}), }),
Json = jiffy:encode(#{ Json = iolist_to_binary(json:encode(#{
<<"name">> => <<"anlicheng">>, <<"name">> => <<"anlicheng">>,
<<"age">> => 30 <<"age">> => 30
}, [force_utf8]), })),
endpoint:forward(Pid, Json), endpoint:forward(Pid, Json),
ok. ok.
@ -49,7 +49,7 @@ test_consumer() ->
ok = brod:start_client(KafkaBootstrapEndpoints, client1, ClientConfig), ok = brod:start_client(KafkaBootstrapEndpoints, client1, ClientConfig),
SubscriberCallbackFun = fun(_Partition, Msg, ShellPid = CallbackState) -> SubscriberCallbackFun = fun(_Partition, Msg, ShellPid = CallbackState) ->
lager:debug("call here msg: ~p", [Msg]), logger:debug("call here msg: ~p", [Msg]),
ShellPid ! Msg, {ok, ack, CallbackState} ShellPid ! Msg, {ok, ack, CallbackState}
end, end,
Res = brod_topic_subscriber:start_link(client1, Topic, all, Res = brod_topic_subscriber:start_link(client1, Topic, all,
@ -57,4 +57,4 @@ test_consumer() ->
_CommittedOffsets=[], message, SubscriberCallbackFun, _CommittedOffsets=[], message, SubscriberCallbackFun,
_CallbackState=self()), _CallbackState=self()),
lager:debug("start subscriber res: ~p", [Res]). logger:debug("start subscriber res: ~p", [Res]).

View File

@ -67,23 +67,23 @@ init([]) ->
{retry_interval, 5} {retry_interval, 5}
], ],
lager:debug("[opts] is: ~p", [Opts]), logger:debug("[opts] is: ~p", [Opts]),
case emqtt:start_link(Opts) of case emqtt:start_link(Opts) of
{ok, ConnPid} -> {ok, ConnPid} ->
%% host相关的全部事件 %% host相关的全部事件
lager:debug("[iot_mqtt_subscriber] start conntecting, pid: ~p", [ConnPid]), logger:debug("[iot_mqtt_subscriber] start conntecting, pid: ~p", [ConnPid]),
{ok, _} = emqtt:connect(ConnPid), {ok, _} = emqtt:connect(ConnPid),
lager:debug("[iot_mqtt_subscriber] connect success, pid: ~p", [ConnPid]), logger:debug("[iot_mqtt_subscriber] connect success, pid: ~p", [ConnPid]),
SubscribeResult = emqtt:subscribe(ConnPid, ?Topics), SubscribeResult = emqtt:subscribe(ConnPid, ?Topics),
lager:debug("[iot_mqtt_subscriber] subscribe topics: ~p, result is: ~p", [?Topics, SubscribeResult]), logger:debug("[iot_mqtt_subscriber] subscribe topics: ~p, result is: ~p", [?Topics, SubscribeResult]),
{ok, #state{conn_pid = ConnPid}}; {ok, #state{conn_pid = ConnPid}};
ignore -> ignore ->
lager:debug("[iot_mqtt_subscriber] connect emqx get ignore"), logger:debug("[iot_mqtt_subscriber] connect emqx get ignore"),
{stop, ignore}; {stop, ignore};
{error, Reason} -> {error, Reason} ->
lager:debug("[iot_mqtt_subscriber] connect emqx get error: ~p", [Reason]), logger:debug("[iot_mqtt_subscriber] connect emqx get error: ~p", [Reason]),
{stop, Reason} {stop, Reason}
end. end.
@ -116,19 +116,19 @@ handle_cast(_Request, State = #state{}) ->
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_info({disconnect, ReasonCode, Properties}, State = #state{}) -> handle_info({disconnect, ReasonCode, Properties}, State = #state{}) ->
lager:debug("[iot_mqtt_subscriber] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), logger:debug("[iot_mqtt_subscriber] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]),
{stop, disconnected, State}; {stop, disconnected, State};
%% json反序列需要在host进程进行 %% json反序列需要在host进程进行
handle_info({publish, #{packet_id := _PacketId, payload := Payload, qos := Qos, topic := Topic}}, State = #state{conn_pid = _ConnPid}) -> handle_info({publish, #{packet_id := _PacketId, payload := Payload, qos := Qos, topic := Topic}}, State = #state{conn_pid = _ConnPid}) ->
lager:debug("[iot_mqtt_subscriber] Recv a topic: ~p, publish packet: ~p, qos: ~p", [Topic, Payload, Qos]), logger:debug("[iot_mqtt_subscriber] Recv a topic: ~p, publish packet: ~p, qos: ~p", [Topic, Payload, Qos]),
%% host进程去处理 %% host进程去处理
{noreply, State}; {noreply, State};
handle_info({puback, Packet = #{packet_id := _PacketId}}, State = #state{}) -> handle_info({puback, Packet = #{packet_id := _PacketId}}, State = #state{}) ->
lager:debug("[iot_mqtt_subscriber] receive puback packet: ~p", [Packet]), logger:debug("[iot_mqtt_subscriber] receive puback packet: ~p", [Packet]),
{noreply, State}; {noreply, State};
handle_info(Info, State = #state{}) -> handle_info(Info, State = #state{}) ->
lager:debug("[iot_mqtt_subscriber] get info: ~p", [Info]), logger:debug("[iot_mqtt_subscriber] get info: ~p", [Info]),
{noreply, State}. {noreply, State}.
%% @private %% @private
@ -144,10 +144,10 @@ terminate(Reason, _State = #state{conn_pid = ConnPid}) when is_pid(ConnPid) ->
{ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames), {ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames),
ok = emqtt:disconnect(ConnPid), ok = emqtt:disconnect(ConnPid),
lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), logger:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]),
ok; ok;
terminate(Reason, _State) -> terminate(Reason, _State) ->
lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), logger:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]),
ok. ok.
%% @private %% @private

View File

@ -13,13 +13,5 @@
-export([test/0]). -export([test/0]).
test() -> test() ->
{error, disabled}.
{ok, Content} = file:read_file("/tmp/test.erl"),
{ok, Tokens, _} = erl_scan:string(binary_to_list(Content)),
{ok, ExprList} = erl_parse:parse_exprs(Tokens),
{value, F, _NewBindings} = erl_eval:exprs(ExprList, []),
F(#{name => <<"test">>}).

View File

@ -12,7 +12,6 @@
%% API %% API
-export([rsa_encode/1]). -export([rsa_encode/1]).
-export([insert_services/1]).
-export([test_influxdb/0]). -export([test_influxdb/0]).
test_influxdb() -> test_influxdb() ->
@ -29,34 +28,20 @@ test_influxdb() ->
end) end)
end, lists:seq(1, 100)). end, lists:seq(1, 100)).
insert_services(Num) ->
lists:foreach(fun(Id) ->
Res = mysql_pool:insert(mysql_iot, <<"micro_service">>,
#{
<<"name">> => <<"微服务"/utf8, (integer_to_binary(Id))/binary>>,
<<"code">> => <<"1223423423423423"/utf8>>,
<<"type">> => 1,
<<"version">> => <<"v1.0">>,
<<"url">> => <<"https://www.baidu.com">>,
<<"detail">> => <<"这是一个关于测试的微服务"/utf8>>
}, false),
lager:debug("insert service result is: ~p", [Res])
end, lists:seq(1, Num)).
rsa_encode(Data) when is_binary(Data) -> rsa_encode(Data) when is_binary(Data) ->
%% %%
PublicPemFile = "/tmp/keys/public.pem", PublicPemFile = "/tmp/keys/public.pem",
%% %%
{ok, PubBin} = file:read_file(PublicPemFile), {ok, PubBin} = file:read_file(PublicPemFile),
lager:debug("pub bin is: ~p", [PubBin]), logger:debug("pub bin is: ~p", [PubBin]),
[Pub] = public_key:pem_decode(PubBin), [Pub] = public_key:pem_decode(PubBin),
lager:debug("pub pem bin is: ~p", [Pub]), logger:debug("pub pem bin is: ~p", [Pub]),
PubKey = public_key:pem_entry_decode(Pub), PubKey = public_key:pem_entry_decode(Pub),
lager:debug("the public key is: ~p", [PubKey]), logger:debug("the public key is: ~p", [PubKey]),
EncData = public_key:encrypt_public(Data, PubKey), EncData = public_key:encrypt_public(Data, PubKey),
lager:debug("enc data is: ~p", [EncData]), logger:debug("enc data is: ~p", [EncData]),
rsa_decode(EncData), rsa_decode(EncData),
@ -68,13 +53,13 @@ rsa_decode(EncData) when is_binary(EncData) ->
%% %%
{ok, PubBin} = file:read_file(PublicPemFile), {ok, PubBin} = file:read_file(PublicPemFile),
lager:debug("pub bin is: ~p", [PubBin]), logger:debug("pub bin is: ~p", [PubBin]),
[Pub] = public_key:pem_decode(PubBin), [Pub] = public_key:pem_decode(PubBin),
lager:debug("pub pem bin is: ~p", [Pub]), logger:debug("pub pem bin is: ~p", [Pub]),
PubKey = public_key:pem_entry_decode(Pub), PubKey = public_key:pem_entry_decode(Pub),
lager:debug("the public key is: ~p", [PubKey]), logger:debug("the public key is: ~p", [PubKey]),
PlainData = public_key:decrypt_private(EncData, PubKey), PlainData = public_key:decrypt_private(EncData, PubKey),
lager:debug("plain data is: ~p", [PlainData]), logger:debug("plain data is: ~p", [PlainData]),
ok. ok.

View File

@ -0,0 +1,588 @@
%%%-------------------------------------------------------------------
%%% @author
%%% @copyright (C) 2026, <COMPANY>
%%% @doc
%%% ContainerRequest wire builder helpers.
%%% @end
%%%-------------------------------------------------------------------
-module(docker_container_builder).
-export([list_request/0, config_request/2, deploy_request/2, start_request/1, stop_request/1, kill_request/1, remove_request/1]).
-spec list_request() -> map().
list_request() ->
#{action => list, all => true}.
-spec config_request(binary(), binary()) -> map().
config_request(ContainerName, ConfigJson) when is_binary(ContainerName), is_binary(ConfigJson) ->
#{
action => config,
target => container_ref(ContainerName),
config => ConfigJson
}.
-spec deploy_request(integer(), map()) -> {ok, map()} | {error, binary()}.
deploy_request(TaskId, Config) when is_integer(TaskId), is_map(Config), TaskId >= 0 ->
try
ensure_supported_deploy_config(Config),
validate_deploy_config(Config),
Params = build_container_deploy_params(Config),
{ok, #{action => deploy, task_id => TaskId, params => Params}}
catch
throw:{error, Reason} ->
{error, Reason}
end;
deploy_request(TaskId, _Config) when is_integer(TaskId), TaskId < 0 ->
{error, <<"task_id must be non-negative">>};
deploy_request(_TaskId, _Config) ->
{error, <<"invalid deploy request">>}.
-spec start_request(binary()) -> map().
start_request(ContainerName) when is_binary(ContainerName) ->
#{action => start, target => container_ref(ContainerName)}.
-spec stop_request(binary()) -> map().
stop_request(ContainerName) when is_binary(ContainerName) ->
#{
action => stop,
target => container_ref(ContainerName),
timeout_seconds => 0
}.
-spec kill_request(binary()) -> map().
kill_request(ContainerName) when is_binary(ContainerName) ->
#{
action => kill,
target => container_ref(ContainerName),
signal => <<>>
}.
-spec remove_request(binary()) -> map().
remove_request(ContainerName) when is_binary(ContainerName) ->
#{
action => remove,
target => container_ref(ContainerName),
force => false,
remove_volumes => false
}.
-spec container_ref(binary()) -> map().
container_ref(ContainerName) when is_binary(ContainerName) ->
#{name => ContainerName, id => <<>>}.
-spec ensure_supported_deploy_config(map()) -> ok.
ensure_supported_deploy_config(Config) when is_map(Config) ->
UnsupportedKeys = [Key || Key <- [<<"container_dir">>], maps:is_key(Key, Config)],
case UnsupportedKeys of
[] ->
ok;
_ ->
Unsupported = iolist_to_binary(lists:join(<<", ">>, UnsupportedKeys)),
throw({error, <<"unsupported container config keys: ", Unsupported/binary>>})
end.
-spec validate_deploy_config(map()) -> ok.
validate_deploy_config(Config) when is_map(Config) ->
Required = [
{<<"image">>, binary},
{<<"container_name">>, binary},
{<<"command">>, {list, binary}},
{<<"restart">>, binary}
],
Optional = [
{<<"privileged">>, boolean},
{<<"entrypoint">>, {list, binary}},
{<<"envs">>, {list, binary}},
{<<"ports">>, {list, binary}},
{<<"expose">>, {list, binary}},
{<<"volumes">>, {list, binary}},
{<<"networks">>, {list, binary}},
{<<"labels">>, {map, {binary, binary}}},
{<<"user">>, binary},
{<<"working_dir">>, binary},
{<<"hostname">>, binary},
{<<"network_mode">>, binary},
{<<"cap_add">>, {list, binary}},
{<<"cap_drop">>, {list, binary}},
{<<"devices">>, {list, binary}},
{<<"mem_limit">>, binary},
{<<"mem_reservation">>, binary},
{<<"cpu_shares">>, non_neg_integer},
{<<"cpus">>, non_neg_number},
{<<"ulimits">>, {map, {binary, binary}}},
{<<"sysctls">>, {map, {binary, binary}}},
{<<"tmpfs">>, {list, binary}},
{<<"extra_hosts">>, {list, binary}},
{<<"healthcheck">>, map}
],
Errors = check_required(Config, Required) ++ check_optional(Config, Optional) ++ check_healthcheck(Config),
case Errors of
[] ->
ok;
_ ->
throw({error, iolist_to_binary(lists:join(<<"|||">>, Errors))})
end.
-spec check_required(map(), list()) -> [binary()].
check_required(Config, Fields) ->
lists:foldl(
fun({Key, Type}, ErrAcc) ->
case maps:get(Key, Config, undefined) of
undefined ->
[iolist_to_binary(io_lib:format("miss requied parameter: ~p", [Key])) | ErrAcc];
Value ->
case check_type(Value, Type) of
true ->
ErrAcc;
false ->
[iolist_to_binary(io_lib:format("required parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc]
end
end
end,
[], Fields).
-spec check_optional(map(), list()) -> [binary()].
check_optional(Config, Fields) ->
lists:foldl(
fun({Key, Type}, ErrAcc) ->
case maps:get(Key, Config, undefined) of
undefined ->
ErrAcc;
Value ->
case check_type(Value, Type) of
true ->
ErrAcc;
false ->
[iolist_to_binary(io_lib:format("optional parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc]
end
end
end,
[], Fields).
-spec type_name(tuple() | atom()) -> binary().
type_name(binary) ->
<<"string">>;
type_name(integer) ->
<<"integer">>;
type_name(non_neg_integer) ->
<<"non-negative integer">>;
type_name(number) ->
<<"number">>;
type_name(non_neg_number) ->
<<"non-negative number">>;
type_name(duration) ->
<<"duration string or non-negative integer">>;
type_name(list) ->
<<"list">>;
type_name({list, binary}) ->
<<"list of string">>;
type_name({list, number}) ->
<<"list of number">>;
type_name({list, integer}) ->
<<"list of integer">>;
type_name(map) ->
<<"map">>;
type_name({map, {binary, binary}}) ->
<<"map of string:string">>;
type_name({map, {binary, any}}) ->
<<"map of string:any">>;
type_name(boolean) ->
<<"boolean">>.
-spec check_type(any(), any()) -> boolean().
check_type(Value, binary) ->
is_binary(Value);
check_type(Value, integer) ->
is_integer(Value);
check_type(Value, non_neg_integer) ->
is_integer(Value) andalso Value >= 0;
check_type(Value, number) ->
is_number(Value);
check_type(Value, non_neg_number) ->
is_number(Value) andalso Value >= 0;
check_type(Value, duration) ->
is_binary(Value) orelse (is_integer(Value) andalso Value >= 0);
check_type(Value, list) when is_list(Value) ->
true;
check_type(Value, {list, binary}) when is_list(Value) ->
lists:all(fun(E) -> is_binary(E) end, Value);
check_type(Value, {list, number}) when is_list(Value) ->
lists:all(fun(E) -> is_number(E) end, Value);
check_type(Value, {list, integer}) when is_list(Value) ->
lists:all(fun(E) -> is_integer(E) end, Value);
check_type(Value, map) when is_map(Value) ->
true;
check_type(Value, {map, {binary, binary}}) when is_map(Value) ->
lists:all(fun({K, V}) -> is_binary(K) andalso is_binary(V) end, maps:to_list(Value));
check_type(Value, {map, {binary, any}}) when is_map(Value) ->
lists:all(fun({K, _}) -> is_binary(K) end, maps:to_list(Value));
check_type(Value, boolean) ->
is_boolean(Value);
check_type(_, _) ->
false.
-spec check_healthcheck(map()) -> [binary()].
check_healthcheck(Config) ->
case maps:get(<<"healthcheck">>, Config, undefined) of
undefined ->
[];
Healthcheck when is_map(Healthcheck) ->
Fields = [
{<<"test">>, {list, binary}},
{<<"interval">>, duration},
{<<"timeout">>, duration},
{<<"retries">>, non_neg_integer}
],
KeyErrors = [<<"optional parameter: <<\"healthcheck\">>, type must be: map of string:any">> ||
{Key, _Value} <- maps:to_list(Healthcheck), not is_binary(Key)],
KeyErrors ++ check_optional(Healthcheck, Fields);
_ ->
[]
end.
-spec build_container_deploy_params(map()) -> map().
build_container_deploy_params(Config) when is_map(Config) ->
#{
container_name => maps:get(<<"container_name">>, Config),
create => build_docker_create_options(Config)
}.
-spec build_docker_create_options(map()) -> map().
build_docker_create_options(Config) when is_map(Config) ->
#{
config => build_docker_container_config(Config),
host_config => build_docker_host_config(Config),
networking_config => build_docker_networking_config(Config)
}.
-spec build_docker_container_config(map()) -> map().
build_docker_container_config(Config) when is_map(Config) ->
#{
image => maps:get(<<"image">>, Config),
cmd => maps:get(<<"command">>, Config),
entrypoint => maps:get(<<"entrypoint">>, Config, []),
env => maps:get(<<"envs">>, Config, []),
labels => maps:get(<<"labels">>, Config, #{}),
volumes => build_container_volumes(maps:get(<<"volumes">>, Config, [])),
user => maps:get(<<"user">>, Config, <<>>),
working_dir => maps:get(<<"working_dir">>, Config, <<>>),
hostname => maps:get(<<"hostname">>, Config, <<>>),
exposed_ports => build_exposed_ports(Config),
healthcheck => build_healthcheck(maps:get(<<"healthcheck">>, Config, undefined))
}.
-spec build_docker_host_config(map()) -> map().
build_docker_host_config(Config) when is_map(Config) ->
#{
binds => build_host_binds(maps:get(<<"volumes">>, Config, [])),
network_mode => maps:get(<<"network_mode">>, Config, <<>>),
restart_policy => build_restart_policy(maps:get(<<"restart">>, Config)),
privileged => maps:get(<<"privileged">>, Config, false),
cap_add => maps:get(<<"cap_add">>, Config, []),
cap_drop => maps:get(<<"cap_drop">>, Config, []),
devices => build_device_mappings(maps:get(<<"devices">>, Config, [])),
memory => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_limit">>, Config, undefined), <<"mem_limit">>)),
memory_reservation => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_reservation">>, Config, undefined), <<"mem_reservation">>)),
nano_cpus => default_uint64(parse_optional_nano_cpus(maps:get(<<"cpus">>, Config, undefined))),
cpu_shares => default_uint64(maps:get(<<"cpu_shares">>, Config, undefined)),
port_bindings => build_port_bindings(maps:get(<<"ports">>, Config, [])),
ulimits => build_ulimits(maps:get(<<"ulimits">>, Config, #{})),
tmpfs => maps:from_list(build_tmpfs_options(maps:get(<<"tmpfs">>, Config, []))),
sysctls => maps:get(<<"sysctls">>, Config, #{}),
extra_hosts => maps:get(<<"extra_hosts">>, Config, [])
}.
-spec build_docker_networking_config(map()) -> map().
build_docker_networking_config(Config) when is_map(Config) ->
Networks = maps:get(<<"networks">>, Config, []),
#{endpoints => [#{name => Network} || Network <- Networks]}.
-spec build_restart_policy(binary()) -> map().
build_restart_policy(Restart0) when is_binary(Restart0) ->
case binary:split(Restart0, <<":">>) of
[Name, RetryCountBin] ->
#{name => Name, maximum_retry_count => parse_uint32(RetryCountBin, <<"restart">>)};
[Name] ->
#{name => Name, maximum_retry_count => 0}
end.
-spec build_healthcheck(undefined | map()) -> undefined | map().
build_healthcheck(undefined) ->
undefined;
build_healthcheck(Healthcheck) when is_map(Healthcheck) ->
#{
test => maps:get(<<"test">>, Healthcheck, []),
interval_ns => parse_duration_ns(maps:get(<<"interval">>, Healthcheck, <<"0s">>), <<"healthcheck.interval">>),
timeout_ns => parse_duration_ns(maps:get(<<"timeout">>, Healthcheck, <<"0s">>), <<"healthcheck.timeout">>),
retries => maps:get(<<"retries">>, Healthcheck, 0)
}.
-spec default_uint64(undefined | non_neg_integer()) -> non_neg_integer().
default_uint64(undefined) ->
0;
default_uint64(Value) when is_integer(Value), Value >= 0 ->
Value.
-spec parse_optional_nano_cpus(undefined | number()) -> undefined | non_neg_integer().
parse_optional_nano_cpus(undefined) ->
undefined;
parse_optional_nano_cpus(Cpus) when is_integer(Cpus), Cpus >= 0 ->
Cpus * 1000000000;
parse_optional_nano_cpus(Cpus) when is_float(Cpus), Cpus >= 0 ->
trunc(Cpus * 1000000000).
-spec build_container_volumes([binary()]) -> [binary()].
build_container_volumes(VolumeSpecs) when is_list(VolumeSpecs) ->
[ContainerPath || VolumeSpec <- VolumeSpecs, {_HostPath, ContainerPath, _ReadOnly} <- [parse_volume_spec(VolumeSpec)]].
-spec build_host_binds([binary()]) -> [binary()].
build_host_binds(VolumeSpecs) when is_list(VolumeSpecs) ->
[volume_bind(HostPath, ContainerPath, ReadOnly) ||
VolumeSpec <- VolumeSpecs,
{HostPath, ContainerPath, ReadOnly} <- [parse_volume_spec(VolumeSpec)]].
-spec parse_volume_spec(binary()) -> {binary(), binary(), boolean()}.
parse_volume_spec(VolumeSpec) when is_binary(VolumeSpec) ->
case binary:split(VolumeSpec, <<":">>, [global]) of
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
{HostPath, ContainerPath, false};
[HostPath, ContainerPath | Modes] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
{HostPath, ContainerPath, lists:member(<<"ro">>, Modes)};
_ ->
throw({error, <<"invalid volume binding">>})
end.
-spec volume_bind(binary(), binary(), boolean()) -> binary().
volume_bind(HostPath, ContainerPath, true) when is_binary(HostPath), is_binary(ContainerPath) ->
<<HostPath/binary, ":", ContainerPath/binary, ":ro">>;
volume_bind(HostPath, ContainerPath, false) when is_binary(HostPath), is_binary(ContainerPath) ->
<<HostPath/binary, ":", ContainerPath/binary>>.
-spec build_exposed_ports(map()) -> [map()].
build_exposed_ports(Config) when is_map(Config) ->
ExposePorts = [build_exposed_port(ExposeSpec) || ExposeSpec <- maps:get(<<"expose">>, Config, [])],
BoundPorts = [
#{container_port => ContainerPort, protocol => Protocol} ||
#{container_port := ContainerPort, protocol := Protocol} <- build_port_bindings(maps:get(<<"ports">>, Config, []))
],
unique_ports(ExposePorts ++ BoundPorts).
-spec build_exposed_port(binary()) -> map().
build_exposed_port(ExposeSpec) when is_binary(ExposeSpec) ->
case binary:split(ExposeSpec, <<"/">>) of
[PortBin] ->
#{container_port => parse_uint32(PortBin, <<"expose">>), protocol => <<"tcp">>};
[PortBin, Protocol] ->
#{container_port => parse_uint32(PortBin, <<"expose">>), protocol => Protocol}
end.
-spec build_port_bindings([binary()]) -> [map()].
build_port_bindings(PortSpecs) when is_list(PortSpecs) ->
[build_port_binding(PortSpec) || PortSpec <- PortSpecs].
-spec build_port_binding(binary()) -> map().
build_port_binding(PortSpec) when is_binary(PortSpec) ->
case binary:split(PortSpec, <<":">>, [global]) of
[HostPortBin, ContainerPortSpec] when HostPortBin =/= <<>>, ContainerPortSpec =/= <<>> ->
{ContainerPort, Protocol} = parse_container_port_spec(ContainerPortSpec, <<"ports">>),
#{
host_ip => <<>>,
host_port => parse_tcp_port(HostPortBin, <<"ports.host_port">>),
container_port => ContainerPort,
protocol => Protocol
};
_ ->
throw({error, <<"invalid port binding">>})
end.
-spec parse_container_port_spec(binary(), binary()) -> {non_neg_integer(), binary()}.
parse_container_port_spec(PortSpec, Field) when is_binary(PortSpec), is_binary(Field) ->
case binary:split(PortSpec, <<"/">>) of
[PortBin] ->
{parse_tcp_port(PortBin, Field), <<"tcp">>};
[PortBin, Protocol] when Protocol =/= <<>> ->
{parse_tcp_port(PortBin, Field), Protocol};
_ ->
throw({error, <<"invalid port binding">>})
end.
-spec unique_ports([map()]) -> [map()].
unique_ports(Ports) when is_list(Ports) ->
{_Seen, Result} = lists:foldl(
fun(Port = #{container_port := ContainerPort, protocol := Protocol}, {Seen, Acc}) ->
Key = {ContainerPort, Protocol},
case maps:is_key(Key, Seen) of
true ->
{Seen, Acc};
false ->
{Seen#{Key => true}, [Port | Acc]}
end
end,
{#{}, []},
Ports),
lists:reverse(Result).
-spec build_device_mappings([binary()]) -> [map()].
build_device_mappings(DeviceSpecs) when is_list(DeviceSpecs) ->
[build_device_mapping(DeviceSpec) || DeviceSpec <- DeviceSpecs].
-spec build_device_mapping(binary()) -> map().
build_device_mapping(DeviceSpec) when is_binary(DeviceSpec) ->
case binary:split(DeviceSpec, <<":">>, [global]) of
[HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> ->
#{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => <<"rwm">>};
[HostPath, ContainerPath, Permissions] when HostPath =/= <<>>, ContainerPath =/= <<>>, Permissions =/= <<>> ->
#{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => Permissions};
_ ->
throw({error, <<"invalid device mapping">>})
end.
-spec build_ulimits(map()) -> [map()].
build_ulimits(Ulimits) when is_map(Ulimits) ->
[build_ulimit(Name, Value) || {Name, Value} <- maps:to_list(Ulimits)].
-spec build_ulimit(binary(), binary()) -> map().
build_ulimit(Name, Value) when is_binary(Name), is_binary(Value) ->
case binary:split(Value, <<":">>) of
[SoftBin, HardBin] ->
#{name => Name, soft => parse_uint64(SoftBin, <<"ulimits.soft">>), hard => parse_uint64(HardBin, <<"ulimits.hard">>)};
[LimitBin] ->
Limit = parse_uint64(LimitBin, <<"ulimits.limit">>),
#{name => Name, soft => Limit, hard => Limit}
end.
-spec build_tmpfs_options([binary()]) -> [{binary(), binary()}].
build_tmpfs_options(TmpfsSpecs) when is_list(TmpfsSpecs) ->
[build_tmpfs_option(TmpfsSpec) || TmpfsSpec <- TmpfsSpecs].
-spec build_tmpfs_option(binary()) -> {binary(), binary()}.
build_tmpfs_option(TmpfsSpec) when is_binary(TmpfsSpec) ->
case binary:split(TmpfsSpec, <<":">>) of
[Path] when Path =/= <<>> ->
{Path, <<>>};
[Path, Options] when Path =/= <<>> ->
{Path, Options};
_ ->
throw({error, <<"invalid tmpfs mount">>})
end.
-spec parse_optional_size_bytes(undefined | binary(), binary()) -> undefined | non_neg_integer().
parse_optional_size_bytes(undefined, _Field) ->
undefined;
parse_optional_size_bytes(Value, Field) when is_binary(Value) ->
parse_size_bytes(Value, Field).
-spec parse_duration_ns(binary() | integer(), binary()) -> non_neg_integer().
parse_duration_ns(Value, _Field) when is_integer(Value), Value >= 0 ->
Value;
parse_duration_ns(Value, Field) when is_binary(Value) ->
parse_scaled_uint64(Value, Field, #{
<<"ns">> => 1,
<<"us">> => 1000,
<<"ms">> => 1000000,
<<"s">> => 1000000000,
<<"m">> => 60000000000,
<<"h">> => 3600000000000,
<<>> => 1000000000
}).
-spec parse_size_bytes(binary(), binary()) -> non_neg_integer().
parse_size_bytes(Value, Field) when is_binary(Value) ->
parse_scaled_uint64(Value, Field, #{
<<"b">> => 1,
<<"k">> => 1024,
<<"kb">> => 1024,
<<"ki">> => 1024,
<<"kib">> => 1024,
<<"m">> => 1048576,
<<"mb">> => 1048576,
<<"mi">> => 1048576,
<<"mib">> => 1048576,
<<"g">> => 1073741824,
<<"gb">> => 1073741824,
<<"gi">> => 1073741824,
<<"gib">> => 1073741824,
<<"t">> => 1099511627776,
<<"tb">> => 1099511627776,
<<"ti">> => 1099511627776,
<<"tib">> => 1099511627776,
<<>> => 1
}).
-spec parse_scaled_uint64(binary(), binary(), map()) -> non_neg_integer().
parse_scaled_uint64(Value0, Field, Multipliers) when is_binary(Value0), is_binary(Field), is_map(Multipliers) ->
Value = trim_binary(Value0),
LowerValue = lower_binary(Value),
{NumberBin, Unit} = split_numeric_suffix(LowerValue),
case maps:get(Unit, Multipliers, undefined) of
undefined ->
throw({error, <<"invalid value for ", Field/binary, ": ", Value0/binary>>});
Multiplier ->
trunc(parse_decimal(NumberBin, Field) * Multiplier)
end.
-spec parse_uint32(binary(), binary()) -> non_neg_integer().
parse_uint32(Value, Field) when is_binary(Value), is_binary(Field) ->
Parsed = parse_uint64(Value, Field),
case Parsed =< 16#FFFFFFFF of
true ->
Parsed;
false ->
throw({error, <<"value overflow for ", Field/binary>>})
end.
-spec parse_tcp_port(binary(), binary()) -> non_neg_integer().
parse_tcp_port(Value, Field) when is_binary(Value), is_binary(Field) ->
Parsed = parse_uint32(Value, Field),
case Parsed =< 65535 of
true ->
Parsed;
false ->
throw({error, <<"port out of range for ", Field/binary>>})
end.
-spec parse_uint64(binary(), binary()) -> non_neg_integer().
parse_uint64(Value0, Field) when is_binary(Value0), is_binary(Field) ->
Value = trim_binary(Value0),
case catch binary_to_integer(Value) of
Parsed when is_integer(Parsed), Parsed >= 0 ->
Parsed;
_ ->
throw({error, <<"invalid unsigned integer for ", Field/binary, ": ", Value0/binary>>})
end.
-spec parse_decimal(binary(), binary()) -> float().
parse_decimal(Value, Field) when is_binary(Value), is_binary(Field) ->
case catch binary_to_integer(Value) of
ParsedInt when is_integer(ParsedInt), ParsedInt >= 0 ->
float(ParsedInt);
_ ->
case catch binary_to_float(Value) of
ParsedFloat when is_float(ParsedFloat), ParsedFloat >= 0 ->
ParsedFloat;
_ ->
throw({error, <<"invalid number for ", Field/binary, ": ", Value/binary>>})
end
end.
-spec split_numeric_suffix(binary()) -> {binary(), binary()}.
split_numeric_suffix(Value) when is_binary(Value) ->
split_numeric_suffix(Value, <<>>).
-spec split_numeric_suffix(binary(), binary()) -> {binary(), binary()}.
split_numeric_suffix(<<Char, Rest/binary>>, Acc)
when (Char >= $0 andalso Char =< $9) orelse Char =:= $. ->
split_numeric_suffix(Rest, <<Acc/binary, Char>>);
split_numeric_suffix(Rest, <<>>) ->
throw({error, <<"invalid numeric value: ", Rest/binary>>});
split_numeric_suffix(Rest, Acc) ->
{Acc, Rest}.
-spec trim_binary(binary()) -> binary().
trim_binary(Value) when is_binary(Value) ->
Trimmed = string:trim(binary_to_list(Value)),
list_to_binary(Trimmed).
-spec lower_binary(binary()) -> binary().
lower_binary(Value) when is_binary(Value) ->
list_to_binary(string:lowercase(binary_to_list(Value))).

View File

@ -1,105 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_buffer).
-include("endpoint.hrl").
%%
-define(RETRY_INTERVAL, 5000).
-export([new/2, append/2, trigger_next/1, trigger_n/1, cleanup/1, ack/2, stat/1]).
-export_type([buffer/0]).
-record(buffer, {
endpoint :: #endpoint{},
next_id = 1 :: integer(),
%%
cursor = 0 :: integer(),
%% ets存储的引用
tid :: ets:tid(),
%%
timer_pid :: pid(),
%%
window_size = 10,
%%
flight_num = 0,
%%
acc_num = 0
}).
-record(north_data, {
id :: integer(),
tuple :: any()
}).
-type buffer() :: #buffer{}.
-spec new(Endpoint :: #endpoint{}, WindowSize :: integer()) -> Buffer :: #buffer{}.
new(Endpoint = #endpoint{id = Id}, WindowSize) when is_integer(WindowSize), WindowSize > 0 ->
%%
EtsName = list_to_atom("endpoint_buffer_ets:" ++ integer_to_list(Id)),
Tid = ets:new(EtsName, [ordered_set, private, {keypos, 2}]),
%%
{ok, TimerPid} = endpoint_timer:start_link(?RETRY_INTERVAL),
#buffer{cursor = 0, tid = Tid, timer_pid = TimerPid, endpoint = Endpoint, window_size = WindowSize}.
-spec append(tuple(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
append(Tuple, Buffer = #buffer{tid = Tid, next_id = NextId, window_size = WindowSize, flight_num = FlightNum}) ->
NorthData = #north_data{id = NextId, tuple = Tuple},
true = ets:insert(Tid, NorthData),
NBuffer = Buffer#buffer{next_id = NextId + 1},
case FlightNum < WindowSize of
true ->
trigger_next(NBuffer);
false ->
NBuffer
end.
-spec trigger_n(Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
trigger_n(Buffer = #buffer{window_size = WindowSize}) ->
%% window_size
lists:foldl(fun(_, Buffer0) -> trigger_next(Buffer0) end, Buffer, lists:seq(1, WindowSize)).
%%
-spec trigger_next(Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
trigger_next(Buffer = #buffer{tid = Tid, cursor = Cursor, timer_pid = TimerPid, flight_num = FlightNum}) ->
case ets:first(Tid) of
'$end_of_table' ->
Buffer;
Key ->
[#north_data{id = Id, tuple = Tuple}|_] = ets:take(Tid, Key),
ReceiverPid = self(),
ReceiverPid ! {next_data, Id, Tuple},
endpoint_timer:task(TimerPid, Id, fun() -> ReceiverPid ! {next_data, Id, Tuple} end),
Buffer#buffer{flight_num = FlightNum + 1, cursor = Cursor + 1}
end.
-spec ack(Id :: integer(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}.
ack(Id, Buffer = #buffer{timer_pid = TimerPid, acc_num = AccNum, flight_num = FlightNum}) when is_integer(Id) ->
endpoint_timer:ack(TimerPid, Id),
trigger_next(Buffer#buffer{acc_num = AccNum + 1, flight_num = FlightNum - 1}).
%%
-spec stat(Buffer :: #buffer{}) -> map().
stat(#buffer{acc_num = AccNum, tid = Tid}) ->
#{
<<"acc_num">> => AccNum,
<<"queue_num">> => ets:info(Tid, size)
}.
-spec cleanup(Buffer :: #buffer{}) -> ok.
cleanup(#buffer{timer_pid = TimerPid}) ->
endpoint_timer:cleanup(TimerPid),
ok.
%%%===================================================================
%%% Internal functions
%%%===================================================================

View File

@ -1,184 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_kafka).
-include("endpoint.hrl").
-behaviour(gen_server).
%% API
-export([start_link/2]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
%%
-define(RETRY_INTERVAL, 5000).
-define(DISCONNECTED, disconnected).
-define(CONNECTED, connected).
-record(state, {
endpoint :: #endpoint{},
buffer :: endpoint_buffer:buffer(),
client_id :: atom(),
client_pid :: undefined | pid(),
status = ?DISCONNECTED
}).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Creates a gen_statem process which calls Module:init/1 to
%% initialize. To ensure a synchronized start-up procedure, this
%% function does not return until Module:init/1 has returned.
start_link(LocalName, Endpoint = #endpoint{}) when is_atom(LocalName) ->
gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
%% @private
%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or
%% gen_statem:start_link/[3,4], this function is called by the new
%% process to initialize.
init([Endpoint = #endpoint{id = Id, matcher = Matcher}]) ->
endpoint_subscription:subscribe(Matcher, self()),
erlang:process_flag(trap_exit, true),
%% ,
erlang:start_timer(0, self(), connect),
%%
Buffer = endpoint_buffer:new(Endpoint, 10),
ClientId = list_to_atom("brod_client:" ++ integer_to_list(Id)),
{ok, #state{endpoint = Endpoint, buffer = Buffer, status = ?DISCONNECTED, client_id = ClientId}}.
%% @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_stat, _From, State = #state{buffer = Buffer}) ->
Stat = endpoint_buffer:stat(Buffer),
{reply, {ok, Stat}, 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({forward, Metric}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:append(Metric, Buffer),
{noreply, State#state{buffer = NBuffer}}.
%% @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, _, connect}, State = #state{buffer = Buffer, status = ?DISCONNECTED, client_id = ClientId,
endpoint = #endpoint{title = Title, config = #kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}}}) ->
lager:debug("[endpoint_kafka] endpoint: ~p, create postman", [Title]),
BaseConfig = [
{reconnect_cool_down_seconds, 5},
{socket_options, [{keepalive, true}]}
],
ClientConfig = case SaslConfig of
{Mechanism, Username, Password} ->
[{sasl, {Mechanism, Username, Password}}|BaseConfig];
undefined ->
BaseConfig
end,
case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of
{ok, ClientPid} ->
case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of
ok ->
NBuffer = endpoint_buffer:trigger_next(Buffer),
{noreply, State#state{buffer = NBuffer, client_pid = ClientPid, status = ?CONNECTED}};
{error, Reason} ->
lager:debug("[endpoint_kafka] start_producer: ~p, get error: ~p", [ClientId, Reason]),
brod:stop_client(ClientId),
retry_connect(),
{noreply, State#state{status = ?DISCONNECTED, client_pid = undefined}}
end;
Error ->
lager:debug("[endpoint_kafka] start_client: ~p, get error: ~p", [ClientId, Error]),
retry_connect(),
{noreply, State#state{status = ?DISCONNECTED, client_pid = undefined}}
end;
%% 线
handle_info({next_data, _Id, _Tuple}, State = #state{status = ?DISCONNECTED}) ->
{noreply, State};
%% mqtt服务器
handle_info({next_data, Id, Metric}, State = #state{status = ?CONNECTED, client_pid = ClientPid,
endpoint = #endpoint{config = #kafka_endpoint{topic = Topic}}}) ->
ReceiverPid = self(),
AckCb = fun(Partition, BaseOffset) ->
lager:debug("[endpoint_kafka] ack partion: ~p, offset: ~p", [Partition, BaseOffset]),
ReceiverPid ! {ack, Id}
end,
_ = brod:produce_cb(ClientPid, Topic, random, <<>>, Metric, AckCb),
{noreply, State};
handle_info({ack, Id}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{noreply, State#state{buffer = NBuffer}};
%% postman进程挂掉时
handle_info({'EXIT', ClientPid, Reason}, State = #state{client_pid = ClientPid, endpoint = #endpoint{title = Title}}) ->
lager:warning("[endpoint_kafka] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]),
retry_connect(),
{noreply, State#state{client_pid = undefined, status = ?DISCONNECTED}};
handle_info(Info, State = #state{status = Status}) ->
lager:warning("[endpoint_kafka] unknown message: ~p, status: ~p", [Info, Status]),
{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{endpoint = #endpoint{title = Title}, buffer = Buffer}) ->
lager:debug("[endpoint_kafka] endpoint: ~p, terminate with reason: ~p", [Title, Reason]),
endpoint_buffer:cleanup(Buffer),
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
%%%===================================================================
retry_connect() ->
erlang:start_timer(?RETRY_INTERVAL, self(), connect).

View File

@ -1,203 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 06. 7 2023 12:02
%%%-------------------------------------------------------------------
-module(endpoint_mqtt).
-include("endpoint.hrl").
-behaviour(gen_server).
%% API
-export([start_link/2]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
%%
-define(RETRY_INTERVAL, 15000).
-define(DISCONNECTED, disconnected).
-define(CONNECTED, connected).
-record(state, {
endpoint :: #endpoint{},
buffer :: endpoint_buffer:buffer(),
conn_pid :: undefined | pid(),
%% , #{PacketId :: integer() => Id :: integer()}
inflight = #{},
status = disconnected
}).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Creates a gen_statem process which calls Module:init/1 to
%% initialize. To ensure a synchronized start-up procedure, this
%% function does not return until Module:init/1 has returned.
start_link(LocalName, Endpoint = #endpoint{}) when is_atom(LocalName) ->
gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
%% @private
%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or
%% gen_statem:start_link/[3,4], this function is called by the new
%% process to initialize.
init([Endpoint = #endpoint{matcher = Matcher}]) ->
% erlang:process_flag(trap_exit, true),
endpoint_subscription:subscribe(Matcher, self()),
%% ,
erlang:start_timer(0, self(), create_postman),
%%
Buffer = endpoint_buffer:new(Endpoint, 10),
{ok, #state{endpoint = Endpoint, buffer = Buffer, status = ?DISCONNECTED}}.
%% @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_stat, _From, State = #state{buffer = Buffer}) ->
Stat = endpoint_buffer:stat(Buffer),
{reply, {ok, Stat}, 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({forward, Metric}, State = #state{buffer = Buffer}) ->
NBuffer = endpoint_buffer:append(Metric, Buffer),
{noreply, State#state{buffer = NBuffer}}.
%% @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, _, create_postman}, State = #state{buffer = Buffer, status = ?DISCONNECTED,
endpoint = #endpoint{title = Title, config = #mqtt_endpoint{host = Host, port = Port, username = Username, password = Password, client_id = ClientId}}}) ->
lager:debug("[endpoint_mqtt] endpoint: ~ts, create postman", [Title]),
Opts = [
{owner, self()},
{clientid, ClientId},
{host, binary_to_list(Host)},
{port, Port},
{tcp_opts, []},
{username, binary_to_list(Username)},
{password, binary_to_list(Password)},
{keepalive, 86400},
{auto_ack, true},
{connect_timeout, 5000},
{proto_ver, v5},
{retry_interval, 5000}
],
try
{ok, ConnPid} = emqtt:start_link(Opts),
lager:debug("[endpoint_mqtt] start connect, options: ~p", [Opts]),
case emqtt:connect(ConnPid, 5000) of
{ok, _} ->
lager:debug("[endpoint_mqtt] connect success, pid: ~p", [ConnPid]),
NBuffer = endpoint_buffer:trigger_n(Buffer),
{noreply, State#state{conn_pid = ConnPid, buffer = NBuffer, status = ?CONNECTED}};
{error, Reason} ->
lager:warning("[endpoint_mqtt] connect get error: ~p", [Reason]),
erlang:start_timer(?RETRY_INTERVAL, self(), create_postman),
{noreply, State}
end
catch _:Error->
lager:warning("[endpoint_mqtt] connect get error: ~p", [Error]),
erlang:start_timer(?RETRY_INTERVAL, self(), create_postman),
{noreply, State}
end;
%% 线
handle_info({next_data, _Id, _Tuple}, State = #state{status = ?DISCONNECTED}) ->
{keep_state, State};
%% mqtt服务器
handle_info({next_data, Id, Metric}, State = #state{status = ?CONNECTED, conn_pid = ConnPid, buffer = Buffer, inflight = InFlight,
endpoint = #endpoint{config = #mqtt_endpoint{topic = Topic, qos = Qos}}}) ->
lager:debug("[endpoint_mqtt] will publish topic: ~p, metric: ~p, qos: ~p", [Topic, Metric, Qos]),
case emqtt:publish(ConnPid, Topic, #{}, Metric, [{qos, Qos}, {retain, true}]) of
ok ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{noreply, State#state{buffer = NBuffer}};
{ok, PacketId} ->
{noreply, State#state{inflight = maps:put(PacketId, Id, InFlight)}};
{error, Reason} ->
lager:warning("[endpoint_mqtt] send message to topic: ~p, get error: ~p", [Topic, Reason]),
{stop, Reason, State}
end;
handle_info({disconnected, ReasonCode, Properties}, State = #state{status = ?CONNECTED}) ->
lager:debug("[endpoint_mqtt] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]),
erlang:start_timer(?RETRY_INTERVAL, self(), create_postman),
{noreply, State#state{conn_pid = undefined, status = ?DISCONNECTED}};
handle_info({publish, Message = #{packet_id := _PacketId, payload := Payload}}, State = #state{status = ?CONNECTED}) ->
lager:debug("[endpoint_mqtt] Recv a publish packet: ~p, payload: ~p", [Message, Payload]),
{noreply, State};
%%
handle_info({puback, #{packet_id := PacketId}}, State = #state{status = ?CONNECTED, inflight = Inflight, buffer = Buffer}) ->
case maps:take(PacketId, Inflight) of
{Id, RestInflight} ->
NBuffer = endpoint_buffer:ack(Id, Buffer),
{noreply, State#state{buffer = NBuffer, inflight = RestInflight}};
error ->
{noreply, State}
end;
%% postman进程挂掉时
handle_info({'EXIT', ConnPid, Reason}, State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid}) ->
lager:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]),
erlang:start_timer(?RETRY_INTERVAL, self(), create_postman),
{noreply, State#state{conn_pid = undefined, status = ?DISCONNECTED}};
handle_info(Info, State = #state{status = Status}) ->
lager:warning("[endpoint_mqtt] unknown message: ~p, status: ~p", [Info, Status]),
{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{endpoint = #endpoint{title = Title}, buffer = Buffer}) ->
lager:debug("[endpoint_mqtt] endpoint: ~p, terminate with reason: ~p", [Title, Reason]),
endpoint_buffer:cleanup(Buffer),
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
%%%===================================================================

View File

@ -1,206 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 07. 11 2025 16:27
%%%-------------------------------------------------------------------
-module(endpoint_subscription).
-author("anlicheng").
-behaviour(gen_server).
%% API
-export([start_link/0]).
-export([subscribe/2, publish/2, get_subscribers/0]).
-export([match_components/2, is_valid_components/1, of_components/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(subscriber, {
topic :: binary(),
subscriber_pid :: pid(),
components = [],
%%
%% 1. topic优先级别最高
%% 2. *
%% 3. +
order :: integer()
}).
-record(state, {
subscribers = []
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec subscribe(Topic :: binary(), SubscriberPid :: pid()) -> ok | {error, Reason :: binary()}.
subscribe(Topic, SubscriberPid) when is_binary(Topic), is_pid(SubscriberPid) ->
gen_server:call(?SERVER, {subscribe, Topic, SubscriberPid}).
-spec get_subscribers() -> {ok, Subscribers :: map()}.
get_subscribers() ->
gen_server:call(?SERVER, get_subscribers).
-spec publish(RouteKey :: binary(), Content :: binary()) -> no_return().
publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) ->
gen_server:cast(?SERVER, {publish, RouteKey, Content}).
%% @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, #state{}}.
%% @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{}}).
%% SubscriberPid只能订阅同一个topic一次
handle_call(get_subscribers, _From, State = #state{subscribers = Subscribers}) ->
{reply, {ok, Subscribers}, State};
handle_call({subscribe, Topic, SubscriberPid}, _From, State = #state{subscribers = Subscribers}) ->
Components = of_components(Topic),
case is_valid_components(Components) of
true ->
Sub = #subscriber{topic = Topic, subscriber_pid = SubscriberPid, components = Components, order = order_num(Components)},
%% SubscriberPid的monitor退
erlang:monitor(process, SubscriberPid),
{reply, ok, State#state{subscribers = Subscribers ++ [Sub]}};
false ->
{reply, {error, <<"invalid topic name">>}, State}
end.
%% @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({publish, RouteKey, Metric}, State = #state{subscribers = Subscribers}) ->
MatchedSubscribers = match_subscribers(Subscribers, RouteKey),
lists:foreach(fun(#subscriber{subscriber_pid = SubscriberPid}) ->
endpoint:forward(SubscriberPid, Metric)
end, MatchedSubscribers),
lager:debug("[efka_subscription] route_key: ~p, metric: ~p, match subscribers: ~p", [RouteKey, Metric, MatchedSubscribers]),
{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({'DOWN', _Ref, process, SubscriberPid, Reason}, State = #state{subscribers = Subscribers}) ->
lager:debug("[efka_subscription] subscriber: ~p, down with reason: ~p", [SubscriberPid, Reason]),
NSubscribers = lists:filter(fun(#subscriber{subscriber_pid = Pid0}) -> SubscriberPid /= Pid0 end, Subscribers),
{noreply, State#state{subscribers = NSubscribers}};
handle_info(Info, State = #state{}) ->
lager:debug("[efka_subscription] get unknown 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{}) ->
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 match_subscribers(Subscribers :: [#subscriber{}], Topic :: binary()) -> [#subscriber{}].
match_subscribers(Subscribers, Topic) when is_list(Subscribers), is_binary(Topic) ->
Components = of_components(Topic),
lists:foldl(fun(S = #subscriber{components = Components0, subscriber_pid = Pid0}, Acc) ->
case match_components(Components0, Components) andalso not contain_channel(Pid0, Acc) of
true ->
[S|Acc];
false ->
Acc
end
end, [], Subscribers).
-spec contain_channel(Pid :: pid(), Subscribers :: list()) -> boolean().
contain_channel(Pid, Subscribers) when is_pid(Pid), is_list(Subscribers) ->
lists:search(fun(#subscriber{subscriber_pid = Pid0}) -> Pid == Pid0 end, Subscribers) /= false.
%% topic和发布的topic的Components信息
%% *++
-spec match_components(list(), list()) -> boolean().
match_components(A, B) when is_list(A), is_list(B) ->
match_components(A, B, false).
match_components([<<"+">>], [_|_], _) ->
true;
match_components([], [], _) ->
true;
match_components([<<"*">>|T0], [_|T1], _) ->
match_components(T0, T1, false);
match_components([C0|T0], [C0|T1], _) ->
match_components(T0, T1, false);
match_components(_, _, _) ->
false.
-spec of_components(Topic :: binary()) -> [binary()].
of_components(Topic) when is_binary(Topic) ->
binary:split(Topic, <<$/>>, [global]).
is_valid_components([]) ->
true;
is_valid_components([<<$+>>|T]) ->
length(T) =:= 0;
is_valid_components([<<$*>>|T]) ->
is_valid_components(T);
is_valid_components([_|T]) ->
is_valid_components(T).
-spec order_num(Components :: list()) -> integer().
order_num([]) ->
1;
order_num([<<$*>>|_]) ->
2;
order_num([<<$+>>|_]) ->
3;
order_num([_|Tail]) ->
order_num(Tail).

View File

@ -1,128 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2024, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 07. 5 2024 10:30
%%%-------------------------------------------------------------------
-module(endpoint_timer).
-author("anlicheng").
-include("endpoint.hrl").
-behaviour(gen_server).
%% API
-export([start_link/1]).
-export([task/3, ack/2, cleanup/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
retry_interval = 0,
%%
timer_map = #{}
}).
%%%===================================================================
%%% API
%%%===================================================================
task(Pid, Id, Task) when is_pid(Pid), is_integer(Id), is_function(Task, 0) ->
gen_server:cast(Pid, {task, Id, Task}).
ack(Pid, Id) when is_pid(Pid), is_integer(Id) ->
gen_server:cast(Pid, {ack, Id}).
cleanup(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, cleanup).
%% @doc Spawns the server and registers the local name (unique)
-spec(start_link(RetryInterval :: integer()) ->
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
start_link(RetryInterval) when is_integer(RetryInterval) ->
gen_server:start_link(?MODULE, [RetryInterval], []).
%%%===================================================================
%%% 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([RetryInterval]) ->
{ok, #state{retry_interval = RetryInterval}}.
%% @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({task, Id, Task}, State = #state{retry_interval = RetryInterval, timer_map = TimerMap}) ->
TimerRef = erlang:start_timer(RetryInterval, self(), {repost_ticker, {Id, Task}}),
{noreply, State#state{timer_map = maps:put(Id, TimerRef, TimerMap)}};
%%
handle_cast({ack, Id}, State = #state{timer_map = TimerMap}) ->
case maps:take(Id, TimerMap) of
error ->
{noreply, State};
{TimerRef, NTimerMap} ->
is_reference(TimerRef) andalso erlang:cancel_timer(TimerRef),
{noreply, State#state{timer_map = NTimerMap}}
end;
handle_cast(cleanup, State = #state{timer_map = TimerMap}) ->
lists:foreach(fun({_, TimerRef}) -> catch erlang:cancel_timer(TimerRef) end, maps:to_list(TimerMap)),
{noreply, State#state{timer_map = #{}}}.
%% @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, _, {repost_ticker, {Id, Task}}}, State = #state{retry_interval = RetryInterval, timer_map = TimerMap}) ->
Task(),
TimerRef = erlang:start_timer(RetryInterval, self(), {repost_ticker, {Id, Task}}),
{noreply, State#state{timer_map = maps:put(Id, TimerRef, TimerMap)}}.
%% @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
%%%===================================================================

View File

@ -0,0 +1,147 @@
%%%-------------------------------------------------------------------
%%% @doc Owns one container deployment task stream.
%%% @end
%%%-------------------------------------------------------------------
-module(iot_container_task).
-behaviour(gen_server).
%% API
-export([start_link/2]).
-export([get_pid/2, subscribe/3, stream/3, close/2]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(MAX_EVENTS, 200).
-define(STOP_AFTER_CLOSE, 300000).
-record(state, {
uuid :: binary(),
task_id :: integer(),
status = pending :: pending | running | success | fail,
events = [] :: [{binary(), binary()}],
listeners = #{} :: #{pid() => reference()},
close_reason = undefined :: undefined | binary(),
stop_ref = undefined :: undefined | reference()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link(binary(), integer()) -> {ok, pid()} | ignore | {error, term()}.
start_link(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) ->
gen_server:start_link({via, gproc, {n, l, name(UUID, TaskId)}}, ?MODULE, [UUID, TaskId], []).
-spec get_pid(binary(), integer()) -> undefined | pid().
get_pid(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) ->
gproc:whereis_name({n, l, name(UUID, TaskId)}).
-spec subscribe(binary(), integer(), pid()) -> ok | {error, term()}.
subscribe(UUID, TaskId, ListenerPid) when is_binary(UUID), is_integer(TaskId), is_pid(ListenerPid) ->
case iot_container_task_sup:ensure_started(UUID, TaskId) of
{ok, Pid} ->
gen_server:call(Pid, {subscribe, ListenerPid});
{error, Reason} ->
{error, Reason}
end.
-spec stream(pid(), binary(), binary()) -> ok.
stream(Pid, Type, Stream) when is_pid(Pid), is_binary(Type), is_binary(Stream) ->
gen_server:cast(Pid, {stream, Type, Stream}).
-spec close(pid(), binary()) -> ok.
close(Pid, Reason) when is_pid(Pid), is_binary(Reason) ->
gen_server:cast(Pid, {close, Reason}).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
-spec init([binary() | integer()]) -> {ok, #state{}}.
init([UUID, TaskId]) ->
ok = iot_log:set_metadata(),
{ok, #state{uuid = UUID, task_id = TaskId}}.
-spec handle_call(term(), {pid(), term()}, #state{}) ->
{reply, term(), #state{}}.
handle_call({subscribe, ListenerPid}, _From, State0 = #state{task_id = TaskId, events = Events, listeners = Listeners, close_reason = CloseReason}) ->
MonRef = erlang:monitor(process, ListenerPid),
maps:get(ListenerPid, Listeners, undefined) =/= undefined andalso erlang:demonitor(maps:get(ListenerPid, Listeners), [flush]),
lists:foreach(fun({Type, Stream}) ->
ListenerPid ! {stream_data, TaskId, Type, Stream}
end, Events),
case CloseReason of
undefined ->
ok;
Reason ->
ListenerPid ! {stream_close, TaskId, Reason}
end,
{reply, ok, State0#state{listeners = maps:put(ListenerPid, MonRef, Listeners)}};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast({stream, Type, Stream}, State0 = #state{task_id = TaskId, events = Events0, listeners = Listeners}) ->
Event = {Type, Stream},
Events = trim_events(Events0 ++ [Event]),
maps:foreach(fun(ListenerPid, _MonRef) ->
ListenerPid ! {stream_data, TaskId, Type, Stream}
end, Listeners),
{noreply, State0#state{status = running, events = Events}};
handle_cast({close, Reason}, State0 = #state{task_id = TaskId, listeners = Listeners}) ->
maps:foreach(fun(ListenerPid, _MonRef) ->
ListenerPid ! {stream_close, TaskId, Reason}
end, Listeners),
Status = close_status(Reason),
{noreply, ensure_stop_timer(State0#state{status = Status, close_reason = Reason})};
handle_cast(_Request, State) ->
{noreply, State}.
-spec handle_info(term(), #state{}) -> {noreply, #state{}} | {stop, normal, #state{}}.
handle_info({'DOWN', _Ref, process, ListenerPid, _Reason}, State = #state{listeners = Listeners}) ->
{noreply, State#state{listeners = maps:remove(ListenerPid, Listeners)}};
handle_info(stop_after_close, State) ->
{stop, normal, State};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), #state{}) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), #state{}, term()) -> {ok, #state{}}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec name(binary(), integer()) -> term().
name(UUID, TaskId) ->
{iot_container_task, UUID, TaskId}.
-spec trim_events([{binary(), binary()}]) -> [{binary(), binary()}].
trim_events(Events) ->
Len = length(Events),
case Len > ?MAX_EVENTS of
true ->
lists:nthtail(Len - ?MAX_EVENTS, Events);
false ->
Events
end.
-spec close_status(binary()) -> success | fail.
close_status(<<"success">>) ->
success;
close_status(_) ->
fail.
-spec ensure_stop_timer(#state{}) -> #state{}.
ensure_stop_timer(State = #state{stop_ref = undefined}) ->
Ref = erlang:send_after(?STOP_AFTER_CLOSE, self(), stop_after_close),
State#state{stop_ref = Ref};
ensure_stop_timer(State) ->
State.

View File

@ -0,0 +1,100 @@
%%%-------------------------------------------------------------------
%%% @doc Dynamic supervisor for container deployment tasks.
%%% @end
%%%-------------------------------------------------------------------
-module(iot_container_task_sup).
-behaviour(supervisor).
%% API
-export([start_link/0]).
-export([ensure_started/2, stream/4, close/3, fail/3]).
%% supervisor callbacks
-export([init/1]).
-define(SERVER, ?MODULE).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
-spec ensure_started(binary(), integer()) -> {ok, pid()} | {error, term()}.
ensure_started(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) ->
case iot_container_task:get_pid(UUID, TaskId) of
undefined ->
case supervisor:start_child(?SERVER, [UUID, TaskId]) of
{ok, Pid} ->
{ok, Pid};
{ok, Pid, _Info} ->
{ok, Pid};
{error, {already_started, Pid}} ->
{ok, Pid};
{error, {shutdown, {failed_to_start_child, _Id, {already_started, Pid}}}} ->
{ok, Pid};
{error, Reason} ->
{error, Reason}
end;
Pid when is_pid(Pid) ->
{ok, Pid}
end.
-spec stream(binary(), integer(), binary(), binary()) -> ok.
stream(UUID, TaskId, Type, Stream)
when is_binary(UUID), is_integer(TaskId), is_binary(Type), is_binary(Stream) ->
case ensure_started(UUID, TaskId) of
{ok, Pid} ->
iot_container_task:stream(Pid, Type, Stream);
{error, Reason} ->
logger:warning("[iot_container_task_sup] start task failed, uuid: ~p, task_id: ~p, reason: ~p", [UUID, TaskId, Reason]),
ok
end.
-spec close(binary(), integer(), binary()) -> ok.
close(UUID, TaskId, Reason) when is_binary(UUID), is_integer(TaskId), is_binary(Reason) ->
case ensure_started(UUID, TaskId) of
{ok, Pid} ->
iot_container_task:close(Pid, Reason);
{error, StartReason} ->
logger:warning("[iot_container_task_sup] start task before close failed, uuid: ~p, task_id: ~p, reason: ~p", [UUID, TaskId, StartReason]),
ok
end.
-spec fail(binary(), integer(), term()) -> ok.
fail(UUID, TaskId, Reason) when is_binary(UUID), is_integer(TaskId) ->
ReasonBin = reason_to_binary(Reason),
ok = stream(UUID, TaskId, <<"error">>, ReasonBin),
close(UUID, TaskId, <<"fail">>).
%%%===================================================================
%%% supervisor callbacks
%%%===================================================================
-spec init([]) -> {ok, {{simple_one_for_one, non_neg_integer(), pos_integer()}, [supervisor:child_spec()]}}.
init([]) ->
SupFlags = {simple_one_for_one, 10, 60},
ChildSpec = #{
id => iot_container_task,
start => {iot_container_task, start_link, []},
restart => temporary,
shutdown => 5000,
type => worker,
modules => [iot_container_task]
},
{ok, {SupFlags, [ChildSpec]}}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec reason_to_binary(term()) -> binary().
reason_to_binary(Reason) when is_binary(Reason) ->
Reason;
reason_to_binary(timeout) ->
<<"timeout">>;
reason_to_binary(Reason) ->
unicode:characters_to_binary(io_lib:format("~p", [Reason])).

View File

@ -0,0 +1,458 @@
%%%-------------------------------------------------------------------
%%% @author
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 22. 9 2023 16:38
%%%-------------------------------------------------------------------
-module(iot_host).
-author("aresei").
-include("iot.hrl").
-include("domain_model.hrl").
-behaviour(gen_statem).
%% , 15
-define(HEARTBEAT_INTERVAL, 900 * 1000).
%%
-define(STATE_DENIED, denied).
-define(STATE_ACTIVATED, activated).
%% API
-export([start_link/2, get_name/1, get_alias_name/1, get_pid/1, handle/2, activate/2]).
-export([lookup_pid/1]).
-export([get_metric/1, get_status/1, kill/1]).
%%
-export([pub/4, attach_channel/2]).
-export([deploy_container/3, start_container/2, stop_container/2, remove_container/2, kill_container/2, config_container/3, get_containers/1, await_reply/3]).
-export([heartbeat/1]).
%% gen_statem callbacks
-export([init/1, handle_event/4, terminate/3, code_change/4, callback_mode/0]).
-record(state, {
host_id :: integer(),
%%
uuid :: binary(),
%%
host_status :: integer(),
has_session = false :: boolean(),
%%
heartbeat_counter = 0 :: integer(),
%% websocket相关
channel_pid :: undefined | pid(),
%%
metrics = #{} :: map()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec get_pid(UUID :: binary()) -> undefined | pid().
get_pid(UUID) when is_binary(UUID) ->
Name = get_name(UUID),
gproc:whereis_name({n, l, Name}).
-spec lookup_pid(UUID :: binary()) -> {ok, pid()} | {error, Reason :: any()}.
lookup_pid(UUID) when is_binary(UUID) ->
case get_pid(UUID) of
undefined ->
{error, <<"host not found">>};
HostPid ->
{ok, HostPid}
end.
-spec get_name(UUID :: binary()) -> term().
get_name(UUID) when is_binary(UUID) ->
{iot_host, UUID}.
-spec get_alias_name(HostId :: integer()) -> term().
get_alias_name(HostId0) when is_integer(HostId0) ->
{iot_host_id, HostId0}.
-spec kill(UUID :: binary()) -> ok | true.
kill(UUID) when is_binary(UUID) ->
case get_pid(UUID) of
undefined ->
ok;
Pid ->
exit(Pid, kill)
end.
%%
-spec handle(Pid :: pid(), Packet :: tuple()) -> ok.
handle(Pid, Packet) when is_pid(Pid) ->
gen_statem:cast(Pid, {handle, Packet}).
-spec get_status(Pid :: pid()) -> {ok, Status :: map()}.
get_status(Pid) when is_pid(Pid) ->
gen_statem:call(Pid, get_status).
%% , true false表示关闭激活
-spec activate(Pid :: pid(), Auth :: boolean()) -> ok | {error, term()}.
activate(Pid, Auth) when is_pid(Pid), is_boolean(Auth) ->
gen_statem:call(Pid, {activate, Auth}).
-spec get_metric(Pid :: pid()) -> {ok, MetricInfo :: map()}.
get_metric(Pid) when is_pid(Pid) ->
gen_statem:call(Pid, get_metric).
-spec attach_channel(pid(), pid()) -> ok | {error, Reason :: binary()}.
attach_channel(Pid, ChannelPid) when is_pid(Pid), is_pid(ChannelPid) ->
gen_statem:call(Pid, {attach_channel, ChannelPid}).
-type request_ref() :: binary().
-spec get_containers(Pid :: pid()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
get_containers(Pid) when is_pid(Pid) ->
container_call(Pid, docker_container_builder:list_request()).
-spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) ->
container_call(Pid, docker_container_builder:config_request(ContainerName, ConfigJson)).
-spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) ->
case docker_container_builder:deploy_request(TaskId, Config) of
{ok, Request} ->
container_call(Pid, Request);
{error, Reason} ->
{error, Reason}
end.
-spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
container_call(Pid, docker_container_builder:start_request(ContainerName)).
-spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
container_call(Pid, docker_container_builder:stop_request(ContainerName)).
-spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
container_call(Pid, docker_container_builder:kill_request(ContainerName)).
-spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}.
remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
container_call(Pid, docker_container_builder:remove_request(ContainerName)).
-spec await_reply(Pid :: pid(), Ref :: request_ref(), Timeout :: integer()) ->
ok | {ok, Result :: term()} | {error, Reason :: term()}.
await_reply(Pid, Ref, Timeout) when is_pid(Pid), is_binary(Ref), is_integer(Timeout) ->
receive
{command_reply, Ref, ok} ->
ok;
{command_reply, Ref, {ok, Result}} ->
{ok, Result};
{command_reply, Ref, {error, Reason}} ->
{error, Reason}
after Timeout ->
ok = gen_statem:call(Pid, {cancel_command_call, Ref}),
flush_reply(Ref),
{error, timeout}
end.
-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok | {error, Reason :: any()}.
pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) ->
gen_statem:call(Pid, {pub, Topic, Qos, Content}).
-spec heartbeat(Pid :: undefined | pid()) -> ok.
heartbeat(undefined) ->
ok;
heartbeat(Pid) when is_pid(Pid) ->
gen_statem:cast(Pid, heartbeat).
%% @doc Creates a gen_statem process which calls Module:init/1 to
%% initialize. To ensure a synchronized start-up procedure, this
%% function does not return until Module:init/1 has returned.
start_link(Name, UUID) when is_binary(UUID) ->
gen_statem:start_link({via, gproc, {n, l, Name}}, ?MODULE, [UUID], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
%% @private
%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or
%% gen_statem:start_link/[3,4], this function is called by the new
%% process to initialize.
init([UUID]) ->
ok = iot_log:set_metadata(),
case iot_api_client:get_host_by_uuid(UUID) of
{ok, #host_info{id = HostId, authorize_status = AuthorizeStatus, status = HostStatus}} ->
%% host_id注册别名, HostPid
AliasName = get_alias_name(HostId),
global:register_name(AliasName, self()),
%%
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
StateName = case AuthorizeStatus =:= ?HOST_AUTHORIZED of
true -> ?STATE_ACTIVATED;
false -> ?STATE_DENIED
end,
{ok, StateName, #state{host_id = HostId, uuid = UUID, host_status = HostStatus, has_session = false}};
undefined ->
logger:warning("[iot_host] host uuid: ~p, load host info failed", [UUID]),
ignore
end.
%% @private
%% @doc This function is called by a gen_statem when it needs to find out
%% the callback mode of the callback module.
callback_mode() ->
handle_event_function.
%% @private
%% @doc If callback_mode is handle_event_function, then whenever a
%% gen_statem receives an event from call/2, cast/2, or as a normal
%% process message, this function is called.
handle_event({call, From}, get_metric, _, State = #state{metrics = Metrics}) ->
{keep_state, State, [{reply, From, {ok, Metrics}}]};
%%
handle_event({call, From}, get_status, _, State = #state{channel_pid = ChannelPid, heartbeat_counter = HeartbeatCounter, metrics = Metrics, has_session = HasSession, host_status = HostStatus}) ->
HasChannel = (ChannelPid /= undefined),
Reply = #{
<<"has_channel">> => HasChannel,
<<"has_session">> => HasSession,
<<"host_status">> => HostStatus,
<<"heartbeat_counter">> => HeartbeatCounter,
<<"metrics">> => Metrics
},
{keep_state, State, [{reply, From, {ok, Reply}}]};
handle_event({call, From}, {container_call, ReceiverPid, Request}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
case HasSession andalso is_pid(ChannelPid) of
true ->
Ref = request_ref(),
ok = ssl_channel:container_call(ChannelPid, ReceiverPid, Ref, Request),
{keep_state, State, [{reply, From, {ok, Ref}}]};
false ->
logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
{keep_state, State, [{reply, From, {error, <<"主机离线,发送命令失败"/utf8>>}}]}
end;
handle_event({call, From}, {cancel_command_call, Ref}, _, State = #state{channel_pid = ChannelPid}) ->
case is_pid(ChannelPid) of
true ->
ok = ssl_channel:cancel_command_call(ChannelPid, Ref),
{keep_state, State, [{reply, From, ok}]};
false ->
{keep_state, State, [{reply, From, ok}]}
end;
%% , pub/sub
handle_event({call, From}, {pub, Topic, Qos, Content}, StateName, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
case StateName of
?STATE_ACTIVATED ->
case HasSession andalso is_pid(ChannelPid) of
true ->
logger:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]),
%% websocket发送消息
ssl_channel:pub(ChannelPid, Topic, Qos, Content),
{keep_state, State, [{reply, From, ok}]};
false ->
logger:debug("[iot_host] uuid: ~p, publish to topic: ~p, content: ~p, invalid state: ~p", [UUID, Topic, Content, state_map(State)]),
{keep_state, State, [{reply, From, {error, <<"主机离线,发送失败"/utf8>>}}]}
end;
?STATE_DENIED ->
{keep_state, State, [{reply, From, {error, <<"主机未激活,发送失败"/utf8>>}}]}
end;
%% / iot efka 线 host
handle_event({call, From}, {activate, Auth}, _, State = #state{uuid = UUID}) ->
NStateName = case Auth of
true ->
?STATE_ACTIVATED;
false ->
?STATE_DENIED
end,
logger:debug("[iot_host] uuid: ~p, state_name change to ~p", [UUID, NStateName]),
{next_state, NStateName, State, [{reply, From, ok}]};
%% channel
handle_event({call, From}, {attach_channel, ChannelPid}, StateName, State = #state{uuid = UUID, channel_pid = OldChannelPid, host_status = HostStatus}) ->
case OldChannelPid == undefined orelse OldChannelPid =:= ChannelPid of
true ->
case StateName of
?STATE_ACTIVATED ->
erlang:monitor(process, ChannelPid),
%% 线
NState = mark_host_online(UUID, HostStatus, State#state{channel_pid = ChannelPid, has_session = true}),
{keep_state, NState, [{reply, From, ok}]};
%%
?STATE_DENIED ->
logger:notice("[iot_host] attach_channel host_id uuid: ~p, channel: ~p, host denied locally", [UUID, ChannelPid]),
erlang:monitor(process, ChannelPid),
NState = mark_host_online(UUID, HostStatus, State#state{channel_pid = ChannelPid, has_session = true}),
{keep_state, NState, [{reply, From, ok}]}
end;
false ->
logger:notice("[iot_host] attach_channel host_id uuid: ~p, old channel exists: ~p", [UUID, OldChannelPid]),
{keep_state, State, [{reply, From, {error, <<"channel existed">>}}]}
end;
%%
handle_event(cast, {handle, {data, RouteKey, MetricBin}}, ?STATE_ACTIVATED,
State = #state{uuid = UUID, has_session = true}) ->
logger:debug("[iot_host] metric_data host: ~p, route_key: ~p, metric: ~p", [UUID, RouteKey, MetricBin]),
endpoint_subscription:publish(get_route_key(RouteKey), MetricBin),
{keep_state, State};
handle_event(cast, {handle, {data, RouteKey, MetricBin}}, StateName,
State = #state{uuid = UUID, has_session = true}) ->
logger:notice("[iot_host] host_uuid: ~p, state_name: ~p, metric_data route_key: ~p, metric: ~p, discard", [UUID, StateName, RouteKey, MetricBin]),
{keep_state, State};
%% ping的数据是通过aes加密后的
handle_event(cast, {handle, {ping, Metrics}}, ?STATE_ACTIVATED, State = #state{uuid = UUID, has_session = true}) ->
logger:debug("[iot_host] ping host_id uuid: ~p, get ping: ~p", [UUID, Metrics]),
{keep_state, State#state{metrics = Metrics}};
%%
handle_event(cast, heartbeat, _, State = #state{uuid = UUID, heartbeat_counter = HeartbeatCounter, host_status = HostStatus}) ->
NState = mark_host_online(UUID, HostStatus, State#state{heartbeat_counter = HeartbeatCounter + 1}),
{keep_state, NState};
%% UDP SSL channel host 线
handle_event(info, {timeout, _, heartbeat_ticker}, _,
State = #state{uuid = UUID, heartbeat_counter = 0, host_status = HostStatus, channel_pid = ChannelPid}) when is_pid(ChannelPid) ->
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
logger:warning("[iot_host] uuid: ~p, udp heartbeat lost but ssl channel is alive: ~p", [UUID, ChannelPid]),
NState = mark_host_online(UUID, HostStatus, State#state{heartbeat_counter = 0}),
{keep_state, NState};
%% UDP SSL channel线,
handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{uuid = UUID, heartbeat_counter = 0, host_status = HostStatus}) ->
logger:warning("[iot_host] uuid: ~p, heartbeat lost, devices will unknown", [UUID]),
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
NState = mark_host_offline(UUID, HostStatus, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}),
{keep_state, NState};
%%
handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{}) ->
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
{keep_state, State#state{heartbeat_counter = 0}};
%% websocket断开的时候
handle_event(info, {'DOWN', _Ref, process, ChannelPid, Reason}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
logger:warning("[iot_host] uuid: ~p, channel: ~p, down with reason: ~p, has_session: ~p, state: ~p", [UUID, ChannelPid, Reason, HasSession, State]),
{keep_state, State#state{channel_pid = undefined, has_session = false}};
handle_event(info, {'DOWN', _Ref, process, Pid, Reason}, _, State = #state{uuid = UUID}) ->
logger:debug("[iot_host] uuid: ~p, process_pid: ~p, down with reason: ~p, state: ~p", [UUID, Pid, Reason, State]),
{keep_state, State};
handle_event(Event, Info, StateName, State = #state{uuid = UUID}) ->
logger:warning("[iot_host] host: ~p, event: ~p, unknown message: ~p, state_name: ~p, state: ~p", [UUID, Event, Info, StateName, state_map(State)]),
{keep_state, State}.
%% @private
%% @doc This function is called by a gen_statem 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_statem terminates with
%% Reason. The return value is ignored.
terminate(Reason, _StateName, _State = #state{uuid = UUID, has_session = HasSession}) ->
logger:debug("[iot_host] host: ~p, terminate with reason: ~p, has_session: ~p", [UUID, Reason, HasSession]),
ok.
%% @private
%% @doc Convert process state when code is changed
code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
{ok, StateName, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec container_call(Pid :: pid(), Request :: term()) ->
{ok, Ref :: request_ref()} | {error, Reason :: any()}.
container_call(Pid, Request) when is_pid(Pid) ->
gen_statem:call(Pid, {container_call, self(), Request}).
-spec get_route_key(binary()) -> binary().
get_route_key(<<"">>) ->
<<"/">>;
get_route_key(RouteKey) when is_binary(RouteKey) ->
RouteKey.
%% state转换成map
state_map(#state{host_id = HostId, uuid = UUID, host_status = HostStatus, has_session = HasSession, heartbeat_counter = HeartbeatCounter, channel_pid = ChannelPid, metrics = Metrics}) ->
#{
host_id => HostId,
uuid => UUID,
host_status => HostStatus,
has_session => HasSession,
heartbeat_counter => HeartbeatCounter,
channel_pid => ChannelPid,
metrics => Metrics
}.
flush_reply(Ref) ->
receive
{command_reply, Ref, _Reply} ->
ok
after 0 ->
ok
end.
-spec request_ref() -> request_ref().
request_ref() ->
crypto:strong_rand_bytes(16).
-spec mark_host_online(binary(), integer(), #state{}) -> #state{}.
mark_host_online(UUID, HostStatus, State) ->
case maybe_mark_host_online(UUID, HostStatus) of
keep_status ->
State;
{next_status, NStatus} ->
State#state{host_status = NStatus}
end.
-spec mark_host_offline(binary(), integer(), #state{}) -> #state{}.
mark_host_offline(UUID, HostStatus, State) ->
case maybe_mark_host_offline(UUID, HostStatus) of
keep_status ->
State;
{next_status, NStatus} ->
State#state{host_status = NStatus}
end.
-spec maybe_mark_host_online(binary(), integer()) -> {next_status, NStatus :: integer()} | keep_status.
maybe_mark_host_online(UUID, HostStatus) ->
case HostStatus of
?HOST_OFFLINE ->
case iot_api_client:change_host_status(UUID, ?HOST_ONLINE) of
{ok, Result} ->
logger:debug("[iot_host] uuid: ~p, change host status online success: ~p", [UUID, Result]),
{next_status, ?HOST_ONLINE};
{error, Reason} ->
logger:warning("[iot_host] uuid: ~p, change host status online failed: ~p", [UUID, Reason]),
keep_status
end;
_ ->
keep_status
end.
-spec maybe_mark_host_offline(binary(), integer()) -> {next_status, NStatus :: integer()} | keep_status.
maybe_mark_host_offline(UUID, HostStatus) ->
case HostStatus of
?HOST_ONLINE ->
case iot_api_client:change_host_status(UUID, ?HOST_OFFLINE) of
{ok, Result} ->
logger:debug("[iot_host] uuid: ~p, change host status offline success: ~p", [UUID, Result]),
{next_status, ?HOST_OFFLINE};
{error, Reason} ->
logger:warning("[iot_host] uuid: ~p, change host status offline failed: ~p", [UUID, Reason]),
keep_status
end;
_ ->
keep_status
end.

View File

@ -0,0 +1,85 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%% @end
%%%-------------------------------------------------------------------
-module(iot_host_sup).
-include("iot.hrl").
-behaviour(supervisor).
-export([start_link/0, init/1, delete_host/1, ensured_host_started/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
Specs = lists:map(fun child_spec/1, iot_api_client:get_all_hosts()),
{ok, {#{strategy => one_for_one, intensity => 1000, period => 3600}, Specs}}.
-spec ensured_host_started(UUID :: binary()) -> {ok, Pid :: pid()} | {error, Reason :: any()}.
ensured_host_started(UUID) when is_binary(UUID) ->
case iot_host:get_pid(UUID) of
undefined ->
%% host对应的信息
case delete_host(UUID) of
ok ->
case supervisor:start_child(?MODULE, child_spec(UUID)) of
{ok, Pid} when is_pid(Pid) ->
{ok, Pid};
{error, {'already_started', Pid}} when is_pid(Pid) ->
{ok, Pid};
{error, Error} ->
{error, Error}
end;
{error, Reason} ->
{error, Reason}
end;
Pid when is_pid(Pid) ->
{ok, Pid}
end.
delete_host(UUID) when is_binary(UUID) ->
Id = iot_host:get_name(UUID),
case supervisor:terminate_child(?MODULE, Id) of
ok ->
delete_host_child(Id, UUID);
{error, not_found} ->
delete_host_child(Id, UUID);
{error, Reason} ->
logger:warning("[iot_host_sup] terminate host: ~p failed: ~p", [UUID, Reason]),
{error, Reason}
end.
delete_host_child(Id, UUID) ->
case supervisor:delete_child(?MODULE, Id) of
ok ->
ok;
{error, not_found} ->
ok;
{error, running} ->
%% ensure killed then delete again
iot_host:kill(UUID),
case supervisor:delete_child(?MODULE, Id) of
ok ->
ok;
{error, not_found} ->
ok;
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
-spec child_spec(UUID :: binary()) -> map().
child_spec(UUID) when is_binary(UUID) ->
Id = iot_host:get_name(UUID),
#{id => Id,
start => {iot_host, start_link, [Id, UUID]},
restart => permanent,
shutdown => 2000,
type => worker,
modules => ['iot_host']}.

View File

@ -0,0 +1,239 @@
%%%-------------------------------------------------------------------
%%% @author licheng5
%%% @copyright (C) 2020, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 26. 4 2020 3:36
%%%-------------------------------------------------------------------
-module(container_handler).
-author("licheng5").
-include("iot.hrl").
-define(REQ_TIMEOUT, 10000).
%% API
-export([handle_request/4]).
handle_request("GET", "/container/get_all", #{<<"uuid">> := UUID}, _) when is_binary(UUID) ->
%% ConfigJson是否是合法的json字符串
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(-1, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:get_containers(Pid) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(-1, Reason)}
end
end;
%% config.json,
handle_request("POST", "/container/push_config", _,
#{<<"uuid">> := UUID, <<"container_name">> := ContainerName, <<"config">> := Config, <<"timeout">> := Timeout0})
when is_binary(UUID), is_binary(ContainerName), is_binary(Config), is_integer(Timeout0) ->
%% ConfigJson是否是合法的json字符串
true = iot_util:is_json(Config),
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(-1, <<"host not found">>)};
Pid when is_pid(Pid) ->
Timeout = Timeout0 * 1000,
case iot_host:config_container(Pid, ContainerName, Config) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, Timeout) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(-1, Reason)}
end
end;
%%
handle_request("POST", "/container/deploy", _, #{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config})
when is_binary(UUID), is_integer(TaskId), is_map(Config) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_container_task_sup:ensure_started(UUID, TaskId) of
{ok, _TaskPid} ->
handle_deploy_container(Pid, UUID, TaskId, Config);
{error, Reason} ->
{ok, 500, iot_util:json_error(500, reason_to_binary(Reason))}
end
end;
%%
handle_request("POST", "/container/start", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:start_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
%%
handle_request("POST", "/container/stop", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:stop_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
handle_request("POST", "/container/kill", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:kill_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
%%
handle_request("POST", "/container/remove", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:remove_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
handle_request(_, Path, _, _) ->
Path1 = list_to_binary(Path),
{ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}.
request_success_response(Result) when is_binary(Result) ->
case decode_json_bytes(Result) of
{ok, Data} ->
iot_util:json_data(Data);
error ->
iot_util:json_data(Result)
end;
request_success_response(Result) ->
iot_util:json_data(Result).
request_error_response(Code, Reason) when is_integer(Code), is_binary(Reason) ->
case decode_json_bytes(Reason) of
{ok, #{<<"message">> := Message}} when is_binary(Message) ->
iot_util:json_error(Code, Message);
{ok, Message} when is_binary(Message) ->
iot_util:json_error(Code, Message);
_ ->
iot_util:json_error(Code, Reason)
end.
-spec request_error_http_response(Reason :: term()) ->
{ok, HttpStatus :: 400 | 504, Body :: binary()}.
request_error_http_response(Reason) ->
HttpStatus = request_error_status(Reason),
{ok, HttpStatus, request_error_response(HttpStatus, reason_to_binary(Reason))}.
-spec request_error_status(Reason :: term()) -> integer().
request_error_status(timeout) ->
504;
request_error_status(<<"timeout">>) ->
504;
request_error_status(_) ->
400.
-spec reason_to_binary(term()) -> binary().
reason_to_binary(Reason) when is_binary(Reason) ->
Reason;
reason_to_binary(timeout) ->
<<"timeout">>;
reason_to_binary(invalid_response) ->
<<"invalid response">>;
reason_to_binary(Reason) ->
unicode:characters_to_binary(io_lib:format("~p", [Reason])).
decode_json_bytes(Data) when is_binary(Data) ->
case catch json:decode(Data) of
{'EXIT', _} ->
error;
{error, _} ->
error;
Decoded ->
{ok, Decoded}
end.
-spec handle_deploy_container(pid(), binary(), integer(), map()) ->
{ok, 200 | 400 | 504, binary()}.
handle_deploy_container(Pid, UUID, TaskId, Config) ->
case iot_host:deploy_container(Pid, TaskId, Config) of
{ok, Ref} ->
case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of
ok ->
{ok, 200, request_success_response(<<"ok">>)};
{ok, Result} ->
{ok, 200, request_success_response(Result)};
{error, Reason} ->
ok = iot_container_task_sup:fail(UUID, TaskId, Reason),
request_error_http_response(Reason)
end;
{error, Reason} when is_binary(Reason) ->
ok = iot_container_task_sup:fail(UUID, TaskId, Reason),
{ok, 200, iot_util:json_error(400, Reason)}
end.

View File

@ -8,7 +8,7 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(endpoint_handler). -module(endpoint_handler).
-author("licheng5"). -author("licheng5").
-include("endpoint.hrl"). -include_lib("endpoint/include/endpoint.hrl").
%% API %% API
-export([handle_request/4]). -export([handle_request/4]).
@ -30,17 +30,17 @@ handle_request("POST", "/endpoint/run_statuses", _, Ids) when is_list(Ids) ->
{ok, 200, iot_util:json_data(Statuses)}; {ok, 200, iot_util:json_data(Statuses)};
handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(Id) -> handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(Id) ->
case iot_api:get_endpoint(Id) of case iot_api_client:get_endpoint(Id) of
undefined -> undefined ->
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
{ok, EndpointInfo} -> {ok, EndpointInfo} ->
case endpoint:endpoint_record(EndpointInfo) of case endpoint:endpoint_record(EndpointInfo) of
{ok, Endpoint = #endpoint{title = Title}} -> {ok, Endpoint = #endpoint{title = Title}} ->
case endpoint_sup:ensured_endpoint_started(Endpoint) of case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid} when is_pid(Pid) ->
{ok, 200, iot_util:json_data(<<"success">>)}; {ok, 200, iot_util:json_data(<<"success">>)};
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]),
{ok, 200, iot_util:json_error(404, <<"start endpoint error">>)} {ok, 200, iot_util:json_error(404, <<"start endpoint error">>)}
end; end;
error -> error ->
@ -49,21 +49,21 @@ handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(
end; end;
handle_request("POST", "/endpoint/stop", _, #{<<"id">> := Id}) when is_integer(Id) -> handle_request("POST", "/endpoint/stop", _, #{<<"id">> := Id}) when is_integer(Id) ->
case iot_api:get_endpoint(Id) of case iot_api_client:get_endpoint(Id) of
undefined -> undefined ->
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
{ok, _} -> {ok, _} ->
case endpoint_sup:delete_endpoint(Id) of case endpoint_adapter_sup:delete_endpoint(Id) of
ok -> ok ->
{ok, 200, iot_util:json_data(<<"success">>)}; {ok, 200, iot_util:json_data(<<"success">>)};
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_handler] stop endpoint id: ~p, get error: ~p", [Id, Reason]), logger:warning("[endpoint_handler] stop endpoint id: ~p, get error: ~p", [Id, Reason]),
{ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)} {ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)}
end end
end; end;
handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_integer(Id) -> handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_integer(Id) ->
case iot_api:get_endpoint(Id) of case iot_api_client:get_endpoint(Id) of
undefined -> undefined ->
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
{ok, EndpointInfo} -> {ok, EndpointInfo} ->
@ -71,25 +71,25 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege
{ok, Endpoint = #endpoint{title = Title}} -> {ok, Endpoint = #endpoint{title = Title}} ->
case endpoint:get_pid(Id) of case endpoint:get_pid(Id) of
undefined -> undefined ->
case endpoint_sup:ensured_endpoint_started(Endpoint) of case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid} when is_pid(Pid) ->
{ok, 200, iot_util:json_data(<<"success">>)}; {ok, 200, iot_util:json_data(<<"success">>)};
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]),
{ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)} {ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)}
end; end;
Pid when is_pid(Pid) -> Pid when is_pid(Pid) ->
case endpoint_sup:delete_endpoint(Id) of case endpoint_adapter_sup:delete_endpoint(Id) of
ok -> ok ->
case endpoint_sup:ensured_endpoint_started(Endpoint) of case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of
{ok, Pid0} when is_pid(Pid0) -> {ok, Pid0} when is_pid(Pid0) ->
{ok, 200, iot_util:json_data(<<"success">>)}; {ok, 200, iot_util:json_data(<<"success">>)};
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]),
{ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)} {ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)}
end; end;
{error, Reason} -> {error, Reason} ->
lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]),
{ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)} {ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)}
end end
end; end;
@ -100,57 +100,23 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege
%% http接口的测试 %% http接口的测试
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"http">>, <<"config">> := #{<<"url">> := Url, <<"pool_size">> := PoolSize}}) when is_integer(PoolSize), PoolSize > 0 -> handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"http">>, <<"config">> := #{<<"url">> := Url, <<"pool_size">> := PoolSize}}) when is_integer(PoolSize), PoolSize > 0 ->
Body = <<"">>, case endpoint_tester:test(#http_endpoint{url = Url, pool_size = PoolSize}) of
ContentType = "application/json", ok ->
case httpc:request(post, {Url, [], ContentType, Body}, [], []) of
{ok, _} ->
{ok, 200, iot_util:json_data(<<"ok">>)}; {ok, 200, iot_util:json_data(<<"ok">>)};
{error, Reason} -> {error, Reason} ->
lager:debug("[endpint_handler] test http: ~p, error: ~p", [Url, Reason]), logger:debug("[endpint_handler] test http: ~p, error: ~p", [Url, Reason]),
{ok, 200, iot_util:json_error(-1, <<"url failed">>)} {ok, 200, iot_util:json_error(-1, <<"url failed">>)}
end; end;
%% mqtt %% mqtt
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"config">> := Config}) -> handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"config">> := Config}) ->
case endpoint:parse_config(<<"mqtt">>, Config) of case endpoint:parse_config(<<"mqtt">>, Config) of
{ok, #mqtt_endpoint{host = Host, port = Port, username = Username, password = Password}} -> {ok, MqttEndpoint = #mqtt_endpoint{}} ->
%% client case endpoint_tester:test(MqttEndpoint) of
ClientId = "mqtt_client_test:" ++ iot_util:rand_bytes(16), ok ->
Opts = [ {ok, 200, iot_util:json_data(<<"ok">>)};
{owner, self()}, {error, Reason} ->
{clientid, ClientId}, {ok, 200, iot_util:json_error(-1, Reason)}
{host, binary_to_list(Host)},
{port, Port},
{tcp_opts, []},
{username, binary_to_list(Username)},
{password, binary_to_list(Password)},
{keepalive, 86400},
{auto_ack, true},
{connect_timeout, 5000},
{proto_ver, v5},
{retry_interval, 5000}
],
case emqtt:start_link(Opts) of
{ok, ConnPid} ->
lager:debug("[endpint_handler] start connect, options: ~p", [Opts]),
case catch emqtt:connect(ConnPid, 5000) of
{ok, _} ->
lager:debug("[endpint_handler] connect success, pid: ~p", [ConnPid]),
emqtt:stop(ConnPid),
{ok, 200, iot_util:json_data(<<"ok">>)};
{error, Reason} ->
lager:warning("[endpint_handler] connect get error: ~p", [Reason]),
emqtt:stop(ConnPid),
{ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)};
Error ->
lager:warning("[endpint_handler] connect get error: ~p", [Error]),
emqtt:stop(ConnPid),
{ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)}
end;
Other ->
lager:warning("[endpint_handler] test connect mqtt with options: ~p, get error: ~p", [Opts, Other]),
{ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)}
end; end;
{error, Errors} -> {error, Errors} ->
{ok, 200, iot_util:json_error(-1, Errors)} {ok, 200, iot_util:json_error(-1, Errors)}
@ -159,33 +125,12 @@ handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"c
%% kafka %% kafka
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"config">> := Config}) -> handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"config">> := Config}) ->
case endpoint:parse_config(<<"kafka">>, Config) of case endpoint:parse_config(<<"kafka">>, Config) of
{ok, #kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}} -> {ok, KafkaEndpoint = #kafka_endpoint{}} ->
BaseConfig = [ case endpoint_tester:test(KafkaEndpoint) of
{reconnect_cool_down_seconds, 5}, ok ->
{socket_options, [{keepalive, true}]} {ok, 200, iot_util:json_data(<<"ok">>)};
], {error, Reason} ->
{ok, 200, iot_util:json_error(-1, Reason)}
ClientConfig = case SaslConfig of
{Mechanism, Username, Password} ->
[{sasl, {Mechanism, Username, Password}}|BaseConfig];
undefined ->
BaseConfig
end,
ClientId = list_to_atom("brod_client_test:" ++ iot_util:rand_bytes(16)),
case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of
{ok, _ClientPid} ->
case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of
ok ->
ok = brod:stop_client(ClientId),
{ok, 200, iot_util:json_data(<<"ok">>)};
{error, Reason} ->
lager:debug("[endpint_handler] start_producer: ~p, get error: ~p", [ClientId, Reason]),
{ok, 200, iot_util:json_error(-1, <<"config kafka server failed">>)}
end;
Error ->
lager:debug("[endpint_handler] start_client: ~p, get error: ~p", [ClientId, Error]),
{ok, 200, iot_util:json_error(-1, <<"config kafka server failed">>)}
end; end;
{error, Errors} -> {error, Errors} ->
{ok, 200, iot_util:json_error(-1, Errors)} {ok, 200, iot_util:json_error(-1, Errors)}
@ -195,7 +140,7 @@ handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"
handle_request("POST", "/endpoint/publish_metric", _, #{<<"route_key">> := RouteKey, <<"metric">> := Metric0}) when is_binary(RouteKey) -> handle_request("POST", "/endpoint/publish_metric", _, #{<<"route_key">> := RouteKey, <<"metric">> := Metric0}) when is_binary(RouteKey) ->
if if
is_map(Metric0) orelse is_list(Metric0) -> is_map(Metric0) orelse is_list(Metric0) ->
Metric = jiffy:encode(Metric0, [force_utf8]), Metric = iolist_to_binary(json:encode(Metric0)),
endpoint_subscription:publish(RouteKey, Metric), endpoint_subscription:publish(RouteKey, Metric),
{ok, 200, iot_util:json_data(<<"ok">>)}; {ok, 200, iot_util:json_data(<<"ok">>)};
is_binary(Metric0) -> is_binary(Metric0) ->

View File

@ -0,0 +1,73 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 08. 5 2025 13:00
%%%-------------------------------------------------------------------
-module(event_stream_handler).
-author("anlicheng").
%% API
-export([init/2]).
init(Req0, Opts) ->
ok = iot_log:set_metadata(),
Method = binary_to_list(cowboy_req:method(Req0)),
Path = binary_to_list(cowboy_req:path(Req0)),
GetParams0 = cowboy_req:parse_qs(Req0),
GetParams = maps:from_list(GetParams0),
logger:debug("method: ~p, path: ~p, get: ~p", [Method, Path, GetParams]),
case parse_stream_params(GetParams) of
{ok, UUID, TaskId} ->
Req1 = cowboy_req:stream_reply(200, #{
<<"Content-Type">> => <<"text/event-stream">>,
<<"Cache-Control">> => <<"no-cache">>,
<<"Connection">> => <<"keep-alive">>
}, Req0),
ok = iot_container_task:subscribe(UUID, TaskId, self()),
receiver_events(TaskId, Req1),
{ok, Req1, Opts};
{error, Reason} ->
Req1 = cowboy_req:reply(400, #{
<<"Content-Type">> => <<"application/json">>
}, iot_util:json_error(400, Reason), Req0),
{ok, Req1, Opts}
end.
receiver_events(TaskId, Req) ->
receive
{stream_data, TaskId, Type, Stream} ->
Data = iolist_to_binary(json:encode(#{<<"type">> => Type, <<"stream">> => Stream})),
Body = iolist_to_binary([<<"event: message\n">>, <<"data: ", Data/binary, "\n">>, <<"\n">>]),
ok = cowboy_req:stream_body(Body, nofin, Req),
receiver_events(TaskId, Req);
{stream_close, TaskId, Reason} ->
CloseFrame = iolist_to_binary([<<"event: close\n">>, <<"data: ", Reason/binary, "\n">>, <<"\n">>]),
ok = cowboy_req:stream_body(CloseFrame, fin, Req)
after 30000 ->
ok = cowboy_req:stream_body(<<": heartbeat\n\n">>, nofin, Req),
receiver_events(TaskId, Req)
end.
-spec parse_stream_params(map()) -> {ok, binary(), integer()} | {error, binary()}.
parse_stream_params(#{<<"uuid">> := UUID, <<"task_id">> := TaskId0}) when is_binary(UUID), is_binary(TaskId0) ->
try binary_to_integer(TaskId0) of
TaskId when TaskId >= 0 ->
{ok, UUID, TaskId};
_ ->
{error, <<"task_id must be non-negative">>}
catch
error:badarg ->
{error, <<"task_id must be integer">>}
end;
parse_stream_params(#{<<"task_id">> := _TaskId0}) ->
{error, <<"uuid required">>};
parse_stream_params(_) ->
{error, <<"uuid and task_id required">>}.

View File

@ -18,7 +18,7 @@
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
handle_request("GET", "/host/metric", #{<<"uuid">> := UUID}, _) -> handle_request("GET", "/host/metric", #{<<"uuid">> := UUID}, _) ->
lager:debug("[host_handler] get host metric uuid is: ~p", [UUID]), logger:debug("[host_handler] get host metric uuid is: ~p", [UUID]),
case iot_host:get_pid(UUID) of case iot_host:get_pid(UUID) of
undefined -> undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)}; {ok, 200, iot_util:json_error(404, <<"host not found">>)};
@ -56,26 +56,22 @@ handle_request("POST", "/host/delete", _, #{<<"uuid">> := UUID}) when is_binary(
handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := true}) when is_binary(UUID) -> handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := true}) when is_binary(UUID) ->
case iot_host_sup:ensured_host_started(UUID) of case iot_host_sup:ensured_host_started(UUID) of
{error, Reason} -> {error, Reason} ->
lager:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), logger:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]),
{ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, 200, iot_util:json_error(400, <<"host not found">>)};
{ok, Pid} when is_pid(Pid) -> {ok, Pid} when is_pid(Pid) ->
lager:debug("[host_handler] activate host_id: ~p, start", [UUID]), logger:debug("[host_handler] activate host_id: ~p, start", [UUID]),
ok = iot_host:activate(Pid, true), auth_response(iot_host:activate(Pid, true))
{ok, 200, iot_util:json_data(<<"success">>)}
end; end;
%% %%
handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := false}) when is_binary(UUID) -> handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := false}) when is_binary(UUID) ->
case iot_host_sup:ensured_host_started(UUID) of case iot_host_sup:ensured_host_started(UUID) of
{error, Reason} -> {error, Reason} ->
lager:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), logger:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]),
{ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, 200, iot_util:json_error(400, <<"host not found">>)};
{ok, Pid} when is_pid(Pid) -> {ok, Pid} when is_pid(Pid) ->
lager:debug("[host_handler] activate host_id: ~p, start", [UUID]), logger:debug("[host_handler] activate host_id: ~p, start", [UUID]),
ok = iot_host:activate(Pid, false), auth_response(iot_host:activate(Pid, false))
{ok, 200, iot_util:json_data(<<"success">>)}
end; end;
%% %%
@ -85,11 +81,15 @@ handle_request("POST", "/host/pub", _, #{<<"uuid">> := UUID, <<"topic">> := Topi
Qos = case Qos0 > 0 of true -> 1; false -> 0 end, Qos = case Qos0 > 0 of true -> 1; false -> 0 end,
case iot_host_sup:ensured_host_started(UUID) of case iot_host_sup:ensured_host_started(UUID) of
{error, Reason} -> {error, Reason} ->
lager:debug("[host_handler] pub host_id: ~p, topic: ~p, failed with reason: ~p", [UUID, Topic, Reason]), logger:debug("[host_handler] pub host_id: ~p, topic: ~p, failed with reason: ~p", [UUID, Topic, Reason]),
{ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, 200, iot_util:json_error(400, <<"host not found">>)};
{ok, Pid} when is_pid(Pid) -> {ok, Pid} when is_pid(Pid) ->
ok = iot_host:pub(Pid, Topic, Qos, Content), case iot_host:pub(Pid, Topic, Qos, Content) of
{ok, 200, iot_util:json_data(<<"success">>)} ok ->
{ok, 200, iot_util:json_data(<<"success">>)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end; end;
handle_request(_, Path, _, _) -> handle_request(_, Path, _, _) ->
@ -99,3 +99,28 @@ handle_request(_, Path, _, _) ->
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% helper methods %% helper methods
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
auth_response(ok) ->
{ok, 200, iot_util:json_data(<<"success">>)};
auth_response({error, Reason}) ->
{ok, auth_error_status(Reason), iot_util:json_error(auth_error_status(Reason), reason_to_binary(Reason))}.
auth_error_status(timeout) ->
504;
auth_error_status(<<"timeout">>) ->
504;
auth_error_status(_) ->
400.
reason_to_binary(Reason) when is_binary(Reason) ->
Reason;
reason_to_binary(timeout) ->
<<"timeout">>;
reason_to_binary(invalid_response) ->
<<"invalid response">>;
reason_to_binary({failed, Reason}) ->
reason_to_binary(Reason);
reason_to_binary({channel_closed, Reason}) ->
<<"channel closed: ", (reason_to_binary(Reason))/binary>>;
reason_to_binary(Reason) ->
unicode:characters_to_binary(io_lib:format("~p", [Reason])).

View File

@ -12,16 +12,28 @@
%% API %% API
-export([init/2]). -export([init/2]).
-define(MAX_BODY_BYTES, 10 * 1024 * 1024).
init(Req0, Opts = [Mod|_]) -> init(Req0, Opts = [Mod|_]) ->
ok = iot_log:set_metadata(),
Method = binary_to_list(cowboy_req:method(Req0)), Method = binary_to_list(cowboy_req:method(Req0)),
Path = binary_to_list(cowboy_req:path(Req0)), Path = binary_to_list(cowboy_req:path(Req0)),
GetParams0 = cowboy_req:parse_qs(Req0), GetParams0 = cowboy_req:parse_qs(Req0),
GetParams = maps:from_list(GetParams0), GetParams = maps:from_list(GetParams0),
{ok, PostParams, Req1} = parse_body(Req0), case parse_body(Req0) of
{ok, PostParams, Req1} ->
handle_request(Mod, Method, Path, GetParams, PostParams, Req1, Opts);
{error, StatusCode, Resp, Req1} ->
Req2 = cowboy_req:reply(StatusCode, #{
<<"Content-Type">> => <<"application/json;charset=utf-8">>
}, Resp, Req1),
{ok, Req2, Opts}
end.
handle_request(Mod, Method, Path, GetParams, PostParams, Req1, Opts) ->
try Mod:handle_request(Method, Path, GetParams, PostParams) of try Mod:handle_request(Method, Path, GetParams, PostParams) of
{ok, StatusCode, Resp} -> {ok, StatusCode, Resp} ->
lager:debug("[http_protocol] request path: ~p, get_params: ~p, post_params: ~p, response: ~ts", logger:debug("[http_protocol] request path: ~p, get_params: ~p, post_params: ~p, response: ~ts",
[Path, GetParams, PostParams, Resp]), [Path, GetParams, PostParams, Resp]),
AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>), AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>),
Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of
@ -45,7 +57,7 @@ init(Req0, Opts = [Mod|_]) ->
}, ErrResp, Req1), }, ErrResp, Req1),
{ok, Req2, Opts}; {ok, Req2, Opts};
_:Error:Stack -> _:Error:Stack ->
lager:warning("[http_handler] get error: ~p, stack: ~p", [Error, Stack]), logger:warning("[http_handler] get error: ~p, stack: ~p", [Error, Stack]),
Req2 = cowboy_req:reply(500, #{ Req2 = cowboy_req:reply(500, #{
<<"Content-Type">> => <<"text/html;charset=utf-8">> <<"Content-Type">> => <<"text/html;charset=utf-8">>
}, <<"Internal Server Error">>, Req1), }, <<"Internal Server Error">>, Req1),
@ -60,17 +72,18 @@ parse_body(Req0) ->
ContentType = cowboy_req:header(<<"content-type">>, Req0), ContentType = cowboy_req:header(<<"content-type">>, Req0),
case ContentType of case ContentType of
<<"application/json", _/binary>> -> <<"application/json", _/binary>> ->
{ok, Body, Req1} = read_body(Req0), case read_body(Req0) of
case Body =/= <<"">> of {ok, Body, Req1} ->
true -> decode_json_body(Body, Req1);
{ok, catch jiffy:decode(Body, [return_maps]), Req1}; {error, payload_too_large, Req1} ->
false -> {error, 413, iot_util:json_error(413, <<"payload too large">>), Req1}
{ok, #{}, Req1}
end; end;
<<"application/x-www-form-urlencoded">> -> <<"application/x-www-form-urlencoded">> ->
{ok, PostParams0, Req1} = cowboy_req:read_urlencoded_body(Req0), case cowboy_req:read_urlencoded_body(Req0) of
PostParams = maps:from_list(PostParams0), {ok, PostParams0, Req1} ->
{ok, PostParams, Req1}; PostParams = maps:from_list(PostParams0),
{ok, PostParams, Req1}
end;
_ -> _ ->
{ok, #{}, Req0} {ok, #{}, Req0}
end. end.
@ -81,7 +94,33 @@ read_body(Req) ->
read_body(Req, AccData) -> read_body(Req, AccData) ->
case cowboy_req:read_body(Req) of case cowboy_req:read_body(Req) of
{ok, Data, Req1} -> {ok, Data, Req1} ->
{ok, <<AccData/binary, Data/binary>>, Req1}; NAccData = <<AccData/binary, Data/binary>>,
case byte_size(NAccData) =< ?MAX_BODY_BYTES of
true ->
{ok, NAccData, Req1};
false ->
{error, payload_too_large, Req1}
end;
{more, Data, Req1} -> {more, Data, Req1} ->
read_body(Req1, <<AccData/binary, Data/binary>>) NAccData = <<AccData/binary, Data/binary>>,
case byte_size(NAccData) =< ?MAX_BODY_BYTES of
true ->
read_body(Req1, NAccData);
false ->
{error, payload_too_large, Req1}
end
end.
decode_json_body(<<"">>, Req) ->
{ok, #{}, Req};
decode_json_body(Body, Req) ->
case catch json:decode(Body) of
{'EXIT', _} ->
{error, 400, iot_util:json_error(400, <<"invalid json">>), Req};
{error, _} ->
{error, 400, iot_util:json_error(400, <<"invalid json">>), Req};
Decoded when is_map(Decoded); is_list(Decoded) ->
{ok, Decoded, Req};
_ ->
{error, 400, iot_util:json_error(400, <<"invalid json">>), Req}
end. end.

View File

@ -1,309 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author licheng5
%%% @copyright (C) 2020, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 26. 4 2020 3:36
%%%-------------------------------------------------------------------
-module(container_handler).
-author("licheng5").
-include("iot.hrl").
-define(REQ_TIMEOUT, 10000).
%% API
-export([handle_request/4]).
handle_request("GET", "/container/get_all", #{<<"uuid">> := UUID}, _) when is_binary(UUID) ->
%% ConfigJson是否是合法的json字符串
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(-1, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:get_containers(Pid) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(-1, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(-1, Reason)}
end
end;
%% config.json,
handle_request("POST", "/container/push_config", _,
#{<<"uuid">> := UUID, <<"container_name">> := ContainerName, <<"config">> := Config, <<"timeout">> := Timeout0})
when is_binary(UUID), is_binary(ContainerName), is_binary(Config), is_integer(Timeout0) ->
%% ConfigJson是否是合法的json字符串
true = iot_util:is_json(Config),
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(-1, <<"host not found">>)};
Pid when is_pid(Pid) ->
Timeout = Timeout0 * 1000,
case iot_host:config_container(Pid, ContainerName, Config) of
{ok, Ref} ->
case iot_host:await_reply(Ref, Timeout) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(-1, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(-1, Reason)}
end
end;
%%
handle_request("POST", "/container/deploy", _, #{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config})
when is_binary(UUID), is_integer(TaskId), is_map(Config) ->
case validate_config(Config) of
ok ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:deploy_container(Pid, TaskId, Config) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
{error, Errors} ->
Reason = iolist_to_binary(lists:join(<<"|||">>, Errors)),
{ok, 200, iot_util:json_error(400, Reason)}
end;
%%
handle_request("POST", "/container/start", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:start_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
%%
handle_request("POST", "/container/stop", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:stop_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
handle_request("POST", "/container/kill", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:kill_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
%%
handle_request("POST", "/container/remove", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) ->
case iot_host:get_pid(UUID) of
undefined ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
Pid when is_pid(Pid) ->
case iot_host:remove_container(Pid, ContainerName) of
{ok, Ref} ->
case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of
{ok, Result} ->
{ok, 200, iot_util:json_data(Result)};
{error, Reason} ->
{ok, 200, iot_util:json_error(400, Reason)}
end;
{error, Reason} when is_binary(Reason) ->
{ok, 200, iot_util:json_error(400, Reason)}
end
end;
handle_request(_, Path, _, _) ->
Path1 = list_to_binary(Path),
{ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% helper methods
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
validate_config(Config) when is_map(Config) ->
%%
Required = [
{<<"image">>, binary},
{<<"container_name">>, binary},
{<<"command">>, {list, binary}},
{<<"restart">>, binary}
],
%%
Optional = [
{<<"privileged">>, boolean},
{<<"envs">>, {list, binary}},
{<<"ports">>, {list, binary}},
{<<"expose">>, {list, binary}},
{<<"volumes">>, {list, binary}},
{<<"networks">>, {list, binary}},
{<<"labels">>, {map, {binary, binary}}},
{<<"user">>, binary},
{<<"working_dir">>, binary},
{<<"hostname">>, binary},
{<<"cap_add">>, {list, binary}},
{<<"cap_drop">>, {list, binary}},
{<<"devices">>, {list, binary}},
{<<"mem_limit">>, binary},
{<<"mem_reservation">>, binary},
{<<"cpu_shares">>, integer},
{<<"cpus">>, number},
{<<"ulimits">>, {map, {binary, binary}}},
{<<"sysctls">>, {map, {binary, binary}}},
{<<"tmpfs">>, {list, binary}},
{<<"extra_hosts">>, {list, binary}},
{<<"healthcheck">>, {map, {binary, any}}}
],
Errors1 = check_required(Config, Required),
Errors2 = check_optional(Config, Optional),
Errors = Errors1 ++ Errors2,
case Errors of
[] ->
ok;
_ ->
{error, lists:map(fun erlang:iolist_to_binary/1, Errors)}
end.
%%------------------------------------------------------------------------------
%%
%%------------------------------------------------------------------------------
check_required(Config, Fields) ->
lists:foldl(
fun({Key, Type}, ErrAcc) ->
case maps:get(Key, Config, undefined) of
undefined ->
[io_lib:format("miss requied parameter: ~p", [Key]) | ErrAcc];
Value ->
case check_type(Value, Type) of
true ->
ErrAcc;
false ->
[io_lib:format("required parameter: ~p, type must be: ~p", [Key, type_name(Type)]) | ErrAcc]
end
end
end,
[], Fields).
%%------------------------------------------------------------------------------
%%
%%------------------------------------------------------------------------------
check_optional(Config, Fields) ->
lists:foldl(
fun({Key, Type}, ErrAcc) ->
case maps:get(Key, Config, undefined) of
undefined ->
ErrAcc;
Value ->
case check_type(Value, Type) of
true ->
ErrAcc;
false ->
[io_lib:format("optional parameter: ~p, type must be: ~p", [Key, type_name(Type)]) | ErrAcc]
end
end
end,
[], Fields).
%%------------------------------------------------------------------------------
%% binary版
%%------------------------------------------------------------------------------
-spec type_name(tuple() | atom()) -> binary().
type_name(binary) ->
<<"string">>;
type_name(integer) ->
<<"integer">>;
type_name(number) ->
<<"number">>;
type_name(list) ->
<<"list">>;
type_name({list, binary}) ->
<<"list of string">>;
type_name({list, number}) ->
<<"list of number">>;
type_name({list, integer}) ->
<<"list of integer">>;
type_name(map) ->
<<"map">>;
type_name({map, {binary, binary}}) ->
<<"map of string:string">>;
type_name({map, {binary, any}}) ->
<<"map of string:any">>;
type_name(boolean) ->
<<"boolean">>.
-spec check_type(Value :: any(), any()) -> boolean().
check_type(Value, binary) ->
is_binary(Value);
check_type(Value, integer) ->
is_integer(Value);
check_type(Value, number) ->
is_number(Value);
check_type(Value, list) when is_list(Value) ->
true;
check_type(Value, {list, binary}) when is_list(Value) ->
lists:all(fun(E) -> is_binary(E) end, Value);
check_type(Value, {list, number}) when is_list(Value) ->
lists:all(fun(E) -> is_number(E) end, Value);
check_type(Value, {list, integer}) when is_list(Value) ->
lists:all(fun(E) -> is_integer(E) end, Value);
check_type(Value, map) when is_map(Value) ->
true;
check_type(Value, {map, {binary, binary}}) when is_map(Value) ->
lists:all(fun({K, V}) -> is_binary(K) andalso is_binary(V) end, maps:to_list(Value));
check_type(Value, {map, {binary, any}}) when is_map(Value) ->
lists:all(fun({K, _}) -> is_binary(K) end, maps:to_list(Value));
check_type(Value, boolean) ->
is_boolean(Value);
check_type(_, _) ->
false.

View File

@ -1,47 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 08. 5 2025 13:00
%%%-------------------------------------------------------------------
-module(event_stream_handler).
-author("anlicheng").
%% API
-export([init/2]).
init(Req0, Opts) ->
Method = binary_to_list(cowboy_req:method(Req0)),
Path = binary_to_list(cowboy_req:path(Req0)),
GetParams0 = cowboy_req:parse_qs(Req0),
GetParams = maps:from_list(GetParams0),
#{<<"task_id">> := TaskId0} = GetParams,
TaskId = binary_to_integer(TaskId0),
lager:debug("method: ~p, path: ~p, get: ~p", [Method, Path, GetParams]),
Req1 = cowboy_req:stream_reply(200, #{
<<"Content-Type">> => <<"text/event-stream">>,
<<"Cache-Control">> => <<"no-cache">>,
<<"Connection">> => <<"keep-alive">>
}, Req0),
ok = iot_event_stream_observer:add_listener(self(), TaskId),
receiver_events(TaskId, Req1),
{ok, Req1, Opts}.
receiver_events(TaskId, Req) ->
receive
{stream_data, TaskId, Type, Stream} ->
Data = jiffy:encode(#{<<"type">> => Type, <<"stream">> => Stream}, [force_utf8]),
Body = iolist_to_binary([<<"event: message\n">>, <<"data: ", Data/binary, "\n">>, <<"\n">>]),
ok = cowboy_req:stream_body(Body, nofin, Req),
receiver_events(TaskId, Req);
{stream_close, TaskId, Reason} ->
CloseFrame = iolist_to_binary([<<"event: close\n">>, <<"data: ", Reason/binary, "\n">>, <<"\n">>]),
ok = cowboy_req:stream_body(CloseFrame, fin, Req)
end.

View File

@ -6,17 +6,14 @@
{applications, {applications,
[ [
sync, sync,
endpoint,
emqtt, emqtt,
eredis, eredis,
ranch, ranch,
cowboy, cowboy,
lager,
jiffy,
brod, brod,
parse_trans,
hackney, hackney,
poolboy, poolboy,
mysql,
gproc, gproc,
% gpb, % gpb,
mnesia, mnesia,

View File

@ -9,34 +9,41 @@
-export([start/2, stop/1]). -export([start/2, stop/1]).
start(_StartType, _StartArgs) -> start(_StartType, _StartArgs) ->
ok = iot_log:set_metadata(),
io:setopts([{encoding, unicode}]), io:setopts([{encoding, unicode}]),
%% %%
erlang:system_flag(fullsweep_after, 16), erlang:system_flag(fullsweep_after, 16),
%% mnesia数据库
start_mnesia(),
%% http服务 iot_mnesia:safe_start(),
%% http服务 supervisor simulator API
start_http_server(), start_http_server(),
%% tcp服务 %% endpoints的相关依赖
start_tcp_server(), start_endpoints(),
%% ssl服务
start_ssl_server(),
iot_sup:start_link(). iot_sup:start_link().
stop(_State) -> stop(_State) ->
stop_started_services(),
ok. ok.
%% internal functions %% internal functions
%% start_endpoints() ->
start_mnesia() -> EndpointInfos = iot_api_client:get_all_endpoints(),
ok = ensure_mnesia_schema(), Endpoints = lists:filtermap(fun(EndpointInfo) ->
%% case endpoint:endpoint_record(EndpointInfo) of
ok = mnesia:start(), error ->
Tables = mnesia:system_info(tables), false;
lager:debug("[iot_app] tables: ~p", [Tables]), {ok, Endpoint} ->
%% {true, Endpoint}
ok. end
end, EndpointInfos),
ok = endpoint_adapter_sup:start_endpoints(Endpoints).
start_http_server() -> start_http_server() ->
{ok, Props} = application:get_env(iot, http_server), {ok, Props} = application:get_env(iot, http_server),
@ -50,6 +57,7 @@ start_http_server() ->
{"/host/[...]", http_protocol, [host_handler]}, {"/host/[...]", http_protocol, [host_handler]},
{"/container/[...]", http_protocol, [container_handler]}, {"/container/[...]", http_protocol, [container_handler]},
{"/endpoint/[...]", http_protocol, [endpoint_handler]}, {"/endpoint/[...]", http_protocol, [endpoint_handler]},
{"/simulator/[...]", http_protocol, [simulator_api_handler]},
{"/event_stream", event_stream_handler, []} {"/event_stream", event_stream_handler, []}
]} ]}
]), ]),
@ -65,41 +73,36 @@ start_http_server() ->
}, },
{ok, Pid} = cowboy:start_clear(http_listener, TransOpts, #{env => #{dispatch => Dispatcher}}), {ok, Pid} = cowboy:start_clear(http_listener, TransOpts, #{env => #{dispatch => Dispatcher}}),
lager:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]). logger:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]).
%% tcp服务 %% ssl服务
start_tcp_server() -> start_ssl_server() ->
{ok, Props} = application:get_env(iot, tcp_server), {ok, Props} = application:get_env(iot, ssl_server),
Acceptors = proplists:get_value(acceptors, Props, 50), Acceptors = proplists:get_value(acceptors, Props, 50),
MaxConnections = proplists:get_value(max_connections, Props, 10240), MaxConnections = proplists:get_value(max_connections, Props, 10240),
Backlog = proplists:get_value(backlog, Props, 1024), Backlog = proplists:get_value(backlog, Props, 1024),
Port = proplists:get_value(port, Props), Port = proplists:get_value(port, Props),
PrivDir = code:priv_dir(iot),
CertFile = filename:join([PrivDir, "ssl", "server.crt"]),
KeyFile = filename:join([PrivDir, "ssl", "server.key"]),
TransOpts = #{ TransOpts = #{
max_connections => MaxConnections, max_connections => MaxConnections,
num_acceptors => Acceptors, num_acceptors => Acceptors,
shutdown => brutal_kill, shutdown => brutal_kill,
socket_opts => [ socket_opts => [
{nodelay, false}, {nodelay, true},
{backlog, Backlog}, {backlog, Backlog},
{port, Port} {port, Port},
{certfile, CertFile},
{keyfile, KeyFile}
] ]
}, },
{ok, _} = ranch:start_listener(tcp_server, ranch_tcp, TransOpts, tcp_channel, []), {ok, _} = ranch:start_listener(ssl_server, ranch_ssl, TransOpts, ssl_channel, []),
lager:debug("[iot_app] the tcp server start at: ~p", [Port]). logger:debug("[iot_app] the ssl server start at: ~p", [Port]).
-spec ensure_mnesia_schema() -> any(). stop_started_services() ->
ensure_mnesia_schema() -> _ = cowboy:stop_listener(http_listener),
case mnesia:system_info(use_dir) of _ = ranch:stop_listener(ssl_server),
true -> _ = iot_mnesia:stop(),
ok; ok.
false ->
mnesia:stop(),
case mnesia:create_schema([node()]) of
ok -> ok;
{error, {_, {already_exists, _}}} -> ok;
Error ->
lager:debug("[iot_app] create mnesia schema failed with error: ~p", [Error]),
throw({init_schema, Error})
end
end.

View File

@ -1,33 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 27. 6 2023 09:48
%%%-------------------------------------------------------------------
-module(iot_auth).
-author("aresei").
%% API
-export([check/5]).
%% token是否是合法值
-spec check(Username :: binary(), Token :: binary(), UUID :: binary(), Salt :: binary(), Timestamp :: integer()) -> boolean().
check(Username, Token, UUID, Salt, Timestamp) when is_binary(Username), is_binary(Token), is_binary(UUID), is_binary(Salt), is_integer(Timestamp) ->
true;
check(Username, Token, UUID, Salt, Timestamp) when is_binary(Username), is_binary(Token), is_binary(UUID), is_binary(Salt), is_integer(Timestamp) ->
BinTimestamp = integer_to_binary(Timestamp),
%% 1
case iot_util:current_time() - Timestamp =< 60 of
true ->
{ok, PreTokens} = application:get_env(iot, pre_tokens),
case proplists:get_value(Username, PreTokens) of
undefined ->
false;
PreToken when is_binary(PreToken) ->
iot_util:md5(<<Salt/binary, "!", PreToken/binary, "!", UUID/binary, "!", BinTimestamp/binary>>) =:= Token
end;
false ->
false
end.

View File

@ -1,390 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 22. 9 2023 16:38
%%%-------------------------------------------------------------------
-module(iot_host).
-author("aresei").
-include("iot.hrl").
-include("message.hrl").
-behaviour(gen_statem).
%% , 15
-define(HEARTBEAT_INTERVAL, 900 * 1000).
%%
-define(STATE_DENIED, denied).
-define(STATE_ACTIVATED, activated).
%% API
-export([start_link/2, get_name/1, get_alias_name/1, get_pid/1, handle/2, activate/2]).
-export([get_metric/1, get_status/1, kill/1]).
%%
-export([pub/4, attach_channel/2, command/3]).
-export([deploy_container/3, start_container/2, stop_container/2, remove_container/2, kill_container/2, config_container/3, get_containers/1, await_reply/2]).
-export([heartbeat/1]).
%% gen_statem callbacks
-export([init/1, handle_event/4, terminate/3, code_change/4, callback_mode/0]).
-record(state, {
host_id :: integer(),
%%
uuid :: binary(),
has_session = false :: boolean(),
%%
heartbeat_counter = 0 :: integer(),
%% websocket相关
channel_pid :: undefined | pid(),
%%
metrics = #{} :: map()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec get_pid(UUID :: binary()) -> undefined | pid().
get_pid(UUID) when is_binary(UUID) ->
Name = get_name(UUID),
whereis(Name).
-spec get_name(UUID :: binary()) -> atom().
get_name(UUID) when is_binary(UUID) ->
binary_to_atom(<<"iot_host:", UUID/binary>>).
-spec get_alias_name(HostId :: integer()) -> atom().
get_alias_name(HostId0) when is_integer(HostId0) ->
HostId = integer_to_binary(HostId0),
binary_to_atom(<<"iot_host_id:", HostId/binary>>).
-spec kill(UUID :: binary()) -> no_return().
kill(UUID) when is_binary(UUID) ->
case whereis(get_name(UUID)) of
undefined ->
ok;
Pid ->
exit(Pid, kill)
end.
%%
-spec handle(Pid :: pid(), Packet :: {atom(), any()}) -> no_return().
handle(Pid, Packet) when is_pid(Pid) ->
gen_statem:cast(Pid, {handle, Packet}).
-spec get_status(Pid :: pid()) -> {ok, Status :: map()}.
get_status(Pid) when is_pid(Pid) ->
gen_statem:call(Pid, get_status).
%% , true false表示关闭激活
-spec activate(Pid :: pid(), Auth :: boolean()) -> ok.
activate(Pid, Auth) when is_pid(Pid), is_boolean(Auth) ->
gen_statem:call(Pid, {activate, Auth}).
-spec get_metric(Pid :: pid()) -> {ok, MetricInfo :: map()}.
get_metric(Pid) when is_pid(Pid) ->
gen_statem:call(Pid, get_metric).
-spec attach_channel(pid(), pid()) -> ok | {error, Reason :: binary()} | {denied, Reason :: binary()}.
attach_channel(Pid, ChannelPid) when is_pid(Pid), is_pid(ChannelPid) ->
gen_statem:call(Pid, {attach_channel, ChannelPid}).
-spec get_containers(Pid :: pid()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
get_containers(Pid) when is_pid(Pid) ->
Request = #jsonrpc_request{method = <<"get_containers">>, params = #{}},
EncConfigBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncConfigBin}).
-spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) ->
Request = #jsonrpc_request{method = <<"config_container">>, params = #{<<"container_name">> => ContainerName, <<"config">> => ConfigJson}},
EncConfigBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncConfigBin}).
-spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) ->
Request = #jsonrpc_request{method = <<"deploy">>, params = #{<<"task_id">> => TaskId, <<"config">> => Config}},
EncDeployBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncDeployBin}).
-spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
Request = #jsonrpc_request{method = <<"start_container">>, params = #{<<"container_name">> => ContainerName}},
EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}).
-spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
Request = #jsonrpc_request{method = <<"stop_container">>, params = #{<<"container_name">> => ContainerName}},
EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}).
-spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
Request = #jsonrpc_request{method = <<"kill_container">>, params = #{<<"container_name">> => ContainerName}},
EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}).
-spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}.
remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) ->
Request = #jsonrpc_request{method = <<"remove_container">>, params = #{<<"container_name">> => ContainerName}},
EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request),
gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}).
-spec await_reply(Ref :: reference(), Timeout :: integer()) -> {ok, Result :: binary()} | {error, Reason :: binary()}.
await_reply(Ref, Timeout) when is_reference(Ref), is_integer(Timeout) ->
receive
{jsonrpc_reply, Ref, #jsonrpc_reply{result = Result, error = undefined}} ->
{ok, Result};
{jsonrpc_reply, Ref, #jsonrpc_reply{result = undefined, error = #{<<"message">> := Message}}} ->
{error, Message}
after Timeout ->
{error, <<"timeout">>}
end.
-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok | {error, Reason :: any()}.
pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) ->
gen_statem:call(Pid, {pub, Topic, Qos, Content}).
-spec command(Pid :: pid(), CommandType :: integer(), Command :: binary()) -> ok | {error, Reason :: any()}.
command(Pid, CommandType, Command) when is_pid(Pid), is_integer(CommandType), is_binary(Command) ->
gen_statem:call(Pid, {command, CommandType, Command}).
-spec heartbeat(Pid :: pid()) -> no_return().
heartbeat(undefined) ->
ok;
heartbeat(Pid) when is_pid(Pid) ->
gen_statem:cast(Pid, heartbeat).
%% @doc Creates a gen_statem process which calls Module:init/1 to
%% initialize. To ensure a synchronized start-up procedure, this
%% function does not return until Module:init/1 has returned.
start_link(Name, UUID) when is_atom(Name), is_binary(UUID) ->
gen_statem:start_link({local, Name}, ?MODULE, [UUID], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
%% @private
%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or
%% gen_statem:start_link/[3,4], this function is called by the new
%% process to initialize.
init([UUID]) ->
case iot_api:get_host_by_uuid(UUID) of
{ok, #{<<"id">> := HostId, <<"authorize_status">> := AuthorizeStatus}} ->
%% host_id注册别名, HostPid
AliasName = get_alias_name(HostId),
global:register_name(AliasName, self()),
%%
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
StateName = case AuthorizeStatus =:= 1 of
true -> ?STATE_ACTIVATED;
false -> ?STATE_DENIED
end,
{ok, StateName, #state{host_id = HostId, uuid = UUID, has_session = false}};
undefined ->
lager:warning("[iot_host] host uuid: ~p, loaded from mysql failed", [UUID]),
ignore
end.
%% @private
%% @doc This function is called by a gen_statem when it needs to find out
%% the callback mode of the callback module.
callback_mode() ->
handle_event_function.
%% @private
%% @doc If callback_mode is handle_event_function, then whenever a
%% gen_statem receives an event from call/2, cast/2, or as a normal
%% process message, this function is called.
handle_event({call, From}, get_metric, _, State = #state{metrics = Metrics}) ->
{keep_state, State, [{reply, From, {ok, Metrics}}]};
%%
handle_event({call, From}, get_status, _, State = #state{channel_pid = ChannelPid, heartbeat_counter = HeartbeatCounter, metrics = Metrics, has_session = HasSession}) ->
HasChannel = (ChannelPid /= undefined),
Reply = #{
<<"has_channel">> => HasChannel,
<<"has_session">> => HasSession,
<<"heartbeat_counter">> => HeartbeatCounter,
<<"metrics">> => Metrics
},
{keep_state, State, [{reply, From, {ok, Reply}}]};
%% channel存在
handle_event({call, From}, {jsonrpc_call, ReceiverPid, RpcCall}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
case HasSession andalso is_pid(ChannelPid) of
true ->
%% websocket发送请求
Ref = tcp_channel:jsonrpc_call(ChannelPid, ReceiverPid, RpcCall),
{keep_state, State, [{reply, From, {ok, Ref}}]};
false ->
lager:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
{keep_state, State, [{reply, From, {error, <<"主机离线,发送请求失败"/utf8>>}}]}
end;
%% , pub/sub
handle_event({call, From}, {pub, Topic, Qos, Content}, ?STATE_ACTIVATED, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
case HasSession andalso is_pid(ChannelPid) of
true ->
lager:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]),
%% websocket发送请求
tcp_channel:pub(ChannelPid, Topic, Qos, Content),
{keep_state, State, [{reply, From, ok}]};
false ->
lager:debug("[iot_host] uuid: ~p, publish to topic: ~p, content: ~p, invalid state: ~p", [UUID, Topic, Content, state_map(State)]),
{keep_state, State, [{reply, From, {error, <<"主机离线,发送失败"/utf8>>}}]}
end;
%%
handle_event({call, From}, {command, CommandType, Command}, ?STATE_ACTIVATED, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
case HasSession andalso is_pid(ChannelPid) of
true ->
lager:debug("[iot_host] host: ~p, command_type: ~p, command: ~p", [UUID, CommandType, Command]),
%% websocket发送请求
tcp_channel:command(ChannelPid, CommandType, Command),
{keep_state, State, [{reply, From, ok}]};
false ->
lager:debug("[iot_host] host: ~p, command_type: ~p, command: ~p, invalid state: ~p", [UUID, CommandType, Command, state_map(State)]),
{keep_state, State, [{reply, From, {error, <<"主机离线,发送指令失败"/utf8>>}}]}
end;
%%
handle_event({call, From}, {activate, true}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) ->
case is_pid(ChannelPid) of
true ->
lager:debug("[iot_host] uuid: ~p, activate: true", [UUID]),
tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<1:8>>);
false ->
lager:debug("[iot_host] uuid: ~p, activate: true, no channel", [UUID])
end,
{next_state, ?STATE_ACTIVATED, State, [{reply, From, ok}]};
%%
handle_event({call, From}, {activate, false}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) ->
case is_pid(ChannelPid) of
true ->
tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<0:8>>),
lager:debug("[iot_host] uuid: ~p, activate: false", [UUID]),
tcp_channel:stop(ChannelPid, closed);
false ->
lager:debug("[iot_host] uuid: ~p, activate: false, no channel", [UUID])
end,
{next_state, ?STATE_DENIED, State#state{channel_pid = undefined, has_session = false}, [{reply, From, ok}]};
%% channel
handle_event({call, From}, {attach_channel, ChannelPid}, StateName, State = #state{uuid = UUID, channel_pid = undefined}) ->
case StateName of
?STATE_ACTIVATED ->
erlang:monitor(process, ChannelPid),
%% 线
ChangeResult = iot_api:change_host_status(UUID, ?HOST_ONLINE),
lager:debug("[iot_host] host_id(attach_channel) uuid: ~p, will change status, result: ~p", [UUID, ChangeResult]),
{keep_state, State#state{channel_pid = ChannelPid, has_session = true}, [{reply, From, ok}]};
%%
?STATE_DENIED ->
lager:notice("[iot_host] attach_channel host_id uuid: ~p, channel: ~p, host inactivated", [UUID, ChannelPid]),
erlang:monitor(process, ChannelPid),
{keep_state, State#state{channel_pid = ChannelPid}, [{reply, From, {denied, <<"host inactivated">>}}]}
end;
%% channel
handle_event({call, From}, {attach_channel, _}, _, State = #state{uuid = UUID, channel_pid = OldChannelPid}) ->
lager:notice("[iot_host] attach_channel host_id uuid: ~p, old channel exists: ~p", [UUID, OldChannelPid]),
{keep_state, State, [{reply, From, {error, <<"channel existed">>}}]};
%%
handle_event(cast, {handle, {data, #data{route_key = RouteKey0, metric = Metric}}}, ?STATE_ACTIVATED,
State = #state{uuid = UUID, has_session = true}) ->
lager:debug("[iot_host] metric_data host: ~p, route_key: ~p, metric: ~p", [UUID, RouteKey0, Metric]),
RouteKey = get_route_key(RouteKey0),
endpoint_subscription:publish(RouteKey, Metric),
{keep_state, State};
%% ping的数据是通过aes加密后的
handle_event(cast, {handle, {ping, Metrics}}, ?STATE_ACTIVATED, State = #state{uuid = UUID, has_session = true}) ->
lager:debug("[iot_host] ping host_id uuid: ~p, get ping: ~p", [UUID, Metrics]),
{keep_state, State#state{metrics = Metrics}};
%%
handle_event(cast, heartbeat, _, State = #state{heartbeat_counter = HeartbeatCounter}) ->
{keep_state, State#state{heartbeat_counter = HeartbeatCounter + 1}};
%% 线,
handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{uuid = UUID, heartbeat_counter = 0, channel_pid = ChannelPid}) ->
lager:warning("[iot_host] uuid: ~p, heartbeat lost, devices will unknown", [UUID]),
{ok, #{<<"status">> := Status}} = iot_api:get_host_by_uuid(UUID),
case Status of
?HOST_NOT_JOINED ->
lager:debug("[iot_host] host: ~p, host_maybe_offline, host not joined, can not change to offline", [UUID]);
?HOST_OFFLINE ->
lager:debug("[iot_host] host: ~p, host_maybe_offline, host now is offline, do nothing", [UUID]);
?HOST_ONLINE ->
iot_api:change_host_status(UUID, ?HOST_OFFLINE)
end,
%% channel
is_pid(ChannelPid) andalso tcp_channel:stop(ChannelPid, closed),
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
{keep_state, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}};
%%
handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{}) ->
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
{keep_state, State#state{heartbeat_counter = 0}};
%% websocket断开的时候
handle_event(info, {'DOWN', _Ref, process, ChannelPid, Reason}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
lager:warning("[iot_host] uuid: ~p, channel: ~p, down with reason: ~p, has_session: ~p, state: ~p", [UUID, ChannelPid, Reason, HasSession, State]),
{keep_state, State#state{channel_pid = undefined, has_session = false}};
handle_event(info, {'DOWN', _Ref, process, Pid, Reason}, _, State = #state{uuid = UUID}) ->
lager:debug("[iot_host] uuid: ~p, process_pid: ~p, down with reason: ~p, state: ~p", [UUID, Pid, Reason, State]),
{keep_state, State};
handle_event(Event, Info, StateName, State = #state{uuid = UUID}) ->
lager:warning("[iot_host] host: ~p, event: ~p, unknown message: ~p, state_name: ~p, state: ~p", [UUID, Event, Info, StateName, state_map(State)]),
{keep_state, State}.
%% @private
%% @doc This function is called by a gen_statem 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_statem terminates with
%% Reason. The return value is ignored.
terminate(Reason, _StateName, _State = #state{uuid = UUID, has_session = HasSession}) ->
lager:debug("[iot_host] host: ~p, terminate with reason: ~p, has_session: ~p", [UUID, Reason, HasSession]),
ok.
%% @private
%% @doc Convert process state when code is changed
code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
{ok, StateName, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec get_route_key(binary()) -> binary().
get_route_key(<<"">>) ->
<<"/">>;
get_route_key(RouteKey) when is_binary(RouteKey) ->
RouteKey.
%% state转换成map
state_map(#state{host_id = HostId, uuid = UUID, has_session = HasSession, heartbeat_counter = HeartbeatCounter, channel_pid = ChannelPid, metrics = Metrics}) ->
#{
host_id => HostId,
uuid => UUID,
has_session => HasSession,
heartbeat_counter => HeartbeatCounter,
channel_pid => ChannelPid,
metrics => Metrics
}.

View File

@ -1,59 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%% @end
%%%-------------------------------------------------------------------
-module(iot_host_sup).
-include("iot.hrl").
-behaviour(supervisor).
-export([start_link/0, init/1, delete_host/1, ensured_host_started/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
Specs = lists:map(fun child_spec/1, iot_api:get_all_hosts()),
{ok, {#{strategy => one_for_one, intensity => 1000, period => 3600}, Specs}}.
-spec ensured_host_started(UUID :: binary()) -> {ok, Pid :: pid()} | {error, Reason :: any()}.
ensured_host_started(UUID) when is_binary(UUID) ->
case iot_host:get_pid(UUID) of
undefined ->
%% host对应的信息
delete_host(UUID),
case supervisor:start_child(?MODULE, child_spec(UUID)) of
{ok, Pid} when is_pid(Pid) ->
{ok, Pid};
{error, {'already_started', Pid}} when is_pid(Pid) ->
{ok, Pid};
{error, Error} ->
{error, Error}
end;
Pid when is_pid(Pid) ->
{ok, Pid}
end.
delete_host(UUID) ->
Id = iot_host:get_name(UUID),
ok = supervisor:terminate_child(?MODULE, Id),
case supervisor:delete_child(?MODULE, Id) of
{error, running} ->
%% ensure killed then delete again
iot_host:kill(UUID),
supervisor:delete_child(?MODULE, Id);
_ ->
ok
end.
child_spec(UUID) ->
Id = iot_host:get_name(UUID),
#{id => Id,
start => {iot_host, start_link, [Id, UUID]},
restart => permanent,
shutdown => 2000,
type => worker,
modules => ['iot_host']}.

View File

@ -1,40 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author licheng5
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 03. 3 2023 11:48
%%%-------------------------------------------------------------------
-module(iot_http_client).
-author("licheng5").
%% API
-export([post/2]).
post(Url, Body) when is_list(Url), is_binary(Body) ->
case hackney:request(post, Url, [], Body) of
{ok, 200, _, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
lager:debug("[iot_http_client] url: ~p, response is: ~p", [Url, RespBody]),
ok;
{error, Reason} ->
lager:warning("[iot_http_client] url: ~p, get error: ~p", [Url, Reason]),
{error, Reason}
end;
{ok, HttpCode, _, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
lager:debug("[iot_http_client] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]),
ok;
{error, Reason} ->
lager:warning("[iot_http_client] url: ~p, http_code: ~p, get error: ~p", [Url, HttpCode, Reason]),
{error, Reason}
end;
{error, Reason} ->
lager:warning("[iot_http_client] url: ~p, get error: ~p", [Url, Reason]),
{error, Reason}
end.

10
apps/iot/src/iot_log.erl Normal file
View File

@ -0,0 +1,10 @@
%%%-------------------------------------------------------------------
%%% @doc Logger helper for project-scoped metadata.
%%%-------------------------------------------------------------------
-module(iot_log).
-export([set_metadata/0]).
-spec set_metadata() -> ok.
set_metadata() ->
logger:set_process_metadata(#{domain => [iot]}).

View File

@ -29,21 +29,21 @@ init([]) ->
Specs = [ Specs = [
#{ #{
id => 'iot_event_stream_observer', id => 'iot_container_task_sup',
start => {'iot_event_stream_observer', start_link, []}, start => {'iot_container_task_sup', start_link, []},
restart => permanent,
shutdown => 2000,
type => worker,
modules => ['iot_event_stream_observer']
},
#{
id => endpoint_sup_sup,
start => {'endpoint_sup_sup', start_link, []},
restart => permanent, restart => permanent,
shutdown => 2000, shutdown => 2000,
type => supervisor, type => supervisor,
modules => ['endpoint_sup_sup'] modules => ['iot_container_task_sup']
},
#{
id => 'udp_server',
start => {'udp_server', start_link, []},
restart => permanent,
shutdown => 2000,
type => worker,
modules => ['udp_server']
}, },
#{ #{

View File

@ -1,26 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2023, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 31. 8 2023 13:48
%%%-------------------------------------------------------------------
-module(iot_udp_handler).
-author("aresei").
%% API
-export([start_link/2, loop/2]).
start_link(Transport, Peer) ->
{ok, spawn_link(?MODULE, loop, [Transport, Peer])}.
loop(Transport = {udp, Server, _Sock}, Peer) ->
receive
{datagram, Server, <<Len:16, HostUUID:Len/binary>>} ->
Pid = iot_host:get_pid(HostUUID),
iot_host:heartbeat(Pid),
loop(Transport, Peer);
{datagram, Server, _} ->
exit(normal)
end.

View File

@ -10,15 +10,15 @@
-author("licheng5"). -author("licheng5").
%% API %% API
-export([timestamp/0, number_format/2, current_time/0, timestamp_of_seconds/0, float_to_binary/2, int_format/2, file_uri/1]). -export([timestamp/0, number_format/2, current_time/0, timestamp_of_seconds/0, float_to_binary/2, int_format/2]).
-export([step/3, chunks/2, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]). -export([step/3, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]).
-export([json_data/1, json_error/2, is_json/1]). -export([json_data/1, json_error/2, is_json/1]).
-export([queue_limited_in/3, assert_call/2, assert/2]). -export([queue_limited_in/3, assert_call/2, assert/2]).
-export([sha256/1]). -export([sha256/1]).
-spec is_json(Json :: term()) -> boolean(). -spec is_json(Json :: term()) -> boolean().
is_json(Json) when is_binary(Json) -> is_json(Json) when is_binary(Json) ->
case catch jiffy:decode(Json, [return_maps]) of case catch json:decode(Json) of
Result when is_list(Result) orelse is_map(Result) -> Result when is_list(Result) orelse is_map(Result) ->
true; true;
_ -> _ ->
@ -61,33 +61,18 @@ step(Start, End, Step, Acc) when Start < End ->
step(_, _, _, Acc) -> step(_, _, _, Acc) ->
lists:reverse(Acc). lists:reverse(Acc).
%%
-spec chunks(list(), integer()) -> [list()].
chunks(List, Size) when is_list(List), is_integer(Size), Size > 0, length(List) =< Size ->
[List];
chunks(List, Size) when is_list(List), is_integer(Size), Size > 0 ->
chunks0(List, Size, Size, [], []).
chunks0([], _, _, [], AccTarget) ->
lists:reverse(AccTarget);
chunks0([], _, _, Target, AccTarget) ->
lists:reverse([lists:reverse(Target) | AccTarget]);
chunks0(List, Size, 0, Target, AccTarget) ->
chunks0(List, Size, Size, [], [lists:reverse(Target) | AccTarget]);
chunks0([Hd | Tail], Size, Num, Target, AccTarget) ->
chunks0(Tail, Size, Num - 1, [Hd | Target], AccTarget).
json_data(Data) -> json_data(Data) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"result">> => Data <<"result">> => Data
}, [force_utf8]). })).
json_error(ErrCode, ErrMessage) when is_integer(ErrCode) -> json_error(ErrCode, ErrMessage) when is_integer(ErrCode) ->
jiffy:encode(#{ iolist_to_binary(json:encode(#{
<<"error">> => #{ <<"error">> => #{
<<"code">> => ErrCode, <<"code">> => ErrCode,
<<"message">> => ErrMessage <<"message">> => ErrMessage
} }
}, [force_utf8]). })).
uuid() -> uuid() ->
rand_bytes(16). rand_bytes(16).
@ -132,18 +117,9 @@ hex(N) ->
%% %%
-spec parse_mapper(Mapper :: binary() | string()) -> error | {ok, F :: fun((binary(), any()) -> any())}. -spec parse_mapper(Mapper :: binary() | string()) -> error | {ok, F :: fun((binary(), any()) -> any())}.
parse_mapper(Mapper) when is_binary(Mapper) -> parse_mapper(_Mapper) ->
parse_mapper(binary_to_list(Mapper)); %% Dynamic Erlang eval is intentionally disabled in runtime code.
parse_mapper(Mapper) when is_list(Mapper) -> error.
{ok, Tokens, _} = erl_scan:string(Mapper),
{ok, ExprList} = erl_parse:parse_exprs(Tokens),
{value, F, _} = erl_eval:exprs(ExprList, []),
case is_function(F, 2) orelse is_function(F, 3) of
true ->
{ok, F};
false ->
error
end.
-spec float_to_binary(Num :: number(), integer()) -> binary(). -spec float_to_binary(Num :: number(), integer()) -> binary().
float_to_binary(V, _) when is_integer(V) -> float_to_binary(V, _) when is_integer(V) ->
@ -158,13 +134,3 @@ assert(false, F) when is_function(F) ->
F(); F();
assert(false, Msg) -> assert(false, Msg) ->
throw(Msg). throw(Msg).
-spec file_uri(Filename :: binary()) -> error | {ok, FileUri :: binary()}.
file_uri(Filename) when is_binary(Filename) ->
case binary:split(Filename, <<"-">>, [global]) of
[Year, Month, Day | _] ->
{ok, <<"https://lgsiot.njau.edu.cn/upload/", Year/binary, $/, Month/binary, $/, Day/binary, $/, Filename/binary>>};
_ ->
error
end.

View File

@ -1,141 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 17. 9 2025 16:05
%%%-------------------------------------------------------------------
-module(message_codec).
-author("anlicheng").
-include("message.hrl").
-define(I8, 1).
-define(I16, 2).
-define(I32, 3).
-define(Bytes, 4).
%% API
-export([encode/2, decode/1]).
-spec encode(MessageType :: integer(), Message :: any()) -> binary().
encode(MessageType, Message) when is_integer(MessageType) ->
Bin = encode0(Message),
<<MessageType, Bin/binary>>.
encode0(#auth_request{uuid = UUID, username = Username, salt = Salt, token = Token, timestamp = Timestamp}) ->
iolist_to_binary([
marshal(?Bytes, UUID),
marshal(?Bytes, Username),
marshal(?Bytes, Salt),
marshal(?Bytes, Token),
marshal(?I32, Timestamp)
]);
encode0(#auth_reply{code = Code, payload = Payload}) ->
iolist_to_binary([
marshal(?I32, Code),
marshal(?Bytes, Payload)
]);
encode0(#jsonrpc_reply{result = Result, error = undefined}) ->
ResultBin = erlang:term_to_binary(#{<<"result">> => Result}),
iolist_to_binary([
marshal(?Bytes, ResultBin)
]);
encode0(#jsonrpc_reply{result = undefined, error = Error}) ->
ResultBin = erlang:term_to_binary(#{<<"error">> => Error}),
iolist_to_binary([
marshal(?Bytes, ResultBin)
]);
encode0(#pub{topic = Topic, qos = Qos, content = Content}) ->
iolist_to_binary([
marshal(?Bytes, Topic),
marshal(?I8, Qos),
marshal(?Bytes, Content)
]);
encode0(#command{command_type = CommandType, command = Command}) ->
iolist_to_binary([
marshal(?I32, CommandType),
marshal(?Bytes, Command)
]);
encode0(#jsonrpc_request{method = Method, params = Params}) ->
ReqBody = erlang:term_to_binary(#{<<"method">> => Method, <<"params">> => Params}),
iolist_to_binary([
marshal(?Bytes, ReqBody)
]);
encode0(#data{route_key = RouteKey, metric = Metric}) ->
iolist_to_binary([
marshal(?Bytes, RouteKey),
marshal(?Bytes, Metric)
]);
encode0(#task_event_stream{task_id = TaskId, type = Type, stream = Stream}) ->
iolist_to_binary([
marshal(?I32, TaskId),
marshal(?Bytes, Type),
marshal(?Bytes, Stream)
]).
-spec decode(Bin :: binary()) -> {ok, Message :: any()} | error.
decode(<<PacketType:8, Packet/binary>>) ->
case unmarshal(Packet) of
{ok, Fields} ->
decode0(PacketType, Fields);
error ->
error
end.
decode0(?MESSAGE_AUTH_REQUEST, [UUID, Username, Salt, Token, Timestamp]) ->
{ok, #auth_request{uuid = UUID, username = Username, salt = Salt, token = Token, timestamp = Timestamp}};
decode0(?MESSAGE_JSONRPC_REPLY, [ReplyBin]) ->
case erlang:binary_to_term(ReplyBin) of
#{<<"result">> := Result} ->
{ok, #jsonrpc_reply{result = Result}};
#{<<"error">> := Error} ->
{ok, #jsonrpc_reply{error = Error}};
_ ->
error
end;
decode0(?MESSAGE_PUB, [Topic, Qos, Content]) ->
{ok, #pub{topic = Topic, qos = Qos, content = Content}};
decode0(?MESSAGE_COMMAND, [CommandType, Command]) ->
{ok, #command{command_type = CommandType, command = Command}};
decode0(?MESSAGE_AUTH_REPLY, [Code, Payload]) ->
{ok, #auth_reply{code = Code, payload = Payload}};
decode0(?MESSAGE_JSONRPC_REQUEST, [ReqBody]) ->
#{<<"method">> := Method, <<"params">> := Params} = erlang:binary_to_term(ReqBody),
{ok, #jsonrpc_request{method = Method, params = Params}};
decode0(?MESSAGE_DATA, [RouteKey, Metric]) ->
{ok, #data{route_key = RouteKey, metric = Metric}};
decode0(?MESSAGE_EVENT_STREAM, [TaskId, Type, Stream]) ->
{ok, #task_event_stream{task_id = TaskId, type = Type, stream = Stream}};
decode0(_, _) ->
error.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% helper methods
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-spec marshal(Type :: ?I8 | ?I16 | ?I32 | ?Bytes, Field :: integer() | binary()) -> binary().
marshal(?I8, Field) when is_integer(Field) ->
<<?I8, Field:8>>;
marshal(?I16, Field) when is_integer(Field) ->
<<?I16, Field:16>>;
marshal(?I32, Field) when is_integer(Field) ->
<<?I32, Field:32>>;
marshal(?Bytes, Field) when is_binary(Field) ->
Len = byte_size(Field),
<<?Bytes, Len:16, Field/binary>>.
-spec unmarshal(Bin :: binary()) -> {ok, Components :: [any()]} | error.
unmarshal(Bin) when is_binary(Bin) ->
unmarshal(Bin, []).
unmarshal(<<>>, Acc) ->
{ok, lists:reverse(Acc)};
unmarshal(<<?I8, F:8, Rest/binary>>, Acc) ->
unmarshal(Rest, [F|Acc]);
unmarshal(<<?I16, F:16, Rest/binary>>, Acc) ->
unmarshal(Rest, [F|Acc]);
unmarshal(<<?I32, F:32, Rest/binary>>, Acc) ->
unmarshal(Rest, [F|Acc]);
unmarshal(<<?Bytes, Len:16, F:Len/binary, Rest/binary>>, Acc) ->
unmarshal(Rest, [F|Acc]);
unmarshal(_, _) ->
error.

View File

@ -0,0 +1,154 @@
%%%-------------------------------------------------------------------
%%% @doc efka_client table store based on mnesia.
%%%-------------------------------------------------------------------
-module(efka_client_store).
-include("iot_tables.hrl").
-export([ensure_table/0, register/2, get/1, update/1, delete/1, list/0]).
-export([auth/2, verify_heartbeat/4]).
-define(TOKEN_HASH_ITERATIONS, 32).
-define(TOKEN_HASH_BYTES, 32).
-define(HEARTBEAT_TIMESTAMP_WINDOW, 120).
-spec ensure_table() -> ok | {error, term()}.
ensure_table() ->
case mnesia:create_table(efka_client, [
{attributes, record_info(fields, efka_client)},
{type, set},
{record_name, efka_client},
{disc_copies, [node()]}
]) of
{atomic, ok} ->
ok;
{aborted, {already_exists, efka_client}} ->
ok;
{aborted, Reason} ->
{error, Reason}
end.
-spec auth(UUID :: binary(), Token :: binary()) -> boolean().
auth(UUID, Token) when is_binary(UUID), UUID =/= <<>>, is_binary(Token) ->
case mnesia:dirty_read(efka_client, UUID) of
[] ->
false;
[#efka_client{token_hash = TokenHash, salt = Salt}] ->
hash_token(Token, Salt) =:= TokenHash
end.
-spec verify_heartbeat(UUID :: binary(), Timestamp :: integer(), Payload :: binary(), Mac :: binary()) -> boolean().
verify_heartbeat(UUID, Timestamp, Payload, Mac)
when is_binary(UUID), UUID =/= <<>>, is_integer(Timestamp), is_binary(Payload), is_binary(Mac) ->
case valid_heartbeat_timestamp(Timestamp) of
true ->
case mnesia:dirty_read(efka_client, UUID) of
[] ->
false;
[#efka_client{heartbeat_secret = HeartbeatSecret}] ->
ExpectedMac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload),
ExpectedMac =:= Mac
end;
false ->
false
end.
-spec register(UUID :: binary(), Token :: binary()) -> ok | {error, term()}.
register(UUID, Token) when is_binary(UUID), UUID =/= <<>>, is_binary(Token), byte_size(Token) >= 32 ->
case mnesia:transaction(fun() ->
case mnesia:read(efka_client, UUID, write) of
[] ->
%% 1. 16
Salt = crypto:strong_rand_bytes(16),
TokenHash = hash_token(Token, Salt),
HeartbeatSecret = heartbeat_secret(Token),
ok = mnesia:write(#efka_client{
uuid = UUID,
token_hash = TokenHash,
salt = Salt,
heartbeat_secret = HeartbeatSecret,
timestamp = iot_util:timestamp()
}),
ok;
[_] ->
{error, already_exists}
end
end) of
{atomic, ok} ->
ok;
{atomic, {error, Reason}} ->
{error, Reason};
{aborted, Reason} ->
{error, Reason}
end.
-spec get(binary()) -> {ok, #efka_client{}} | not_found | {error, term()}.
get(UUID) when is_binary(UUID), UUID =/= <<>> ->
case mnesia:transaction(fun() ->
case mnesia:read(efka_client, UUID, read) of
[Client = #efka_client{}] ->
{ok, Client};
[] ->
not_found
end
end) of
{atomic, Result} ->
Result;
{aborted, Reason} ->
{error, Reason}
end.
-spec update(#efka_client{}) -> ok | {error, term()}.
update(Client = #efka_client{uuid = UUID}) when is_binary(UUID), UUID =/= <<>> ->
case mnesia:transaction(fun() ->
case mnesia:read(efka_client, UUID, write) of
[_] ->
ok = mnesia:write(Client),
ok;
[] ->
{error, not_found}
end
end) of
{atomic, ok} ->
ok;
{atomic, {error, Reason}} ->
{error, Reason};
{aborted, Reason} ->
{error, Reason}
end.
-spec delete(binary()) -> ok | {error, term()}.
delete(UUID) when is_binary(UUID), UUID =/= <<>> ->
case mnesia:transaction(fun() ->
ok = mnesia:delete({efka_client, UUID}),
ok
end) of
{atomic, ok} ->
ok;
{aborted, Reason} ->
{error, Reason}
end.
-spec list() -> {ok, [#efka_client{}]} | {error, term()}.
list() ->
case mnesia:transaction(fun() ->
mnesia:foldl(fun(Client = #efka_client{}, Acc) -> [Client | Acc] end, [], efka_client)
end) of
{atomic, Clients} ->
{ok, lists:reverse(Clients)};
{aborted, Reason} ->
{error, Reason}
end.
-spec hash_token(binary(), binary()) -> binary().
hash_token(Token, Salt) when is_binary(Token), is_binary(Salt) ->
crypto:pbkdf2_hmac(sha256, Token, Salt, ?TOKEN_HASH_ITERATIONS, ?TOKEN_HASH_BYTES).
-spec heartbeat_secret(binary()) -> binary().
heartbeat_secret(Token) when is_binary(Token) ->
crypto:hash(sha256, Token).
-spec valid_heartbeat_timestamp(integer()) -> boolean().
valid_heartbeat_timestamp(Timestamp) ->
Now = iot_util:current_time(),
Timestamp =< Now andalso Now - Timestamp =< ?HEARTBEAT_TIMESTAMP_WINDOW.

View File

@ -0,0 +1,74 @@
%%%-------------------------------------------------------------------
%%% @doc Mnesia bootstrap and runtime startup helpers.
%%%
%%% `init/0` is a manual, one-time bootstrap operation. Application
%%% startup must call `start/0`, which only starts an existing schema and
%%% verifies required tables.
%%%-------------------------------------------------------------------
-module(iot_mnesia).
-include("iot_tables.hrl").
-export([safe_start/0, stop/0]).
-define(TABLES, [efka_client]).
-define(WAIT_TIMEOUT, 5000).
safe_start() ->
case ensure_schema() of
create_and_start ->
ok = mnesia:start(),
ok = create_tables(),
ok = wait_for_tables();
only_start ->
ok = mnesia:start(),
ok = wait_for_tables()
end,
log_runtime_info().
ensure_schema() ->
stopped = mnesia:stop(),
case mnesia:create_schema([node()]) of
ok ->
create_and_start;
{error, {_, {already_exists, _}}} ->
only_start;
{error, {_, already_exists}} ->
only_start;
{error, _Reason} ->
only_start
end.
-spec stop() -> ok.
stop() ->
_ = mnesia:stop(),
ok.
-spec create_tables() -> ok.
create_tables() ->
case efka_client_store:ensure_table() of
ok ->
ok;
{error, Reason} ->
{error, {create_table_failed, {efka_client, Reason}}}
end.
-spec wait_for_tables() -> ok.
wait_for_tables() ->
case mnesia:wait_for_tables(?TABLES, ?WAIT_TIMEOUT) of
ok ->
ok;
{timeout, Tables} ->
{error, {mnesia_tables_timeout, Tables}};
{error, Reason} ->
{error, {mnesia_tables_error, Reason}}
end.
-spec log_runtime_info() -> ok.
log_runtime_info() ->
logger:debug("[iot_mnesia] node: ~p, dir: ~p, tables: ~p", [
node(),
mnesia:system_info(directory),
mnesia:system_info(tables)
]),
ok.

View File

@ -1,48 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2018, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 29. 2018 17:01
%%%-------------------------------------------------------------------
-module(mysql_pool).
-author("aresei").
%% API
-export([get_row/2, get_row/3, get_all/2, get_all/3]).
-export([update/4, update_by/2, update_by/3, insert/4]).
%%
-spec get_row(Pool :: atom(), Sql::binary()) -> {ok, Record::map()} | undefined.
get_row(Pool, Sql) when is_atom(Pool), is_binary(Sql) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_row(ConnPid, Sql) end).
-spec get_row(Pool :: atom(), Sql::binary(), Params::list()) -> {ok, Record::map()} | undefined.
get_row(Pool, Sql, Params) when is_atom(Pool), is_binary(Sql), is_list(Params) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_row(ConnPid, Sql, Params) end).
-spec get_all(Pool :: atom(), Sql::binary()) -> {ok, Rows::list()} | {error, Reason :: any()}.
get_all(Pool, Sql) when is_atom(Pool), is_binary(Sql) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_all(ConnPid, Sql) end).
-spec get_all(Pool :: atom(), Sql::binary(), Params::list()) -> {ok, Rows::list()} | {error, Reason::any()}.
get_all(Pool, Sql, Params) when is_atom(Pool), is_binary(Sql), is_list(Params) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_all(ConnPid, Sql, Params) end).
-spec insert(Pool :: atom(), Table :: binary(), Fields :: map() | list(), boolean()) ->
ok | {ok, InsertId :: integer()} | {error, Reason :: any()}.
insert(Pool, Table, Fields, FetchInsertId) when is_atom(Pool), is_binary(Table), is_list(Fields); is_map(Fields), is_boolean(FetchInsertId) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:insert(ConnPid, Table, Fields, FetchInsertId) end).
-spec update_by(Pool :: atom(), UpdateSql :: binary()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}.
update_by(Pool, UpdateSql) when is_atom(Pool), is_binary(UpdateSql) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update_by(ConnPid, UpdateSql) end).
-spec update_by(Pool :: atom(), UpdateSql :: binary(), Params :: list()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}.
update_by(Pool, UpdateSql, Params) when is_atom(Pool), is_binary(UpdateSql) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update_by(ConnPid, UpdateSql, Params) end).
-spec update(Pool :: atom(), Table :: binary(), Fields :: map(), WhereFields :: map()) -> {ok, AffectedRows::integer()} | {error, Reason::any()}.
update(Pool, Table, Fields, WhereFields) when is_atom(Pool), is_binary(Table), is_map(Fields), is_map(WhereFields) ->
poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update(ConnPid, Table, Fields, WhereFields) end).

View File

@ -1,144 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author aresei
%%% @copyright (C) 2018, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 29. 2018 17:01
%%%-------------------------------------------------------------------
-module(mysql_provider).
-author("aresei").
%% API
-export([get_row/2, get_row/3, get_all/2, get_all/3]).
-export([update/4, update_by/2, update_by/3, insert/4]).
%%
-spec get_row(ConnPid :: pid(), Sql::binary()) -> {ok, Record::map()} | undefined.
get_row(ConnPid, Sql) when is_pid(ConnPid), is_binary(Sql) ->
lager:debug("[mysql_client] get_row sql is: ~p", [Sql]),
case mysql:query(ConnPid, Sql) of
{ok, Names, [Row | _]} ->
{ok, maps:from_list(lists:zip(Names, Row))};
{ok, _, []} ->
undefined;
Error ->
lager:warning("[mysql_client] get error: ~p", [Error]),
undefined
end.
-spec get_row(ConnPid :: pid(), Sql::binary(), Params::list()) -> {ok, Record::map()} | undefined.
get_row(ConnPid, Sql, Params) when is_pid(ConnPid), is_binary(Sql), is_list(Params) ->
lager:debug("[mysql_client] get_row sql is: ~p, params: ~p", [Sql, Params]),
case mysql:query(ConnPid, Sql, Params) of
{ok, Names, [Row | _]} ->
{ok, maps:from_list(lists:zip(Names, Row))};
{ok, _, []} ->
undefined;
Error ->
lager:warning("[mysql_client] get error: ~p", [Error]),
undefined
end.
-spec get_all(ConnPid :: pid(), Sql::binary()) -> {ok, Rows::list()} | {error, Reason :: any()}.
get_all(ConnPid, Sql) when is_pid(ConnPid), is_binary(Sql) ->
lager:debug("[mysql_client] get_all sql is: ~p", [Sql]),
case mysql:query(ConnPid, Sql) of
{ok, Names, Rows} ->
{ok, lists:map(fun(Row) -> maps:from_list(lists:zip(Names, Row)) end, Rows)};
{error, Reason} ->
lager:warning("[mysql_client] get error: ~p", [Reason]),
{error, Reason}
end.
-spec get_all(ConnPid :: pid(), Sql::binary(), Params::list()) -> {ok, Rows::list()} | {error, Reason::any()}.
get_all(ConnPid, Sql, Params) when is_pid(ConnPid), is_binary(Sql), is_list(Params) ->
lager:debug("[mysql_client] get_all sql is: ~p, params: ~p", [Sql, Params]),
case mysql:query(ConnPid, Sql, Params) of
{ok, Names, Rows} ->
{ok, lists:map(fun(Row) -> maps:from_list(lists:zip(Names, Row)) end, Rows)};
{error, Reason} ->
lager:warning("[mysql_client] get error: ~p", [Reason]),
{error, Reason}
end.
-spec insert(ConnPid :: pid(), Table :: binary(), Fields :: map() | list(), boolean()) ->
ok | {ok, InsertId :: integer()} | {error, Reason :: any()}.
insert(ConnPid, Table, Fields, FetchInsertId) when is_pid(ConnPid), is_binary(Table), is_map(Fields), is_boolean(FetchInsertId) ->
insert(ConnPid, Table, maps:to_list(Fields), FetchInsertId);
insert(ConnPid, Table, Fields, FetchInsertId) when is_pid(ConnPid), is_binary(Table), is_list(Fields), is_boolean(FetchInsertId) ->
{Keys, Values} = kvs(Fields),
FieldSql = iolist_to_binary(lists:join(<<", ">>, Keys)),
Placeholders = lists:duplicate(length(Keys), <<"?">>),
ValuesPlaceholder = iolist_to_binary(lists:join(<<", ">>, Placeholders)),
Sql = <<"INSERT INTO ", Table/binary, "(", FieldSql/binary, ") VALUES(", ValuesPlaceholder/binary, ")">>,
lager:debug("[mysql_client] insert sql is: ~p, params: ~p", [Sql, Values]),
case mysql:query(ConnPid, Sql, Values) of
ok ->
case FetchInsertId of
true ->
InsertId = mysql:insert_id(ConnPid),
{ok, InsertId};
false ->
ok
end;
Error ->
Error
end.
-spec update_by(ConnPid :: pid(), UpdateSql :: binary()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}.
update_by(ConnPid, UpdateSql) when is_pid(ConnPid), is_binary(UpdateSql) ->
lager:debug("[mysql_client] updateBySql sql: ~p", [UpdateSql]),
case mysql:query(ConnPid, UpdateSql) of
ok ->
AffectedRows = mysql:affected_rows(ConnPid),
{ok, AffectedRows};
Error ->
Error
end.
-spec update_by(ConnPid :: pid(), UpdateSql :: binary(), Params :: list()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}.
update_by(ConnPid, UpdateSql, Params) when is_pid(ConnPid), is_binary(UpdateSql) ->
lager:debug("[mysql_client] updateBySql sql: ~p, params: ~p", [UpdateSql, Params]),
case mysql:query(ConnPid, UpdateSql, Params) of
ok ->
AffectedRows = mysql:affected_rows(ConnPid),
{ok, AffectedRows};
Error ->
Error
end.
-spec update(ConnPid :: pid(), Sql :: binary(), Fields :: map(), WhereFields :: map()) ->
{ok, AffectedRows::integer()} | {error, Reason::any()}.
update(ConnPid, Table, Fields, WhereFields) when is_pid(ConnPid), is_binary(Table), is_map(Fields), is_map(WhereFields) ->
%% set
{SetKeys, SetVals} = kvs(Fields),
SetKeys1 = lists:map(fun(K) when is_binary(K) -> <<"`", K/binary, "` = ?">> end, SetKeys),
SetSql = iolist_to_binary(lists:join(<<", ">>, SetKeys1)),
%% where
{WhereKeys, WhereVals} = kvs(WhereFields),
WhereKeys1 = lists:map(fun(K) when is_binary(K) -> <<"`", K/binary, "` = ?">> end, WhereKeys),
WhereSql = iolist_to_binary(lists:join(<<" AND ">>, WhereKeys1)),
Params = SetVals ++ WhereVals,
Sql = <<"UPDATE ", Table/binary, " SET ", SetSql/binary, " WHERE ", WhereSql/binary>>,
lager:debug("[mysql_client] update sql is: ~p, params: ~p", [Sql, Params]),
case mysql:query(ConnPid, Sql, Params) of
ok ->
AffectedRows = mysql:affected_rows(ConnPid),
{ok, AffectedRows};
Error ->
lager:error("[mysql_client] update sql: ~p, params: ~p, get a error: ~p", [Sql, Params, Error]),
Error
end.
-spec kvs(Fields :: map() | list()) -> {Keys :: list(), Values :: list()}.
kvs(Fields) when is_map(Fields) ->
kvs(maps:to_list(Fields));
kvs(Fields) when is_list(Fields) ->
{Keys0, Values0} = lists:foldl(fun({K, V}, {Acc0, Acc1}) -> {[K|Acc0], [V|Acc1]} end, {[], []}, Fields),
{lists:reverse(Keys0), lists:reverse(Values0)}.

View File

@ -0,0 +1,156 @@
%%%-------------------------------------------------------------------
%%% @author Codex
%%% @doc
%%% control api iot_api_client
%%% @end
%%%-------------------------------------------------------------------
-module(simulator_api_handler).
-include_lib("endpoint/include/endpoint.hrl").
-export([handle_request/4]).
-define(HOST_UUID, <<"qbxmjyzrkpntfgswaevodhluicqzxplkm">>).
-define(HOST_ID, 1).
-define(DEVICE_UUID, <<"sim-device-001">>).
-define(DEVICE_ID, 1).
-define(ENDPOINT_ID, 1).
-define(TABLE, ?MODULE).
-spec handle_request(string(), string(), map(), map()) ->
{ok, non_neg_integer(), iodata()}.
handle_request("GET", "/simulator/get_all_hosts", _, _) ->
{ok, 200, iot_util:json_data([?HOST_UUID])};
handle_request("GET", "/simulator/get_host_by_uuid", #{<<"uuid">> := UUID}, _) when is_binary(UUID) ->
reply_host_by_uuid(UUID);
handle_request("GET", "/simulator/get_host_by_id", #{<<"host_id">> := HostIdBin}, _) when is_binary(HostIdBin) ->
case binary_to_integer(HostIdBin) of
?HOST_ID ->
{ok, 200, iot_util:json_data(host_info())};
_ ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)}
end;
handle_request("POST", "/simulator/change_host_status", _, #{<<"uuid">> := UUID, <<"new_status">> := Status})
when is_binary(UUID), is_integer(Status) ->
reply_change_host_status(UUID, Status);
handle_request("GET", "/simulator/get_host_devices", #{<<"host_id">> := HostIdBin}, _) when is_binary(HostIdBin) ->
case binary_to_integer(HostIdBin) of
?HOST_ID ->
{ok, 200, iot_util:json_data([device_info()])};
_ ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)}
end;
handle_request("GET", "/simulator/get_device_by_uuid", #{<<"device_uuid">> := DeviceUUID}, _) when is_binary(DeviceUUID) ->
case DeviceUUID of
?DEVICE_UUID ->
{ok, 200, iot_util:json_data(device_info())};
_ ->
{ok, 200, iot_util:json_error(404, <<"device not found">>)}
end;
handle_request("POST", "/simulator/change_device_status", _, #{<<"device_uuid">> := DeviceUUID, <<"new_status">> := Status})
when is_binary(DeviceUUID), is_integer(Status) ->
reply_change_device_status(DeviceUUID, Status);
handle_request("GET", "/simulator/get_all_endpoints", _, _) ->
{ok, 200, iot_util:json_data([endpoint_info()])};
handle_request("GET", "/simulator/get_endpoint", #{<<"id">> := IdBin}, _) when is_binary(IdBin) ->
case binary_to_integer(IdBin) of
?ENDPOINT_ID ->
{ok, 200, iot_util:json_data(endpoint_info())};
_ ->
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}
end;
handle_request("POST", "/simulator/endpoint_sink", _, Body) ->
logger:debug("[simulator_api_handler] receive endpoint sink body: ~p", [Body]),
{ok, 200, iot_util:json_data(<<"ok">>)};
handle_request(_, Path, _, _) ->
PathBin = list_to_binary(Path),
{ok, 200, iot_util:json_error(-1, <<"url: ", PathBin/binary, " not found">>)}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec reply_host_by_uuid(binary()) -> {ok, non_neg_integer(), iodata()}.
reply_host_by_uuid(?HOST_UUID) ->
{ok, 200, iot_util:json_data(host_info())};
reply_host_by_uuid(_) ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)}.
-spec reply_change_host_status(binary(), integer()) -> {ok, non_neg_integer(), iodata()}.
reply_change_host_status(?HOST_UUID, Status) ->
ok = put_state({host_status, ?HOST_UUID}, Status),
{ok, 200, iot_util:json_data(1)};
reply_change_host_status(_, _) ->
{ok, 200, iot_util:json_error(404, <<"host not found">>)}.
-spec reply_change_device_status(binary(), integer()) -> {ok, non_neg_integer(), iodata()}.
reply_change_device_status(?DEVICE_UUID, Status) ->
ok = put_state({device_status, ?DEVICE_UUID}, Status),
{ok, 200, iot_util:json_data(1)};
reply_change_device_status(_, _) ->
{ok, 200, iot_util:json_error(404, <<"device not found">>)}.
-spec host_info() -> map().
host_info() ->
#{
<<"id">> => ?HOST_ID,
<<"uuid">> => ?HOST_UUID,
<<"authorize_status">> => 1,
<<"status">> => get_state({host_status, ?HOST_UUID}, 1)
}.
-spec device_info() -> map().
device_info() ->
#{
<<"id">> => ?DEVICE_ID,
<<"host_id">> => ?HOST_ID,
<<"device_uuid">> => ?DEVICE_UUID,
<<"status">> => get_state({device_status, ?DEVICE_UUID}, 1)
}.
-spec endpoint_info() -> map().
endpoint_info() ->
#{
<<"id">> => ?ENDPOINT_ID,
<<"matcher">> => <<"simulator.metric">>,
<<"title">> => <<"Simulator HTTP Endpoint">>,
<<"type">> => <<"http">>,
<<"config">> => #{
<<"url">> => <<"http://127.0.0.1:18090/simulator/endpoint_sink">>,
<<"pool_size">> => 4
},
<<"status">> => 1,
<<"updated_at">> => iot_util:timestamp(),
<<"created_at">> => iot_util:timestamp()
}.
-spec ensure_table() -> ets:tid().
ensure_table() ->
case ets:info(?TABLE) of
undefined ->
try ets:new(?TABLE, [named_table, public, set]) of
Tid ->
Tid
catch
error:badarg ->
?TABLE
end;
_ ->
?TABLE
end.
-spec get_state(term(), term()) -> term().
get_state(Key, Default) ->
_ = ensure_table(),
case ets:lookup(?TABLE, Key) of
[{_, Value}] ->
Value;
[] ->
Default
end.
-spec put_state(term(), term()) -> ok.
put_state(Key, Value) ->
_ = ensure_table(),
true = ets:insert(?TABLE, {Key, Value}),
ok.

View File

@ -1,195 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author licheng5
%%% @copyright (C) 2021, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 11. 1 2021 12:17
%%%-------------------------------------------------------------------
-module(tcp_channel).
-author("licheng5").
-include("message.hrl").
-behaviour(ranch_protocol).
%% API
-export([pub/4, jsonrpc_call/3, command/3]).
-export([start_link/3, stop/2]).
%% gen_server callbacks
-export([init/3, handle_call/3, handle_cast/2, handle_info/2, code_change/3, terminate/2]).
-record(state, {
transport,
socket,
uuid :: undefined | binary(),
%% id
host_pid = undefined,
%% id
packet_id = 1 :: integer(),
%%
inflight = #{}
}).
%%
-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> no_return().
pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) ->
gen_server:cast(Pid, {pub, Topic, Qos, Content}).
%%
-spec command(Pid :: pid(), CommandType :: integer(), Command :: binary()) -> no_return().
command(Pid, CommandType, Command) when is_pid(Pid), is_integer(CommandType), is_binary(Command) ->
gen_server:cast(Pid, {command, CommandType, Command}).
%%
-spec jsonrpc_call(Pid :: pid(), ReceiverPid :: pid(), CallBin :: binary()) -> Ref :: reference().
jsonrpc_call(Pid, ReceiverPid, CallBin) when is_pid(Pid), is_pid(ReceiverPid), is_binary(CallBin) ->
Ref = make_ref(),
gen_server:cast(Pid, {jsonrpc_call, ReceiverPid, Ref, CallBin}),
Ref.
%%
-spec stop(Pid :: pid(), Reason :: any()) -> no_return().
stop(undefined, _Reason) ->
ok;
stop(Pid, Reason) when is_pid(Pid) ->
gen_server:stop(Pid, Reason, 5000).
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
start_link(Ref, Transport, Opts) ->
{ok, proc_lib:spawn_link(?MODULE, init, [Ref, Transport, Opts])}.
init(Ref, Transport, _Opts = []) ->
{ok, Socket} = ranch:handshake(Ref),
lager:debug("[sdlan_channel] get a new connection: ~p", [Socket]),
Transport:setopts(Socket, [binary, {active, true}, {packet, 4}]),
% erlang:start_timer(?PING_TICKER, self(), ping_ticker),
gen_server:enter_loop(?MODULE, [], #state{transport = Transport, socket = Socket}).
handle_call(_Request, _From, State) ->
{reply, ok, State}.
%% , pub/sub机制
handle_cast({pub, Topic, Qos, Content}, State = #state{transport = Transport, socket = Socket}) ->
EncPub = message_codec:encode(?MESSAGE_PUB, #pub{topic = Topic, qos = Qos, content = Content}),
Transport:send(Socket, <<?PACKET_CAST, EncPub/binary>>),
{noreply, State};
%% Command消息
handle_cast({command, CommandType, Command}, State = #state{transport = Transport, socket = Socket}) ->
EncCommand = message_codec:encode(?MESSAGE_COMMAND, #command{command_type = CommandType, command = Command}),
Transport:send(Socket, <<?PACKET_CAST, EncCommand/binary>>),
{noreply, State};
%%
handle_cast({jsonrpc_call, ReceiverPid, Ref, CallBin}, State = #state{transport = Transport, socket = Socket, packet_id = PacketId, inflight = Inflight}) ->
Transport:send(Socket, <<?PACKET_REQUEST, PacketId:32, CallBin/binary>>),
{noreply, State#state{packet_id = PacketId + 1, inflight = maps:put(PacketId, {ReceiverPid, Ref}, Inflight)}}.
%% auth验证
handle_info({tcp, Socket, <<?PACKET_REQUEST, PacketId:32, RequestBin/binary>>}, State = #state{transport = Transport, socket = Socket}) ->
{ok, #auth_request{uuid = UUID, username = Username, token = Token, salt = Salt, timestamp = Timestamp}} = message_codec:decode(RequestBin),
lager:debug("[ws_channel] auth uuid: ~p", [UUID]),
case iot_auth:check(Username, Token, UUID, Salt, Timestamp) of
true ->
case iot_api:get_host_by_uuid(UUID) of
undefined ->
lager:warning("[ws_channel] uuid: ~p, user: ~p, host not found", [UUID, Username]),
{stop, State};
{ok, _} ->
%%
{ok, HostPid} = iot_host_sup:ensured_host_started(UUID),
case iot_host:attach_channel(HostPid, self()) of
ok ->
%% host的monitor
erlang:monitor(process, HostPid),
AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 0, payload = <<"ok">>}),
Transport:send(Socket, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
{noreply, State#state{uuid = UUID, host_pid = HostPid}};
{denied, Reason} when is_binary(Reason) ->
erlang:monitor(process, HostPid),
AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 1, payload = Reason}),
Transport:send(Socket, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
lager:debug("[ws_channel] uuid: ~p, attach channel get error: ~p, stop channel", [UUID, Reason]),
{noreply, State#state{uuid = UUID, host_pid = HostPid}};
{error, Reason} when is_binary(Reason) ->
AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 2, payload = Reason}),
Transport:send(Socket, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
lager:debug("[ws_channel] uuid: ~p, attach channel get error: ~p, stop channel", [UUID, Reason]),
{stop, State}
end
end;
false ->
lager:warning("[ws_channel] uuid: ~p, user: ~p, auth failed", [UUID, Username]),
{stop, State}
end;
handle_info({tcp, Socket, <<?PACKET_CAST, CastBin/binary>>}, State = #state{socket = Socket, host_pid = HostPid}) when is_pid(HostPid) ->
{ok, CastMessage} = message_codec:decode(CastBin),
case CastMessage of
#data{} = Data ->
iot_host:handle(HostPid, {data, Data});
#task_event_stream{task_id = TaskId, type = <<"close">>, stream = Reason} ->
iot_event_stream_observer:stream_close(TaskId, Reason);
#task_event_stream{task_id = TaskId, type = Type, stream = Stream} ->
lager:debug("[tcp_channel] get task_id: ~p, type: ~ts, stream: ~ts", [TaskId, Type, Stream]),
iot_event_stream_observer:stream_data(TaskId, Type, Stream)
end,
{noreply, State};
%handle_info({tcp, Socket, <<?PACKET_PING, PingData/binary>>}, State = #state{socket = Socket, host_pid = HostPid}) when is_pid(HostPid) ->
% Ping = message_pb:decode_msg(PingData, ping),
% iot_host:handle(HostPid, {ping, Ping}),
% {noreply, State};
%%
handle_info({tcp, Socket, <<?PACKET_RESPONSE, PacketId:32, ResponseBin/binary>>}, State = #state{socket = Socket, inflight = Inflight}) when PacketId > 0 ->
{ok, RpcReply} = message_codec:decode(ResponseBin),
case maps:take(PacketId, Inflight) of
error ->
{noreply, State};
{{ReceiverPid, Ref}, NInflight} ->
case is_pid(ReceiverPid) andalso is_process_alive(ReceiverPid) of
true ->
ReceiverPid ! {jsonrpc_reply, Ref, RpcReply};
false ->
lager:warning("[ws_channel] get async_call_reply message: ~p, packet_id: ~p, but receiver_pid is deaded", [RpcReply, PacketId])
end,
{noreply, State#state{inflight = NInflight}}
end;
handle_info({tcp_error, Sock, Reason}, State = #state{socket = Sock}) ->
lager:notice("[sdlan_channel] tcp_error: ~p", [Reason]),
{stop, normal, State};
handle_info({tcp_closed, Sock}, State = #state{socket = Sock}) ->
lager:notice("[sdlan_channel] tcp_closed"),
{stop, normal, State};
%%
handle_info({stop, Reason}, State) ->
{stop, Reason, State};
%%
handle_info({'DOWN', _, process, HostPid, Reason}, State = #state{uuid = UUID, host_pid = HostPid}) ->
lager:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]),
{stop, State};
handle_info(Info, State) ->
lager:warning("[sdlan_channel] get a unknown message: ~p, channel will closed, state: ~p", [Info, State]),
{noreply, State}.
terminate(Reason, #state{}) ->
lager:warning("[sdlan_channel] stop with reason: ~p", [Reason]),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.

View File

@ -0,0 +1,369 @@
%%%-------------------------------------------------------------------
%%% @author licheng5
%%% @copyright (C) 2021, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 11. 1 2021 12:17
%%%-------------------------------------------------------------------
-module(ssl_channel).
-author("licheng5").
-behaviour(ranch_protocol).
-define(INFLIGHT_TIMEOUT, 60000).
-define(SSL_IDLE_TIMEOUT, 120000).
%% API
-export([pub/4, container_call/4, cancel_command_call/2]).
-export([start_link/3, stop/2]).
%% gen_server callbacks
-export([init/3, handle_call/3, handle_cast/2, handle_info/2, code_change/3, terminate/2]).
-record(state, {
transport,
socket,
uuid :: undefined | binary(),
is_authed = false :: boolean(),
%% id
host_pid = undefined,
%% iot command command_response
inflight = #{},
idle_timer_ref :: undefined | reference()
}).
-record(inflight_command, {
receiver_pid :: undefined | pid(),
timer_ref :: reference()
}).
-type request_ref() :: binary().
%%
-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok.
pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) ->
gen_server:cast(Pid, {pub, Topic, Qos, Content}).
-spec container_call(Pid :: pid(), ReceiverPid :: pid(), Ref :: request_ref(), Request :: map()) -> ok.
container_call(Pid, ReceiverPid, Ref, Request) when is_pid(Pid), is_pid(ReceiverPid), is_binary(Ref), is_map(Request) ->
gen_server:cast(Pid, {command_call, ReceiverPid, Ref, {container, Request}}),
ok.
-spec cancel_command_call(Pid :: pid(), Ref :: request_ref()) -> ok.
cancel_command_call(Pid, Ref) when is_pid(Pid), is_binary(Ref) ->
gen_server:call(Pid, {cancel_command_call, Ref}).
%%
-spec stop(Pid :: undefined | pid(), Reason :: any()) -> ok.
stop(undefined, _Reason) ->
ok;
stop(Pid, Reason) when is_pid(Pid) ->
gen_server:stop(Pid, Reason, 5000).
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
start_link(Ref, Transport, Opts) ->
{ok, proc_lib:spawn_link(?MODULE, init, [Ref, Transport, Opts])}.
init(Ref, Transport, _Opts = []) ->
ok = iot_log:set_metadata(),
{ok, Socket} = ranch:handshake(Ref),
logger:debug("[ssl_channel] get a new connection: ~p", [Socket]),
Transport:setopts(Socket, [binary, {active, true}, {packet, 4}]),
IdleTimerRef = start_idle_timer(),
gen_server:enter_loop(?MODULE, [], #state{transport = Transport, socket = Socket, idle_timer_ref = IdleTimerRef}).
handle_call({cancel_command_call, Ref}, _From, State = #state{inflight = Inflight}) ->
case maps:take(Ref, Inflight) of
{#inflight_command{timer_ref = TimerRef}, NInflight} ->
erlang:cancel_timer(TimerRef),
{reply, ok, State#state{inflight = NInflight}};
error ->
{reply, ok, State}
end;
handle_call(_Request, _From, State) ->
{reply, ok, State}.
%% , pub/sub机制
handle_cast({pub, Topic, Qos, Content}, State = #state{transport = Transport, socket = Socket}) ->
Packet = term_to_binary({<<"message">>, {<<"pub">>, #{<<"topic">> => Topic, <<"qos">> => Qos, <<"content">> => Content}}}),
case Transport:send(Socket, Packet) of
ok ->
{noreply, State};
{error, Reason} ->
logger:warning("[ssl_channel] send pub failed, reason: ~p", [Reason]),
{stop, {send_failed, Reason}, State}
end;
%% iot efka 使 command/command_response
handle_cast({command_call, ReceiverPid, Ref, Body}, State = #state{transport = Transport, socket = Socket, inflight = Inflight}) ->
Packet = term_to_binary({<<"command">>, Ref, encode_command_body(Body)}),
case Transport:send(Socket, Packet) of
ok ->
TimerRef = erlang:start_timer(?INFLIGHT_TIMEOUT, self(), {command_timeout, Ref}),
CommandInfo = #inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef},
{noreply, State#state{inflight = maps:put(Ref, CommandInfo, Inflight)}};
{error, Reason} ->
logger:warning("[ssl_channel] send command failed, ref: ~p, reason: ~p", [Ref, Reason]),
deliver_command_error(ReceiverPid, Ref, {send_failed, Reason}),
{stop, {send_failed, Reason}, State}
end.
handle_info({timeout, TimerRef, {command_timeout, Ref}}, State = #state{inflight = Inflight}) ->
case maps:get(Ref, Inflight, undefined) of
#inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef} ->
logger:warning("[ws_channel] command timeout, ref: ~p", [Ref]),
reply_command_timeout(ReceiverPid, Ref),
{noreply, State#state{inflight = maps:remove(Ref, Inflight)}};
_ ->
{noreply, State}
end;
handle_info({timeout, TimerRef, ssl_idle_timeout}, State = #state{idle_timer_ref = TimerRef}) ->
logger:notice("[ssl_channel] ssl channel idle timeout"),
{stop, ssl_idle_timeout, State#state{idle_timer_ref = undefined}};
handle_info({timeout, _TimerRef, ssl_idle_timeout}, State) ->
{noreply, State};
%%
handle_info({stop, Reason}, State) ->
{stop, Reason, State};
%%
handle_info({'DOWN', _, process, HostPid, Reason}, State = #state{uuid = UUID, host_pid = HostPid}) ->
logger:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]),
{stop, Reason, State};
handle_info({ssl, Socket, PacketBin}, State = #state{socket = Socket}) when is_binary(PacketBin) ->
State1 = reset_idle_timer(State),
try binary_to_term(PacketBin, [safe]) of
{<<"request">>, Ref, Body} ->
handle_request_frame(Ref, Body, State1);
{<<"message">>, Body} ->
handle_message_frame(Body, State1);
{<<"command_response">>, Ref, Response} ->
handle_command_response_frame(Ref, Response, State1);
Other ->
logger:warning("[ssl_channel] unsupported packet: ~p", [Other]),
{stop, bad_packet, State1}
catch error:Error ->
logger:warning("[ssl_channel] binary_to_term get error: ~p, packet_size: ~p", [Error, byte_size(PacketBin)]),
{stop, bad_packet, State1}
end;
handle_info({ssl_closed, Socket}, State = #state{socket = Socket}) ->
logger:notice("[ssl_channel] ssl socket closed"),
{stop, normal, State};
handle_info({ssl_error, Socket, Reason}, State = #state{socket = Socket}) ->
logger:notice("[ssl_channel] ssl socket error: ~p", [Reason]),
{stop, normal, State};
handle_info({ssl_passive, Socket}, State = #state{transport = Transport, socket = Socket}) ->
ok = Transport:setopts(Socket, [{active, true}]),
{noreply, State};
handle_info(Info, State) ->
logger:warning("[ssl_channel] get a unknown message: ~p, state: ~p", [Info, State]),
{noreply, State}.
terminate(Reason, #state{inflight = Inflight, transport = Transport, socket = Socket, idle_timer_ref = IdleTimerRef}) ->
cancel_timer(IdleTimerRef),
maps:foreach(fun(Ref, CommandInfo) -> reply_command_closed(Ref, CommandInfo, Reason) end, Inflight),
Transport:close(Socket),
logger:warning("[ssl_channel] stop with reason: ~p", [Reason]),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%% helper methods
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-spec handle_request_frame(request_ref(), tuple(), #state{}) -> {noreply, #state{}} | {stop, term(), #state{}}.
handle_request_frame(Ref, {<<"auth_request">>, #{<<"uuid">> := UUID}}, State = #state{is_authed = true}) ->
logger:warning("[ws_channel] repeated auth request, ref: ~p, uuid: ~p, close channel", [Ref, UUID]),
{stop, repeated_auth, State};
handle_request_frame(Ref, {<<"auth_request">>, #{<<"uuid">> := UUID, <<"token">> := Token, <<"timestamp">> := Timestamp}}, State = #state{transport = Transport, socket = Socket}) ->
maybe
ok ?= auth(Token, UUID, Timestamp),
{ok, HostPid} ?= iot_host:lookup_pid(UUID),
ok ?= iot_host:attach_channel(HostPid, self()),
erlang:monitor(process, HostPid),
ok = send_reply_frame(Transport, Socket, Ref, {<<"auth_response">>, <<"ok">>}),
logger:debug("[ws_channel] auth uuid: ~p", [UUID]),
{noreply, State#state{uuid = UUID, is_authed = true, host_pid = HostPid}}
else
{error, Reason} ->
logger:warning("[ws_channel] uuid: ~p, auth failed with reason: ~p", [UUID, Reason]),
case send_reply_frame(Transport, Socket, Ref, {<<"auth_response">>, {<<"error">>, {<<"failed">>, Reason}}}) of
ok ->
{stop, Reason, State};
{error, SendReason} ->
logger:warning("[ws_channel] uuid: ~p, send auth failed response failed: ~p", [UUID, SendReason]),
{stop, {send_failed, SendReason}, State}
end
end;
handle_request_frame(Ref, {<<"container">>, ContainerCommand}, State) ->
logger:warning("[ws_channel] unsupported request message type: container, ref: ~p, command: ~p", [Ref, ContainerCommand]),
{stop, normal, State};
handle_request_frame(Ref, Body, State) ->
logger:warning("[ws_channel] unsupported request body, ref: ~p, body: ~p", [Ref, Body]),
{stop, normal, State}.
-spec handle_message_frame(term(), #state{}) ->
{noreply, #state{}} | {stop, term(), #state{}}.
handle_message_frame(<<"ping">>, State = #state{transport = Transport, socket = Socket}) ->
Packet = term_to_binary({<<"message">>, <<"pong">>}),
case Transport:send(Socket, Packet) of
ok ->
{noreply, State};
{error, Reason} ->
logger:warning("[ssl_channel] send pong failed, reason: ~p", [Reason]),
{stop, {send_failed, Reason}, State}
end;
handle_message_frame({<<"data">>, #{<<"route_key">> := RouteKey, <<"metric">> := Metric}}, State = #state{host_pid = HostPid}) when is_pid(HostPid) ->
iot_host:handle(HostPid, {data, RouteKey, Metric}),
{noreply, State};
handle_message_frame({<<"task_event">>, Event}, State = #state{uuid = UUID, host_pid = HostPid}) when is_binary(UUID), is_pid(HostPid) ->
handle_event_stream_frame(UUID, Event),
{noreply, State};
handle_message_frame(Body, State) ->
logger:warning("[ssl_channel] unsupported message body: ~p", [Body]),
{noreply, State}.
-spec handle_event_stream_frame(binary(), map()) -> ok.
handle_event_stream_frame(UUID, #{<<"task_id">> := TaskId, <<"type">> := <<"close">>, <<"stream">> := Reason}) ->
logger:debug("[ssl_channel] get uuid: ~p, task_id: ~p, close with reason: ~p", [UUID, TaskId, Reason]),
iot_container_task_sup:close(UUID, TaskId, Reason);
handle_event_stream_frame(UUID, #{<<"task_id">> := TaskId, <<"type">> := Type, <<"stream">> := Stream}) ->
logger:debug("[ssl_channel] get uuid: ~p, task_id: ~p, type: ~ts, stream: ~ts", [UUID, TaskId, Type, Stream]),
iot_container_task_sup:stream(UUID, TaskId, Type, Stream);
handle_event_stream_frame(UUID, Event) ->
logger:warning("[ssl_channel] invalid task_event, uuid: ~p, event: ~p", [UUID, Event]),
ok.
-spec handle_command_response_frame(request_ref(), tuple(), #state{}) ->
{noreply, #state{}}.
handle_command_response_frame(Ref, Reply, State = #state{inflight = Inflight}) when is_binary(Ref) ->
case maps:take(Ref, Inflight) of
error ->
{noreply, State};
{#inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef}, NInflight} ->
erlang:cancel_timer(TimerRef),
deliver_command_response(ReceiverPid, Ref, Reply),
{noreply, State#state{inflight = NInflight}}
end;
handle_command_response_frame(Ref, Reply, State) ->
logger:warning("[ws_channel] unexpected command_response frame, ref: ~p, reply: ~p", [Ref, Reply]),
{noreply, State}.
-spec send_reply_frame(module(), any(), request_ref(), tuple()) -> ok | {error, term()}.
send_reply_frame(Transport, Socket, Ref, Reply) ->
Packet = term_to_binary({<<"response">>, Ref, Reply}),
Transport:send(Socket, Packet).
-spec decode_command_response({binary(), term()} | tuple()) ->
ok | {ok, term()} | {error, term()}.
decode_command_response({<<"container">>, <<"ok">>}) ->
ok;
decode_command_response({<<"container">>, {<<"ok">>, Result}}) ->
{ok, Result};
decode_command_response({<<"container">>, {<<"error">>, Reason}}) ->
{error, Reason};
decode_command_response(_Reply) ->
{error, invalid_response}.
-spec deliver_command_response(undefined | pid(), request_ref(), tuple()) -> ok.
deliver_command_response(undefined, _Ref, _Reply) ->
ok;
deliver_command_response(ReceiverPid, Ref, Reply) when is_pid(ReceiverPid) ->
case is_process_alive(ReceiverPid) of
true ->
ReceiverPid ! {command_reply, Ref, decode_command_response(Reply)};
false ->
logger:warning("[ws_channel] get command_response: ~p, ref: ~p, but receiver_pid is deaded", [Reply, Ref])
end.
-spec deliver_command_error(undefined | pid(), request_ref(), term()) -> ok.
deliver_command_error(ReceiverPid, Ref, Reason) when is_pid(ReceiverPid) ->
ReceiverPid ! {command_reply, Ref, {error, Reason}},
ok;
deliver_command_error(_ReceiverPid, _Ref, _Reason) ->
ok.
-spec reply_command_timeout(undefined | pid(), request_ref()) -> ok.
reply_command_timeout(ReceiverPid, Ref) when is_pid(ReceiverPid) ->
ReceiverPid ! {command_reply, Ref, {error, timeout}},
ok;
reply_command_timeout(_ReceiverPid, _Ref) ->
ok.
-spec reply_command_closed(request_ref(), #inflight_command{}, term()) -> ok.
reply_command_closed(Ref, #inflight_command{receiver_pid = ReceiverPid}, Reason) when is_pid(ReceiverPid) ->
ReceiverPid ! {command_reply, Ref, {error, {channel_closed, Reason}}},
ok;
reply_command_closed(_Ref, _CommandInfo, _Reason) ->
ok.
%% token是否是合法值
-spec auth(Token :: binary(), UUID :: binary(), Timestamp :: integer()) ->
ok | {error, Reason :: binary()}.
auth(Token, UUID, Timestamp) when is_binary(Token), is_binary(UUID), is_integer(Timestamp) ->
%% 1
Now = iot_util:current_time(),
case Timestamp =< Now andalso Now - Timestamp =< 60 of
true ->
case efka_client_store:auth(UUID, Token) of
true ->
ok;
false ->
{error, <<"invalid token">>}
end;
false ->
{error, <<"invalid timestamp">>}
end.
-spec encode_command_body({container, map()}) -> {binary(), map()}.
encode_command_body({container, Request}) ->
{<<"container">>, safe_term(Request)}.
-spec safe_term(term()) -> term().
safe_term(true) ->
true;
safe_term(false) ->
false;
safe_term(undefined) ->
undefined;
safe_term(Value) when is_atom(Value) ->
atom_to_binary(Value, utf8);
safe_term(Value) when is_map(Value) ->
maps:from_list([{safe_term(K), safe_term(V)} || {K, V} <- maps:to_list(Value)]);
safe_term(Value) when is_list(Value) ->
[safe_term(Item) || Item <- Value];
safe_term(Value) when is_tuple(Value) ->
list_to_tuple([safe_term(Item) || Item <- tuple_to_list(Value)]);
safe_term(Value) ->
Value.
-spec start_idle_timer() -> reference().
start_idle_timer() ->
erlang:start_timer(?SSL_IDLE_TIMEOUT, self(), ssl_idle_timeout).
-spec reset_idle_timer(#state{}) -> #state{}.
reset_idle_timer(State = #state{idle_timer_ref = TimerRef}) ->
cancel_timer(TimerRef),
State#state{idle_timer_ref = start_idle_timer()}.
-spec cancel_timer(undefined | reference()) -> ok.
cancel_timer(undefined) ->
ok;
cancel_timer(TimerRef) ->
_ = erlang:cancel_timer(TimerRef),
ok.

View File

@ -1,19 +1,22 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @author anlicheng %%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY> %%% @copyright (C) 2026, <COMPANY>
%%% @doc %%% @doc
%%% %%%
%%% @end %%% @end
%%% Created : 26. 9 2025 12:19 %%% Created : 09. 5 2026 18:02
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(iot_event_stream_observer). -module(udp_server).
-author("anlicheng"). -author("anlicheng").
-behaviour(gen_server). -behaviour(gen_server).
-define(HEARTBEAT_VERSION, 1).
-define(HEARTBEAT_NONCE_BYTES, 16).
-define(HEARTBEAT_MAC_BYTES, 32).
%% API %% API
-export([start_link/0]). -export([start_link/0]).
-export([add_listener/2, stream_data/3, stream_close/2]).
%% gen_server callbacks %% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
@ -21,25 +24,13 @@
-define(SERVER, ?MODULE). -define(SERVER, ?MODULE).
-record(state, { -record(state, {
listeners = #{} socket :: gen_udp:socket()
}). }).
%%%=================================================================== %%%===================================================================
%%% API %%% API
%%%=================================================================== %%%===================================================================
-spec add_listener(ListenerPid :: pid(), TaskId :: integer()) -> ok.
add_listener(ListenerPid, TaskId) when is_pid(ListenerPid), is_integer(TaskId) ->
gen_server:call(?SERVER, {add_listener, ListenerPid, TaskId}).
-spec stream_data(TaskId :: integer(), Type :: binary(), Stream :: binary()) -> no_return().
stream_data(TaskId, Type, Stream) when is_integer(TaskId), is_binary(Type), is_binary(Stream) ->
gen_server:cast(?SERVER, {stream_data, TaskId, Type, Stream}).
-spec stream_close(TaskId :: integer(), Reason :: binary()) -> no_return().
stream_close(TaskId, Reason) when is_integer(TaskId), is_binary(Reason) ->
gen_server:cast(?SERVER, {stream_close, TaskId, Reason}).
%% @doc Spawns the server and registers the local name (unique) %% @doc Spawns the server and registers the local name (unique)
-spec(start_link() -> -spec(start_link() ->
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}). {ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
@ -56,7 +47,12 @@ start_link() ->
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
{stop, Reason :: term()} | ignore). {stop, Reason :: term()} | ignore).
init([]) -> init([]) ->
{ok, #state{}}. ok = iot_log:set_metadata(),
{ok, UdpServerProps} = application:get_env(iot, udp_server),
Port = proplists:get_value(port, UdpServerProps),
logger:debug("[udp_server] start at port: ~p", [Port]),
{ok, Socket} = gen_udp:open(Port, [binary, {active, true}]),
{ok, #state{socket = Socket}}.
%% @private %% @private
%% @doc Handling call messages %% @doc Handling call messages
@ -68,9 +64,8 @@ init([]) ->
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners = Listeners}) -> handle_call(_Request, _From, State = #state{}) ->
erlang:monitor(process, ListenerPid), {reply, ok, State}.
{reply, ok, State#state{listeners = maps:put(TaskId, ListenerPid, Listeners)}}.
%% @private %% @private
%% @doc Handling cast messages %% @doc Handling cast messages
@ -78,21 +73,7 @@ handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners
{noreply, NewState :: #state{}} | {noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_cast({stream_data, TaskId, Type, Stream}, State = #state{listeners = Listeners}) -> handle_cast(_Request, State = #state{}) ->
case maps:find(TaskId, Listeners) of
error ->
ok;
{ok, ListenerPid} ->
is_process_alive(ListenerPid) andalso ListenerPid ! {stream_data, TaskId, Type, Stream}
end,
{noreply, State};
handle_cast({stream_close, TaskId, Reason}, State = #state{listeners = Listeners}) ->
case maps:find(TaskId, Listeners) of
error ->
ok;
{ok, ListenerPid} ->
is_process_alive(ListenerPid) andalso ListenerPid ! {stream_close, TaskId, Reason}
end,
{noreply, State}. {noreply, State}.
%% @private %% @private
@ -101,9 +82,12 @@ handle_cast({stream_close, TaskId, Reason}, State = #state{listeners = Listeners
{noreply, NewState :: #state{}} | {noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} | {noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}). {stop, Reason :: term(), NewState :: #state{}}).
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State = #state{listeners = Listeners}) -> handle_info({udp, Socket, Ip, Port, Packet}, State = #state{socket = Socket}) ->
NListeners = maps:filter(fun(_, ListenerPid) -> ListenerPid /= Pid end, Listeners), handle_heartbeat_packet(Packet, {Ip, Port}),
{noreply, State#state{listeners = NListeners}}. {noreply, State};
handle_info(Info, State = #state{}) ->
logger:warning("[udp_server] ignore unknown info: ~p", [Info]),
{noreply, State}.
%% @private %% @private
%% @doc This function is called by a gen_server when it is about to %% @doc This function is called by a gen_server when it is about to
@ -112,7 +96,8 @@ handle_info({'DOWN', _Ref, process, Pid, _Reason}, State = #state{listeners = Li
%% with Reason. The return value is ignored. %% with Reason. The return value is ignored.
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), -spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
State :: #state{}) -> term()). State :: #state{}) -> term()).
terminate(_Reason, _State = #state{}) -> terminate(_Reason, _State = #state{socket = Socket}) ->
gen_udp:close(Socket),
ok. ok.
%% @private %% @private
@ -126,3 +111,31 @@ code_change(_OldVsn, State = #state{}, _Extra) ->
%%%=================================================================== %%%===================================================================
%%% Internal functions %%% Internal functions
%%%=================================================================== %%%===================================================================
-spec handle_heartbeat_packet(binary(), term()) -> ok.
handle_heartbeat_packet(Packet, Peer) when is_binary(Packet) ->
case decode_heartbeat_packet(Packet) of
{ok, UUID, Timestamp, Payload, Mac} ->
case efka_client_store:verify_heartbeat(UUID, Timestamp, Payload, Mac) of
true ->
Pid = iot_host:get_pid(UUID),
iot_host:heartbeat(Pid);
false ->
logger:warning("[udp_server] ignore invalid heartbeat auth from peer: ~p, uuid: ~p", [Peer, UUID]),
ok
end;
error ->
logger:warning("[udp_server] ignore invalid heartbeat packet from peer: ~p, packet: ~p", [Peer, Packet]),
ok
end.
-spec decode_heartbeat_packet(binary()) ->
{ok, UUID :: binary(), Timestamp :: integer(), Payload :: binary(), Mac :: binary()} | error.
decode_heartbeat_packet(Packet = <<?HEARTBEAT_VERSION:8, UUIDLen:16, UUID:UUIDLen/binary,
Timestamp:64/unsigned-big, _Nonce:?HEARTBEAT_NONCE_BYTES/binary, _Mac:?HEARTBEAT_MAC_BYTES/binary>>)
when UUIDLen > 0 ->
PayloadSize = byte_size(Packet) - ?HEARTBEAT_MAC_BYTES,
<<Payload:PayloadSize/binary, Mac:?HEARTBEAT_MAC_BYTES/binary>> = Packet,
{ok, UUID, Timestamp, Payload, Mac};
decode_heartbeat_packet(_Packet) ->
error.

View File

@ -1,34 +0,0 @@
%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 18. 8 2025 18:40
%%%-------------------------------------------------------------------
-module(http_client).
-author("anlicheng").
%% API
-export([post/3]).
%% Headers = [
%% {<<"content-type">>, <<"application/json">>}
%% ]
-spec post(Url :: string(), Headers :: list(), Body :: binary()) -> {ok, RespBody :: binary()} | {error, Reason :: any()}.
post(Url, Headers, Body) when is_list(Url), is_list(Headers), is_binary(Body) ->
case hackney:request(post, Url, Headers, Body, [{pool, false}]) of
{ok, 200, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef),
lager:debug("[iot_api] send body: ~p, get error is: ~p", [Body, RespBody]),
hackney:close(ClientRef),
{ok, RespBody};
{ok, HttpCode, _, ClientRef} ->
{ok, RespBody} = hackney:body(ClientRef),
hackney:close(ClientRef),
lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]),
{error, {HttpCode, RespBody}};
{error, Reason} ->
lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, Reason]),
{error, Reason}
end.

View File

@ -2,114 +2,75 @@
{iot, [ {iot, [
{http_server, [ {http_server, [
{port, 18090}, {port, 18090},
{acceptors, 500}, {acceptors, 5},
{max_connections, 10240}, {max_connections, 1024},
{backlog, 10240} {backlog, 1024}
]}, ]},
{tcp_server, [ {ssl_server, [
{port, 18092}, {port, 1443},
{acceptors, 500}, {acceptors, 5},
{max_connections, 10240}, {max_connections, 1024},
{backlog, 10240} {backlog, 1024}
]},
{redis_server, [
{port, 16379},
{acceptors, 500},
{max_connections, 10240},
{backlog, 10240}
]}, ]},
{udp_server, [ {udp_server, [
{port, 18080} {port, 18080}
]}, ]},
{api_url, "http://100.123.0.4/api/v1"}, {api_url, "http://127.0.0.1:18090/simulator"}
]},
{endpoint, [
%% 支持的协议 %% 支持的协议
{endpoints, [ {endpoints, [
{root_dir, "/usr/local/code/database/"},
{support_protocols, [ {support_protocols, [
http http
]} ]}
]}, ]},
%% 目标服务器地址 {endpoint_log, [
{emqx_server, [ {path, "${endpoint_root}/endpoint_log/unmatched_publish.log"},
{host, {39, 98, 184, 67}}, {max_bytes, 10485760},
{port, 1883}, {max_files, 10}
{tcp_opts, []},
{username, "test"},
{password, "test1234"},
{keepalive, 86400},
{retry_interval, 5}
]},
%% 权限检验时的预埋token
{pre_tokens, [
{<<"test">>, <<"iot2023">>}
]} ]}
%{pools, [
% %% mysql连接池配置
% {mysql_iot,
% [{size, 10}, {max_overflow, 20}, {worker_module, mysql}],
% [
% {host, "47.111.101.3"},
% {port, 3306},
% {user, "root"},
% {connect_mode, synchronous},
% {keep_alive, true},
% {password, "r3a-7Qrh#3Q"},
% {database, "nannong_demo"}
% ]
% },
% %% redis连接池
% {redis_pool,
% [{size, 10}, {max_overflow, 20}, {worker_module, eredis}],
% [
% {host, "127.0.0.1"},
% {port, 6379},
% {database, 1}
% ]
% }
%]}
]}, ]},
%% 系统日志配置,使用 OTP logger
{kernel, [
%% 设置 Logger 的 primary log level
{logger_level, debug},
{logger, [
{handler, default, logger_std_h,
#{
level => debug,
filter_default => stop,
filters => [
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
],
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
}
},
%% 系统日志配置系统日志为lager, 支持日志按日期自动分割 {handler, disk, logger_disk_log_h,
{lager, [ #{
{colored, true}, level => debug,
%% Whether to write a crash log, and where. Undefined means no crash logger. filter_default => stop,
{crash_log, "trade_hub.crash.log"}, filters => [
%% Maximum size in bytes of events in the crash log - defaults to 65536 {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{crash_log_msg_size, 65536}, {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
%% Maximum size of the crash log in bytes, before its rotated, set ],
%% to 0 to disable rotation - default is 0 config => #{
{crash_log_size, 10485760}, file => "log/debug.log",
%% What time to rotate the crash log - default is no time max_no_files => 10,
%% rotation. See the README for a description of this format. max_no_bytes => 524288000
{crash_log_date, "$D0"}, },
%% Number of rotated crash logs to keep, 0 means keep only the formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
%% 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, "notice.log"}, {level, notice}, {size, 314572800}]},
{lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]},
{lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]}
]} ]}
]} ]}

View File

@ -2,83 +2,74 @@
{iot, [ {iot, [
{http_server, [ {http_server, [
{port, 18080}, {port, 18080},
{acceptors, 500}, {acceptors, 5},
{max_connections, 10240}, {max_connections, 1024},
{backlog, 10240} {backlog, 1024}
]}, ]},
{redis_server, [ {ssl_server, [
{port, 16379}, {port, 1443},
{acceptors, 500}, {acceptors, 5},
{max_connections, 10240}, {max_connections, 1024},
{backlog, 10240} {backlog, 1024}
]}, ]},
{udp_server, [ {udp_server, [
{port, 18080} {port, 18080}
]}, ]},
%% 权限检验时的预埋token {api_url, "https://lgsiot.njau.edu.cn/api/v1/taskLog"}
{pre_tokens, [
{<<"test">>, <<"iot2023">>}
]},
{api_url, "https://lgsiot.njau.edu.cn/api/v1/taskLog"},
{influxdb, [
{host, "172.19.0.4"},
{port, 8086},
{token, <<"A-ZRjqMK_7NR45lXXEiR7AEtYCd1ETzq9Z61FTMQLb5O4-1hSf8sCrjdPB84e__xsrItKHL3qjJALgbYN-H_VQ==">>}
]}
%{pools, [
% %% redis连接池
% {redis_pool,
% [{size, 10}, {max_overflow, 20}, {worker_module, eredis}],
% [
% {host, "172.30.6.175"},
% {port, 26379},
% {database, 1}
% ]
% }
%]}
]}, ]},
{endpoint, [
{endpoints, [
{root_dir, "/var/lib/endpoint/database/"},
{support_protocols, [
http
]}
]},
%% 系统日志配置系统日志为lager, 支持日志按日期自动分割 {endpoint_log, [
{lager, [ {path, "/var/lib/endpoint/endpoint_log/unmatched_publish.log"},
{colored, true}, {max_bytes, 10485760},
%% Whether to write a crash log, and where. Undefined means no crash logger. {max_files, 10}
{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 %% 系统日志配置,使用 OTP logger
{async_threshold, 20}, {kernel, [
%% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold' %% 设置 Logger 的 primary log level
%% to async_threshold - async_threshold_window {logger_level, debug},
{async_threshold_window, 5}, {logger, [
{handler, default, logger_std_h,
#{
level => debug,
filter_default => stop,
filters => [
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
],
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
}
},
{handler, disk, logger_disk_log_h,
#{
level => debug,
filter_default => stop,
filters => [
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
],
config => #{
file => "log/debug.log",
max_no_files => 10,
max_no_bytes => 524288000
},
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
}
}
{handlers, [
%% debug | info | warning | error, 日志级别
{lager_console_backend, debug},
{lager_file_backend, [{file, "debug.log"}, {level, debug}, {size, 314572800}]},
{lager_file_backend, [{file, "notice.log"}, {level, notice}, {size, 314572800}]},
{lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]},
{lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]}
]} ]}
]} ]}

77
config/sys-test.config Normal file
View File

@ -0,0 +1,77 @@
[
{iot, [
{http_server, [
{port, 18090},
{acceptors, 5},
{max_connections, 1024},
{backlog, 1024}
]},
{ssl_server, [
{port, 1443},
{acceptors, 5},
{max_connections, 1024},
{backlog, 1024}
]},
{udp_server, [
{port, 18080}
]},
{api_url, "http://127.0.0.1/api/v1"}
]},
{endpoint, [
%% 支持的协议
{endpoints, [
{root_dir, "/usr/local/code/database/"},
{support_protocols, [
http
]}
]},
{endpoint_log, [
{path, "${endpoint_root}/endpoint_log/unmatched_publish.log"},
{max_bytes, 10485760},
{max_files, 10}
]}
]},
%% 系统日志配置,使用 OTP logger
{kernel, [
%% 设置 Logger 的 primary log level
{logger_level, debug},
{logger, [
{handler, default, logger_std_h,
#{
level => debug,
filter_default => stop,
filters => [
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
],
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
}
},
{handler, disk, logger_disk_log_h,
#{
level => debug,
filter_default => stop,
filters => [
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
],
config => #{
file => "log/debug.log",
max_no_files => 10,
max_no_bytes => 524288000
},
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
}
}
]}
]}
].

View File

@ -5,7 +5,7 @@
+K true +K true
+A30 +A30
-mnesia dir '"/usr/local/var/mnesia/iot"' -mnesia dir '"/var/lib/iot/mnesia/"'
-mnesia dump_log_write_threshold 50000 -mnesia dump_log_write_threshold 50000
-mnesia dc_dump_limit 40 -mnesia dc_dump_limit 40

View File

@ -0,0 +1,670 @@
# /container/deploy 容器创建请求参数说明
本文档说明 `iot` HTTP 接口 `POST /container/deploy` 当前支持的 JSON 参数、校验规则,以及服务端把 JSON 解码成 Erlang map 后如何转换成下发给 `efka` 的容器部署命令。
对应代码:
- HTTP 入口:[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63)
- HTTP JSON 解析:[src/transport/http/http_protocol.erl](/usr/local/code/cloudkit/iot/src/transport/http/http_protocol.erl:68)
- 参数校验与内部 map 构造:[src/docker/docker_container_builder.erl](/usr/local/code/cloudkit/iot/src/docker/docker_container_builder.erl:25)
- 主机命令下发:[src/host/iot_host.erl](/usr/local/code/cloudkit/iot/src/host/iot_host.erl:99)
## 1. 接口
```http
POST /container/deploy
Content-Type: application/json
```
HTTP body 必须是 JSON object。`http_protocol` 会使用 `json:decode/1` 解析请求体:
- JSON object -> Erlang map
- JSON array -> Erlang list
- JSON string -> Erlang binary
- JSON integer -> Erlang integer
- JSON float -> Erlang float
- JSON boolean -> Erlang `true | false`
`/container/deploy` handler 只接受顶层 map且必须匹配
```erlang
#{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config}
```
其中:
- `UUID` 必须是 binary也就是 JSON string。
- `TaskId` 必须是 integer。
- `Config` 必须是 map也就是 JSON object。
## 2. 完整 JSON 格式
下面示例包含当前支持的所有字段。实际请求可以只传必填字段和需要的可选字段。
```json
{
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
"task_id": 1001,
"config": {
"image": "docker.io/library/nginx:latest",
"container_name": "my_nginx",
"command": ["nginx", "-g", "daemon off;"],
"restart": "always",
"entrypoint": ["/docker-entrypoint.sh"],
"envs": ["ENV=prod", "TZ=Asia/Shanghai"],
"expose": ["80", "443/tcp", "53/udp"],
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
"networks": ["bridge"],
"network_mode": "bridge",
"labels": {
"app": "nginx",
"env": "prod"
},
"user": "www-data",
"working_dir": "/app",
"hostname": "myhost",
"privileged": false,
"cap_add": ["NET_ADMIN"],
"cap_drop": ["MKNOD"],
"devices": ["/dev/ttyUSB0:/dev/ttyUSB0:rwm"],
"mem_limit": "512m",
"mem_reservation": "256m",
"cpu_shares": 512,
"cpus": 1.5,
"ulimits": {
"nofile": "1024:2048"
},
"sysctls": {
"net.ipv4.ip_forward": "1"
},
"tmpfs": ["/tmp", "/run:rw,size=64m"],
"extra_hosts": ["host.docker.internal:host-gateway"],
"healthcheck": {
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
"interval": "30s",
"timeout": "10s",
"retries": 3
}
}
}
```
## 3. 顶层参数
| 字段 | JSON 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `uuid` | string | 是 | 目标 efka 所属主机 UUID。服务端用它查找 `iot_host` 进程,不会下发到 efka。 |
| `task_id` | non-negative integer | 是 | 部署任务 ID。会进入内部命令的 `task_id` 字段,用于关联部署结果和部署日志流。`iot` 内部使用 `{uuid, task_id}` 作为任务唯一标识。 |
| `config` | object | 是 | 容器创建配置。会被校验并转换成内部部署参数 map。 |
顶层没有 `timeout` 字段。当前 HTTP handler 等待 efka command response 的超时时间固定为 10 秒。
部署过程的实时反馈通过 SSE 读取:
```http
GET /event_stream?uuid=<host_uuid>&task_id=<task_id>
```
`/container/deploy` 收到合法请求后会先在 `iot` 内部创建或复用 `{uuid, task_id}` 对应的任务进程,然后再向 `efka` 下发部署命令。SSE handler 订阅同一个任务进程;该进程会缓存最近的部署事件,支持多个页面同时订阅,并在收到 efka 的 close 事件后关闭 SSE。
## 4. config 参数
### 必填字段
| 字段 | JSON 类型 | 内部类型 | 说明 |
| --- | --- | --- | --- |
| `image` | string | binary | 镜像名。没有 tag 时 efka 部署逻辑会补 `:latest`。 |
| `container_name` | string | binary | 容器名称。 |
| `command` | string[] | binary list | 容器启动命令,对应 Docker create config 的 `cmd`。 |
| `restart` | string | binary | 重启策略,例如 `no``always``unless-stopped``on-failure``on-failure:3`。 |
### 可选字段
| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `entrypoint` | string[] | `[]` | Docker create config 的 `entrypoint`。 |
| `envs` | string[] | `[]` | 环境变量列表,例如 `["A=1"]`,对应 Docker create config 的 `env`。 |
| `expose` | string[] | `[]` | 容器暴露端口,只表示容器端口,不绑定宿主机端口。 |
| `ports` | string[] | `[]` | 宿主机到容器的端口映射,格式为 `host_port:container_port`,例如 `["8080:80", "443:443"]`。 |
| `volumes` | string[] | `[]` | volume bind 列表,格式见后文。 |
| `networks` | string[] | `[]` | Docker network 名称列表,用于 networking config。 |
| `network_mode` | string | `""` | Docker host config 的 `network_mode`。 |
| `labels` | object string:string | `{}` | 容器 labels。key/value 都必须是 string。 |
| `user` | string | `""` | 容器运行用户。 |
| `working_dir` | string | `""` | 容器工作目录。 |
| `hostname` | string | `""` | 容器 hostname。 |
| `privileged` | boolean | `false` | 是否 privileged。 |
| `cap_add` | string[] | `[]` | 追加 Linux capability。 |
| `cap_drop` | string[] | `[]` | 删除 Linux capability。 |
| `devices` | string[] | `[]` | 设备映射列表,格式见后文。 |
| `mem_limit` | string | `0` | 内存上限,解析成字节数。 |
| `mem_reservation` | string | `0` | 内存软限制,解析成字节数。 |
| `cpu_shares` | non-negative integer | `0` | Docker CPU shares。 |
| `cpus` | non-negative number | `0` | CPU 数量,转换成 nano cpus。 |
| `ulimits` | object string:string | `[]` | ulimit 配置,格式见后文。 |
| `sysctls` | object string:string | `{}` | sysctl 配置。key/value 都必须是 string。 |
| `tmpfs` | string[] | `{}` | tmpfs mount 配置,格式见后文。 |
| `extra_hosts` | string[] | `[]` | 额外 hosts例如 `["host.docker.internal:host-gateway"]`。 |
| `healthcheck` | object | `undefined` | 健康检查配置,格式见后文。 |
### 不支持字段
| 字段 | 当前行为 | 说明 |
| --- | --- | --- |
| `container_dir` | 明确拒绝 | 容器目录由 efka 按系统默认规则管理HTTP 调用方不能指定 efka 主机上的部署目录。 |
| `env_file` | 忽略 | 当前校验不会识别该字段,后续构造 Docker create options 时也不会使用。 |
| 其他未知字段 | 忽略 | 除 `container_dir` 外,未知字段不会报错,也不会进入内部部署参数。 |
## 5. 校验规则
校验分为两层。
第一层在 `container_handler`
- `uuid` 必须是 binary。
- `task_id` 必须是非负 integer。
- `config` 必须是 map。
第二层在 `docker_container_builder:deploy_request/2`
- 先拒绝不支持的 `container_dir` 字段。
- 检查必填字段是否存在。
- 检查已知字段类型。
- 检查 `healthcheck` 内部字段类型,避免无效嵌套字段进入构造阶段。
- 构造内部部署 map。
- 对需要解析的字段做格式解析例如端口、volume、size、duration、ulimit。
类型错误会返回类似:
```text
required parameter: <<"command">>, type must be: list of string
optional parameter: <<"labels">>, type must be: map of string:string
```
缺少必填字段会返回类似:
```text
miss requied parameter: <<"image">>
```
注意:错误文本中的 `requied` 是当前代码里的原始拼写。
## 6. 内部转换结果
`config` 校验通过后,`docker_container_builder:deploy_request(TaskId, Config)` 生成:
```erlang
#{
<<"action">> => <<"deploy">>,
<<"task_id">> => TaskId,
<<"params">> => #{
<<"container_name">> => ContainerName,
<<"create">> => #{
<<"config">> => ContainerConfig,
<<"host_config">> => HostConfig,
<<"networking_config">> => NetworkingConfig
}
}
}
```
该 map 会通过 efka/iot 长连接协议下发:
```erlang
{<<"command">>, Ref, {<<"container">>, #{
<<"action">> => <<"deploy">>,
<<"task_id">> => TaskId,
<<"params">> => Params
}}}
```
efka 返回:
```erlang
{<<"command_response">>, Ref, {<<"container">>, Reply}}
```
其中 `Ref``crypto:strong_rand_bytes(16)` 生成的 16 字节 binary。网络帧只使用 safe term协议 label、command map key 和 action 使用 binary`efka` 收到后直接按 binary key/action 处理。
HTTP handler 最多等待 10 秒。超时返回 HTTP 504其他参数或执行错误通常返回 HTTP 400找不到 host 返回业务错误 code 404。
## 7. Docker create config 映射
`create.config``build_docker_container_config/1` 生成:
| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `image` | `image` | 原值。 |
| `cmd` | `command` | 原值。 |
| `entrypoint` | `entrypoint` | 默认 `[]`。 |
| `env` | `envs` | 默认 `[]`。 |
| `labels` | `labels` | 默认 `{}`。 |
| `volumes` | `volumes` | 只保留 container path 列表。 |
| `user` | `user` | 默认 `""`。 |
| `working_dir` | `working_dir` | 默认 `""`。 |
| `hostname` | `hostname` | 默认 `""`。 |
| `exposed_ports` | `expose` + `ports` | 转成 `#{container_port, protocol}` map 列表;`ports` 中的容器端口会自动补进 `exposed_ports`。 |
| `healthcheck` | `healthcheck` | 未传时为 `undefined`。 |
## 8. Docker host config 映射
`create.host_config``build_docker_host_config/1` 生成:
| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `binds` | `volumes` | 转成 Docker bind 字符串列表。 |
| `network_mode` | `network_mode` | 默认 `""`。 |
| `restart_policy` | `restart` | 转成 `#{name, maximum_retry_count}`。 |
| `privileged` | `privileged` | 默认 `false`。 |
| `cap_add` | `cap_add` | 默认 `[]`。 |
| `cap_drop` | `cap_drop` | 默认 `[]`。 |
| `devices` | `devices` | 转成设备映射 map 列表。 |
| `memory` | `mem_limit` | 解析成字节数,未传为 `0`。 |
| `memory_reservation` | `mem_reservation` | 解析成字节数,未传为 `0`。 |
| `nano_cpus` | `cpus` | `cpus * 1000000000`,未传为 `0`。 |
| `cpu_shares` | `cpu_shares` | 未传为 `0`。 |
| `port_bindings` | `ports` | 转成 `#{host_ip, host_port, container_port, protocol}` map 列表。 |
| `ulimits` | `ulimits` | 转成 ulimit map 列表。 |
| `tmpfs` | `tmpfs` | 转成 map。 |
| `sysctls` | `sysctls` | 默认 `{}`。 |
| `extra_hosts` | `extra_hosts` | 默认 `[]`。 |
## 9. Docker networking config 映射
`create.networking_config``build_docker_networking_config/1` 生成:
```erlang
#{<<"endpoints">> => [#{<<"name">> => Network} || Network <- Networks]}
```
来源字段:
```json
"networks": ["bridge", "mynet"]
```
转换结果:
```erlang
#{<<"endpoints">> => [
#{<<"name">> => <<"bridge">>},
#{<<"name">> => <<"mynet">>}
]}
```
## 10. 复杂字段转换规则
### restart
输入:
```json
"restart": "always"
```
转换:
```erlang
#{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}
```
输入:
```json
"restart": "on-failure:3"
```
转换:
```erlang
#{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3}
```
### expose
输入:
```json
["80", "443/tcp", "53/udp"]
```
转换:
```erlang
[
#{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
#{<<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
#{<<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```
端口必须是无符号整数,且不能超过 `4294967295`
### ports
输入:
```json
["8080:80", "443:443", "8053:53/udp"]
```
转换为 `create.host_config.port_bindings`
```erlang
[
#{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
#{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
#{<<"host_ip">> => <<>>, <<"host_port">> => 8053, <<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```
规则:
- 端口映射之间使用冒号分隔:`host_port:container_port`
- 容器端口可以带协议:`host_port:container_port/protocol`
- 未指定协议时默认为 `tcp`
- host port 和 container port 都不能为空,且必须是 `0..65535` 范围内的无符号整数。
- `ports` 中出现的容器端口会自动补进 `create.config.exposed_ports`,因此不需要在 `expose` 里重复声明。
### volumes
输入:
```json
["/host/data:/data", "/host/log:/var/log:ro", "/host/cache:/cache:rw"]
```
转换为 `create.config.volumes`
```erlang
[<<"/data">>, <<"/var/log">>, <<"/cache">>]
```
转换为 `create.host_config.binds`
```erlang
[
<<"/host/data:/data">>,
<<"/host/log:/var/log:ro">>,
<<"/host/cache:/cache">>
]
```
规则:
- `host_path:container_path` -> 读写挂载。
- `host_path:container_path:ro` -> 只读挂载。
- `host_path:container_path:rw` -> 当前实现视为读写挂载,输出时不会保留 `:rw`
- host path 和 container path 都不能为空。
### devices
输入:
```json
["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"]
```
转换:
```erlang
[
#{
<<"path_on_host">> => <<"/dev/ttyUSB0">>,
<<"path_in_container">> => <<"/dev/ttyUSB0">>,
<<"cgroup_permissions">> => <<"rwm">>
},
#{
<<"path_on_host">> => <<"/dev/snd">>,
<<"path_in_container">> => <<"/dev/snd">>,
<<"cgroup_permissions">> => <<"rwm">>
}
]
```
规则:
- `host_path:container_path` -> 权限默认为 `rwm`
- `host_path:container_path:permissions` -> 使用第三段作为权限。
- 任意路径或 permissions 为空时返回 `invalid device mapping`
### ulimits
输入:
```json
{
"nofile": "1024:2048",
"nproc": "4096"
}
```
转换:
```erlang
[
#{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048},
#{<<"name">> => <<"nproc">>, <<"soft">> => 4096, <<"hard">> => 4096}
]
```
规则:
- `"soft:hard"` -> 分别设置 soft 和 hard。
- `"limit"` -> soft 和 hard 都等于 limit。
- 数值必须是无符号整数。
### tmpfs
输入:
```json
["/tmp", "/run:rw,size=64m"]
```
转换:
```erlang
#{
<<"/tmp">> => <<>>,
<<"/run">> => <<"rw,size=64m">>
}
```
规则:
- `"path"` -> options 为 `""`
- `"path:options"` -> options 为第二段。
- path 不能为空。
### healthcheck
输入:
```json
{
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
"interval": "30s",
"timeout": "10s",
"retries": 3
}
```
转换:
```erlang
#{
<<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>],
<<"interval_ns">> => 30000000000,
<<"timeout_ns">> => 10000000000,
<<"retries">> => 3
}
```
字段规则:
| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `test` | string[] | `[]` | Docker healthcheck test 参数列表。 |
| `interval` | string 或 non-negative integer | `"0s"` | string 会解析时间单位integer 直接视为纳秒。 |
| `timeout` | string 或 non-negative integer | `"0s"` | string 会解析时间单位integer 直接视为纳秒。 |
| `retries` | non-negative integer | `0` | Docker healthcheck 重试次数。 |
时间单位:
| 单位 | 含义 |
| --- | --- |
| `ns` | 纳秒 |
| `us` | 微秒 |
| `ms` | 毫秒 |
| `s` | 秒 |
| `m` | 分钟 |
| `h` | 小时 |
| 无单位 | 秒 |
示例:
- `"30s"` -> `30000000000`
- `"10ms"` -> `10000000`
- `"2m"` -> `120000000000`
- `"5"` -> `5000000000`
### mem_limit 和 mem_reservation
输入:
```json
"mem_limit": "512m",
"mem_reservation": "1g"
```
转换:
```erlang
<<"memory">> => 536870912,
<<"memory_reservation">> => 1073741824
```
支持单位:
| 单位 | 倍数 |
| --- | --- |
| `b` 或无单位 | 1 |
| `k`, `kb`, `ki`, `kib` | 1024 |
| `m`, `mb`, `mi`, `mib` | 1048576 |
| `g`, `gb`, `gi`, `gib` | 1073741824 |
| `t`, `tb`, `ti`, `tib` | 1099511627776 |
数值支持整数或小数,例如 `"1.5g"`
### cpus
输入:
```json
"cpus": 1.5
```
转换:
```erlang
<<"nano_cpus">> => 1500000000
```
规则:
- integer 或 float 都可以。
- 必须大于等于 0。
- 转换公式:`trunc(Cpus * 1000000000)`
## 11. 最小请求示例
```json
{
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
"task_id": 1001,
"config": {
"image": "docker.io/library/nginx:latest",
"container_name": "my_nginx",
"command": ["nginx", "-g", "daemon off;"],
"restart": "always"
}
}
```
对应下发给 `efka` 的协议命令示意:
```erlang
{<<"command">>, Ref, {<<"container">>, #{
<<"action">> => <<"deploy">>,
<<"task_id">> => 1001,
<<"params">> => #{
<<"container_name">> => <<"my_nginx">>,
<<"create">> => #{
<<"config">> => #{
<<"image">> => <<"docker.io/library/nginx:latest">>,
<<"cmd">> => [<<"nginx">>, <<"-g">>, <<"daemon off;">>],
<<"entrypoint">> => [],
<<"env">> => [],
<<"labels">> => #{},
<<"volumes">> => [],
<<"user">> => <<>>,
<<"working_dir">> => <<>>,
<<"hostname">> => <<>>,
<<"exposed_ports">> => [],
<<"healthcheck">> => undefined
},
<<"host_config">> => #{
<<"binds">> => [],
<<"network_mode">> => <<>>,
<<"restart_policy">> => #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0},
<<"privileged">> => false,
<<"cap_add">> => [],
<<"cap_drop">> => [],
<<"devices">> => [],
<<"memory">> => 0,
<<"memory_reservation">> => 0,
<<"nano_cpus">> => 0,
<<"cpu_shares">> => 0,
<<"port_bindings">> => [],
<<"ulimits">> => [],
<<"tmpfs">> => #{},
<<"sysctls">> => #{},
<<"extra_hosts">> => []
},
<<"networking_config">> => #{<<"endpoints">> => []}
}
}
}}}
```
## 12. 响应
成功时 HTTP body 由 `iot_util:json_data/1` 包装:
```json
{
"result": "ok"
}
```
如果 efka 返回的是 JSON binaryhandler 会尝试解码后再放入 `result`
错误示例:
```json
{
"error": {
"code": 400,
"message": "invalid port binding"
}
}
```
常见状态:
| HTTP 状态 | 场景 |
| --- | --- |
| `200` | host not found 或构造阶段返回的业务错误也可能通过 200 包装业务错误。 |
| `400` | 参数校验失败、efka 返回普通错误。 |
| `504` | 等待 efka command response 超时。 |

268
docs/efka_iot_protocol.md Normal file
View File

@ -0,0 +1,268 @@
# EFKA 与 IOT 交互协议
本文档描述 `efka``iot` 之间的 TLS 长连接协议。当前协议由 Erlang term 直接序列化,发送端使用 `term_to_binary/1`,接收端使用 `binary_to_term(PacketBin, [safe])`
协议帧只使用 safe external term顶层 label、业务 label、map key 使用 binary`Ref` 使用 `crypto:strong_rand_bytes(16)` 生成,是 16 字节 binary。网络协议里不发送 Erlang `reference()`,也不依赖动态创建 atom。
## 传输层
- `efka` 作为 TLS client 连接 `iot`
- `iot` 作为 TLS server 接收多个 `efka` 连接,一个连接对应一个 `ssl_channel` 进程。
- socket 使用 `{packet, 4}`,每个 Erlang term binary 作为一个完整包发送。
- `Ref` 使用 `crypto:strong_rand_bytes(16)` 生成,只在当前连接的 inflight 表内匹配。
## 顶层帧
协议顶层 tuple 用来表达交互语义:
```erlang
{<<"request">>, Ref, Body}
{<<"response">>, Ref, Reply}
{<<"command">>, Ref, {Domain, Payload}}
{<<"command_response">>, Ref, {Domain, Reply}}
{<<"message">>, Body}
```
语义说明:
| 帧 | 方向 | 语义 |
| --- | --- | --- |
| `{<<"request">>, Ref, Body}` | efka -> iot | efka 发起请求,需要 iot 回复 |
| `{<<"response">>, Ref, Reply}` | iot -> efka | iot 对 efka request 的回复 |
| `{<<"command">>, Ref, {Domain, Payload}}` | iot -> efka | iot 下发命令,需要 efka 回复 |
| `{<<"command_response">>, Ref, {Domain, Reply}}` | efka -> iot | efka 对 iot command 的回复 |
| `{<<"message">>, Body}` | 双向 | 异步消息,不要求回复 |
`command``command_response``Domain` 表示业务域,目前支持:
- `<<"container">>`
## 鉴权请求
初始连接由 `efka` 发起鉴权 request。每条 TLS 连接只允许一次鉴权;`iot` 侧鉴权成功后会在 `ssl_channel` 标记该连接已鉴权,如果同一连接再次发送 `auth_request``iot` 会直接关闭连接。
```erlang
{<<"request">>, Ref, {<<"auth_request">>, #{
<<"uuid">> => UUID,
<<"token">> => Token,
<<"timestamp">> => Timestamp
}}}
```
`iot` 回复:
```erlang
{<<"response">>, Ref, {<<"auth_response">>, <<"ok">>}}
{<<"response">>, Ref, {<<"auth_response">>, {<<"error">>, {<<"failed">>, Reason}}}}
```
处理语义:
- `<<"ok">>``efka` 进入 `activated` 状态。
- `{<<"error">>, {<<"failed">>, Reason}}`:鉴权失败,`iot` 返回失败响应后关闭连接;`efka` 进入重连流程。
## 授权控制
`/host/activate` 只修改 `iot` 本地和持久化的 host 授权状态,不再向 `efka` 下发 auth command。`efka` 可以继续保持连接并发送数据,是否处理这些数据由 `iot_host` 当前状态决定。
因此当前协议没有 `{<<"command">>, Ref, {<<"auth">>, ...}}``{<<"command_response">>, Ref, {<<"auth">>, ...}}`。授权关闭时,`iot_host` 保持 channel 在线,但不处理上报数据;授权重新打开后,已在线的 channel 可以继续使用。
## 容器管理命令
`iot``efka` 的容器管理使用 command 语义:
```erlang
{<<"command">>, Ref, {<<"container">>, CommandMap}}
```
`efka` 回复:
```erlang
{<<"command_response">>, Ref, {<<"container">>, Reply}}
```
`Reply` 取值:
```erlang
<<"ok">>
{<<"ok">>, Result}
{<<"error">>, Reason}
```
`CommandMap` 使用 binary key 和 binary action`efka` 接收后直接按 binary key/action 匹配Docker 参数链路继续使用 binary-key map不再转换成 atom-key map。
### list
```erlang
#{<<"action">> => <<"list">>}
```
返回当前 `efka` 主机上的容器列表。
### deploy
```erlang
#{
<<"action">> => <<"deploy">>,
<<"task_id">> => TaskId,
<<"params">> => Params
}
```
触发容器部署。部署过程中的流式日志不通过该 command response 返回,而是通过 `message``task_event` 上报。
### start
```erlang
#{
<<"action">> => <<"start">>,
<<"target">> => Target
}
```
### stop
```erlang
#{
<<"action">> => <<"stop">>,
<<"target">> => Target,
<<"timeout_seconds">> => TimeoutSeconds
}
```
### kill
```erlang
#{
<<"action">> => <<"kill">>,
<<"target">> => Target,
<<"signal">> => Signal
}
```
### remove
```erlang
#{
<<"action">> => <<"remove">>,
<<"target">> => Target,
<<"force">> => Force,
<<"remove_volumes">> => RemoveVolumes
}
```
### config
```erlang
#{
<<"action">> => <<"config">>,
<<"target">> => Target,
<<"config">> => Config
}
```
更新容器配置文件。
### Target
容器目标使用 map 表示:
```erlang
#{
<<"name">> => ContainerName,
<<"id">> => ContainerId
}
```
`name``id` 至少一个非空;优先使用 `name``name` 为空时使用 `id`
## 异步消息
`message` 不带 `Ref`,不要求对端回复。
### efka -> iot: data
```erlang
{<<"message">>, {<<"data">>, #{
<<"route_key">> => RouteKey,
<<"metric">> => Metric
}}}
```
用于 `efka` 上报业务指标数据。
### efka -> iot: task_event
```erlang
{<<"message">>, {<<"task_event">>, #{
<<"task_id">> => TaskId,
<<"type">> => Type,
<<"stream">> => Stream
}}}
```
任务事件流关闭时:
```erlang
{<<"message">>, {<<"task_event">>, #{
<<"task_id">> => TaskId,
<<"type">> => <<"close">>,
<<"stream">> => Reason
}}}
```
`task_event` 本身不携带 `uuid``iot` 在接收该消息时使用当前已鉴权 `ssl_channel` 绑定的 host UUID把事件路由到内部任务进程 `{UUID, TaskId}`。HTTP 页面通过 SSE 订阅时也必须使用同一组参数:
```http
GET /event_stream?uuid=<host_uuid>&task_id=<task_id>
```
`iot` 会为每个 `{UUID, TaskId}` 维护一个独立的任务进程,用于缓存最近的部署日志、支持多个 SSE listener并在收到 close 事件后结束事件流。
### efka -> iot: ping
```erlang
{<<"message">>, <<"ping">>}
```
用于 TLS 长连接的应用层保活。`efka` 在鉴权成功后周期发送,当前发送间隔为 30 秒。
`iot` 收到后回复:
```erlang
{<<"message">>, <<"pong">>}
```
`ping/pong` 只表示 TLS 连接仍可读写,不参与 host online/offline 判定。host 上下线仍由 UDP 心跳和 `iot_host` 本地连接状态共同维护。
### iot -> efka: pub
```erlang
{<<"message">>, {<<"pub">>, #{
<<"topic">> => Topic,
<<"qos">> => Qos,
<<"content">> => Content
}}}
```
用于 `iot``efka` 本地订阅系统发布 topic 消息。
## 状态与超时
- `efka` 鉴权超时时间5 秒。
- `iot` command inflight 超时时间60 秒。
- `iot` SSL channel 空闲超时时间120 秒。120 秒内没有收到任何 TLS 包,包括 `ping`、业务 `message``request``command_response``iot` 会主动关闭该连接。
- `iot` 管理多个 `efka` 时,每个连接有独立 `ssl_channel` 和独立 inflight 表。
- command 超时后,`iot` 删除 inflight 记录;之后如果迟到的 `command_response` 到达,会被视为未预期响应。
## 兼容性
当前协议不兼容旧 tuple
- 旧容器管理:`{request, Ref, {container_request, ...}}`
- 旧容器回复:`{response, Ref, {container_response, ...}}`
- 旧授权控制:`{message, {auth_control, Command}}`
- 已移除的 auth command`{command, Ref, {auth, activate | deactivate}}`
- 旧 RefErlang `reference()`,例如 `make_ref()` 生成的值。
如果需要滚动升级,应先增加临时兼容分支或引入协议版本协商。

View File

@ -1,11 +1,44 @@
## 心跳机制 ## 心跳机制
* 边缘主机通过心跳机制来判断主机是否存活(解决弱网环境下websocket链接会经常断开的问题) * 边缘主机通过心跳机制来判断主机是否存活(解决弱网环境下websocket链接会经常断开的问题)
* 边缘主机每隔5秒发送一次心跳包服务端每隔2分钟检测一下判断是否有收到心跳包如果没有收到则认为主机离线 * 边缘主机定期发送 UDP 心跳包,服务端按照 `iot_host` 的心跳检测周期判断是否有收到心跳包。
* 如果 UDP 心跳丢失但 SSL channel 仍然存在,服务端不会把 host 标记为离线。
* 如果 UDP 心跳丢失且 SSL channel 也不存在,服务端会把 host 标记为离线。
### udp服务器 ### udp服务器
* 端口: 18080 * 端口: 18080
### 心跳包格式 ### 心跳包格式
* <<Len:2, HostUUID/binary>> 旧格式 `<<Len:16, HostUUID/binary>>` 已不再接受。
* Len表示HostUUID对应的字节数Len本身占用2字节长度(HostUUID不一定是固定长度因此需要标注)
* 注解:采用这种格式是为了方便后续扩展别的心跳信息字段 当前心跳包格式:
```erlang
<<
Version:8,
UuidLen:16,
UUID:UuidLen/binary,
Timestamp:64/unsigned-big,
Nonce:16/binary,
Mac:32/binary
>>
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `Version` | 当前固定为 `1`。 |
| `UuidLen` | `UUID` 的字节数。 |
| `UUID` | efka 对应的 host UUID。 |
| `Timestamp` | 秒级 Unix 时间戳。服务端只接受 120 秒时间窗内的心跳。 |
| `Nonce` | 16 字节随机数,必须加入 HMAC payload使同一秒内多次心跳的 Mac 不同。当前服务端不保存 nonce。 |
| `Mac` | HMAC-SHA256 结果32 字节。 |
`Mac` 的计算内容为前面所有字段:
```erlang
Payload = <<Version:8, UuidLen:16, UUID:UuidLen/binary, Timestamp:64/unsigned-big, Nonce:16/binary>>,
Mac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload)
```
服务端在 `efka_client_store:verify_heartbeat/4` 中校验时间窗和 HMAC。`HeartbeatSecret` 使用 `SHA256(Token)`,其中 `Token` 和 TLS 鉴权使用同一个 auth token。`iot` 侧只保存派生后的 `heartbeat_secret`,不保存明文 token。

View File

@ -3,7 +3,7 @@
```markdown ```markdown
# 📘 IoT API 接口文档 # 📘 IoT API 接口文档
> 模块:`iot_api` > 模块:`iot_api_client`
> 作者:**anlicheng** > 作者:**anlicheng**
> 创建时间2023-12-24 > 创建时间2023-12-24
> 数据格式:`application/json` > 数据格式:`application/json`
@ -133,7 +133,36 @@ POST /change_host_status
--- ---
### 5. 获取主机下的设备列表 ### 5. 修改主机授权状态
**接口:**
```
POST /change_host_authorize_status
```
**请求体:**
```json
{
"uuid": "uuid-1",
"new_authorize_status": 1
}
```
`new_authorize_status``1` 表示允许 `iot_host` 处理该 host 的上报数据,为 `0` 表示保持连接但不处理上报数据。
**返回示例:**
```json
{
"result": "ok"
}
```
---
### 6. 获取主机下的设备列表
**接口:** **接口:**
@ -162,7 +191,7 @@ GET /get_host_devices?host_id=<id>
## 🔧 设备Device相关接口 ## 🔧 设备Device相关接口
### 6. 获取设备详情 ### 7. 获取设备详情
**接口:** **接口:**
@ -190,7 +219,7 @@ GET /get_device_by_uuid?device_uuid=<uuid>
--- ---
### 7. 修改设备状态 ### 8. 修改设备状态
**接口:** **接口:**
@ -219,7 +248,7 @@ POST /change_device_status
## 🌐 Endpoint数据终端相关接口 ## 🌐 Endpoint数据终端相关接口
### 8. 获取所有 Endpoint ### 9. 获取所有 Endpoint
**接口:** **接口:**
@ -312,7 +341,7 @@ GET /get_all_endpoints
--- ---
### 9. 获取指定 Endpoint 信息 ### 10. 获取指定 Endpoint 信息
**接口:** **接口:**

View File

@ -1,4 +1,7 @@
{erl_opts, [debug_info]}. {erl_opts, [debug_info]}.
{project_app_dirs, ["apps/*"]}.
{deps, [ {deps, [
{poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}}, {poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}},
{hackney, ".*", {git, "https://github.com/benoitc/hackney.git", {tag, "1.25.0"}}}, {hackney, ".*", {git, "https://github.com/benoitc/hackney.git", {tag, "1.25.0"}}},
@ -6,27 +9,25 @@
{cowboy, ".*", {git, "https://github.com/ninenines/cowboy.git", {tag, "2.14.0"}}}, {cowboy, ".*", {git, "https://github.com/ninenines/cowboy.git", {tag, "2.14.0"}}},
{ranch, ".*", {git, "https://github.com/ninenines/ranch.git", {tag, "2.2.0"}}}, {ranch, ".*", {git, "https://github.com/ninenines/ranch.git", {tag, "2.2.0"}}},
{brod, ".*", {git, "https://github.com/kafka4beam/brod.git", {tag, "4.4.5"}}}, {brod, ".*", {git, "https://github.com/kafka4beam/brod.git", {tag, "4.4.5"}}},
{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"}}},
{eredis, ".*", {git, "https://github.com/wooga/eredis.git", {tag, "v1.2.0"}}}, {eredis, ".*", {git, "https://github.com/wooga/eredis.git", {tag, "v1.2.0"}}},
{emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {branch, "main"}}}, {emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {tag, "v1.2"}}},
{gproc, ".*", {git, "https://github.com/uwiger/gproc.git", {tag, "0.9.1"}}}, {gproc, ".*", {git, "https://github.com/uwiger/gproc.git", {tag, "0.9.1"}}}
{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, {iot, "0.1.0"}, {relx, [{release, {iot, "0.1.0"},
[iot, [iot,
endpoint,
sasl]}, sasl]},
{mode, dev}, {include_erts, true},
{mode, prod},
%{mode, prod}, %{mode, prod},
%% automatically picked up if the files %% automatically picked up if the files
%% exist but can be set manually, which %% exist but can be set manually, which
%% is required if the names aren't exactly %% is required if the names aren't exactly
%% sys.config and vm.args %% sys.config and vm.args
{sys_config, "./config/sys.config"}, {sys_config, "./config/sys-dev.config"},
{vm_args, "./config/vm.args"} {vm_args, "./config/vm.args"}
%% the .src form of the configuration files do %% the .src form of the configuration files do
@ -39,14 +40,12 @@
[%% prod is the default mode when prod [%% prod is the default mode when prod
%% profile is used, so does not have %% profile is used, so does not have
%% to be explicitly included like this %% to be explicitly included like this
{mode, prod} {mode, prod},
{sys_config, "./config/sys-prod.config"}
%% use minimal mode to exclude ERTS %% use minimal mode to exclude ERTS
%% {mode, minimal} %% {mode, minimal}
] ]
}]}]}. }]}]}.
{erl_opts, [{parse_transform,lager_transform}]}.
{rebar_packages_cdn, "https://hexpm.upyun.com"}. {rebar_packages_cdn, "https://hexpm.upyun.com"}.

View File

@ -15,14 +15,13 @@
{<<"crc32cer">>,{pkg,<<"crc32cer">>,<<"1.0.3">>},2}, {<<"crc32cer">>,{pkg,<<"crc32cer">>,<<"1.0.3">>},2},
{<<"emqtt">>, {<<"emqtt">>,
{git,"https://gitea.s5s8.com/anlicheng/emqtt.git", {git,"https://gitea.s5s8.com/anlicheng/emqtt.git",
{ref,"5111914a9b1b92b0b497f825c77bdd365e3989b0"}}, {ref,"c5a52dcb57cd23a4318e342b705f9f726e8d67a1"}},
0}, 0},
{<<"eredis">>, {<<"eredis">>,
{git,"https://github.com/wooga/eredis.git", {git,"https://github.com/wooga/eredis.git",
{ref,"9ad91f149310a7d002cb966f62b7e2c3330abb04"}}, {ref,"9ad91f149310a7d002cb966f62b7e2c3330abb04"}},
0}, 0},
{<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1}, {<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1},
{<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1},
{<<"gproc">>, {<<"gproc">>,
{git,"https://github.com/uwiger/gproc.git", {git,"https://github.com/uwiger/gproc.git",
{ref,"4ca45e0a97722a418a31eb1753f4e3b953f7fb1d"}}, {ref,"4ca45e0a97722a418a31eb1753f4e3b953f7fb1d"}},
@ -32,25 +31,10 @@
{ref,"8c00789e411d7c09a9808d720232098da1f19d69"}}, {ref,"8c00789e411d7c09a9808d720232098da1f19d69"}},
0}, 0},
{<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1}, {<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1},
{<<"jiffy">>,
{git,"https://github.com/davisp/jiffy.git",
{ref,"9ea1b35b6e60ba21dfd4adbd18e7916a831fd7d4"}},
0},
{<<"kafka_protocol">>,{pkg,<<"kafka_protocol">>,<<"4.2.7">>},1}, {<<"kafka_protocol">>,{pkg,<<"kafka_protocol">>,<<"4.2.7">>},1},
{<<"lager">>,
{git,"https://github.com/erlang-lager/lager.git",
{ref,"459a3b2cdd9eadd29e5a7ce5c43932f5ccd6eb88"}},
0},
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},1}, {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},1},
{<<"mysql">>, {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},1},
{git,"https://github.com/mysql-otp/mysql-otp",
{ref,"caf5ff96c677a8fe0ce6f4082bc036c8fd27dd62"}},
0},
{<<"parse_trans">>,
{git,"https://github.com/uwiger/parse_trans",
{ref,"6f3645afb43c7c57d61b54ef59aecab288ce1013"}},
0},
{<<"poolboy">>, {<<"poolboy">>,
{git,"https://github.com/devinus/poolboy.git", {git,"https://github.com/devinus/poolboy.git",
{ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}}, {ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}},
@ -70,22 +54,22 @@
{<<"certifi">>, <<"0E6E882FCDAAA0A5A9F2B3DB55B1394DBA07E8D6D9BCAD08318FB604C6839712">>}, {<<"certifi">>, <<"0E6E882FCDAAA0A5A9F2B3DB55B1394DBA07E8D6D9BCAD08318FB604C6839712">>},
{<<"crc32cer">>, <<"AD0E42BED8603F2C72DE2A00F1B5063FFE12D5988615CAD984096900431D1C1A">>}, {<<"crc32cer">>, <<"AD0E42BED8603F2C72DE2A00F1B5063FFE12D5988615CAD984096900431D1C1A">>},
{<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>}, {<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>},
{<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>},
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
{<<"kafka_protocol">>, <<"6F53B15CD6F6A12C1D0010DB074B4A15985C71BC7F594BC2D67D9837B3B378A1">>}, {<<"kafka_protocol">>, <<"6F53B15CD6F6A12C1D0010DB074B4A15985C71BC7F594BC2D67D9837B3B378A1">>},
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
{<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>}, {<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>},
{<<"parse_trans">>, <<"6E6AA8167CB44CC8F39441D05193BE6E6F4E7C2946CB2759F015F8C56B76E5FF">>},
{<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>},
{<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]},
{pkg_hash_ext,[ {pkg_hash_ext,[
{<<"certifi">>, <<"B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60">>}, {<<"certifi">>, <<"B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60">>},
{<<"crc32cer">>, <<"08FDCD5CE51ACD839A12E98742F0F0EDA19A2A679FC9FBFAF6AAB958310FB70E">>}, {<<"crc32cer">>, <<"08FDCD5CE51ACD839A12E98742F0F0EDA19A2A679FC9FBFAF6AAB958310FB70E">>},
{<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>}, {<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>},
{<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>},
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
{<<"kafka_protocol">>, <<"1D5E9597AD3C0776C86DC5E08D3BAAEA7DB805A52E5FD35E3F071AAD7789FC4C">>}, {<<"kafka_protocol">>, <<"1D5E9597AD3C0776C86DC5E08D3BAAEA7DB805A52E5FD35E3F071AAD7789FC4C">>},
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
{<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>}, {<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>},
{<<"parse_trans">>, <<"620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A">>},
{<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>},
{<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]}
]. ].

View File

@ -1,37 +0,0 @@
# 待完成
## 1. endpoint需要存储在数据库
```mysql
CREATE TABLE `endpoint` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称',
`title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '序列号',
`type` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '类型',
`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息基于json格式存储',
`status` smallint NOT NULL DEFAULT '-1',
`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```
## 2. service_config微服务配置也要存在于数据库
```mysql
CREATE TABLE `service_config` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`service_id` bigint unsigned NOT NULL COMMENT '服务的id',
`host_uuid` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称',
`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息基于json格式存储',
`last_config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息基于json格式存储',
`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_service_id` (`service_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```