diff --git a/Devices/m5stack-tab5/device.properties b/Devices/m5stack-tab5/device.properties index 31683c46..99766dcc 100644 --- a/Devices/m5stack-tab5/device.properties +++ b/Devices/m5stack-tab5/device.properties @@ -39,6 +39,9 @@ sdkconfig.CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=8 sdkconfig.CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=15 # Fixes recent changes to esp_hosted sdkconfig.CONFIG_ESP_HOSTED_USE_MEMPOOL=n +# ESP-NOW bridge (EspNowBackendHosted.cpp) registers 6 custom-RPC handlers (4 resp + 2 event); default cap is 3 +sdkconfig.CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER=y +sdkconfig.CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS=8 # Performance: larger L2 cache reduces PSRAM stalls for draw/DPI buffers sdkconfig.CONFIG_CACHE_L2_CACHE_256KB=y # Performance: use P4's PPA (pixel processing accelerator for rotation) diff --git a/Platforms/platform-esp32/include/tactility/drivers/esp32_esp_hosted_ota.h b/Platforms/platform-esp32/include/tactility/drivers/esp32_esp_hosted_ota.h new file mode 100644 index 00000000..1908d4dd --- /dev/null +++ b/Platforms/platform-esp32/include/tactility/drivers/esp32_esp_hosted_ota.h @@ -0,0 +1,7 @@ +#pragma once + +struct FirmwareOps; + +/** @return the esp_hosted co-processor FirmwareOps implementation. Only declared/linked when + * CONFIG_SLAVE_SOC_WIFI_SUPPORTED - callers (esp32_wifi.cpp) must guard with the same #if. */ +const FirmwareOps* esp32_esp_hosted_ota_get_ops(); diff --git a/Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp b/Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp new file mode 100644 index 00000000..69fd6ece --- /dev/null +++ b/Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp @@ -0,0 +1,254 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include +#include +#include + +#include +extern "C" { +#include +} +#include +#include +#include + +#include +#include + +#include +#include + +#define TAG "esp32_esp_hosted_ota" + +namespace { + +constexpr EventBits_t CP_INIT_BIT = BIT0; + +// An event group (not a binary semaphore) because multiple callers can wait concurrently - a +// binary semaphore only wakes one waiter per give(), which would leave the other(s) blocked +// until a second CP_INIT event that may never come. +EventGroupHandle_t cpInitEventGroup = nullptr; +esp_event_handler_instance_t cpInitHandlerInstance = nullptr; +std::atomic handlerRegistered{false}; + +// Monotonically bumped on every CP_INIT (including ones after a slave reset/reboot). Callers +// snapshot this before triggering a (re)connect and wait for it to advance past that snapshot, +// so a CP_INIT_BIT left set from a boot that predates the call can't be mistaken for readiness of +// the *current* RPC layer. +std::atomic cpInitGeneration{0}; + +void onCpInitEvent(void* /*arg*/, esp_event_base_t /*base*/, int32_t /*id*/, void* /*data*/) { + cpInitGeneration.fetch_add(1); + if (cpInitEventGroup != nullptr) { + xEventGroupSetBits(cpInitEventGroup, CP_INIT_BIT); + } +} + +/** Thread-safe lazy init: the first caller through wins the race, so concurrent callers can't + * both try to create the event group/register the handler at once. */ +bool ensureHandlerRegistered() { + if (handlerRegistered) { + return true; + } + static std::atomic initializing{false}; + bool expected = false; + if (!initializing.compare_exchange_strong(expected, true)) { + while (!handlerRegistered && initializing) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + return handlerRegistered; + } + + if (cpInitEventGroup == nullptr) { + cpInitEventGroup = xEventGroupCreate(); + } + if (cpInitEventGroup == nullptr) { + LOG_E(TAG, "xEventGroupCreate() failed"); + initializing = false; + return false; + } + if (cpInitHandlerInstance == nullptr) { + esp_err_t err = esp_event_handler_instance_register(ESP_HOSTED_EVENT, ESP_HOSTED_EVENT_CP_INIT, + onCpInitEvent, nullptr, &cpInitHandlerInstance); + if (err != ESP_OK) { + LOG_E(TAG, "esp_event_handler_instance_register() failed: %d", err); + initializing = false; + return false; + } + } + + handlerRegistered = true; + initializing = false; + return true; +} + +bool waitReady(void* /*ctx*/, uint32_t timeoutMs) { + esp_hosted_coprocessor_fwver_t probeVersion = {}; + if (esp_hosted_get_coprocessor_fwversion(&probeVersion) == ESP_OK) { + // Already up (e.g. WiFi/BT brought it up earlier this boot). + return true; + } + + // Register the event handler *before* triggering the connect, so we can't miss the + // ESP_HOSTED_EVENT_CP_INIT event firing in the window between triggering and waiting. + if (!ensureHandlerRegistered()) { + return false; + } + + // Snapshot the generation before triggering (re)connect - a CP_INIT that already happened + // (e.g. from a boot before a slave reset) doesn't count as readiness for this call; only a + // CP_INIT observed after this point (generation advances past the snapshot) does. + uint32_t generationBeforeConnect = cpInitGeneration.load(); + + // Nothing in Tactility's boot sequence calls esp_hosted_connect_to_slave() on its own + // (only WiFi/BT starting does today, as a side effect) - trigger it ourselves so ESP-NOW + // and OTA can work standalone, matching how ESP-NOW works on native (non-hosted) chips. + if (esp_hosted_connect_to_slave() != ESP_OK) { + LOG_W(TAG, "esp_hosted_connect_to_slave() returned an error - will still wait for CP_INIT in case it's async"); + } + + // Wait for the co-processor's own RPC layer to finish booting (ESP_HOSTED_EVENT_CP_INIT, + // logged upstream as "Coprocessor Boot-up") - this is later than the transport/SDIO link + // simply being up, and firing a custom RPC request before this point corrupts the RPC + // channel for subsequent real calls (observed: esp_wifi_init() failing until reboot). + // vTaskSetTimeOutState()/xTaskCheckForTimeOut() track elapsed ticks from a snapshot rather + // than comparing against a fixed deadline tick count, so this is correct across a tick-count + // wraparound (a fixed "now + timeout" deadline can wrap past TickType_t's max and compare as + // already-elapsed on the very next check). + TimeOut_t timeoutState; + vTaskSetTimeOutState(&timeoutState); + TickType_t remaining = pdMS_TO_TICKS(timeoutMs); + while (true) { + if (cpInitGeneration.load() != generationBeforeConnect) { + return true; + } + + if (xTaskCheckForTimeOut(&timeoutState, &remaining) == pdTRUE) { + return false; + } + + // pdTRUE: consume the bit so a stale (pre-existing) signal doesn't let a *later* call + // short-circuit past its own fresh wait. Safe for concurrent waiters because every waiter + // re-checks cpInitGeneration (not just the bit) both before and after waking - FreeRTOS + // delivers a set to all currently-blocked waiters before any auto-clear happens, so a + // waiter still genuinely waiting for a not-yet-arrived CP_INIT simply loops again. + EventBits_t bits = xEventGroupWaitBits(cpInitEventGroup, CP_INIT_BIT, pdTRUE, pdFALSE, remaining); + if ((bits & CP_INIT_BIT) == 0) { + return false; // timed out + } + if (cpInitGeneration.load() != generationBeforeConnect) { + return true; // genuinely new CP_INIT since this call's connect trigger + } + // Otherwise: consumed a stale bit - loop back and re-wait. + } +} + +error_t getInfo(void* /*ctx*/, FirmwareInfo* info) { + if (info == nullptr) { + return ERROR_INVALID_ARGUMENT; + } + esp_hosted_coprocessor_fwver_t version = {}; + esp_err_t err = esp_hosted_get_coprocessor_fwversion(&version); + if (err != ESP_OK) { + return esp_err_to_error(err); + } + info->fw_major = version.major1; + info->fw_minor = version.minor1; + info->fw_patch = version.patch1; + + // Chip identification is best-effort: report the version even if this fails (e.g. a slave + // firmware old enough to lack the esp_hosted_get_cp_info() RPC). + info->name[0] = '\0'; + info->hw_id = 0; + uint32_t chipId = 0; + if (::esp_hosted_get_cp_info(&chipId, info->name, sizeof(info->name)) == ESP_OK) { + info->hw_id = chipId; + } + + return ERROR_NONE; +} + +// esp_hosted's OTA API is a single global stream (esp_hosted_slave_ota_begin/write/end/activate +// take no handle/context - there's exactly one co-processor). FirmwareUpdateHandle is an +// intentionally-incomplete/opaque type (see wifi.h) so it can't be instantiated directly - this +// sentinel object just gives begin()/write()/finish()/abort() a distinct, non-null address to +// pass around and check against, matching the generic FirmwareOps shape. +int otaHandleSentinelStorage = 0; +FirmwareUpdateHandle* otaHandleSentinel = reinterpret_cast(&otaHandleSentinelStorage); + +// Guards against two overlapping OTA sessions (concurrent begin() calls) and against a stale +// handle from a finished/aborted session still being accepted by write()/finish()/abort() - the +// sentinel address alone can't distinguish "the one active session" from "a session that already +// ended", since it's always the same pointer. +std::atomic otaSessionActive{false}; + +error_t beginUpdate(void* /*ctx*/, const FirmwareUpdateRequest* /*req*/, FirmwareUpdateHandle** handle) { + if (handle == nullptr) { + return ERROR_INVALID_ARGUMENT; + } + bool expected = false; + if (!otaSessionActive.compare_exchange_strong(expected, true)) { + return ERROR_RESOURCE_BUSY; + } + esp_err_t err = ::esp_hosted_slave_ota_begin(); + if (err != ESP_OK) { + otaSessionActive = false; + return esp_err_to_error(err); + } + *handle = otaHandleSentinel; + return ERROR_NONE; +} + +error_t writeUpdate(FirmwareUpdateHandle* handle, const void* data, size_t len) { + if (handle != otaHandleSentinel || !otaSessionActive.load()) { + return ERROR_INVALID_ARGUMENT; + } + return esp_err_to_error(::esp_hosted_slave_ota_write( + const_cast(static_cast(data)), static_cast(len))); +} + +error_t finishUpdate(FirmwareUpdateHandle* handle) { + if (handle != otaHandleSentinel || !otaSessionActive.load()) { + return ERROR_INVALID_ARGUMENT; + } + otaSessionActive = false; + return esp_err_to_error(::esp_hosted_slave_ota_end()); +} + +error_t abortUpdate(FirmwareUpdateHandle* handle) { + if (handle != otaHandleSentinel || !otaSessionActive.load()) { + return ERROR_INVALID_ARGUMENT; + } + otaSessionActive = false; + // esp_hosted has no distinct abort call - end() is the only way to close out a begin(), + // successful or not (matches how the previous single-stream app code always called + // esp_hosted_slave_ota_end() on both the success and failure paths). + return esp_err_to_error(::esp_hosted_slave_ota_end()); +} + +error_t activate(void* /*ctx*/) { + return esp_err_to_error(::esp_hosted_slave_ota_activate()); +} + +const FirmwareOps firmwareOps = { + .wait_ready = waitReady, + .get_info = getInfo, + .begin = beginUpdate, + .write = writeUpdate, + .finish = finishUpdate, + .abort = abortUpdate, + .activate = activate, +}; + +} // namespace + +const FirmwareOps* esp32_esp_hosted_ota_get_ops() { + return &firmwareOps; +} + +#endif // CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index ccecbb59..af5325b4 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -17,6 +17,10 @@ #include #include +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#include +#endif + #include #include #include @@ -489,6 +493,24 @@ error_t api_remove_event_callback(Device* device, WifiEventCallback callback) { return ERROR_NOT_FOUND; } +error_t api_get_firmware_ops(Device* /*device*/, const FirmwareOps** ops, void** ctx) { + // ops/ctx are caller-supplied output pointers, reachable from external (ELF) apps via + // wifi_get_firmware_ops() - validate at this API boundary rather than trusting the caller. + if (ops == nullptr || ctx == nullptr) { + return ERROR_INVALID_ARGUMENT; + } +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + // Only meaningful on a hosted board (P4+C6/C5 etc.) - this wifi device is backed by a real + // co-processor with its own updatable firmware there. On a native (non-hosted) chip, this + // device's "radio" is the chip's own built-in WiFi, nothing to update via this interface. + *ops = esp32_esp_hosted_ota_get_ops(); + *ctx = nullptr; // esp32_esp_hosted_ota's FirmwareOps functions are all singleton/global, no per-call ctx needed + return ERROR_NONE; +#else + return ERROR_NOT_SUPPORTED; +#endif +} + const WifiApi esp32_wifi_api = { .get_radio_state = api_get_radio_state, .get_station_state = api_get_station_state, @@ -502,7 +524,8 @@ const WifiApi esp32_wifi_api = { .station_disconnect = api_station_disconnect, .station_get_rssi = api_station_get_rssi, .add_event_callback = api_add_event_callback, - .remove_event_callback = api_remove_event_callback + .remove_event_callback = api_remove_event_callback, + .get_firmware_ops = api_get_firmware_ops }; // ---- Driver lifecycle ---- diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 43ff0f0e..a0d87632 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -39,6 +39,11 @@ if (DEFINED ENV{ESP_IDF_VERSION}) list(APPEND REQUIRES_LIST esp_tinyusb) endif () + if ("${IDF_TARGET}" STREQUAL "esp32p4") + # EspNowBackendHosted.cpp needs esp_hosted.h / esp_hosted_misc.h. + list(APPEND REQUIRES_LIST espressif__esp_hosted) + endif () + else () list(APPEND REQUIRES_LIST diff --git a/Tactility/Include/Tactility/app/fileselection/FileSelection.h b/Tactility/Include/Tactility/app/fileselection/FileSelection.h index f2c08473..e80ae1e3 100644 --- a/Tactility/Include/Tactility/app/fileselection/FileSelection.h +++ b/Tactility/Include/Tactility/app/fileselection/FileSelection.h @@ -1,5 +1,10 @@ #pragma once +#include +#include + +#include + namespace tt::app::fileselection { /** diff --git a/Tactility/Include/Tactility/service/espnow/EspNow.h b/Tactility/Include/Tactility/service/espnow/EspNow.h index d29ef61d..42c2e43f 100644 --- a/Tactility/Include/Tactility/service/espnow/EspNow.h +++ b/Tactility/Include/Tactility/service/espnow/EspNow.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -66,4 +66,4 @@ size_t getMaxDataLength(); } -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED \ No newline at end of file +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED \ No newline at end of file diff --git a/Tactility/Include/Tactility/service/wifi/Wifi.h b/Tactility/Include/Tactility/service/wifi/Wifi.h index 207ddb57..a51e4f72 100644 --- a/Tactility/Include/Tactility/service/wifi/Wifi.h +++ b/Tactility/Include/Tactility/service/wifi/Wifi.h @@ -83,6 +83,17 @@ void disconnect(); /** @return true if the connection isn't unencrypted. */ bool isConnectionSecure(); +/** + * @brief Suspend or resume the periodic background auto-connect scan. + * @param[in] paused when true, the auto-connect timer stops issuing scans until resumed. + * Intended for narrow, short-lived windows where any WiFi/co-processor traffic would be + * unsafe (e.g. a known co-processor reboot in progress) - callers must resume when done. + * Independent of the internal connect()/disconnect() auto-connect pause bookkeeping: only + * a matching setAutoScanPaused(false) clears this, it is never cleared implicitly by a + * connection succeeding/failing or the radio being enabled. + */ +void setAutoScanPaused(bool paused); + /** @return the RSSI value (negative number) or return 1 when not connected. */ int getRssi(); diff --git a/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h index 7d1f83e5..ec98fc33 100644 --- a/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h +++ b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include "ChatState.h" #include "ChatView.h" @@ -43,4 +43,4 @@ public: } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatProtocol.h b/Tactility/Private/Tactility/app/chat/ChatProtocol.h index d62812e9..dd8d397d 100644 --- a/Tactility/Private/Tactility/app/chat/ChatProtocol.h +++ b/Tactility/Private/Tactility/app/chat/ChatProtocol.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -94,4 +94,4 @@ size_t getMaxMessageLength(size_t nicknameLen, size_t targetLen); } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatSettings.h b/Tactility/Private/Tactility/app/chat/ChatSettings.h index 6e4e5b1d..950841f4 100644 --- a/Tactility/Private/Tactility/app/chat/ChatSettings.h +++ b/Tactility/Private/Tactility/app/chat/ChatSettings.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -28,4 +28,4 @@ bool settingsFileExists(); } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatState.h b/Tactility/Private/Tactility/app/chat/ChatState.h index eb0d239d..cf66c208 100644 --- a/Tactility/Private/Tactility/app/chat/ChatState.h +++ b/Tactility/Private/Tactility/app/chat/ChatState.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include @@ -56,4 +56,4 @@ public: } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/app/chat/ChatView.h b/Tactility/Private/Tactility/app/chat/ChatView.h index e246aaf0..794235a8 100644 --- a/Tactility/Private/Tactility/app/chat/ChatView.h +++ b/Tactility/Private/Tactility/app/chat/ChatView.h @@ -4,7 +4,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include "ChatState.h" #include "ChatSettings.h" @@ -76,4 +76,4 @@ public: } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/service/espnow/EspNowBackend.h b/Tactility/Private/Tactility/service/espnow/EspNowBackend.h new file mode 100644 index 00000000..a30b1049 --- /dev/null +++ b/Tactility/Private/Tactility/service/espnow/EspNowBackend.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +namespace tt::service::espnow::backend { + +/** + * Bring up the backend (WiFi if needed + ESP-NOW init + recv/send callbacks + PMK). + * Native backend calls esp_now_* directly; hosted backend bridges over esp_hosted's + * custom RPC channel to the co-processor. Either way, recvCallback is invoked with + * the same signature ESP-NOW itself uses. + */ +bool init(const EspNowConfig& config, esp_now_recv_cb_t recvCallback); + +bool deinit(); + +bool addPeer(const esp_now_peer_info_t& peer); + +bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength); + +/** Returns 0 if not initialized. */ +uint32_t getVersion(); + +} + +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h b/Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h new file mode 100644 index 00000000..dc0d190e --- /dev/null +++ b/Tactility/Private/Tactility/service/espnow/EspNowHostedTransport.h @@ -0,0 +1,33 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +namespace tt::service::espnow::backend { + +/** + * The esp_hosted SDIO transport to the co-processor is brought up by esp_hosted_init() + * (auto-called at process startup via a constructor attribute - always runs regardless of + * WiFi/BT state), but the actual slave handshake only happens via esp_hosted_connect_to_slave(), + * which today is only called as a side effect of the WiFi or BT stack starting + * (esp32_wifi.cpp / vhci_drv.c). Nothing in Tactility's boot sequence calls it on its own, so + * without this, ESP-NOW-over-bridge would require WiFi or BT to already be enabled - unlike + * native ESP32 chips where ESP-NOW works standalone. + * + * This actively triggers the slave connect (safe to call even if already connected - the + * underlying transport_drv_reconfigure() checks is_transport_tx_ready() first and returns + * immediately if so) and then polls until the link is confirmed up or the timeout expires. + * + * @param timeoutMs how long to poll after triggering the connect before giving up + * @return true once the transport is confirmed up, false on timeout + */ +bool waitForHostedTransport(uint32_t timeoutMs); + +} + +#endif // CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/service/espnow/EspNowService.h b/Tactility/Private/Tactility/service/espnow/EspNowService.h index c54ec35d..24008c86 100644 --- a/Tactility/Private/Tactility/service/espnow/EspNowService.h +++ b/Tactility/Private/Tactility/service/espnow/EspNowService.h @@ -4,7 +4,7 @@ #include #endif -#ifdef CONFIG_ESP_WIFI_ENABLED +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -75,4 +75,4 @@ std::shared_ptr findService(); } -#endif // ESP_PLATFORM +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Private/Tactility/service/espnow/EspNowWifi.h b/Tactility/Private/Tactility/service/espnow/EspNowWifi.h deleted file mode 100644 index 1e3d4ff2..00000000 --- a/Tactility/Private/Tactility/service/espnow/EspNowWifi.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#ifdef ESP_PLATFORM -#include -#endif - -#ifdef CONFIG_ESP_WIFI_ENABLED - -#include "Tactility/service/espnow/EspNow.h" - -namespace tt::service::espnow { - -bool initWifi(const EspNowConfig& config); - -bool deinitWifi(); - -} - -#endif \ No newline at end of file diff --git a/Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h b/Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h new file mode 100644 index 00000000..45cd0ef0 --- /dev/null +++ b/Tactility/Private/Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 Tactility + * + * SPDX-License-Identifier: Apache-2.0 + * + * Wire format for bridging ESP-NOW across esp-hosted-mcu's custom RPC + * channel (esp_hosted_send_custom_data / esp_hosted_register_custom_callback). + * Shared verbatim between slave (runs real esp_now_*) and host (P4, no radio). + * + * Kept in sync by hand with the copy in the esp-hosted-mcu fork at + * common/espnow_bridge/esp_hosted_espnow_bridge_proto.h (Shadowtrance/esp-hosted-mcu, + * branch feature/espnow-bridge) — this is a wire-format contract between two repos. + */ + +#ifndef __ESP_HOSTED_ESPNOW_BRIDGE_PROTO_H +#define __ESP_HOSTED_ESPNOW_BRIDGE_PROTO_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* msg_id namespace: any uint32_t except 0xFFFFFFFF (reserved by esp_hosted). */ +#define ESPNOW_BRIDGE_MSG_BASE 0xE500U +#define ESPNOW_BRIDGE_REQ_INIT (ESPNOW_BRIDGE_MSG_BASE + 0) +#define ESPNOW_BRIDGE_RESP_INIT (ESPNOW_BRIDGE_MSG_BASE + 1) +#define ESPNOW_BRIDGE_REQ_DEINIT (ESPNOW_BRIDGE_MSG_BASE + 2) +#define ESPNOW_BRIDGE_RESP_DEINIT (ESPNOW_BRIDGE_MSG_BASE + 3) +#define ESPNOW_BRIDGE_REQ_ADD_PEER (ESPNOW_BRIDGE_MSG_BASE + 4) +#define ESPNOW_BRIDGE_RESP_ADD_PEER (ESPNOW_BRIDGE_MSG_BASE + 5) +#define ESPNOW_BRIDGE_REQ_SEND (ESPNOW_BRIDGE_MSG_BASE + 6) +#define ESPNOW_BRIDGE_RESP_SEND (ESPNOW_BRIDGE_MSG_BASE + 7) +#define ESPNOW_BRIDGE_EVT_RECV (ESPNOW_BRIDGE_MSG_BASE + 8) +#define ESPNOW_BRIDGE_EVT_SEND_STATUS (ESPNOW_BRIDGE_MSG_BASE + 9) + +#define ESPNOW_BRIDGE_ETH_ALEN 6 +#define ESPNOW_BRIDGE_KEY_LEN 16 +/* ESP-NOW v2.0 payload limit (ESP_NOW_MAX_DATA_LEN_V2 in esp_now.h). The slave reports its + * actual negotiated esp_now_get_version() back to the host in RESP_INIT; hosts talking to a + * v1.0-only slave still work, they just never send payloads over 250B (native ESP-NOW callers + * enforce that themselves based on the reported version, same as the non-bridged backend). */ +#define ESPNOW_BRIDGE_MAX_DATA_LEN 1470 + +/* req_init mode: matches tt::service::espnow::Mode / WIFI_MODE_STA vs WIFI_MODE_AP on the slave */ +#define ESPNOW_BRIDGE_MODE_STATION 0U +#define ESPNOW_BRIDGE_MODE_ACCESS_POINT 1U + +/* req: ESPNOW_BRIDGE_REQ_INIT */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* echoed back verbatim in the matching resp_status_t/resp_init_t so a + * response arriving after its request already timed out (e.g. a slow + * REQ_INIT retry) can't be mistaken for the answer to a newer request + * of the same type - see EspNowBackendHosted.cpp's doRequest(). */ + uint8_t pmk[ESPNOW_BRIDGE_KEY_LEN]; + uint8_t channel; /* 0 = use current STA/AP channel */ + uint8_t long_range; /* bool as uint8_t: fixed 1-byte width for this hand-synced wire struct */ + uint8_t mode; /* ESPNOW_BRIDGE_MODE_STATION / ESPNOW_BRIDGE_MODE_ACCESS_POINT: which WiFi + * mode+interface the slave should bring up and register ESP-NOW against */ +} espnow_bridge_req_init_t; + +/* req: ESPNOW_BRIDGE_REQ_DEINIT */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* see espnow_bridge_req_init_t::txn_id */ +} espnow_bridge_req_deinit_t; + +/* resp: ESPNOW_BRIDGE_RESP_DEINIT / RESP_ADD_PEER / RESP_SEND */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* copied from the request this responds to */ + int32_t esp_err; /* raw esp_err_t from the slave-side call */ +} espnow_bridge_resp_status_t; + +/* resp: ESPNOW_BRIDGE_RESP_INIT */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* copied from the request this responds to */ + int32_t esp_err; /* raw esp_err_t from the slave-side esp_now_init() call */ + uint32_t espnow_version; /* esp_now_get_version() result, 0 if esp_err != ESP_OK */ +} espnow_bridge_resp_init_t; + +/* req: ESPNOW_BRIDGE_REQ_ADD_PEER */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* see espnow_bridge_req_init_t::txn_id */ + uint8_t peer_addr[ESPNOW_BRIDGE_ETH_ALEN]; + uint8_t lmk[ESPNOW_BRIDGE_KEY_LEN]; + uint8_t channel; + uint8_t encrypt; /* bool as uint8_t: fixed 1-byte width for this hand-synced wire struct */ + uint8_t ifidx; /* ESPNOW_BRIDGE_MODE_STATION / ESPNOW_BRIDGE_MODE_ACCESS_POINT: which + * interface (WIFI_IF_STA / WIFI_IF_AP) to bind the peer to on the slave, + * matching esp_now_peer_info_t::ifidx on native */ +} espnow_bridge_req_add_peer_t; + +/* req: ESPNOW_BRIDGE_REQ_SEND */ +typedef struct __attribute__((packed)) { + uint32_t txn_id; /* see espnow_bridge_req_init_t::txn_id */ + uint8_t dest_addr[ESPNOW_BRIDGE_ETH_ALEN]; + uint8_t broadcast; /* bool as uint8_t; true: dest_addr ignored, esp_now_send(NULL, ...) */ + uint16_t data_len; + uint8_t data[ESPNOW_BRIDGE_MAX_DATA_LEN]; +} espnow_bridge_req_send_t; + +/* event: ESPNOW_BRIDGE_EVT_RECV (slave-initiated, unsolicited) */ +typedef struct __attribute__((packed)) { + uint8_t src_addr[ESPNOW_BRIDGE_ETH_ALEN]; + uint8_t des_addr[ESPNOW_BRIDGE_ETH_ALEN]; + int8_t rssi; + uint8_t channel; + uint16_t data_len; + uint8_t data[ESPNOW_BRIDGE_MAX_DATA_LEN]; +} espnow_bridge_evt_recv_t; + +/* event: ESPNOW_BRIDGE_EVT_SEND_STATUS (slave-initiated, unsolicited) */ +typedef struct __attribute__((packed)) { + uint8_t peer_addr[ESPNOW_BRIDGE_ETH_ALEN]; + uint8_t success; /* bool as uint8_t: fixed 1-byte width for this hand-synced wire struct */ +} espnow_bridge_evt_send_status_t; + +#ifdef __cplusplus +} +#endif + +#endif /* __ESP_HOSTED_ESPNOW_BRIDGE_PROTO_H */ diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index f882456a..83517aff 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -80,7 +80,7 @@ namespace service { #ifdef ESP_PLATFORM namespace development { extern const ServiceManifest manifest; } #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) namespace espnow { extern const ServiceManifest manifest; } #endif // Secondary (UI) @@ -161,7 +161,7 @@ namespace app { namespace screenshot { extern const AppManifest manifest; } #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) namespace chat { extern const AppManifest manifest; } #endif } @@ -229,7 +229,7 @@ static void registerInternalApps() { addAppManifest(app::screenshot::manifest); #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) addAppManifest(app::chat::manifest); #endif @@ -328,7 +328,7 @@ static void registerAndStartPrimaryServices() { addService(service::development::manifest); #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) addService(service::espnow::manifest); #endif #ifdef ESP_PLATFORM diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index 25fda35d..fe95aba7 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -187,4 +187,4 @@ extern const AppManifest manifest = { } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatProtocol.cpp b/Tactility/Source/app/chat/ChatProtocol.cpp index e3cc1803..e9b5742b 100644 --- a/Tactility/Source/app/chat/ChatProtocol.cpp +++ b/Tactility/Source/app/chat/ChatProtocol.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -171,4 +171,4 @@ size_t getMaxMessageLength(size_t nicknameLen, size_t targetLen) { } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp index 8c63bf2d..84aee796 100644 --- a/Tactility/Source/app/chat/ChatSettings.cpp +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -201,4 +201,4 @@ bool settingsFileExists() { } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatState.cpp b/Tactility/Source/app/chat/ChatState.cpp index 100650e2..4c817409 100644 --- a/Tactility/Source/app/chat/ChatState.cpp +++ b/Tactility/Source/app/chat/ChatState.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include @@ -57,4 +57,4 @@ std::vector ChatState::getFilteredMessages() const { } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/app/chat/ChatView.cpp b/Tactility/Source/app/chat/ChatView.cpp index d7f4cca0..eb9368fa 100644 --- a/Tactility/Source/app/chat/ChatView.cpp +++ b/Tactility/Source/app/chat/ChatView.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -296,4 +296,4 @@ void ChatView::onChannelCancel(lv_event_t* e) { } // namespace tt::app::chat -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNow.cpp b/Tactility/Source/service/espnow/EspNow.cpp index aaee10d0..d0797ec6 100644 --- a/Tactility/Source/service/espnow/EspNow.cpp +++ b/Tactility/Source/service/espnow/EspNow.cpp @@ -2,7 +2,7 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include @@ -97,4 +97,4 @@ size_t getMaxDataLength() { } -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowBackendHosted.cpp b/Tactility/Source/service/espnow/EspNowBackendHosted.cpp new file mode 100644 index 00000000..b6966795 --- /dev/null +++ b/Tactility/Source/service/espnow/EspNowBackendHosted.cpp @@ -0,0 +1,313 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include +#include + +// esp_hosted_misc.h lacks its own extern "C" guard, so its declarations get C++ name-mangled +// when included from a .cpp file, causing "undefined reference" at link time against the +// library's plain-C symbols (esp_hosted_send_custom_data/register_custom_callback). +extern "C" { +#include +} +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace tt::service::espnow::backend { + +constexpr auto* TAG = "EspNowBackendHosted"; +constexpr TickType_t REQUEST_TIMEOUT = 2000U / portTICK_PERIOD_MS; + +static RecursiveMutex requestMutex; +static Semaphore responseSemaphore(1, 0); +// Callbacks (onRespGeneric/onRespInit) run from esp_hosted's own dispatch task, concurrently +// with doRequest() running on the caller's task - std::atomic instead of requestMutex here +// because taking requestMutex from the callback would deadlock: doRequest() holds it for its +// entire wait, including while blocked in responseSemaphore.acquire() waiting on the very +// callback that would need the lock. +static std::atomic lastResponseErr{ESP_FAIL}; +static std::atomic waitingForResponse{false}; +// The txn_id doRequest() is currently waiting a response for. Response handlers only accept a +// response (release the semaphore) if its txn_id matches - otherwise a delayed response to a +// prior, already-timed-out request (e.g. a REQ_INIT retry's earlier attempt) can't be mistaken +// for the answer to whatever request is active now, even a later request of the same type. +static std::atomic activeTxnId{0}; +static std::atomic nextTxnId{1}; + +// Written by init()/deinit() on the caller's task, read by onEvtRecv() on esp_hosted's dispatch +// task - atomic so a callback removal during deinit() can't race a concurrent RX event dispatch +// (the dispatch task must see either the old callback or nullptr, never a torn/stale pointer). +static std::atomic userRecvCallback{nullptr}; +static uint32_t espnowVersion = 0; + +static std::atomic lastReportedInitVersion{0}; + +static void onRespGeneric(uint32_t msgId, const uint8_t* data, size_t dataLen, void* ctx) { + (void)msgId; (void)ctx; + if (dataLen != sizeof(espnow_bridge_resp_status_t)) { + LOG_E(TAG, "Malformed response (%zu bytes)", dataLen); + return; + } + const auto* resp = reinterpret_cast(data); + if (!waitingForResponse || resp->txn_id != activeTxnId.load()) { + return; // stale response for a request that's no longer (or never was) being waited on + } + lastResponseErr = static_cast(resp->esp_err); + responseSemaphore.release(); +} + +static void onRespInit(uint32_t msgId, const uint8_t* data, size_t dataLen, void* ctx) { + (void)msgId; (void)ctx; + if (dataLen != sizeof(espnow_bridge_resp_init_t)) { + LOG_E(TAG, "Malformed RESP_INIT (%zu bytes)", dataLen); + return; + } + const auto* resp = reinterpret_cast(data); + if (!waitingForResponse || resp->txn_id != activeTxnId.load()) { + return; // stale response for a request that's no longer (or never was) being waited on + } + lastResponseErr = static_cast(resp->esp_err); + lastReportedInitVersion = resp->espnow_version; + responseSemaphore.release(); +} + +static void onEvtRecv(uint32_t msgId, const uint8_t* data, size_t dataLen, void* ctx) { + (void)msgId; (void)ctx; + if (dataLen != sizeof(espnow_bridge_evt_recv_t)) { + LOG_E(TAG, "Malformed RX event (%zu bytes)", dataLen); + return; + } + esp_now_recv_cb_t recvCallback = userRecvCallback.load(); + if (recvCallback == nullptr) { + return; + } + + const auto* evt = reinterpret_cast(data); + if (evt->data_len > ESPNOW_BRIDGE_MAX_DATA_LEN) { + LOG_E(TAG, "Malformed RX event: data_len %u exceeds buffer capacity", (unsigned)evt->data_len); + return; + } + + // Build a transient esp_now_recv_info_t matching the shape native ESP-NOW delivers. + wifi_pkt_rx_ctrl_t rxCtrl = {}; + rxCtrl.rssi = evt->rssi; + rxCtrl.channel = evt->channel; + + uint8_t srcAddr[ESPNOW_BRIDGE_ETH_ALEN]; + uint8_t desAddr[ESPNOW_BRIDGE_ETH_ALEN]; + memcpy(srcAddr, evt->src_addr, ESPNOW_BRIDGE_ETH_ALEN); + memcpy(desAddr, evt->des_addr, ESPNOW_BRIDGE_ETH_ALEN); + + esp_now_recv_info_t recvInfo = {}; + recvInfo.src_addr = srcAddr; + recvInfo.des_addr = desAddr; + recvInfo.rx_ctrl = &rxCtrl; + + recvCallback(&recvInfo, evt->data, evt->data_len); +} + +static void onEvtSendStatus(uint32_t msgId, const uint8_t* data, size_t dataLen, void* ctx) { + (void)msgId; (void)ctx; + if (dataLen != sizeof(espnow_bridge_evt_send_status_t)) { + LOG_E(TAG, "Malformed send-status event (%zu bytes)", dataLen); + return; + } + const auto* evt = reinterpret_cast(data); + if (!evt->success) { + LOG_W(TAG, "Peer send reported failure"); + } +} + +/** Sends a request and blocks (holding requestMutex) until the matching response arrives or + * times out. Every request struct's first field is `uint32_t txn_id`; doRequest() stamps a + * fresh transaction id directly into that leading 4 bytes of reqData (memcpy rather than a + * `uint32_t*` into the packed struct, which trips -Waddress-of-packed-member even though the + * first field of every one of these structs is always naturally aligned) before sending - the + * matching response handler (onRespGeneric/onRespInit) only accepts a response whose txn_id + * equals this call's, so a late response to an earlier, already-timed-out request (e.g. a + * REQ_INIT retry) can't be mistaken for the answer to this request. */ +static esp_err_t doRequest(uint32_t reqMsgId, uint8_t* reqData, size_t reqDataLen) { + auto lock = requestMutex.asScopedLock(); + lock.lock(); + + // Drain any stale token left by a response that arrived after a previous request's + // doRequest() call already timed out - without this, that late release would let this new + // request's acquire() below return immediately with a stale lastResponseErr/version instead + // of actually waiting for its own response. + while (responseSemaphore.acquire(0)) {} + + uint32_t txnId = nextTxnId.fetch_add(1); + memcpy(reqData, &txnId, sizeof(txnId)); + activeTxnId = txnId; + + waitingForResponse = true; + esp_err_t sendErr = esp_hosted_send_custom_data(reqMsgId, reqData, reqDataLen); + if (sendErr != ESP_OK) { + waitingForResponse = false; + LOG_E(TAG, "esp_hosted_send_custom_data() failed for req 0x%lx: %d", (unsigned long)reqMsgId, sendErr); + return sendErr; + } + + bool gotResponse = responseSemaphore.acquire(REQUEST_TIMEOUT); + waitingForResponse = false; + if (!gotResponse) { + LOG_E(TAG, "Timed out waiting for response to req 0x%lx", (unsigned long)reqMsgId); + return ESP_ERR_TIMEOUT; + } + + return lastResponseErr; +} + +static bool registerCallbacksOnce() { + static bool registered = false; + if (registered) { + return true; + } + + bool allOk = true; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_RESP_INIT, onRespInit, nullptr) == ESP_OK; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_RESP_DEINIT, onRespGeneric, nullptr) == ESP_OK; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_RESP_ADD_PEER, onRespGeneric, nullptr) == ESP_OK; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_RESP_SEND, onRespGeneric, nullptr) == ESP_OK; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_EVT_RECV, onEvtRecv, nullptr) == ESP_OK; + allOk &= esp_hosted_register_custom_callback(ESPNOW_BRIDGE_EVT_SEND_STATUS, onEvtSendStatus, nullptr) == ESP_OK; + + if (!allOk) { + LOG_E(TAG, "Failed to register one or more custom callbacks"); + return false; + } + + registered = true; + return true; +} + +bool init(const EspNowConfig& config, esp_now_recv_cb_t recvCallback) { + constexpr uint32_t HOSTED_TRANSPORT_WAIT_TIMEOUT_MS = 10000; + if (!waitForHostedTransport(HOSTED_TRANSPORT_WAIT_TIMEOUT_MS)) { + LOG_E(TAG, "esp_hosted transport didn't come up within %" PRIu32 "ms - is WiFi enabled?", + HOSTED_TRANSPORT_WAIT_TIMEOUT_MS); + return false; + } + + if (!registerCallbacksOnce()) { + return false; + } + + userRecvCallback = recvCallback; + + espnow_bridge_req_init_t req = {}; + memcpy(req.pmk, config.masterKey, ESPNOW_BRIDGE_KEY_LEN); + req.channel = config.channel; + req.long_range = config.longRange; + req.mode = (config.mode == Mode::AccessPoint) ? ESPNOW_BRIDGE_MODE_ACCESS_POINT : ESPNOW_BRIDGE_MODE_STATION; + + // The slave registers its custom RPC handlers (slave_espnow_bridge_init()) partway through + // its own app_main(), which isn't gated on - and can run later than - the CP_INIT event + // waitForHostedTransport() already waited for. So the very first REQ_INIT after a cold + // boot can still race ahead of the slave being ready to handle it, timing out once even + // though the transport itself is fine. Retry a few times with a short backoff before + // giving up - the slave finishes registering shortly after CP_INIT in practice. + constexpr int MAX_INIT_ATTEMPTS = 4; + constexpr TickType_t INIT_RETRY_DELAY = 500U / portTICK_PERIOD_MS; + + esp_err_t err = ESP_FAIL; + for (int attempt = 1; attempt <= MAX_INIT_ATTEMPTS; attempt++) { + err = doRequest(ESPNOW_BRIDGE_REQ_INIT, reinterpret_cast(&req), sizeof(req)); + if (err == ESP_OK) { + break; + } + if (attempt < MAX_INIT_ATTEMPTS) { + LOG_W(TAG, "REQ_INIT attempt %d/%d failed (%d) - retrying, slave may still be booting", + attempt, MAX_INIT_ATTEMPTS, err); + vTaskDelay(INIT_RETRY_DELAY); + } + } + + if (err != ESP_OK) { + LOG_E(TAG, "Remote esp_now_init() failed after %d attempts: %d", MAX_INIT_ATTEMPTS, err); + userRecvCallback = nullptr; + return false; + } + + espnowVersion = lastReportedInitVersion; + if (espnowVersion != 0) { + LOG_I(TAG, "Co-processor ESP-NOW version: %u.0", (unsigned)espnowVersion); + } else { + LOG_W(TAG, "Co-processor didn't report an ESP-NOW version - assuming v1.0"); + espnowVersion = 1; + } + return true; +} + +bool deinit() { + espnow_bridge_req_deinit_t req = {}; + esp_err_t err = doRequest(ESPNOW_BRIDGE_REQ_DEINIT, reinterpret_cast(&req), sizeof(req)); + userRecvCallback = nullptr; + espnowVersion = 0; + if (err != ESP_OK) { + LOG_E(TAG, "Remote esp_now_deinit() failed: %d", err); + return false; + } + return true; +} + +bool addPeer(const esp_now_peer_info_t& peer) { + espnow_bridge_req_add_peer_t req = {}; + memcpy(req.peer_addr, peer.peer_addr, ESPNOW_BRIDGE_ETH_ALEN); + memcpy(req.lmk, peer.lmk, ESPNOW_BRIDGE_KEY_LEN); + req.channel = peer.channel; + req.encrypt = peer.encrypt; + req.ifidx = (peer.ifidx == WIFI_IF_AP) ? ESPNOW_BRIDGE_MODE_ACCESS_POINT : ESPNOW_BRIDGE_MODE_STATION; + + esp_err_t err = doRequest(ESPNOW_BRIDGE_REQ_ADD_PEER, reinterpret_cast(&req), sizeof(req)); + if (err != ESP_OK) { + LOG_E(TAG, "Remote esp_now_add_peer() failed: %d", err); + return false; + } + return true; +} + +bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) { + if (bufferLength > ESPNOW_BRIDGE_MAX_DATA_LEN) { + LOG_E(TAG, "Payload too large for bridge (%zu bytes)", bufferLength); + return false; + } + + espnow_bridge_req_send_t req = {}; + req.broadcast = (address == nullptr); + if (!req.broadcast) { + memcpy(req.dest_addr, address, ESPNOW_BRIDGE_ETH_ALEN); + } + req.data_len = static_cast(bufferLength); + memcpy(req.data, buffer, bufferLength); + + // Must send the full fixed-size struct, not just header + payload: the slave's on_req_send + // validates data_len against sizeof(espnow_bridge_req_send_t) exactly, so a truncated wire + // length was always rejected as ESP_ERR_INVALID_SIZE (this made every send fail while + // receive still worked fine, since RX events aren't size-truncated the same way). + esp_err_t err = doRequest(ESPNOW_BRIDGE_REQ_SEND, reinterpret_cast(&req), sizeof(req)); + return err == ESP_OK; +} + +uint32_t getVersion() { + return espnowVersion; +} + +} + +#endif // CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowBackendNative.cpp b/Tactility/Source/service/espnow/EspNowBackendNative.cpp new file mode 100644 index 00000000..93b8bc65 --- /dev/null +++ b/Tactility/Source/service/espnow/EspNowBackendNative.cpp @@ -0,0 +1,208 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include +#include + +#include +#include + +#include + +namespace tt::service::espnow::backend { + +constexpr auto* TAG = "EspNowBackendNative"; +static bool wifiStartedByEspNow = false; +static bool longRangeProtocolApplied = false; +static wifi_interface_t longRangeInterface = WIFI_IF_STA; +static uint8_t savedProtocolBitmap = 0; + +static bool deinitWifi(); + +static bool initWifi(const EspNowConfig& config) { + auto wifi_state = wifi::getRadioState(); + bool wifi_already_running = (wifi_state != wifi::RadioState::Off && wifi_state != wifi::RadioState::OffPending); + + wifi_mode_t mode; + if (config.mode == Mode::Station) { + mode = wifi_mode_t::WIFI_MODE_STA; + } else { + mode = wifi_mode_t::WIFI_MODE_AP; + } + + // Only initialize WiFi if it's not already running; ESP-NOW coexists with STA mode + wifiStartedByEspNow = !wifi_already_running; + if (wifiStartedByEspNow) { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + if (esp_wifi_init(&cfg) != ESP_OK) { + LOG_E(TAG,"esp_wifi_init() failed"); + wifiStartedByEspNow = false; + return false; + } + + if (esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK) { + LOG_E(TAG,"esp_wifi_set_storage() failed"); + esp_wifi_deinit(); + wifiStartedByEspNow = false; + return false; + } + + if (esp_wifi_set_mode(mode) != ESP_OK) { + LOG_E(TAG,"esp_wifi_set_mode() failed"); + esp_wifi_deinit(); + wifiStartedByEspNow = false; + return false; + } + + if (esp_wifi_start() != ESP_OK) { + LOG_E(TAG,"esp_wifi_start() failed"); + esp_wifi_deinit(); + wifiStartedByEspNow = false; + return false; + } + + if (config.channel != 0 && esp_wifi_set_channel(config.channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { + LOG_E(TAG,"esp_wifi_set_channel() failed"); + deinitWifi(); + return false; + } + } else if (config.channel != 0 && + wifi_state != wifi::RadioState::ConnectionActive && wifi_state != wifi::RadioState::ConnectionPending) { + // WifiService already owns the radio but isn't associated to an AP: an unassociated STA's + // operating channel is otherwise left undefined/wherever it last scanned to, which silently + // breaks ESP-NOW (esp_now_send() reports success but nothing reaches a peer sitting on a + // different channel) - only skip this when actually connected/connecting, where the STA is + // already locked to the AP's channel and forcing a different one would disrupt that link. + if (esp_wifi_set_channel(config.channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { + LOG_W(TAG, "esp_wifi_set_channel() failed on externally managed radio - ESP-NOW may not reach peers"); + } + } + + if (config.longRange) { + wifi_interface_t wifi_interface = (config.mode == Mode::Station) ? WIFI_IF_STA : WIFI_IF_AP; + + // Preserve whatever protocol mask the interface already had (e.g. set by WifiService if + // it owns this interface) so it can be restored in deinitWifi() - esp_wifi_set_protocol() + // below otherwise permanently stomps it, even when ESP-NOW doesn't own the radio. Skip + // applying long-range at all if the snapshot fails: without a saved mask to restore, + // changing the protocol would permanently alter an externally-managed interface with no + // way to undo it later. + uint8_t previousBitmap = 0; + bool hadPreviousBitmap = esp_wifi_get_protocol(wifi_interface, &previousBitmap) == ESP_OK; + + if (!hadPreviousBitmap) { + LOG_W(TAG, "esp_wifi_get_protocol() failed - skipping long-range (can't safely restore protocol mask later)"); + } else if (esp_wifi_set_protocol(wifi_interface, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR) != ESP_OK) { + LOG_W(TAG,"esp_wifi_set_protocol() for long range failed"); + } else { + longRangeProtocolApplied = true; + longRangeInterface = wifi_interface; + savedProtocolBitmap = previousBitmap; + } + } + + LOG_I(TAG, "WiFi initialized for ESP-NOW (wifi already running: %s)", wifi_already_running ? "yes" : "no"); + return true; +} + +static bool deinitWifi() { + // Restore the interface's protocol mask before either stopping WiFi or handing the radio + // back to WifiService - covers both paths, since an externally managed radio (wifiStartedByEspNow + // == false) is the case that actually needs its prior protocol bits put back. + if (longRangeProtocolApplied) { + if (esp_wifi_set_protocol(longRangeInterface, savedProtocolBitmap) != ESP_OK) { + LOG_W(TAG, "Failed to restore prior WiFi protocol bitmap"); + } + longRangeProtocolApplied = false; + } + + if (wifiStartedByEspNow) { + esp_wifi_stop(); + esp_wifi_deinit(); + wifiStartedByEspNow = false; + LOG_I(TAG, "WiFi stopped (was started by ESP-NOW)"); + } else { + LOG_I(TAG, "WiFi left running (managed by WiFi service)"); + } + return true; +} + +static uint32_t espnowVersion = 0; + +bool init(const EspNowConfig& config, esp_now_recv_cb_t recvCallback) { + if (!initWifi(config)) { + LOG_E(TAG, "initWifi() failed"); + return false; + } + + if (esp_now_init() != ESP_OK) { + LOG_E(TAG, "esp_now_init() failed"); + deinitWifi(); + return false; + } + + if (esp_now_register_recv_cb(recvCallback) != ESP_OK) { + LOG_E(TAG, "esp_now_register_recv_cb() failed"); + esp_now_deinit(); + deinitWifi(); + return false; + } + + if (esp_now_set_pmk(config.masterKey) != ESP_OK) { + LOG_E(TAG, "esp_now_set_pmk() failed"); + esp_now_deinit(); + deinitWifi(); + return false; + } + + espnowVersion = 0; + if (esp_now_get_version(&espnowVersion) == ESP_OK) { + LOG_I(TAG, "ESP-NOW version: %u.0", (unsigned)espnowVersion); + } else { + LOG_W(TAG, "Failed to get ESP-NOW version"); + } + + return true; +} + +bool deinit() { + bool success = true; + + if (esp_now_deinit() != ESP_OK) { + LOG_E(TAG, "esp_now_deinit() failed"); + success = false; + } + + if (!deinitWifi()) { + LOG_E(TAG, "deinitWifi() failed"); + success = false; + } + + espnowVersion = 0; + return success; +} + +bool addPeer(const esp_now_peer_info_t& peer) { + if (esp_now_add_peer(&peer) != ESP_OK) { + LOG_E(TAG, "Failed to add peer"); + return false; + } else { + LOG_I(TAG, "Peer added"); + return true; + } +} + +bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) { + return esp_now_send(address, buffer, bufferLength) == ESP_OK; +} + +uint32_t getVersion() { + return espnowVersion; +} + +} + +#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowHostedTransport.cpp b/Tactility/Source/service/espnow/EspNowHostedTransport.cpp new file mode 100644 index 00000000..73bb4c9d --- /dev/null +++ b/Tactility/Source/service/espnow/EspNowHostedTransport.cpp @@ -0,0 +1,33 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) + +#include + +#include + +namespace tt::service::espnow::backend { + +// Thin wrapper: the actual CP_INIT wait/generation-tracking logic lives in the kernel driver +// (Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp, reached via the generic +// FirmwareOps::wait_ready() interface in tactility/drivers/wifi.h) so it's a single source of +// truth shared with any other caller (e.g. an external OTA app), instead of being duplicated +// here. +bool waitForHostedTransport(uint32_t timeoutMs) { + Device* device = wifi_find_first_registered_device(); + if (device == nullptr) { + return false; + } + const FirmwareOps* ops = nullptr; + void* ctx = nullptr; + if (wifi_get_firmware_ops(device, &ops, &ctx) != ERROR_NONE) { + return false; + } + return ops->wait_ready(ctx, timeoutMs); +} + +} + +#endif // CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowService.cpp b/Tactility/Source/service/espnow/EspNowService.cpp index c008a0ff..7f19930c 100644 --- a/Tactility/Source/service/espnow/EspNowService.cpp +++ b/Tactility/Source/service/espnow/EspNowService.cpp @@ -2,13 +2,13 @@ #include #endif -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) +#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include #include #include #include -#include +#include #include #include @@ -60,33 +60,13 @@ void EspNowService::enableFromDispatcher(const EspNowConfig& config) { return; } - if (!initWifi(config)) { - LOG_E(TAG,"initWifi() failed"); + if (!backend::init(config, receiveCallback)) { + LOG_E(TAG, "backend::init() failed"); return; } - if (esp_now_init() != ESP_OK) { - LOG_E(TAG,"esp_now_init() failed"); - return; - } - - if (esp_now_register_recv_cb(receiveCallback) != ESP_OK) { - LOG_E(TAG,"esp_now_register_recv_cb() failed"); - return; - } - - //#if CONFIG_ESPNOW_ENABLE_POWER_SAVE - // ESP_ERROR_CHECK( esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW) ); - // ESP_ERROR_CHECK( esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL) ); - //#endif - - if (esp_now_set_pmk(config.masterKey) != ESP_OK) { - LOG_E(TAG,"esp_now_set_pmk() failed"); - return; - } - - espnowVersion = 0; - if (esp_now_get_version(&espnowVersion) == ESP_OK) { + espnowVersion = backend::getVersion(); + if (espnowVersion != 0) { LOG_I(TAG, "ESP-NOW version: %u.0", (unsigned)espnowVersion); } else { LOG_W(TAG, "Failed to get ESP-NOW version"); @@ -119,12 +99,8 @@ void EspNowService::disableFromDispatcher() { return; } - if (esp_now_deinit() != ESP_OK) { - LOG_E(TAG,"esp_now_deinit() failed"); - } - - if (!deinitWifi()) { - LOG_E(TAG,"deinitWifi() failed"); + if (!backend::deinit()) { + LOG_E(TAG, "backend::deinit() failed"); } espnowVersion = 0; @@ -164,7 +140,7 @@ bool EspNowService::isEnabled() const { } bool EspNowService::addPeer(const esp_now_peer_info_t& peer) { - if (esp_now_add_peer(&peer) != ESP_OK) { + if (!backend::addPeer(peer)) { LOG_E(TAG,"Failed to add peer"); return false; } else { @@ -180,7 +156,7 @@ bool EspNowService::send(const uint8_t* address, const uint8_t* buffer, size_t b if (!isEnabled()) { return false; } else { - return esp_now_send(address, buffer, bufferLength) == ESP_OK; + return backend::send(address, buffer, bufferLength); } } @@ -223,4 +199,4 @@ extern const ServiceManifest manifest = { } -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED +#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/espnow/EspNowWifi.cpp b/Tactility/Source/service/espnow/EspNowWifi.cpp deleted file mode 100644 index 229af886..00000000 --- a/Tactility/Source/service/espnow/EspNowWifi.cpp +++ /dev/null @@ -1,92 +0,0 @@ -#ifdef ESP_PLATFORM -#include -#endif - -#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) - -#include -#include - -#include -#include - -#include - -namespace tt::service::espnow { - -constexpr auto* TAG = "EspNowService"; -static bool wifiStartedByEspNow = false; - -bool initWifi(const EspNowConfig& config) { - auto wifi_state = wifi::getRadioState(); - bool wifi_already_running = (wifi_state != wifi::RadioState::Off && wifi_state != wifi::RadioState::OffPending); - - wifi_mode_t mode; - if (config.mode == Mode::Station) { - mode = wifi_mode_t::WIFI_MODE_STA; - } else { - mode = wifi_mode_t::WIFI_MODE_AP; - } - - // Only initialize WiFi if it's not already running; ESP-NOW coexists with STA mode - wifiStartedByEspNow = !wifi_already_running; - if (wifiStartedByEspNow) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - if (esp_wifi_init(&cfg) != ESP_OK) { - LOG_E(TAG,"esp_wifi_init() failed"); - return false; - } - - if (esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK) { - LOG_E(TAG,"esp_wifi_set_storage() failed"); - return false; - } - - if (esp_wifi_set_mode(mode) != ESP_OK) { - LOG_E(TAG,"esp_wifi_set_mode() failed"); - return false; - } - - if (esp_wifi_start() != ESP_OK) { - LOG_E(TAG,"esp_wifi_start() failed"); - return false; - } - - if (esp_wifi_set_channel(config.channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { - LOG_E(TAG,"esp_wifi_set_channel() failed"); - return false; - } - } - - if (config.longRange) { - wifi_interface_t wifi_interface; - if (config.mode == Mode::Station) { - wifi_interface = WIFI_IF_STA; - } else { - wifi_interface = WIFI_IF_AP; - } - - if (esp_wifi_set_protocol(wifi_interface, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR) != ESP_OK) { - LOG_W(TAG,"esp_wifi_set_protocol() for long range failed"); - } - } - - LOG_I(TAG, "WiFi initialized for ESP-NOW (wifi already running: %s)", wifi_already_running ? "yes" : "no"); - return true; -} - -bool deinitWifi() { - if (wifiStartedByEspNow) { - esp_wifi_stop(); - esp_wifi_deinit(); - wifiStartedByEspNow = false; - LOG_I(TAG, "WiFi stopped (was started by ESP-NOW)"); - } else { - LOG_I(TAG, "WiFi left running (managed by WiFi service)"); - } - return true; -} - -} // namespace tt::service::espnow - -#endif // CONFIG_SOC_WIFI_SUPPORTED && !CONFIG_SLAVE_SOC_WIFI_SUPPORTED diff --git a/Tactility/Source/service/wifi/Wifi.cpp b/Tactility/Source/service/wifi/Wifi.cpp index 933251fc..d3b07407 100644 --- a/Tactility/Source/service/wifi/Wifi.cpp +++ b/Tactility/Source/service/wifi/Wifi.cpp @@ -17,8 +17,10 @@ #include #include #include +#include #include +#include namespace tt::service::wifi { @@ -63,7 +65,14 @@ struct WifiServiceState { std::shared_ptr> pubsub = std::make_shared>(); RecursiveMutex mutex; bool secureConnection = false; + // Internal: set by connect()/disconnect() while a manual attempt is in flight, cleared on + // connection success/failure. Distinct from externalScanPause below - the two must not + // clobber each other, otherwise a caller's explicit pause (e.g. AutoScanPauseGuard during a + // co-processor OTA) can be silently cleared by an unrelated connect/disconnect finishing. bool pauseAutoConnect = false; + // External: only setAutoScanPaused() may set/clear this. Read alongside pauseAutoConnect to + // gate scan scheduling (both must be false to scan). + std::atomic externalScanPause{false}; bool connectionTargetRemember = false; settings::WifiApSettings connectionTarget; uint16_t scanRecordLimit = TT_WIFI_SCAN_RECORD_LIMIT; @@ -224,11 +233,12 @@ bool findAutoConnectAp(settings::WifiApSettings& out) { void dispatchAutoConnect() { LOG_I(TAG, "dispatchAutoConnect()"); - if (state.pauseAutoConnect) { + if (state.pauseAutoConnect || state.externalScanPause.load()) { // A manual disconnect() or an in-progress manual connect() has paused - // auto-connect. This is called on every SCAN_FINISHED, not just the - // auto-connect timer's own scans (e.g. WifiManage re-scans on show), - // so it must honor the pause instead of reconnecting unconditionally. + // auto-connect, or a caller (e.g. AutoScanPauseGuard) has externally paused it. + // This is called on every SCAN_FINISHED, not just the auto-connect timer's own + // scans (e.g. WifiManage re-scans on show), so it must honor the pause instead of + // reconnecting unconditionally. return; } RadioState radio_state = getRadioState(); @@ -249,7 +259,8 @@ void dispatchAutoConnect() { } bool shouldScanForAutoConnect() { - bool radio_scannable = getRadioState() == RadioState::On && !isScanning() && !state.pauseAutoConnect; + bool radio_scannable = getRadioState() == RadioState::On && !isScanning() && + !state.pauseAutoConnect && !state.externalScanPause.load(); if (!radio_scannable) return false; TickType_t current_time = kernel::getTicks(); @@ -328,6 +339,11 @@ void onWifiDeviceEvent(Device* /*device*/, void* /*context*/, ::WifiEvent event) publish(event); } +void autoScanSetPaused(bool paused) { + LOG_I(TAG, "autoScanSetPaused(%d)", (int)paused); + state.externalScanPause = paused; +} + } // namespace // region Public functions @@ -416,6 +432,10 @@ void disconnect() { getMainDispatcher().dispatch([] { dispatchDisconnect(); }); } +void setAutoScanPaused(bool paused) { + autoScanSetPaused(paused); +} + void setScanRecords(uint16_t records) { LOG_I(TAG, "setScanRecords(%u)", records); if (!started) return; @@ -479,6 +499,8 @@ public: bool onStart(ServiceContext& /*service*/) override { check(!started); + wifi_auto_scan_set_paused_function(autoScanSetPaused); + state.device = wifi_find_first_registered_device(); if (state.device == nullptr) { LOG_W(TAG, "No WiFi device found"); @@ -515,6 +537,8 @@ public: state.secureConnection = false; state.pauseAutoConnect = false; state.device = nullptr; + + wifi_auto_scan_set_paused_function(nullptr); } }; diff --git a/TactilityC/Include/tt_app_fileselection.h b/TactilityC/Include/tt_app_fileselection.h new file mode 100644 index 00000000..45334732 --- /dev/null +++ b/TactilityC/Include/tt_app_fileselection.h @@ -0,0 +1,32 @@ +#pragma once + +#include "tt_app.h" +#include "tt_bundle.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Show a file selection dialog that allows the user to select an existing file. + * @return the launch ID of the dialog, which can be compared in onResult to identify the source + */ +AppLaunchId tt_app_fileselection_start_for_existing_file(); + +/** + * Show a file selection dialog that allows the user to select a new or existing file. + * @return the launch ID of the dialog, which can be compared in onResult to identify the source + */ +AppLaunchId tt_app_fileselection_start_for_existing_or_new_file(); + +/** + * @param[in] handle the result bundle passed to onResult + * @param[out] buffer the buffer to store the selected path in + * @param[in] bufferSize the size of the buffer (must include room for the null terminator) + * @return true if a path was selected and written to buffer, false otherwise + */ +bool tt_app_fileselection_get_result_path(BundleHandle handle, char* buffer, uint32_t bufferSize); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityC/Source/tt_app_fileselection.cpp b/TactilityC/Source/tt_app_fileselection.cpp new file mode 100644 index 00000000..acdbf1e4 --- /dev/null +++ b/TactilityC/Source/tt_app_fileselection.cpp @@ -0,0 +1,29 @@ +#include "tt_app_fileselection.h" +#include +#include +#include + +#include +#include + +extern "C" { + +AppLaunchId tt_app_fileselection_start_for_existing_file() { + return tt::app::fileselection::startForExistingFile(); +} + +AppLaunchId tt_app_fileselection_start_for_existing_or_new_file() { + return tt::app::fileselection::startForExistingOrNewFile(); +} + +bool tt_app_fileselection_get_result_path(BundleHandle handle, char* buffer, uint32_t bufferSize) { + auto path = tt::app::fileselection::getResultPath(*(tt::Bundle*)handle); + if (path.empty() || bufferSize == 0) { + return false; + } + strncpy(buffer, path.c_str(), bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + return true; +} + +} diff --git a/TactilityC/Source/tt_init.cpp b/TactilityC/Source/tt_init.cpp index 8c1fa018..6e9554dc 100644 --- a/TactilityC/Source/tt_init.cpp +++ b/TactilityC/Source/tt_init.cpp @@ -2,6 +2,7 @@ #include "tt_app.h" #include "tt_app_alertdialog.h" +#include "tt_app_fileselection.h" #include "tt_app_selectiondialog.h" #include "tt_bundle.h" #include "tt_gps.h" @@ -41,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -274,6 +276,9 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(tt_app_get_parameters), ESP_ELFSYM_EXPORT(tt_app_set_result), ESP_ELFSYM_EXPORT(tt_app_has_result), + ESP_ELFSYM_EXPORT(tt_app_fileselection_start_for_existing_file), + ESP_ELFSYM_EXPORT(tt_app_fileselection_start_for_existing_or_new_file), + ESP_ELFSYM_EXPORT(tt_app_fileselection_get_result_path), ESP_ELFSYM_EXPORT(tt_app_selectiondialog_start), ESP_ELFSYM_EXPORT(tt_app_selectiondialog_get_result_index), ESP_ELFSYM_EXPORT(tt_app_alertdialog_start), @@ -460,6 +465,8 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(ppa_do_scale_rotate_mirror), // esp_cache.h ESP_ELFSYM_EXPORT(esp_cache_msync), + // esp_system.h + ESP_ELFSYM_EXPORT(esp_restart), #endif // delimiter ESP_ELFSYM_END diff --git a/TactilityKernel/include/tactility/drivers/wifi.h b/TactilityKernel/include/tactility/drivers/wifi.h index 71fedf6d..0a5b1030 100644 --- a/TactilityKernel/include/tactility/drivers/wifi.h +++ b/TactilityKernel/include/tactility/drivers/wifi.h @@ -5,6 +5,7 @@ #include #include +#include #ifdef __cplusplus extern "C" { @@ -196,6 +197,15 @@ struct WifiApi { * @return ERROR_NONE on success */ error_t (*remove_event_callback)(struct Device* device, WifiEventCallback callback); + + /** + * Get this device's co-processor firmware update interface, if it has one. + * @param[in] device the wifi device + * @param[out] ops filled in with the FirmwareOps vtable and its ctx, if supported + * @return ERROR_NONE on success, ERROR_NOT_SUPPORTED if this device has no updatable + * co-processor (ops is left untouched in that case) + */ + error_t (*get_firmware_ops)(struct Device* device, const struct FirmwareOps** ops, void** ctx); }; extern const struct DeviceType WIFI_TYPE; @@ -216,6 +226,7 @@ error_t wifi_station_disconnect(struct Device* device); error_t wifi_station_get_rssi(struct Device* device, int32_t* rssi); error_t wifi_add_event_callback(struct Device* device, void* callback_context, WifiEventCallback callback); error_t wifi_remove_event_callback(struct Device* device, WifiEventCallback callback); +error_t wifi_get_firmware_ops(struct Device* device, const struct FirmwareOps** ops, void** ctx); #ifdef __cplusplus } diff --git a/TactilityKernel/include/tactility/firmware/firmware.h b/TactilityKernel/include/tactility/firmware/firmware.h new file mode 100644 index 00000000..94b2b7c6 --- /dev/null +++ b/TactilityKernel/include/tactility/firmware/firmware.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Generic co-processor/companion-radio firmware info & update interface. + * + * Not tied to any particular driver: some devices are backed by a separate co-processor with its + * own updatable firmware (e.g. esp-hosted-mcu's P4+C6/C5 pairing, reachable via + * WifiApi::get_firmware_ops() in tactility/drivers/wifi.h); most aren't. A driver with no such + * co-processor returns ERROR_NOT_SUPPORTED from whatever function exposes this interface + * (leaving its output params untouched) - callers must check the result before using any + * FirmwareOps function. + */ +struct FirmwareInfo { + char name[32]; // human-readable target name, e.g. "esp32c6" - empty if unavailable + uint32_t hw_id; // raw hardware/chip id, implementation-defined + uint32_t fw_major; + uint32_t fw_minor; + uint32_t fw_patch; +}; + +struct FirmwareUpdateRequest { + size_t image_size; + uint32_t flags; +}; + +struct FirmwareUpdateHandle; + +struct FirmwareOps { + /** + * Wait for the co-processor to be ready to accept firmware info/update calls. + * @param[in] ctx opaque context, passed to every other FirmwareOps function + * @param[in] timeout_ms how long to wait + * @return true if ready, false on timeout + */ + bool (*wait_ready)(void* ctx, uint32_t timeout_ms); + + /** + * Get the co-processor's current firmware/hardware info. + * @return ERROR_NONE on success, or an error code (e.g. not ready) + */ + error_t (*get_info)(void* ctx, struct FirmwareInfo* info); + + /** + * Begin a firmware update, filling in *handle for use with write()/finish()/abort(). + * @return ERROR_NONE on success, or an error code + */ + error_t (*begin)(void* ctx, const struct FirmwareUpdateRequest* req, struct FirmwareUpdateHandle** handle); + + /** Write a chunk of firmware image data. @return ERROR_NONE on success, or an error code */ + error_t (*write)(struct FirmwareUpdateHandle* handle, const void* data, size_t len); + + /** Finalize a successful update. @return ERROR_NONE on success, or an error code */ + error_t (*finish)(struct FirmwareUpdateHandle* handle); + + /** Abort an in-progress update (e.g. after a write() failure). */ + error_t (*abort)(struct FirmwareUpdateHandle* handle); + + /** + * Activate the most recently finish()ed firmware, causing the co-processor to reboot into it. + * @note Not guaranteed to be supported by every co-processor firmware version - callers should + * check get_info()'s reported version against their own known-good threshold before calling + * this, and fall back to treating the update as complete after finish() otherwise. + */ + error_t (*activate)(void* ctx); +}; + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/include/tactility/wifi_auto_scan.h b/TactilityKernel/include/tactility/wifi_auto_scan.h new file mode 100644 index 00000000..b5352900 --- /dev/null +++ b/TactilityKernel/include/tactility/wifi_auto_scan.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Suspend or resume WifiService's periodic background auto-connect scan. + * + * Not a device operation - see tactility/drivers/wifi.h for the actual radio API. This exists + * for narrow, short-lived windows where any WiFi/co-processor traffic would be unsafe (e.g. a + * known co-processor reboot in progress during an OTA update) - callers must resume when done. + * Independent of WifiService's own internal connect()/disconnect() pause bookkeeping: it is + * never cleared implicitly by a connection succeeding/failing or the radio being enabled, only a + * matching wifi_auto_scan_set_paused(false) clears it. + * + * The real implementation lives in the Tactility WiFi service + * (Tactility/Source/service/wifi/Wifi.cpp), a layer above TactilityKernel - TactilityKernel + * can't call up into it directly (and mustn't link against it: TactilityKernelTests links + * TactilityKernel alone, without Tactility). Tactility registers its implementation at startup + * via wifi_auto_scan_set_paused_function(); until then (or on a build that never links Tactility, e.g. a + * bare-kernel target) this is a no-op, same as WifiApi::get_firmware_ops() returning + * ERROR_NOT_SUPPORTED when nothing is registered. + * + * @param[in] paused when true, the auto-connect timer stops issuing scans until resumed + */ +void wifi_auto_scan_set_paused(bool paused); + +/** @brief Register the real implementation. Called once by the Tactility WiFi service. */ +void wifi_auto_scan_set_paused_function(void (*set_paused)(bool paused)); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/source/drivers/wifi.cpp b/TactilityKernel/source/drivers/wifi.cpp index f25b158b..8ac182d1 100644 --- a/TactilityKernel/source/drivers/wifi.cpp +++ b/TactilityKernel/source/drivers/wifi.cpp @@ -68,6 +68,14 @@ error_t wifi_remove_event_callback(struct Device* device, WifiEventCallback call return WIFI_API(device)->remove_event_callback(device, callback); } +error_t wifi_get_firmware_ops(struct Device* device, const struct FirmwareOps** ops, void** ctx) { + auto* api = WIFI_API(device); + if (api->get_firmware_ops == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->get_firmware_ops(device, ops, ctx); +} + const struct DeviceType WIFI_TYPE = { .name = "wifi" }; diff --git a/TactilityKernel/source/kernel_symbols.c b/TactilityKernel/source/kernel_symbols.c index 42ff02a2..c8e86f96 100644 --- a/TactilityKernel/source/kernel_symbols.c +++ b/TactilityKernel/source/kernel_symbols.c @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -326,7 +327,10 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(wifi_station_get_rssi), DEFINE_MODULE_SYMBOL(wifi_add_event_callback), DEFINE_MODULE_SYMBOL(wifi_remove_event_callback), + DEFINE_MODULE_SYMBOL(wifi_get_firmware_ops), DEFINE_MODULE_SYMBOL(WIFI_TYPE), + // wifi_auto_scan + DEFINE_MODULE_SYMBOL(wifi_auto_scan_set_paused), // drivers/usb_host_hid DEFINE_MODULE_SYMBOL(usb_host_hid_is_connected), DEFINE_MODULE_SYMBOL(usb_host_hid_subscribe), diff --git a/TactilityKernel/source/wifi_auto_scan.c b/TactilityKernel/source/wifi_auto_scan.c new file mode 100644 index 00000000..a2e19659 --- /dev/null +++ b/TactilityKernel/source/wifi_auto_scan.c @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +static void (*registeredSetPaused)(bool paused) = NULL; + +void wifi_auto_scan_set_paused_function(void (*set_paused)(bool paused)) { + registeredSetPaused = set_paused; +} + +void wifi_auto_scan_set_paused(bool paused) { + if (registeredSetPaused != NULL) { + registeredSetPaused(paused); + } +}