From fe878987539b9747f0dd12f9e086ed3eb39c0c2f Mon Sep 17 00:00:00 2001 From: Winford Date: Sat, 21 Mar 2026 21:55:30 +0000 Subject: [PATCH 1/5] ESP32: reduce binary size of wifi6 builds Use new version 2 logging on WiFi6 capable devices (ESP32-C5 and ESP32-C61) to keep binary size within the allotted partition size. Signed-off-by: Winford --- CHANGELOG.md | 1 + src/platforms/esp32/sdkconfig.defaults.esp32c5 | 1 + src/platforms/esp32/sdkconfig.defaults.esp32c61 | 6 ++++++ 3 files changed, 8 insertions(+) create mode 100644 src/platforms/esp32/sdkconfig.defaults.esp32c61 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb18261eaa..0c69c0ad4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ strict format validation - ESP32: the `boot.avm` partition for Erlang-only images has been increased from 256KB to 512KB, matching the Elixir partition layout. The `main.avm` offset is now `0x250000` for all images (previously `0x210000` for Erlang-only). +- Changed ESP32-C5 IDF logging to V2 to reduce the compiled size ### Fixed - Fixed `erlang:cancel_timer/1` return type spec and documentation to match OTP diff --git a/src/platforms/esp32/sdkconfig.defaults.esp32c5 b/src/platforms/esp32/sdkconfig.defaults.esp32c5 index 949d6b1614..4a0647a4a8 100644 --- a/src/platforms/esp32/sdkconfig.defaults.esp32c5 +++ b/src/platforms/esp32/sdkconfig.defaults.esp32c5 @@ -4,3 +4,4 @@ # ESP32-C5 built with tests takes up too much flash space, so below is for fitting within partitions. # CONFIG_MBEDTLS_ECP_FIXED_POINT_OPTIM=n +CONFIG_LOG_VERSION_2=y diff --git a/src/platforms/esp32/sdkconfig.defaults.esp32c61 b/src/platforms/esp32/sdkconfig.defaults.esp32c61 new file mode 100644 index 0000000000..9a54658af7 --- /dev/null +++ b/src/platforms/esp32/sdkconfig.defaults.esp32c61 @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Winford (Uncle Grumpy) +# SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later + +# ESP32-C61 build is too large, below are optimizations for fitting within available partition +# space. +CONFIG_LOG_VERSION_2=y From f4547cd555607c32f75558a89ba007e696c9b186 Mon Sep 17 00:00:00 2001 From: Winford Date: Fri, 27 Mar 2026 17:21:39 +0000 Subject: [PATCH 2/5] ESP32: fix network driver bugs Avoid a possible crash when connecting to an open network, by not de-refernencing a NULL pointer. Fix incorrect cast that may lead to crashes or unexpected behaviors during client connection events when AP mode is enabled. Signed-off-by: Winford --- CHANGELOG.md | 1 + .../esp32/components/avm_builtins/network_driver.c | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c69c0ad4c..fe70ab2691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ matching the Elixir partition layout. The `main.avm` offset is now `0x250000` fo of raising `badarg` - Fixed `erlang:raise/3` with a built stacktrace causing an assertion failure when the re-raised exception passes through a non-matching catch clause +- Fixed improper cast of ESP32 `event_data` for `WIFI_EVENT_AP_STA(DIS)CONNECTED` events ### Removed - Removed old `json_encoder` module (now standard Erlang/OTP `json` module is available) diff --git a/src/platforms/esp32/components/avm_builtins/network_driver.c b/src/platforms/esp32/components/avm_builtins/network_driver.c index c67d481f06..890b6f2efb 100644 --- a/src/platforms/esp32/components/avm_builtins/network_driver.c +++ b/src/platforms/esp32/components/avm_builtins/network_driver.c @@ -299,14 +299,14 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ case WIFI_EVENT_AP_STACONNECTED: { ESP_LOGI(TAG, "WIFI_EVENT_AP_STACONNECTED received."); - wifi_event_ap_staconnected_t *event = (wifi_event_ap_staconnected_t *) event_base; + wifi_event_ap_staconnected_t *event = (wifi_event_ap_staconnected_t *) event_data; send_ap_sta_connected(data, event->mac); break; } case WIFI_EVENT_AP_STADISCONNECTED: { ESP_LOGI(TAG, "WIFI_EVENT_AP_STADISCONNECTED received."); - wifi_event_ap_stadisconnected_t *event = (wifi_event_ap_stadisconnected_t *) event_base; + wifi_event_ap_stadisconnected_t *event = (wifi_event_ap_stadisconnected_t *) event_data; send_ap_sta_disconnected(data, event->mac); break; } @@ -441,7 +441,7 @@ static wifi_config_t *get_sta_wifi_config(term sta_config, GlobalContext *global free(psk); return NULL; } - if (UNLIKELY(strlen(psk) > sizeof(wifi_config->sta.password))) { + if (!IS_NULL_PTR(psk) && UNLIKELY(strlen(psk) > sizeof(wifi_config->sta.password))) { ESP_LOGE(TAG, "psk cannot be more than %d characters", sizeof(wifi_config->sta.password)); free(ssid); free(psk); From c94a5f94a40eb0189999f8ff52565d6ffac14c32 Mon Sep 17 00:00:00 2001 From: Winford Date: Tue, 21 May 2024 15:28:05 -0700 Subject: [PATCH 3/5] Correct the type name for db() to dbm() Corrects the type name db() to the more correct `dbm()`, and adds a brief edoc explanation for the value. Signed-off-by: Winford --- CHANGELOG.md | 1 + libs/avm_network/src/network.erl | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe70ab2691..ca88e5a122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ strict format validation matching the Elixir partition layout. The `main.avm` offset is now `0x250000` for all images (previously `0x210000` for Erlang-only). - Changed ESP32-C5 IDF logging to V2 to reduce the compiled size +- Updated network type db() to dbm() to reflect the actual representation of the type ### Fixed - Fixed `erlang:cancel_timer/1` return type spec and documentation to match OTP diff --git a/libs/avm_network/src/network.erl b/libs/avm_network/src/network.erl index 0a127ef4c8..eea3b4f8e1 100644 --- a/libs/avm_network/src/network.erl +++ b/libs/avm_network/src/network.erl @@ -171,7 +171,10 @@ -type network_config() :: [sta_config() | ap_config() | sntp_config() | mdns_config()]. --type db() :: integer(). +-type dbm() :: integer(). +%% `dbm()' decibel-milliwatts (or dBm) will typically be a negative number, but in the presence of +%% 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 sta_status() :: associated | connected | connecting | degraded | disconnected | disconnecting | inactive. @@ -367,12 +370,12 @@ stop() -> gen_server:stop(?SERVER). %%----------------------------------------------------------------------------- -%% @returns {ok, Rssi} in decibels, or {error, Reason}. +%% @returns {ok, Rssi} in dBm, or {error, Reason}. %% %% @doc Get the rssi information of AP to which the device is associated with. %% @end %%----------------------------------------------------------------------------- --spec sta_rssi() -> {ok, Rssi :: db()} | {error, Reason :: term()}. +-spec sta_rssi() -> {ok, Rssi :: dbm()} | {error, Reason :: term()}. sta_rssi() -> case whereis(network_port) of undefined -> From 44fba79c7020c0da97a858294959e27204b51fee Mon Sep 17 00:00:00 2001 From: Winford Date: Sun, 21 Apr 2024 15:18:19 -0700 Subject: [PATCH 4/5] Add network:wifi_scan/0,1 to esp32 network driver Signed-off-by: Winford --- CHANGELOG.md | 1 + examples/erlang/esp32/CMakeLists.txt | 2 + examples/erlang/esp32/wifi_scan.erl | 75 ++ examples/erlang/esp32/wifi_scan_callback.erl | 64 ++ libs/avm_network/src/network.erl | 380 ++++++++- .../components/avm_builtins/network_driver.c | 766 +++++++++++++++++- .../test/main/test_erl_sources/CMakeLists.txt | 2 + .../main/test_erl_sources/test_wifi_scan.erl | 186 +++++ src/platforms/esp32/test/main/test_main.c | 6 + 9 files changed, 1460 insertions(+), 22 deletions(-) create mode 100644 examples/erlang/esp32/wifi_scan.erl create mode 100644 examples/erlang/esp32/wifi_scan_callback.erl create mode 100644 src/platforms/esp32/test/main/test_erl_sources/test_wifi_scan.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index ca88e5a122..8d8fe92b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `erlang:display_string/1` and `erlang:display_string/2` - Added Thumb-2 support to armv6m JIT backend, optimizing code for ARMv7-M and later cores - Added support for `binary:split/2,3` list patterns and `trim` / `trim_all` options +- Added `network:wifi_scan/0,1` to ESP32 network driver to scan available APs when in sta or sta+ap mode. ### Changed - ~10% binary size reduction by rewriting module loading logic diff --git a/examples/erlang/esp32/CMakeLists.txt b/examples/erlang/esp32/CMakeLists.txt index 39e497bd55..f79687a160 100644 --- a/examples/erlang/esp32/CMakeLists.txt +++ b/examples/erlang/esp32/CMakeLists.txt @@ -38,3 +38,5 @@ pack_runnable(reformat_nvs reformat_nvs eavmlib avm_esp32) pack_runnable(uartecho uartecho eavmlib estdlib avm_esp32) pack_runnable(ledc_example ledc_example eavmlib estdlib avm_esp32) pack_runnable(epmd_disterl epmd_disterl eavmlib estdlib avm_network avm_esp32) +pack_runnable(wifi_scan wifi_scan estdlib eavmlib avm_network avm_esp32) +pack_runnable(wifi_scan_callback wifi_scan_callback estdlib eavmlib avm_network avm_esp32) diff --git a/examples/erlang/esp32/wifi_scan.erl b/examples/erlang/esp32/wifi_scan.erl new file mode 100644 index 0000000000..896d3a7016 --- /dev/null +++ b/examples/erlang/esp32/wifi_scan.erl @@ -0,0 +1,75 @@ +%% This file is part of AtomVM. +%% +%% Copyright (c) 2023 +%% All rights reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% +%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +%% + +-module(wifi_scan). + +-export([start/0]). + +start() -> + {ok, _Pid} = network:start([{sta, [managed]}]), + scan_passive([show_hidden, {dwell, 1000}]), + scan_active([{dwell, 500}]). + +scan_active(Config) -> + io:format( + "\nStarting active scan with configuration ~p, this may take some time depending on dwell ms used.\n\n", + [Config] + ), + BeginTime = erlang:monotonic_time(millisecond), + {ok, {Num, Networks}} = network:wifi_scan(Config), + io:format("Active scan found ~p networks in ~pms.\n", [ + Num, erlang:monotonic_time(millisecond) - BeginTime + ]), + format_networks(Networks). + +scan_passive(Config) -> + io:format( + "\nStarting passive scan with configuration: ~p, this may take some time depending on dwell ms used.\n\n", + [Config] + ), + Opts = [passive | Config], + BeginTime = erlang:monotonic_time(millisecond), + ScanResults = network:wifi_scan(Opts), + {ok, {Num, Networks}} = ScanResults, + io:format("Passive scan found ~p networks in ~pms.\n", [ + Num, erlang:monotonic_time(millisecond) - BeginTime + ]), + format_networks(Networks). + +format_networks(Networks) -> + lists:foreach( + fun( + _Network = #{ + authmode := Mode, + bssid := BSSID, + channel := Number, + hidden := Hidden, + rssi := DBm, + ssid := SSID + } + ) -> + io:format( + "Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel ~p, hidden: ~p\n", + [SSID, BSSID, DBm, Mode, Number, Hidden] + ) + end, + Networks + ). diff --git a/examples/erlang/esp32/wifi_scan_callback.erl b/examples/erlang/esp32/wifi_scan_callback.erl new file mode 100644 index 0000000000..60200eeb0e --- /dev/null +++ b/examples/erlang/esp32/wifi_scan_callback.erl @@ -0,0 +1,64 @@ +%% This file is part of AtomVM. +%% +%% Copyright (c) 2026 +%% All rights reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% +%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +%% + +-module(wifi_scan_callback). + +-export([start/0]). + +start() -> + Config = [{sta, [managed, {scan_done, fun display_scan_results/1}]}], + {ok, _Pid} = network:start(Config), + io:format( + "\nStarting active scan with configuration ~p, this may take some time depending on dwell ms used.\n\n", + [Config] + ), + case network:wifi_scan() of + {error, Reason} -> + io:format("wifi_scan failed for reason ~p\n", [Reason]); + ok -> + timer:sleep(infinity) + end. + +display_scan_results(Results) -> + case Results of + {error, Reason} -> + io:format("wifi_scan failed for reason ~p.\n", [Reason]); + {Num, Networks} -> + io:format("wifi_scan callback got ~p results:\n", [Num]), + lists:foreach( + fun( + _Network = #{ + authmode := Mode, + bssid := BSSID, + channel := Number, + hidden := Hidden, + ssid := SSID, + rssi := DBm + } + ) -> + io:format( + "Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel: ~p, hidden: ~p\n", + [SSID, BSSID, DBm, Mode, Number, Hidden] + ) + end, + Networks + ) + end. diff --git a/libs/avm_network/src/network.erl b/libs/avm_network/src/network.erl index eea3b4f8e1..b25359f215 100644 --- a/libs/avm_network/src/network.erl +++ b/libs/avm_network/src/network.erl @@ -28,7 +28,8 @@ sta_rssi/0, sta_disconnect/0, sta_connect/0, sta_connect/1, - sta_status/0 + sta_status/0, + wifi_scan/0, wifi_scan/1 ]). -export([start/1, start_link/1, stop/0]). -export([ @@ -42,12 +43,23 @@ -define(SERVER, ?MODULE). +% maximum number of channels, used to calculate the gen_server:call/3 scan timeout. +-define(DEVICE_2G_CHANNELS, 14). +-define(DEVICE_2G5G_CHANNELS, 71). + +-define(DEFAULT_PASSIVE_DWELL, 360). +-define(DEFAULT_ACTIVE_DWELL, 120). +-define(GEN_RESPONSE_MS, 5000). + -type ipv4_info() :: { IPAddress :: inet:ip4_address(), NetMask :: inet:ip4_address(), Gateway :: inet:ip4_address() }. -type ip_info() :: ipv4_info(). +-type bssid() :: <<_:48>>. +-type ssid() :: string() | binary(). +%% SSIDs can be strings or binary strings up to 32 characters long. --type ssid_config() :: {ssid, string() | binary()}. +-type ssid_config() :: {ssid, ssid()}. -type psk_config() :: {psk, string() | binary()}. -type app_managed_config() :: managed | {managed, boolean()}. %% Setting `{managed, true}' or including the atom `managed' in the `sta_config()' will signal to @@ -69,17 +81,35 @@ %% no longer occur, and the application must use `network:sta_connect/0' to reconnect to the last %% access point, or use `network:sta_connect/1' to connect to a new access point in a manner %% determined by the application. +-type scan_done_config() :: + {scan_done, fun((scan_results() | {error, Reason :: term()}) -> term()) | pid()}. +%% If no callback is configured for scan done events then all scans will block the caller until +%% the scan is complete. If a `scan_done' event callback is configured either at startup, or through +%% `sta_connect/1', then calls to `wifi_scan/0,1' will return `ok' after a scan is initiated, and +%% the callback will receive scan results or error tuples. If a `pid()' is used in the callback +%% configuration `{scan_results, Results :: scan_results()}' or +%% `{scan_results, {error, Reason :: term()}}' will be sent to the receiver. -type sta_got_ip_config() :: {got_ip, fun((ip_info()) -> term())}. +-type sta_scan_config() :: + {default_scan_results, 1..64} + %% The maximum allowed number of scan results varies by chip-set. See: `wifi_scan/1'. + | {scan_dwell_ms, 1..1500} + | scan_show_hidden + | {scan_show_hidden, boolean()} + | scan_passive + | {scan_passive, boolean()}. -type sta_config_property() :: app_managed_config() | ssid_config() | psk_config() | dhcp_hostname_config() + | sta_scan_config() | sta_connected_config() | sta_beacon_timeout_config() | sta_disconnected_config() - | sta_got_ip_config(). + | sta_got_ip_config() + | scan_done_config(). -type sta_config() :: {sta, [sta_config_property()]}. -type mac() :: binary(). @@ -135,6 +165,7 @@ | ghz5_40mhz_channel() | ghz5_80mhz_channel() | ghz5_160mhz_channel(). + -type ap_channel_cfg() :: {ap_channel, wifi_channel()}. -type ap_ssid_hidden_config() :: {ap_ssid_hidden, boolean()}. -type ap_max_connections_config() :: {ap_max_connections, non_neg_integer()}. @@ -178,13 +209,64 @@ -type sta_status() :: associated | connected | connecting | degraded | disconnected | disconnecting | inactive. +-type scan_option() :: + {results, 1..64} + | {dwell, 1..1500} + | show_hidden + | {show_hidden, boolean()} + | passive + | {passive, boolean()}. +%% 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 +%% maximum number of results varies by chip, see: `network:wifi_scan/1'. The option `show_hidden' +%% will also include hidden networks in the scan results. Default options are: +%% `[{results, 6}, {dwell, 120}, {passive, false}, {show_hidden, false}]', if `passive' (or +%% `{passive, true}') is used the default dwell time per channel is 360 ms. +-type scan_options() :: [scan_option()]. + +-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 + | dpp + | wpa_enterprise + | wpa3_enterprise + | wpa2_wpa3_enterprise. + +-type network_properties() :: + #{ + authmode := auth_type(), + bssid := bssid(), + channel := wifi_channel(), + hidden := boolean(), + rssi := dbm(), + ssid := ssid() + }. +%% A map of network properties with the keys: `ssid', `rssi', `authmode', `bssid', `channel' and `hidden' +-type scan_results() :: {NetworksDiscovered :: 0..64, [network_properties()]}. + -record(state, { config :: network_config(), port :: port(), ref :: reference(), sta_ip_info :: ip_info() | undefined, mdns :: pid() | undefined, - sta_state :: sta_status() + sta_state :: sta_status(), + scan_receiver :: + {callback, pid() | fun((scan_results() | {error, term()}) -> term())} + | {reply, gen_server:from()} + | undefined + | blocked }). %%----------------------------------------------------------------------------- @@ -367,7 +449,31 @@ sta_connect() -> %%----------------------------------------------------------------------------- -spec stop() -> ok | {error, Reason :: term()}. stop() -> - gen_server:stop(?SERVER). + case erlang:whereis(network) of + Pid when is_pid(Pid) -> + Monitor = monitor(process, Pid), + try gen_server:call(Pid, stop_network) of + ok -> + receive + {'DOWN', Monitor, process, Pid, normal} -> + ok; + {'DOWN', Monitor, process, Pid, Reason} -> + {error, Reason} + after 5000 -> + erlang:demonitor(Monitor, [flush]), + {error, timeout} + end; + Error -> + erlang:demonitor(Monitor, [flush]), + Error + catch + exit:{noproc, _} -> + erlang:demonitor(Monitor, [flush]), + ok + end; + _ -> + ok + end. %%----------------------------------------------------------------------------- %% @returns {ok, Rssi} in dBm, or {error, Reason}. @@ -408,6 +514,159 @@ sta_rssi() -> sta_status() -> gen_server:call(?SERVER, sta_status). +%%----------------------------------------------------------------------------- +%% @param Options is a `scan_options()' list +%% @returns `ok', `{ok, 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 for clients connected to the esp32 +%% access point (in using ap+sta mode), but should not cause an active client connection to be +%% lost. Espressif's documentation recommends not exceeding 1500 ms per-channel scan times or +%% network connections may be lost, this is enforced as a hard limit. The return when no callback +%% is used 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. If a callback function is used it will receive the bare results +%% tuple, (no `ok') or an error tuple. If a `pid()' is used to receive callback results, the +%% results will be wrapped in a tuple with `scan_results', for example: +%% `{scan_results, {NumberResults, Networks}}'. The network maps in the list consist of network +%% name and other network properties (direct call without a configured callback): +%% +%% `{ok, { +%% NumberResults, +%% [#{authmode := Mode, bssid := BSSID, channel := ChNum, hidden := Bool, rssi := DBm, ssid := SSID}, ...] +%% }}' +%% +%% In the event of a failure `{error, Reason :: term()}' will be returned. If the network is +%% stopped while a scan is in progress, the caller or callback may receive either a successful scan +%% result, or `{error, canceled}'. In the unlikely circumstance that internal de-registration of +%% the scan done event handler fails, all future scans will be denied with `{error, blocked}'. +%% Only one scan may be active at a time. Starting a second scan before results from the first are +%% returned will result in `{error, busy}'. +%% +%% To minimize the risk of out-of-memory errors, this driver limits the maximum number of returned +%% networks depending on the target and memory configuration: +%% +%% +%% +%% +%% +%% +%% +%% +%% +%% +%% +%% +%% +%%
Chip Maximum number of networks
`ANY with PSRAM allocatable' `64'
`ESP32' `20'
`ESP32-C2' `10'
`ESP32-C3' `20'
`ESP32-C5' `14'
`ESP32-C6' `20'
`ESP32-C61' `14'
`ESP32-P4' `64'
`ESP32-S2' `14'
`ESP32-S3' `20'
+%% +%% Optionally a callback may be configured for scan done events. In this case this function will +%% return `ok' immediately after a scan is successfully initiated, or an error if the scan was +%% not successfully started, avoiding having the caller remain blocked while waiting for results. +%% The callback function will receive the bare results tuple without being wrapped in an `ok' +%% tuple. In the event of an error while scanning the callback will receive an `{error, Reason}' +%% tuple. If a `pid()' is used for callback the result will be wrapped in a `scan_results' tuple. +%% For example: `{scan_results, {1, [#{...}]}}' +%% +%% For convenience `network:wifi_scan/0' may be used to scan with default options. The driver must +%% already be started, and not in the process of connecting to an access when this function is +%% used. +%% +%% 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. Short dwell +%% times can easily miss networks, so applications need to be adjusted for their environment. +%% 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 formula: +%% +%%
+%%     Timeout = (dwell * NumChannels) + 5000
+%% 
+%% +%% 2.4Ghz wifi chips support a total of 14 channels, while dual-band (2.4Ghz + 5Ghz) capable chips +%% support up to 71 channels, in global ("world safe") mode. Be advised that network scans on 5Ghz +%% capable devices can take considerably longer than 2.4Ghz only devices, even when using short +%% dwell times. The use of a `scan_done' event callback is strongly encouraged. +%% +%% The actual number of channels scanned will be determined by the country code, which currently +%% does not have a configurable option, but is set to `01' (world safe mode) and will automatically +%% change to match the devices connection to an access point. +%% +%% The default scan options may be configured by adding `sta_scan_config()' options to the +%% `sta_config()'. +%% +%% Warning: This feature is not yet supported on platforms other than ESP32, and will raise an +%% `unsupported_platform' error. +%% +%% @end +%%----------------------------------------------------------------------------- +-spec wifi_scan(Options :: scan_options()) -> + ok + | {ok, scan_results()} + | {error, Reason :: term()}. +wifi_scan(Options) -> + case atomvm:platform() of + esp32 -> ok; + _ -> error(unsupported_platform) + end, + Passive = proplists:get_bool(passive, Options), + Dwell = + case {proplists:get_value(dwell, Options), Passive} of + {undefined, false} -> ?DEFAULT_ACTIVE_DWELL; + {undefined, _} -> ?DEFAULT_PASSIVE_DWELL; + {Value, _} -> Value + end, + {NumChannels, DefaultTimeout} = get_num_channels_timeout(), + ComputedTimeout = (Dwell * NumChannels), + Timeout = erlang:max(DefaultTimeout, ComputedTimeout) + ?GEN_RESPONSE_MS, + case erlang:whereis(?SERVER) of + undefined -> {error, not_started}; + _ -> gen_server:call(?SERVER, {scan, Options}, Timeout) + end. + +%% @doc Equivalent to `wifi_scan/1' with `sta_scan_config()' options set in `sta_config()'. +%% @end +-spec wifi_scan() -> + ok + | {ok, scan_results()} + | {error, Reason :: term()}. +wifi_scan() -> + case atomvm:platform() of + esp32 -> ok; + _ -> error(unsupported_platform) + end, + Config = + try + NetConfig = gen_server:call(?SERVER, get_config), + proplists:get_value(sta, NetConfig) + catch + exit:{noproc, _} -> {error, not_started} + end, + case Config of + {error, _} = Error -> + Error; + undefined -> + {error, no_sta_mode}; + StaCfg -> + Results = proplists:get_value(default_scan_results, StaCfg, 6), + Passive = proplists:get_bool(scan_passive, StaCfg), + DefaultDwell = + case Passive of + false -> ?DEFAULT_ACTIVE_DWELL; + _ -> ?DEFAULT_PASSIVE_DWELL + end, + Dwell = proplists:get_value(scan_dwell_ms, StaCfg, DefaultDwell), + Hidden = proplists:get_bool(scan_show_hidden, StaCfg), + wifi_scan([ + {results, Results}, + {dwell, Dwell}, + {show_hidden, Hidden}, + {passive, Passive} + ]) + end. + %% %% gen_server callbacks %% @@ -460,6 +719,41 @@ handle_call({connect, Config}, _From, #state{config = OldConfig, ref = Ref} = St end; handle_call(sta_status, _From, State) -> {reply, State#state.sta_state, State}; +handle_call( + {scan, ScanOpts}, From, #state{ref = Ref, scan_receiver = undefined, config = Config} = State +) -> + case proplists:get_value(sta, Config) of + undefined -> + {reply, {error, no_sta_mode}, State}; + StaConfig -> + case proplists:get_value(scan_done, StaConfig) of + undefined -> + network_port ! {self(), Ref, {scan, ScanOpts}}, + {noreply, State#state{scan_receiver = {reply, From}}}; + FunOrPid -> + network_port ! {self(), Ref, {scan, [{reply, true} | ScanOpts]}}, + wait_scan_start_reply(Ref, {callback, FunOrPid}, State) + end + end; +handle_call({scan, _ScanOpts}, _From, #state{scan_receiver = blocked} = State) -> + {reply, {error, blocked}, State}; +handle_call({scan, _ScanOpts}, _From, State) -> + {reply, {error, busy}, State}; +handle_call(get_config, _From, #state{config = Config} = State) -> + {reply, Config, State}; +handle_call(get_scan_state, _From, #state{scan_receiver = Active} = State) -> + Scanning = + case Active of + undefined -> inactive; + _ -> active + end, + {reply, Scanning, State}; +handle_call(stop_network, From, #state{scan_receiver = undefined} = State) -> + gen_server:reply(From, ok), + {stop, normal, State}; +handle_call(stop_network, From, #state{ref = Ref} = State) -> + network_port ! {self(), Ref, {cancel_scan, {shutdown, From}}}, + {noreply, State}; handle_call(_Msg, _From, State) -> {reply, {error, unknown_message}, State}. @@ -501,6 +795,44 @@ handle_info( handle_info({Ref, {sntp_sync, TimeVal}} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_sntp_sync_callback(Config, TimeVal), {noreply, State}; +handle_info( + {Ref, {scan_results, {error, {unregister_handler, _}}} = Msg}, #state{ref = Ref} = State +) -> + scan_reply_or_callback(Msg, State), + {noreply, State#state{scan_receiver = blocked}}; +handle_info( + {Ref, {scan_results, _Results} = Msg}, #state{ref = Ref, scan_receiver = {reply, _}} = State +) -> + scan_reply_or_callback(Msg, State), + {noreply, State#state{scan_receiver = undefined}}; +handle_info({Ref, {scan_results, _Results} = Msg}, #state{ref = Ref} = State) -> + spawn(fun() -> scan_reply_or_callback(Msg, State) end), + {noreply, State#state{scan_receiver = undefined}}; +handle_info({Ref, {scan_canceled, {Next, ReplyTo}, ok}}, #state{ref = Ref} = State) -> + spawn(fun() -> scan_reply_or_callback({scan_results, {error, canceled}}, State) end), + gen_server:reply(ReplyTo, ok), + case Next of + shutdown -> + {stop, normal, State#state{scan_receiver = undefined}}; + _ -> + {noreply, State#state{scan_receiver = undefined}} + end; +handle_info( + {Ref, {scan_canceled, {shutdown, ReplyTo}, {error, _} = _Error}}, + #state{ref = Ref, scan_receiver = undefined} = State +) -> + gen_server:reply(ReplyTo, ok), + {stop, normal, State}; +handle_info({Ref, {scan_canceled, {_, ReplyTo}, Error}}, #state{ref = Ref} = State) -> + gen_server:reply(ReplyTo, Error), + {noreply, State}; +%% catch oom errors when wifi_scan cannot allocate `scan_results` atom +handle_info({Ref, {error, _} = Error}, #state{ref = Ref, scan_receiver = {reply, _}} = State) -> + scan_reply_or_callback({scan_results, Error}, State), + {noreply, State#state{scan_receiver = undefined}}; +handle_info({Ref, {error, _} = Error}, #state{ref = Ref} = State) -> + spawn(fun() -> scan_reply_or_callback({scan_results, Error}, State) end), + {noreply, State#state{scan_receiver = undefined}}; handle_info(Msg, State) -> io:format("Received spurious message ~p~n", [Msg]), {noreply, State}. @@ -548,10 +880,40 @@ wait_for_port_close(PortMonitor, Port) -> {error, timeout} end. +wait_scan_start_reply(Ref, Dispatch, State) -> + receive + {Ref, ok} -> + {reply, ok, State#state{scan_receiver = Dispatch}}; + {Ref, {error, _} = Error} -> + {reply, Error, State#state{scan_receiver = undefined}}; + {Ref, {scan_results, {error, _} = Error}} -> + {reply, Error, State#state{scan_receiver = undefined}} + after 10000 -> + {reply, {error, timeout}, State#state{scan_receiver = undefined}} + end. + %% %% Internal operations %% +scan_reply_or_callback({scan_results, Results} = Msg, #state{scan_receiver = Dispatch} = _State) -> + case Dispatch of + {reply, {Pid, _} = From} when is_pid(Pid) -> + case Results of + {error, _} -> + gen_server:reply(From, Results); + _ -> + gen_server:reply(From, {ok, Results}) + end; + {callback, Pid} when is_pid(Pid) -> + Pid ! Msg; + {callback, Fun} when is_function(Fun, 1) -> + Fun(Results); + _ -> + ok + end, + ok. + %% @private maybe_sta_connected_callback(Config) -> maybe_callback0(connected, proplists:get_value(sta, Config)). @@ -768,3 +1130,11 @@ wait_for_ap_started(Timeout) -> %% @private sta_disconnected_default_callback() -> sta_connect(). + +get_num_channels_timeout() -> + case erlang:system_info(esp32_chip_info) of + #{model := esp32_c5} -> + {?DEVICE_2G5G_CHANNELS, 15000}; + _ -> + {?DEVICE_2G_CHANNELS, 5000} + end. diff --git a/src/platforms/esp32/components/avm_builtins/network_driver.c b/src/platforms/esp32/components/avm_builtins/network_driver.c index 890b6f2efb..e0213771a8 100644 --- a/src/platforms/esp32/components/avm_builtins/network_driver.c +++ b/src/platforms/esp32/components/avm_builtins/network_driver.c @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -44,19 +45,35 @@ #include #include #include +#include #include #include #include #include -#if ESP_IDF_VERSION_MAJOR >= 5 -#include -#endif #pragma GCC diagnostic pop +#include +#include #include #define TCPIP_HOSTNAME_MAX_SIZE 255 +// Reduce the maximum number of networks returned for devices with less ram to help mitigate OOM. +#if defined(CONFIG_IDF_TARGET_ESP32P4) || defined(CONFIG_SPIRAM_USE_MALLOC) +#define MAX_SCAN_RESULTS 64 +#elif defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32C61) || defined(CONFIG_IDF_TARGET_ESP32C5) +#define MAX_SCAN_RESULTS 14 +#elif defined(CONFIG_IDF_TARGET_ESP32C2) +#define MAX_SCAN_RESULTS 10 +#else +#define MAX_SCAN_RESULTS 20 +#endif +#define DEFAULT_SCAN_RESULT_MAX 6 +#define IDF_DEFAULT_ACTIVE_SCAN_TIME 120 +#define IDF_DEFAULT_PASSIVE_SCAN_TIME 360 +#define SSID_MAX_SIZE 33 +#define BSSID_SIZE 6 + #define TAG "network_driver" #define PORT_REPLY_SIZE (TUPLE_SIZE(2) + REF_SIZE) @@ -71,6 +88,7 @@ static const char *const host_atom = ATOM_STR("\x4", "host"); static const char *const managed_atom = ATOM_STR("\x7", "managed"); static const char *const max_connections_atom = ATOM_STR("\xF", "max_connections"); static const char *const psk_atom = ATOM_STR("\x3", "psk"); +static const char *const rssi_atom = ATOM_STR("\x4", "rssi"); static const char *const sntp_atom = ATOM_STR("\x4", "sntp"); static const char *const sntp_sync_atom = ATOM_STR("\x9", "sntp_sync"); static const char *const ssid_atom = ATOM_STR("\x4", "ssid"); @@ -93,20 +111,23 @@ enum enum network_cmd { NetworkInvalidCmd = 0, - // TODO add support for scan, ifconfig NetworkStartCmd, NetworkRssiCmd, NetworkStopCmd, StaHaltCmd, - StaConnectCmd + StaConnectCmd, + NetworkScanCmd, + NetworkScanStopCmd }; static const AtomStringIntPair cmd_table[] = { { ATOM_STR("\x5", "start"), NetworkStartCmd }, - { ATOM_STR("\x4", "rssi"), NetworkRssiCmd }, + { rssi_atom, NetworkRssiCmd }, { ATOM_STR("\x4", "stop"), NetworkStopCmd }, { ATOM_STR("\x8", "halt_sta"), StaHaltCmd }, { ATOM_STR("\x7", "connect"), StaConnectCmd }, + { ATOM_STR("\x4", "scan"), NetworkScanCmd }, + { ATOM_STR("\xB", "cancel_scan"), NetworkScanStopCmd }, SELECT_INT_DEFAULT(NetworkInvalidCmd) }; @@ -119,11 +140,113 @@ struct ClientData bool managed; }; +struct ScanData +{ + uint64_t ref_ticks; + uint32_t owner_process_id; + uint16_t num_results; + GlobalContext *global; +}; + +static void scan_done_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); + static inline term make_atom(GlobalContext *global, AtomString atom_str) { return globalcontext_make_atom(global, atom_str); } +static inline term authmode_to_atom_term(GlobalContext *global, wifi_auth_mode_t mode) +{ + term authmode = UNDEFINED_ATOM; + switch (mode) { + case WIFI_AUTH_OPEN: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x4", "open")); + break; + case WIFI_AUTH_WEP: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x3", "wep")); + break; + case WIFI_AUTH_WPA_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x7", "wpa_psk")); + break; + case WIFI_AUTH_WPA2_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x8", "wpa2_psk")); + break; + case WIFI_AUTH_WPA_WPA2_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xC", "wpa_wpa2_psk")); + break; + case WIFI_AUTH_ENTERPRISE: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x3", "eap")); + break; + case WIFI_AUTH_WPA3_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x8", "wpa3_psk")); + break; + case WIFI_AUTH_WPA2_WPA3_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xD", "wpa2_wpa3_psk")); + break; + case WIFI_AUTH_WAPI_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x4", "wapi")); + break; + case WIFI_AUTH_WPA3_ENT_192: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x13", "wpa3_enterprise_192")); + break; + case WIFI_AUTH_OWE: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x3", "owe")); + break; +// NOTE: dummy_ atoms will not be observed in the wild and are not pre-populated to the atom table +#if (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0)) + case WIFI_AUTH_DUMMY1: + authmode = make_atom(global, ATOM_STR("\x6", "dummy1")); + break; + case WIFI_AUTH_DUMMY2: + authmode = make_atom(global, ATOM_STR("\x6", "dummy2")); + break; +#if (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0)) + case WIFI_AUTH_DUMMY3: + authmode = make_atom(global, ATOM_STR("\x6", "dummy3")); + break; +#if (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 2, 0)) + case WIFI_AUTH_DUMMY4: + authmode = make_atom(global, ATOM_STR("\x6", "dummy4")); + break; + case WIFI_AUTH_DUMMY5: + authmode = make_atom(global, ATOM_STR("\x6", "dummy5")); + break; +#endif +#endif +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)) + case WIFI_AUTH_WPA3_EXT_PSK: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xC", "wpa3_ext_psk")); + break; + case WIFI_AUTH_WPA3_EXT_PSK_MIXED_MODE: + authmode = globalcontext_existing_term_from_atom_string(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 = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x3", "dpp")); + break; +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)) + case WIFI_AUTH_WPA3_ENTERPRISE: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xf", "wpa3_enterprise")); + break; + case WIFI_AUTH_WPA2_WPA3_ENTERPRISE: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x14", "wpa2_wpa3_enterprise")); + break; +#endif + case WIFI_AUTH_WPA_ENTERPRISE: + authmode = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xE", "wpa_enterprise")); + break; + case WIFI_AUTH_MAX: + authmode = ERROR_ATOM; + break; + } + return authmode; +} + +#define UNLIKELY_NOT_ESP_OK(E) UNLIKELY((E) != ESP_OK) + static term tuple_from_addr(Heap *heap, uint32_t addr) { term terms[4]; @@ -135,6 +258,167 @@ static term tuple_from_addr(Heap *heap, uint32_t addr) return port_heap_create_tuple_n(heap, 4, terms); } +static inline bool ensure_atom(GlobalContext *global, AtomString atom) +{ + return globalcontext_make_atom(global, atom) != term_invalid_term(); +} + +static bool ensure_scan_atoms_exist(GlobalContext *global) +{ + // Atoms needed in return message + if (UNLIKELY(!ensure_atom(global, ssid_atom))) { + return false; + } + if (UNLIKELY(!ensure_atom(global, rssi_atom))) { + return false; + } + static const char *const authmode = ATOM_STR("\x8", "authmode"); + if (UNLIKELY(!ensure_atom(global, authmode))) { + return false; + } + static const char *const bssid = ATOM_STR("\x5", "bssid"); + if (UNLIKELY(!ensure_atom(global, bssid))) { + return false; + } + static const char *const channel = ATOM_STR("\x7", "channel"); + if (UNLIKELY(!ensure_atom(global, channel))) { + return false; + } + static const char *const hidden = ATOM_STR("\x6", "hidden"); + if (UNLIKELY(!ensure_atom(global, hidden))) { + return false; + } + static const char *const unregister_handler = ATOM_STR("\x12", "unregister_handler"); + if (UNLIKELY(!ensure_atom(global, unregister_handler))) { + return false; + } + static const char *const scan_failed = ATOM_STR("\xB", "scan_failed"); + if (UNLIKELY(!ensure_atom(global, scan_failed))) { + return false; + } + static const char *const scan_canceled = ATOM_STR("\xD", "scan_canceled"); + if (UNLIKELY(!ensure_atom(global, scan_canceled))) { + return false; + } + + // Common auth atoms, not every auth atom is preloaded + static const char *const open = ATOM_STR("\x4", "open"); + if (UNLIKELY(!ensure_atom(global, open))) { + return false; + } + static const char *const wep = ATOM_STR("\x3", "wep"); + if (UNLIKELY(!ensure_atom(global, wep))) { + return false; + } + static const char *const wpa_psk = ATOM_STR("\x7", "wpa_psk"); + if (UNLIKELY(!ensure_atom(global, wpa_psk))) { + return false; + } + static const char *const wpa2_psk = ATOM_STR("\x8", "wpa2_psk"); + if (UNLIKELY(!ensure_atom(global, wpa2_psk))) { + return false; + } + static const char *const wpa_wpa2_psk = ATOM_STR("\xC", "wpa_wpa2_psk"); + if (UNLIKELY(!ensure_atom(global, wpa_wpa2_psk))) { + return false; + } + static const char *const wpa3_psk = ATOM_STR("\x8", "wpa3_psk"); + if (UNLIKELY(!ensure_atom(global, wpa3_psk))) { + return false; + } + static const char *const eap = ATOM_STR("\x3", "eap"); + if (UNLIKELY(!ensure_atom(global, eap))) { + return false; + } + static const char *const wpa2_wpa3_psk = ATOM_STR("\xD", "wpa2_wpa3_psk"); + if (UNLIKELY(!ensure_atom(global, wpa2_wpa3_psk))) { + return false; + } + static const char *const wapi = ATOM_STR("\x4", "wapi"); + if (UNLIKELY(!ensure_atom(global, wapi))) { + return false; + } + static const char *const wpa3_enterprise_192 = ATOM_STR("\x13", "wpa3_enterprise_192"); + if (UNLIKELY(!ensure_atom(global, wpa3_enterprise_192))) { + return false; + } + static const char *const owe = ATOM_STR("\x3", "owe"); + if (UNLIKELY(!ensure_atom(global, owe))) { + return false; + } +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)) + static const char *const wpa3_ext_psk = ATOM_STR("\xC", "wpa3_ext_psk"); + if (UNLIKELY(!ensure_atom(global, wpa3_ext_psk))) { + return false; + } + static const char *const wpa3_ext_psk_mixed = ATOM_STR("\x12", "wpa3_ext_psk_mixed"); + if (UNLIKELY(!ensure_atom(global, wpa3_ext_psk_mixed))) { + return false; + } +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)) + static const char *const dpp = ATOM_STR("\x3", "dpp"); + if (UNLIKELY(!ensure_atom(global, dpp))) { + return false; + } +#endif +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)) + static const char *const wpa3_enterprise = ATOM_STR("\xF", "wpa3_enterprise"); + if (UNLIKELY(!ensure_atom(global, wpa3_enterprise))) { + return false; + } + static const char *const wpa2_wpa3_enterprise = ATOM_STR("\x14", "wpa2_wpa3_enterprise"); + if (UNLIKELY(!ensure_atom(global, wpa2_wpa3_enterprise))) { + return false; + } +#endif + static const char *const wpa_enterprise = ATOM_STR("\xE", "wpa_enterprise"); + if (UNLIKELY(!ensure_atom(global, wpa_enterprise))) { + return false; + } + return true; +} + +static term wifi_ap_records_to_list_maybe_gc(GlobalContext *global, wifi_ap_record_t *ap_records, uint16_t num_results, Heap *heap) +{ + term authmode_atom_term = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x8", "authmode")); + term bssid_atom_term = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x5", "bssid")); + term channel_atom_term = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x7", "channel")); + term hidden_atom_term = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\x6", "hidden")); + term rssi_atom_term = globalcontext_existing_term_from_atom_string(global, rssi_atom); + term ssid_atom_term = globalcontext_existing_term_from_atom_string(global, ssid_atom); + + term networks_data_list = term_nil(); + for (int i = num_results - 1; i >= 0; i--) { + + term ssid_term; + term hidden = FALSE_ATOM; + size_t ssid_size = strnlen((char *) ap_records[i].ssid, SSID_MAX_SIZE - 1); + if (ssid_size > 0) { + ssid_term = term_from_literal_binary(ap_records[i].ssid, ssid_size, heap, global); + } else { + ssid_term = term_from_literal_binary((const uint8_t *) "", 0, heap, global); + hidden = TRUE_ATOM; + } + + term rssi = term_from_int(ap_records[i].rssi); + term authmode = authmode_to_atom_term(global, ap_records[i].authmode); + term bssid_term = term_from_literal_binary(ap_records[i].bssid, BSSID_SIZE, heap, global); + term channel = term_from_int((int32_t) ap_records[i].primary); + + term ap_data = term_alloc_map(6, heap); + term_set_map_assoc(ap_data, 0, authmode_atom_term, authmode); + term_set_map_assoc(ap_data, 1, bssid_atom_term, bssid_term); + term_set_map_assoc(ap_data, 2, channel_atom_term, channel); + term_set_map_assoc(ap_data, 3, hidden_atom_term, hidden); + term_set_map_assoc(ap_data, 4, rssi_atom_term, rssi); + term_set_map_assoc(ap_data, 5, ssid_atom_term, ssid_term); + + networks_data_list = term_list_prepend(ap_data, networks_data_list, heap); + } + return networks_data_list; +} + static void send_term(Heap *heap, struct ClientData *data, term t) { term ref = term_from_ref_ticks(data->ref_ticks, heap); @@ -263,21 +547,205 @@ static void send_sntp_sync(struct ClientData *data, struct timeval *tv) END_WITH_STACK_HEAP(heap, data->global); } -#define UNLIKELY_NOT_ESP_OK(E) UNLIKELY((E) != ESP_OK) +static void send_scan_error_reason(Context *ctx, term pid, term ref, term reason) +{ + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2); + port_ensure_available(ctx, error_size); + + term scan_results_atom = globalcontext_existing_term_from_atom_string(ctx->global, ATOM_STR("\xC", "scan_results")); + term ret = port_create_tuple2(ctx, scan_results_atom, port_create_error_tuple(ctx, reason)); + port_send_reply(ctx, pid, ref, ret); +} + +// heap must be allocated and free'd by the caller +static void send_scan_error_from_task(GlobalContext *global, uint32_t local_process_id, term reason, uint64_t ref_ticks, Heap heap) +{ + term ref = term_from_ref_ticks(ref_ticks, &heap); + term pid = term_from_local_process_id(local_process_id); + term error_tuple = port_heap_create_error_tuple(&heap, reason); + term scan_results_atom = globalcontext_existing_term_from_atom_string(global, ATOM_STR("\xC", "scan_results")); + term ret = port_heap_create_tuple2(&heap, scan_results_atom, error_tuple); + term msg = port_heap_create_tuple2(&heap, ref, ret); + port_send_message_from_task(global, pid, msg); +} + +static void send_scan_results(struct ScanData *data) +{ + uint16_t discovered = 0; + esp_err_t err = esp_wifi_scan_get_ap_num(&discovered); + 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 number of networks found, reason: %s", esp_err_to_name(err)); + const char *error = esp_err_to_name(err); + size_t error_len = strlen(error); + + // Handler should always be unregistered before sending a reply to avoid a race condition, + // once the message is sent another scan can be initiated, removing the handler after + // sending the reply could remove the new one. + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + TERM_BINARY_HEAP_SIZE(error_len), heap); + term reason = term_from_const_binary((const uint8_t *) error, (uint16_t) error_len, &heap, data->global); + send_scan_error_from_task(data->global, data->owner_process_id, reason, data->ref_ticks, heap); + END_WITH_STACK_HEAP(heap, data->global); + + if (UNLIKELY(err != ESP_OK)) { + goto unregister_failed; + } else { + // This cannot be free'd if unregister fails or the next scan_done event will try + // to access the free'd data and cause a hard crash. + free(data); + } + return; + } + ESP_LOGD(TAG, "Scan found %u networks.", discovered); + + uint16_t num_results = data->num_results; + uint16_t return_results; + if (discovered > num_results) { + return_results = num_results; + } else { + return_results = discovered; + } + wifi_ap_record_t *ap_records = NULL; + if (return_results > 0) { + ap_records = (wifi_ap_record_t *) calloc((size_t) return_results, sizeof(wifi_ap_record_t)); + if (IS_NULL_PTR(ap_records)) { + esp_wifi_clear_ap_list(); + + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2), heap); + send_scan_error_from_task(data->global, data->owner_process_id, OUT_OF_MEMORY_ATOM, data->ref_ticks, heap); + END_WITH_STACK_HEAP(heap, data->global); + + if (UNLIKELY(err != ESP_OK)) { + goto unregister_failed; + } else { + free(data); + } + return; + } + err = esp_wifi_scan_get_ap_records(&return_results, ap_records); + } else { + esp_wifi_clear_ap_list(); + err = ESP_OK; + } + + if (UNLIKELY(err != ESP_OK)) { + esp_wifi_clear_ap_list(); + free(ap_records); + ap_records = NULL; + ESP_LOGE(TAG, "Failed to obtain scan results, reason: %s", esp_err_to_name(err)); + const char *error = esp_err_to_name(err); + size_t error_len = strlen(error); + + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + TERM_BINARY_HEAP_SIZE(error_len), heap); + term reason = term_from_const_binary((const uint8_t *) error, (uint16_t) error_len, &heap, data->global); + send_scan_error_from_task(data->global, data->owner_process_id, reason, data->ref_ticks, heap); + END_WITH_STACK_HEAP(heap, data->global); + + if (UNLIKELY(err != ESP_OK)) { + goto unregister_failed; + } else { + free(data); + } + return; + } + + // ap_data example: {scan_results, {NumberResults, [#{authmode => Mode, bssid => Bssid, channel => ChNumber, hidden => Bool, rssi => DBM, ssid => SSID}]}} + size_t ap_data_size = (TERM_MAP_SIZE(6) + TERM_BINARY_HEAP_SIZE(SSID_MAX_SIZE) + TERM_BINARY_HEAP_SIZE(BSSID_SIZE)); + size_t results_size = (PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + BOXED_INT_SIZE + LIST_SIZE(return_results, ap_data_size)); + ESP_LOGD(TAG, "Requesting size '%zu' on heap for scan results", results_size); + + Heap heap; + if (UNLIKELY(memory_init_heap(&heap, results_size) != MEMORY_GC_OK)) { + esp_wifi_clear_ap_list(); + free(ap_records); + ap_records = NULL; + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2), err_heap); + send_scan_error_from_task(data->global, data->owner_process_id, OUT_OF_MEMORY_ATOM, data->ref_ticks, err_heap); + END_WITH_STACK_HEAP(err_heap, data->global); + if (UNLIKELY(err != ESP_OK)) { + goto unregister_failed; + } else { + free(data); + } + return; + } + + // Unregister the callback, but do not report any problems until after results have been sent. + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + + term networks_data_list = term_nil(); + if (return_results != 0) { + networks_data_list = wifi_ap_records_to_list_maybe_gc(data->global, ap_records, return_results, &heap); + } + free(ap_records); + ap_records = NULL; + + term scan_results = port_heap_create_tuple2(&heap, term_from_int(discovered), networks_data_list); + + term scan_results_atom = globalcontext_existing_term_from_atom_string(data->global, ATOM_STR("\xC", "scan_results")); + term results_tuple = port_heap_create_tuple2(&heap, scan_results_atom, scan_results); + + term ref = term_from_ref_ticks(data->ref_ticks, &heap); + term msg = port_heap_create_tuple2(&heap, ref, results_tuple); + + port_send_message_from_task(data->global, term_from_local_process_id(data->owner_process_id), msg); + memory_destroy_heap_from_task(&heap, data->global); + + // Send this event unregister error after the good scan results. If this problem is observed, blocking scans + // may need the error included along with the good results (since future scans may fail). This should be an + // extremely unlikely scenario. + if (UNLIKELY(err != ESP_OK)) { + goto unregister_failed; + } else { + free(data); + } + return; + +unregister_failed: + ESP_LOGE(TAG, "Failed to unregister event handler for reason %s, to prevent memory leaks future scans will not be permitted", esp_err_to_name(err)); + const char *error = esp_err_to_name(err); + size_t error_len = strlen(error); + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + TUPLE_SIZE(2) + TERM_BINARY_HEAP_SIZE(error_len), err_heap); + + term reason = term_from_const_binary((const uint8_t *) error, (uint16_t) error_len, &err_heap, data->global); + term unregister_handler_atom = globalcontext_existing_term_from_atom_string(data->global, ATOM_STR("\x12", "unregister_handler")); + term reason_tuple = port_heap_create_tuple2(&err_heap, unregister_handler_atom, reason); + term error_tuple = port_heap_create_error_tuple(&err_heap, reason_tuple); + term payload = port_heap_create_tuple2(&err_heap, globalcontext_existing_term_from_atom_string(data->global, ATOM_STR("\xC", "scan_results")), error_tuple); + + term reference = term_from_ref_ticks(data->ref_ticks, &err_heap); + term message = port_heap_create_tuple2(&err_heap, reference, payload); + port_send_message_from_task(data->global, term_from_local_process_id(data->owner_process_id), message); + END_WITH_STACK_HEAP(err_heap, data->global); + return; +} // -// Event Handler +// Event Handlers // static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { + // TODO: Change all of the logging in event_handler to debug level, or move it to the + // called send_* functions. We should not do any io operations inside the callback handler, + // but debug output seems a fair trade off. struct ClientData *data = (struct ClientData *) arg; - if (event_base == WIFI_EVENT) { switch (event_id) { case WIFI_EVENT_STA_START: { + // TODO: expose an erlang callback so applications can choose how to respond to this + // event, i.e. start a periodic scan for known networks, or initiate a connection ESP_LOGI(TAG, "WIFI_EVENT_STA_START received."); if (!data->managed) { esp_wifi_connect(); @@ -331,6 +799,14 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ break; } + case WIFI_EVENT_SCAN_DONE: { + ESP_LOGD(TAG, "WiFI network scan complete."); + // The real handler (scan_done_handler) is registered and unregistered per request. + // We catch this here so that we can subscribe to all wifi events in network_start, + // otherwise each event needs to be subscribed and unsubscribed individually. + break; + } + default: ESP_LOGI(TAG, "Unhandled wifi event: %" PRIi32 ".", event_id); break; @@ -381,6 +857,33 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ } } +static void scan_done_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) +{ + struct ScanData *data = arg; + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { + wifi_event_sta_scan_done_t *scan_done = (wifi_event_sta_scan_done_t *) event_data; + if (scan_done->status == 0) { + ESP_LOGD(TAG, "Scan complete."); + send_scan_results(data); + } else { + ESP_LOGW(TAG, "Scan ended with status: failure"); + esp_wifi_clear_ap_list(); + esp_err_t err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + BEGIN_WITH_STACK_HEAP(PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2), heap); + send_scan_error_from_task(data->global, data->owner_process_id, + globalcontext_existing_term_from_atom_string(data->global, ATOM_STR("\xB", "scan_failed")), + data->ref_ticks, heap); + END_WITH_STACK_HEAP(heap, data->global); + if (LIKELY(err == ESP_OK)) { + free(data); + } else { + ESP_LOGE(TAG, "Failed to unregister event handler for reason %s, future scans may fail", + esp_err_to_name(err)); + } + } + } +} // // message processing // @@ -415,6 +918,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"); @@ -686,8 +1190,6 @@ static void start_network(Context *ctx, term pid, term ref, term config) data->ref_ticks = term_to_ref_ticks(ref); data->managed = roaming; - esp_err_t err; - esp_netif_t *sta_wifi_interface = NULL; if ((sta_wifi_config != NULL) || (roaming)) { sta_wifi_interface = esp_netif_create_default_wifi_sta(); @@ -709,26 +1211,28 @@ static void start_network(Context *ctx, term pid, term ref, term config) } } + esp_err_t err; wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (UNLIKELY_NOT_ESP_OK(err = esp_wifi_init(&cfg))) { - ESP_LOGE(TAG, "Failed to initialize ESP WiFi"); + ESP_LOGE(TAG, "Failed to initialize ESP WiFi, reason: %s", esp_err_to_name(err)); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); goto cleanup; } if (UNLIKELY((err = esp_wifi_set_storage(WIFI_STORAGE_FLASH)) != ESP_OK)) { - ESP_LOGE(TAG, "Failed to set ESP WiFi storage"); + ESP_LOGE(TAG, "Failed to set ESP WiFi storage, reason: %s", esp_err_to_name(err)); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); goto cleanup; } if ((err = esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, data)) != ESP_OK) { - ESP_LOGE(TAG, "Failed to register wifi event handler"); + ESP_LOGE(TAG, "Failed to register wifi event handler, reason: %s", esp_err_to_name(err)); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); goto cleanup; } + if ((err = esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, data)) != ESP_OK) { ESP_LOGE(TAG, "Failed to register got_ip event handler"); term error = port_create_error_tuple(ctx, term_from_int(err)); @@ -838,7 +1342,6 @@ static void start_network(Context *ctx, term pid, term ref, term config) static void stop_network(Context *ctx) { - // Stop sntp (ignore OK, or not configured error) esp_sntp_stop(); @@ -901,7 +1404,7 @@ static void get_sta_rssi(Context *ctx, term pid, term ref) term rssi = term_from_int(sta_rssi); // {Ref, {rssi, -25}} port_ensure_available(ctx, tuple_reply_size); - term reply = port_create_tuple2(ctx, make_atom(ctx->global, ATOM_STR("\x4", "rssi")), rssi); + term reply = port_create_tuple2(ctx, make_atom(ctx->global, rssi_atom), rssi); port_send_reply(ctx, pid, ref, reply); } @@ -1033,6 +1536,230 @@ static void sta_reconnect(Context *ctx, term pid, term ref) port_send_reply(ctx, pid, ref, OK_ATOM); } +static void wifi_scan(Context *ctx, term pid, term ref, term config) +{ + static const char *const scan_results = ATOM_STR("\xC", "scan_results"); + term results_atom = globalcontext_make_atom(ctx->global, scan_results); + if (UNLIKELY(results_atom == term_invalid_term())) { + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(2); + port_ensure_available(ctx, error_size); + term ret = port_create_error_tuple(ctx, OUT_OF_MEMORY_ATOM); + port_send_reply(ctx, pid, ref, ret); + return; + } + + 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/0,1"); + term reason = make_atom(ctx->global, ATOM_STR("\x10", "unsupported_mode")); + if (UNLIKELY(reason == term_invalid_term())) { + reason = OUT_OF_MEMORY_ATOM; + } + send_scan_error_reason(ctx, pid, ref, reason); + return; + } + + term cfg_results = interop_kv_get_value_default(config, ATOM_STR("\x7", "results"), term_from_int(DEFAULT_SCAN_RESULT_MAX), ctx->global); + if (UNLIKELY(!term_is_integer(cfg_results))) { + ESP_LOGE(TAG, "results option must be an integer (i.e. {results, 6})"); + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } + + uint32_t requested_results = term_to_int(cfg_results); + if (UNLIKELY((requested_results < 1) || (requested_results > MAX_SCAN_RESULTS))) { + ESP_LOGE(TAG, "results option must be between 1 and %i on this platform.", MAX_SCAN_RESULTS); + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } + uint16_t num_results = (uint16_t) requested_results; + 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; + term passive_atom = globalcontext_existing_term_from_atom_string(ctx->global, ATOM_STR("\x7", "passive")); + if (!term_is_invalid_term(term_passive)) { + if ((term_passive == TRUE_ATOM) || (term_passive == passive_atom)) { + active_scan = false; + } else if (term_passive != FALSE_ATOM) { + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } + } + + 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})"); + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } + dwell_ms = (uint32_t) term_to_int(cfg_dwell); + if (UNLIKELY((dwell_ms < 1lu) || (dwell_ms > 1500lu))) { + ESP_LOGE(TAG, "Per channel dwell time milliseconds must be {dwell, 1..1500}"); + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } else { + ESP_LOGD(TAG, "Scan will spend %lu 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; + + term hidden_atom = globalcontext_existing_term_from_atom_string(ctx->global, ATOM_STR("\xB", "show_hidden")); + if (!term_is_invalid_term(term_hidden)) { + if ((term_hidden == TRUE_ATOM) || (term_hidden == hidden_atom)) { + show_hidden = true; + } else if (term_hidden != FALSE_ATOM) { + send_scan_error_reason(ctx, pid, ref, BADARG_ATOM); + return; + } + } + + 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 = (wifi_scan_config_t *) calloc(1, sizeof(wifi_scan_config_t)); + if (IS_NULL_PTR(scan_config)) { + ESP_LOGE(TAG, "Unable to allocate memory for configuration"); + send_scan_error_reason(ctx, pid, ref, OUT_OF_MEMORY_ATOM); + return; + } + + 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; + + if (UNLIKELY(!ensure_scan_atoms_exist(ctx->global))) { + ESP_LOGE(TAG, "Unable to allocate atoms for scan return, atom table exhausted or OOM!"); + send_scan_error_reason(ctx, pid, ref, OUT_OF_MEMORY_ATOM); + free(scan_config); + return; + } + + struct ScanData *data = malloc(sizeof(struct ScanData)); + if (IS_NULL_PTR(data)) { + ESP_LOGE(TAG, "Failed to allocate ClientData"); + send_scan_error_reason(ctx, pid, ref, OUT_OF_MEMORY_ATOM); + free(scan_config); + return; + } + data->global = ctx->global; + data->owner_process_id = term_to_local_process_id(pid); + data->ref_ticks = term_to_ref_ticks(ref); + data->num_results = num_results; + + if ((err = esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler, data)) != ESP_OK) { + ESP_LOGE(TAG, "Failed to register wifi event handler"); + send_scan_error_reason(ctx, pid, ref, term_from_int(err)); + free(data); + free(scan_config); + return; + } + + err = esp_wifi_scan_start(scan_config, false); + free(scan_config); + scan_config = NULL; + if (UNLIKELY(err != ESP_OK)) { + const char *err_str = esp_err_to_name(err); + size_t error_len = strlen(err_str); + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(2) + TUPLE_SIZE(2) + TERM_BINARY_HEAP_SIZE(error_len); + port_ensure_available(ctx, error_size); + term reason = term_from_literal_binary((const uint8_t *) err_str, (uint16_t) error_len, &ctx->heap, ctx->global); + term scan_results_atom = globalcontext_existing_term_from_atom_string(ctx->global, ATOM_STR("\xC", "scan_results")); + term ret = port_create_tuple2(ctx, scan_results_atom, port_create_error_tuple(ctx, reason)); + err = esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_SCAN_DONE, &scan_done_handler); + port_send_reply(ctx, pid, ref, ret); + if (UNLIKELY(err != ESP_OK)) { + ESP_LOGE(TAG, "Failed to unregister event handler, future scans may fail"); + } else { + free(data); + } + return; + } + + // Only send a reply when requested by the gen_server, this should only be sent when using callbacks. + // Direct calls expect an error, or wait for a results reply. + term reply_term = interop_kv_get_value_default(config, ATOM_STR("\x5", "reply"), term_invalid_term(), ctx->global); + if (!term_is_invalid_term(reply_term) && (reply_term == TRUE_ATOM)) { + port_ensure_available(ctx, PORT_REPLY_SIZE); + port_send_reply(ctx, pid, ref, OK_ATOM); + } + + return; +} + +static void cancel_scan(Context *ctx, term pid, term ref, term reply_config) +{ + term scan_canceled_atom = globalcontext_existing_term_from_atom_string(ctx->global, ATOM_STR("\xD", "scan_canceled")); + + esp_err_t err = esp_wifi_scan_stop(); + if (UNLIKELY(err == ESP_ERR_WIFI_STATE)) { + ESP_LOGE(TAG, "Unable to stop wifi scan, wifi is negotiating a connection to an access point"); + + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(3) + TUPLE_SIZE(2); + port_ensure_available(ctx, error_size); + + term reason = make_atom(ctx->global, ATOM_STR("\xE", "sta_connecting")); + if (UNLIKELY(reason == term_invalid_term())) { + reason = OUT_OF_MEMORY_ATOM; + } + term error_tuple = port_create_error_tuple(ctx, reason); + term ret = port_create_tuple3(ctx, scan_canceled_atom, reply_config, error_tuple); + port_send_reply(ctx, pid, ref, ret); + return; + + } else if (UNLIKELY(err != ESP_OK)) { + const char *err_str = esp_err_to_name(err); + size_t error_len = strlen(err_str); + size_t error_size = PORT_REPLY_SIZE + TUPLE_SIZE(3) + TUPLE_SIZE(2) + TERM_BINARY_HEAP_SIZE(error_len); + port_ensure_available(ctx, error_size); + + term reason = term_from_const_binary((const uint8_t *) err_str, (uint16_t) error_len, &ctx->heap, ctx->global); + term error_tuple = port_create_error_tuple(ctx, reason); + term ret = port_create_tuple3(ctx, scan_canceled_atom, reply_config, error_tuple); + port_send_reply(ctx, pid, ref, ret); + return; + } + + size_t reply_size = PORT_REPLY_SIZE + TUPLE_SIZE(3); + port_ensure_available(ctx, reply_size); + term reply = port_create_tuple3(ctx, scan_canceled_atom, reply_config, OK_ATOM); + port_send_reply(ctx, pid, ref, reply); +} + static NativeHandlerResult consume_mailbox(Context *ctx) { bool cmd_terminate = false; @@ -1072,6 +1799,12 @@ static NativeHandlerResult consume_mailbox(Context *ctx) cmd_terminate = true; stop_network(ctx); break; + case NetworkScanCmd: + wifi_scan(ctx, pid, ref, config); + break; + case NetworkScanStopCmd: + cancel_scan(ctx, pid, ref, config); + break; case StaHaltCmd: sta_disconnect(ctx, pid, ref); break; @@ -1140,7 +1873,6 @@ Context *network_driver_create_port(GlobalContext *global, term opts) Context *ctx = context_new(global); ctx->native_handler = consume_mailbox; - ctx->platform_data = NULL; return ctx; } diff --git a/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt b/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt index ce8d9f62e5..a3d0586d0b 100644 --- a/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt +++ b/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt @@ -78,6 +78,7 @@ compile_erlang(test_time_and_processes) compile_erlang(test_twdt) compile_erlang(test_tz) compile_erlang(test_deep_sleep_hold) +compile_erlang(test_wifi_scan) set(erlang_test_beams test_esp_partition.beam @@ -100,6 +101,7 @@ set(erlang_test_beams test_twdt.beam test_tz.beam test_deep_sleep_hold.beam + test_wifi_scan.beam ) if(NOT AVM_DISABLE_JIT) diff --git a/src/platforms/esp32/test/main/test_erl_sources/test_wifi_scan.erl b/src/platforms/esp32/test/main/test_erl_sources/test_wifi_scan.erl new file mode 100644 index 0000000000..ba0860ddf0 --- /dev/null +++ b/src/platforms/esp32/test/main/test_erl_sources/test_wifi_scan.erl @@ -0,0 +1,186 @@ +%% This file is part of AtomVM. +%% +%% Copyright (c) 2024 +%% All rights reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% +%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +%% + +-module(test_wifi_scan). + +-export([start/0]). + +start() -> + case erlang:system_info(esp32_chip_info) of + #{model := esp32_s2} -> + io:format("test_wifi_scan skipped on this platform."), + ok; + #{model := esp32_h2} -> + io:format("No wifi, test_wifi_scan skipped on this platform."), + ok; + _ -> + wifi_scan_test(), + deny_concurrent_scan_test(), + network_stop_while_scanning_test() + end. + +wifi_scan_test() -> + case network:start([{sta, [managed]}]) of + {ok, _Pid} -> + try + case network:wifi_scan([{passive, false}]) of + {ok, {Num, Networks}} -> + io:format("network:wifi_scan found ~p networks.\n", [Num]), + lists:foreach( + fun( + _Network = #{ + authmode := Mode, + bssid := BSSID, + channel := Number, + hidden := Hidden, + rssi := DBm, + ssid := SSID + } + ) -> + io:format( + "Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel ~p, hidden: ~p\n", + [SSID, BSSID, DBm, Mode, Number, Hidden] + ) + end, + Networks + ), + true = lists:any( + fun(#{ssid := SSID}) -> SSID =:= <<"Wokwi-GUEST">> end, Networks + ), + ok; + {error, Reason} -> + erlang:error({scan_failed, Reason}) + end + after + ok = network:stop() + end; + {error, Reason} -> + erlang:error({network_start_failed, Reason}) + end. + +deny_concurrent_scan_test() -> + case network:start([{sta, [managed]}]) of + {ok, _Pid} -> + try + Self = self(), + Child = erlang:spawn(fun() -> + Self ! {scan_ready, self()}, + receive + go -> ok + end, + report_scan(Self) + end), + receive + {scan_ready, Child} -> + Child ! go, + receive + starting_scan -> + ok + after 5000 -> + erlang:error({deny_concurrent_scan_test, timeout}) + end + after 5000 -> + erlang:error({deny_concurrent_scan_test, scan_ready_timeout}) + end, + ParentResult = network:wifi_scan([{passive, false}]), + ChildResult = + receive + {scan_process_result, Child, Result} -> + Result + after 15000 -> + erlang:error(scan_process_timeout) + end, + case {ParentResult, ChildResult} of + {{error, busy}, ok} -> + ok; + {{ok, _}, busy_error} -> + ok; + {{error, busy}, busy_error} -> + erlang:error(both_scans_rejected); + {{ok, _}, ok} -> + erlang:error(both_scans_succeeded); + {{error, Reason}, _} -> + erlang:error({parent_scan_failed, Reason}); + {_, {error, Reason}} -> + erlang:error({report_scan_failed, Reason}) + end + after + ok = network:stop() + end; + {error, Reason} -> + erlang:error({network_start_failed, Reason}) + end. + +report_scan(Owner) -> + Owner ! starting_scan, + case network:wifi_scan([{passive, false}]) of + {ok, {Num, Networks}} when is_integer(Num) andalso is_list(Networks) -> + Owner ! {scan_process_result, self(), ok}; + {error, busy} -> + Owner ! {scan_process_result, self(), busy_error}; + {error, Reason} -> + Owner ! {scan_process_result, self(), {error, Reason}} + end. + +network_stop_while_scanning_test() -> + erlang:register(stop_test, self()), + try + Config = [{sta, [managed, {scan_done, fun scan_callback_handler/1}]}], + case network:start(Config) of + {ok, _Pid} -> + ok = network:wifi_scan([{passive, false}]), + case network:stop() of + ok -> + receive + {Num, Networks} when is_integer(Num) andalso is_list(Networks) -> + ok; + {error, canceled} -> + ok; + {error, Reason} -> + erlang:error({network_stop_while_scanning_test, {failed, Reason}}) + after 15000 -> + erlang:error(scan_callback_timeout) + end; + Error -> + erlang:error({stop_failed, Error}) + end; + {error, Reason} -> + erlang:error({network_start_failed, Reason}) + end + after + erlang:unregister(stop_test), + case erlang:whereis(network) of + undefined -> + ok; + _ -> + ok = network:stop(), + erlang:error(network_not_stopped) + end + end, + ok. + +scan_callback_handler(Results) -> + case erlang:whereis(stop_test) of + undefined -> + erlang:error({lost_parent, stop_test}); + Pid -> + Pid ! Results + end. diff --git a/src/platforms/esp32/test/main/test_main.c b/src/platforms/esp32/test/main/test_main.c index a42aaeb4d1..44964949cf 100644 --- a/src/platforms/esp32/test/main/test_main.c +++ b/src/platforms/esp32/test/main/test_main.c @@ -618,6 +618,12 @@ TEST_CASE("test_wifi_managed", "[test_run]") term ret_value = avm_test_case("test_wifi_managed.beam"); TEST_ASSERT(ret_value == OK_ATOM); } + +TEST_CASE("test_wifi_scan", "[test_run]") +{ + term ret_value = avm_test_case("test_wifi_scan.beam"); + TEST_ASSERT(ret_value == OK_ATOM); +} #endif // Works C3 on local runs, but fails GH actions From d725788e17e876f39bd37a53bf6ef0832f9c3368 Mon Sep 17 00:00:00 2001 From: Winford Date: Wed, 22 May 2024 12:04:31 -0700 Subject: [PATCH 5/5] Document new wifi functions Adds documentation for `network:wifi_scan/0,1` and updates details for `network:sta_rssi/0`. Signed-off-by: Winford --- doc/src/network-programming-guide.md | 167 +++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 12 deletions(-) diff --git a/doc/src/network-programming-guide.md b/doc/src/network-programming-guide.md index 1d587da34b..da04e967ec 100644 --- a/doc/src/network-programming-guide.md +++ b/doc/src/network-programming-guide.md @@ -76,6 +76,11 @@ Callback functions can be specified by the following configuration parameters: * `{connected, fun(() -> term())}` A callback function which will be called when the device connects to the target network. * `{disconnected, fun(() -> term())}` A callback function which will be called when the device disconnects from the target network. If no callback function is provided the default behavior is to attempt to reconnect immediately. By providing a callback function the application can decide whether to reconnect, or connect to a new access point. * `{got_ip, fun((ip_info()) -> term())}` A callback function which will be called when the device obtains an IP address. In this case, the IPv4 IP address, net mask, and gateway are provided as a parameter to the callback function. +* `{scan_done, fun((scan_results() | {error, Reason :: term()}) -> term()) | pid()}` +**(ESP32 only)** A callback function (receives `scan_results() | {error, Reason}`) or pid (receives +`{scan_results, scan_results() | {error, Reason}}`) which will be invoked once a network scan is +completed. This allows for event-driven connection management and prevents blocking the caller when +requesting a scan of available wifi networks. ```{warning} IPv6 addresses are not yet supported in AtomVM. @@ -90,30 +95,71 @@ The following example illustrates initialization of the WiFi network in STA mode ```erlang Config = [ {sta, [ - {ssid, <<"myssid">>}, - {psk, <<"mypsk">>}, + managed, {connected, fun connected/0}, {got_ip, fun got_ip/1}, - {disconnected, fun disconnected/0} + {disconnected, fun disconnected/0}, + {scan_done, fun got_scan_results/1}, {dhcp_hostname, <<"myesp32">>} ]} ], {ok, Pid} = network:start(Config), +ok = network:wifi_scan(), ... ``` -The following callback functions will be called when the corresponding events occur during the lifetime of the network connection. +The following callback functions will be called when the corresponding events occur during the +lifetime of the network connection. This example demonstrates using callbacks to scan for networks, +and if a found network is stored in nvs with an `ssid` key value that matches, it will use the +stored `psk` key value to authenticate. After an IP address is acquired, the example application's +supervised network service will be started by the `start_my_server_sup` function (this function is +left as an exercise for the reader, see: [`supervisor`](./apidocs/erlang/estdlib/supervisor.md)). ```erlang connected() -> io:format("Connected to AP.~n"). -gotIp(IpInfo) -> - io:format("Got IP: ~p~n", [IpInfo]). +got_ip(IpInfo) -> + io:format("Got IP: ~p~n", [IpInfo]), + erlang:spawn(fun() -> start_my_server_sup() end). disconnected() -> - io:format("Disconnected from AP, attempting to reconnect~n"), - network:sta_connect(). + io:format("Disconnected from AP, starting scan~n"), + erlang:spawn(fun() -> network:wifi_scan() end). + +got_scan_results({error, Reason}) -> + io:format("WiFi scan error ~p, retrying in 60 seconds.~n", [Reason]), + erlang:spawn(fun() -> + timer:sleep(60_000), + network:wifi_scan() + end); +got_scan_results({NumResults, Results}) -> + io:format("WiFi scan found ~p networks.~n", [NumResults]), + erlang:spawn(fun() -> connect_if_known(Results) end). + +connect_if_known([]) -> + io:format("No known networks found, re-scanning in 60 seconds.~n"), + erlang:spawn(fun() -> + timer:sleep(60_000), + network:wifi_scan() + end); +connect_if_known([#{ssid := SSID, authmode := Auth} | Results]) -> + case SSID =:= esp:nvs_fetch_binary(network, ssid) of + true -> + case esp:nvs_fetch_binary(network, psk) of + undefined when Auth =:= open -> + io:format("Connecting to unsecured network ~s...~n", [SSID]), + network:sta_connect([{ssid, SSID}]); + undefined -> + io:format("No psk stored in nvs for network ~s with ~p security!~n", [SSID, Auth]), + connect_if_known(Results); + PSK -> + io:format("Connecting to ~s (~p)...~n", [SSID, Auth]), + network:sta_connect([{ssid, SSID}, {psk, PSK}]) + end; + false -> + connect_if_known(Results) + end. ``` In a typical application, the network should be configured and an IP address should be acquired first, before starting clients or services that have a dependency on the network. @@ -138,10 +184,107 @@ case network:wait_for_sta(Config, 15000) of end ``` -To obtain the signal strength (in decibels) of the connection to the associated access point use [`network:sta_rssi/0`](./apidocs/erlang/eavmlib/network.md#sta_rssi0). - ### STA (or AP+STA) mode functions +Some functions are only available if the device is configured in STA or AP+STA mode. + +#### `sta_rssi` + +Once connected to an access point, the signal strength in decibel-milliwatts (dBm) of the +connection to the associated access point may be obtained using +[`network:sta_rssi/0`](./apidocs/erlang/eavmlib/network.md#sta_rssi0). The value returned as +`{ok, Value}` will typically be a negative number, but in the +presence of 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. + +#### `wifi_scan` + +```{notice} +This function is currently only supported on the ESP32 platform. +``` + +After the network has been configured for STA or AP+STA mode and started, you may scan for +available access points using +[`network:wifi_scan/0`](./apidocs/erlang/eavmlib/network.md#wifi_scan0) or +[`network:wifi_scan/1`](./apidocs/erlang/eavmlib/network.md#wifi_scan1). Scanning for access +points will temporarily inhibit other traffic on the access point network if it is in use, but +should not cause any active connections to be dropped. With no options, a default 'active' +(`{passive, false}`) scan, with a per-channel dwell time of 120ms will be used and will return +network details for up to 6 access points, the default may be changed using the `sta_scan_config()` +option in the `sta_config()`. The return value for the scan takes the form of a tuple consisting +of `{ok, Results}`, where `Results = {FoundAPs, NetworkList}`. `FoundAPs` may be a number larger +than the length of the NetworkList if more access points were discovered than the number of results +requested. The entries in the `NetworkList` take the form of a map with the keys `ssid` mapped to +the network name, `rssi` for the dBm signal strength of the access point, `authmode` value is the +authentication method used by the network, `bssid` (a.k.a MAC address) of the access point, the +`channel` key for the primary channel for the network, hidden networks (when `show_hidden` is +used in the `scan_options()`) will have an empty `ssid` and the `hidden` key will be set to `true`. +If an error is encountered the return will be `{error, Reason :: term()}`. If the network is +stopped while a scan is in progress, the callback or caller may receive either a successful scan +result, or `{error, canceled}`. + +Blocking example with no `scan_done` callback: +```erlang +case network:wifi_scan() of + {ok, {Num, Networks}} -> + io:format("network scan found ~p networks.~n", [Num]), + lists:foreach( + fun( + _Network = #{ssid := SSID, rssi := DBm, authmode := Mode, bssid := BSSID, channel := Number} + ) -> + io:format( + "Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel ~p~n", + [SSID, BSSID, DBm, Mode, Number] + ) + end, + Networks + ); + {error, Reason} -> + io:format("Failed to scan for wifi networks for reason ~p.~n", [Reason]) +end, +... +``` + +To avoid blocking the caller for extended lengths of time, especially on 5 Ghz capable devices, +a callback function may be configured in the network config. See +[Station mode callbacks](#station-mode-callbacks). + +To minimize the risk of out-of-memory errors, this driver limits the maximum number of returned +networks depending on the target and memory configuration: +ESP32-C2 supports up to 10, ESP32-S2/ESP32-C61/ESP32-C5 up to 14, most other targets up to 20, +and ESP32-P4 or PSRAM-enabled builds up to 64. + +The default scan is quite fast, and likely may not find all the available networks. Scans are +quite configurable with `active` (the default) and `passive` modes. Options should take the form of +a proplist. The per-channel scan time can be changed with the `dwell` key, the channel dwell time +can be set for up to 1500 ms. Passive scans are slower, as they always linger on each channel for +the full dwell time. Passive mode can be used by simply adding `passive` to the configuration +proplist. Keep in mind when choosing a dwell time that between each progressively scanned channel +the device must return to the home channel for a short time (typically 30ms), but for scans with a +dwell time of over 1000ms the home channel dwell time will increase to 60ms to help mitigate +beacon-timeout events. In some network configuration beacon timeout events may still occur, but +should not lead to a dropped connection, and after the scan completes the device should receive the +next beacon from the access point. The default of 6 access points in the returned `NetworkList` may +be changed with the `results` key. By default hidden networks are ignored, but can be included in +the results by adding `show_hidden` to the configuration. + +For example, to do a passive scan using an ESP32-C6, including hidden networks, using the longest +allowed scan time and showing the maximum number of networks available use the following: + +```erlang +{ok, Results} = network:wifi_scan([passive, {results, 20}, {dwell, 1500}, show_hidden]). +``` + +For convenience the default options used by `network:wifi_scan/0` may be configured along +with the `sta_config()` used to start the network driver. For the corresponding startup-time scan +configuration keys, consult `sta_scan_config()` in the `sta_config()` definition rather than the +runtime [`scan_options()`](./apidocs/erlang/eavmlib/network.md#scan-options) accepted by +`network:wifi_scan/1`. For most applications that will use wifi scan results, it is recommended to +start the driver with a configuration that uses a custom callback function for `disconnected` +events, so that the driver will remain idle and allow the use of scan results to decide if a +connection should be made. + #### `sta_status` The function [`network:sta_status/0`](./apidocs/erlang/eavmlib/network.md#sta_status0) may be used @@ -191,9 +334,9 @@ The `` property list may contain the following entries: If the SSID is omitted in configuration, the SSID name `atomvm-` will be created, where `` is the hexadecimal representation of the factory-assigned MAC address of the device. This name should be sufficiently unique to disambiguate it from other reachable ESP32 devices, but it may also be difficult to read or remember. -If the password is omitted, then an _open network_ will be created, and a warning will be printed to the console. Otherwise, the AP network will be started using WPA+WPA2 authentication. +If the password is omitted, then an __open network__ will be created, and a warning will be printed to the console. Otherwise, the AP network will be started using WPA+WPA2 authentication. -If the channel is omitted the default chanel for esp32 is `1`. This setting is only used while a device is operation is AP mode only. If `ap_channel` is configured, it will be temporarily changed to match the associated access point if AP + STA mode is used and the station is associated with an access point. This is a hardware limitation due to the modem radio only being able to operate on a single channel (frequency) at a time. +If the channel is omitted the default channel for esp32 is `1`. This setting is only used while a device is operation is AP mode only. If `ap_channel` is configured, it will be temporarily changed to match the associated access point if AP + STA mode is used and the station is associated with an access point. This is a hardware limitation due to the modem radio only being able to operate on a single channel (frequency) at a time. The [`network:start/1`](./apidocs/erlang/eavmlib/network.md#start1) will immediately return `{ok, Pid}`, where `Pid` is the process id of the network server, if the network was properly initialized, or `{error, Reason}`, if there was an error in configuration. However, the application may want to wait for the device to to be ready to accept connections from other devices, or to be notified when other devices connect to this AP.