Network Programming Guide

One of the exciting features of the ESP32 and the Pico W/Pico 2 W is their support for WiFi networking, allowing ESP32 and Pico W/Pico 2 W micro-controllers to communicate with the outside world over common IP networking protocols, such as TCP or IDP. The ESP32 and the Pico W/Pico 2 W can be configured in station mode (STA), whereby the devices connect to an existing access point, as well as “softAP” mode (AP), whereby they function as an access point, to which other stations can connect. The ESP32 also supports a combined STA+softAP mode, which allows the device to function in both STA and softAP mode simultaneously.

AtomVM provides an Erlang API interface for interacting with the WiFi networking layer on ESP32 and Pico W/Pico 2 W devices, providing support for configuring your ESP32 or Pico W/Pico 2 W device in STA mode, AP mode, or a combined STA+AP mode, allowing Erlang/Elixir applications to send and receive data from other devices on a network. This interface is encapsulated in the network module, which implements a simple interface for connecting to existing WiFi networks or for functioning as a WiFi access point. The same network module is used for both the ESP32 and the Pico W/Pico 2 W.

Once the network has been set up (in STA or AP mode), AtomVM can use various socket interfaces to interact with the socket layer to create a client or server application. For example, on ESP32, AtomVM supports the gen_udp and gen_tcp APIs, while AtomVM extensions may support HTTP, MQTT, and other protocols built over low-level networking interfaces.

The AtomVM networking API leverages callback functions, allowing applications to be responsive to changes in the underlying network, which can frequently occur in embedded applications, where devices can easily lose and then regain network connectivity. In such cases, it is important for applications to be resilient to changes in network availability, by closing or re-opening socket connections in response to disconnections and re-connections in the underlying network.

This document describes the basic design of the AtomVM network interfaces, and how to interact programmatically with it.

Station (STA) mode

In STA mode, the ESP32 or the Pico W/Pico 2 W connect to an existing WiFi network.

In this case, the input configuration should be a properties list containing a tuple of the form {sta, <sta-properties>}, where <sta-properties> is a property list containing configuration properties for the device in station mode.

The <sta-properties> property list should contain the following entries:

  • {ssid, string() | binary()} The SSID to which the device should connect.

  • {psk, string() | binary()} The password required to authenticate to the network, if required.

In addition, the following optional parameters can be specified to configure the network:

  • {dhcp_hostname, string()|binary()} The DHCP hostname as which the device should register (default: <<"atomvm-<hexmac>">>, where <hexmac> is the hexadecimal representation of the factory-assigned MAC address of the device).

The following options are only supported on ESP32 platform:

  • {beacon_timeout, fun(() -> term())} A callback function which will be called when the device does not receive a beacon frame from the connected access point during the “inactive time” (6 second default, currently not configurable).

  • managed or {managed, boolean()} Used to activate application-managed mode, see below.

The network:start/1 will immediately return {ok, Pid}, where Pid is the process ID of the network server instance, 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 connect to the target network and obtain an IP address, for example, before starting clients or services that require network access.

Managed mode

For fine-grained control over network connections in station mode, the managed boolean may be used, to start the driver and enable station mode, without starting a connection to an access point. In this mode, providing the ssid and psk tuples is optional. If a configuration is provided, this will be used by the sta_connect/0 function to initiate a connection to the access point. If ssid and psk are omitted from the configuration they must be supplied to sta_connect/1 to initiate a connection to an access point. Any new configuration values passed to sta_connect/1 will replace any previous values, but leave the rest unchanged. Callback configuration as well as mdns and sntp configurations may also be updated in the configuration passed to sta_connect/1.

When using managed mode applications should include a disconnected callback to also inhibit the automatic reconnection behavior.

Station mode callbacks

Applications can specify callback functions, which get triggered as events emerge from the network layer, including connection to and disconnection from the target network, as well as IP address acquisition.

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.

Callback functions are optional, but are highly recommended for building robust WiFi applications. The return value from callback functions is ignored, and AtomVM provides no guarantees about the execution context (i.e., BEAM process) in which these functions are invoked.

Station mode example configuration

The following example illustrates initialization of the WiFi network in STA mode. The example program will configure the network to connect to a specified network. Events that occur during the lifecycle of the network will trigger invocations of the specified callback functions.

Config = [
    {sta, [
        managed,
        {connected, fun connected/0},
        {got_ip, fun got_ip/1},
        {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. 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).

connected() ->
    io:format("Connected to AP.~n").

got_ip(IpInfo) ->
    io:format("Got IP: ~p~n", [IpInfo]),
    erlang:spawn(fun() -> start_my_server_sup() end).

disconnected() ->
    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.

STA Mode Convenience Functions

The network module supports the network:wait_for_sta/1,2 convenience functions for applications that do not need robust connection management. These functions are synchronous and will wait until the device is connected to the specified AP. Supply the properties list specified in the {sta, [...]} component of the above configuration, in addition to an optional timeout (in milliseconds).

For example:

Config = [
    {ssid, <<"myssid">>},
    {psk,  <<"mypsk">>},
    {dhcp_hostname, <<"mydevice">>}
],
case network:wait_for_sta(Config, 15000) of
    {ok, {Address, _Netmask, _Gateway}} ->
        io:format("Acquired IP address: ~p~n", [Address]);
    {error, Reason} ->
        io:format("Network initialization failed: ~p~n", [Reason])
end

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

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 or network:wifi_scan/1. 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, scan_canceled}.

Blocking example with no scan_done callback:

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.

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:

{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() 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 may be used any time after the driver has been started to get the current connection state of the sta interface. When a connection is initiated, either at start up or when network:sta_connect/1 is used in application managed mode (which will start with a disconnected state) the interface will be marked as connecting followed by associated after a connection is established with an access point. After receiving an IP address the connection will be fully connected. If a beacon timeout event is received (this indicates poor signal strength or a heavily congested network) the status will change to degraded for the remainder of the connection session. This does not always mean that the connection is still poor, but it can be a helpful diagnostic when experiencing network problems, and often does result in a dropped connection. When stopping the interface with network:sta_disconnect/0 the state will change to disconnecting until the interface is completely stopped and set to disconnected.

sta_disconnect

The function network:sta_disconnect/0 will disconnect a station from the associated access point. Note that using this function without providing a custom disconnected event callback function will result in the driver immediately attempting to reconnect to the last associated access point.

This function is currently only supported on the ESP32 platform.

sta_connect

Using the function network:sta_connect/0 will start a connection to the last configured access point. To connect to a new access point use network:sta_connect/1 with either a proplist consisting of [{ssid, NetworkName}, {psk, Password}, {dhcp_hostname, Hostname}] (setting the hostname is optional, and psk is not required for unsecure networks), or a complete network_config() consisting of [sta_config(), sntp_config(), mdns_config()].

This function is currently only supported on the ESP32 platform.

AP mode

In AP mode, the ESP32 starts a WiFi network to which other devices (laptops, mobile devices, other ESP32 devices, etc) can connect. The ESP32 will create an IPv4 network, and will assign itself the address 192.168.4.1. Devices that attach to the ESP32 in AP mode will be assigned sequential addresses in the 192.168.4.0/24 range, e.g., 192.168.4.2, 192.168.4.3, etc.

To initialize the ESP32 device in AP mode, the input configuration should be a properties list containing a tuple of the form {ap, <ap-properties>}, where <ap-properties> is a property list containing configuration properties for the device in AP mode.

The <ap-properties> property list may contain the following entries:

  • {ssid, string() | binary()} The SSID to which the device should connect.

  • {psk, string() | binary()} The password required to authenticate to the network, if required. Note that this password must be a minimum of 8 characters.

  • {ap_channel, wifi_channel()} The channel the access point should use.

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.

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

The network:start/1 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.

Applications can specify callback functions, which get triggered as events emerge from the network layer, including when a station connects or disconnects from the AP, as well as when a station is assigned an IP address.

Callback functions can be specified by the following configuration parameters:

  • {ap_started, fun(() -> term())} A callback function which will be called when the AP endpoint has started and is ready to be connected to.

  • {sta_connected, fun((Mac::binary()) -> term())} A callback function which will be called when a device connects to the AP. The MAC address of the connected station, as a 6-byte binary, is passed to the callback function.

  • {sta_disconnected, fun((Mac::binary()) -> term())} A callback function which will be called when a device disconnects from the AP. The MAC address of the disconnected station, as a 6-byte binary, is passed to the callback function.

  • {sta_ip_assigned, fun((ipv4_address()) -> term())} A callback function which will be called when the AP assigns an IP address to a station. The assigned IP address is passed to the callback function.

Warning

IPv6 addresses are not yet supported in AtomVM.

Callback functions are completely optional, but are highly recommended for building robust WiFi applications. The return value from callback functions is ignored, and AtomVM provides no guarantees about the execution context (i.e., BEAM process) in which these functions are invoked.

In addition, the following optional parameters can be specified to configure the AP network:

  • {ssid_hidden, boolean()} Whether the AP network should be not be broadcast (false, by default)

  • {max_connections, non_neg_integer()} The maximum number of devices that can connect to this network (by default, 4)

The following example illustrates initialization of the WiFi network in AP mode. The example program will configure the network to connect to start a WiFi network with the name myssid and password mypsk. Events that occur during the lifecycle of the network will trigger invocations of the specified callback functions.

Config = [
    {ap, [
        {ssid, <<"myssid">>},
        {psk,  <<"mypsk">>},
        {ap_started, fun ap_started/0},
        {sta_connected, fun sta_connected/1},
        {sta_ip_assigned, fun sta_ip_assigned/1},
        {sta_disconnected, fun sta_disconnected/1},
    ]}
],
{ok, Pid} = network:start(Config),
...

The following callback functions will be called when the corresponding events occur during the lifetime of the network connection.

ap_started() ->
    io:format("AP started.~n").

sta_connected(Mac) ->
    io:format("STA connected with mac ~p~n", [Mac]).

sta_disconnected(Mac) ->
    io:format("STA disconnected with mac ~p~n", [Mac]).

sta_ip_assigned(Address) ->
    io:format("STA assigned address ~p~n", [Address]).

In a typical application, the network should be configured and the application should wait for the AP to report that it has started, before starting clients or services that have a dependency on the network.

AP Mode Convenience Functions

The network module supports the network:wait_for_ap/1,2 convenience functions for applications that do not need robust connection management. These functions are synchronous and will wait until the device is successfully starts an AP. Supply the properties list specified in the {ap, [...]} component of the above configuration, in addition to an optional timeout (in milliseconds).

For example:

Config = [
    {psk,  <<"mypsk">>}
],
case network:wait_for_ap(Config, 15000) of
    ok ->
        io:format("AP network started at 192.168.4.1~n");
    {error, Reason} ->
        io:format("Network initialization failed: ~p~n", [Reason])
end

STA+AP mode

The network module can be started in both STA and AP mode. In this case, the ESP32 device will both connect to an access point in its STA mode, and will simultaneously serve as an access point in its role in AP mode.

In order to enable both STA and AP mode, simply provide valid configuration for both modes in the configuration structure supplied to the network:start/1 function.

SNTP Support

You may configure the networking layer to automatically synchronize time on the ESP32 with an NTP server accessible on the network.

To synchronize time with an NTP server, add a property list with the tag sntp at the top level configuration passed into the network:start/1 function. Specify the NTP hostname or IP address with which your device should sync using the host property tag. The host value can be a string or binary.

You can also specify a callback function that will get called when the clock is synchronized with the SNTP server via the synchronized property tag. This function takes a tuple with the updated time in seconds and microseconds.

For example:

{sntp, [
    {host, <<"pool.ntp.org">>},
    {synchronized, fun sntp_synchronized/1}
]}

where the sntp_synchronized/1 function is defined as:

sntp_synchronized({TVSec, TVUsec}) ->
    io:format("Synchronized time with SNTP server. TVSec=~p TVUsec=~p~n", [TVSec, TVUsec]).

Note

The device must be in STA mode and connected to an access point in order to use an SNTP server on your network or on the internet.

NVS Credentials

It can become tiresome to enter an SSID and password for every application, and in general it is bad security practice to hard-wire WiFi credentials in your application source code.

You may instead store an STA or AP SSID and PSK in non-volatile storage (NVS) on and ESP32 device.

Caution

Credentials are stored un-encrypted and in plaintext and should not be considered secure. Future versions may use encrypted NVS storage.

Stopping the Network

To stop the network and free any resources in use, issue the stop/0 function:

network:stop().

Note

If network:stop/0 is called while a WiFi scan is in progress, the scan caller or callback may receive either the final scan result or {error, scan_canceled}.