Unverified Commit 6cedba7b authored by Evan Vigil-McClanahan's avatar Evan Vigil-McClanahan Committed by GitHub
Browse files

Merge pull request #454 from helium/jsk/regional-enforcement

Add downlink throttling
parents 57a17708 547b0427
Showing with 459 additions and 40 deletions
+459 -40
......@@ -49,7 +49,10 @@
latlong,
reg_domain_confirmed = false :: boolean(),
reg_region :: atom(),
reg_freq_list :: [float()]
reg_freq_list :: [float()],
reg_throttle :: miner_lora_throttle:handle(),
last_tmst_us = undefined :: undefined | integer(), % last concentrator tmst reported by the packet forwarder
last_mono_us = undefined :: undefined | integer() % last local monotonic timestamp taken when packet forwarder reported last tmst
}).
-record(country, {
......@@ -71,6 +74,22 @@
%% in meters
-define(MAX_WANDER_DIST, 200).
%% Maximum `tmst` counter value reported by an SX130x concentrator
%% IC. This is a raw [1] counter value with the following
%% characteristics:
%%
%% - unsigned
%% - counts upwards
%% - 32 bits
%% - increments at 1 MHz
%%
%% [1]: On SX1301 it is a raw value. On SX1302 it is a 32 bit value
%% counting at 32 MHz, but the SX1302 HAL throws away 5 bits to match
%% SX1301's behavior.
%%
%% Equivalent `(2^32)-1`
-define(MAX_TMST_VAL, 4294967295).
%% ------------------------------------------------------------------
%% API Function Definitions
%% ------------------------------------------------------------------
......@@ -204,7 +223,8 @@ init(Args) ->
pubkey_bin = blockchain_swarm:pubkey_bin(),
reg_domain_confirmed = RegDomainConfirmed,
reg_region = DefaultRegRegion,
reg_freq_list = DefaultRegFreqList}}.
reg_freq_list = DefaultRegFreqList,
reg_throttle = miner_lora_throttle:new(DefaultRegRegion)}}.
handle_call({send, _Payload, _When, _ChannelSelectorFun, _DataRate, _Power, _IPol}, _From,
#state{reg_domain_confirmed = false}=State) ->
......@@ -214,7 +234,10 @@ handle_call({send, Payload, When, ChannelSelectorFun, DataRate, Power, IPol}, Fr
#state{ socket=Socket,
gateways=Gateways,
packet_timers=Timers,
reg_freq_list = Freqs}=State) ->
reg_freq_list = Freqs,
reg_throttle = Throttle,
last_tmst_us = PrevTmst_us,
last_mono_us = PrevMono_us }=State) ->
case select_gateway(Gateways) of
{error, _}=Error ->
{reply, Error, State};
......@@ -238,18 +261,28 @@ handle_call({send, Payload, When, ChannelSelectorFun, DataRate, Power, IPol}, Fr
<<"data">> => base64:encode(Payload)
}
},
%% TODO we should check this for regulatory compliance
BinJSX = jsx:encode(DecodedJSX),
lager:info("PULL_RESP ~p to ~p:~p", [DecodedJSX, IP, Port]),
%% Check this transmission for regulatory compliance.
{SpreadingFactor, Bandwidth} = parse_datarate(DataRate),
TimeOnAir = miner_lora_throttle:time_on_air(Bandwidth, SpreadingFactor, 5, 8, true, byte_size(Payload)),
AdjustedTmst_us = tmst_to_local_monotonic_time(When, PrevTmst_us, PrevMono_us),
SentAt = AdjustedTmst_us / 1000,
case miner_lora_throttle:can_send(Throttle, SentAt, LocalFreq, TimeOnAir) of
false -> lager:warning("This transmission should have been rejected");
true -> ok
end,
Packet = <<?PROTOCOL_2:8/integer-unsigned, Token/binary, ?PULL_RESP:8/integer-unsigned, BinJSX/binary>>,
maybe_mirror(State#state.mirror_socket, Packet),
lager:debug("sending packet via channel: ~p",[LocalFreq]),
ok = gen_udp:send(Socket, IP, Port, Packet),
%% TODO a better timeout would be good here
Ref = erlang:send_after(10000, self(), {tx_timeout, Token}),
{noreply, State#state{packet_timers=maps:put(Token, {send, Ref, From}, Timers)}}
{noreply, State#state{packet_timers = maps:put(Token, {send, Ref, From, SentAt, LocalFreq, TimeOnAir}, Timers)}}
end;
handle_call(port, _From, State) ->
{reply, inet:port(State#state.socket), State};
......@@ -309,7 +342,7 @@ handle_info(reg_domain_timeout, #state{reg_domain_confirmed=false, pubkey_bin=Ad
lager:info("confirmed regulatory domain for miner ~p. region: ~p, freqlist: ~p",
[Addr, Region, FrequencyList]),
{noreply, State#state{ reg_domain_confirmed = true, reg_region = Region,
reg_freq_list = FrequencyList}}
reg_freq_list = FrequencyList, reg_throttle = miner_lora_throttle:new(Region)}}
end
catch
_Type:Exception ->
......@@ -319,15 +352,16 @@ handle_info(reg_domain_timeout, #state{reg_domain_confirmed=false, pubkey_bin=Ad
end;
handle_info({tx_timeout, Token}, #state{packet_timers=Timers}=State) ->
case maps:find(Token, Timers) of
{ok, {send, _Ref, From}} ->
{ok, {send, _Ref, From, _SentAt, _LocalFreq, _TimeOnAir}} ->
gen_server:reply(From, {error, timeout});
error ->
ok
end,
{noreply, State#state{packet_timers=maps:remove(Token, Timers)}};
handle_info({udp, Socket, IP, Port, Packet}, #state{socket=Socket}=State) ->
RxInstantLocal_us = erlang:monotonic_time(microsecond),
maybe_mirror(State#state.mirror_socket, Packet),
State2 = handle_udp_packet(Packet, IP, Port, State),
State2 = handle_udp_packet(Packet, IP, Port, RxInstantLocal_us, State),
{noreply, State2};
handle_info({udp_passive, Socket}, #state{socket=Socket}=State) ->
inet:setopts(Socket, [{active, 100}]),
......@@ -370,13 +404,14 @@ select_gateway(Gateways) ->
{ok, erlang:element(2, erlang:hd(maps:to_list(Gateways)))}
end.
-spec handle_udp_packet(binary(), inet:ip_address(), inet:port_number(), state()) -> state().
-spec handle_udp_packet(binary(), inet:ip_address(), inet:port_number(), integer(), state()) -> state().
handle_udp_packet(<<?PROTOCOL_2:8/integer-unsigned,
Token:2/binary,
?PUSH_DATA:8/integer-unsigned,
MAC:64/integer,
JSON/binary>>, IP, Port, #state{socket=Socket, gateways=Gateways,
reg_domain_confirmed = RegDomainConfirmed}=State) ->
JSON/binary>>, IP, Port, RxInstantLocal_us,
#state{socket=Socket, gateways=Gateways,
reg_domain_confirmed = RegDomainConfirmed}=State) ->
lager:info("PUSH_DATA ~p from ~p on ~p", [jsx:decode(JSON), MAC, Port]),
Gateway =
case maps:find(MAC, Gateways) of
......@@ -392,12 +427,12 @@ handle_udp_packet(<<?PROTOCOL_2:8/integer-unsigned,
Packet = <<?PROTOCOL_2:8/integer-unsigned, Token/binary, ?PUSH_ACK:8/integer-unsigned>>,
maybe_mirror(State#state.mirror_socket, Packet),
maybe_send_udp_ack(Socket, IP, Port, Packet, RegDomainConfirmed),
handle_json_data(jsx:decode(JSON, [return_maps]), Gateway, State);
handle_json_data(jsx:decode(JSON, [return_maps]), Gateway, RxInstantLocal_us, State);
handle_udp_packet(<<?PROTOCOL_2:8/integer-unsigned,
Token:2/binary,
?PULL_DATA:8/integer-unsigned,
MAC:64/integer>>, IP, Port, #state{socket=Socket, gateways=Gateways,
reg_domain_confirmed = RegDomainConfirmed}=State) ->
MAC:64/integer>>, IP, Port, _RxInstantLocal_us, #state{socket=Socket, gateways=Gateways,
reg_domain_confirmed = RegDomainConfirmed}=State) ->
Packet = <<?PROTOCOL_2:8/integer-unsigned, Token/binary, ?PULL_ACK:8/integer-unsigned>>,
maybe_mirror(State#state.mirror_socket, Packet),
maybe_send_udp_ack(Socket, IP, Port, Packet, RegDomainConfirmed),
......@@ -414,66 +449,67 @@ handle_udp_packet(<<?PROTOCOL_2:8/integer-unsigned,
Token:2/binary,
?TX_ACK:8/integer-unsigned,
_MAC:64/integer,
MaybeJSON/binary>>, _IP, _Port, #state{packet_timers=Timers}=State) ->
MaybeJSON/binary>>, _IP, _Port, _RxInstantLocal_us, #state{packet_timers=Timers, reg_throttle=Throttle}=State) ->
lager:info("TX ack for token ~p ~p", [Token, MaybeJSON]),
case maps:find(Token, Timers) of
{ok, {send, Ref, From}} when MaybeJSON == <<>> -> %% empty string means success, at least with the semtech reference implementation
{ok, {send, Ref, From, SentAt, LocalFreq, TimeOnAir}} when MaybeJSON == <<>> -> %% empty string means success, at least with the semtech reference implementation
_ = erlang:cancel_timer(Ref),
_ = gen_server:reply(From, ok),
State#state{packet_timers=maps:remove(Token, Timers)};
{ok, {send, Ref, From}} ->
State#state{packet_timers=maps:remove(Token, Timers),
reg_throttle=miner_lora_throttle:track_sent(Throttle, SentAt, LocalFreq, TimeOnAir)};
{ok, {send, Ref, From, SentAt, LocalFreq, TimeOnAir}} ->
%% likely some kind of error here
_ = erlang:cancel_timer(Ref),
Reply = case kvc:path([<<"txpk_ack">>, <<"error">>], jsx:decode(MaybeJSON)) of
{Reply, NewThrottle} = case kvc:path([<<"txpk_ack">>, <<"error">>], jsx:decode(MaybeJSON)) of
<<"NONE">> ->
lager:info("packet sent ok"),
ok;
{ok, miner_lora_throttle:track_sent(Throttle, SentAt, LocalFreq, TimeOnAir)};
<<"COLLISION_", _/binary>> ->
%% colliding with a beacon or another packet, check if join2/rx2 is OK
lager:info("collision"),
{error, collision};
{{error, collision}, Throttle};
<<"TOO_LATE">> ->
lager:info("too late"),
{error, too_late};
{{error, too_late}, Throttle};
<<"TOO_EARLY">> ->
lager:info("too early"),
{error, too_early};
{{error, too_early}, Throttle};
<<"TX_FREQ">> ->
lager:info("tx frequency not supported"),
{error, bad_tx_frequency};
<<"TX_POWER">> ->
lager:info("tx power not supported"),
{error, bad_tx_power};
{{error, bad_tx_power}, Throttle};
<<"GPL_UNLOCKED">> ->
lager:info("transmitting on GPS time not supported because no GPS lock"),
{error, no_gps_lock};
{{error, no_gps_lock}, Throttle};
Error ->
%% any other errors are pretty severe
lager:error("Failure enqueing packet for gateway ~p", [Error]),
{error, {unknown, Error}}
{{error, {unknown, Error}}, Throttle}
end,
gen_server:reply(From, Reply),
State#state{packet_timers=maps:remove(Token, Timers)};
State#state{packet_timers=maps:remove(Token, Timers), reg_throttle=NewThrottle};
error ->
State
end;
handle_udp_packet(Packet, _IP, _Port, State) ->
handle_udp_packet(Packet, _IP, _Port, _RxInstantLocal_us, State) ->
lager:info("unhandled udp packet ~p", [Packet]),
State.
-spec handle_json_data(map(), gateway(), state()) -> state().
handle_json_data(#{<<"rxpk">> := Packets} = Map, Gateway, State0) ->
State1 = handle_packets(sort_packets(Packets), Gateway, State0),
handle_json_data(maps:remove(<<"rxpk">>, Map), Gateway, State1);
handle_json_data(#{<<"stat">> := Status} = Map, Gateway0, #state{gateways=Gateways}=State) ->
-spec handle_json_data(map(), gateway(), integer(), state()) -> state().
handle_json_data(#{<<"rxpk">> := Packets} = Map, Gateway, RxInstantLocal_us, State0) ->
State1 = handle_packets(sort_packets(Packets), Gateway, RxInstantLocal_us, State0),
handle_json_data(maps:remove(<<"rxpk">>, Map), Gateway, RxInstantLocal_us, State1);
handle_json_data(#{<<"stat">> := Status} = Map, Gateway0, RxInstantLocal_us, #state{gateways=Gateways}=State) ->
Gateway1 = Gateway0#gateway{status=Status},
lager:info("got status ~p", [Status]),
lager:info("Gateway ~p", [lager:pr(Gateway1, ?MODULE)]),
Mac = Gateway1#gateway.mac,
State1 = maybe_update_gps(Status, State),
handle_json_data(maps:remove(<<"stat">>, Map), Gateway1,
handle_json_data(maps:remove(<<"stat">>, Map), Gateway1, RxInstantLocal_us,
State1#state{gateways=maps:put(Mac, Gateway1, Gateways)});
handle_json_data(_, _Gateway, State) ->
handle_json_data(_, _Gateway, _RxInstantLocal_us, State) ->
State.
%% cache GPS the state with each update. I'm not sure if this will
......@@ -499,12 +535,12 @@ sort_packets(Packets) ->
Packets
).
-spec handle_packets(list(), gateway(), state()) -> state().
handle_packets([], _Gateway, State) ->
-spec handle_packets(list(), gateway(), integer(), state()) -> state().
handle_packets([], _Gateway, _RxInstantLocal_us, State) ->
State;
handle_packets(_Packets, _Gateway, #state{reg_domain_confirmed = false} = State) ->
handle_packets(_Packets, _Gateway, _RxInstantLocal_us, #state{reg_domain_confirmed = false} = State) ->
State;
handle_packets([Packet|Tail], Gateway, #state{reg_region = Region} = State) ->
handle_packets([Packet|Tail], Gateway, RxInstantLocal_us, #state{reg_region = Region} = State) ->
Data = base64:decode(maps:get(<<"data">>, Packet)),
case route(Data) of
error ->
......@@ -526,7 +562,7 @@ handle_packets([Packet|Tail], Gateway, #state{reg_region = Region} = State) ->
lager:notice("Routing ~p", [RoutingInfo]),
erlang:spawn(fun() -> send_to_router(Type, RoutingInfo, Packet, Region) end)
end,
handle_packets(Tail, Gateway, State).
handle_packets(Tail, Gateway, RxInstantLocal_us, State#state{last_mono_us = RxInstantLocal_us, last_tmst_us = maps:get(<<"tmst">>, Packet)}).
-spec route(binary()) -> any().
route(Pkt) ->
......@@ -653,6 +689,40 @@ channel(Freq, [H|T], Acc) ->
channel(Freq, T, Acc+1)
end.
%% @doc returns a tuple of {SpreadingFactor, Bandwidth} from strings like "SFdBWddd"
%%
%% Example: `{7, 125} = scratch:parse_datarate("SF7BW125")'
-spec parse_datarate(string()) -> {integer(), integer()}.
parse_datarate(Datarate) ->
case Datarate of
[$S, $F, SF1, SF2, $B, $W, BW1, BW2, BW3] ->
{erlang:list_to_integer([SF1, SF2]), erlang:list_to_integer([BW1, BW2, BW3])};
[$S, $F, SF1, $B, $W, BW1, BW2, BW3] ->
{erlang:list_to_integer([SF1]), erlang:list_to_integer([BW1, BW2, BW3])}
end.
%% @doc adjusts concentrator timestamp (`tmst`) to a monotonic value.
%%
%% The returned value is a best-effort estimate of what
%% `erlang:monotonic_time(microsecond)` would return if it was called
%% at `Tmst_us`.
-spec tmst_to_local_monotonic_time(immediate | integer(), undefined | integer(), undefined | integer()) -> integer().
tmst_to_local_monotonic_time(immediate, _PrevTmst_us, _PrevMonoTime_us) ->
erlang:monotonic_time(microsecond);
tmst_to_local_monotonic_time(When, undefined, undefined) ->
%% We haven't yet received a `tmst` from the packet forwarder, so
%% we don't have anything to track. Let's just use the current
%% time and hope for the best.
erlang:monotonic_time(microsecond);
tmst_to_local_monotonic_time(Tmst_us, PrevTmst_us, PrevMonoTime_us) when Tmst_us >= PrevTmst_us ->
Tmst_us - PrevTmst_us + PrevMonoTime_us;
tmst_to_local_monotonic_time(Tmst_us, PrevTmst_us, PrevMonoTime_us) ->
%% Because `Tmst_us` is less than the last `tmst` we received from
%% the packet forwarder, we allow for the possibility one single
%% roll over of the clock has occurred, and that `Tmst_us` might
%% represent a time in the future.
Tmst_us + ?MAX_TMST_VAL - PrevTmst_us + PrevMonoTime_us.
%% Extracts a packet's RSSI, abstracting away the differences between
%% GWMP JSON V1/V2.
-spec packet_rssi(map()) -> number().
......
%% @doc This module provides time-on-air regulatory compliance for the
%% EU868 and US915 ISM bands.
%%
%% This module does not interface with hardware or provide any
%% transmission capabilities itself. Instead, the API provides its
%% core functionality through `track_sent/4', `can_send/4', and
%% `time_on_air/6'.
-module(miner_lora_throttle).
-export([
can_send/4,
dwell_time/3,
new/1,
time_on_air/6,
track_sent/4,
track_sent/9
]).
-export_type([
region/0,
handle/0
]).
-record(sent_packet, {
sent_at :: number(),
time_on_air :: number(),
frequency :: number()
}).
-type region() :: 'AS923' | 'AU915' | 'CN470' | 'CN779' | 'EU433' | 'EU868' | 'IN865' | 'KR920' | 'US915'.
-type regulatory_model() :: {dwell | duty, Limit :: number(), Period :: number()}.
-opaque handle() :: {regulatory_model(), list(#sent_packet{})}.
%% Maximum time allowable time on air.
-define(MAX_TIME_ON_AIR_MS, 400).
-spec model(region()) -> regulatory_model().
model(Region) ->
case Region of
%% Limit Period
%% (%)
'AS923' -> {'duty', 0.01, 3600000};
'AU915' -> {'duty', 0.01, 3600000};
'CN470' -> {'duty', 0.01, 3600000};
'CN779' -> {'duty', 0.01, 3600000};
'EU433' -> {'duty', 0.01, 3600000};
'EU868' -> {'duty', 0.01, 3600000};
'IN865' -> {'duty', 0.01, 3600000};
'KR920' -> {'duty', 0.01, 3600000};
%% ms Period
'US915' -> {'dwell', 400, 20000}
end.
%% Updates Handle with time-on-air information.
%%
%% This function does not send/transmit itself.
-spec track_sent(
Handle :: handle(),
SentAt :: number(),
Frequency :: number(),
Bandwidth :: number(),
SpreadingFactor :: integer(),
CodeRate :: integer(),
PreambleSymbols :: integer(),
ExplicitHeader :: boolean(),
PayloadLen :: integer()
) ->
handle().
track_sent(
Handle,
SentAt,
Frequency,
Bandwidth,
SpreadingFactor,
CodeRate,
PreambleSymbols,
ExplicitHeader,
PayloadLen
) ->
TimeOnAir = time_on_air(
Bandwidth,
SpreadingFactor,
CodeRate,
PreambleSymbols,
ExplicitHeader,
PayloadLen
),
track_sent(Handle, SentAt, Frequency, TimeOnAir).
-spec track_sent(handle(), number(), number(), number()) -> handle().
track_sent({Region, SentPackets}, SentAt, Frequency, TimeOnAir) ->
NewSent = #sent_packet{
frequency = Frequency,
sent_at = SentAt,
time_on_air = TimeOnAir
},
{Region, trim_sent(Region, [NewSent | SentPackets])}.
-spec trim_sent(regulatory_model(), list(#sent_packet{})) -> list(#sent_packet{}).
trim_sent(Model, SentPackets = [NewSent, LastSent | _])
when NewSent#sent_packet.sent_at < LastSent#sent_packet.sent_at ->
trim_sent(Model, lists:sort(fun (A, B) -> A > B end, SentPackets));
trim_sent({_, _, Period}, SentPackets = [H | _]) ->
CutoffTime = H#sent_packet.sent_at - Period,
Pred = fun (Sent) -> Sent#sent_packet.sent_at > CutoffTime end,
lists:takewhile(Pred, SentPackets).
%% @doc Based on previously sent packets, returns a boolean value if
%% it is legal to send on Frequency at time Now.
%%
%%
-spec can_send(
Handle :: handle(),
AtTime :: number(),
Frequency :: integer(),
TimeOnAir :: number()
) ->
boolean().
can_send(_Handle, _AtTime, _Frequency, TimeOnAir) when TimeOnAir > ?MAX_TIME_ON_AIR_MS ->
%% TODO: check that all regions have do in fact have the same
%% maximum time on air.
false;
can_send({{dwell, Limit, Period}, SentPackets}, AtTime, Frequency, TimeOnAir) ->
CutoffTime = AtTime - Period + TimeOnAir,
ProjectedDwellTime = dwell_time(SentPackets, CutoffTime, Frequency) + TimeOnAir,
ProjectedDwellTime =< Limit;
can_send({{duty, Limit, Period}, SentPackets}, AtTime, _Frequency, TimeOnAir) ->
CutoffTime = AtTime - Period,
CurrDwell = dwell_time(SentPackets, CutoffTime, all),
(CurrDwell + TimeOnAir) / Period < Limit.
%% @doc Computes the total time on air for packets sent on Frequency
%% and no older than CutoffTime.
-spec dwell_time(list(#sent_packet{}), integer(), number() | 'all') -> number().
dwell_time(SentPackets, CutoffTime, Frequency) ->
dwell_time(SentPackets, CutoffTime, Frequency, 0).
-spec dwell_time(list(#sent_packet{}), integer(), number() | 'all', number()) -> number().
%% Scenario 1: entire packet sent before CutoffTime
dwell_time([P | T], CutoffTime, Frequency, Acc)
when P#sent_packet.sent_at + P#sent_packet.time_on_air < CutoffTime ->
dwell_time(T, CutoffTime, Frequency, Acc);
%% Scenario 2: packet sent on non-relevant frequency.
dwell_time([P | T], CutoffTime, Frequency, Acc) when is_number(Frequency), P#sent_packet.frequency /= Frequency ->
dwell_time(T, CutoffTime, Frequency, Acc);
%% Scenario 3: Packet started before CutoffTime but finished after CutoffTime.
dwell_time([P | T], CutoffTime, Frequency, Acc) when P#sent_packet.sent_at =< CutoffTime ->
RelevantTimeOnAir = P#sent_packet.time_on_air - (CutoffTime - P#sent_packet.sent_at),
true = RelevantTimeOnAir >= 0,
dwell_time(T, CutoffTime, Frequency, Acc + RelevantTimeOnAir);
%% Scenario 4: 100 % of packet transmission after CutoffTime.
dwell_time([P | T], CutoffTime, Frequency, Acc) ->
dwell_time(T, CutoffTime, Frequency, Acc + P#sent_packet.time_on_air);
dwell_time([], _CutoffTime, _Frequency, Acc) ->
Acc.
%% @doc Returns total time on air for packet sent with given
%% parameters.
%%
%% See Semtech Appnote AN1200.13, "LoRa Modem Designer's Guide"
-spec time_on_air(
Bandwidth :: number(),
SpreadingFactor :: number(),
CodeRate :: integer(),
PreambleSymbols :: integer(),
ExplicitHeader :: boolean(),
PayloadLen :: integer()
) ->
Milliseconds :: float().
time_on_air(
Bandwidth,
SpreadingFactor,
CodeRate,
PreambleSymbols,
ExplicitHeader,
PayloadLen
) ->
SymbolDuration = symbol_duration(Bandwidth, SpreadingFactor),
PayloadSymbols = payload_symbols(
SpreadingFactor,
CodeRate,
ExplicitHeader,
PayloadLen,
(Bandwidth =< 125000) and (SpreadingFactor >= 11)
),
SymbolDuration * (4.25 + PreambleSymbols + PayloadSymbols).
%% @doc Returns the number of payload symbols required to send payload.
-spec payload_symbols(integer(), integer(), boolean(), integer(), boolean()) -> number().
payload_symbols(
SpreadingFactor,
CodeRate,
ExplicitHeader,
PayloadLen,
LowDatarateOptimized
) ->
EH = b2n(ExplicitHeader),
LDO = b2n(LowDatarateOptimized),
8 +
(erlang:max(
math:ceil(
(8 * PayloadLen - 4 * SpreadingFactor + 28 +
16 - 20 * (1 - EH)) /
(4 * (SpreadingFactor - 2 * LDO))
) * (CodeRate),
0
)).
-spec symbol_duration(number(), number()) -> float().
symbol_duration(Bandwidth, SpreadingFactor) ->
math:pow(2, SpreadingFactor) / Bandwidth.
%% @doc Returns a new handle for the given region.
-spec new(region()) -> handle().
new(Region) ->
{model(Region), []}.
-spec b2n(boolean()) -> integer().
b2n(false) ->
0;
b2n(true) ->
1.
-module(miner_lora_throttle_tests).
-include_lib("eunit/include/eunit.hrl").
%% Test cases generated with https://www.loratools.nl/#/airtime and
%% truncated to milliseconds.
us_time_on_air_test() ->
?assertEqual(991, ms(miner_lora_throttle:time_on_air(125.0e3, 12, 5, 8, true, 7))),
?assertEqual(2465, ms(miner_lora_throttle:time_on_air(125.0e3, 12, 5, 8, true, 51))),
?assertEqual(495, ms(miner_lora_throttle:time_on_air(125.0e3, 11, 5, 8, true, 7))),
?assertEqual(1314, ms(miner_lora_throttle:time_on_air(125.0e3, 11, 5, 8, true, 51))),
?assertEqual(247, ms(miner_lora_throttle:time_on_air(125.0e3, 10, 5, 8, true, 7))),
?assertEqual(616, ms(miner_lora_throttle:time_on_air(125.0e3, 10, 5, 8, true, 51))),
?assertEqual(123, ms(miner_lora_throttle:time_on_air(125.0e3, 9, 5, 8, true, 7))),
?assertEqual(328, ms(miner_lora_throttle:time_on_air(125.0e3, 9, 5, 8, true, 51))),
?assertEqual(72, ms(miner_lora_throttle:time_on_air(125.0e3, 8, 5, 8, true, 7))),
?assertEqual(184, ms(miner_lora_throttle:time_on_air(125.0e3, 8, 5, 8, true, 51))),
?assertEqual(36, ms(miner_lora_throttle:time_on_air(125.0e3, 7, 5, 8, true, 7))),
?assertEqual(102, ms(miner_lora_throttle:time_on_air(125.0e3, 7, 5, 8, true, 51))),
ok.
us915_dwell_time_test() ->
MaxDwell = 400,
Period = 20000,
HalfMax = MaxDwell div 2,
QuarterMax = MaxDwell div 4,
%% There are no special frequencies in region US915, so the
%% lorareg API doesn't care what values you use for Frequency
%% arguments as long as they are distinct and comparable. We can
%% use channel number instead like so.
Ch0 = 0,
Ch1 = 1,
%% Time naught. Times can be negative as the only requirement
%% lorareg places is on time is that it is monotonically
%% increasing and expressed as milliseconds.
T0 = -123456789,
S0 = miner_lora_throttle:new('US915'),
S1 = miner_lora_throttle:track_sent(S0, T0, Ch0, MaxDwell),
S2 = miner_lora_throttle:track_sent(S1, T0, Ch1, HalfMax),
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + 100, Ch0, MaxDwell)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0, Ch1, HalfMax)),
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + 1, Ch0, MaxDwell)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + 1, Ch1, HalfMax)),
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + Period - 1, Ch0, MaxDwell)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period, Ch0, MaxDwell)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period + 1, Ch0, MaxDwell)),
%% The following cases are all allowed because no matter how you
%% vary the start time this transmission, (HalfMax + HalfMax)
%% ratifies the constrain of `=< MaxDwell'.
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax - 1, Ch1, HalfMax)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax, Ch1, HalfMax)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax + 1, Ch1, HalfMax)),
%% None of the following cases are allowed because they all exceed
%% maximum dwell time by 1.
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax - 1, Ch1, HalfMax + 1)),
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax - 2, Ch1, HalfMax + 1)),
?assertEqual(false, miner_lora_throttle:can_send(S2, T0 + Period - HalfMax - 3, Ch1, HalfMax + 1)),
%% The following cases are all allowed because they all begin a full
%% period of concern after the currently tracked transmissions.
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period + MaxDwell, Ch0, MaxDwell)),
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period + MaxDwell, Ch1, MaxDwell)),
%% Let's finish of by tracking two more small packets of 1/4
%% maximum dwell in length and asserting that there is no more
%% time left in the [T0, T0 + Period) for even a packet of 1ms in duration.
?assertEqual(true, miner_lora_throttle:can_send(S2, T0 + Period div 4, Ch1, QuarterMax)),
S3 = miner_lora_throttle:track_sent(S2, T0 + Period div 4, Ch1, QuarterMax),
?assertEqual(true, miner_lora_throttle:can_send(S3, T0 + (Period * 0.75), Ch1, QuarterMax)),
S4 = miner_lora_throttle:track_sent(S3, T0 + (Period * 3) div 4, Ch1, QuarterMax),
?assertEqual(false, miner_lora_throttle:can_send(S4, T0 + Period - 1, Ch1, 1)),
%% ... but one ms later, we're all clear to send a packet. Note
%% that if had sent that first packet on channel 1 even a ms later
%% this would fail too.
?assertEqual(true, miner_lora_throttle:can_send(S4, T0 + Period, Ch1, 1)),
ok.
eu868_duty_cycle_test() ->
MaxTimeOnAir = 400,
Ten_ms = 10,
Ch0 = 0,
Ch1 = 1,
S0 = miner_lora_throttle:new('EU868'),
?assertEqual(true, miner_lora_throttle:can_send(S0, 0, Ch0, MaxTimeOnAir)),
?assertEqual(false, miner_lora_throttle:can_send(S0, 0, Ch0, MaxTimeOnAir + 1)),
%% Send 3599 packets of duration 10ms on a single channel over the
%% course of one hour. All should be accepted because 3599 * 10ms
%% = 35.99s, or approx 0.9997 % duty-cycle.
{S1, Now} = lists:foldl(
fun (N, {State, _T}) ->
Now = (N - 1) * 1000,
?assertEqual(true, miner_lora_throttle:can_send(State, Now, Ch0, Ten_ms)),
{miner_lora_throttle:track_sent(State, Now, Ch0, Ten_ms), Now + 1000}
end,
{miner_lora_throttle:new('EU868'), 0},
lists:seq(1, 3599)
),
%% Let's try sending on a different channel. This will fail
%% because, unlike FCC, ETSI rules limit overall duty-cycle and
%% not per-channel dwell. So despite being a different channel, if
%% this transmission were allowed, it raise our overall duty cycle
%% to exactly 1 %.
?assertEqual(false, miner_lora_throttle:can_send(S1, Now, Ch1, Ten_ms)),
ok.
%% Converts floating point seconds to integer seconds to remove
%% floating point ambiguity from test cases.
ms(Seconds) ->
erlang:trunc(Seconds * 1000.0).
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment