diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index c03d1bd0ae4..add6c16727f 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 "" @@ -1670,10 +1671,12 @@ msgstr "" #: ports/espressif/common-hal/espidf/__init__.c #: ports/espressif/common-hal/qspibus/QSPIBus.c +#: shared-module/_airlift/__init__.c msgid "Operation timed out" msgstr "" #: ports/espressif/common-hal/espidf/__init__.c +#: shared-module/_airlift/__init__.c msgid "Received response was invalid" msgstr "" @@ -1710,8 +1713,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/_airlift/__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 +2515,7 @@ msgid "%q must be >= %d" msgstr "" #: py/argcheck.c shared-bindings/gifio/GifWriter.c -#: shared-module/gifio/OnDiskGif.c +#: shared-module/_airlift/__init__.c shared-module/gifio/OnDiskGif.c msgid "%q must be <= %d" msgstr "" @@ -2524,7 +2527,7 @@ msgstr "" msgid "%q length must be >= %d" msgstr "" -#: py/argcheck.c +#: py/argcheck.c shared-module/_airlift/__init__.c msgid "%q length must be <= %d" msgstr "" diff --git a/main.c b/main.c index d7a0d31a941..40d40ad14cd 100644 --- a/main.c +++ b/main.c @@ -105,6 +105,10 @@ #include "shared-module/memorymonitor/__init__.h" #endif +#if CIRCUITPY_AIRLIFT +#include "shared-module/_airlift/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_AIRLIFT + airlift_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..25185c23b3b 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_AIRLIFT_CS (&pin_GPIO46) +#define CIRCUITPY_AIRLIFT_BUSY (&pin_GPIO3) +#define CIRCUITPY_AIRLIFT_RESET (&pin_GPIO22) +#define CIRCUITPY_AIRLIFT_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..ee1c95d9712 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_AIRLIFT = 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..15670b7e457 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_AIRLIFT, shared-module/_airlift/socketpool.c is used instead. +#if !CIRCUITPY_AIRLIFT + #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_AIRLIFT diff --git a/ports/raspberrypi/common-hal/socketpool/Socket.h b/ports/raspberrypi/common-hal/socketpool/Socket.h index d7898777744..7af293c0cca 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_AIRLIFT +// The sockets live on the ESP32 co-processor (nina-fw). +#include "shared-module/_airlift/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..ebc12351632 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_AIRLIFT, shared-module/_airlift/socketpool.c is used instead. +#if !CIRCUITPY_AIRLIFT + #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_AIRLIFT diff --git a/ports/raspberrypi/common-hal/socketpool/SocketPool.h b/ports/raspberrypi/common-hal/socketpool/SocketPool.h index 864a52b3283..e66255ce499 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_AIRLIFT +#include "shared-module/_airlift/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..f0ed893dc10 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_AIRLIFT, shared-module/_airlift/socketpool.c is used instead. +#if !CIRCUITPY_AIRLIFT + #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_AIRLIFT diff --git a/ports/raspberrypi/common-hal/socketpool/__init__.h b/ports/raspberrypi/common-hal/socketpool/__init__.h index d55e720b373..c750c8b9ee7 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_AIRLIFT #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..10b1ccecfae 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -104,6 +104,9 @@ endif ifeq ($(CIRCUITPY_AESIO),1) SRC_PATTERNS += aesio/% endif +ifeq ($(CIRCUITPY_AIRLIFT),1) +SRC_PATTERNS += _airlift/% +endif ifeq ($(CIRCUITPY_ALARM),1) SRC_PATTERNS += alarm/__init__.c alarm/SleepMemory.c alarm/pin/% alarm/time/% endif @@ -732,6 +735,7 @@ SRC_BINDINGS_ENUMS += \ util.c SRC_SHARED_MODULE_ALL = \ + _airlift/__init__.c \ _bleio/Address.c \ _bleio/Attribute.c \ _bleio/ScanEntry.c \ @@ -1089,6 +1093,9 @@ endif # All possible sources are listed here, and are filtered by SRC_PATTERNS. SRC_SHARED_MODULE_INTERNAL = \ $(filter $(SRC_PATTERNS), \ + _airlift/network.c \ + _airlift/socketpool.c \ + _airlift/transport.c \ displayio/bus_core.c \ displayio/display_core.c \ usb/utf16le.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 0fc82d03704..442482f533a 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -99,6 +99,11 @@ CFLAGS += -DMICROPY_PY_SELECT_SELECT=$(MICROPY_PY_SELECT_SELECT) CIRCUITPY_AESIO ?= $(CIRCUITPY_FULL_BUILD) CFLAGS += -DCIRCUITPY_AESIO=$(CIRCUITPY_AESIO) +# An ESP32 co-processor running nina-fw, shared by the core and ESP32SPI (`_airlift`). +# Not part of CIRCUITPY_NETWORK, which would also turn on ssl, ipaddress and so on. +CIRCUITPY_AIRLIFT ?= 0 +CFLAGS += -DCIRCUITPY_AIRLIFT=$(CIRCUITPY_AIRLIFT) + # TODO: CIRCUITPY_ALARM will gradually be added to as many ports as possible # so make this 1 or CIRCUITPY_FULL_BUILD eventually CIRCUITPY_ALARM ?= 0 diff --git a/shared-bindings/_airlift/__init__.c b/shared-bindings/_airlift/__init__.c new file mode 100644 index 00000000000..ac232529669 --- /dev/null +++ b/shared-bindings/_airlift/__init__.c @@ -0,0 +1,217 @@ +// 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/_airlift/__init__.h" + +//| """Shared access to an ESP32 co-processor running nina-fw +//| +//| The `_airlift` 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 +//| can also drive an ESP32 the core doesn't use, such as an AirLift breakout, +//| through pins given to `init`. 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( +//| spi: Optional[busio.SPI] = None, +//| cs: Optional[digitalio.DigitalInOut] = None, +//| ready: Optional[digitalio.DigitalInOut] = None, +//| reset: Optional[digitalio.DigitalInOut] = None, +//| gpio0: Optional[digitalio.DigitalInOut] = None, +//| ) -> None: +//| """With no arguments, claim the board's own ESP32 pins and the board SPI bus, +//| and hard-reset the ESP32. That ESP32 stays claimed across soft reloads. Does +//| nothing if it is already claimed. Raises `ValueError` if a pin is in use. +//| +//| With a bus and pins, talk to an ESP32 that the core doesn't manage, such as +//| an AirLift breakout, and hard-reset it. They are used until `release` or the +//| end of the program. Calling it again replaces them. Raises `ValueError` if +//| the core has claimed the board's own ESP32. +//| +//| :param busio.SPI spi: the bus the ESP32 is on +//| :param digitalio.DigitalInOut cs: the ESP32's chip select +//| :param digitalio.DigitalInOut ready: the ESP32's BUSY/READY output +//| :param digitalio.DigitalInOut reset: the ESP32's reset input +//| :param digitalio.DigitalInOut gpio0: the ESP32's GPIO0, if connected""" +//| ... +//| +static mp_obj_t _airlift_init(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_spi, ARG_cs, ARG_ready, ARG_reset, ARG_gpio0 }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_spi, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_cs, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_ready, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_reset, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_gpio0, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + }; + 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); + + if (args[ARG_spi].u_obj == mp_const_none && args[ARG_cs].u_obj == mp_const_none && + args[ARG_ready].u_obj == mp_const_none && args[ARG_reset].u_obj == mp_const_none) { + common_hal__airlift_init(); + return mp_const_none; + } + busio_spi_obj_t *spi = MP_OBJ_TO_PTR(mp_arg_validate_type(args[ARG_spi].u_obj, &busio_spi_type, MP_QSTR_spi)); + digitalio_digitalinout_obj_t *cs = MP_OBJ_TO_PTR( + mp_arg_validate_type(args[ARG_cs].u_obj, &digitalio_digitalinout_type, MP_QSTR_cs)); + digitalio_digitalinout_obj_t *ready = MP_OBJ_TO_PTR( + mp_arg_validate_type(args[ARG_ready].u_obj, &digitalio_digitalinout_type, MP_QSTR_ready)); + digitalio_digitalinout_obj_t *reset = MP_OBJ_TO_PTR( + mp_arg_validate_type(args[ARG_reset].u_obj, &digitalio_digitalinout_type, MP_QSTR_reset)); + digitalio_digitalinout_obj_t *gpio0 = NULL; + if (args[ARG_gpio0].u_obj != mp_const_none) { + gpio0 = MP_OBJ_TO_PTR( + mp_arg_validate_type(args[ARG_gpio0].u_obj, &digitalio_digitalinout_type, MP_QSTR_gpio0)); + } + // Keeps them alive while the core uses them. + mp_obj_t objs = mp_obj_new_tuple(5, (mp_obj_t[]) { + args[ARG_spi].u_obj, args[ARG_cs].u_obj, args[ARG_ready].u_obj, args[ARG_reset].u_obj, + args[ARG_gpio0].u_obj + }); + common_hal__airlift_init_pins(objs, spi, cs, ready, reset, gpio0); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(_airlift_init_obj, 0, _airlift_init); + +//| def active() -> bool: +//| """True if the core has claimed the board's own ESP32, as it does for web +//| workflow. `transact` can then be used without calling `init`.""" +//| ... +//| +static mp_obj_t _airlift_active(void) { + return mp_obj_new_bool(common_hal__airlift_active()); +} +static MP_DEFINE_CONST_FUN_OBJ_0(_airlift_active_obj, _airlift_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 _airlift_reset(void) { + common_hal__airlift_reset(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(_airlift_reset_obj, _airlift_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 _airlift_release(void) { + common_hal__airlift_release(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(_airlift_release_obj, _airlift_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 _airlift_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. + airlift_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__airlift_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(_airlift_transact_obj, 1, _airlift_transact); + +static const mp_rom_map_elem_t _airlift_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR__airlift) }, + { MP_ROM_QSTR(MP_QSTR_init), MP_ROM_PTR(&_airlift_init_obj) }, + { MP_ROM_QSTR(MP_QSTR_active), MP_ROM_PTR(&_airlift_active_obj) }, + { MP_ROM_QSTR(MP_QSTR_reset), MP_ROM_PTR(&_airlift_reset_obj) }, + { MP_ROM_QSTR(MP_QSTR_release), MP_ROM_PTR(&_airlift_release_obj) }, + { MP_ROM_QSTR(MP_QSTR_transact), MP_ROM_PTR(&_airlift_transact_obj) }, + { MP_ROM_QSTR(MP_QSTR_MAX_SOCKETS), MP_ROM_INT(CIRCUITPY_AIRLIFT_MAX_SOCKETS) }, + { MP_ROM_QSTR(MP_QSTR_MAX_TRANSFER), MP_ROM_INT(AIRLIFT_MAX_TRANSFER) }, + { MP_ROM_QSTR(MP_QSTR_MAX_READ), MP_ROM_INT(AIRLIFT_MAX_READ) }, +}; + +static MP_DEFINE_CONST_DICT(_airlift_module_globals, _airlift_module_globals_table); + +const mp_obj_module_t _airlift_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&_airlift_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR__airlift, _airlift_module); diff --git a/shared-bindings/_airlift/__init__.h b/shared-bindings/_airlift/__init__.h new file mode 100644 index 00000000000..b0d1e44373b --- /dev/null +++ b/shared-bindings/_airlift/__init__.h @@ -0,0 +1,26 @@ +// 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/_airlift/transport.h" + +void common_hal__airlift_init(void); +// objs holds the VM objects so the GC keeps them while the core uses them. +void common_hal__airlift_init_pins(mp_obj_t objs, busio_spi_obj_t *spi, + digitalio_digitalinout_obj_t *cs, digitalio_digitalinout_obj_t *ready, + digitalio_digitalinout_obj_t *reset, digitalio_digitalinout_obj_t *gpio0); +bool common_hal__airlift_active(void); +void common_hal__airlift_reset(void); +void common_hal__airlift_release(void); +// expected_replies < 0 takes the count from the reply. Returns a list of bytearrays. +mp_obj_t common_hal__airlift_transact(uint8_t cmd, const airlift_param_t *params, size_t n_params, + bool param_len_16, int expected_replies, bool reply_len_16); diff --git a/shared-module/_airlift/__init__.c b/shared-module/_airlift/__init__.c new file mode 100644 index 00000000000..47bac9389a3 --- /dev/null +++ b/shared-module/_airlift/__init__.c @@ -0,0 +1,104 @@ +// 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/_airlift/__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[AIRLIFT_MAX_TRANSFER]; +static uint16_t vm_reply_lens[255]; + +void common_hal__airlift_init(void) { + if (!airlift_transport_init()) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_pin); + } +} + +void common_hal__airlift_init_pins(mp_obj_t objs, busio_spi_obj_t *spi, + digitalio_digitalinout_obj_t *cs, digitalio_digitalinout_obj_t *ready, + digitalio_digitalinout_obj_t *reset, digitalio_digitalinout_obj_t *gpio0) { + if (!airlift_transport_init_vm(spi, cs, ready, reset, gpio0)) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_ESP32); + } + MP_STATE_VM(airlift_vm_objs) = objs; +} + +bool common_hal__airlift_active(void) { + return airlift_transport_active(); +} + +static void check_active(void) { + if (!airlift_transport_claimed()) { + mp_raise_OSError(MP_ENODEV); + } +} + +void common_hal__airlift_reset(void) { + check_active(); + airlift_transport_reset(); +} + +void common_hal__airlift_release(void) { + airlift_transport_deinit(); +} + +// Raises the exception type ESP32SPI raises for the same failure, so its error +// handling keeps working. +static void raise_for(airlift_result_t result, uint8_t cmd) { + switch (result) { + case AIRLIFT_OK: + return; + case AIRLIFT_NOT_ACTIVE: + mp_raise_OSError(MP_ENODEV); + case AIRLIFT_BUSY: + mp_raise_OSError(MP_EBUSY); + case AIRLIFT_INTERRUPTED: + // Nothing was sent. Raise the pending KeyboardInterrupt now. + mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); + mp_raise_OSError(MP_EINTR); + case AIRLIFT_BAD_SOCKET: + mp_raise_OSError(MP_EBADF); + case AIRLIFT_TOO_BIG: + if (cmd == AIRLIFT_GET_DATABUF_TCP) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_size, AIRLIFT_MAX_READ); + } + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q length must be <= %d"), MP_QSTR_params, AIRLIFT_MAX_TRANSFER); + case AIRLIFT_TIMEOUT_READY: + case AIRLIFT_TIMEOUT_SELECT: + case AIRLIFT_TIMEOUT_START: + mp_raise_msg(&mp_type_TimeoutError, MP_ERROR_TEXT("Operation timed out")); + case AIRLIFT_ERR_RESPONSE: + case AIRLIFT_BAD_REPLY: + mp_raise_msg(&mp_type_BrokenPipeError, MP_ERROR_TEXT("Received response was invalid")); + case AIRLIFT_REPLY_TOO_BIG: + mp_raise_OSError(MP_ENOBUFS); + } +} + +mp_obj_t common_hal__airlift_transact(uint8_t cmd, const airlift_param_t *params, size_t n_params, + bool param_len_16, int expected_replies, bool reply_len_16) { + airlift_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), + }; + airlift_result_t result = airlift_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/_airlift/network.c b/shared-module/_airlift/network.c new file mode 100644 index 00000000000..e1503919e93 --- /dev/null +++ b/shared-module/_airlift/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/_airlift/network.h" +#include "shared-module/_airlift/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 = AIRLIFT_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 airlift_result_t command(uint8_t cmd, const airlift_param_t *params, size_t n_params, + uint8_t *buf, size_t buf_len, uint16_t *lens, int expected) { + airlift_reply_t reply = { + .buf = buf, .buf_len = buf_len, .lens = lens, .max_count = expected, + }; + airlift_result_t result = airlift_transact(cmd, params, n_params, false, &reply, expected, false, false); + if (result == AIRLIFT_OK && (int)reply.count != expected) { + return AIRLIFT_BAD_REPLY; + } + return result; +} + +static const uint8_t ff = 0xff; +static const airlift_param_t ff_param = { .buf = &ff, .len = 1 }; + +static bool read_status(void) { + uint8_t buf[1]; + uint16_t lens[1]; + if (command(AIRLIFT_GET_CONN_STATUS, NULL, 0, buf, sizeof(buf), lens, 1) != AIRLIFT_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 == AIRLIFT_WL_CONNECTED && + command(AIRLIFT_GET_IPADDR, &ff_param, 1, buf, sizeof(buf), lens, 3) == AIRLIFT_OK && lens[0] == 4) { + memcpy(&ipv4_address, buf, 4); + } +} + +void airlift_network_refresh(bool force) { + if (!airlift_transport_active()) { + status = AIRLIFT_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 == airlift_transport_generation() && + now - refreshed_ms < REFRESH_MS) { + return; + } + uint32_t gen = airlift_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 airlift_network_status(void) { + if (!airlift_transport_active() || generation != airlift_transport_generation()) { + return airlift_transport_active() ? AIRLIFT_WL_IDLE_STATUS : AIRLIFT_WL_NO_SHIELD; + } + return status; +} + +uint32_t airlift_network_ipv4_address(void) { + return airlift_network_status() == AIRLIFT_WL_CONNECTED ? ipv4_address : 0; +} + +static bool connected_to(const char *ssid) { + if (!read_status() || status != AIRLIFT_WL_CONNECTED) { + return false; + } + uint8_t buf[33]; + uint16_t lens[1]; + if (command(AIRLIFT_GET_CURR_SSID, &ff_param, 1, buf, sizeof(buf), lens, 1) != AIRLIFT_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 = airlift_transport_generation(); + join_vm_commands = airlift_transport_vm_network_commands(); + join_tried = true; + airlift_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 ? AIRLIFT_SET_NET : AIRLIFT_SET_PASSPHRASE, params, open ? 1 : 2, + buf, sizeof(buf), lens, 1) == AIRLIFT_OK && buf[0] == 1; +} + +bool airlift_network_join(const char *ssid, const char *password) { + if (!airlift_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; + airlift_network_refresh(true); + return true; + } + if (join_tried && join_generation == airlift_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 = airlift_transport_vm_network_commands(); + join_ms = supervisor_ticks_ms64() - REJOIN_MS; + joining = false; + airlift_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 == AIRLIFT_WL_CONNECTED) { + break; + } + } + } + airlift_network_refresh(true); + return airlift_network_status() == AIRLIFT_WL_CONNECTED; +} + +void airlift_network_maintain(void) { + if (!airlift_transport_active() || join_ssid[0] == '\0') { + return; + } + airlift_network_refresh(false); + if (airlift_network_status() == AIRLIFT_WL_CONNECTED) { + joining = false; + return; + } + uint64_t now = supervisor_ticks_ms64(); + bool was_reset = join_generation != airlift_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 != airlift_transport_vm_network_commands(); + if (was_reset || (!vm_managed && now - join_ms >= REJOIN_MS)) { + joining = start_join(); + } +} diff --git a/shared-module/_airlift/network.h b/shared-module/_airlift/network.h new file mode 100644 index 00000000000..817e84c8466 --- /dev/null +++ b/shared-module/_airlift/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 AIRLIFT_WL_IDLE_STATUS (0) +#define AIRLIFT_WL_NO_SSID_AVAIL (1) +#define AIRLIFT_WL_CONNECTED (3) +#define AIRLIFT_WL_CONNECT_FAILED (4) +#define AIRLIFT_WL_DISCONNECTED (6) +#define AIRLIFT_WL_STOPPED (254) +#define AIRLIFT_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, airlift_network_maintain() keeps trying in the +// background. Returns true when connected. Never raises. +bool airlift_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 airlift_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 airlift_network_refresh(bool force); +// The cached status. AIRLIFT_WL_NO_SHIELD when the core doesn't own the ESP32. +uint8_t airlift_network_status(void); +// The cached IPv4 address in network byte order, or 0 when not connected. +uint32_t airlift_network_ipv4_address(void); diff --git a/shared-module/_airlift/socketpool.c b/shared-module/_airlift/socketpool.c new file mode 100644 index 00000000000..8996a196cb9 --- /dev/null +++ b/shared-module/_airlift/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/_airlift/socketpool.h" +#include "shared-module/_airlift/network.h" +#include "shared-module/_airlift/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 airlift_socketpool_interface = { &mp_type_object }; + +// The open socket object for each ESP32 socket number. +static socketpool_socket_obj_t *by_num[CIRCUITPY_AIRLIFT_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 = AIRLIFT_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 != AIRLIFT_NO_SOCKET && airlift_transport_active() && + self->generation == airlift_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 airlift_transport_active() && !airlift_transport_busy(); +} + +static void set_num(socketpool_socket_obj_t *self, uint8_t num) { + self->num = num; + self->generation = airlift_transport_generation(); + by_num[num] = self; + airlift_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_AIRLIFT_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 == airlift_transport_generation()) { + airlift_transport_set_owned(self->num, false); + } + } + self->num = AIRLIFT_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 airlift_result_t command(uint8_t cmd, const airlift_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]; + airlift_reply_t reply = { .buf = buf, .buf_len = buf_len, .lens = lens, .max_count = 1 }; + airlift_result_t result = airlift_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 airlift_result_t socket_command(uint8_t cmd, uint8_t num, uint8_t *buf, size_t buf_len, + size_t *reply_len) { + airlift_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(AIRLIFT_STOP_CLIENT_TCP, num, buf, sizeof(buf), &len) == AIRLIFT_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 airlift_user_reset() doesn't stop it + // after someone else is given it. + airlift_transport_set_owned(num, true); + if (stop_pending == 0) { + stop_pending_generation = airlift_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; + } + airlift_param_t params[2] = { + { .buf = &self->num, .len = 1 }, + { .buf = self->tx, .len = self->tx_len }, + }; + uint8_t buf[2]; + size_t len; + airlift_result_t result = command(AIRLIFT_SEND_DATA_TCP, params, 2, true, buf, sizeof(buf), false, &len); + if (result == AIRLIFT_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 == AIRLIFT_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 != AIRLIFT_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_AIRLIFT_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_AIRLIFT_MAX_SOCKETS; i++) { + if (by_num[i] != NULL && is_open(by_num[i]) && !by_num[i]->listening) { + return true; + } + } + return false; +} + +void airlift_socketpool_poll_resume(void) { + if (!tick_enabled) { + supervisor_enable_tick(); + tick_enabled = true; + } + if (any_client_open()) { + poll_countdown = POLL_ACTIVE_MS; + } else if (airlift_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 airlift_socketpool_tick(void) { + if (!poll_wanted) { + return; + } + ms_since_pass++; + if (airlift_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 = airlift_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 airlift_socketpool_background(void) { + if (!can_transact() || !airlift_transport_bus_free()) { + return false; + } + airlift_network_maintain(); + if (stop_pending_generation != airlift_transport_generation()) { + stop_pending = 0; + } + for (uint8_t num = 0; num < CIRCUITPY_AIRLIFT_MAX_SOCKETS; num++) { + if ((stop_pending & (1 << num)) && stop(num)) { + stop_pending &= ~(1 << num); + airlift_transport_set_owned(num, false); + } + } + // Serial output written by the VM since the last pass. + flush_all(); + return true; +} + +void airlift_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 AirLift 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(&airlift_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 = AIRLIFT_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() || airlift_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(AIRLIFT_AVAIL_DATA_TCP, self->num, buf, sizeof(buf), &len) != AIRLIFT_OK) { + return -MP_EAGAIN; + } + uint16_t num = le16(buf, len); + if (num >= CIRCUITPY_AIRLIFT_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 = AIRLIFT_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 != AIRLIFT_NO_SOCKET && parked_generation == airlift_transport_generation()) { + // Only one is kept. The other is stopped at the end of the VM run. + airlift_transport_set_owned(parked_num, false); + airlift_transport_leaked_listener(parked_num); + } + parked_num = num; + parked_port = self->port; + parked_generation = airlift_transport_generation(); + by_num[num] = NULL; + self->num = AIRLIFT_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(AIRLIFT_GET_CLIENT_STATE_TCP, self->num, buf, sizeof(buf), &len) != AIRLIFT_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() || + airlift_transport_vm_allocating()) { + return false; + } + if (parked_num != AIRLIFT_NO_SOCKET && parked_generation == airlift_transport_generation() && + parked_port == self->port) { + // Still listening. + set_num(self, parked_num); + self->listening = true; + parked_num = AIRLIFT_NO_SOCKET; + return true; + } + // START_SERVER_TCP before WiFi is up hangs nina-fw until a hard reset. + airlift_network_refresh(false); + if (airlift_network_status() != AIRLIFT_WL_CONNECTED) { + return false; + } + // GET_SOCKET doesn't reserve the number. START_SERVER_TCP does. + uint8_t buf[1]; + size_t len; + if (command(AIRLIFT_GET_SOCKET, NULL, 0, false, buf, sizeof(buf), false, &len) != AIRLIFT_OK || + len != 1 || buf[0] >= CIRCUITPY_AIRLIFT_MAX_SOCKETS) { + return false; + } + uint8_t num = buf[0]; + uint8_t port[2] = { self->port >> 8, self->port & 0xff }; + uint8_t mode = TCP_MODE; + airlift_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(AIRLIFT_START_SERVER_TCP, params, 3, false, buf, sizeof(buf), false, &len) != AIRLIFT_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, airlift_result_t result) { + if (result == AIRLIFT_BUSY || result == AIRLIFT_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; + airlift_result_t result = socket_command(AIRLIFT_AVAIL_DATA_TCP, self->num, avail_buf, sizeof(avail_buf), &reply_len); + if (result != AIRLIFT_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 >= AIRLIFT_SOCKET_RX_SIZE; + uint8_t *dest = direct ? buf : self->rx; + uint16_t want = MIN(avail, direct ? MIN(len, AIRLIFT_MAX_READ) : AIRLIFT_SOCKET_RX_SIZE); + uint8_t want_le[2] = { want & 0xff, want >> 8 }; + airlift_param_t params[2] = { + { .buf = &self->num, .len = 1 }, + { .buf = want_le, .len = 2 }, + }; + result = command(AIRLIFT_GET_DATABUF_TCP, params, 2, true, dest, want, true, &reply_len); + if (result != AIRLIFT_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 == AIRLIFT_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 == AIRLIFT_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, AIRLIFT_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(AIRLIFT_AVAIL_DATA_TCP, self->num, buf, sizeof(buf), &len) == AIRLIFT_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_AIRLIFT_MAX_SOCKETS && by_num[sock->num] == self) { + by_num[sock->num] = sock; + } + self->num = AIRLIFT_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 = AIRLIFT_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/_airlift/socketpool.h b/shared-module/_airlift/socketpool.h new file mode 100644 index 00000000000..b3de4755137 --- /dev/null +++ b/shared-module/_airlift/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_AIRLIFT is on. +// Only TCP is supported, and only the core uses it for now (web workflow): +// SocketPool() accepts only airlift_socketpool_interface, which Python can't reach. + +#include +#include + +#include "py/obj.h" +#include "shared-module/_airlift/transport.h" + +// Bytes read from the ESP32 at a time. Web workflow reads headers a byte at a time. +#ifndef AIRLIFT_SOCKET_RX_SIZE +#define AIRLIFT_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 AIRLIFT_SOCKET_TX_SIZE +#define AIRLIFT_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; + // airlift_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 AIRLIFT_NO_SOCKET when closed. + uint8_t num; + uint8_t type; + bool listening; + bool connected; + bool connected_valid; + uint8_t rx[AIRLIFT_SOCKET_RX_SIZE]; + uint8_t tx[AIRLIFT_SOCKET_TX_SIZE]; +} socketpool_socket_obj_t; + +// The network interface object the core passes to SocketPool's constructor. +extern const mp_obj_base_t airlift_socketpool_interface; + +// AirLift has no callbacks, so web workflow is polled: this schedules its next +// background pass, sooner while a client is connected. +void airlift_socketpool_poll_resume(void); +#define socketpool_socket_poll_resume() airlift_socketpool_poll_resume() +// Called from supervisor_tick() every millisecond. +void airlift_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 airlift_socketpool_background(void); +// Called at the end of each web workflow background pass. Sends what's waiting +// in the TX buffers. +void airlift_socketpool_background_done(void); diff --git a/shared-module/_airlift/transport.c b/shared-module/_airlift/transport.c new file mode 100644 index 00000000000..d3441cf4ac7 --- /dev/null +++ b/shared-module/_airlift/transport.c @@ -0,0 +1,718 @@ +// 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/_airlift/transport.h" + +#include "py/mpconfig.h" +#include "py/mpstate.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_AIRLIFT_SPI_INDEX +#define CIRCUITPY_AIRLIFT_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; +// NULL when GPIO0 isn't connected. +static digitalio_digitalinout_obj_t *gpio0; +#ifdef CIRCUITPY_AIRLIFT_CS +// The board's own ESP32, which the core claims. +static digitalio_digitalinout_obj_t board_cs; +static digitalio_digitalinout_obj_t board_ready; +static digitalio_digitalinout_obj_t board_reset; +#ifdef CIRCUITPY_AIRLIFT_GPIO0 +static digitalio_digitalinout_obj_t board_gpio0; +#endif +#endif +static bool active; +// The bus and pins are VM objects passed to _airlift.init(). They are dropped +// at the end of the VM run. +static bool vm_owned; +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. airlift_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[AIRLIFT_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; +} + +// VM code can deinit the pins it gave to _airlift.init(). +static bool pins_usable(void) { + if (!vm_owned) { + return true; + } + if (common_hal_digitalio_digitalinout_deinited(cs) || + common_hal_digitalio_digitalinout_deinited(ready) || + common_hal_digitalio_digitalinout_deinited(reset)) { + return false; + } + return gpio0 == NULL || !common_hal_digitalio_digitalinout_deinited(gpio0); +} + +static airlift_result_t lock_bus(bool wait, bool interruptible) { + if (common_hal_busio_spi_deinited(spi)) { + return AIRLIFT_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 AIRLIFT_BUSY; + } + if (interruptible && mp_hal_is_interrupted()) { + return AIRLIFT_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 AIRLIFT_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 airlift_result_t build_command(uint8_t cmd, const airlift_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_AIRLIFT_MAX_SOCKETS)) { + return AIRLIFT_BAD_SOCKET; + } + if (cmd == AIRLIFT_GET_DATABUF_TCP && n_params >= 2 && params[1].len == 2 && + (params[1].buf[0] | (params[1].buf[1] << 8)) > AIRLIFT_MAX_READ) { + return AIRLIFT_TOO_BIG; + } + if (n_params > 255) { + return AIRLIFT_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 AIRLIFT_TOO_BIG; + } + packet_len += params[i].len + (param_len_16 ? 2 : 1); + } + if (packet_len > AIRLIFT_MAX_TRANSFER) { + return AIRLIFT_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. AIRLIFT_MAX_TRANSFER is one, so this still fits. + while ((p - tx_buf) % 4 != 0) { + *p++ = 0; + } + *len = p - tx_buf; + return AIRLIFT_OK; +} + +static airlift_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 ? AIRLIFT_INTERRUPTED : AIRLIFT_TIMEOUT_READY; + } + airlift_result_t result = lock_bus(from_vm, from_vm); + if (result != AIRLIFT_OK) { + return result; + } + if (!wait_ready(true, SELECT_TIMEOUT_MS, false, NULL)) { + unlock_bus(); + return AIRLIFT_TIMEOUT_SELECT; + } + common_hal_busio_spi_write(spi, tx_buf, len); + unlock_bus(); + return AIRLIFT_OK; +} + +static airlift_result_t read_reply(uint8_t cmd, airlift_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 AIRLIFT_ERR_RESPONSE; + } + if (b == START_CMD) { + break; + } + if (tries == START_TRIES) { + return AIRLIFT_TIMEOUT_START; + } + pause_ms(START_RETRY_MS); + } + if (!check_byte(cmd | REPLY_FLAG)) { + return AIRLIFT_BAD_REPLY; + } + size_t count; + if (expected_replies >= 0) { + if (!check_byte(expected_replies)) { + return AIRLIFT_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 AIRLIFT_BAD_REPLY; + } + return too_big ? AIRLIFT_REPLY_TOO_BIG : AIRLIFT_OK; +} + +static airlift_result_t receive_reply(uint8_t cmd, airlift_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 AIRLIFT_TIMEOUT_READY; + } + // Wait for the bus even in the background: the ESP has a reply queued. + airlift_result_t result = lock_bus(true, false); + if (result == AIRLIFT_BUSY) { + // The reply can't be read, so the ESP is out of step. Start it over. + airlift_transport_reset(); + } + if (result != AIRLIFT_OK) { + return result; + } + if (!wait_ready(true, SELECT_TIMEOUT_MS, false, NULL)) { + unlock_bus(); + return AIRLIFT_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 airlift_param_t *params, size_t n_params, + airlift_reply_t *reply, airlift_result_t *result) { + int sock = socket_param(cmd, n_params); + if (sock < 0 || params[sock].len < 1 || params[sock].buf[0] >= CIRCUITPY_AIRLIFT_MAX_SOCKETS || + !(core_owned & (1 << params[sock].buf[0]))) { + return false; + } + uint8_t value; + switch (cmd) { + case AIRLIFT_STOP_CLIENT_TCP: + value = 1; // Stopped. + break; + case AIRLIFT_GET_CLIENT_STATE_TCP: + value = 0; // CLOSED. + break; + default: + *result = AIRLIFT_BAD_SOCKET; + return true; + } + if (reply->max_count < 1 || reply->buf_len < 1) { + *result = AIRLIFT_REPLY_TOO_BIG; + return true; + } + reply->buf[0] = value; + reply->lens[0] = 1; + reply->count = 1; + *result = AIRLIFT_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(airlift_result_t result) { + switch (result) { + case AIRLIFT_OK: + case AIRLIFT_REPLY_TOO_BIG: + core_failures = 0; + return; + case AIRLIFT_TIMEOUT_READY: + case AIRLIFT_TIMEOUT_SELECT: + // It didn't answer for 1-10 s. + core_failures = CORE_FAILURES_BEFORE_RESET; + break; + case AIRLIFT_TIMEOUT_START: + case AIRLIFT_ERR_RESPONSE: + case AIRLIFT_BAD_REPLY: + core_failures++; + break; + default: + // Nothing was sent. + return; + } + if (core_failures >= CORE_FAILURES_BEFORE_RESET) { + airlift_transport_reset(); + } +} + +airlift_result_t airlift_transact(uint8_t cmd, const airlift_param_t *params, size_t n_params, + bool param_len_16, airlift_reply_t *reply, int expected_replies, bool reply_len_16, + bool from_vm) { + if (!active || !pins_usable()) { + return AIRLIFT_NOT_ACTIVE; + } + if (busy) { + return AIRLIFT_BUSY; + } + size_t len; + airlift_result_t result = build_command(cmd, params, n_params, param_len_16, &len); + if (result != AIRLIFT_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 == AIRLIFT_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 != AIRLIFT_ERR_RESPONSE) { + break; + } + } + busy = false; + if (!from_vm) { + check_core_result(result); + } + if (from_vm && result == AIRLIFT_OK) { + switch (cmd) { + case AIRLIFT_GET_SOCKET: + vm_allocating = true; + vm_allocating_ms = supervisor_ticks_ms64(); + break; + case AIRLIFT_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 AIRLIFT_START_CLIENT_TCP: + case AIRLIFT_STOP_CLIENT_TCP: + vm_allocating = false; + break; + case AIRLIFT_SET_NET: + case AIRLIFT_SET_PASSPHRASE: + case AIRLIFT_SET_AP_NET: + case AIRLIFT_SET_AP_PASSPHRASE: + case AIRLIFT_DISCONNECT: + case AIRLIFT_SET_ENT_ENABLE: + vm_network_commands++; + break; + default: + break; + } + } + return result; +} + +uint32_t airlift_transport_vm_network_commands(void) { + return vm_network_commands; +} + +bool airlift_transport_vm_allocating(void) { + if (vm_allocating && supervisor_ticks_ms64() - vm_allocating_ms >= VM_ALLOCATING_TIMEOUT_MS) { + vm_allocating = false; + } + return vm_allocating; +} + +void airlift_transport_set_owned(uint8_t sock, bool owned) { + if (sock >= CIRCUITPY_AIRLIFT_MAX_SOCKETS) { + return; + } + if (owned) { + core_owned |= 1 << sock; + } else { + core_owned &= ~(1 << sock); + } +} + +void airlift_transport_leaked_listener(uint8_t sock) { + if (sock < CIRCUITPY_AIRLIFT_MAX_SOCKETS) { + vm_listening |= 1 << sock; + } +} + +#ifdef CIRCUITPY_AIRLIFT_CS +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; +#endif + +bool airlift_transport_init(void) { + #ifdef CIRCUITPY_AIRLIFT_CS + if (active) { + return !vm_owned; + } + // board.SPI() may already exist; then the ESP shares it with user code. + mp_obj_t board_spi = common_hal_board_get_spi(CIRCUITPY_AIRLIFT_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_AIRLIFT_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_AIRLIFT_CS) || + !common_hal_mcu_pin_is_free(CIRCUITPY_AIRLIFT_BUSY) || + !common_hal_mcu_pin_is_free(CIRCUITPY_AIRLIFT_RESET)) { + return false; + } + #ifdef CIRCUITPY_AIRLIFT_GPIO0 + if (!common_hal_mcu_pin_is_free(CIRCUITPY_AIRLIFT_GPIO0)) { + return false; + } + #endif + + spi = MP_OBJ_TO_PTR(common_hal_board_create_spi(CIRCUITPY_AIRLIFT_SPI_INDEX)); + common_hal_busio_spi_never_reset(spi); + cs = &board_cs; + claim_esp_pin(cs, CIRCUITPY_AIRLIFT_CS); + common_hal_digitalio_digitalinout_switch_to_output(cs, true, DRIVE_MODE_PUSH_PULL); + ready = &board_ready; + claim_esp_pin(ready, CIRCUITPY_AIRLIFT_BUSY); + reset = &board_reset; + claim_esp_pin(reset, CIRCUITPY_AIRLIFT_RESET); + common_hal_digitalio_digitalinout_switch_to_output(reset, true, DRIVE_MODE_PUSH_PULL); + #ifdef CIRCUITPY_AIRLIFT_GPIO0 + gpio0 = &board_gpio0; + claim_esp_pin(gpio0, CIRCUITPY_AIRLIFT_GPIO0); + #else + gpio0 = NULL; + #endif + active = true; + airlift_transport_reset(); + return true; + #else + return false; + #endif +} + +bool airlift_transport_init_vm(busio_spi_obj_t *vm_spi, digitalio_digitalinout_obj_t *vm_cs, + digitalio_digitalinout_obj_t *vm_ready, digitalio_digitalinout_obj_t *vm_reset, + digitalio_digitalinout_obj_t *vm_gpio0) { + if (active && !vm_owned) { + return false; + } + // A second init() replaces the first. + spi = vm_spi; + cs = vm_cs; + ready = vm_ready; + reset = vm_reset; + gpio0 = vm_gpio0; + vm_owned = true; + common_hal_digitalio_digitalinout_switch_to_output(cs, true, DRIVE_MODE_PUSH_PULL); + common_hal_digitalio_digitalinout_switch_to_input(ready, PULL_NONE); + common_hal_digitalio_digitalinout_switch_to_output(reset, true, DRIVE_MODE_PUSH_PULL); + if (gpio0 != NULL) { + common_hal_digitalio_digitalinout_switch_to_input(gpio0, PULL_NONE); + } + active = true; + airlift_transport_reset(); + return true; +} + +void airlift_transport_deinit(void) { + if (!active || busy) { + return; + } + active = false; + if (vm_owned) { + // The bus and pins are VM code's, and stay usable by it. + vm_owned = false; + MP_STATE_VM(airlift_vm_objs) = MP_OBJ_NULL; + } else { + common_hal_digitalio_digitalinout_deinit(cs); + common_hal_digitalio_digitalinout_deinit(ready); + common_hal_digitalio_digitalinout_deinit(reset); + if (gpio0 != NULL) { + common_hal_digitalio_digitalinout_deinit(gpio0); + } + } + // The bus stays as board.SPI(). It is released at the next soft reset, + // because airlift_transport_using_spi() no longer claims it. + spi = NULL; + cs = ready = reset = gpio0 = NULL; + // The core's sockets can't be used any more. + generation++; + core_owned = 0; +} + +void airlift_transport_reset(void) { + if (!active || !pins_usable()) { + 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; + if (gpio0 != NULL) { + // High selects a normal boot rather than the bootloader. + common_hal_digitalio_digitalinout_switch_to_output(gpio0, true, DRIVE_MODE_PUSH_PULL); + } + 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); + if (gpio0 != NULL) { + common_hal_digitalio_digitalinout_switch_to_input(gpio0, PULL_NONE); + } + generation++; + vm_listening = 0; + core_owned = 0; + vm_allocating = false; + core_failures = 0; + busy = was_busy; +} + +void airlift_user_reset(void) { + if (!active) { + return; + } + // The VM's bus and pins are about to go away. + if (vm_owned) { + airlift_transport_deinit(); + return; + } + // User code may have exited holding the bus lock. + common_hal_busio_spi_unlock(spi); + vm_allocating = false; + if (vm_listening != 0) { + airlift_transport_reset(); + return; + } + for (uint8_t sock = 0; sock < CIRCUITPY_AIRLIFT_MAX_SOCKETS; sock++) { + if (core_owned & (1 << sock)) { + continue; + } + airlift_param_t param = { .buf = &sock, .len = 1 }; + uint8_t buf[1]; + uint16_t lens[1]; + airlift_reply_t reply = { .buf = buf, .buf_len = sizeof(buf), .lens = lens, .max_count = 1 }; + if (airlift_transact(AIRLIFT_STOP_CLIENT_TCP, ¶m, 1, false, &reply, 1, false, false) != AIRLIFT_OK) { + // Don't wait out a timeout for every socket. A reset closes them all. + airlift_transport_reset(); + return; + } + } +} + +bool airlift_transport_active(void) { + return active && !vm_owned; +} + +bool airlift_transport_claimed(void) { + return active && pins_usable(); +} + +bool airlift_transport_busy(void) { + return busy; +} + +bool airlift_transport_bus_free(void) { + if (!airlift_transport_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 airlift_transport_has_data_ready(void) { + return airlift_transport_active() && gpio0 != NULL; +} + +bool airlift_transport_data_ready(void) { + // During a reset GPIO0 is an output, held high. + return !airlift_transport_active() || gpio0 == NULL || common_hal_digitalio_digitalinout_get_value(gpio0); +} + +uint32_t airlift_transport_generation(void) { + return generation; +} + +bool airlift_transport_using_spi(const busio_spi_obj_t *bus) { + return airlift_transport_active() && bus == spi; +} + +MP_REGISTER_ROOT_POINTER(mp_obj_t airlift_vm_objs); diff --git a/shared-module/_airlift/transport.h b/shared-module/_airlift/transport.h new file mode 100644 index 00000000000..d75a754267c --- /dev/null +++ b/shared-module/_airlift/transport.h @@ -0,0 +1,152 @@ +// 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" +#include "shared-bindings/digitalio/DigitalInOut.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_AIRLIFT_MAX_SOCKETS +#define CIRCUITPY_AIRLIFT_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 AIRLIFT_MAX_TRANSFER (4092) +// GET_DATABUF_TCP reply: START, cmd, 1 param, 16-bit length, data, END. +#define AIRLIFT_MAX_READ (AIRLIFT_MAX_TRANSFER - 6) +// SEND_DATA_TCP command: START, cmd, 2 params, 16-bit length + socket, +// 16-bit length + data, END. +#define AIRLIFT_MAX_SEND (AIRLIFT_MAX_TRANSFER - 9) + +#define AIRLIFT_SET_NET (0x10) +#define AIRLIFT_SET_PASSPHRASE (0x11) +#define AIRLIFT_SET_AP_NET (0x18) +#define AIRLIFT_SET_AP_PASSPHRASE (0x19) +#define AIRLIFT_GET_CONN_STATUS (0x20) +#define AIRLIFT_GET_IPADDR (0x21) +#define AIRLIFT_GET_CURR_SSID (0x23) +#define AIRLIFT_START_SERVER_TCP (0x28) +#define AIRLIFT_AVAIL_DATA_TCP (0x2B) +#define AIRLIFT_START_CLIENT_TCP (0x2D) +#define AIRLIFT_STOP_CLIENT_TCP (0x2E) +#define AIRLIFT_GET_CLIENT_STATE_TCP (0x2F) +#define AIRLIFT_DISCONNECT (0x30) +#define AIRLIFT_GET_SOCKET (0x3F) +#define AIRLIFT_SEND_DATA_TCP (0x44) +#define AIRLIFT_GET_DATABUF_TCP (0x45) +#define AIRLIFT_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 AIRLIFT_NO_SOCKET (255) + +typedef struct { + const uint8_t *buf; + size_t len; +} airlift_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 airlift_transact(). +} airlift_reply_t; + +typedef enum { + AIRLIFT_OK, + AIRLIFT_NOT_ACTIVE, + // Another command is in flight, or the SPI bus is locked by someone else. + AIRLIFT_BUSY, + // A VM caller was interrupted before anything was sent. + AIRLIFT_INTERRUPTED, + // The command was rejected before sending it. + AIRLIFT_BAD_SOCKET, + AIRLIFT_TOO_BIG, + // BUSY never went low. + AIRLIFT_TIMEOUT_READY, + // BUSY never went high after CS went low. + AIRLIFT_TIMEOUT_SELECT, + // No START_CMD at the start of the reply. + AIRLIFT_TIMEOUT_START, + // The ESP32 replied ERR_CMD. + AIRLIFT_ERR_RESPONSE, + // A framing byte was wrong. + AIRLIFT_BAD_REPLY, + // The reply didn't fit in the airlift_reply_t. It was read and dropped. + AIRLIFT_REPLY_TOO_BIG, +} airlift_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 airlift_transport_init(void); +// Uses a bus and pins that VM code made, for an ESP32 the core doesn't manage, +// and hard-resets the ESP32. gpio0 may be NULL. The caller keeps the objects +// alive through MP_STATE_VM(airlift_vm_objs). They are dropped at the end of the +// VM run. Returns false if the core has claimed its own ESP32. +bool airlift_transport_init_vm(busio_spi_obj_t *spi, digitalio_digitalinout_obj_t *cs, + digitalio_digitalinout_obj_t *ready, digitalio_digitalinout_obj_t *reset, + digitalio_digitalinout_obj_t *gpio0); +// Releases the pins. The board SPI bus is left to board.SPI(). +void airlift_transport_deinit(void); +// Hard reset through ESP_RESET. Takes about 760 ms. Changes the generation. +void airlift_transport_reset(void); +// True when the core has claimed its own ESP32, which web workflow can use. +bool airlift_transport_active(void); +// True when either the core or VM code has set up the transport, and VM code +// hasn't deinited the pins it gave. +bool airlift_transport_claimed(void); +// True from the start of a command's send to the end of its reply. +bool airlift_transport_busy(void); +// False while someone else holds the SPI bus lock. Background callers don't +// wait for it. +bool airlift_transport_bus_free(void); +// Changes on every hard reset, so sockets can tell they no longer exist. +uint32_t airlift_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 airlift_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 airlift_transport_data_ready(void); +bool airlift_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 airlift_user_reset(void); + +// Marks a socket number as the core's, so airlift_user_reset() leaves it open. A +// hard reset clears them all. +void airlift_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 airlift_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 airlift_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 airlift_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. +airlift_result_t airlift_transact(uint8_t cmd, const airlift_param_t *params, size_t n_params, + bool param_len_16, airlift_reply_t *reply, int expected_replies, bool reply_len_16, + bool from_vm); diff --git a/shared-module/board/__init__.c b/shared-module/board/__init__.c index 6bfc0bbf0f1..52b7ba5ace7 100644 --- a/shared-module/board/__init__.c +++ b/shared-module/board/__init__.c @@ -25,6 +25,10 @@ #include "shared-module/sharpdisplay/SharpMemoryFramebuffer.h" #endif +#if CIRCUITPY_AIRLIFT +#include "shared-module/_airlift/transport.h" +#endif + #if CIRCUITPY_AURORA_EPAPER #include "shared-bindings/aurora_epaper/aurora_framebuffer.h" #include "shared-module/aurora_epaper/aurora_framebuffer.h" @@ -196,6 +200,14 @@ void reset_board_buses(void) { #endif } #endif + #if CIRCUITPY_AIRLIFT + // The ESP32 co-processor keeps the bus between VM runs. Don't let a lock + // left by user code block it. + if (airlift_transport_using_spi(&spi_obj[instance])) { + common_hal_busio_spi_unlock(&spi_obj[instance]); + display_using_spi = true; + } + #endif if (spi_obj_created[instance]) { if (!display_using_spi) { common_hal_busio_spi_deinit(&spi_obj[instance]); diff --git a/supervisor/shared/tick.c b/supervisor/shared/tick.c index 81815c0813b..f53145a7a24 100644 --- a/supervisor/shared/tick.c +++ b/supervisor/shared/tick.c @@ -31,6 +31,10 @@ #include "shared-module/audiofilewriter/AudioFileWriter.h" #endif +#if CIRCUITPY_AIRLIFT && CIRCUITPY_SOCKETPOOL +#include "shared-module/_airlift/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_AIRLIFT && CIRCUITPY_SOCKETPOOL + airlift_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..d5a9cfc73bf 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_AIRLIFT +#include "shared-module/_airlift/network.h" +#include "shared-module/_airlift/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_AIRLIFT) // 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_AIRLIFT +static uint8_t _last_airlift_status = AIRLIFT_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_AIRLIFT + // Cached, so the status bar doesn't cause SPI traffic. + if (!airlift_transport_active()) { + return false; + } + *ipv4_address = airlift_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_AIRLIFT 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_AIRLIFT + if (_last_airlift_status != airlift_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_AIRLIFT 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_AIRLIFT + serial_write_compressed(MP_ERROR_TEXT("Wi-Fi: ")); + _last_airlift_status = airlift_network_status(); + if (!_last_enabled) { + serial_write_compressed(MP_ERROR_TEXT("off")); + } else if (_last_airlift_status == AIRLIFT_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_AIRLIFT) +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. (AirLift boards do retry; see + // _airlift_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_AIRLIFT +// 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 _airlift_retry_listening(void) { + static uint64_t last_try_ms; + static uint32_t last_try_generation; + if (!common_hal_socketpool_socket_get_closed(&listening) || + airlift_network_status() != AIRLIFT_WL_CONNECTED) { + return; + } + uint64_t now = supervisor_ticks_ms64(); + if (last_try_generation == airlift_transport_generation() && now - last_try_ms < 5000) { + return; + } + last_try_ms = now; + last_try_generation = airlift_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_AIRLIFT) #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_AIRLIFT + mp_obj_t socketpool_radio = MP_OBJ_FROM_PTR(&airlift_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_AIRLIFT + 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 (!airlift_transport_init()) { + return false; + } + + // Returns early if already connected to this network, so the open + // connections survive. If it fails, the background keeps trying. + airlift_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_AIRLIFT + // Report success anyway, so the background callback is installed: + // it keeps trying (_airlift_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_AIRLIFT + // 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 (!airlift_socketpool_background()) { + socketpool_socket_poll_resume(); + return; + } + _airlift_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_AIRLIFT + airlift_socketpool_background_done(); + #endif + // Resume polling socketpool_socket_poll_resume(); diff --git a/tests/circuitpython-manual/airlift/esp_wedge.py b/tests/circuitpython-manual/airlift/esp_wedge.py new file mode 100644 index 00000000000..018cb6d6e9a --- /dev/null +++ b/tests/circuitpython-manual/airlift/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 _airlift.reset() undoes.""" + +import struct +import time + +import _airlift + +_airlift.reset() +try: + _airlift.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:", _airlift.transact(0x20)[0][0]) + print("PASS") +except OSError as e: + print("still wedged:", repr(e)) diff --git a/tests/circuitpython-manual/airlift/esp_wedge_host.py b/tests/circuitpython-manual/airlift/esp_wedge_host.py new file mode 100644 index 00000000000..5228580637c --- /dev/null +++ b/tests/circuitpython-manual/airlift/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/airlift/no_web_workflow.py b/tests/circuitpython-manual/airlift/no_web_workflow.py new file mode 100644 index 00000000000..4e3de121c06 --- /dev/null +++ b/tests/circuitpython-manual/airlift/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 _airlift +import board +import busio +from digitalio import DigitalInOut + +from adafruit_esp32spi import adafruit_esp32spi + +print("core owns the ESP32:", _airlift.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/airlift/readme.md b/tests/circuitpython-manual/airlift/readme.md new file mode 100644 index 00000000000..bca1cff37ed --- /dev/null +++ b/tests/circuitpython-manual/airlift/readme.md @@ -0,0 +1,67 @@ +# Web workflow over an ESP32 co-processor (`_airlift`) + +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 `AIRLIFT_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/airlift/run_on_board.py b/tests/circuitpython-manual/airlift/run_on_board.py new file mode 100644 index 00000000000..7fe85443ad3 --- /dev/null +++ b/tests/circuitpython-manual/airlift/run_on_board.py @@ -0,0 +1,68 @@ +# 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