From d78dd4c8f4c74530632beb7333323ebaba24e5ef Mon Sep 17 00:00:00 2001 From: Winford Date: Sun, 21 Apr 2024 15:18:19 -0700 Subject: [PATCH] Add network:wifi_scan/0,1 to esp32 network driver Adds the ability for devices configured for sta or ap+sta to scan for available access points when not currently associated to an AP. With no arguments the default maximum results returned is 6. All default options can be configured in the `sta_config()` section of the configuration used to start the network driver, or set in the configuration parameter of `network:wifi_scan/1`. Signed-off-by: Winford --- CHANGELOG.md | 1 + libs/eavmlib/src/network.erl | 133 ++++++++- .../components/avm_builtins/network_driver.c | 260 +++++++++++++++++- 3 files changed, 392 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0178458..0fa5161eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a limited implementation of the OTP `ets` interface - Added `code:all_loaded/0` and `code:all_available/0` +- Add `network:wifi_scan/0,1` to ESP32 network driver to scan available APs when in station (or sta+ap) mode. ## [0.6.6] - Unreleased diff --git a/libs/eavmlib/src/network.erl b/libs/eavmlib/src/network.erl index 53c912d5b..d1ea7cf69 100644 --- a/libs/eavmlib/src/network.erl +++ b/libs/eavmlib/src/network.erl @@ -25,7 +25,8 @@ -export([ wait_for_sta/0, wait_for_sta/1, wait_for_sta/2, wait_for_ap/0, wait_for_ap/1, wait_for_ap/2, - sta_rssi/0 + sta_rssi/0, + wifi_scan/0, wifi_scan/1 ]). -export([start/1, start_link/1, stop/0]). -export([ @@ -37,6 +38,10 @@ ]). -define(SERVER, ?MODULE). +% These values ate used to calculate the gen_server:call/3 timeout. +-define(DEVICE_TOTAL_CHANNELS, 14). +-define(GEN_RESPONSE_MS, 5000). +-define(MAX_SHORT_DWELL, 320). -type octet() :: 0..255. -type ipv4_address() :: {octet(), octet(), octet(), octet()}. @@ -53,10 +58,16 @@ -type sta_beacon_timeout_config() :: {beacon_timeout, fun(() -> term())}. -type sta_disconnected_config() :: {disconnected, fun(() -> term())}. -type sta_got_ip_config() :: {got_ip, fun((ip_info()) -> term())}. +-type sta_scan_config() :: + {default_scan_results, 1..20} + | {scan_dwell_ms, 1..1500} + | scan_show_hidden + | scan_passive. -type sta_config_property() :: ssid_config() | psk_config() | dhcp_hostname_config() + | sta_scan_config() | sta_connected_config() | sta_beacon_timeout_config() | sta_disconnected_config() @@ -150,6 +161,37 @@ %% a powerful signal this can be a positive number. A level of 0 dBm corresponds to the power of 1 %% milliwatt. A 10 dBm decrease in level is equivalent to a ten-fold decrease in signal power. +-type scan_options() :: + {results, 1..20} + | {dwell, 1..1500} + | show_hidden + | passive. +%% The `results' key is used to set the maximum number of networks returned in the +%% networks list, and the `dwell' is used to set the dwell time (in milliseconds) +%% spent on each channel. The option `show_hidden' will also include hidden networks +%% in the scan results. Default options are: `[{results, 6}, {dwell, 120}]', if +%% `passive' is used the default dwell time per channel is 360 ms. + +-type auth_type() :: + open + | wep + | wpa_psk + | wpa2_psk + | wpa_wpa2_psk + | eap + | wpa3_psk + | wpa2_wpa3_psk + | wapi + | owe + | wpa3_enterprise_192 + | wpa3_ext_psk + | wpa3_ext_psk_mixed. + +-type network_properties() :: [ + {rssi, dbm()} | [{authmode, auth_type()} | [{channel, wifi_channel()}]] +]. +%% A proplist of network properties with the keys: `rssi', `authmode' and `channel' + -record(state, { config :: network_config(), port :: port(), @@ -326,6 +368,75 @@ sta_rssi() -> Other -> {error, Other} end. +%% @param Options is a list of `scan_options()' +%% @returns Scan result tuple, or {error, Reason} if a failure occurred. +%% +%% @doc Scan for available WiFi networks. +%% +%% The network must first be started in sta or sta+ap mode before scanning for access points. While +%% a scan is in progress network traffic will be inhibited, but should not cause an active connection +%% to be lost. Espressif's documentation recommends not exceeding 1500 ms per-chanel scan times or +%% network connections may be lost, this is enforced as a hard limit. The return is a tuple +%% `{ok, Results}', where Results is a tuple with the number of discovered networks and a list of +%% networks, which may be shorter than the size of the discovered networks if a smaller `MaxAPs' was +%% used. The network tuples in the list consist of network name and a proplist of network information: +%% +%% `{ok, {NumberResults, [{SSID, [{rssi, DBm}, {authmode, Mode}, {channel, Number}]}, ...]}}' +%% +%% Example: +%%
+%%          ...
+%%          {ok, {Number, Results}} = wifi_scan(10),
+%%          io:format("Number of networks discovered: ~p~n", [Number])
+%%          lists:foreach(fun(Network = {SSID, [{rssi, DBm}, {authmode, Mode}, {channel, Number}]}) ->
+%%              io:format("Network: ~p, signal ~p dBm, Security: ~p, channel ~p~n",
+%%                  [SSID, DBm, Mode, Number]) end,
+%%              Results).
+%%          
+%% +%% For convenience `network_wifi_scan/0' may be used to scan with default options. +%% +%% Note: If a long dwell time is used, the return time for this function can be considerably longer +%% than the default gen_server timeout, especially when performing a passive scan. Passive scans +%% always use the full dwell time for each channel, active scans with a dwell time of more than 240 +%% milliseconds will have a minimum dwell of 1/2 the maximum dwell time set by the `dwell' option. +%% The timeout for these longer scans is determined by the following: +%% Timeout = (dwell * 14) + 5000. +%% That is the global maximum wifi channels multiplied by the dwell time with an additional +%% gen_server default timeout period added. The actual number of channels scanned will be +%% determined by the country code set by the devices connection to an access point. This is 13 +%% channels most of the world, 11 for North America, and 14 in some parts of Asia. +%% +%% The default options may be configured by adding `sta_scan_config()' options to the +%% `sta_config()'. +%% +%% Warning: This feature is not yet available on the rp2040 platform. +%% +%% @end +%%----------------------------------------------------------------------------- +-spec wifi_scan([Options :: scan_options(), ...]) -> + {ok, {NetworksDiscovered :: 0..20, [{SSID :: string(), [ApInfo :: network_properties()]}, ...]}} + | {error, Reason :: term()}. +wifi_scan(Options) -> + Dwell = proplists:get_value(dwell, Options), + case Dwell of + undefined -> + gen_server:call(?SERVER, {scan, Options}); + Millis when Millis =< ?MAX_SHORT_DWELL -> + gen_server:call(?SERVER, {scan, Options}); + ChanDwellMs -> + Timeout = (ChanDwellMs * ?DEVICE_TOTAL_CHANNELS) + ?GEN_RESPONSE_MS, + gen_server:call(?SERVER, {scan, Options}, Timeout) + end. + +wifi_scan() -> + Config = gen_server:call(?SERVER, get_config), + Results = proplists:get_value(default_scan_results, proplists:get_value(sta, Config), 6), + Dwell = proplists:get_value(scan_dwell_ms, proplists:get_value(sta, Config), 120), + Hidden = proplists:get_value(scan_show_hidden, proplists:get_value(sta, Config), false), + Passive = proplists:get_value(scan_passive, proplists:get_value(sta, Config), false), + wifi_scan([{results, Results}, {dwell, Dwell}, {show_hidden, Hidden}, {passive, Passive}]). + %% %% gen_server callbacks %% @@ -340,6 +451,12 @@ handle_call(start, From, #state{config = Config} = State) -> Ref = make_ref(), Port ! {self(), Ref, {start, Config}}, wait_start_reply(Ref, From, Port, State); +handle_call({scan, ScanOpts}, From, State) -> + Ref = make_ref(), + network_port ! {self(), Ref, {scan, ScanOpts}}, + wait_scan_results(Ref, From, State#state{ref = Ref}); +handle_call(get_config, _From, #state{config = Config} = State) -> + {reply, Config, State}; handle_call(_Msg, _From, State) -> {reply, {error, unknown_message}, State}. @@ -354,6 +471,20 @@ wait_start_reply(Ref, From, Port, State) -> {stop, {start_failed, Reason}, ER, State} end. +%% @private +wait_scan_results(Ref, From, State) -> + receive + {Ref, {error, _Reason} = ER} -> + gen_server:reply(From, ER), + {noreply, State#state{ref = Ref}}; + {Ref, Results} -> + gen_server:reply(From, Results), + {noreply, State#state{ref = Ref}}; + Any -> + gen_server:reply(From, Any), + {noreply, State#state{ref = Ref}} + end. + %% @hidden handle_cast(_Msg, State) -> {noreply, State}. diff --git a/src/platforms/esp32/components/avm_builtins/network_driver.c b/src/platforms/esp32/components/avm_builtins/network_driver.c index 46ee0dc48..a2a72cda5 100644 --- a/src/platforms/esp32/components/avm_builtins/network_driver.c +++ b/src/platforms/esp32/components/avm_builtins/network_driver.c @@ -57,6 +57,10 @@ #define TCPIP_HOSTNAME_MAX_SIZE 255 +#define DEFAULT_SCAN_RESULT_MAX 6 +#define IDF_DEFAULT_ACTIVE_SCAN_TIME 120 +#define IDF_DEFAULT_PASSIVE_SCAN_TIME 360 + #define TAG "network_driver" #define PORT_REPLY_SIZE (TUPLE_SIZE(2) + REF_SIZE) @@ -94,13 +98,15 @@ enum network_cmd // TODO add support for scan, ifconfig NetworkStartCmd, NetworkRssiCmd, - NetworkStopCmd + NetworkStopCmd, + NetworkScanCmd }; static const AtomStringIntPair cmd_table[] = { { ATOM_STR("\x5", "start"), NetworkStartCmd }, { ATOM_STR("\x4", "rssi"), NetworkRssiCmd }, { ATOM_STR("\x4", "stop"), NetworkStopCmd }, + { ATOM_STR("\x4", "scan"), NetworkScanCmd }, SELECT_INT_DEFAULT(NetworkInvalidCmd) }; @@ -117,6 +123,64 @@ static inline term make_atom(GlobalContext *global, AtomString atom_str) return globalcontext_make_atom(global, atom_str); } +static inline term authmode_to_atom_term(Context *ctx, wifi_auth_mode_t mode) +{ + term authmode = term_invalid_term(); + switch (mode) { + case WIFI_AUTH_OPEN: + authmode = make_atom(ctx->global, ATOM_STR("\x4", "open")); + break; + case WIFI_AUTH_WEP: + authmode = make_atom(ctx->global, ATOM_STR("\x3", "wep")); + break; + case WIFI_AUTH_WPA_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\x7", "wpa_psk")); + break; + case WIFI_AUTH_WPA2_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\x8", "wpa2_psk")); + break; + case WIFI_AUTH_WPA_WPA2_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\xC", "wpa_wpa2_psk")); + break; + case WIFI_AUTH_ENTERPRISE: + authmode = make_atom(ctx->global, ATOM_STR("\x3", "eap")); + break; + case WIFI_AUTH_WPA3_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\x8", "wpa3_psk")); + break; + case WIFI_AUTH_WPA2_WPA3_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\xD", "wpa2_wpa3_psk")); + break; + case WIFI_AUTH_WAPI_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\x4", "wapi")); + break; + case WIFI_AUTH_WPA3_ENT_192: + authmode = make_atom(ctx->global, ATOM_STR("\x13", "wpa3_enterprise_192")); + break; +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)) + case WIFI_AUTH_OWE: + authmode = make_atom(ctx->global, ATOM_STR("\x3", "owe")); + break; +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)) + case WIFI_AUTH_WPA3_EXT_PSK: + authmode = make_atom(ctx->global, ATOM_STR("\xC", "wpa3_ext_psk")); + break; + case WIFI_AUTH_WPA3_EXT_PSK_MIXED_MODE: + authmode = make_atom(ctx->global, ATOM_STR("\x12", "wpa3_ext_psk_mixed")); + break; +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)) + case WIFI_AUTH_DPP: + authmode = make_atom(ctx->global, ATOM_STR("\x3", "dpp")); + break; +#endif + case WIFI_AUTH_MAX: + authmode = ERROR_ATOM; + } + return authmode; +} + static term tuple_from_addr(Heap *heap, uint32_t addr) { term terms[4]; @@ -320,6 +384,9 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ case WIFI_EVENT_STA_BEACON_TIMEOUT: { ESP_LOGI(TAG, "WIFI_EVENT_STA_BEACON_TIMEOUT received. Maybe poor signal, or network congestion?"); send_sta_beacon_timeout(data); + + case WIFI_EVENT_SCAN_DONE: { + ESP_LOGD(TAG, "Scan complete."); break; } @@ -396,6 +463,7 @@ static wifi_config_t *get_sta_wifi_config(term sta_config, GlobalContext *global ESP_LOGE(TAG, "get_sta_wifi_config: Invalid SSID"); return NULL; } + char *psk = NULL; if (term_is_invalid_term(pass_term)) { ESP_LOGW(TAG, "Warning: Attempting to connect to open network"); @@ -867,6 +935,194 @@ static void get_sta_rssi(Context *ctx, term pid, term ref) port_send_reply(ctx, pid, ref, reply); } +static void wifi_scan(Context *ctx, term pid, term ref, term config) +{ + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(2); + + wifi_mode_t mode; + esp_err_t err = esp_wifi_get_mode(&mode); + if ((err != ESP_OK) || ((mode != WIFI_MODE_STA) && (mode != WIFI_MODE_APSTA))) { + ESP_LOGE(TAG, "WiFi must already be configured in STA or AP+STA mode to use network:wifi_scan/2"); + port_ensure_available(ctx, error_size); + term Reason = make_atom(ctx->global, ATOM_STR("\x10", "unsupported_mode")); + term error = port_create_error_tuple(ctx, Reason); + port_send_reply(ctx, pid, ref, error); + return; + } + + term cfg_results = interop_kv_get_value_default(config, ATOM_STR("\x7", "results"), term_from_int32(DEFAULT_SCAN_RESULT_MAX), ctx->global); + if (UNLIKELY(!term_is_integer(cfg_results))) { + ESP_LOGE(TAG, "Results requested must be between 1 and 20 (i.e. {results, 6})"); + port_ensure_available(ctx, error_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } + uint16_t num_results = (uint16_t) term_to_int32(cfg_results); + if (UNLIKELY((num_results < 1) || (num_results > 20))) { + ESP_LOGE(TAG, "Results requested must be {results, 1..20})"); + port_ensure_available(ctx, error_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } else { + ESP_LOGD(TAG, "Scan will return a maximum of %u results", num_results); + } + + term term_passive = interop_kv_get_value_default(config, ATOM_STR("\x7", "passive"), term_invalid_term(), ctx->global); + bool active_scan = true; + if ((term_passive != term_invalid_term()) && (term_passive == TRUE_ATOM)) { + active_scan = false; + } + + term cfg_dwell = interop_kv_get_value_default(config, ATOM_STR("\x5", "dwell"), term_invalid_term(), ctx->global); + uint32_t dwell_ms = 0; + if (cfg_dwell == term_invalid_term()) { + if (active_scan == true) { + dwell_ms = IDF_DEFAULT_ACTIVE_SCAN_TIME; + } else { + dwell_ms = IDF_DEFAULT_PASSIVE_SCAN_TIME; + } + } else { + if (UNLIKELY(!term_is_integer(cfg_dwell))) { + ESP_LOGE(TAG, "Channel dwell time milliseconds must be an integer (i.e. {dwell, 250})"); + port_ensure_available(ctx, error_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } + dwell_ms = (uint32_t) term_to_int32(cfg_dwell); + if (UNLIKELY((dwell_ms < 1lu) || (dwell_ms > 1500lu))) { + ESP_LOGE(TAG, "Per channel dwell time milliseconds must be {dwell, 1..1500}"); + port_ensure_available(ctx, error_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } else { + ESP_LOGD(TAG, "Scan will spend %u ms per channel", dwell_ms); + } + } + + term term_hidden = interop_kv_get_value_default(config, ATOM_STR("\xB", "show_hidden"), term_invalid_term(), ctx->global); + bool show_hidden = false; + if ((term_hidden != term_invalid_term()) && (term_hidden == TRUE_ATOM)) { + show_hidden = true; + } + + wifi_scan_type_t scan_type = WIFI_SCAN_TYPE_ACTIVE; + switch (active_scan) { + case false: + scan_type = WIFI_SCAN_TYPE_PASSIVE; + break; + case true: + break; + } + + wifi_scan_config_t scan_config = { 0 }; + if (scan_type == WIFI_SCAN_TYPE_ACTIVE) { + scan_config.scan_time.active.max = dwell_ms; + // For fast scans use the same min time as max (like ESP-IDF default), but for longer + // per-channel dwell times set the min scan time to 1/2 of the maximum, but never less + // than the 120ms min used in the default scan. + if (dwell_ms > IDF_DEFAULT_ACTIVE_SCAN_TIME * 2) { + scan_config.scan_time.active.min = (dwell_ms / 2); + } else { + scan_config.scan_time.active.min = dwell_ms; + } + } else { + scan_config.scan_time.passive = dwell_ms; + if (dwell_ms > 1000) { + // Increase home channel dwell between scanning consecutive channel from 30 to 60ms to prevent beacon timeouts + scan_config.home_chan_dwell_time = 60; + } + } + + scan_config.show_hidden = show_hidden; + scan_config.scan_type = scan_type; + + wifi_ap_record_t ap_info[num_results]; + memset(ap_info, 0, sizeof(ap_info)); + esp_wifi_scan_start(&scan_config, true); + + // "num_results" is used both for input (max number of num_results to return) and output, + // after a scan num_results will hold the actual number of records returned. + err = esp_wifi_scan_get_ap_records(&num_results, ap_info); + if (UNLIKELY(err != ESP_OK)) { + // the ap_list must be cleared on failures to prevent a memory leak + esp_wifi_clear_ap_list(); + ESP_LOGE(TAG, "Failed to obtain scan results"); + port_ensure_available(ctx, error_size); + term Reason = make_atom(ctx->global, ATOM_STR("\xE", "internal_error")); + term error = port_create_error_tuple(ctx, Reason); + port_send_reply(ctx, pid, ref, error); + return; + } + uint16_t ap_count = 0; + err = esp_wifi_scan_get_ap_num(&ap_count); + if (UNLIKELY(err != ESP_OK)) { + // clear ap_list on failure to prevent a memory leak + esp_wifi_clear_ap_list(); + ESP_LOGE(TAG, "Failed to get the count of found networks"); + port_ensure_available(ctx, error_size); + term Reason = make_atom(ctx->global, ATOM_STR("\xE", "internal_error")); + term error = port_create_error_tuple(ctx, Reason); + port_send_reply(ctx, pid, ref, error); + return; + } + + // return data: {ref, {ok, {NumberResults, [{SSID, [{rssi, DBm}, {authmode, Mode}, {channel, Number}]}]}}} + size_t ap_data_size = TUPLE_SIZE(2) + LIST_SIZE(3, TUPLE_SIZE(2) + sizeof(ap_info->ssid) * 2); + size_t results_size = PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + LIST_SIZE(num_results, ap_data_size); + port_ensure_available(ctx, results_size); + ESP_LOGD(TAG, "Reserved size '%i' for reply", results_size); + + term RSSI_ATOM = make_atom(ctx->global, ATOM_STR("\x4", "rssi")); + term AUTHMODE_ATOM = make_atom(ctx->global, ATOM_STR("\x8", "authmode")); + term CHANNEL_ATOM = make_atom(ctx->global, ATOM_STR("\x7", "channel")); + + term networks_data_list = term_nil(); + if (num_results == 0) { + term rssi_tuple = port_create_tuple2(ctx, RSSI_ATOM, UNDEFINED_ATOM); + term auth_tuple = port_create_tuple2(ctx, AUTHMODE_ATOM, UNDEFINED_ATOM); + term chan_tuple = port_create_tuple2(ctx, CHANNEL_ATOM, UNDEFINED_ATOM); + term ap_data = term_nil(); + ap_data = term_list_prepend(chan_tuple, ap_data, &ctx->heap); + ap_data = term_list_prepend(auth_tuple, ap_data, &ctx->heap); + ap_data = term_list_prepend(rssi_tuple, ap_data, &ctx->heap); + term ap_tuple = port_create_tuple2(ctx, NONE_ATOM, ap_data); + networks_data_list = term_list_prepend(ap_tuple, networks_data_list, &ctx->heap); + } else { + for (int i = num_results - 1; i >= 0; i--) { + term ssid = term_from_string(ap_info[i].ssid, (uint16_t) strlen((const char *) ap_info[i].ssid), &ctx->heap); + ESP_LOGD(TAG, "Adding SSID: %s", ap_info[i].ssid); + + term rssi = term_from_int(ap_info[i].rssi); + term rssi_tuple = port_create_tuple2(ctx, RSSI_ATOM, rssi); + ESP_LOGD(TAG, "Adding RSSI: %i", ap_info[i].rssi); + + term authmode = authmode_to_atom_term(ctx, ap_info[i].authmode); + term auth_tuple = port_create_tuple2(ctx, AUTHMODE_ATOM, authmode); + ESP_LOGD(TAG, "Adding auth mode: %i", ap_info[i].authmode); + + term channel = term_from_int32((int32_t) ap_info[i].primary); + term chan_tuple = port_create_tuple2(ctx, CHANNEL_ATOM, channel); + ESP_LOGD(TAG, "Adding Channel: %i", ap_info[i].primary); + + term ap_data = term_nil(); + ap_data = term_list_prepend(chan_tuple, ap_data, &ctx->heap); + ap_data = term_list_prepend(auth_tuple, ap_data, &ctx->heap); + ap_data = term_list_prepend(rssi_tuple, ap_data, &ctx->heap); + + term ap_tuple = port_create_tuple2(ctx, ssid, ap_data); + networks_data_list = term_list_prepend(ap_tuple, networks_data_list, &ctx->heap); + } + } + term scan_results = port_create_tuple2(ctx, term_from_int32(num_results), networks_data_list); + term ret = port_create_tuple2(ctx, OK_ATOM, scan_results); + + port_send_reply(ctx, pid, ref, ret); +} + static NativeHandlerResult consume_mailbox(Context *ctx) { bool cmd_terminate = false; @@ -905,6 +1161,8 @@ static NativeHandlerResult consume_mailbox(Context *ctx) case NetworkStopCmd: cmd_terminate = true; stop_network(ctx); + case NetworkScanCmd: + wifi_scan(ctx, pid, ref, config); break; default: {