From 2a4b49bebbd274986746ce1c067d1fd054f9d501 Mon Sep 17 00:00:00 2001 From: foamyguy Date: Tue, 20 Oct 2026 13:47:06 -0500 Subject: [PATCH 1/4] _nina module with transport, enable web workflow on Fruit Jam --- locale/circuitpython.pot | 17 +- main.c | 9 + .../boards/adafruit_fruit_jam/mpconfigboard.h | 7 + .../adafruit_fruit_jam/mpconfigboard.mk | 9 + .../common-hal/socketpool/Socket.c | 7 + .../common-hal/socketpool/Socket.h | 7 + .../common-hal/socketpool/SocketPool.c | 7 + .../common-hal/socketpool/SocketPool.h | 6 + .../common-hal/socketpool/__init__.c | 7 + .../common-hal/socketpool/__init__.h | 2 + py/circuitpy_defns.mk | 7 + py/circuitpy_mpconfig.mk | 5 + shared-bindings/_nina/__init__.c | 166 ++++ shared-bindings/_nina/__init__.h | 22 + shared-module/_nina/__init__.c | 95 +++ shared-module/_nina/network.c | 206 +++++ shared-module/_nina/network.h | 39 + shared-module/_nina/socketpool.c | 727 ++++++++++++++++++ shared-module/_nina/socketpool.h | 74 ++ shared-module/_nina/transport.c | 647 ++++++++++++++++ shared-module/_nina/transport.h | 140 ++++ supervisor/shared/tick.c | 8 + supervisor/shared/web_workflow/web_workflow.c | 205 ++++- tests/circuitpython-manual/nina/esp_wedge.py | 29 + .../nina/esp_wedge_host.py | 70 ++ .../nina/no_web_workflow.py | 24 + tests/circuitpython-manual/nina/readme.md | 67 ++ .../circuitpython-manual/nina/run_on_board.py | 67 ++ .../nina/web_workflow_download_host.py | 74 ++ .../nina/web_workflow_files_host.py | 72 ++ .../nina/web_workflow_serial_host.py | 124 +++ 31 files changed, 2900 insertions(+), 46 deletions(-) create mode 100644 shared-bindings/_nina/__init__.c create mode 100644 shared-bindings/_nina/__init__.h create mode 100644 shared-module/_nina/__init__.c create mode 100644 shared-module/_nina/network.c create mode 100644 shared-module/_nina/network.h create mode 100644 shared-module/_nina/socketpool.c create mode 100644 shared-module/_nina/socketpool.h create mode 100644 shared-module/_nina/transport.c create mode 100644 shared-module/_nina/transport.h create mode 100644 tests/circuitpython-manual/nina/esp_wedge.py create mode 100644 tests/circuitpython-manual/nina/esp_wedge_host.py create mode 100644 tests/circuitpython-manual/nina/no_web_workflow.py create mode 100644 tests/circuitpython-manual/nina/readme.md create mode 100644 tests/circuitpython-manual/nina/run_on_board.py create mode 100644 tests/circuitpython-manual/nina/web_workflow_download_host.py create mode 100644 tests/circuitpython-manual/nina/web_workflow_files_host.py create mode 100644 tests/circuitpython-manual/nina/web_workflow_serial_host.py diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index c03d1bd0ae4..6c5952dbd0d 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -974,7 +974,6 @@ msgstr "" #: ports/atmel-samd/common-hal/pulseio/PulseIn.c #: ports/atmel-samd/common-hal/rotaryio/IncrementalEncoder.c #: ports/cxd56/common-hal/pulseio/PulseIn.c -#: ports/espressif/common-hal/mii/RMII.c #: ports/raspberrypi/common-hal/picodvi/Framebuffer_RP2350.c #: ports/zephyr-cp/common-hal/busio/I2C.c #: ports/zephyr-cp/common-hal/busio/SPI.c @@ -1182,6 +1181,7 @@ msgid "Another send is already active" msgstr "" #: ports/atmel-samd/common-hal/sdioio/SDCard.c +#: ports/espressif/common-hal/mii/Ethernet.c #: ports/espressif/common-hal/qspibus/QSPIBus.c shared-bindings/emmcio/EMMC.c #: shared-bindings/socketpool/SocketPool.c msgid "%q failure: %d" @@ -1517,7 +1517,8 @@ msgid "%q must be 8, 16, 24, or 32" msgstr "" #: ports/espressif/common-hal/audiobusio/__init__.c -#: ports/espressif/common-hal/audioi2sin/I2SIn.c shared-bindings/emmcio/EMMC.c +#: ports/espressif/common-hal/audioi2sin/I2SIn.c +#: ports/espressif/common-hal/mii/Ethernet.c shared-bindings/emmcio/EMMC.c msgid "Peripheral in use" msgstr "" @@ -1669,11 +1670,11 @@ msgid "Requested resource not found" msgstr "" #: ports/espressif/common-hal/espidf/__init__.c -#: ports/espressif/common-hal/qspibus/QSPIBus.c +#: ports/espressif/common-hal/qspibus/QSPIBus.c shared-module/_nina/__init__.c msgid "Operation timed out" msgstr "" -#: ports/espressif/common-hal/espidf/__init__.c +#: ports/espressif/common-hal/espidf/__init__.c shared-module/_nina/__init__.c msgid "Received response was invalid" msgstr "" @@ -1710,8 +1711,8 @@ msgstr "" #: ports/raspberrypi/common-hal/usb_host/Port.c #: shared-bindings/digitalio/DigitalInOut.c shared-bindings/emmcio/EMMC.c #: shared-bindings/i2cioexpander/IOPin.c shared-bindings/microcontroller/Pin.c -#: shared-module/max3421e/Max3421E.c shared-module/usb/core/Device.c -#: shared-module/usb_host_audio/USBIn.c +#: shared-module/_nina/__init__.c shared-module/max3421e/Max3421E.c +#: shared-module/usb/core/Device.c shared-module/usb_host_audio/USBIn.c msgid "%q in use" msgstr "" @@ -2512,7 +2513,7 @@ msgid "%q must be >= %d" msgstr "" #: py/argcheck.c shared-bindings/gifio/GifWriter.c -#: shared-module/gifio/OnDiskGif.c +#: shared-module/_nina/__init__.c shared-module/gifio/OnDiskGif.c msgid "%q must be <= %d" msgstr "" @@ -2524,7 +2525,7 @@ msgstr "" msgid "%q length must be >= %d" msgstr "" -#: py/argcheck.c +#: py/argcheck.c shared-module/_nina/__init__.c msgid "%q length must be <= %d" msgstr "" diff --git a/main.c b/main.c index d7a0d31a941..86303c22b8c 100644 --- a/main.c +++ b/main.c @@ -105,6 +105,10 @@ #include "shared-module/memorymonitor/__init__.h" #endif +#if CIRCUITPY_NINA +#include "shared-module/_nina/transport.h" +#endif + #if CIRCUITPY_SOCKETPOOL #include "shared-bindings/socketpool/__init__.h" #endif @@ -433,6 +437,11 @@ static void cleanup_after_vm(mp_obj_t exception) { socketpool_user_reset(); #endif + // Close sockets that user code left open on the ESP32 co-processor. + #if CIRCUITPY_NINA + nina_user_reset(); + #endif + // Turn off user initiated WiFi connections. #if CIRCUITPY_WIFI wifi_user_reset(); diff --git a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.h b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.h index 08c3271552c..318ba55e734 100644 --- a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.h +++ b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.h @@ -42,6 +42,13 @@ #define CIRCUITPY_PSRAM_CHIP_SELECT (&pin_GPIO47) +// The ESP32-C6 running nina-fw, on board.SPI(). nina-fw raises ESP_IRQ when a +// socket has data, so web workflow polls only then. +#define CIRCUITPY_NINA_CS (&pin_GPIO46) +#define CIRCUITPY_NINA_BUSY (&pin_GPIO3) +#define CIRCUITPY_NINA_RESET (&pin_GPIO22) +#define CIRCUITPY_NINA_GPIO0 (&pin_GPIO23) + // #define CIRCUITPY_CONSOLE_UART_TX (&pin_GPIO44) // #define CIRCUITPY_CONSOLE_UART_RX (&pin_GPIO45) diff --git a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk index 49333599816..8c02687bb9f 100644 --- a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk @@ -30,3 +30,12 @@ CIRCUITPY_USB_HOST_BULK = 1 # USB microphones as audio sources. Needs the isochronous ring above. CIRCUITPY_USB_HOST_AUDIO = 1 + +# The ESP32-C6 co-processor, shared by the core and ESP32SPI. The core uses it +# for web workflow. The flags are set one by one, because CIRCUITPY_NETWORK +# would also turn on ssl, ipaddress and so on. +CIRCUITPY_NINA = 1 +CIRCUITPY_SOCKETPOOL = 1 +CIRCUITPY_WEB_WORKFLOW = 1 +CIRCUITPY_MDNS = 0 +CIRCUITPY_SSL = 0 diff --git a/ports/raspberrypi/common-hal/socketpool/Socket.c b/ports/raspberrypi/common-hal/socketpool/Socket.c index 357c1162f12..2e0d5c0cb50 100644 --- a/ports/raspberrypi/common-hal/socketpool/Socket.c +++ b/ports/raspberrypi/common-hal/socketpool/Socket.c @@ -8,6 +8,11 @@ // // SPDX-License-Identifier: MIT +#include "py/mpconfig.h" + +// With CIRCUITPY_NINA, shared-module/_nina/socketpool.c is used instead. +#if !CIRCUITPY_NINA + #include "shared-bindings/socketpool/Socket.h" #include "py/gc.h" @@ -1338,3 +1343,5 @@ void socketpool_socket_reset(socketpool_socket_obj_t *self) { self->pcb.tcp = NULL; self->state = _ERR_BADF; } + +#endif // !CIRCUITPY_NINA diff --git a/ports/raspberrypi/common-hal/socketpool/Socket.h b/ports/raspberrypi/common-hal/socketpool/Socket.h index d7898777744..15306d1d585 100644 --- a/ports/raspberrypi/common-hal/socketpool/Socket.h +++ b/ports/raspberrypi/common-hal/socketpool/Socket.h @@ -13,6 +13,11 @@ #include "common-hal/socketpool/SocketPool.h" +#if CIRCUITPY_NINA +// The sockets live on the ESP32 co-processor (nina-fw). +#include "shared-module/_nina/socketpool.h" +#else + typedef struct _lwip_socket_obj_t { mp_obj_base_t base; @@ -59,3 +64,5 @@ typedef struct _lwip_socket_obj_t { #define socketpool_socket_poll_resume(x) void socket_user_reset(void); + +#endif diff --git a/ports/raspberrypi/common-hal/socketpool/SocketPool.c b/ports/raspberrypi/common-hal/socketpool/SocketPool.c index 12a2ef65c2d..d5a02e28232 100644 --- a/ports/raspberrypi/common-hal/socketpool/SocketPool.c +++ b/ports/raspberrypi/common-hal/socketpool/SocketPool.c @@ -4,6 +4,11 @@ // // SPDX-License-Identifier: MIT +#include "py/mpconfig.h" + +// With CIRCUITPY_NINA, shared-module/_nina/socketpool.c is used instead. +#if !CIRCUITPY_NINA + #include "shared-bindings/socketpool/SocketPool.h" #include "common-hal/socketpool/__init__.h" #include "common-hal/socketpool/Socket.h" @@ -54,3 +59,5 @@ mp_obj_t common_hal_socketpool_getaddrinfo_raise(socketpool_socketpool_obj_t *se tuple->items[4] = MP_OBJ_FROM_PTR(sockaddr); return mp_obj_new_list(1, (mp_obj_t *)&tuple); } + +#endif // !CIRCUITPY_NINA diff --git a/ports/raspberrypi/common-hal/socketpool/SocketPool.h b/ports/raspberrypi/common-hal/socketpool/SocketPool.h index 864a52b3283..a7832bd9b4e 100644 --- a/ports/raspberrypi/common-hal/socketpool/SocketPool.h +++ b/ports/raspberrypi/common-hal/socketpool/SocketPool.h @@ -8,8 +8,14 @@ #include "py/obj.h" +#if CIRCUITPY_NINA +#include "shared-module/_nina/socketpool.h" +#else + #include "lwip/ip_addr.h" typedef struct { mp_obj_base_t base; } socketpool_socketpool_obj_t; + +#endif diff --git a/ports/raspberrypi/common-hal/socketpool/__init__.c b/ports/raspberrypi/common-hal/socketpool/__init__.c index 84a5309ae11..01bf6c59d3c 100644 --- a/ports/raspberrypi/common-hal/socketpool/__init__.c +++ b/ports/raspberrypi/common-hal/socketpool/__init__.c @@ -4,6 +4,11 @@ // // SPDX-License-Identifier: MIT +#include "py/mpconfig.h" + +// With CIRCUITPY_NINA, shared-module/_nina/socketpool.c is used instead. +#if !CIRCUITPY_NINA + #include "py/runtime.h" #include "shared/runtime/interrupt_char.h" @@ -79,3 +84,5 @@ void socketpool_resolve_host_raise(const char *host, ip_addr_t *addr) { mp_raise_OSError(-result); } } + +#endif // !CIRCUITPY_NINA diff --git a/ports/raspberrypi/common-hal/socketpool/__init__.h b/ports/raspberrypi/common-hal/socketpool/__init__.h index d55e720b373..2511a5f3867 100644 --- a/ports/raspberrypi/common-hal/socketpool/__init__.h +++ b/ports/raspberrypi/common-hal/socketpool/__init__.h @@ -6,7 +6,9 @@ #pragma once +#if !CIRCUITPY_NINA #include "lwip/ip_addr.h" mp_obj_t socketpool_ip_addr_to_str(const ip_addr_t *addr); void socketpool_resolve_host_raise(const char *host, ip_addr_t *addr); +#endif diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index ec84d3ec381..b1703947cc8 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -333,6 +333,9 @@ endif ifeq ($(CIRCUITPY_NEOPIXEL_WRITE),1) SRC_PATTERNS += neopixel_write/% endif +ifeq ($(CIRCUITPY_NINA),1) +SRC_PATTERNS += _nina/% +endif ifeq ($(CIRCUITPY_NVM),1) SRC_PATTERNS += nvm/% endif @@ -737,6 +740,7 @@ SRC_SHARED_MODULE_ALL = \ _bleio/ScanEntry.c \ _bleio/ScanResults.c \ _eve/__init__.c \ + _nina/__init__.c \ adafruit_pixelbuf/PixelBuf.c \ adafruit_pixelbuf/__init__.c \ _pixelmap/PixelMap.c \ @@ -1091,6 +1095,9 @@ SRC_SHARED_MODULE_INTERNAL = \ $(filter $(SRC_PATTERNS), \ displayio/bus_core.c \ displayio/display_core.c \ + _nina/network.c \ + _nina/socketpool.c \ + _nina/transport.c \ usb/utf16le.c \ ) diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 0fc82d03704..096218ad11e 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -482,6 +482,11 @@ CFLAGS += -DCIRCUITPY_NETWORK=$(CIRCUITPY_NETWORK) CIRCUITPY_NEOPIXEL_WRITE ?= 1 CFLAGS += -DCIRCUITPY_NEOPIXEL_WRITE=$(CIRCUITPY_NEOPIXEL_WRITE) +# An ESP32 co-processor running nina-fw, shared by the core and ESP32SPI (`_nina`). +# Not part of CIRCUITPY_NETWORK, which would also turn on ssl, ipaddress and so on. +CIRCUITPY_NINA ?= 0 +CFLAGS += -DCIRCUITPY_NINA=$(CIRCUITPY_NINA) + CIRCUITPY_NVM ?= 1 CFLAGS += -DCIRCUITPY_NVM=$(CIRCUITPY_NVM) diff --git a/shared-bindings/_nina/__init__.c b/shared-bindings/_nina/__init__.c new file mode 100644 index 00000000000..686ee7fa058 --- /dev/null +++ b/shared-bindings/_nina/__init__.c @@ -0,0 +1,166 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/_nina/__init__.h" + +//| """Shared access to an ESP32 co-processor running nina-fw +//| +//| The `_nina` module lets libraries send commands to the ESP32 that the core +//| also uses, so that user code and the core can use the same co-processor. +//| It is meant for libraries such as ESP32SPI, not for user code. +//| """ +//| +//| MAX_SOCKETS: int +//| """The number of sockets in nina-fw. Commands naming a higher socket are rejected.""" +//| +//| MAX_TRANSFER: int +//| """The longest command, in bytes, that the ESP32 receives intact.""" +//| +//| MAX_READ: int +//| """The largest ``GET_DATABUF_TCP`` read the ESP32 can reply to.""" +//| + +//| def init() -> None: +//| """Claim the ESP32's pins and the board SPI bus, and hard-reset the ESP32. +//| The ESP32 stays claimed across soft reloads. Does nothing if it is already +//| claimed. Raises `ValueError` if a pin is in use. +//| +//| Temporary: the core will claim the ESP32 itself when web workflow is configured.""" +//| ... +//| +static mp_obj_t _nina_init(void) { + common_hal__nina_init(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(_nina_init_obj, _nina_init); + +//| def active() -> bool: +//| """True if the core has claimed the ESP32 and `transact` can be used.""" +//| ... +//| +static mp_obj_t _nina_active(void) { + return mp_obj_new_bool(common_hal__nina_active()); +} +static MP_DEFINE_CONST_FUN_OBJ_0(_nina_active_obj, _nina_active); + +//| def reset() -> None: +//| """Hard-reset the ESP32 through its reset pin. This closes every socket and +//| disconnects from WiFi. Takes about 0.75 seconds.""" +//| ... +//| +static mp_obj_t _nina_reset(void) { + common_hal__nina_reset(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(_nina_reset_obj, _nina_reset); + +//| def release() -> None: +//| """Give up the ESP32's pins, so that code can drive them directly. Does +//| nothing if they aren't claimed.""" +//| ... +//| +static mp_obj_t _nina_release(void) { + common_hal__nina_release(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(_nina_release_obj, _nina_release); + +//| def transact( +//| cmd: int, +//| params: Optional[Sequence[Union[ReadableBuffer, Sequence[int]]]] = None, +//| *, +//| reply_params: Optional[int] = 1, +//| sent_param_len_16: bool = False, +//| recv_param_len_16: bool = False, +//| ) -> List[bytearray]: +//| """Send one command and return its reply parameters. This has the same +//| contract as ESP32SPI's ``ESP_SPIcontrol._send_command_get_response()``, +//| and raises the same exceptions. +//| +//| :param int cmd: the nina-fw command +//| :param params: the parameters, each a buffer or a sequence of byte values +//| :param int reply_params: how many parameters the reply must have, or ``None`` to accept any number +//| :param bool sent_param_len_16: send each parameter length as 16 bits +//| :param bool recv_param_len_16: read each reply parameter length as 16 bits +//| """ +//| ... +//| +static mp_obj_t _nina_transact(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_cmd, ARG_params, ARG_reply_params, ARG_sent_param_len_16, ARG_recv_param_len_16 }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_cmd, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_params, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_reply_params, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = MP_OBJ_NEW_SMALL_INT(1)} }, + { MP_QSTR_sent_param_len_16, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_recv_param_len_16, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + uint8_t cmd = mp_arg_validate_int_range(args[ARG_cmd].u_int, 0, 255, MP_QSTR_cmd); + int expected_replies = -1; + if (args[ARG_reply_params].u_obj != mp_const_none) { + expected_replies = mp_arg_validate_int_range(mp_obj_get_int(args[ARG_reply_params].u_obj), + 0, 255, MP_QSTR_reply_params); + } + + size_t n_params = 0; + mp_obj_t *items = NULL; + if (args[ARG_params].u_obj != mp_const_none) { + mp_obj_get_array(args[ARG_params].u_obj, &n_params, &items); + } + // ESP32SPI sends at most 5 parameters. + nina_param_t params[16]; + mp_arg_validate_length_max(n_params, MP_ARRAY_SIZE(params), MP_QSTR_params); + // Parameters given as sequences of ints, such as (socket,), are packed here. + uint8_t packed[64]; + size_t packed_len = 0; + for (size_t i = 0; i < n_params; i++) { + mp_buffer_info_t bufinfo; + if (mp_get_buffer(items[i], &bufinfo, MP_BUFFER_READ)) { + params[i].buf = bufinfo.buf; + params[i].len = bufinfo.len; + continue; + } + size_t len; + mp_obj_t *values; + mp_obj_get_array(items[i], &len, &values); + mp_arg_validate_length_max(packed_len + len, sizeof(packed), MP_QSTR_params); + params[i].buf = packed + packed_len; + params[i].len = len; + for (size_t j = 0; j < len; j++) { + packed[packed_len++] = mp_arg_validate_int_range(mp_obj_get_int(values[j]), 0, 255, MP_QSTR_params); + } + } + + return common_hal__nina_transact(cmd, params, n_params, args[ARG_sent_param_len_16].u_bool, + expected_replies, args[ARG_recv_param_len_16].u_bool); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(_nina_transact_obj, 1, _nina_transact); + +static const mp_rom_map_elem_t _nina_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR__nina) }, + { MP_ROM_QSTR(MP_QSTR_init), MP_ROM_PTR(&_nina_init_obj) }, + { MP_ROM_QSTR(MP_QSTR_active), MP_ROM_PTR(&_nina_active_obj) }, + { MP_ROM_QSTR(MP_QSTR_reset), MP_ROM_PTR(&_nina_reset_obj) }, + { MP_ROM_QSTR(MP_QSTR_release), MP_ROM_PTR(&_nina_release_obj) }, + { MP_ROM_QSTR(MP_QSTR_transact), MP_ROM_PTR(&_nina_transact_obj) }, + { MP_ROM_QSTR(MP_QSTR_MAX_SOCKETS), MP_ROM_INT(CIRCUITPY_NINA_MAX_SOCKETS) }, + { MP_ROM_QSTR(MP_QSTR_MAX_TRANSFER), MP_ROM_INT(NINA_MAX_TRANSFER) }, + { MP_ROM_QSTR(MP_QSTR_MAX_READ), MP_ROM_INT(NINA_MAX_READ) }, +}; + +static MP_DEFINE_CONST_DICT(_nina_module_globals, _nina_module_globals_table); + +const mp_obj_module_t _nina_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&_nina_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR__nina, _nina_module); diff --git a/shared-bindings/_nina/__init__.h b/shared-bindings/_nina/__init__.h new file mode 100644 index 00000000000..db36bf2caa9 --- /dev/null +++ b/shared-bindings/_nina/__init__.h @@ -0,0 +1,22 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "py/obj.h" +#include "shared-module/_nina/transport.h" + +void common_hal__nina_init(void); +bool common_hal__nina_active(void); +void common_hal__nina_reset(void); +void common_hal__nina_release(void); +// expected_replies < 0 takes the count from the reply. Returns a list of bytearrays. +mp_obj_t common_hal__nina_transact(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, int expected_replies, bool reply_len_16); diff --git a/shared-module/_nina/__init__.c b/shared-module/_nina/__init__.c new file mode 100644 index 00000000000..51876160f7e --- /dev/null +++ b/shared-module/_nina/__init__.c @@ -0,0 +1,95 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "shared-bindings/_nina/__init__.h" + +#include "py/mperrno.h" +#include "py/objlist.h" +#include "py/runtime.h" + +// Replies to VM commands land here before they are copied into bytearrays. Only +// the VM uses these, so a background command can't overwrite them. +static uint8_t vm_reply_buf[NINA_MAX_TRANSFER]; +static uint16_t vm_reply_lens[255]; + +void common_hal__nina_init(void) { + if (!nina_transport_init()) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_pin); + } +} + +bool common_hal__nina_active(void) { + return nina_transport_active(); +} + +static void check_active(void) { + if (!nina_transport_active()) { + mp_raise_OSError(MP_ENODEV); + } +} + +void common_hal__nina_reset(void) { + check_active(); + nina_transport_reset(); +} + +void common_hal__nina_release(void) { + nina_transport_deinit(); +} + +// Raises the exception type ESP32SPI raises for the same failure, so its error +// handling keeps working. +static void raise_for(nina_result_t result, uint8_t cmd) { + switch (result) { + case NINA_OK: + return; + case NINA_NOT_ACTIVE: + mp_raise_OSError(MP_ENODEV); + case NINA_BUSY: + mp_raise_OSError(MP_EBUSY); + case NINA_INTERRUPTED: + // Nothing was sent. Raise the pending KeyboardInterrupt now. + mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); + mp_raise_OSError(MP_EINTR); + case NINA_BAD_SOCKET: + mp_raise_OSError(MP_EBADF); + case NINA_TOO_BIG: + if (cmd == NINA_GET_DATABUF_TCP) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_size, NINA_MAX_READ); + } + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q length must be <= %d"), MP_QSTR_params, NINA_MAX_TRANSFER); + case NINA_TIMEOUT_READY: + case NINA_TIMEOUT_SELECT: + case NINA_TIMEOUT_START: + mp_raise_msg(&mp_type_TimeoutError, MP_ERROR_TEXT("Operation timed out")); + case NINA_ERR_RESPONSE: + case NINA_BAD_REPLY: + mp_raise_msg(&mp_type_BrokenPipeError, MP_ERROR_TEXT("Received response was invalid")); + case NINA_REPLY_TOO_BIG: + mp_raise_OSError(MP_ENOBUFS); + } +} + +mp_obj_t common_hal__nina_transact(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, int expected_replies, bool reply_len_16) { + nina_reply_t reply = { + .buf = vm_reply_buf, + .buf_len = sizeof(vm_reply_buf), + .lens = vm_reply_lens, + .max_count = MP_ARRAY_SIZE(vm_reply_lens), + }; + nina_result_t result = nina_transact(cmd, params, n_params, param_len_16, &reply, + expected_replies, reply_len_16, true); + raise_for(result, cmd); + + mp_obj_list_t *list = MP_OBJ_TO_PTR(mp_obj_new_list(reply.count, NULL)); + size_t offset = 0; + for (size_t i = 0; i < reply.count; i++) { + list->items[i] = mp_obj_new_bytearray(vm_reply_lens[i], vm_reply_buf + offset); + offset += vm_reply_lens[i]; + } + return MP_OBJ_FROM_PTR(list); +} diff --git a/shared-module/_nina/network.c b/shared-module/_nina/network.c new file mode 100644 index 00000000000..94001c5161a --- /dev/null +++ b/shared-module/_nina/network.c @@ -0,0 +1,206 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared-module/_nina/network.h" +#include "shared-module/_nina/transport.h" + +#include "py/mpconfig.h" +#include "supervisor/shared/tick.h" + +#define JOIN_TIMEOUT_MS (10000) +#define JOIN_POLL_MS (100) +#define REFRESH_MS (1000) +// How often the background tries to rejoin while disconnected. +#define REJOIN_MS (30000) + +static uint8_t status = NINA_WL_NO_SHIELD; +static uint32_t ipv4_address; +static uint32_t generation; +static uint64_t refreshed_ms; +static bool refreshed; + +// The network from settings.toml. +static char join_ssid[33]; +static char join_password[64]; +// The core's last join attempt: when, in which generation, and how many WiFi +// commands VM code had sent by then. +static uint64_t join_ms; +static uint32_t join_generation; +static uint32_t join_vm_commands; +static bool join_tried; +// A background join was sent and hasn't finished yet. +static bool joining; + +// Sends a command with 8-bit parameter lengths from background context, and +// reads a reply of `expected` parameters into buf. +static nina_result_t command(uint8_t cmd, const nina_param_t *params, size_t n_params, + uint8_t *buf, size_t buf_len, uint16_t *lens, int expected) { + nina_reply_t reply = { + .buf = buf, .buf_len = buf_len, .lens = lens, .max_count = expected, + }; + nina_result_t result = nina_transact(cmd, params, n_params, false, &reply, expected, false, false); + if (result == NINA_OK && (int)reply.count != expected) { + return NINA_BAD_REPLY; + } + return result; +} + +static const uint8_t ff = 0xff; +static const nina_param_t ff_param = { .buf = &ff, .len = 1 }; + +static bool read_status(void) { + uint8_t buf[1]; + uint16_t lens[1]; + if (command(NINA_GET_CONN_STATUS, NULL, 0, buf, sizeof(buf), lens, 1) != NINA_OK || lens[0] != 1) { + return false; + } + status = buf[0]; + return true; +} + +static void read_address(void) { + uint8_t buf[12]; + uint16_t lens[3]; + ipv4_address = 0; + if (status == NINA_WL_CONNECTED && + command(NINA_GET_IPADDR, &ff_param, 1, buf, sizeof(buf), lens, 3) == NINA_OK && lens[0] == 4) { + memcpy(&ipv4_address, buf, 4); + } +} + +void nina_network_refresh(bool force) { + if (!nina_transport_active()) { + status = NINA_WL_NO_SHIELD; + ipv4_address = 0; + return; + } + uint64_t now = supervisor_ticks_ms64(); + // A hard reset drops the connection, so the cache is stale. + if (!force && refreshed && generation == nina_transport_generation() && + now - refreshed_ms < REFRESH_MS) { + return; + } + uint32_t gen = nina_transport_generation(); + if (!read_status()) { + // Leave the cache alone and try again next time. + return; + } + read_address(); + generation = gen; + refreshed_ms = now; + refreshed = true; +} + +uint8_t nina_network_status(void) { + if (!nina_transport_active() || generation != nina_transport_generation()) { + return nina_transport_active() ? NINA_WL_IDLE_STATUS : NINA_WL_NO_SHIELD; + } + return status; +} + +uint32_t nina_network_ipv4_address(void) { + return nina_network_status() == NINA_WL_CONNECTED ? ipv4_address : 0; +} + +static bool connected_to(const char *ssid) { + if (!read_status() || status != NINA_WL_CONNECTED) { + return false; + } + uint8_t buf[33]; + uint16_t lens[1]; + if (command(NINA_GET_CURR_SSID, &ff_param, 1, buf, sizeof(buf), lens, 1) != NINA_OK) { + return false; + } + return lens[0] == strlen(ssid) && memcmp(buf, ssid, lens[0]) == 0; +} + +// Waits without checking for Ctrl-C, running background tasks. +static void pause_ms(uint32_t ms) { + uint64_t start = supervisor_ticks_ms64(); + while (supervisor_ticks_ms64() - start < ms) { + RUN_BACKGROUND_TASKS; + } +} + +// Sends SET_PASSPHRASE (or SET_NET for an open network). Doesn't wait. +static bool start_join(void) { + join_ms = supervisor_ticks_ms64(); + join_generation = nina_transport_generation(); + join_vm_commands = nina_transport_vm_network_commands(); + join_tried = true; + nina_param_t params[2] = { + { .buf = (const uint8_t *)join_ssid, .len = strlen(join_ssid) }, + { .buf = (const uint8_t *)join_password, .len = strlen(join_password) }, + }; + bool open = join_password[0] == '\0'; + uint8_t buf[1]; + uint16_t lens[1]; + return command(open ? NINA_SET_NET : NINA_SET_PASSPHRASE, params, open ? 1 : 2, + buf, sizeof(buf), lens, 1) == NINA_OK && buf[0] == 1; +} + +bool nina_network_join(const char *ssid, const char *password) { + if (!nina_transport_active()) { + return false; + } + strncpy(join_ssid, ssid, sizeof(join_ssid) - 1); + strncpy(join_password, password, sizeof(join_password) - 1); + // Joining again, even the same network, drops every open connection. + if (connected_to(ssid)) { + joining = false; + nina_network_refresh(true); + return true; + } + if (join_tried && join_generation == nina_transport_generation()) { + // This already failed once since the last reset. Don't hold up every + // VM start for it; let the background try again soon, even if the last + // VM run managed the connection itself. + join_vm_commands = nina_transport_vm_network_commands(); + join_ms = supervisor_ticks_ms64() - REJOIN_MS; + joining = false; + nina_network_refresh(true); + return false; + } + joining = false; + if (start_join()) { + // Like ESP32SPI's connect_AP(), wait out failures too: the status passes + // through other values on the way to connected. + uint64_t start = supervisor_ticks_ms64(); + while (supervisor_ticks_ms64() - start < JOIN_TIMEOUT_MS) { + pause_ms(JOIN_POLL_MS); + if (read_status() && status == NINA_WL_CONNECTED) { + break; + } + } + } + nina_network_refresh(true); + return nina_network_status() == NINA_WL_CONNECTED; +} + +void nina_network_maintain(void) { + if (!nina_transport_active() || join_ssid[0] == '\0') { + return; + } + nina_network_refresh(false); + if (nina_network_status() == NINA_WL_CONNECTED) { + joining = false; + return; + } + uint64_t now = supervisor_ticks_ms64(); + bool was_reset = join_generation != nina_transport_generation(); + if (joining && !was_reset && now - join_ms < JOIN_TIMEOUT_MS) { + return; + } + joining = false; + // Leave the connection to user code that joined or left a network itself, + // until it resets the ESP32 or the VM ends. + bool vm_managed = join_vm_commands != nina_transport_vm_network_commands(); + if (was_reset || (!vm_managed && now - join_ms >= REJOIN_MS)) { + joining = start_join(); + } +} diff --git a/shared-module/_nina/network.h b/shared-module/_nina/network.h new file mode 100644 index 00000000000..15d6ac7d058 --- /dev/null +++ b/shared-module/_nina/network.h @@ -0,0 +1,39 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +// The ESP32's WiFi connection, as the core sees it. The status and address are +// cached, so the status bar can read them without a SPI round trip. + +#include +#include + +// nina-fw connection status values, as in ESP32SPI. +#define NINA_WL_IDLE_STATUS (0) +#define NINA_WL_NO_SSID_AVAIL (1) +#define NINA_WL_CONNECTED (3) +#define NINA_WL_CONNECT_FAILED (4) +#define NINA_WL_DISCONNECTED (6) +#define NINA_WL_STOPPED (254) +#define NINA_WL_NO_SHIELD (255) + +// Sets the network the core keeps the ESP32 on. Called at each VM start. Joins +// it unless already connected, waiting up to 10 seconds the first time since +// the last hard reset; after that, nina_network_maintain() keeps trying in the +// background. Returns true when connected. Never raises. +bool nina_network_join(const char *ssid, const char *password); +// Called from the background. Rejoins without waiting after a hard reset, and +// every 30 seconds while disconnected, unless VM code has joined or left a +// network itself since the core last tried. +void nina_network_maintain(void); +// Re-reads the status and address, at most once a second unless forced. Call it +// only where an SPI command may run. +void nina_network_refresh(bool force); +// The cached status. NINA_WL_NO_SHIELD when the core doesn't own the ESP32. +uint8_t nina_network_status(void); +// The cached IPv4 address in network byte order, or 0 when not connected. +uint32_t nina_network_ipv4_address(void); diff --git a/shared-module/_nina/socketpool.c b/shared-module/_nina/socketpool.c new file mode 100644 index 00000000000..56bbca5e793 --- /dev/null +++ b/shared-module/_nina/socketpool.c @@ -0,0 +1,727 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/mpconfig.h" + +#if CIRCUITPY_SOCKETPOOL + +#include + +#include "shared-module/_nina/socketpool.h" +#include "shared-module/_nina/network.h" +#include "shared-module/_nina/transport.h" + +#include "py/mperrno.h" +#include "py/runtime.h" +#include "shared/runtime/interrupt_char.h" +#include "shared-bindings/socketpool/__init__.h" +#include "shared-bindings/socketpool/Socket.h" +#include "shared-bindings/socketpool/SocketPool.h" +#include "supervisor/port.h" +#include "supervisor/shared/tick.h" +#include "supervisor/workflow.h" + +// Background pass intervals, while a client is connected and while only the +// listening socket is open. +#define POLL_ACTIVE_MS (20) +#define POLL_IDLE_MS (100) +// The idle interval when nina-fw signals data ready on GPIO0. A pass then only +// keeps the WiFi status current. +#define POLL_IDLE_DATA_READY_MS (500) +// Shortest time between passes when data ready rises. +#define POLL_MIN_MS (5) +// How long a socket's connection state is trusted before asking the ESP32 again. +// Web workflow asks several times per pass. +#define CONNECTED_CACHE_MS (10) +// How long to wait before asking the ESP32 for data again, after it had none. +// Without this, a loop polling the serial websocket would keep the bus busy. +#define AVAIL_RETRY_MS (2) +// How long close() waits without progress for the ESP32 to take the rest of the +// TX buffer. +#define DRAIN_TIMEOUT_MS (1000) + +#define TCP_MODE (0) +#define SOCKET_CLOSED (0) +#define SOCKET_ESTABLISHED (4) + +const mp_obj_base_t nina_socketpool_interface = { &mp_type_object }; + +// The open socket object for each ESP32 socket number. +static socketpool_socket_obj_t *by_num[CIRCUITPY_NINA_MAX_SOCKETS]; +// Sockets closed when the ESP32 couldn't be reached. They are stopped in the +// next background pass. +static uint8_t stop_pending; +static uint32_t stop_pending_generation; +// A listener the core closed. nina-fw can't stop a listener, so it is left +// running and handed back to the next listen() on the same port. +static uint8_t parked_num = NINA_NO_SOCKET; +static uint16_t parked_port; +static uint32_t parked_generation; + +static volatile bool poll_wanted; +static volatile uint32_t poll_countdown; +static volatile uint32_t ms_since_pass; +static bool last_data_ready; +static bool tick_enabled; + +static bool is_open(const socketpool_socket_obj_t *self) { + return self->num != NINA_NO_SOCKET && nina_transport_active() && + self->generation == nina_transport_generation(); +} + +// A command can be sent now. It can't while another one is in flight, which +// happens when this is called from a background task run during its waits. +static bool can_transact(void) { + return nina_transport_active() && !nina_transport_busy(); +} + +static void set_num(socketpool_socket_obj_t *self, uint8_t num) { + self->num = num; + self->generation = nina_transport_generation(); + by_num[num] = self; + nina_transport_set_owned(num, true); +} + +// Forgets the socket number without telling the ESP32. +static void forget(socketpool_socket_obj_t *self) { + if (self->num < CIRCUITPY_NINA_MAX_SOCKETS && by_num[self->num] == self) { + by_num[self->num] = NULL; + // After a hard reset the number may belong to someone else. + if (self->generation == nina_transport_generation()) { + nina_transport_set_owned(self->num, false); + } + } + self->num = NINA_NO_SOCKET; + self->listening = false; + self->connected = false; + self->connected_valid = false; + self->rx_head = 0; + self->rx_len = 0; + self->tx_len = 0; +} + +// Sends a command about one socket and reads its one-parameter reply. *reply_len +// is the reply's length. +static nina_result_t command(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, uint8_t *buf, size_t buf_len, bool reply_len_16, size_t *reply_len) { + uint16_t lens[1]; + nina_reply_t reply = { .buf = buf, .buf_len = buf_len, .lens = lens, .max_count = 1 }; + nina_result_t result = nina_transact(cmd, params, n_params, param_len_16, &reply, 1, + reply_len_16, false); + *reply_len = reply.count == 1 ? lens[0] : 0; + return result; +} + +static nina_result_t socket_command(uint8_t cmd, uint8_t num, uint8_t *buf, size_t buf_len, + size_t *reply_len) { + nina_param_t param = { .buf = &num, .len = 1 }; + return command(cmd, ¶m, 1, false, buf, buf_len, false, reply_len); +} + +static uint16_t le16(const uint8_t *buf, size_t len) { + return len >= 2 ? buf[0] | (buf[1] << 8) : (len == 1 ? buf[0] : 0); +} + +static bool stop(uint8_t num) { + uint8_t buf[1]; + size_t len; + return socket_command(NINA_STOP_CLIENT_TCP, num, buf, sizeof(buf), &len) == NINA_OK; +} + +// Stops the connection and forgets the number. If the ESP32 can't be reached, +// it is stopped in the next background pass. +static void stop_and_forget(socketpool_socket_obj_t *self) { + uint8_t num = self->num; + bool stopped = can_transact() && stop(num); + forget(self); + if (!stopped) { + // Until then it stays the core's, so nina_user_reset() doesn't stop it + // after someone else is given it. + nina_transport_set_owned(num, true); + if (stop_pending == 0) { + stop_pending_generation = nina_transport_generation(); + } + stop_pending |= 1 << num; + } +} + +// Sends the TX buffer. Returns 0 when it's empty, -MP_EAGAIN if the ESP32 can't +// be reached right now, -MP_ENOBUFS if the ESP32 had no room for all of it (the +// rest stays here), or -MP_ENOTCONN if the connection is gone or was ended +// because the send failed. +static int flush(socketpool_socket_obj_t *self) { + if (self->tx_len == 0) { + return 0; + } + if (!can_transact()) { + return -MP_EAGAIN; + } + nina_param_t params[2] = { + { .buf = &self->num, .len = 1 }, + { .buf = self->tx, .len = self->tx_len }, + }; + uint8_t buf[2]; + size_t len; + nina_result_t result = command(NINA_SEND_DATA_TCP, params, 2, true, buf, sizeof(buf), false, &len); + if (result == NINA_BUSY) { + return -MP_EAGAIN; + } + // The reply is the count sent. DATA_SENT_TCP isn't asked: it says yes even + // when nothing went out. + size_t sent = result == NINA_OK ? MIN(le16(buf, len), self->tx_len) : 0; + if (sent == self->tx_len) { + self->tx_len = 0; + return 0; + } + // nina-fw's send doesn't wait for room in its TCP send buffer. + if (sent > 0) { + memmove(self->tx, self->tx + sent, self->tx_len - sent); + self->tx_len -= sent; + return -MP_ENOBUFS; + } + if (result != NINA_OK) { + // Whether the ESP32 sent any of it is unknown, so the stream may have a + // hole now. End the connection rather than carry on. + self->tx_len = 0; + stop_and_forget(self); + return -MP_ENOTCONN; + } + // Nothing went out: the ESP32 has no room, or the connection is gone. + self->connected_valid = false; + if (common_hal_socketpool_socket_get_connected(self)) { + return -MP_ENOBUFS; + } + self->tx_len = 0; + return -MP_ENOTCONN; +} + +// Sends all of the TX buffer, waiting while the ESP32 has no room for it. +static void drain(socketpool_socket_obj_t *self) { + uint64_t progress_ms = supervisor_ticks_ms64(); + uint16_t left = self->tx_len; + while (flush(self) == -MP_ENOBUFS) { + uint64_t now = supervisor_ticks_ms64(); + if (self->tx_len < left) { + left = self->tx_len; + progress_ms = now; + } else if (now - progress_ms >= DRAIN_TIMEOUT_MS) { + break; + } + port_task_sleep_ms(1); + } +} + +static void flush_all(void) { + for (uint8_t num = 0; num < CIRCUITPY_NINA_MAX_SOCKETS; num++) { + socketpool_socket_obj_t *sock = by_num[num]; + if (sock != NULL && is_open(sock) && sock->tx_len > 0) { + flush(sock); + } + } +} + +static bool any_client_open(void) { + for (size_t i = 0; i < CIRCUITPY_NINA_MAX_SOCKETS; i++) { + if (by_num[i] != NULL && is_open(by_num[i]) && !by_num[i]->listening) { + return true; + } + } + return false; +} + +void nina_socketpool_poll_resume(void) { + if (!tick_enabled) { + supervisor_enable_tick(); + tick_enabled = true; + } + if (any_client_open()) { + poll_countdown = POLL_ACTIVE_MS; + } else if (nina_transport_has_data_ready()) { + poll_countdown = POLL_IDLE_DATA_READY_MS; + } else { + poll_countdown = POLL_IDLE_MS; + } + ms_since_pass = 0; + poll_wanted = true; +} + +void nina_socketpool_tick(void) { + if (!poll_wanted) { + return; + } + ms_since_pass++; + if (nina_transport_has_data_ready()) { + // A pass runs soon after data ready rises. While it stays high, for + // example because user code hasn't read its socket, the usual intervals + // apply, but never the long idle one. + bool ready = nina_transport_data_ready(); + if (ready && !last_data_ready) { + uint32_t soonest = ms_since_pass >= POLL_MIN_MS ? 1 : POLL_MIN_MS - ms_since_pass; + poll_countdown = MIN(poll_countdown, soonest); + } else if (ready) { + poll_countdown = MIN(poll_countdown, POLL_IDLE_MS); + } + last_data_ready = ready; + } + if (--poll_countdown == 0) { + poll_wanted = false; + supervisor_workflow_request_background(); + } +} + +bool nina_socketpool_background(void) { + if (!can_transact() || !nina_transport_bus_free()) { + return false; + } + nina_network_maintain(); + if (stop_pending_generation != nina_transport_generation()) { + stop_pending = 0; + } + for (uint8_t num = 0; num < CIRCUITPY_NINA_MAX_SOCKETS; num++) { + if ((stop_pending & (1 << num)) && stop(num)) { + stop_pending &= ~(1 << num); + nina_transport_set_owned(num, false); + } + } + // Serial output written by the VM since the last pass. + flush_all(); + return true; +} + +void nina_socketpool_background_done(void) { + // TCP_NODELAY doesn't send right away (most replies would go out in many + // small pieces), so this sends what the pass left. + if (can_transact()) { + flush_all(); + } +} + +void socketpool_user_reset(void) { + // Python can't make NINA sockets yet, so there are none to close. +} + +void common_hal_socketpool_socketpool_construct(socketpool_socketpool_obj_t *self, mp_obj_t network_interface) { + if (network_interface != MP_OBJ_FROM_PTR(&nina_socketpool_interface)) { + mp_arg_error_invalid(MP_QSTR_radio); + } +} + +bool socketpool_socket(socketpool_socketpool_obj_t *self, + socketpool_socketpool_addressfamily_t family, socketpool_socketpool_sock_t type, + int proto, socketpool_socket_obj_t *sock) { + if (family != SOCKETPOOL_AF_INET || type != SOCKETPOOL_SOCK_STREAM) { + return false; + } + sock->base.type = &socketpool_socket_type; + sock->pool = self; + sock->num = NINA_NO_SOCKET; + sock->type = type; + sock->timeout_ms = (uint32_t)-1; + sock->port = 0; + sock->next_avail_ms = 0; + forget(sock); + return true; +} + +socketpool_socket_obj_t *common_hal_socketpool_socket(socketpool_socketpool_obj_t *self, + socketpool_socketpool_addressfamily_t family, socketpool_socketpool_sock_t type, int proto) { + socketpool_socket_obj_t *sock = mp_obj_malloc_with_finaliser(socketpool_socket_obj_t, &socketpool_socket_type); + if (!socketpool_socket(self, family, type, proto, sock)) { + mp_raise_NotImplementedError(NULL); + } + return sock; +} + +mp_obj_t common_hal_socketpool_getaddrinfo_raise(socketpool_socketpool_obj_t *self, const char *host, int port, int family, int type, int proto, int flags) { + mp_raise_NotImplementedError(NULL); +} + +// Waits for a VM caller with a timeout. Returns false when it's time to give up. +static bool keep_waiting(const socketpool_socket_obj_t *self, uint64_t start) { + if (self->timeout_ms == 0 || mp_hal_is_interrupted()) { + return false; + } + if (self->timeout_ms != (uint32_t)-1 && supervisor_ticks_ms64() - start >= self->timeout_ms) { + return false; + } + RUN_BACKGROUND_TASKS; + return true; +} + +static int try_accept(socketpool_socket_obj_t *self, socketpool_socket_obj_t *accepted) { + // A VM GET_SOCKET is about to use the lowest free number, which is the one + // nina-fw would give a new client. + if (!can_transact() || nina_transport_vm_allocating()) { + return -MP_EAGAIN; + } + uint8_t buf[2]; + size_t len; + // On a listening socket, AVAIL_DATA_TCP accepts a waiting client, and + // replies with its socket number. + if (socket_command(NINA_AVAIL_DATA_TCP, self->num, buf, sizeof(buf), &len) != NINA_OK) { + return -MP_EAGAIN; + } + uint16_t num = le16(buf, len); + if (num >= CIRCUITPY_NINA_MAX_SOCKETS) { + return -MP_EAGAIN; + } + if (!common_hal_socketpool_socket_get_closed(accepted)) { + common_hal_socketpool_socket_close(accepted); + } + accepted->base.type = &socketpool_socket_type; + accepted->pool = self->pool; + accepted->type = SOCKETPOOL_SOCK_STREAM; + accepted->timeout_ms = self->timeout_ms; + accepted->port = self->port; + accepted->next_avail_ms = 0; + forget(accepted); + set_num(accepted, num); + accepted->connected = true; + accepted->connected_valid = true; + accepted->connected_ms = supervisor_ticks_ms64(); + return 1; +} + +int socketpool_socket_accept(socketpool_socket_obj_t *self, mp_obj_t *peer_out, socketpool_socket_obj_t *accepted) { + if (!is_open(self) || !self->listening) { + return -MP_EBADF; + } + uint64_t start = supervisor_ticks_ms64(); + int result; + while ((result = try_accept(self, accepted)) == -MP_EAGAIN && keep_waiting(self, start)) { + } + // nina-fw doesn't say who the peer is. + if (result > 0 && peer_out != NULL) { + *peer_out = mp_const_none; + } + return result; +} + +socketpool_socket_obj_t *common_hal_socketpool_socket_accept(socketpool_socket_obj_t *self, mp_obj_t *peer_out) { + socketpool_socket_obj_t *accepted = mp_obj_malloc_with_finaliser(socketpool_socket_obj_t, &socketpool_socket_type); + accepted->num = NINA_NO_SOCKET; + int result = socketpool_socket_accept(self, peer_out, accepted); + if (result < 0) { + m_del_obj(socketpool_socket_obj_t, accepted); + mp_raise_OSError(-result); + } + return accepted; +} + +size_t common_hal_socketpool_socket_bind(socketpool_socket_obj_t *self, const char *host, size_t hostlen, uint32_t port) { + // nina-fw listens on every interface. + self->port = port; + return 0; +} + +void socketpool_socket_close(socketpool_socket_obj_t *self) { + if (!is_open(self)) { + forget(self); + return; + } + uint8_t num = self->num; + if (self->listening) { + // STOP_CLIENT_TCP would free the number but leave the listener + // running, so keep it, still owned, for the next listen() on this port. + if (parked_num != NINA_NO_SOCKET && parked_generation == nina_transport_generation()) { + // Only one is kept. The other is stopped at the end of the VM run. + nina_transport_set_owned(parked_num, false); + nina_transport_leaked_listener(parked_num); + } + parked_num = num; + parked_port = self->port; + parked_generation = nina_transport_generation(); + by_num[num] = NULL; + self->num = NINA_NO_SOCKET; + self->listening = false; + return; + } + // Web workflow closes as soon as a reply is written, so this is often the + // end of it. + drain(self); + // A failed send may have ended it already. + if (is_open(self)) { + stop_and_forget(self); + } +} + +void common_hal_socketpool_socket_close(socketpool_socket_obj_t *self) { + socketpool_socket_close(self); +} + +void common_hal_socketpool_socket_connect(socketpool_socket_obj_t *self, const char *host, size_t hostlen, uint32_t port) { + mp_raise_NotImplementedError(NULL); +} + +bool common_hal_socketpool_socket_get_closed(socketpool_socket_obj_t *self) { + return !is_open(self); +} + +bool common_hal_socketpool_socket_get_connected(socketpool_socket_obj_t *self) { + if (!is_open(self) || self->listening) { + return false; + } + uint64_t now = supervisor_ticks_ms64(); + if ((self->connected_valid && now - self->connected_ms < CONNECTED_CACHE_MS) || !can_transact()) { + return self->connected; + } + uint8_t buf[1]; + size_t len; + if (socket_command(NINA_GET_CLIENT_STATE_TCP, self->num, buf, sizeof(buf), &len) != NINA_OK || + len != 1) { + return self->connected; + } + self->connected_ms = now; + self->connected_valid = true; + self->connected = buf[0] == SOCKET_ESTABLISHED; + if (buf[0] == SOCKET_CLOSED) { + // nina-fw frees the number of a closed client when asked its state, so + // it may be handed to someone else now. + forget(self); + } + return self->connected; +} + +mp_uint_t common_hal_socketpool_socket_get_timeout(socketpool_socket_obj_t *self) { + return self->timeout_ms; +} + +mp_int_t common_hal_socketpool_socket_get_type(socketpool_socket_obj_t *self) { + return self->type; +} + +bool common_hal_socketpool_socket_listen(socketpool_socket_obj_t *self, int backlog) { + if (self->type != SOCKETPOOL_SOCK_STREAM || is_open(self) || !can_transact() || + nina_transport_vm_allocating()) { + return false; + } + if (parked_num != NINA_NO_SOCKET && parked_generation == nina_transport_generation() && + parked_port == self->port) { + // Still listening. + set_num(self, parked_num); + self->listening = true; + parked_num = NINA_NO_SOCKET; + return true; + } + // START_SERVER_TCP before WiFi is up hangs nina-fw until a hard reset. + nina_network_refresh(false); + if (nina_network_status() != NINA_WL_CONNECTED) { + return false; + } + // GET_SOCKET doesn't reserve the number. START_SERVER_TCP does. + uint8_t buf[1]; + size_t len; + if (command(NINA_GET_SOCKET, NULL, 0, false, buf, sizeof(buf), false, &len) != NINA_OK || + len != 1 || buf[0] >= CIRCUITPY_NINA_MAX_SOCKETS) { + return false; + } + uint8_t num = buf[0]; + uint8_t port[2] = { self->port >> 8, self->port & 0xff }; + uint8_t mode = TCP_MODE; + nina_param_t params[3] = { + { .buf = port, .len = 2 }, + { .buf = &num, .len = 1 }, + { .buf = &mode, .len = 1 }, + }; + // A reply of 0 means the port is in use. + if (command(NINA_START_SERVER_TCP, params, 3, false, buf, sizeof(buf), false, &len) != NINA_OK || + len != 1 || buf[0] != 1) { + return false; + } + set_num(self, num); + self->listening = true; + return true; +} + +mp_uint_t common_hal_socketpool_socket_recvfrom_into(socketpool_socket_obj_t *self, + uint8_t *buf, uint32_t len, mp_obj_t *peer_out) { + mp_raise_NotImplementedError(NULL); +} + +// What a read returns when a command fails. Only a busy transport is worth +// trying again; other failures would repeat, and once they reset the ESP32 the +// socket is gone. +static int recv_failed(socketpool_socket_obj_t *self, nina_result_t result) { + if (result == NINA_BUSY || result == NINA_INTERRUPTED) { + return -MP_EAGAIN; + } + return is_open(self) ? -MP_EIO : -MP_ENOTCONN; +} + +// Reads from the ESP32. Large reads go straight into buf; small ones fill the +// RX buffer first. +static int try_recv(socketpool_socket_obj_t *self, uint8_t *buf, uint32_t len) { + if (self->rx_len > 0) { + size_t n = MIN(len, self->rx_len); + memcpy(buf, self->rx + self->rx_head, n); + self->rx_head += n; + self->rx_len -= n; + return n; + } + if (!can_transact()) { + return -MP_EAGAIN; + } + // The peer may be waiting for what's buffered before it sends more. + flush(self); + uint64_t now = supervisor_ticks_ms64(); + if (now < self->next_avail_ms) { + return -MP_EAGAIN; + } + uint8_t avail_buf[2]; + size_t reply_len; + nina_result_t result = socket_command(NINA_AVAIL_DATA_TCP, self->num, avail_buf, sizeof(avail_buf), &reply_len); + if (result != NINA_OK) { + return recv_failed(self, result); + } + uint16_t avail = le16(avail_buf, reply_len); + if (avail == 0) { + self->next_avail_ms = now + AVAIL_RETRY_MS; + return common_hal_socketpool_socket_get_connected(self) ? -MP_EAGAIN : -MP_ENOTCONN; + } + bool direct = len >= NINA_SOCKET_RX_SIZE; + uint8_t *dest = direct ? buf : self->rx; + uint16_t want = MIN(avail, direct ? MIN(len, NINA_MAX_READ) : NINA_SOCKET_RX_SIZE); + uint8_t want_le[2] = { want & 0xff, want >> 8 }; + nina_param_t params[2] = { + { .buf = &self->num, .len = 1 }, + { .buf = want_le, .len = 2 }, + }; + result = command(NINA_GET_DATABUF_TCP, params, 2, true, dest, want, true, &reply_len); + if (result != NINA_OK) { + return recv_failed(self, result); + } + if (reply_len == 0) { + return -MP_EAGAIN; + } + if (direct) { + return reply_len; + } + self->rx_head = 0; + self->rx_len = reply_len; + return try_recv(self, buf, len); +} + +int socketpool_socket_recv_into(socketpool_socket_obj_t *self, const uint8_t *buf, uint32_t len) { + if (!is_open(self) || self->listening) { + return -MP_ENOTCONN; + } + if (len == 0) { + return 0; + } + uint64_t start = supervisor_ticks_ms64(); + int result; + while ((result = try_recv(self, (uint8_t *)buf, len)) == -MP_EAGAIN && keep_waiting(self, start)) { + } + return result; +} + +mp_uint_t common_hal_socketpool_socket_recv_into(socketpool_socket_obj_t *self, const uint8_t *buf, uint32_t len) { + int received = socketpool_socket_recv_into(self, buf, len); + if (received < 0) { + mp_raise_OSError(-received); + } + return received; +} + +int socketpool_socket_send(socketpool_socket_obj_t *self, const uint8_t *buf, uint32_t len) { + if (!is_open(self) || self->listening) { + return -MP_ENOTCONN; + } + size_t total = 0; + while (total < len) { + if (self->tx_len == NINA_SOCKET_TX_SIZE) { + int result = flush(self); + if (result == -MP_EAGAIN) { + // Another command is in flight (this is a background task run + // during its waits) or another device holds the bus. Waiting + // here would never end, so the rest is dropped. + return len; + } + if (result == -MP_ENOTCONN) { + return result; + } + if (self->tx_len == NINA_SOCKET_TX_SIZE) { + // The ESP32 has no room yet. The caller tries again. + return total > 0 ? (int)total : -MP_EAGAIN; + } + } + size_t n = MIN(len - total, NINA_SOCKET_TX_SIZE - self->tx_len); + memcpy(self->tx + self->tx_len, buf + total, n); + self->tx_len += n; + total += n; + } + return len; +} + +mp_uint_t common_hal_socketpool_socket_send(socketpool_socket_obj_t *self, const uint8_t *buf, uint32_t len) { + int sent = socketpool_socket_send(self, buf, len); + if (sent < 0) { + mp_raise_OSError(-sent); + } + return sent; +} + +mp_uint_t common_hal_socketpool_socket_sendto(socketpool_socket_obj_t *self, + const char *host, size_t hostlen, uint32_t port, const uint8_t *buf, uint32_t len) { + mp_raise_NotImplementedError(NULL); +} + +void common_hal_socketpool_socket_settimeout(socketpool_socket_obj_t *self, uint32_t timeout_ms) { + self->timeout_ms = timeout_ms; +} + +int common_hal_socketpool_socket_setsockopt(socketpool_socket_obj_t *self, int level, int optname, const void *value, size_t optlen) { + if (level != SOCKETPOOL_IPPROTO_TCP || optname != SOCKETPOOL_TCP_NODELAY || optlen != sizeof(int)) { + return -MP_EOPNOTSUPP; + } + // Accepted, but nothing is sent right away: the buffer goes out before + // the next read and at the end of the background pass. + return 0; +} + +bool common_hal_socketpool_readable(socketpool_socket_obj_t *self) { + if (!is_open(self) || self->listening) { + return false; + } + if (self->rx_len > 0) { + return true; + } + flush(self); + uint8_t buf[2]; + size_t len; + return can_transact() && + socket_command(NINA_AVAIL_DATA_TCP, self->num, buf, sizeof(buf), &len) == NINA_OK && + le16(buf, len) > 0; +} + +bool common_hal_socketpool_writable(socketpool_socket_obj_t *self) { + return common_hal_socketpool_socket_get_connected(self); +} + +void socketpool_socket_move(socketpool_socket_obj_t *self, socketpool_socket_obj_t *sock) { + *sock = *self; + if (sock->num < CIRCUITPY_NINA_MAX_SOCKETS && by_num[sock->num] == self) { + by_num[sock->num] = sock; + } + self->num = NINA_NO_SOCKET; + self->tx_len = 0; + self->rx_len = 0; +} + +void socketpool_socket_reset(socketpool_socket_obj_t *self) { + if (self->base.type == &socketpool_socket_type) { + return; + } + self->base.type = &socketpool_socket_type; + self->num = NINA_NO_SOCKET; + self->listening = false; + self->connected = false; + self->connected_valid = false; + self->rx_len = 0; + self->tx_len = 0; +} + +#endif // CIRCUITPY_SOCKETPOOL diff --git a/shared-module/_nina/socketpool.h b/shared-module/_nina/socketpool.h new file mode 100644 index 00000000000..f7d0270dc02 --- /dev/null +++ b/shared-module/_nina/socketpool.h @@ -0,0 +1,74 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +// socketpool common-hal on an ESP32 co-processor running nina-fw. Ports include +// this from common-hal/socketpool/{Socket,SocketPool}.h when CIRCUITPY_NINA is on. +// Only TCP is supported, and only the core uses it for now (web workflow): +// SocketPool() accepts only nina_socketpool_interface, which Python can't reach. + +#include +#include + +#include "py/obj.h" +#include "shared-module/_nina/transport.h" + +// Bytes read from the ESP32 at a time. Web workflow reads headers a byte at a time. +#ifndef NINA_SOCKET_RX_SIZE +#define NINA_SOCKET_RX_SIZE (512) +#endif +// Small sends are collected here and sent together when full, before a read, +// on close, and at the end of each web workflow background pass. +#ifndef NINA_SOCKET_TX_SIZE +#define NINA_SOCKET_TX_SIZE (2048) +#endif + +typedef struct { + mp_obj_base_t base; +} socketpool_socketpool_obj_t; + +typedef struct { + mp_obj_base_t base; + socketpool_socketpool_obj_t *pool; + // When `connected` was last read from the ESP32. + uint64_t connected_ms; + // Don't ask the ESP32 for data again before this, after it last had none. + uint64_t next_avail_ms; + // nina_transport_generation() when the socket was opened. After a hard + // reset the socket no longer exists. + uint32_t generation; + uint32_t timeout_ms; + uint16_t port; + uint16_t rx_head; + uint16_t rx_len; + uint16_t tx_len; + // The ESP32's socket number, or NINA_NO_SOCKET when closed. + uint8_t num; + uint8_t type; + bool listening; + bool connected; + bool connected_valid; + uint8_t rx[NINA_SOCKET_RX_SIZE]; + uint8_t tx[NINA_SOCKET_TX_SIZE]; +} socketpool_socket_obj_t; + +// The network interface object the core passes to SocketPool's constructor. +extern const mp_obj_base_t nina_socketpool_interface; + +// NINA has no callbacks, so web workflow is polled: this schedules its next +// background pass, sooner while a client is connected. +void nina_socketpool_poll_resume(void); +#define socketpool_socket_poll_resume() nina_socketpool_poll_resume() +// Called from supervisor_tick() every millisecond. +void nina_socketpool_tick(void); +// Called at the start of each web workflow background pass. Refreshes the WiFi +// status, sends what's waiting in the TX buffers, and returns false if the ESP32 +// can't be used right now, so the pass should be skipped. +bool nina_socketpool_background(void); +// Called at the end of each web workflow background pass. Sends what's waiting +// in the TX buffers. +void nina_socketpool_background_done(void); diff --git a/shared-module/_nina/transport.c b/shared-module/_nina/transport.c new file mode 100644 index 00000000000..02e36d5dd9c --- /dev/null +++ b/shared-module/_nina/transport.c @@ -0,0 +1,647 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared-module/_nina/transport.h" + +#include "py/mpconfig.h" +#include "py/obj.h" +#include "shared/runtime/interrupt_char.h" +#include "shared-bindings/board/__init__.h" +#include "shared-bindings/digitalio/DigitalInOut.h" +#include "shared-bindings/microcontroller/Pin.h" +#include "supervisor/shared/tick.h" + +#if CIRCUITPY_TINYUSB +#include "supervisor/usb.h" +#endif + +#ifndef CIRCUITPY_NINA_SPI_INDEX +#define CIRCUITPY_NINA_SPI_INDEX (0) +#endif + +#define START_CMD (0xE0) +#define END_CMD (0xEE) +#define ERR_CMD (0xEF) +#define REPLY_FLAG (0x80) + +// The same values as ESP32SPI. +#define SPI_BAUDRATE (8000000) +#define READY_TIMEOUT_MS (10000) +#define SELECT_TIMEOUT_MS (1000) +#define START_TRIES (10) +#define START_RETRY_MS (10) + +// How long a VM caller waits for someone else to unlock the SPI bus. +#define BUS_LOCK_TIMEOUT_MS (1000) +// How long a VM GET_SOCKET holds off the core from taking a socket. +#define VM_ALLOCATING_TIMEOUT_MS (1000) +// How many core commands in a row may get a garbled reply before the ESP is +// reset. +#define CORE_FAILURES_BEFORE_RESET (3) +// How many times a command is sent when the ESP says it arrived garbled. +#define COMMAND_TRIES (2) + +static busio_spi_obj_t *spi; +static digitalio_digitalinout_obj_t cs; +static digitalio_digitalinout_obj_t ready; +static digitalio_digitalinout_obj_t reset; +#ifdef CIRCUITPY_NINA_GPIO0 +static digitalio_digitalinout_obj_t gpio0; +#endif +static bool active; +static volatile bool busy; +static uint32_t generation; +// Sockets that VM code started a server on since the last hard reset. +static uint8_t vm_listening; +// Sockets the core has open. nina_user_reset() leaves these alone. +static uint8_t core_owned; +// When VM code last did GET_SOCKET without using the number yet. +static bool vm_allocating; +static uint64_t vm_allocating_ms; +static uint32_t vm_network_commands; +static uint8_t core_failures; + +// Commands are built here and sent with one SPI write, as ESP32SPI does. +static uint8_t tx_buf[NINA_MAX_TRANSFER]; + +// Web workflow sends its commands from a background task, where +// RUN_BACKGROUND_TASKS does nothing because background tasks don't nest. Run +// USB directly too, so the host doesn't see the board stop answering while the +// ESP takes up to READY_TIMEOUT_MS to reply. +static void run_background_tasks(void) { + RUN_BACKGROUND_TASKS; + #if CIRCUITPY_TINYUSB + usb_background(); + #endif +} + +// Waits without checking for Ctrl-C, running background tasks. +static void pause_ms(uint32_t ms) { + uint64_t start = supervisor_ticks_ms64(); + while (supervisor_ticks_ms64() - start < ms) { + run_background_tasks(); + } +} + +// Waits for the ESP's BUSY/READY line to read `level`. Background tasks run at +// most once per millisecond, so a quick handshake isn't slowed down by them. +static bool wait_ready(bool level, uint32_t timeout_ms, bool interruptible, bool *interrupted) { + uint64_t start = supervisor_ticks_ms64(); + uint64_t last = start; + while (common_hal_digitalio_digitalinout_get_value(&ready) != level) { + uint64_t now = supervisor_ticks_ms64(); + if (now - start >= timeout_ms) { + return false; + } + if (now != last) { + last = now; + if (interruptible && mp_hal_is_interrupted()) { + *interrupted = true; + return false; + } + run_background_tasks(); + } + } + return true; +} + +static nina_result_t lock_bus(bool wait, bool interruptible) { + if (common_hal_busio_spi_deinited(spi)) { + return NINA_NOT_ACTIVE; + } + uint64_t start = supervisor_ticks_ms64(); + while (!common_hal_busio_spi_try_lock(spi)) { + if (!wait || supervisor_ticks_ms64() - start >= BUS_LOCK_TIMEOUT_MS) { + return NINA_BUSY; + } + if (interruptible && mp_hal_is_interrupted()) { + return NINA_INTERRUPTED; + } + run_background_tasks(); + } + // Other devices on the bus may have changed its settings. + common_hal_busio_spi_configure(spi, SPI_BAUDRATE, 0, 0, 8); + common_hal_digitalio_digitalinout_set_value(&cs, false); + return NINA_OK; +} + +static void unlock_bus(void) { + common_hal_digitalio_digitalinout_set_value(&cs, true); + common_hal_busio_spi_unlock(spi); +} + +static uint8_t read_byte(void) { + uint8_t b = 0; + common_hal_busio_spi_read(spi, &b, 1, 0); + return b; +} + +static bool check_byte(uint8_t expected) { + return read_byte() == expected; +} + +static void discard(size_t len) { + uint8_t scratch[32]; + while (len > 0) { + size_t n = MIN(len, sizeof(scratch)); + common_hal_busio_spi_read(spi, scratch, n, 0); + len -= n; + } +} + +// Which parameter holds the socket number, or -1 for commands without one. +static int socket_param(uint8_t cmd, size_t n_params) { + switch (cmd) { + case 0x28: // START_SERVER_TCP: [ip,] port, socket, mode + case 0x2D: // START_CLIENT_TCP: [host,] ip, port, socket, mode + return n_params >= 2 ? (int)n_params - 2 : -1; + case 0x29: // GET_STATE_TCP + case 0x2A: // DATA_SENT_TCP + case 0x2B: // AVAIL_DATA_TCP + case 0x2C: // GET_DATA_TCP + case 0x2E: // STOP_CLIENT_TCP + case 0x2F: // GET_CLIENT_STATE_TCP + case 0x39: // SEND_UDP_DATA + case 0x3A: // GET_REMOTE_DATA + case 0x44: // SEND_DATA_TCP + case 0x45: // GET_DATABUF_TCP + case 0x46: // INSERT_DATABUF_TCP + return 0; + default: + return -1; + } +} + +// Checks the command and builds it in tx_buf. Returns its padded length in *len. +static nina_result_t build_command(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, size_t *len) { + int sock = socket_param(cmd, n_params); + if (sock >= 0 && (params[sock].len < 1 || params[sock].buf[0] >= CIRCUITPY_NINA_MAX_SOCKETS)) { + return NINA_BAD_SOCKET; + } + if (cmd == NINA_GET_DATABUF_TCP && n_params >= 2 && params[1].len == 2 && + (params[1].buf[0] | (params[1].buf[1] << 8)) > NINA_MAX_READ) { + return NINA_TOO_BIG; + } + if (n_params > 255) { + return NINA_TOO_BIG; + } + // START, cmd, count, the params, END. + size_t packet_len = 4; + for (size_t i = 0; i < n_params; i++) { + if (!param_len_16 && params[i].len > 255) { + return NINA_TOO_BIG; + } + packet_len += params[i].len + (param_len_16 ? 2 : 1); + } + if (packet_len > NINA_MAX_TRANSFER) { + return NINA_TOO_BIG; + } + uint8_t *p = tx_buf; + *p++ = START_CMD; + *p++ = cmd & ~REPLY_FLAG; + *p++ = n_params; + for (size_t i = 0; i < n_params; i++) { + if (param_len_16) { + *p++ = params[i].len >> 8; + } + *p++ = params[i].len & 0xff; + memcpy(p, params[i].buf, params[i].len); + p += params[i].len; + } + *p++ = END_CMD; + // Pad to a multiple of 4. NINA_MAX_TRANSFER is one, so this still fits. + while ((p - tx_buf) % 4 != 0) { + *p++ = 0; + } + *len = p - tx_buf; + return NINA_OK; +} + +static nina_result_t send_command(size_t len, bool from_vm) { + bool interrupted = false; + if (!wait_ready(false, READY_TIMEOUT_MS, from_vm, &interrupted)) { + return interrupted ? NINA_INTERRUPTED : NINA_TIMEOUT_READY; + } + nina_result_t result = lock_bus(from_vm, from_vm); + if (result != NINA_OK) { + return result; + } + if (!wait_ready(true, SELECT_TIMEOUT_MS, false, NULL)) { + unlock_bus(); + return NINA_TIMEOUT_SELECT; + } + common_hal_busio_spi_write(spi, tx_buf, len); + unlock_bus(); + return NINA_OK; +} + +static nina_result_t read_reply(uint8_t cmd, nina_reply_t *reply, int expected_replies, + bool reply_len_16) { + // Like ESP32SPI's _wait_spi_char(): a few tries for START_CMD. + for (int tries = 1;; tries++) { + uint8_t b = read_byte(); + if (b == ERR_CMD) { + return NINA_ERR_RESPONSE; + } + if (b == START_CMD) { + break; + } + if (tries == START_TRIES) { + return NINA_TIMEOUT_START; + } + pause_ms(START_RETRY_MS); + } + if (!check_byte(cmd | REPLY_FLAG)) { + return NINA_BAD_REPLY; + } + size_t count; + if (expected_replies >= 0) { + if (!check_byte(expected_replies)) { + return NINA_BAD_REPLY; + } + count = expected_replies; + } else { + count = read_byte(); + } + bool too_big = false; + size_t used = 0; + reply->count = 0; + for (size_t i = 0; i < count; i++) { + size_t len = read_byte(); + if (reply_len_16) { + len = (len << 8) | read_byte(); + } + // Keep reading a reply that doesn't fit, so the framing stays in step. + if (too_big || i >= reply->max_count || used + len > reply->buf_len) { + too_big = true; + discard(len); + continue; + } + if (len > 0) { + common_hal_busio_spi_read(spi, reply->buf + used, len, 0); + } + reply->lens[i] = len; + used += len; + reply->count++; + } + if (!check_byte(END_CMD)) { + return NINA_BAD_REPLY; + } + return too_big ? NINA_REPLY_TOO_BIG : NINA_OK; +} + +static nina_result_t receive_reply(uint8_t cmd, nina_reply_t *reply, int expected_replies, + bool reply_len_16) { + // The command has been sent, so the reply is read even after Ctrl-C. + if (!wait_ready(false, READY_TIMEOUT_MS, false, NULL)) { + return NINA_TIMEOUT_READY; + } + // Wait for the bus even in the background: the ESP has a reply queued. + nina_result_t result = lock_bus(true, false); + if (result == NINA_BUSY) { + // The reply can't be read, so the ESP is out of step. Start it over. + nina_transport_reset(); + } + if (result != NINA_OK) { + return result; + } + if (!wait_ready(true, SELECT_TIMEOUT_MS, false, NULL)) { + unlock_bus(); + return NINA_TIMEOUT_SELECT; + } + result = read_reply(cmd, reply, expected_replies, reply_len_16); + unlock_bus(); + return result; +} + +// Answers a VM command about one of the core's sockets without sending it. +// User code can hold a stale number after nina-fw freed it and gave it to the +// core, and stopping or using it would break the core's connection. To user +// code the socket looks closed. Returns false for commands that can be sent. +static bool guard_core_socket(uint8_t cmd, const nina_param_t *params, size_t n_params, + nina_reply_t *reply, nina_result_t *result) { + int sock = socket_param(cmd, n_params); + if (sock < 0 || params[sock].len < 1 || params[sock].buf[0] >= CIRCUITPY_NINA_MAX_SOCKETS || + !(core_owned & (1 << params[sock].buf[0]))) { + return false; + } + uint8_t value; + switch (cmd) { + case NINA_STOP_CLIENT_TCP: + value = 1; // Stopped. + break; + case NINA_GET_CLIENT_STATE_TCP: + value = 0; // CLOSED. + break; + default: + *result = NINA_BAD_SOCKET; + return true; + } + if (reply->max_count < 1 || reply->buf_len < 1) { + *result = NINA_REPLY_TOO_BIG; + return true; + } + reply->buf[0] = value; + reply->lens[0] = 1; + reply->count = 1; + *result = NINA_OK; + return true; +} + +// The core can't raise, so nothing above it notices an ESP that has stopped +// answering or lost step, and every later command fails the same way. A hard +// reset closes the core's sockets, which web workflow then reopens. None of +// the commands the core sends gets ERR_CMD from a working ESP. +static void check_core_result(nina_result_t result) { + switch (result) { + case NINA_OK: + case NINA_REPLY_TOO_BIG: + core_failures = 0; + return; + case NINA_TIMEOUT_READY: + case NINA_TIMEOUT_SELECT: + // It didn't answer for 1-10 s. + core_failures = CORE_FAILURES_BEFORE_RESET; + break; + case NINA_TIMEOUT_START: + case NINA_ERR_RESPONSE: + case NINA_BAD_REPLY: + core_failures++; + break; + default: + // Nothing was sent. + return; + } + if (core_failures >= CORE_FAILURES_BEFORE_RESET) { + nina_transport_reset(); + } +} + +nina_result_t nina_transact(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, nina_reply_t *reply, int expected_replies, bool reply_len_16, + bool from_vm) { + if (!active) { + return NINA_NOT_ACTIVE; + } + if (busy) { + return NINA_BUSY; + } + size_t len; + nina_result_t result = build_command(cmd, params, n_params, param_len_16, &len); + if (result != NINA_OK) { + return result; + } + if (from_vm && guard_core_socket(cmd, params, n_params, reply, &result)) { + return result; + } + busy = true; + for (int tries = 0; tries < COMMAND_TRIES; tries++) { + result = send_command(len, from_vm); + if (result == NINA_OK) { + result = receive_reply(cmd, reply, expected_replies, reply_len_16); + } + // nina-fw replies ERR_CMD before running anything when the command + // arrived garbled (seen about once in 15000 commands under load), so it is safe to send + // again. tx_buf still holds it. + if (result != NINA_ERR_RESPONSE) { + break; + } + } + busy = false; + if (!from_vm) { + check_core_result(result); + } + if (from_vm && result == NINA_OK) { + switch (cmd) { + case NINA_GET_SOCKET: + vm_allocating = true; + vm_allocating_ms = supervisor_ticks_ms64(); + break; + case NINA_START_SERVER_TCP: + // build_command() checked the socket number, so it is in range here. + if (n_params >= 2) { + vm_listening |= 1 << params[n_params - 2].buf[0]; + } + MP_FALLTHROUGH; + case NINA_START_CLIENT_TCP: + case NINA_STOP_CLIENT_TCP: + vm_allocating = false; + break; + case NINA_SET_NET: + case NINA_SET_PASSPHRASE: + case NINA_SET_AP_NET: + case NINA_SET_AP_PASSPHRASE: + case NINA_DISCONNECT: + case NINA_SET_ENT_ENABLE: + vm_network_commands++; + break; + default: + break; + } + } + return result; +} + +uint32_t nina_transport_vm_network_commands(void) { + return vm_network_commands; +} + +bool nina_transport_vm_allocating(void) { + if (vm_allocating && supervisor_ticks_ms64() - vm_allocating_ms >= VM_ALLOCATING_TIMEOUT_MS) { + vm_allocating = false; + } + return vm_allocating; +} + +void nina_transport_set_owned(uint8_t sock, bool owned) { + if (sock >= CIRCUITPY_NINA_MAX_SOCKETS) { + return; + } + if (owned) { + core_owned |= 1 << sock; + } else { + core_owned &= ~(1 << sock); + } +} + +void nina_transport_leaked_listener(uint8_t sock) { + if (sock < CIRCUITPY_NINA_MAX_SOCKETS) { + vm_listening |= 1 << sock; + } +} + +static bool claim_esp_pin(digitalio_digitalinout_obj_t *dio, const mcu_pin_obj_t *pin) { + dio->base.type = &digitalio_digitalinout_type; + if (common_hal_digitalio_digitalinout_construct(dio, pin) != DIGITALINOUT_OK) { + return false; + } + common_hal_digitalio_digitalinout_never_reset(dio); + return true; +} + +typedef struct { + const mcu_pin_obj_t *clock; + const mcu_pin_obj_t *mosi; + const mcu_pin_obj_t *miso; +} spi_pins_t; + +bool nina_transport_init(void) { + if (active) { + return true; + } + // board.SPI() may already exist; then the ESP shares it with user code. + mp_obj_t board_spi = common_hal_board_get_spi(CIRCUITPY_NINA_SPI_INDEX); + if (board_spi == NULL || common_hal_busio_spi_deinited(MP_OBJ_TO_PTR(board_spi))) { + static const spi_pins_t spi_pins[] = CIRCUITPY_BOARD_SPI_PIN; + const spi_pins_t *p = &spi_pins[CIRCUITPY_NINA_SPI_INDEX]; + if (!common_hal_mcu_pin_is_free(p->clock) || !common_hal_mcu_pin_is_free(p->mosi) || + !common_hal_mcu_pin_is_free(p->miso)) { + return false; + } + } + if (!common_hal_mcu_pin_is_free(CIRCUITPY_NINA_CS) || + !common_hal_mcu_pin_is_free(CIRCUITPY_NINA_BUSY) || + !common_hal_mcu_pin_is_free(CIRCUITPY_NINA_RESET)) { + return false; + } + #ifdef CIRCUITPY_NINA_GPIO0 + if (!common_hal_mcu_pin_is_free(CIRCUITPY_NINA_GPIO0)) { + return false; + } + #endif + + spi = MP_OBJ_TO_PTR(common_hal_board_create_spi(CIRCUITPY_NINA_SPI_INDEX)); + common_hal_busio_spi_never_reset(spi); + claim_esp_pin(&cs, CIRCUITPY_NINA_CS); + common_hal_digitalio_digitalinout_switch_to_output(&cs, true, DRIVE_MODE_PUSH_PULL); + claim_esp_pin(&ready, CIRCUITPY_NINA_BUSY); + claim_esp_pin(&reset, CIRCUITPY_NINA_RESET); + common_hal_digitalio_digitalinout_switch_to_output(&reset, true, DRIVE_MODE_PUSH_PULL); + #ifdef CIRCUITPY_NINA_GPIO0 + claim_esp_pin(&gpio0, CIRCUITPY_NINA_GPIO0); + #endif + active = true; + nina_transport_reset(); + return true; +} + +void nina_transport_deinit(void) { + if (!active || busy) { + return; + } + active = false; + common_hal_digitalio_digitalinout_deinit(&cs); + common_hal_digitalio_digitalinout_deinit(&ready); + common_hal_digitalio_digitalinout_deinit(&reset); + #ifdef CIRCUITPY_NINA_GPIO0 + common_hal_digitalio_digitalinout_deinit(&gpio0); + #endif + // The bus stays as board.SPI(). It is released at the next soft reset, + // because nina_transport_using_spi() no longer claims it. + spi = NULL; + // The core's sockets can't be used any more. + generation++; + core_owned = 0; +} + +void nina_transport_reset(void) { + if (!active) { + return; + } + // A command started from a background task during the reset would see an ESP + // that isn't running yet. + bool was_busy = busy; + busy = true; + #ifdef CIRCUITPY_NINA_GPIO0 + // High selects a normal boot rather than the bootloader. + common_hal_digitalio_digitalinout_switch_to_output(&gpio0, true, DRIVE_MODE_PUSH_PULL); + #endif + common_hal_digitalio_digitalinout_set_value(&cs, true); + common_hal_digitalio_digitalinout_set_value(&reset, false); + pause_ms(10); + common_hal_digitalio_digitalinout_set_value(&reset, true); + pause_ms(750); + #ifdef CIRCUITPY_NINA_GPIO0 + common_hal_digitalio_digitalinout_switch_to_input(&gpio0, PULL_NONE); + #endif + generation++; + vm_listening = 0; + core_owned = 0; + vm_allocating = false; + core_failures = 0; + busy = was_busy; +} + +void nina_user_reset(void) { + if (!active) { + return; + } + // User code may have exited holding the bus lock. + common_hal_busio_spi_unlock(spi); + vm_allocating = false; + if (vm_listening != 0) { + nina_transport_reset(); + return; + } + for (uint8_t sock = 0; sock < CIRCUITPY_NINA_MAX_SOCKETS; sock++) { + if (core_owned & (1 << sock)) { + continue; + } + nina_param_t param = { .buf = &sock, .len = 1 }; + uint8_t buf[1]; + uint16_t lens[1]; + nina_reply_t reply = { .buf = buf, .buf_len = sizeof(buf), .lens = lens, .max_count = 1 }; + if (nina_transact(NINA_STOP_CLIENT_TCP, ¶m, 1, false, &reply, 1, false, false) != NINA_OK) { + // Don't wait out a timeout for every socket. A reset closes them all. + nina_transport_reset(); + return; + } + } +} + +bool nina_transport_active(void) { + return active; +} + +bool nina_transport_busy(void) { + return busy; +} + +bool nina_transport_bus_free(void) { + if (!active || busy || common_hal_busio_spi_deinited(spi) || !common_hal_busio_spi_try_lock(spi)) { + return false; + } + common_hal_busio_spi_unlock(spi); + return true; +} + +bool nina_transport_has_data_ready(void) { + #ifdef CIRCUITPY_NINA_GPIO0 + return active; + #else + return false; + #endif +} + +bool nina_transport_data_ready(void) { + #ifdef CIRCUITPY_NINA_GPIO0 + // During a reset GPIO0 is an output, held high. + return !active || common_hal_digitalio_digitalinout_get_value(&gpio0); + #else + return true; + #endif +} + +uint32_t nina_transport_generation(void) { + return generation; +} + +bool nina_transport_using_spi(const busio_spi_obj_t *bus) { + return active && bus == spi; +} diff --git a/shared-module/_nina/transport.h b/shared-module/_nina/transport.h new file mode 100644 index 00000000000..c4a55878b14 --- /dev/null +++ b/shared-module/_nina/transport.h @@ -0,0 +1,140 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +// Driver for an ESP32 co-processor running nina-fw, spoken to over SPI. The +// framing matches ESP32SPI's _send_command() and _wait_response_cmd(). Only one +// command is in flight at a time; the board SPI lock is held only during each +// half of a command, so other devices on the bus can use it in between. + +#include +#include +#include + +#include "shared-bindings/busio/SPI.h" + +// Number of sockets in nina-fw (CONFIG_LWIP_MAX_SOCKETS). A command naming a +// higher socket hangs nina-fw 3.1.0 until a hard reset. +#ifndef CIRCUITPY_NINA_MAX_SOCKETS +#define CIRCUITPY_NINA_MAX_SOCKETS (6) +#endif + +// The ESP32 receives and sends at most this many bytes per SPI transfer. +// Longer commands are silently truncated, and longer replies hang it. +#define NINA_MAX_TRANSFER (4092) +// GET_DATABUF_TCP reply: START, cmd, 1 param, 16-bit length, data, END. +#define NINA_MAX_READ (NINA_MAX_TRANSFER - 6) +// SEND_DATA_TCP command: START, cmd, 2 params, 16-bit length + socket, +// 16-bit length + data, END. +#define NINA_MAX_SEND (NINA_MAX_TRANSFER - 9) + +#define NINA_SET_NET (0x10) +#define NINA_SET_PASSPHRASE (0x11) +#define NINA_SET_AP_NET (0x18) +#define NINA_SET_AP_PASSPHRASE (0x19) +#define NINA_GET_CONN_STATUS (0x20) +#define NINA_GET_IPADDR (0x21) +#define NINA_GET_CURR_SSID (0x23) +#define NINA_START_SERVER_TCP (0x28) +#define NINA_AVAIL_DATA_TCP (0x2B) +#define NINA_START_CLIENT_TCP (0x2D) +#define NINA_STOP_CLIENT_TCP (0x2E) +#define NINA_GET_CLIENT_STATE_TCP (0x2F) +#define NINA_DISCONNECT (0x30) +#define NINA_GET_SOCKET (0x3F) +#define NINA_SEND_DATA_TCP (0x44) +#define NINA_GET_DATABUF_TCP (0x45) +#define NINA_SET_ENT_ENABLE (0x4F) + +// The socket number GET_SOCKET returns when all are in use, and AVAIL_DATA_TCP +// returns for a listening socket with nobody waiting. +#define NINA_NO_SOCKET (255) + +typedef struct { + const uint8_t *buf; + size_t len; +} nina_param_t; + +typedef struct { + uint8_t *buf; // The reply parameters, back to back. + size_t buf_len; + uint16_t *lens; // Length of each reply parameter. + size_t max_count; + size_t count; // Set by nina_transact(). +} nina_reply_t; + +typedef enum { + NINA_OK, + NINA_NOT_ACTIVE, + // Another command is in flight, or the SPI bus is locked by someone else. + NINA_BUSY, + // A VM caller was interrupted before anything was sent. + NINA_INTERRUPTED, + // The command was rejected before sending it. + NINA_BAD_SOCKET, + NINA_TOO_BIG, + // BUSY never went low. + NINA_TIMEOUT_READY, + // BUSY never went high after CS went low. + NINA_TIMEOUT_SELECT, + // No START_CMD at the start of the reply. + NINA_TIMEOUT_START, + // The ESP32 replied ERR_CMD. + NINA_ERR_RESPONSE, + // A framing byte was wrong. + NINA_BAD_REPLY, + // The reply didn't fit in the nina_reply_t. It was read and dropped. + NINA_REPLY_TOO_BIG, +} nina_result_t; + +// Claims the ESP pins and the board SPI bus, then hard-resets the ESP32. +// Returns false if a pin or the bus is already in use. Never raises. +bool nina_transport_init(void); +// Releases the pins. The board SPI bus is left to board.SPI(). +void nina_transport_deinit(void); +// Hard reset through ESP_RESET. Takes about 760 ms. Changes the generation. +void nina_transport_reset(void); +bool nina_transport_active(void); +// True from the start of a command's send to the end of its reply. +bool nina_transport_busy(void); +// False while someone else holds the SPI bus lock. Background callers don't +// wait for it. +bool nina_transport_bus_free(void); +// Changes on every hard reset, so sockets can tell they no longer exist. +uint32_t nina_transport_generation(void); +// Whether the board connects GPIO0, which nina-fw drives high while a socket +// has data to read or a listener has a client waiting. +bool nina_transport_has_data_ready(void); +// The level of that signal; true when the board doesn't connect it. Safe to +// call from an interrupt. +bool nina_transport_data_ready(void); +bool nina_transport_using_spi(const busio_spi_obj_t *spi); +// At the end of a VM run, closes the sockets user code left open. nina-fw can't +// close a listening socket, so if user code started a server, the ESP32 is +// hard-reset instead. +void nina_user_reset(void); + +// Marks a socket number as the core's, so nina_user_reset() leaves it open. A +// hard reset clears them all. +void nina_transport_set_owned(uint8_t sock, bool owned); +// Records that the core left a listener running on this socket. nina-fw can't +// stop it, so the ESP32 is hard-reset at the end of the VM run. +void nina_transport_leaked_listener(uint8_t sock); +// True while VM code has done GET_SOCKET but not yet used the number. GET_SOCKET +// doesn't reserve it, so the core must not take a socket in the meantime. +bool nina_transport_vm_allocating(void); +// Counts the WiFi join and disconnect commands VM code has sent, so the core +// can tell whether user code is managing the connection itself. +uint32_t nina_transport_vm_network_commands(void); + +// Sends one command and reads its reply. expected_replies < 0 means take the +// count from the reply. from_vm callers wait for a locked bus and can be +// interrupted, but only before anything is sent. When the ESP32 stops answering +// the core's commands, or garbles three replies in a row, it is hard-reset. +nina_result_t nina_transact(uint8_t cmd, const nina_param_t *params, size_t n_params, + bool param_len_16, nina_reply_t *reply, int expected_replies, bool reply_len_16, + bool from_vm); diff --git a/supervisor/shared/tick.c b/supervisor/shared/tick.c index 81815c0813b..1633c9bc46f 100644 --- a/supervisor/shared/tick.c +++ b/supervisor/shared/tick.c @@ -31,6 +31,10 @@ #include "shared-module/audiofilewriter/AudioFileWriter.h" #endif +#if CIRCUITPY_NINA && CIRCUITPY_SOCKETPOOL +#include "shared-module/_nina/socketpool.h" +#endif + #include "shared-bindings/microcontroller/__init__.h" #if CIRCUITPY_WATCHDOG @@ -90,6 +94,10 @@ void supervisor_tick(void) { keypad_tick(); #endif + #if CIRCUITPY_NINA && CIRCUITPY_SOCKETPOOL + nina_socketpool_tick(); + #endif + background_callback_add(&tick_callback, supervisor_background_tick, NULL); } diff --git a/supervisor/shared/web_workflow/web_workflow.c b/supervisor/shared/web_workflow/web_workflow.c index c57d0e44975..b5ec48e8b28 100644 --- a/supervisor/shared/web_workflow/web_workflow.c +++ b/supervisor/shared/web_workflow/web_workflow.c @@ -7,9 +7,9 @@ // Include strchrnul() #define _GNU_SOURCE +#include #include #include -#include #include #include "extmod/vfs.h" @@ -19,7 +19,6 @@ #include "py/mpstate.h" #include "py/persistentcode.h" -#include "shared-bindings/wifi/Radio.h" #include "supervisor/filesystem.h" #include "supervisor/port.h" #include "supervisor/shared/reload.h" @@ -50,6 +49,7 @@ #if CIRCUITPY_WIFI #include "common-hal/wifi/__init__.h" #include "shared-bindings/wifi/__init__.h" +#include "shared-bindings/wifi/Radio.h" #endif #if CIRCUITPY_MII @@ -57,6 +57,12 @@ #include "shared-bindings/mii/Ethernet.h" #endif +#if CIRCUITPY_NINA +#include "shared-module/_nina/network.h" +#include "shared-module/_nina/transport.h" +#include "supervisor/shared/tick.h" +#endif + #if CIRCUITPY_SETTINGS_TOML #include "supervisor/shared/settings.h" #endif @@ -90,7 +96,6 @@ typedef struct { bool json; bool websocket; bool new_socket; - uint32_t websocket_version; // RFC6455 for websockets says this header should be 24 base64 characters long. char websocket_key[24 + 1]; } _request; @@ -99,7 +104,7 @@ typedef struct { static wifi_radio_error_t _wifi_status = WIFI_RADIO_ERROR_NONE; #endif -#if CIRCUITPY_STATUS_BAR && (CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII) +#if CIRCUITPY_STATUS_BAR && (CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII || CIRCUITPY_NINA) // Store various last states to compute if status bar needs an update. static bool _last_enabled = false; static uint32_t _last_ip = 0; @@ -110,6 +115,10 @@ static mp_int_t _last_web_api_port = 80; static wifi_radio_error_t _last_wifi_status = WIFI_RADIO_ERROR_NONE; #endif +#if CIRCUITPY_STATUS_BAR && CIRCUITPY_NINA +static uint8_t _last_nina_status = NINA_WL_NO_SHIELD; +#endif + #if CIRCUITPY_MDNS static mdns_server_obj_t mdns; #endif @@ -215,6 +224,13 @@ static bool _get_web_workflow_ip(uint32_t *ipv4_address) { } *ipv4_address = mii_get_ipv4_address(); return true; + #elif CIRCUITPY_NINA + // Cached, so the status bar doesn't cause SPI traffic. + if (!nina_transport_active()) { + return false; + } + *ipv4_address = nina_network_ipv4_address(); + return true; #else return false; #endif @@ -262,7 +278,7 @@ mdns_server_obj_t *supervisor_web_workflow_mdns(mp_obj_t network_interface) { #if CIRCUITPY_STATUS_BAR bool supervisor_web_workflow_status_dirty(void) { - #if CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII + #if CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII || CIRCUITPY_NINA uint32_t ipv4_address = 0; bool enabled = _get_web_workflow_ip(&ipv4_address); if (enabled != _last_enabled || ipv4_address != _last_ip || web_api_port != _last_web_api_port) { @@ -273,6 +289,11 @@ bool supervisor_web_workflow_status_dirty(void) { return true; } #endif + #if CIRCUITPY_NINA + if (_last_nina_status != nina_network_status()) { + return true; + } + #endif return false; #else return false; @@ -282,7 +303,7 @@ bool supervisor_web_workflow_status_dirty(void) { #if CIRCUITPY_STATUS_BAR void supervisor_web_workflow_status(void) { - #if CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII + #if CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII || CIRCUITPY_NINA uint32_t ipv4_address = 0; _last_enabled = _get_web_workflow_ip(&ipv4_address); _last_web_api_port = web_api_port; @@ -315,6 +336,17 @@ void supervisor_web_workflow_status(void) { #elif CIRCUITPY_MII _last_ip = 0; serial_write_compressed(MP_ERROR_TEXT("No IP")); + #elif CIRCUITPY_NINA + serial_write_compressed(MP_ERROR_TEXT("Wi-Fi: ")); + _last_nina_status = nina_network_status(); + if (!_last_enabled) { + serial_write_compressed(MP_ERROR_TEXT("off")); + } else if (_last_nina_status == NINA_WL_CONNECT_FAILED) { + serial_write_compressed(MP_ERROR_TEXT("Authentication failure")); + } else { + _last_ip = 0; + serial_write_compressed(MP_ERROR_TEXT("No IP")); + } #endif #else return; @@ -322,8 +354,65 @@ void supervisor_web_workflow_status(void) { } #endif +#if CIRCUITPY_WEB_WORKFLOW && CIRCUITPY_SETTINGS_TOML && (CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII || CIRCUITPY_NINA) +static bool _open_listening(void) { + #if CIRCUITPY_SOCKETPOOL_IPV6 + bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET6, SOCKETPOOL_SOCK_STREAM, 0, &listening); + #else + bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET, SOCKETPOOL_SOCK_STREAM, 0, &listening); + #endif + // Ports with an offloaded network stack can refuse to create a + // socket until the interface has an address (this board returns + // ENOTCONN before DHCP completes). Binding and listening on the + // unset descriptor then fails with EBADF, and reporting success + // leaves the supervisor polling a socket that was never opened. + // + // Returning false avoids that but is not a retry: the background + // callback never re-enters this function, so the next attempt is + // the next supervisor_workflow_reset(), i.e. a VM restart. A board + // that only gets an address after boot will not bring the workflow + // up on its own until then. (NINA boards do retry; see + // _nina_retry_listening().) + if (!opened) { + return false; + } + common_hal_socketpool_socket_settimeout(&listening, 0); + if (common_hal_socketpool_socket_bind(&listening, "", 0, web_api_port) != 0) { + common_hal_socketpool_socket_close(&listening); + return false; + } + if (!common_hal_socketpool_socket_listen(&listening, 1)) { + common_hal_socketpool_socket_close(&listening); + return false; + } + return true; +} +#endif + +#if CIRCUITPY_NINA +// User code may reset the ESP32 or change its network, which closes the +// listening socket, and nothing calls back when the network comes back. So the +// background pass reopens it: right away after a reset, otherwise at most every +// 5 seconds. It is never closed to reopen it; nina-fw can't stop a listener. +static void _nina_retry_listening(void) { + static uint64_t last_try_ms; + static uint32_t last_try_generation; + if (!common_hal_socketpool_socket_get_closed(&listening) || + nina_network_status() != NINA_WL_CONNECTED) { + return; + } + uint64_t now = supervisor_ticks_ms64(); + if (last_try_generation == nina_transport_generation() && now - last_try_ms < 5000) { + return; + } + last_try_ms = now; + last_try_generation = nina_transport_generation(); + _open_listening(); +} +#endif + bool supervisor_start_web_workflow(void) { - #if CIRCUITPY_WEB_WORKFLOW && CIRCUITPY_SETTINGS_TOML && (CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII) + #if CIRCUITPY_WEB_WORKFLOW && CIRCUITPY_SETTINGS_TOML && (CIRCUITPY_WIFI || CIRCUITPY_HOSTNETWORK || CIRCUITPY_MII || CIRCUITPY_NINA) #if CIRCUITPY_WIFI mp_obj_t socketpool_radio = MP_OBJ_FROM_PTR(&common_hal_wifi_radio_obj); @@ -333,6 +422,8 @@ bool supervisor_start_web_workflow(void) { // The board never registered its ethernet instance. return false; } + #elif CIRCUITPY_NINA + mp_obj_t socketpool_radio = MP_OBJ_FROM_PTR(&nina_socketpool_interface); #else mp_obj_t socketpool_radio = MP_OBJ_FROM_PTR(&common_hal_hostnetwork_obj); #endif @@ -378,6 +469,40 @@ bool supervisor_start_web_workflow(void) { } #endif + #if CIRCUITPY_NINA + char ssid[33]; + char password[64]; + + result = settings_get_str("CIRCUITPY_WIFI_SSID", ssid, sizeof(ssid)); + if (result != SETTINGS_OK || strlen(ssid) < 1) { + return false; + } + + // Without an API password there's no web workflow, so leave the ESP32 to + // user code. It is checked again below; this check must come before the + // ESP32 is touched. + result = settings_get_str("CIRCUITPY_WEB_API_PASSWORD", password, sizeof(password)); + if (result != SETTINGS_OK) { + return false; + } + + result = settings_get_str("CIRCUITPY_WIFI_PASSWORD", password, sizeof(password)); + if (result == SETTINGS_ERR_NOT_FOUND) { + // if password is unspecified, assume an open network + password[0] = '\0'; + } else if (result != SETTINGS_OK) { + return false; + } + + if (!nina_transport_init()) { + return false; + } + + // Returns early if already connected to this network, so the open + // connections survive. If it fails, the background keeps trying. + nina_network_join(ssid, password); + #endif + #if CIRCUITPY_MII && !CIRCUITPY_WIFI mii_ethernet_obj_t *eth = (mii_ethernet_obj_t *)mii_get_default_instance(); if (eth == NULL) { @@ -452,35 +577,13 @@ bool supervisor_start_web_workflow(void) { } #endif - if (common_hal_socketpool_socket_get_closed(&listening)) { - #if CIRCUITPY_SOCKETPOOL_IPV6 - bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET6, SOCKETPOOL_SOCK_STREAM, 0, &listening); + if (common_hal_socketpool_socket_get_closed(&listening) && !_open_listening()) { + #if CIRCUITPY_NINA + // Report success anyway, so the background callback is installed: + // it keeps trying (_nina_retry_listening()). #else - bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET, SOCKETPOOL_SOCK_STREAM, 0, &listening); + return false; #endif - // Ports with an offloaded network stack can refuse to create a - // socket until the interface has an address (this board returns - // ENOTCONN before DHCP completes). Binding and listening on the - // unset descriptor then fails with EBADF, and reporting success - // leaves the supervisor polling a socket that was never opened. - // - // Returning false avoids that but is not a retry: the background - // callback never re-enters this function, so the next attempt is - // the next supervisor_workflow_reset(), i.e. a VM restart. A board - // that only gets an address after boot will not bring the workflow - // up on its own until then. - if (!opened) { - return false; - } - common_hal_socketpool_socket_settimeout(&listening, 0); - if (common_hal_socketpool_socket_bind(&listening, "", 0, web_api_port) != 0) { - common_hal_socketpool_socket_close(&listening); - return false; - } - if (!common_hal_socketpool_socket_listen(&listening, 1)) { - common_hal_socketpool_socket_close(&listening); - return false; - } } // Wake polling thread (maybe) socketpool_socket_poll_resume(); @@ -1272,6 +1375,17 @@ static void _decode_percents(char *str) { } } +// Parse leading decimal digits. Overflow is not checked. +// Used instead of strtoul() and strtoull(), which would pull in libc code. +static uint64_t _parse_decimal(const char *str) { + uint64_t value = 0; + while ('0' <= *str && *str <= '9') { + value = value * 10 + (*str - '0'); + str++; + } + return value; +} + static bool _reply(socketpool_socket_obj_t *socket, _request *request) { if (request->redirect) { #if CIRCUITPY_MDNS @@ -1607,7 +1721,7 @@ static void _process_request(socketpool_socket_obj_t *socket, _request *request) strncpy(request->host, request->header_value, sizeof(request->host) - 1); request->host[sizeof(request->host) - 1] = '\0'; } else if (strcasecmp(request->header_key, "Content-Length") == 0) { - request->content_length = strtoul(request->header_value, NULL, 10); + request->content_length = _parse_decimal(request->header_value); } else if (strcasecmp(request->header_key, "Expect") == 0) { request->expect = strcmp(request->header_value, "100-continue") == 0; } else if (strcasecmp(request->header_key, "Accept") == 0) { @@ -1616,11 +1730,9 @@ static void _process_request(socketpool_socket_obj_t *socket, _request *request) strncpy(request->origin, request->header_value, sizeof(request->origin) - 1); request->origin[sizeof(request->origin) - 1] = '\0'; } else if (strcasecmp(request->header_key, "X-Timestamp") == 0) { - request->timestamp_ms = strtoull(request->header_value, NULL, 10); + request->timestamp_ms = _parse_decimal(request->header_value); } else if (strcasecmp(request->header_key, "Upgrade") == 0) { request->websocket = strcmp(request->header_value, "websocket") == 0; - } else if (strcasecmp(request->header_key, "Sec-WebSocket-Version") == 0) { - request->websocket_version = strtoul(request->header_value, NULL, 10); } else if (strcasecmp(request->header_key, "Sec-WebSocket-Key") == 0 && strlen(request->header_value) == 24) { strcpy(request->websocket_key, request->header_value); @@ -1683,6 +1795,16 @@ static bool supervisor_filesystem_access_could_block(void) { } void supervisor_web_workflow_background(void *data) { + #if CIRCUITPY_NINA + // Skip this pass while a command is in flight or the bus is in use, so + // nothing here waits for something that can't finish until it returns. + if (!nina_socketpool_background()) { + socketpool_socket_poll_resume(); + return; + } + _nina_retry_listening(); + #endif + // If "/sd" is mounted AND shared with a display, access could block. // We don't have a good way to defer a filesystem action way down inside _process_request // when this happens, so just postpone if there's a chance of blocking. (#8980) @@ -1722,11 +1844,18 @@ void supervisor_web_workflow_background(void *data) { } break; } + // Nothing to accept from, for example while the listening socket is + // closed. Without this the loop never ends. + break; } // Let the websocket code run. websocket_background(); + #if CIRCUITPY_NINA + nina_socketpool_background_done(); + #endif + // Resume polling socketpool_socket_poll_resume(); diff --git a/tests/circuitpython-manual/nina/esp_wedge.py b/tests/circuitpython-manual/nina/esp_wedge.py new file mode 100644 index 00000000000..2102500269a --- /dev/null +++ b/tests/circuitpython-manual/nina/esp_wedge.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""Board side of esp_wedge_host.py: wedge the ESP32 on purpose and keep the VM +running, so the cleanup at the end of the VM run can't fix it. The core should +notice its own commands failing, hard-reset the ESP32 and bring web workflow +back. nina-fw 3.1.0 stops answering SPI commands until a hard reset when +START_SERVER_TCP (0x28) comes before a WiFi join, which _nina.reset() undoes.""" + +import struct +import time + +import _nina + +_nina.reset() +try: + _nina.transact(0x28, (struct.pack(">H", 5000), (0,), (0,))) +except OSError as e: + print("START_SERVER_TCP before WiFi:", repr(e)) +print("WEDGED") +end = time.monotonic() + 40 +while time.monotonic() < end: + time.sleep(0.1) +# A VM command works again only if the core reset the ESP32. +try: + print("status after:", _nina.transact(0x20)[0][0]) + print("PASS") +except OSError as e: + print("still wedged:", repr(e)) diff --git a/tests/circuitpython-manual/nina/esp_wedge_host.py b/tests/circuitpython-manual/nina/esp_wedge_host.py new file mode 100644 index 00000000000..5228580637c --- /dev/null +++ b/tests/circuitpython-manual/nina/esp_wedge_host.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""Host test: web workflow recovers on its own from a wedged ESP32. Runs +esp_wedge.py on the board through run_on_board.py and, from the moment the +board prints WEDGED, polls web workflow until it answers again. +Usage: esp_wedge_host.py [runs]""" + +import os +import subprocess +import sys +import threading +import time +import urllib.request + +ip = sys.argv[1] +runs = int(sys.argv[2]) if len(sys.argv) > 2 else 1 +here = os.path.dirname(os.path.abspath(__file__)) + + +def up(): + try: + with urllib.request.urlopen("http://%s/cp/version.json" % ip, timeout=1) as r: + return r.status == 200 + except OSError: + return False + + +passed = 0 +for run in range(runs): + time.sleep(2) + print("run %d: web workflow up before: %s" % (run + 1, up())) + p = subprocess.Popen( + [ + sys.executable, + os.path.join(here, "run_on_board.py"), + os.path.join(here, "esp_wedge.py"), + "70", + ], + stdout=subprocess.PIPE, + text=True, + ) + result = {} + + def poll(t0): + while time.monotonic() - t0 < 40: + if up(): + result["back"] = time.monotonic() - t0 + return + time.sleep(0.2) + + poller = None + board_pass = False + for line in p.stdout: + line = line.rstrip() + if line: + print(" board:", line) + board_pass = board_pass or line == "PASS" + if line == "WEDGED" and poller is None: + poller = threading.Thread(target=poll, args=(time.monotonic(),)) + poller.start() + p.wait() + if poller: + poller.join() + print( + " web workflow back %s" + % ("after %.1f s" % result["back"] if "back" in result else "NOT within 40 s") + ) + passed += board_pass and "back" in result +print("%d of %d passed" % (passed, runs)) diff --git a/tests/circuitpython-manual/nina/no_web_workflow.py b/tests/circuitpython-manual/nina/no_web_workflow.py new file mode 100644 index 00000000000..e80cb7afb82 --- /dev/null +++ b/tests/circuitpython-manual/nina/no_web_workflow.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""With no CIRCUITPY_WEB_API_PASSWORD in settings.toml, the core must leave the +ESP32 alone, so ESP32SPI code that passes its own pins works as before. +Remove the password, reset the board, then run this with run_on_board.py +(or as code.py). Expected: "core owns the ESP32: False" and a firmware version.""" + +import _nina +import board +import busio +from digitalio import DigitalInOut + +from adafruit_esp32spi import adafruit_esp32spi + +print("core owns the ESP32:", _nina.active()) +esp = adafruit_esp32spi.ESP_SPIcontrol( + busio.SPI(board.SCK, board.MOSI, board.MISO), + DigitalInOut(board.ESP_CS), + DigitalInOut(board.ESP_BUSY), + DigitalInOut(board.ESP_RESET), +) +print("old-style ESP_SPIcontrol firmware:", esp.firmware_version) +print("status after its own reset:", esp.status) diff --git a/tests/circuitpython-manual/nina/readme.md b/tests/circuitpython-manual/nina/readme.md new file mode 100644 index 00000000000..bf7bcba8ef2 --- /dev/null +++ b/tests/circuitpython-manual/nina/readme.md @@ -0,0 +1,67 @@ +# Web workflow over an ESP32 co-processor (`_nina`) + +Manual tests for web workflow on boards whose WiFi is an ESP32 co-processor +running nina-fw, such as the Adafruit Fruit Jam. They run on a host computer +on the same network as the board. + +## Prerequisites + +- The board's `settings.toml` sets `CIRCUITPY_WIFI_SSID`, + `CIRCUITPY_WIFI_PASSWORD` and `CIRCUITPY_WEB_API_PASSWORD`. The host tests + read the web API password from that file, so pass its path (normally on the + mounted CIRCUITPY drive). +- Python 3 on the host. `run_on_board.py` and `esp_wedge_host.py` also need + `pyserial` and the board's USB serial port. The port defaults to the first + Fruit Jam in `/dev/serial/by-id`; set `NINA_TEST_SERIAL` to use another one. +- Find the board's IP address in the serial console's title bar, or in + `http://circuitpython.local/cp/version.json`. + +## Tests + +Each test prints what it checks and ends with `PASS` or `FAIL`. + +- `web_workflow_files_host.py [upload dir]`: lists `/`, + downloads `boot_out.txt`, and uploads, reads back and deletes a 20 KB file. + The upload dir defaults to `/saves/`, which web workflow can write to while + the host has CIRCUITPY mounted. +- `web_workflow_download_host.py [size]`: uploads one + file (100 KB by default) to `/saves/` and downloads it `n` times, checking + every byte. +- `web_workflow_serial_host.py `: drives the REPL through + the `/cp/serial/` websocket, including a soft reboot over the same + connection. +- `esp_wedge_host.py [runs]`: runs `esp_wedge.py` on the board, which + leaves the ESP32 unresponsive while the VM keeps running. Web workflow should + come back on its own, without a reset of the board. +- `no_web_workflow.py`: with `CIRCUITPY_WEB_API_PASSWORD` removed from + `settings.toml`, the core must leave the ESP32 to user code. Run it with + `run_on_board.py no_web_workflow.py`; it needs `adafruit_esp32spi` in `lib/`. + +## Expected output + +`esp_wedge_host.py`: + +``` +run 1: web workflow up before: True + board: START_SERVER_TCP before WiFi: TimeoutError('Timed out waiting for SPI char',) + board: WEDGED + board: status after: 3 + board: PASS + board: [end] + board: [end] + board: > + web workflow back after 4.2 s +1 of 1 passed +``` + +`web_workflow_serial_host.py`: + +``` +ctrl-c -> prompt: True +eval 6*7 -> 42: True 140 ms +3000-char line arrives whole: True +ctrl-d -> soft reboot seen: True +code.py ran after reload: True +same websocket at the REPL again: True +eval after reload -> 43: True +``` diff --git a/tests/circuitpython-manual/nina/run_on_board.py b/tests/circuitpython-manual/nina/run_on_board.py new file mode 100644 index 00000000000..fe234c55a24 --- /dev/null +++ b/tests/circuitpython-manual/nina/run_on_board.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT +"""Run a local .py file on the board through the raw REPL and stream its output. +Usage: run_on_board.py