diff --git a/CHANGELOG.md b/CHANGELOG.md index ef25c675b3..f856fd7313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added missing `ledc` functions for esp32 platform - Added support for Elixir GenServer and Supervisor. - Added support for 10 new STM32 families by switching to STM32 official SDK +- Added `network:sta_connect/0,1` and `network:sta_disconnect/0` to ESP32 network driver. +- Added option to set a custom callback for esp32 network driver +`disconnected` events +- Added `network:sta_status/0` to get the current connection state of the sta interface. ### Changed @@ -94,6 +98,8 @@ instead `badarg`. - Badarg error return from calling crypto:crypto_one_time with invalid arguments now matches OTP24+. - When function head doesn't match, function arguments are now in stacktrace - Function arguments are added to stacktrace also for some NIFs, when one of the arguments is badarg +- Using a custom callback for STA disconnected events in esp32 network driver will stop automatic re-connect, +allowing applications to use scan results or other means to decide when and where to connect. ### Fixed diff --git a/doc/src/network-programming-guide.md b/doc/src/network-programming-guide.md index e53bff8388..1d587da34b 100644 --- a/doc/src/network-programming-guide.md +++ b/doc/src/network-programming-guide.md @@ -20,21 +20,61 @@ This document describes the basic design of the AtomVM network interfaces, and h 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, }`, where `` is a property list containing configuration properties for the device in station mode. +In this case, the input configuration should be a properties list containing a tuple of the form +`{sta, }`, where `` is a property list containing configuration +properties for the device in station mode. The `` 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. -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 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. +In addition, the following optional parameters can be specified to configure the network: -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. +* `{dhcp_hostname, string()|binary()}` The DHCP hostname as which the device should register +(default: `<<"atomvm-">>`, where `` 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](#managed-mode). + +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 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`](./apidocs/erlang/eavmlib/network.md#sta_connect0) 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`](./apidocs/erlang/eavmlib/network.md#sta_connect1) 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. +* `{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. ```{warning} @@ -43,10 +83,7 @@ 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. -In addition, the following optional parameters can be specified to configure the AP network (ESP32 only): - -* `{dhcp_hostname, string()|binary()}` The DHCP hostname as which the device should register (`<<"atomvm-">>`, where `` is the hexadecimal representation of the factory-assigned MAC address of the device). -* `{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). +### 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. @@ -75,7 +112,8 @@ gotIp(IpInfo) -> io:format("Got IP: ~p~n", [IpInfo]). disconnected() -> - io:format("Disconnected from AP.~n"). + io:format("Disconnected from AP, attempting to reconnect~n"), + network:sta_connect(). ``` 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. @@ -102,6 +140,43 @@ 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 + +#### `sta_status` + +The function [`network:sta_status/0`](./apidocs/erlang/eavmlib/network.md#sta_status0) 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`](./apidocs/erlang/eavmlib/network.md#sta_disconnect0) 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`](./apidocs/erlang/eavmlib/network.md#sta_connect0) will +start a connection to the last configured access point. To connect to a new access point use +[`network:sta_connect/1`](./apidocs/erlang/eavmlib/network.md#sta_connect1) 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. diff --git a/libs/eavmlib/src/network.erl b/libs/eavmlib/src/network.erl index 8015671010..0a127ef4c8 100644 --- a/libs/eavmlib/src/network.erl +++ b/libs/eavmlib/src/network.erl @@ -25,7 +25,10 @@ -export([ wait_for_sta/0, wait_for_sta/1, wait_for_sta/2, wait_for_ap/0, wait_for_ap/1, wait_for_ap/2, - sta_rssi/0 + sta_rssi/0, + sta_disconnect/0, + sta_connect/0, sta_connect/1, + sta_status/0 ]). -export([start/1, start_link/1, stop/0]). -export([ @@ -46,14 +49,31 @@ -type ssid_config() :: {ssid, string() | binary()}. -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 +%% the driver that sta mode connections are managed in the user application, allowing to start the +%% driver in STA (or AP+STA) mode, configuring `ssid' and `psk' if provided, but not immediately +%% connecting to the access point. If `ssid` and `psk` are provided in `managed` mode, this +%% configuration will be used when starting a connection using `sta_connect/0`. When using this +%% mode of operation applications will likely need to provide an `sta_disconnected_config()' to +%% replace the default callback (which attempts to reconnect to the last network) and instead scan +%% for available networks, or use some other means of determining when, and which network to +%% connect to. -type dhcp_hostname_config() :: {dhcp_hostname, string() | binary()}. -type sta_connected_config() :: {connected, fun(() -> term())}. -type sta_beacon_timeout_config() :: {beacon_timeout, fun(() -> term())}. -type sta_disconnected_config() :: {disconnected, fun(() -> term())}. +%% If no callback is configured the default behavior when the connection to an access point is +%% lost is to attempt to reconnect. If a callback is provided these automatic re-connections will +%% 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 sta_got_ip_config() :: {got_ip, fun((ip_info()) -> term())}. -type sta_config_property() :: - ssid_config() + app_managed_config() + | ssid_config() | psk_config() | dhcp_hostname_config() | sta_connected_config() @@ -152,13 +172,16 @@ -type network_config() :: [sta_config() | ap_config() | sntp_config() | mdns_config()]. -type db() :: integer(). +-type sta_status() :: + associated | connected | connecting | degraded | disconnected | disconnecting | inactive. -record(state, { config :: network_config(), port :: port(), ref :: reference(), - sta_ip_info :: ip_info(), - mdns :: pid() | undefined + sta_ip_info :: ip_info() | undefined, + mdns :: pid() | undefined, + sta_state :: sta_status() }). %%----------------------------------------------------------------------------- @@ -269,10 +292,16 @@ wait_for_ap(ApConfig, Timeout) -> %% @doc Start a network interface. %% %% This function will start a network interface, which will attempt to -%% connect to an AP endpoint in the background. Specify callback -%% functions to receive definitive -%% information that the connection succeeded. See the AtomVM Network -%% FSM Programming Manual for more information. +%% connect to an AP endpoint in the background. If the `managed' +%% option us used the driver will be started, but the connection will +%% be delayed until `network:sta_connect/0,1' is used to start the +%% connection. Specify callback functions to receive definitive +%% information that the connection succeeded; specify a +%% `sta_disconnected_config()' in the `sta_config()' to manage +%% re-connections in the application, rather than the default +%% automatic attempt to reconnect until a connection is reestablished. +%% +%% See the AtomVM Network Programming Manual for more information. %% @end %%----------------------------------------------------------------------------- -spec start(Config :: network_config()) -> {ok, pid()} | {error, Reason :: term()}. @@ -283,6 +312,50 @@ start(Config) -> start_link(Config) -> gen_server:start_link({local, ?MODULE}, ?MODULE, Config, []). +%%----------------------------------------------------------------------------- +%% @returns `ok', if the network disconnects from the access point, or +%% `{error, Reason}' if a failure occurred. +%% @doc Disconnect from access point. +%% +%% This will terminate a connection to an access point. +%% +%% Note: Using this function without providing an `sta_disconnected_config()' +%% in the `sta_config()' will result in the driver immediately attempting to +%% reconnect to the same access point again. +%% @end +%%----------------------------------------------------------------------------- +-spec sta_disconnect() -> ok | {error, Reason :: term()}. +sta_disconnect() -> + gen_server:call(?SERVER, halt_sta, 65000). + +%%----------------------------------------------------------------------------- +%% @param Config The new station mode network configuration +%% @returns ok, if the network interface was started, or {error, Reason} if +%% a failure occurred (e.g., due to malformed network configuration). +%% @doc Connect to a new access point after the network driver has been started. +%% +%% This function will attempt to connect to a new AP endpoint in the +%% background. +%% @end +%%----------------------------------------------------------------------------- +-spec sta_connect(Config :: network_config() | [sta_config_property()]) -> + ok | {error, Reason :: term()}. +sta_connect(Config) -> + gen_server:call(?SERVER, {connect, Config}, 65000). + +%%----------------------------------------------------------------------------- +%% @returns ok, if the network interface was started, or {error, Reason} if +%% a failure occurred (e.g., due to malformed network configuration). +%% @doc Connect to an access point after a network disconnection. +%% +%% This function will attempt to connect, in the background, to the +%% last AP endpoint that was configured. +%% @end +%%----------------------------------------------------------------------------- +-spec sta_connect() -> ok | {error, Reason :: term()}. +sta_connect() -> + gen_server:call(?SERVER, connect). + %%----------------------------------------------------------------------------- %% @returns ok, if the network interface was stopped, or {error, Reason} if %% a failure occurred. @@ -314,6 +387,24 @@ sta_rssi() -> end end. +%%----------------------------------------------------------------------------- +%% @returns ConnectionState :: sta_status(). +%% +%% @doc Get the connection status of the sta interface. +%% +%% Results will be one of: `associated', `connected', `connecting', `degraded', +%% `disconnected', `disconnecting', or `inactive'. The state `associated' indicates +%% that the station is connected to an access point, but does not yet have an IP address. +%% A status of `degraded' indicates that the connection has experienced at least one +%% beacon timeout event during the current connection session. This does not necessarily +%% mean the connection is still in a poor state, but it might be helpful diagnosing +%% problems with networked applications. +%% @end +%%----------------------------------------------------------------------------- +-spec sta_status() -> Status :: sta_status(). +sta_status() -> + gen_server:call(?SERVER, sta_status). + %% %% gen_server callbacks %% @@ -322,8 +413,22 @@ sta_rssi() -> init(Config) -> Port = get_port(), Ref = make_ref(), - {ok, #state{config = Config, port = Port, ref = Ref}, {continue, start_port}}. + Status = + case proplists:get_value(sta, Config) of + undefined -> + inactive; + STA -> + case proplists:get_value(managed, STA, false) of + false -> + connecting; + true -> + disconnected + end + end, + {ok, #state{config = Config, port = Port, ref = Ref, sta_state = Status}, + {continue, start_port}}. +%% @hidden handle_continue(start_port, #state{config = Config, port = Port, ref = Ref} = State) -> Port ! {self(), Ref, {start, Config}}, receive @@ -334,6 +439,24 @@ handle_continue(start_port, #state{config = Config, port = Port, ref = Ref} = St end. %% @hidden +handle_call(halt_sta, _From, #state{ref = Ref} = State) -> + network_port ! {self(), Ref, halt_sta}, + wait_halt_sta_reply(Ref, State#state{sta_state = disconnecting}); +handle_call(connect, _From, #state{config = Config, ref = Ref} = State) -> + network_port ! {self(), Ref, {connect, Config}}, + wait_connect_reply(Ref, Config, State#state{sta_state = connecting}); +handle_call({connect, Config}, _From, #state{config = OldConfig, ref = Ref} = State) -> + case update_config(OldConfig, Config) of + {error, Reason} -> + {reply, {error, Reason}, State}; + NewConfig -> + network_port ! {self(), Ref, {connect, NewConfig}}, + wait_connect_reply(Ref, NewConfig, State#state{ + sta_state = connecting, config = NewConfig + }) + end; +handle_call(sta_status, _From, State) -> + {reply, State#state.sta_state, State}; handle_call(_Msg, _From, State) -> {reply, {error, unknown_message}, State}. @@ -344,16 +467,16 @@ handle_cast(_Msg, State) -> %% @hidden handle_info({Ref, sta_connected} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_sta_connected_callback(Config), - {noreply, State}; + {noreply, State#state{sta_state = associated}}; handle_info({Ref, sta_beacon_timeout} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_sta_beacon_timeout_callback(Config), - {noreply, State}; + {noreply, State#state{sta_state = degraded}}; handle_info({Ref, sta_disconnected} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_sta_disconnected_callback(Config), - {noreply, State}; + {noreply, State#state{sta_state = disconnected, sta_ip_info = undefined}}; handle_info({Ref, {sta_got_ip, IpInfo}} = _Msg, #state{ref = Ref, config = Config} = State0) -> maybe_sta_got_ip_callback(Config, IpInfo), - State1 = State0#state{sta_ip_info = IpInfo}, + State1 = State0#state{sta_ip_info = IpInfo, sta_state = connected}, State2 = maybe_start_mdns(State1), {noreply, State2}; handle_info({Ref, ap_started} = _Msg, #state{ref = Ref, config = Config} = State) -> @@ -362,7 +485,9 @@ handle_info({Ref, ap_started} = _Msg, #state{ref = Ref, config = Config} = State handle_info({Ref, {ap_sta_connected, Mac}} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_ap_sta_connected_callback(Config, Mac), {noreply, State}; -handle_info({Ref, {ap_sta_disconnected, Mac}} = _Msg, #state{ref = Ref, config = Config} = State) -> +handle_info( + {Ref, {ap_sta_disconnected, Mac}} = _Msg, #state{ref = Ref, config = Config} = State +) -> maybe_ap_sta_disconnected_callback(Config, Mac), {noreply, State}; handle_info( @@ -386,6 +511,28 @@ terminate(_Reason, State) -> network_port ! {?SERVER, Ref, stop}, wait_for_port_close(PortMonitor, Port). +%% @private +wait_connect_reply(Ref, NewConfig, State) -> + receive + {Ref, ok} -> + {reply, ok, State#state{config = NewConfig}}; + {Ref, {error, _Reason} = ER} -> + {reply, ER, State#state{sta_state = disconnected}} + after 60000 -> + {reply, {error, timeout}, State#state{sta_state = disconnected}} + end. + +%% @private +wait_halt_sta_reply(Ref, State) -> + receive + {Ref, ok} -> + {reply, ok, State#state{sta_state = disconnected}}; + {Ref, {error, _Reason} = Error} -> + {reply, Error, State} + after 60000 -> + {reply, {error, timeout}, State} + end. + wait_for_port_close(PortMonitor, Port) -> receive {'DOWN', PortMonitor, port, Port, _DownReason} -> @@ -412,7 +559,9 @@ maybe_sta_beacon_timeout_callback(Config) -> %% @private maybe_sta_disconnected_callback(Config) -> - maybe_callback0(disconnected, proplists:get_value(sta, Config)). + maybe_callback2( + disconnected, proplists:get_value(sta, Config), fun sta_disconnected_default_callback/0 + ). %% @private maybe_sta_got_ip_callback(Config, IpInfo) -> @@ -489,6 +638,21 @@ maybe_callback1({Key, Arg} = Msg, Config) -> spawn(fun() -> Fun(Arg) end) end. +%% @private +maybe_callback2(_Key, undefined, _Default) -> + ok; +maybe_callback2(Key, Config, Default) -> + case proplists:get_value(Key, Config) of + undefined when is_function(Default) -> + spawn(fun() -> Default() end); + Pid when is_pid(Pid) -> + Pid ! Key; + Fun when is_function(Fun) -> + spawn(fun() -> Fun() end); + _ -> + ok + end. + %% @private -spec get_port() -> port(). get_port() -> @@ -507,6 +671,75 @@ open_port() -> erlang:register(network_port, Port), Port. +%% @private +-spec update_config( + OldConfig :: network_config() | [sta_config_property()], NewConfig :: network_config() +) -> UpdatedConfig :: network_config() | {error, Reason :: term()}. +update_config(OldConfig, NewConfig) -> + try + OldSTA = proplists:get_value(sta, OldConfig, []), + NewSTA = + case proplists:get_value(sta, NewConfig) of + undefined -> + SSID = + case proplists:get_value(ssid, NewConfig) of + undefined -> + case proplists:get_value(ssid, OldSTA) of + undefined -> + error(no_ssid); + OldSsid -> + OldSsid + end; + Ssid -> + Ssid + end, + case proplists:get_value(psk, NewConfig) of + undefined -> + update_opts(OldSTA, [{ssid, SSID}]); + PSK -> + update_opts(OldSTA, [{ssid, SSID}, {psk, PSK}]) + end; + NewSta -> + NewSta + end, + STA = {sta, update_opts(OldSTA, NewSTA)}, + AP = + case proplists:get_value(ap, OldConfig) of + undefined -> []; + Ap -> {ap, Ap} + end, + MDNS = + case + update_opts( + proplists:get_value(mdns, OldConfig, []), + proplists:get_value(mdns, NewConfig, []) + ) + of + [] -> []; + MdnsCfg -> {mdns, MdnsCfg} + end, + SNTP = + case + update_opts( + proplists:get_value(sntp, OldConfig, []), + proplists:get_value(sntp, NewConfig, []) + ) + of + [] -> []; + NewSntp -> {sntp, NewSntp} + end, + lists:flatten([STA, AP, MDNS, SNTP]) + catch + error:Reason -> {error, Reason} + end. + +%% @private +update_opts(OldOpts, NewOpts) -> + Old = proplists:to_map(OldOpts), + New = proplists:to_map(NewOpts), + NewMap = maps:merge(Old, New), + proplists:from_map(NewMap). + %% @private wait_for_ip(Timeout) -> receive @@ -528,3 +761,7 @@ wait_for_ap_started(Timeout) -> after Timeout -> {error, timeout} end. + +%% @private +sta_disconnected_default_callback() -> + sta_connect(). diff --git a/src/platforms/esp32/components/avm_builtins/network_driver.c b/src/platforms/esp32/components/avm_builtins/network_driver.c index 00c657c8d0..c67d481f06 100644 --- a/src/platforms/esp32/components/avm_builtins/network_driver.c +++ b/src/platforms/esp32/components/avm_builtins/network_driver.c @@ -68,6 +68,7 @@ static const char *const ap_sta_ip_assigned_atom = ATOM_STR("\x12", "ap_sta_ip_a static const char *const ap_started_atom = ATOM_STR("\xA", "ap_started"); static const char *const dhcp_hostname_atom = ATOM_STR("\xD", "dhcp_hostname"); 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 sntp_atom = ATOM_STR("\x4", "sntp"); @@ -95,13 +96,17 @@ enum network_cmd // TODO add support for scan, ifconfig NetworkStartCmd, NetworkRssiCmd, - NetworkStopCmd + NetworkStopCmd, + StaHaltCmd, + StaConnectCmd }; static const AtomStringIntPair cmd_table[] = { { ATOM_STR("\x5", "start"), NetworkStartCmd }, { ATOM_STR("\x4", "rssi"), NetworkRssiCmd }, { ATOM_STR("\x4", "stop"), NetworkStopCmd }, + { ATOM_STR("\x8", "halt_sta"), StaHaltCmd }, + { ATOM_STR("\x7", "connect"), StaConnectCmd }, SELECT_INT_DEFAULT(NetworkInvalidCmd) }; @@ -111,6 +116,7 @@ struct ClientData uint32_t port_process_id; uint32_t owner_process_id; uint64_t ref_ticks; + bool managed; }; static inline term make_atom(GlobalContext *global, AtomString atom_str) @@ -121,10 +127,10 @@ static inline term make_atom(GlobalContext *global, AtomString atom_str) static term tuple_from_addr(Heap *heap, uint32_t addr) { term terms[4]; - terms[0] = term_from_int32((addr >> 24) & 0xFF); - terms[1] = term_from_int32((addr >> 16) & 0xFF); - terms[2] = term_from_int32((addr >> 8) & 0xFF); - terms[3] = term_from_int32(addr & 0xFF); + terms[0] = term_from_int((addr >> 24) & 0xFF); + terms[1] = term_from_int((addr >> 16) & 0xFF); + terms[2] = term_from_int((addr >> 8) & 0xFF); + terms[3] = term_from_int(addr & 0xFF); return port_heap_create_tuple_n(heap, 4, terms); } @@ -273,7 +279,9 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ case WIFI_EVENT_STA_START: { ESP_LOGI(TAG, "WIFI_EVENT_STA_START received."); - esp_wifi_connect(); + if (!data->managed) { + esp_wifi_connect(); + } break; } @@ -285,7 +293,6 @@ static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_ case WIFI_EVENT_STA_DISCONNECTED: { ESP_LOGI(TAG, "WIFI_EVENT_STA_DISCONNECTED received."); - esp_wifi_connect(); send_sta_disconnected(data); break; } @@ -386,12 +393,20 @@ static wifi_config_t *get_sta_wifi_config(term sta_config, GlobalContext *global } term ssid_term = interop_kv_get_value(sta_config, ssid_atom, global); term pass_term = interop_kv_get_value(sta_config, psk_atom, global); + term managed_term = interop_kv_get_value_default(sta_config, managed_atom, FALSE_ATOM, global); + + bool roaming = false; + if ((!term_is_invalid_term(managed_term)) && (managed_term != FALSE_ATOM)) { + roaming = true; + } // // Check parameters // if (term_is_invalid_term(ssid_term)) { - ESP_LOGE(TAG, "get_sta_wifi_config: Missing SSID"); + if (roaming != true) { + ESP_LOGE(TAG, "get_sta_wifi_config: Missing SSID"); + } return NULL; } int ok = 0; @@ -579,6 +594,9 @@ static void maybe_set_sntp(term sntp_config, GlobalContext *global) char *host = interop_term_to_string(interop_kv_get_value(sntp_config, host_atom, global), &ok); if (LIKELY(ok)) { // do not free(sntp) + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } esp_sntp_setoperatingmode(SNTP_OPMODE_POLL); esp_sntp_setservername(0, host); sntp_set_time_sync_notification_cb(time_sync_notification_cb); @@ -640,13 +658,19 @@ static void start_network(Context *ctx, term pid, term ref, term config) return; } + bool roaming = false; + term managed = interop_kv_get_value_default(sta_config, managed_atom, FALSE_ATOM, ctx->global); + if ((!term_is_invalid_term(managed)) && (managed != FALSE_ATOM)) { + roaming = true; + } + wifi_config_t *sta_wifi_config = get_sta_wifi_config(sta_config, ctx->global); wifi_config_t *ap_wifi_config = get_ap_wifi_config(ap_config, ctx->global); - if (IS_NULL_PTR(sta_wifi_config) && IS_NULL_PTR(ap_wifi_config)) { + if ((!roaming) && IS_NULL_PTR(sta_wifi_config) && IS_NULL_PTR(ap_wifi_config)) { ESP_LOGE(TAG, "Unable to get STA or AP configuration"); term error = port_create_error_tuple(ctx, BADARG_ATOM); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } struct ClientData *data = malloc(sizeof(struct ClientData)); @@ -654,23 +678,24 @@ static void start_network(Context *ctx, term pid, term ref, term config) ESP_LOGE(TAG, "Failed to allocate ClientData"); term error = port_create_error_tuple(ctx, OUT_OF_MEMORY_ATOM); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } data->global = ctx->global; data->port_process_id = ctx->process_id; data->owner_process_id = term_to_local_process_id(pid); 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) { + if ((sta_wifi_config != NULL) || (roaming)) { sta_wifi_interface = esp_netif_create_default_wifi_sta(); if (IS_NULL_PTR(sta_wifi_interface)) { ESP_LOGE(TAG, "Failed to create network STA interface"); term error = port_create_error_tuple(ctx, ERROR_ATOM); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } } esp_netif_t *ap_wifi_interface = NULL; @@ -680,7 +705,7 @@ static void start_network(Context *ctx, term pid, term ref, term config) ESP_LOGE(TAG, "Failed to create network AP interface"); term error = port_create_error_tuple(ctx, ERROR_ATOM); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } } @@ -689,56 +714,57 @@ static void start_network(Context *ctx, term pid, term ref, term config) ESP_LOGE(TAG, "Failed to initialize ESP WiFi"); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } if (UNLIKELY((err = esp_wifi_set_storage(WIFI_STORAGE_FLASH)) != ESP_OK)) { ESP_LOGE(TAG, "Failed to set ESP WiFi storage"); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + 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"); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + 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)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } if ((err = esp_event_handler_register(IP_EVENT, IP_EVENT_AP_STAIPASSIGNED, &event_handler, data)) != ESP_OK) { ESP_LOGE(TAG, "Failed to register staipassigned event handler"); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } if ((err = esp_event_handler_register(sntp_event_base, SNTP_EVENT_BASE_SYNC, &event_handler, data)) != ESP_OK) { ESP_LOGE(TAG, "Failed to register sntp event handler"); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } // // Set the wifi mode // wifi_mode_t wifi_mode = WIFI_MODE_NULL; - if (!IS_NULL_PTR(sta_wifi_config) && !IS_NULL_PTR(ap_wifi_config)) { + if ((!IS_NULL_PTR(sta_wifi_config) || (roaming)) && !IS_NULL_PTR(ap_wifi_config)) { wifi_mode = WIFI_MODE_APSTA; - } else if (!IS_NULL_PTR(sta_wifi_config)) { - wifi_mode = WIFI_MODE_STA; - } else { + } else if (!IS_NULL_PTR(ap_wifi_config)) { wifi_mode = WIFI_MODE_AP; + } else { + wifi_mode = WIFI_MODE_STA; } + if ((err = esp_wifi_set_mode(wifi_mode)) != ESP_OK) { ESP_LOGE(TAG, "Error setting wifi mode %d", err); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } else { ESP_LOGI(TAG, "WIFI mode set to %d", wifi_mode); } @@ -749,16 +775,11 @@ static void start_network(Context *ctx, term pid, term ref, term config) if (!IS_NULL_PTR(sta_wifi_config)) { if ((err = esp_wifi_set_config(WIFI_IF_STA, sta_wifi_config)) != ESP_OK) { ESP_LOGE(TAG, "Error setting STA mode config %d", err); - free(sta_wifi_config); - if (!IS_NULL_PTR(ap_wifi_config)) { - free(ap_wifi_config); - } term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } else { ESP_LOGI(TAG, "STA mode configured"); - free(sta_wifi_config); } } @@ -768,13 +789,11 @@ static void start_network(Context *ctx, term pid, term ref, term config) if (!IS_NULL_PTR(ap_wifi_config)) { if ((err = esp_wifi_set_config(WIFI_IF_AP, ap_wifi_config)) != ESP_OK) { ESP_LOGE(TAG, "Error setting AP mode config %d", err); - free(ap_wifi_config); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } else { ESP_LOGI(TAG, "AP mode configured"); - free(ap_wifi_config); } } @@ -785,7 +804,7 @@ static void start_network(Context *ctx, term pid, term ref, term config) ESP_LOGE(TAG, "Error in esp_wifi_start %d", err); term error = port_create_error_tuple(ctx, term_from_int(err)); port_send_reply(ctx, pid, ref, error); - return; + goto cleanup; } else { ESP_LOGI(TAG, "WIFI started"); } @@ -809,10 +828,20 @@ static void start_network(Context *ctx, term pid, term ref, term config) // Done -- send an ok so the FSM can proceed // port_send_reply(ctx, pid, ref, OK_ATOM); + goto cleanup; + +cleanup: + free(sta_wifi_config); + free(ap_wifi_config); + return; } static void stop_network(Context *ctx) { + + // Stop sntp (ignore OK, or not configured error) + esp_sntp_stop(); + // Stop unregister event callbacks so they dont trigger during shutdown. esp_event_handler_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler); esp_event_handler_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler); @@ -834,9 +863,6 @@ static void stop_network(Context *ctx) esp_wifi_stop(); esp_wifi_deinit(); - // Stop sntp (ignore OK, or not configured error) - esp_sntp_stop(); - // Destroy existing netif interfaces if (ap_wifi_interface != NULL) { esp_netif_destroy_default_wifi(ap_wifi_interface); @@ -879,6 +905,134 @@ static void get_sta_rssi(Context *ctx, term pid, term ref) port_send_reply(ctx, pid, ref, reply); } +static void sta_disconnect(Context *ctx, term pid, term ref) +{ + esp_err_t err = esp_wifi_disconnect(); + if (UNLIKELY(err != ESP_OK)) { + ESP_LOGE(TAG, "Error while disconnecting from AP (%i)", err); + port_ensure_available(ctx, PORT_REPLY_SIZE + TUPLE_SIZE(2)); + term error = port_create_error_tuple(ctx, term_from_int(err)); + port_send_reply(ctx, pid, ref, error); + return; + } + + size_t heap_size = PORT_REPLY_SIZE + TUPLE_SIZE(2); + if (UNLIKELY(memory_ensure_free(ctx, heap_size) != MEMORY_GC_OK)) { + ESP_LOGE(TAG, "Unable to allocate heap space for sta_disconnect; no message sent"); + return; + } + + port_send_reply(ctx, pid, ref, OK_ATOM); +} + +static void sta_connect(Context *ctx, term pid, term ref, term config) +{ + size_t tuple_reply_size = PORT_REPLY_SIZE + TUPLE_SIZE(2); + + // + // Check wifi mode + // + 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, "sta_connect: WiFi mode must be started in either STA mode or APSTA mode to use this function"); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, ERROR_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } + + // + // Get the STA config + // + term sta_config = interop_kv_get_value_default(config, sta_atom, term_invalid_term(), ctx->global); + if (UNLIKELY(term_is_invalid_term(sta_config))) { + // Also accept a proplist containing `ssid` and `psk` key/value tuples. + if ((interop_kv_get_value_default(config, ssid_atom, term_invalid_term(), ctx->global)) != (term_invalid_term())) { + sta_config = config; + } else { + ESP_LOGE(TAG, "Expected STA configuration but got none"); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } + } + + wifi_config_t *sta_wifi_config = get_sta_wifi_config(sta_config, ctx->global); + if (IS_NULL_PTR(sta_wifi_config)) { + ESP_LOGE(TAG, "Unable to get STA configuration"); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, BADARG_ATOM); + port_send_reply(ctx, pid, ref, error); + return; + } + + // + // Set up STA mode + // + if ((err = esp_wifi_set_config(ESP_IF_WIFI_STA, sta_wifi_config)) != ESP_OK) { + ESP_LOGE(TAG, "Error setting STA mode config %d", err); + free(sta_wifi_config); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, term_from_int(err)); + port_send_reply(ctx, pid, ref, error); + return; + } else { + ESP_LOGD(TAG, "STA mode configured"); + free(sta_wifi_config); + } + + // + // Set the DHCP hostname + // + esp_netif_t *sta_interface = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + set_dhcp_hostname(sta_interface, "STA", interop_kv_get_value(sta_config, dhcp_hostname_atom, ctx->global)); + + if ((err = esp_wifi_connect()) != ESP_OK) { + ESP_LOGE(TAG, "Error while connecting: %d", err); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, term_from_int(err)); + port_send_reply(ctx, pid, ref, error); + return; + } else { + ESP_LOGI(TAG, "WiFi connection started."); + } + + // + // Set up simple NTP, if configured + // + maybe_set_sntp(interop_kv_get_value(config, sntp_atom, ctx->global), ctx->global); + + if (UNLIKELY(memory_ensure_free(ctx, tuple_reply_size) != MEMORY_GC_OK)) { + ESP_LOGE(TAG, "Unable to allocate heap space for sta_connect/1; no message sent"); + return; + } + port_send_reply(ctx, pid, ref, OK_ATOM); +} + +static void sta_reconnect(Context *ctx, term pid, term ref) +{ + size_t tuple_reply_size = PORT_REPLY_SIZE + TUPLE_SIZE(2); + + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error while connecting: %d", err); + port_ensure_available(ctx, tuple_reply_size); + term error = port_create_error_tuple(ctx, term_from_int(err)); + port_send_reply(ctx, pid, ref, error); + return; + } else { + ESP_LOGI(TAG, "WiFi connection started."); + } + + if (UNLIKELY(memory_ensure_free(ctx, tuple_reply_size) != MEMORY_GC_OK)) { + ESP_LOGE(TAG, "Unable to allocate heap space for sta_connect/0; no message sent"); + return; + } + port_send_reply(ctx, pid, ref, OK_ATOM); +} + static NativeHandlerResult consume_mailbox(Context *ctx) { bool cmd_terminate = false; @@ -890,7 +1044,7 @@ static NativeHandlerResult consume_mailbox(Context *ctx) return NativeContinue; } - //TODO: port this code to standard port (and gen_message) + // TODO: port this code to standard port (and gen_message) term pid = term_get_tuple_element(msg, 0); term ref = term_get_tuple_element(msg, 1); term cmd = term_get_tuple_element(msg, 2); @@ -918,6 +1072,16 @@ static NativeHandlerResult consume_mailbox(Context *ctx) cmd_terminate = true; stop_network(ctx); break; + case StaHaltCmd: + sta_disconnect(ctx, pid, ref); + break; + case StaConnectCmd: + if (term_is_invalid_term(config)) { + sta_reconnect(ctx, pid, ref); + } else { + sta_connect(ctx, pid, ref, config); + } + break; default: { ESP_LOGE(TAG, "Unrecognized command: %x", cmd); 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 3710f05434..d20ad32aa9 100644 --- a/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt +++ b/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt @@ -62,6 +62,7 @@ compile_erlang(test_esp_partition) compile_erlang(test_esp_timer_get_time) compile_erlang(test_file) compile_erlang(test_wifi_example) +compile_erlang(test_wifi_managed) compile_erlang(test_list_to_atom) compile_erlang(test_list_to_binary) compile_erlang(test_md5) @@ -83,6 +84,7 @@ set(erlang_test_beams test_esp_timer_get_time.beam test_file.beam test_wifi_example.beam + test_wifi_managed.beam test_list_to_atom.beam test_list_to_binary.beam test_md5.beam diff --git a/src/platforms/esp32/test/main/test_erl_sources/test_wifi_managed.erl b/src/platforms/esp32/test/main/test_erl_sources/test_wifi_managed.erl new file mode 100644 index 0000000000..d01b907095 --- /dev/null +++ b/src/platforms/esp32/test/main/test_erl_sources/test_wifi_managed.erl @@ -0,0 +1,148 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Peter M +% +% 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_managed). + +-export([start/0]). + +start() -> + case verify_platform(atomvm:platform()) of + ok -> + ok = test_managed_start(), + ok = test_managed_connect(), + ok = test_sta_status_lifecycle(), + ok = test_disconnect_and_reconnect(), + ok = test_connect_new_ap(), + network:stop(), + ok; + Error -> + Error + end. + +%% Test starting the network driver in managed mode (no immediate connection) +test_managed_start() -> + Self = self(), + Config = + [ + {sta, [ + managed, + {connected, fun() -> Self ! sta_connected end}, + {got_ip, fun(IpInfo) -> Self ! {got_ip, IpInfo} end}, + {disconnected, fun() -> Self ! sta_disconnected end} + ]}, + {sntp, [{host, "time.aws.com"}, {synchronized, fun sntp_synchronized/1}]} + ], + case network:start(Config) of + {ok, _Pid} -> + io:format("Managed network started.~n"), + %% In managed mode, sta_status should be disconnected (not connecting) + disconnected = network:sta_status(), + io:format("test_managed_start OK.~n"), + ok; + Error -> + Error + end. + +%% Test connecting to an AP using sta_connect/1 +test_managed_connect() -> + %% Status should still be disconnected before connect + disconnected = network:sta_status(), + ok = + network:sta_connect([ + {ssid, "Wokwi-GUEST"}, + {psk, ""}, + {sntp, [{host, "pool.ntp.org"}, {synchronized, fun sntp_synchronized/1}]} + ]), + %% After initiating connect, status should be connecting + Status = network:sta_status(), + case Status of + connecting -> + ok; + associated -> + ok; + connected -> + ok; + Other -> + error({unexpected_sta_status, Other}) + end, + case wait_for_ip(20000) of + ok -> ok; + E -> error({waiting_for_ip, E}) + end, + connected = network:sta_status(), + io:format("test_managed_connect OK.~n"), + ok. + +%% Test the full sta_status lifecycle +test_sta_status_lifecycle() -> + %% We should be connected from previous test + connected = network:sta_status(), + io:format("test_sta_status_lifecycle OK.~n"), + ok. + +%% Test disconnecting and reconnecting to the same AP +test_disconnect_and_reconnect() -> + ok = network:sta_disconnect(), + ok = wait_for_disconnect(5000), + disconnected = network:sta_status(), + %% Reconnect using sta_connect/0 (reconnect to last AP) + ok = network:sta_connect(), + ok = wait_for_ip(20000), + connected = network:sta_status(), + io:format("test_disconnect_and_reconnect OK.~n"), + ok. + +%% Test connecting to a new AP using sta_connect/1 with sta_config +test_connect_new_ap() -> + ok = network:sta_disconnect(), + ok = wait_for_disconnect(5000), + disconnected = network:sta_status(), + %% Connect again with explicit config + ok = network:sta_connect([{ssid, "Wokwi-GUEST"}, {psk, ""}]), + ok = wait_for_ip(20000), + connected = network:sta_status(), + io:format("test_connect_new_ap OK.~n"), + ok. + +sntp_synchronized({TVSec, TVUsec}) -> + io:format("Synchronized time with SNTP server. TVSec=~p TVUsec=~p~n", [TVSec, TVUsec]). + +verify_platform(esp32) -> + ok; +verify_platform(Platform) -> + {error, {unsupported_platform, Platform}}. + +wait_for_ip(Timeout) -> + receive + {got_ip, IpInfo} -> + io:format("Got IP: ~p~n", [IpInfo]), + ok + after Timeout -> + {error, timeout} + end. + +wait_for_disconnect(Timeout) -> + receive + sta_disconnected -> + io:format("STA disconnected.~n"), + ok + after Timeout -> + {error, timeout} + end. diff --git a/src/platforms/esp32/test/main/test_main.c b/src/platforms/esp32/test/main/test_main.c index 321cdf57f8..809ad06df1 100644 --- a/src/platforms/esp32/test/main/test_main.c +++ b/src/platforms/esp32/test/main/test_main.c @@ -613,6 +613,12 @@ TEST_CASE("test_wifi_example", "[test_run]") term ret_value = avm_test_case("test_wifi_example.beam"); TEST_ASSERT(ret_value == OK_ATOM); } + +TEST_CASE("test_wifi_managed", "[test_run]") +{ + term ret_value = avm_test_case("test_wifi_managed.beam"); + TEST_ASSERT(ret_value == OK_ATOM); +} #endif // Works C3 on local runs, but fails GH actions