Skip to content

Commit 9caa9c9

Browse files
committed
Merge pull request #1165 from UncleGrumpy/esp32_wifi_scan
Add support for esp32 network driver scanning for access points Add `network:wifi_scan/0,1` to esp32 network driver, giving the ability for devices configured for "station mode" (or with "station + access point mode") to scan for available access points. note: these changes depend on PR #1181 Closes #2024 These changes are made under both the "Apache 2.0" and the "GNU Lesser General Public License 2.1 or later" license terms (dual license). SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
2 parents 3fe0bf0 + 1465e69 commit 9caa9c9

10 files changed

Lines changed: 1861 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010
- Added Erlang distribution over serial (uart)
1111
- Added WASM32 JIT backend for Emscripten platform
12+
- Added `network:wifi_scan/0,1` to ESP32 network driver to scan available APs when in sta or sta+ap mode.
13+
14+
### Changed
15+
- Updated network type db() to dbm() to reflect the actual representation of the type
1216

1317
### Fixed
1418
- Stop using deprecated `term_from_int32` on STM32 platform
1519
- Stop using deprecated `term_from_int32` on RP2 platform
1620
- Stop using deprecated `term_from_int32` on ESP32 platform
21+
- Fixed improper cast of ESP32 `event_data` for `WIFI_EVENT_AP_STA(DIS)CONNECTED` events
1722

1823
## [0.7.0-alpha.1] - 2026-04-06
1924

doc/src/network-programming-guide.md

Lines changed: 158 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ Callback functions can be specified by the following configuration parameters:
7676
* `{connected, fun(() -> term())}` A callback function which will be called when the device connects to the target network.
7777
* `{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.
7878
* `{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.
79+
* `{scan_done, fun((scan_results() | {error, Reason :: term()}) -> term()) | pid()}`
80+
**(ESP32 only)** A callback function (receives `scan_results() | {error, Reason}`) or pid (receives
81+
`{scan_results, scan_results() | {error, Reason}}`) which will be invoked once a network scan is
82+
completed. This allows for event-driven connection management and prevents blocking the caller when
83+
requesting a scan of available wifi networks.
7984

8085
```{warning}
8186
IPv6 addresses are not yet supported in AtomVM.
@@ -90,30 +95,71 @@ The following example illustrates initialization of the WiFi network in STA mode
9095
```erlang
9196
Config = [
9297
{sta, [
93-
{ssid, <<"myssid">>},
94-
{psk, <<"mypsk">>},
98+
managed,
9599
{connected, fun connected/0},
96100
{got_ip, fun got_ip/1},
97-
{disconnected, fun disconnected/0}
101+
{disconnected, fun disconnected/0},
102+
{scan_done, fun got_scan_results/1},
98103
{dhcp_hostname, <<"myesp32">>}
99104
]}
100105
],
101106
{ok, Pid} = network:start(Config),
107+
ok = network:wifi_scan(),
102108
...
103109
```
104110

105-
The following callback functions will be called when the corresponding events occur during the lifetime of the network connection.
111+
The following callback functions will be called when the corresponding events occur during the
112+
lifetime of the network connection. This example demonstrates using callbacks to scan for networks,
113+
and if a found network is stored in nvs with an `ssid` key value that matches, it will use the
114+
stored `psk` key value to authenticate. After an IP address is acquired, the example application's
115+
supervised network service will be started by the `start_my_server_sup` function (this function is
116+
left as an exercise for the reader, see: [`supervisor`](./apidocs/erlang/estdlib/supervisor.md)).
106117

107118
```erlang
108119
connected() ->
109120
io:format("Connected to AP.~n").
110121

111-
gotIp(IpInfo) ->
112-
io:format("Got IP: ~p~n", [IpInfo]).
122+
got_ip(IpInfo) ->
123+
io:format("Got IP: ~p~n", [IpInfo]),
124+
erlang:spawn(fun() -> start_my_server_sup() end).
113125

114126
disconnected() ->
115-
io:format("Disconnected from AP, attempting to reconnect~n"),
116-
network:sta_connect().
127+
io:format("Disconnected from AP, starting scan~n"),
128+
erlang:spawn(fun() -> network:wifi_scan() end).
129+
130+
got_scan_results({error, Reason}) ->
131+
io:format("WiFi scan error ~p, retrying in 60 seconds.~n", [Reason]),
132+
erlang:spawn(fun() ->
133+
timer:sleep(60_000),
134+
network:wifi_scan()
135+
end);
136+
got_scan_results({NumResults, Results}) ->
137+
io:format("WiFi scan found ~p networks.~n", [NumResults]),
138+
erlang:spawn(fun() -> connect_if_known(Results) end).
139+
140+
connect_if_known([]) ->
141+
io:format("No known networks found, re-scanning in 60 seconds.~n"),
142+
erlang:spawn(fun() ->
143+
timer:sleep(60_000),
144+
network:wifi_scan()
145+
end);
146+
connect_if_known([#{ssid := SSID, authmode := Auth} | Results]) ->
147+
case SSID =:= esp:nvs_fetch_binary(network, ssid) of
148+
true ->
149+
case esp:nvs_fetch_binary(network, psk) of
150+
undefined when Auth =:= open ->
151+
io:format("Connecting to unsecured network ~s...~n", [SSID]),
152+
network:sta_connect([{ssid, SSID}]);
153+
undefined ->
154+
io:format("No psk stored in nvs for network ~s with ~p security!~n", [SSID, Auth]),
155+
connect_if_known(Results);
156+
PSK ->
157+
io:format("Connecting to ~s (~p)...~n", [SSID, Auth]),
158+
network:sta_connect([{ssid, SSID}, {psk, PSK}])
159+
end;
160+
false ->
161+
connect_if_known(Results)
162+
end.
117163
```
118164

119165
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
138184
end
139185
```
140186

141-
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).
142-
143187
### STA (or AP+STA) mode functions
144188

189+
Some functions are only available if the device is configured in STA or AP+STA mode.
190+
191+
#### `sta_rssi`
192+
193+
Once connected to an access point, the signal strength in decibel-milliwatts (dBm) of the
194+
connection to the associated access point may be obtained using
195+
[`network:sta_rssi/0`](./apidocs/erlang/eavmlib/network.md#sta_rssi0). The value returned as
196+
`{ok, Value}` will typically be a negative number, but in the
197+
presence of a powerful signal this can be a positive number. A level of 0 dBm corresponds to the
198+
power of 1 milliwatt. A 10 dBm decrease in level is equivalent to a ten-fold decrease in signal
199+
power.
200+
201+
#### `wifi_scan`
202+
203+
```{notice}
204+
This function is currently only supported on the ESP32 platform.
205+
```
206+
207+
After the network has been configured for STA or AP+STA mode and started, you may scan for
208+
available access points using
209+
[`network:wifi_scan/0`](./apidocs/erlang/eavmlib/network.md#wifi_scan0) or
210+
[`network:wifi_scan/1`](./apidocs/erlang/eavmlib/network.md#wifi_scan1). Scanning for access
211+
points will temporarily inhibit other traffic on the access point network if it is in use, but
212+
should not cause any active connections to be dropped. With no options, a default 'active'
213+
(`{passive, false}`) scan, with a per-channel dwell time of 120ms will be used and will return
214+
network details for up to 6 access points, the default may be changed using the `sta_scan_config()`
215+
option in the `sta_config()`. The return value for the scan takes the form of a tuple consisting
216+
of `{ok, Results}`, where `Results = {FoundAPs, NetworkList}`. `FoundAPs` may be a number larger
217+
than the length of the NetworkList if more access points were discovered than the number of results
218+
requested. The entries in the `NetworkList` take the form of a map with the keys `ssid` mapped to
219+
the network name, `rssi` for the dBm signal strength of the access point, `authmode` value is the
220+
authentication method used by the network, `bssid` (a.k.a MAC address) of the access point, the
221+
`channel` key for the primary channel for the network, hidden networks (when `show_hidden` is
222+
used in the `scan_options()`) will have an empty `ssid` and the `hidden` key will be set to `true`.
223+
If an error is encountered the return will be `{error, Reason :: term()}`. If the network is
224+
stopped while a scan is in progress, the callback or caller may receive either a successful scan
225+
result, or `{error, scan_canceled}`.
226+
227+
Blocking example with no `scan_done` callback:
228+
```erlang
229+
case network:wifi_scan() of
230+
{ok, {Num, Networks}} ->
231+
io:format("network scan found ~p networks.~n", [Num]),
232+
lists:foreach(
233+
fun(
234+
_Network = #{ssid := SSID, rssi := DBm, authmode := Mode, bssid := BSSID, channel := Number}
235+
) ->
236+
io:format(
237+
"Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel ~p~n",
238+
[SSID, BSSID, DBm, Mode, Number]
239+
)
240+
end,
241+
Networks
242+
);
243+
{error, Reason} ->
244+
io:format("Failed to scan for wifi networks for reason ~p.~n", [Reason])
245+
end,
246+
...
247+
```
248+
249+
To avoid blocking the caller for extended lengths of time, especially on 5 Ghz capable devices,
250+
a callback function may be configured in the network config. See
251+
[Station mode callbacks](#station-mode-callbacks).
252+
253+
To minimize the risk of out-of-memory errors, this driver limits the maximum number of returned
254+
networks depending on the target and memory configuration:
255+
ESP32-C2 supports up to 10, ESP32-S2/ESP32-C61/ESP32-C5 up to 14, most other targets up to 20,
256+
and ESP32-P4 or PSRAM-enabled builds up to 64.
257+
258+
The default scan is quite fast, and likely may not find all the available networks. Scans are
259+
quite configurable with `active` (the default) and `passive` modes. Options should take the form of
260+
a proplist. The per-channel scan time can be changed with the `dwell` key, the channel dwell time
261+
can be set for up to 1500 ms. Passive scans are slower, as they always linger on each channel for
262+
the full dwell time. Passive mode can be used by simply adding `passive` to the configuration
263+
proplist. Keep in mind when choosing a dwell time that between each progressively scanned channel
264+
the device must return to the home channel for a short time (typically 30ms), but for scans with a
265+
dwell time of over 1000ms the home channel dwell time will increase to 60ms to help mitigate
266+
beacon-timeout events. In some network configuration beacon timeout events may still occur, but
267+
should not lead to a dropped connection, and after the scan completes the device should receive the
268+
next beacon from the access point. The default of 6 access points in the returned `NetworkList` may
269+
be changed with the `results` key. By default hidden networks are ignored, but can be included in
270+
the results by adding `show_hidden` to the configuration.
271+
272+
For example, to do a passive scan using an ESP32-C6, including hidden networks, using the longest
273+
allowed scan time and showing the maximum number of networks available use the following:
274+
275+
```erlang
276+
{ok, Results} = network:wifi_scan([passive, {results, 20}, {dwell, 1500}, show_hidden]).
277+
```
278+
279+
For convenience the default options used by `network:wifi_scan/0` may be configured along
280+
with the `sta_config()` used to start the network driver. For the corresponding startup-time scan
281+
configuration keys, consult `sta_scan_config()` in the `sta_config()` definition rather than the
282+
runtime [`scan_options()`](./apidocs/erlang/eavmlib/network.md#scan-options) accepted by
283+
`network:wifi_scan/1`. For most applications that will use wifi scan results, it is recommended to
284+
start the driver with a configuration that uses a custom callback function for `disconnected`
285+
events, so that the driver will remain idle and allow the use of scan results to decide if a
286+
connection should be made.
287+
145288
#### `sta_status`
146289

147290
The function [`network:sta_status/0`](./apidocs/erlang/eavmlib/network.md#sta_status0) may be used
@@ -191,9 +334,9 @@ The `<ap-properties>` property list may contain the following entries:
191334

192335
If the SSID is omitted in configuration, the SSID name `atomvm-<hexmac>` will be created, where `<hexmac>` 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.
193336

194-
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.
337+
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.
195338

196-
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.
339+
If the channel is omitted the default channel for esp32 is `1`. This setting is only used while a device is operation in 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.
197340

198341
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.
199342

@@ -324,6 +467,7 @@ To stop the network and free any resources in use, issue the [`stop/0`](./apidoc
324467
network:stop().
325468
```
326469

327-
```{caution}
328-
Stop is currently unimplemented.
470+
```{note}
471+
If `network:stop/0` is called while a WiFi scan is in progress, the scan caller or callback may
472+
receive either the final scan result or `{error, scan_canceled}`.
329473
```

examples/erlang/esp32/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ pack_runnable(reformat_nvs reformat_nvs eavmlib avm_esp32)
3838
pack_runnable(uartecho uartecho eavmlib estdlib avm_esp32)
3939
pack_runnable(ledc_example ledc_example eavmlib estdlib avm_esp32)
4040
pack_runnable(epmd_disterl epmd_disterl eavmlib estdlib avm_network avm_esp32)
41+
pack_runnable(wifi_scan wifi_scan estdlib eavmlib avm_network avm_esp32)
42+
pack_runnable(wifi_scan_callback wifi_scan_callback estdlib eavmlib avm_network avm_esp32)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
%% This file is part of AtomVM.
2+
%%
3+
%% Copyright (c) 2023 <winford@object.stream>
4+
%% All rights reserved.
5+
%%
6+
%% Licensed under the Apache License, Version 2.0 (the "License");
7+
%% you may not use this file except in compliance with the License.
8+
%% You may obtain a copy of the License at
9+
%%
10+
%% http://www.apache.org/licenses/LICENSE-2.0
11+
%%
12+
%% Unless required by applicable law or agreed to in writing, software
13+
%% distributed under the License is distributed on an "AS IS" BASIS,
14+
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
%% See the License for the specific language governing permissions and
16+
%% limitations under the License.
17+
%%
18+
%%
19+
%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
20+
%%
21+
22+
-module(wifi_scan).
23+
24+
-export([start/0]).
25+
26+
start() ->
27+
{ok, _Pid} = network:start([{sta, [managed]}]),
28+
scan_passive([show_hidden, {dwell, 1000}]),
29+
scan_active([{dwell, 500}]).
30+
31+
scan_active(Config) ->
32+
io:format(
33+
"\nStarting active scan with configuration ~p, this may take some time depending on dwell ms used.\n\n",
34+
[Config]
35+
),
36+
BeginTime = erlang:monotonic_time(millisecond),
37+
{ok, {Num, Networks}} = network:wifi_scan(Config),
38+
io:format("Active scan found ~p networks in ~pms.\n", [
39+
Num, erlang:monotonic_time(millisecond) - BeginTime
40+
]),
41+
format_networks(Networks).
42+
43+
scan_passive(Config) ->
44+
io:format(
45+
"\nStarting passive scan with configuration: ~p, this may take some time depending on dwell ms used.\n\n",
46+
[Config]
47+
),
48+
Opts = [passive | Config],
49+
BeginTime = erlang:monotonic_time(millisecond),
50+
ScanResults = network:wifi_scan(Opts),
51+
{ok, {Num, Networks}} = ScanResults,
52+
io:format("Passive scan found ~p networks in ~pms.\n", [
53+
Num, erlang:monotonic_time(millisecond) - BeginTime
54+
]),
55+
format_networks(Networks).
56+
57+
format_networks(Networks) ->
58+
lists:foreach(
59+
fun(
60+
_Network = #{
61+
authmode := Mode,
62+
bssid := BSSID,
63+
channel := Number,
64+
hidden := Hidden,
65+
rssi := DBm,
66+
ssid := SSID
67+
}
68+
) ->
69+
io:format(
70+
"Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel ~p, hidden: ~p\n",
71+
[SSID, BSSID, DBm, Mode, Number, Hidden]
72+
)
73+
end,
74+
Networks
75+
).
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
%% This file is part of AtomVM.
2+
%%
3+
%% Copyright (c) 2026 <winford@object.stream>
4+
%% All rights reserved.
5+
%%
6+
%% Licensed under the Apache License, Version 2.0 (the "License");
7+
%% you may not use this file except in compliance with the License.
8+
%% You may obtain a copy of the License at
9+
%%
10+
%% http://www.apache.org/licenses/LICENSE-2.0
11+
%%
12+
%% Unless required by applicable law or agreed to in writing, software
13+
%% distributed under the License is distributed on an "AS IS" BASIS,
14+
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
%% See the License for the specific language governing permissions and
16+
%% limitations under the License.
17+
%%
18+
%%
19+
%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
20+
%%
21+
22+
-module(wifi_scan_callback).
23+
24+
-export([start/0]).
25+
26+
start() ->
27+
Config = [{sta, [managed, {scan_done, fun display_scan_results/1}]}],
28+
{ok, _Pid} = network:start(Config),
29+
io:format(
30+
"\nStarting active scan with configuration ~p, this may take some time depending on dwell ms used.\n\n",
31+
[Config]
32+
),
33+
case network:wifi_scan() of
34+
{error, Reason} ->
35+
io:format("wifi_scan failed for reason ~p\n", [Reason]);
36+
ok ->
37+
timer:sleep(infinity)
38+
end.
39+
40+
display_scan_results(Results) ->
41+
case Results of
42+
{error, Reason} ->
43+
io:format("wifi_scan failed for reason ~p.\n", [Reason]);
44+
{Num, Networks} ->
45+
io:format("wifi_scan callback got ~p results:\n", [Num]),
46+
lists:foreach(
47+
fun(
48+
_Network = #{
49+
authmode := Mode,
50+
bssid := BSSID,
51+
channel := Number,
52+
hidden := Hidden,
53+
ssid := SSID,
54+
rssi := DBm
55+
}
56+
) ->
57+
io:format(
58+
"Network: ~p, BSSID: ~p, signal ~p dBm, Security: ~p, channel: ~p, hidden: ~p\n",
59+
[SSID, BSSID, DBm, Mode, Number, Hidden]
60+
)
61+
end,
62+
Networks
63+
)
64+
end.

0 commit comments

Comments
 (0)