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: {