diff --git a/components/modules/CMakeLists.txt b/components/modules/CMakeLists.txt index c5e77a2a..fa765429 100644 --- a/components/modules/CMakeLists.txt +++ b/components/modules/CMakeLists.txt @@ -8,6 +8,7 @@ set(module_srcs "dht.c" "encoder.c" "eromfs.c" + "espnow.c" "file.c" "gpio.c" "heaptrace.c" diff --git a/components/modules/Kconfig b/components/modules/Kconfig index 7741e37b..95e16d28 100644 --- a/components/modules/Kconfig +++ b/components/modules/Kconfig @@ -82,6 +82,12 @@ menu "NodeMCU modules" store the directory path as part of the filename just as SPIFFS does. + config NODEMCU_CMODULE_ESPNOW + bool "ESP-NOW module" + default "n" + help + Includes the espnow module. + config NODEMCU_CMODULE_ETH depends on IDF_TARGET_ESP32 select ETH_USE_ESP32_EMAC diff --git a/components/modules/espnow.c b/components/modules/espnow.c new file mode 100644 index 00000000..5507a9b3 --- /dev/null +++ b/components/modules/espnow.c @@ -0,0 +1,390 @@ +/* + * Copyright 2024 Dius Computing Pty Ltd. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the + * distribution. + * - Neither the name of the copyright holders nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @author Jade Mattsson + */ +#include "module.h" +#include "platform.h" +#include "ip_fmt.h" +#include "task/task.h" +#include "lauxlib.h" +#include "esp_now.h" +#include "esp_wifi.h" +#include "esp_err.h" + +#if ESP_NOW_ETH_ALEN != 6 +# error "MAC address length assumption broken" +#endif +#if ESP_NOW_MAX_DATA_LEN > 0xffff +# error "Update len field in received_packet_t" +#endif + +typedef struct { + uint8_t src[6]; + uint8_t dst[6]; + int16_t rssi; + uint16_t len; // ESP_NOW_MAX_DATA_LEN is currently 250 + char data[0]; +} received_packet_t; + +typedef struct { + uint8_t dst[6]; + esp_now_send_status_t status; +} sent_packet_t; + + +static task_handle_t espnow_task = 0; + +static int recv_ref = LUA_NOREF; +static int sent_ref = LUA_NOREF; + + +// --- Helper functions ----------------------------------- + +static int *cb_ref_for_event(const char *name) +{ + if (strcmp("receive", name) == 0) + return &recv_ref; + else if (strcmp("sent", name) == 0) + return &sent_ref; + else + return NULL; +} + +static int hexval(lua_State *L, char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + else if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + else if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + else + return luaL_error(L, "invalid hex digit '%c'", c); +} + +// TODO: share with wifi_sta.c +static bool parse_mac(const char *str, uint8_t out[6]) +{ + const char *fmts[] = { + "%hhx%hhx%hhx%hhx%hhx%hhx", + "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + "%hhx-%hhx-%hhx-%hhx-%hhx-%hhx", + "%hhx %hhx %hhx %hhx %hhx %hhx", + NULL + }; + for (unsigned i = 0; fmts[i]; ++i) + { + if (sscanf (str, fmts[i], + &out[0], &out[1], &out[2], &out[3], &out[4], &out[5]) == 6) + return true; + } + return false; +} + + +static void on_receive(const esp_now_recv_info_t *info, const uint8_t *data, int len) +{ + if (len < 0) + return; // Don't do that. + + received_packet_t *p = malloc(sizeof(received_packet_t) + len); + if (!p) + { + NODE_ERR("out of memory\n"); + return; + } + memcpy(p->src, info->src_addr, sizeof(p->src)); + memcpy(p->dst, info->des_addr, sizeof(p->dst)); + p->rssi = info->rx_ctrl->rssi; + p->len = len; + memcpy(p->data, data, len); + + if (!task_post_high(espnow_task, (task_param_t)p)) + { + NODE_ERR("lost esp-now packet; task queue full\n"); + free(p); + } +} + + +static void on_sent(const uint8_t *mac, esp_now_send_status_t status) +{ + sent_packet_t *p = malloc(sizeof(sent_packet_t)); + if (!p) + { + NODE_ERR("out of memory\n"); + return; + } + memcpy(p->dst, mac, sizeof(p->dst)); + p->status = status; + + if (!task_post_medium(espnow_task, (task_param_t)p)) + { + NODE_ERR("lost esp-now packet; task queue full\n"); + free(p); + } +} + + +static void espnow_task_fn(task_param_t param, task_prio_t prio) +{ + lua_State* L = lua_getstate(); + int top = lua_gettop(L); + luaL_checkstack(L, 3, ""); + + if (prio == TASK_PRIORITY_HIGH) // received packet + { + received_packet_t *p = (received_packet_t *)param; + if (recv_ref != LUA_NOREF) + { + lua_rawgeti (L, LUA_REGISTRYINDEX, recv_ref); + lua_createtable(L, 0, 4); // src, dst, rssi, data + char mac[MAC_STR_SZ]; + macstr(mac, p->src); + lua_pushstring(L, mac); + lua_setfield(L, -2, "src"); + macstr(mac, p->dst); + lua_pushstring(L, mac); + lua_setfield(L, -2, "dst"); + lua_pushinteger(L, p->rssi); + lua_setfield(L, -2, "rssi"); + lua_pushlstring(L, p->data, p->len); + lua_setfield(L, -2, "data"); + luaL_pcallx(L, 1, 0); + } + free(p); + } + else if (prio == TASK_PRIORITY_MEDIUM) // sent + { + sent_packet_t *p = (sent_packet_t *)param; + if (sent_ref != LUA_NOREF) + { + lua_rawgeti (L, LUA_REGISTRYINDEX, sent_ref); + char dst[MAC_STR_SZ]; + macstr(dst, p->dst); + lua_pushstring(L, dst); + if (p->status == ESP_NOW_SEND_SUCCESS) + lua_pushinteger(L, 1); + else + lua_pushnil(L); + luaL_pcallx(L, 2, 0); + } + free(p); + } + + lua_settop(L, top); // restore original before exit +} + + +static void err_check(lua_State *L, esp_err_t err) +{ + if (err != ESP_OK) + luaL_error(L, "%s", esp_err_to_name(err)); +} + + +// --- Lua interface functions ----------------------------------- + +static int lespnow_start(lua_State *L) +{ + err_check(L, esp_now_init()); + err_check(L, esp_now_register_recv_cb(on_receive)); + err_check(L, esp_now_register_send_cb(on_sent)); + return 0; +} + +static int lespnow_stop(lua_State *L) +{ + err_check(L, esp_now_unregister_send_cb()); + err_check(L, esp_now_unregister_recv_cb()); + err_check(L, esp_now_deinit()); + return 0; +} + +static int lespnow_getversion(lua_State *L) +{ + uint32_t ver; + err_check(L, esp_now_get_version(&ver)); + lua_pushinteger(L, ver); + return 1; +} + +// espnow.on('sent' or 'received', cb) +// sent -> cb('ma:ca:dd:00:11:22', status) +// received -> cb({ src=, dst=, data=, rssi= }) +static int lespnow_on(lua_State *L) +{ + const char *evtname = luaL_checkstring(L, 1); + int *ref = cb_ref_for_event(evtname); + if (!ref) + return luaL_error(L, "unknown event type"); + + if (lua_isnoneornil(L, 2)) + { + if (*ref != LUA_NOREF) + { + luaL_unref(L, LUA_REGISTRYINDEX, *ref); + *ref = LUA_NOREF; + } + } + else if (lua_isfunction(L, 2)) + { + if (*ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, *ref); + lua_pushvalue(L, 2); + *ref = luaL_ref(L, LUA_REGISTRYINDEX); + } + else + return luaL_error(L, "expected function"); + return 0; +} + +// espnow.send('ma:ca:dd:00:11:22' or nil, str) +static int lespnow_send(lua_State *L) +{ + const char *mac = luaL_optstring(L, 1, NULL); + size_t payloadlen = 0; + const char *payload = luaL_checklstring(L, 2, &payloadlen); + + uint8_t peer_addr[6]; + if (mac && !parse_mac(mac, peer_addr)) + return luaL_error(L, "bad peer address"); + + err_check(L, esp_now_send( + mac ? peer_addr : NULL, (const uint8_t *)payload, payloadlen)); + return 0; +} + + +// espnow.addpeer('ma:ca:dd:00:11:22', { lmk=, channel=, encrypt= } +static int lespnow_addpeer(lua_State *L) +{ + esp_now_peer_info_t peer_info; + memset(&peer_info, 0, sizeof(peer_info)); + + const char *mac = luaL_checkstring(L, 1); + + wifi_mode_t mode = WIFI_MODE_NULL; + err_check(L, esp_wifi_get_mode(&mode)); + + switch (mode) + { + case WIFI_MODE_STA: peer_info.ifidx = WIFI_IF_STA; break; + case WIFI_MODE_APSTA: // fall-through + case WIFI_MODE_AP: peer_info.ifidx = WIFI_IF_AP; break; + default: return luaL_error(L, "No wifi interface found"); + } + + if (!parse_mac(mac, peer_info.peer_addr)) + return luaL_error(L, "bad peer address"); + + lua_settop(L, 2); // Discard excess parameters, to ensure we have space + if (lua_istable(L, 2)) + { + lua_getfield(L, 2, "encrypt"); + peer_info.encrypt = luaL_optint(L, -1, 0); + lua_pop(L, 1); + if (peer_info.encrypt) + { + lua_getfield(L, 2, "lmk"); + size_t lmklen = 0; + const char *lmkstr = luaL_checklstring(L, -1, &lmklen); + lua_pop(L, 1); + if (lmklen != 2*sizeof(peer_info.lmk)) + return luaL_error(L, "LMK must be %d hex digits", 2*ESP_NOW_KEY_LEN); + for (unsigned i = 0; i < sizeof(peer_info.lmk); ++i) + peer_info.lmk[i] = + (hexval(L, lmkstr[i*2]) << 4) + hexval(L, lmkstr[i*2 +1]); + } + + lua_getfield(L, 2, "channel"); + peer_info.channel = luaL_optint(L, -1, 0); + lua_pop(L, 1); + } + + err_check(L, esp_now_add_peer(&peer_info)); + return 0; +} + +// espnow.delpeer('ma:ca:dd:00:11:22') +static int lespnow_delpeer(lua_State *L) +{ + const char *mac = luaL_checkstring(L, 1); + uint8_t peer_addr[6]; + if (!parse_mac(mac, peer_addr)) + return luaL_error(L, "bad peer address"); + + err_check(L, esp_now_del_peer(peer_addr)); + return 0; +} + +static int lespnow_setpmk(lua_State *L) +{ + uint8_t pmk[ESP_NOW_KEY_LEN] = { 0, }; + size_t len = 0; + const char *str = luaL_checklstring(L, 1, &len); + if (len != sizeof(pmk) * 2) + return luaL_error(L, "PMK must be %d hex digits", 2*ESP_NOW_KEY_LEN); + for (unsigned i = 0; i < sizeof(pmk); ++i) + pmk[i] = (hexval(L, str[i*2]) << 4) + hexval(L, str[i*2 +1]); + + err_check(L, esp_now_set_pmk(pmk)); + return 0; +} + +static int lespnow_setwakewindow(lua_State *L) +{ + int n = luaL_checkint(L, 1); + if (n < 0 || n > 0xffff) + return luaL_error(L, "wake window out of bounds"); + err_check(L, esp_now_set_wake_window(n)); + return 0; +} + +static int lespnow_init(lua_State *L) +{ + espnow_task = task_get_id(espnow_task_fn); + return 0; +} + +LROT_BEGIN(espnow, NULL, 0) + LROT_FUNCENTRY( start, lespnow_start ) + LROT_FUNCENTRY( stop, lespnow_stop ) + LROT_FUNCENTRY( getversion, lespnow_getversion ) + LROT_FUNCENTRY( on, lespnow_on ) // 'receive', 'sent' + LROT_FUNCENTRY( send, lespnow_send ) + LROT_FUNCENTRY( addpeer, lespnow_addpeer ) + LROT_FUNCENTRY( delpeer, lespnow_delpeer ) + LROT_FUNCENTRY( setpmk, lespnow_setpmk ) + LROT_FUNCENTRY( setwakewindow, lespnow_setwakewindow ) +LROT_END(espnow, NULL, 0) + +NODEMCU_MODULE(ESPNOW, "espnow", espnow, lespnow_init); diff --git a/docs/modules/espnow.md b/docs/modules/espnow.md new file mode 100644 index 00000000..c572f9b8 --- /dev/null +++ b/docs/modules/espnow.md @@ -0,0 +1,247 @@ +# ESP-NOW Module +| Since | Origin / Contributor | Maintainer | Source | +| :----- | :-------------------- | :---------- | :------ | +| 2024-03-07 | [Jade Mattsson](https://github.com/jmattsson) |[Jade Mattsson](https://github.com/jmattsson) | [espnow.c](../../components/modules/espnow.c)| + +The `espnow` module provides an interface to Espressif's [ESP-NOW functionality](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_now.html). To quote their documentation directly: + +"ESP-NOW is a kind of connectionless Wi-Fi communication protocol that is defined by Espressif. In ESP-NOW, application data is encapsulated in a vendor-specific action frame and then transmitted from one Wi-Fi device to another without connection." + +Packets can be sent to either individual peers, the whole list of defined peers, or broadcast to everyone in range. For non-broadcast packets, ESP-NOW provides optional encryption support to prevent eavesdropping. To use encryption, a "Primary Master Key" (PMK) should first be set. When registering a peer, a peer-specific "Local Master Key" (LMK) is then given, which is further encrypted by the PMK. All packets sent to that peer will be encrypted with the resulting key. + +To broadcast packets, a peer with address 'ff:ff:ff:ff:ff:ff' must first be registered. Broadcast packets do not support encryption, and attempting to enable encrypting when registering the broadcast peer will result in an error. + +ESP-NOW uses a WiFi vendor-specific action frame to transmit data, and as such it requires the WiFi stack to have been started before ESP-NOW packets can be sent and received. + + +## espnow.start + +Starts the ESP-NOW stack. While this may be called prior to `wifi.start()`, packet transmission and reception will not be possible until after the WiFi stack has been started. + +#### Syntax +```lua +espnow.start() +``` + +#### Parameters +None. + +#### Returns +`nil` + +An error will be raised if the ESP-NOW stack cannot be started. + + +## espnow.stop + +Stops the ESP-NOW stack. + +#### Syntax +```lua +espnow.stop() +``` + +#### Parameters +None. + +#### Returns +`nil` + +An error will be raised if the ESP-NOW stack cannot be stopped. + + +## espnow.getversion + +Returns the raw version number enum value. Currently, it is `1`. Might be useful for checking version compatibility in the future. + +#### Syntax +```lua +ver = espnow.getversion() +``` + +#### Parameters +None. + +#### Returns +An integer representing the ESP-NOW version. + + +## espnow.setpmk + +Sets the Primary Master Key (PMK). When using security, this should be done prior to adding any peers, as their LMKs will be encrypted by the current PMK. + +#### Syntax +```lua +espnow.setpmk(pmk) +``` +#### Parameters +`pmk` The Primary Master Key, given as a hex-encoding of a 16-byte key (i.e. the `pmk` should consist of 32 hex digits. + +#### Returns +`nil` + +An error will be raised if the PMK cannot be set. + +#### Example +```lua +espnow.setpmk('00112233445566778899aabbccddeeff') +``` + + +## espnow.setwakewindow + +Controls the wake window during which ESP-NOW listens. In most cases this should never need to be changed from the default. Refer to the Espressif documentation for further details. + +#### Syntax +```lua +espnow.setwakewindow(window) +``` + +#### Parameters +`window` An integer between 0 and 65535. + +#### Returns +`nil` + + +## espnow.addpeer + +Registers a peer MAC address. Optionally parameters for the peer may be included, such as encryption key. + +#### Syntax +```lua +espnow.addpeer(mac, options) +``` +#### Parameters +`mac` The peer mac address, given as a string in `00:11:22:33:44:55` format (colons optional, and may also be replaced by '-' or ' '). +`options` A table with with following entries: + - `channel` An integer indicating the WiFi channel to be used. The default is `0`, indicating that the current WiFi channel should be used. If non-zero, must match the current WiFi channel. + - `lmk` The LMK for the peer, if encryption is to be used. + - `encrypt` A non-zero integer to indicate encryption should be enabled. When set, makes `lmk` a required field. + +#### Returns +`nil` + +An error will be raised if a peer cannot be added, such as if the peer list if full, or the peer has already been added. + +#### Examples + +Adding a peer without encryption enabled. +```lua +espnow.addpeer('7c:df:a1:c1:4c:71') +``` + +Adding a peer with encryption enabled. Please use randomly generated keys instead of these easily guessable placeholders. +```lua +espnow.setpmk('ffeeddccbbaa99887766554433221100') +espnow.addpeer('7c:df:a1:c1:4c:71', { encrypt = 1, lmk = '00112233445566778899aabbccddeeff' }) +``` + +## espnow.delpeer + +Deletes a previously added peer from the internal peer list. + +#### Syntax +```lua +espnow.delpeer(mac) +``` + +#### Parameters +`mac` The MAC address of the peer to delete. + +#### Returns +`nil` + +Returns an error if the peer cannot be deleted. + + +## espnow.on + +Registers or unregisters callback handlers for the ESP-NOW events. + +There are two events available, `sent` which is issued in response to a packet send request and which reports the status of the send attempt, and 'receive' which is issued when an ESP-NOW packet is successfully received. + +Only a single callback function can be registered for each event. + +The callback function for the `sent` event is invoked with two parameters, the destination MAC address, and a `1`/`nil` to indicate whether the send was believed to be successful or not. + +The callback function for the `receive` event is invoked with a single parameter, a table with the following keys: + - `src` The sender MAC address + - `dst` The destination MAC address (likely either the local MAC of the receiver, or the broadcast address) + - `rssi` The RSSI value from the packet, indicating signal strength between the two devices + - `data` The actual payload data, as a string. The string may contain binary data. + +#### Syntax +```lua +espnow.on(event, callbackfn) +``` + +#### Parameters +`event` The event name, one of `sent` or `receive`. +`callbackfn` The callback function to register, or `nil` to unregister the previously set callback function for the event. + +#### Returns +`nil` + +Raises an error if invalid arguments are given. + +#### Example +Registering callback handlers. +```lua +espnow.on('sent', + function(mac, success) print(mac, success and 'Yay!' or 'Noooo') end) +espnow.on('receive', + function(t) print(t.src, '->', t.dst, '@', t.rssi, ':', t.data) end) +``` + +Unregistering callback handlers. +```lua +espnow.on('sent') -- implicit nil +espnow.on('receive', nil) +``` + + +## espnow.send + +Attempts to send an ESP-NOW packet to one or more peers. + +In general it is strongly recommended to use the encryption functionality, as this ensures not only secrecy but also prevent unintentional interference between different users of ESP-NOW. + +If you do need to use broadcasts or multicasts, you should make sure to have a unique, recognisable marker at the start of the payload to make filtering out unwanted messages easy, both for you and other ESP-NOW users. + +#### Syntax +```lua +espnow.send(peer, data) +``` + +#### Parameters +`peer` The peer MAC address to send to. Must have previously been added via `espnow.addpeer()`. If `peer` is given as `nil`, the packet is sent to all registered non-broadcast/multicast peers, and the `sent` callback is invoked for each of those peers. +`data` A string of data to send. May contain binary bytes. Maximum supported length at the time of writing is 250 bytes. + +#### Returns +`nil`, but the `sent` callback is invoked with the status afterwards. + +Raises an error if the peer is not valid, or other fatal errors preventing a send attempt from even being made. The `sent` callback will not be invoked in this case. + +#### Example +Broadcasting a message to every single ESP-NOW device in range. +```lua +bcast='ff:ff:ff:ff:ff:ff' +espnow.addpeer(bcast) +espnow.send(bcast, '[NodeMCU] Hello, world!') +``` + +Sending a directed message to one specific ESP-NOW device. +```lua +peer='7c:df:a1:c1:4c:71' +espnow.addpeer(peer) +espnow.send(peer, 'Hello, you!') +``` + +Sending a message to all registered peers. +```lua +espnow.addpeer('7c:df:a1:c1:4c:71') +espnow.addpeer('7c:df:a1:c1:4c:47') +espnow.addpeer('7c:df:a1:c1:4f:12') +espnow.send(nil, 'Hello, peers!') +``` diff --git a/mkdocs.yml b/mkdocs.yml index 801e3847..8682c4c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,7 @@ nav: - 'dht': 'modules/dht.md' - 'encoder': 'modules/encoder.md' - 'eromfs': 'modules/eromfs.md' + - 'espnow': 'modules/espnow.md' - 'eth': 'modules/eth.md' - 'file': 'modules/file.md' - 'gpio': 'modules/gpio.md'