diff --git a/components/bt/ble_profiles/nimble/CMakeLists.txt b/components/bt/ble_profiles/nimble/CMakeLists.txt index 9d1a93b9955..d36bf55b901 100644 --- a/components/bt/ble_profiles/nimble/CMakeLists.txt +++ b/components/bt/ble_profiles/nimble/CMakeLists.txt @@ -16,5 +16,10 @@ add_subdirectory(ble_ots) list(APPEND _srcs ${ble_ots_srcs}) list(APPEND _include_dirs ${ble_ots_include_dirs}) +# Electronic Shelf Label (pulls in OTS via Kconfig select) +add_subdirectory(ble_esl) +list(APPEND _srcs ${ble_esl_srcs}) +list(APPEND _include_dirs ${ble_esl_include_dirs}) + set(nimble_profiles_srcs "${_srcs}" PARENT_SCOPE) set(nimble_profiles_include_dirs "${_include_dirs}" PARENT_SCOPE) diff --git a/components/bt/ble_profiles/nimble/Kconfig.in b/components/bt/ble_profiles/nimble/Kconfig.in index 6e1c16e21f6..e9d0920f615 100644 --- a/components/bt/ble_profiles/nimble/Kconfig.in +++ b/components/bt/ble_profiles/nimble/Kconfig.in @@ -7,3 +7,6 @@ # Object Transfer Service source "$IDF_PATH/components/bt/ble_profiles/nimble/ble_ots/Kconfig.in" + +# Electronic Shelf Label +source "$IDF_PATH/components/bt/ble_profiles/nimble/ble_esl/Kconfig.in" diff --git a/components/bt/ble_profiles/nimble/ble_esl/CMakeLists.txt b/components/bt/ble_profiles/nimble/ble_esl/CMakeLists.txt new file mode 100644 index 00000000000..fdf62c35d2f --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/CMakeLists.txt @@ -0,0 +1,44 @@ +# Electronic Shelf Label (ESL) profile core for the NimBLE host. +# +# This directory is not a standalone IDF component: it is pulled in by +# components/bt/ble_profiles/nimble/CMakeLists.txt via add_subdirectory() and +# exports its sources and public include directory to the parent scope, which +# forwards them to the bt component. +# + +set(ble_esl_srcs "" PARENT_SCOPE) +set(ble_esl_include_dirs "" PARENT_SCOPE) + +if(NOT CONFIG_BLE_ESL_ENABLED) + return() +endif() + +set(_srcs "${CMAKE_CURRENT_LIST_DIR}/src/common/esl_common.c") + +# The role-private headers (src//*_int.h) sit next to the sources that +# include them, so only the public include/ has to be exported. +set(_include_dirs "${CMAKE_CURRENT_LIST_DIR}/include") + +if(CONFIG_BLE_ESL_TAG_ROLE) + list(APPEND _srcs + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_state.c" + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_cmd_display.c" + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_cmd_led.c" + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_cmd_lifecycle.c" + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_pawr.c" + "${CMAKE_CURRENT_LIST_DIR}/src/esl/esl_gatts.c" + ) +endif() + +if(CONFIG_BLE_ESL_AP_ROLE) + list(APPEND _srcs + "${CMAKE_CURRENT_LIST_DIR}/src/ap/ap_connection.c" + "${CMAKE_CURRENT_LIST_DIR}/src/ap/ap_pawr.c" + "${CMAKE_CURRENT_LIST_DIR}/src/ap/ap_lifecycle.c" + "${CMAKE_CURRENT_LIST_DIR}/src/ap/ap_command.c" + "${CMAKE_CURRENT_LIST_DIR}/src/ap/ap_gattc.c" + ) +endif() + +set(ble_esl_srcs "${_srcs}" PARENT_SCOPE) +set(ble_esl_include_dirs "${_include_dirs}" PARENT_SCOPE) diff --git a/components/bt/ble_profiles/nimble/ble_esl/Kconfig.in b/components/bt/ble_profiles/nimble/ble_esl/Kconfig.in new file mode 100644 index 00000000000..e2c139500b6 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/Kconfig.in @@ -0,0 +1,146 @@ +menu "Electronic Shelf Label (ESL) (EXPERIMENTAL)" + + config BLE_ESL_ENABLED + bool "Enable Electronic Shelf Label (ESL) (EXPERIMENTAL)" + depends on BT_NIMBLE_ENABLED + depends on IDF_EXPERIMENTAL_FEATURES + default n + help + Build the Electronic Shelf Label profile core on top of the NimBLE + host. The Tag and Access Point roles are selected separately below, + so a device only pays for the role it actually implements. + + Both roles exchange commands over Periodic Advertising with + Responses (PAwR). Display images are transferred over the Object + Transfer Service, which is selected automatically when needed. + + Note: this profile is experimental. Its public API, Kconfig option + names and event layout may change without notice in future releases, + and it is not recommended for production use yet. Enable + IDF_EXPERIMENTAL_FEATURES to make this option selectable. + + config BLE_ESL_TAG_ROLE + bool "Enable ESL Tag role" + depends on BLE_ESL_ENABLED + default n + help + Enable the Electronic Shelf Label (ESL) Tag role. + When enabled, the device can operate as an ESL tag that + receives commands from an Access Point over PAwR. + + config BLE_ESL_AP_ROLE + bool "Enable ESL Access Point role" + depends on BLE_ESL_ENABLED + default n + select BLE_OTS_ENABLED + select BLE_OTS_CLIENT_ENABLED + help + Enable the Electronic Shelf Label (ESL) Access Point role. + When enabled, the device can operate as an AP that manages + and synchronises ESL tags via PAwR. The AP always pulls in + the OTS client, which it uses to push images to the tags. + + menu "Access Point Configuration" + depends on BLE_ESL_AP_ROLE + + config BLE_ESL_AP_MAX_CONNECTIONS + int "Maximum simultaneous ACL connections" + default 8 + range 1 BT_NIMBLE_MAX_CONNECTIONS + help + Maximum number of simultaneous ACL connections to ESL + devices. Increasing this value allows the AP to configure + more tags in parallel but consumes additional memory and + radio time. Must not exceed the NimBLE connection limit. + + config BLE_ESL_AP_MAX_ESLS + int "Maximum number of tracked ESLs" + default 100 + range 1 65535 + help + Maximum number of ESL tags tracked across all states + (unassociated, synchronising, synchronised, etc.). + Increase this if the deployment manages a large number + of tags. Each tracked ESL consumes a small amount of + heap memory. + + endmenu + + menu "Tag Configuration" + depends on BLE_ESL_TAG_ROLE + + config BLE_ESL_MAX_DISPLAYS + int "Maximum number of displays" + default 1 + range 0 255 + help + Maximum number of display devices exposed by the ESL tag. + Set this to match the actual number of e-ink or other + displays attached to the hardware. + + config BLE_ESL_MAX_IMAGES + int "Maximum number of stored images" + default 4 + range 1 255 + help + Maximum number of images that can be stored on the tag. + This determines the number of OTS objects available for + image transfer. Increasing this value requires more + storage space (flash or RAM). + + config BLE_ESL_MAX_LEDS + int "Maximum number of LEDs" + default 1 + range 0 255 + help + Maximum number of LEDs exposed by the ESL tag. Set this + to match the actual number of controllable LEDs on the + hardware. + + config BLE_ESL_MAX_SENSORS + int "Maximum number of sensors" + default 1 + range 0 255 + help + Maximum number of sensors exposed by the ESL tag (e.g. + temperature, battery voltage). Set this to match the + actual number of sensors available on the hardware. + + config BLE_ESL_OTS_SUPPORT + bool "Enable OTP Object Server for image storage" + default y + select BLE_OTS_ENABLED + select BLE_OTS_SERVER_ENABLED + help + Enable the Object Transfer Protocol (OTP) server so the + AP can push display images to this tag over GATT. Disable + this only if the tag has no display or images are + provisioned by other means. + + config BLE_ESL_MAX_IMAGE_SIZE + int "Maximum image size (bytes)" + depends on BLE_ESL_OTS_SUPPORT + default 4096 + range 1 4194304 + help + Maximum size in octets reserved for each stored image + object (its OTS Allocated Size). An OACP Write is rejected + with Invalid Parameter when Offset + Length exceeds this + value, since the tag does not support the OACP Append + feature. Set this to the largest image the display can + hold. The underlying data buffer is still allocated on + demand, so this value only sets the accepted write limit. + + config BLE_ESL_VENDOR_SPECIFIC + bool "Enable vendor-specific opcode support" + default n + help + Enable handling of vendor-specific ESL opcodes. When + enabled, the tag advertises a PnP ID in the Device + Information Service (DIS) and accepts vendor-defined + commands. Enable this if custom, manufacturer-specific + ESL operations are required. + + endmenu + +endmenu diff --git a/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl.h b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl.h new file mode 100644 index 00000000000..8135ac4bd43 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl.h @@ -0,0 +1,325 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl.h + * @brief Electronic Shelf Label (ESL) Profile — ESL Role (Server / Peripheral) + * + * Public API for the ESL component. Manages the five-state ESL state machine, + * ESL Service GATT database, command processing (ECP and PAwR), display/LED/sensor + * element management, OTP Object Server for image storage, and LE Secure Connections + * security requirements. + */ + +#ifndef BLE_ESL_H +#define BLE_ESL_H + +#include +#include +#include "esp_err.h" +#include "ble_esl_common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== Kconfig Defaults ========================== */ + +#ifndef CONFIG_BLE_ESL_MAX_DISPLAYS +#define CONFIG_BLE_ESL_MAX_DISPLAYS 1 /*!< Maximum number of displays */ +#endif + +#ifndef CONFIG_BLE_ESL_MAX_IMAGES +#define CONFIG_BLE_ESL_MAX_IMAGES 4 /*!< Maximum number of stored images */ +#endif + +#ifndef CONFIG_BLE_ESL_MAX_LEDS +#define CONFIG_BLE_ESL_MAX_LEDS 1 /*!< Maximum number of LEDs */ +#endif + +#ifndef CONFIG_BLE_ESL_MAX_SENSORS +#define CONFIG_BLE_ESL_MAX_SENSORS 1 /*!< Maximum number of sensors */ +#endif + +#ifndef CONFIG_BLE_ESL_OTS_SUPPORT +#define CONFIG_BLE_ESL_OTS_SUPPORT 1 /*!< Enable OTS Object Server for image storage */ +#endif + +#ifndef CONFIG_BLE_ESL_MAX_IMAGE_SIZE +#define CONFIG_BLE_ESL_MAX_IMAGE_SIZE 4096 /*!< Allocated Size reserved per image object (bytes) */ +#endif + +#ifndef CONFIG_BLE_ESL_VENDOR_SPECIFIC +#define CONFIG_BLE_ESL_VENDOR_SPECIFIC 0 /*!< Enable vendor-specific opcode support */ +#endif + +/* ========================== Configuration ========================== */ + +/** + * @brief ESL component configuration + * + * Passed to ble_esl_init() to configure element counts, display/LED/sensor + * information, image storage, and vendor-specific support. + * + * @note The array members carry no explicit length field (except @c sensor_info, + * which is paired with @c sensor_info_len). Their length is implied by the + * matching count member and CANNOT be validated by ble_esl_init() — the + * caller MUST size each array as documented below: + * - @c display_info : num_displays entries + * - @c image_writable_mask : num_images entries + * - @c led_info : num_leds bytes + * All arrays must stay valid for the lifetime of the ESL component, as + * only the pointers are stored. + */ +typedef struct { + uint8_t num_displays; /*!< Number of displays supported (0 to CONFIG_BLE_ESL_MAX_DISPLAYS) */ + uint8_t num_images; /*!< Max storable images, i.e. Max_Image_Index + 1 (0 to CONFIG_BLE_ESL_MAX_IMAGES) */ + uint8_t num_leds; /*!< Number of LEDs supported (0 to CONFIG_BLE_ESL_MAX_LEDS) */ + uint8_t num_sensors; /*!< Number of sensors supported (0 to CONFIG_BLE_ESL_MAX_SENSORS) */ + const ble_esl_display_info_t *display_info; /*!< Display Data array (num_displays entries), NULL if 0 displays */ + const bool *image_writable_mask; /*!< Per-image writable flag array (num_images entries); NULL means every image is read-only (static/pre-loaded) */ + const uint8_t *led_info; /*!< Pre-built LED Information array (1 byte each), NULL if 0 leds */ + const uint8_t *sensor_info; /*!< Concatenated Sensor Information structures, NULL if 0 sensors */ + uint16_t sensor_info_len; /*!< Total byte length of sensor_info array */ + bool vendor_specific; /*!< True to register DIS PnP ID characteristic */ + const uint8_t *pnp_id; /*!< PnP ID value (7 octets), required when vendor_specific */ +} ble_esl_config_t; + +/* ========================== Event Types ========================== */ + +/** + * @brief ESL event types dispatched through the application callback + */ +typedef enum { + BLE_ESL_EVT_STATE_CHANGED = 0, /*!< State transition occurred */ + BLE_ESL_EVT_IMAGE_WRITE, /*!< Image data received via OTS */ + BLE_ESL_EVT_DISPLAY_IMAGE, /*!< Display a stored image */ + BLE_ESL_EVT_REFRESH_DISPLAY, /*!< Refresh the current display image */ + BLE_ESL_EVT_SENSOR_READ, /*!< Read sensor data request */ + BLE_ESL_EVT_LED_CONTROL, /*!< LED control request */ + BLE_ESL_EVT_FACTORY_RESET, /*!< Factory reset initiated */ + BLE_ESL_EVT_UNASSOCIATE, /*!< Unassociation initiated */ +} ble_esl_event_t; + +/* ========================== Event Data Structures ========================== */ + +/** + * @brief Event data for BLE_ESL_EVT_STATE_CHANGED + */ +typedef struct { + ble_esl_state_t prev_state; /*!< Previous ESL state */ + ble_esl_state_t new_state; /*!< New ESL state */ +} ble_esl_state_changed_evt_param_t; + +/** + * @brief Event data for BLE_ESL_EVT_IMAGE_WRITE + */ +typedef struct { + uint8_t image_index; /*!< Image storage index (0 to Max_Image_Index) */ + const uint8_t *data; /*!< Pointer to received image data */ + uint32_t length; /*!< Length of image data in bytes */ + uint32_t offset; /*!< Write offset within the image object */ +} ble_esl_image_write_evt_param_t; + +/** + * @brief Event data for BLE_ESL_EVT_DISPLAY_IMAGE + */ +typedef struct { + uint8_t display_index; /*!< Index of the target display (0-based) */ + uint8_t image_index; /*!< Index of the stored image to display (0-based) */ +} ble_esl_display_image_evt_param_t; + +/** + * @brief Event data for BLE_ESL_EVT_REFRESH_DISPLAY + */ +typedef struct { + uint8_t display_index; /*!< Index of the display to refresh (0-based) */ +} ble_esl_refresh_display_evt_param_t; + +/** + * @brief Event data for BLE_ESL_EVT_SENSOR_READ + */ +typedef struct { + uint8_t sensor_index; /*!< Index of the sensor to read (0-based) */ +} ble_esl_sensor_read_evt_param_t; + +/** + * @brief Event data for BLE_ESL_EVT_LED_CONTROL + * + * Carries all LED control parameters to the application callback. + */ +typedef struct { + uint8_t led_index; /*!< Target LED index (0-based) */ + uint8_t color_red; /*!< 2-bit red component (0–3) */ + uint8_t color_green; /*!< 2-bit green component (0–3) */ + uint8_t color_blue; /*!< 2-bit blue component (0–3) */ + uint8_t brightness; /*!< 2-bit brightness (0=25%, 1=50%, 2=75%, 3=100%) */ + uint64_t pattern; /*!< 40-bit flashing pattern (lower 40 bits) */ + uint8_t bit_off_period; /*!< Off-time per bit = value × 2 ms */ + uint8_t bit_on_period; /*!< On-time per bit = value × 2 ms */ + uint8_t repeat_type; /*!< 0 = count, 1 = time duration */ + uint16_t repeats_duration; /*!< 15-bit repetition count or duration (seconds) */ + bool is_off; /*!< True = LED should be turned off */ +} ble_esl_led_control_evt_param_t; + +/** + * @brief Union of all ESL event data structures + * + * Discriminated by the ble_esl_event_t argument in the callback. + * FACTORY_RESET and UNASSOCIATE events carry no data (param is NULL). + */ +typedef union { + ble_esl_state_changed_evt_param_t state_changed; /*!< BLE_ESL_EVT_STATE_CHANGED */ + ble_esl_image_write_evt_param_t image_write; /*!< BLE_ESL_EVT_IMAGE_WRITE */ + ble_esl_display_image_evt_param_t display_image; /*!< BLE_ESL_EVT_DISPLAY_IMAGE */ + ble_esl_refresh_display_evt_param_t refresh_display; /*!< BLE_ESL_EVT_REFRESH_DISPLAY */ + ble_esl_sensor_read_evt_param_t sensor_read; /*!< BLE_ESL_EVT_SENSOR_READ */ + ble_esl_led_control_evt_param_t led_control; /*!< BLE_ESL_EVT_LED_CONTROL */ +} ble_esl_cb_param_t; + +/** + * @brief ESL application event callback type + * + * @param event Event type identifier + * @param param Pointer to event-specific data (may be NULL for events with no data) + */ +typedef void (*ble_esl_cb_t)(ble_esl_event_t event, ble_esl_cb_param_t *param); + +/* ========================== Public APIs ========================== */ + +/** + * @brief Initialize the ESL component + * + * Registers the ESL Service GATT database, initializes the OTS Object Server + * (if enabled and images > 0), registers DIS PnP ID if vendor_specific is true, + * and sets the state machine to Unassociated. Does not start advertising. + * + * @note This function modifies the GLOBAL NimBLE Security Manager configuration + * (@c ble_hs_cfg.sm_io_cap, @c sm_bonding, @c sm_mitm, @c sm_sc, + * @c sm_our_key_dist, @c sm_their_key_dist) to the settings the ESL Profile + * mandates: LE Secure Connections, bonding, No Input No Output, no MITM. + * These settings are host-wide and therefore affect all other profiles and + * connections. The previous values are not saved and ble_esl_deinit() does + * not restore them. An application requiring different SM settings must + * apply them after this function returns. + * + * @param[in] config Pointer to ESL configuration structure + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if config is invalid + * - ESP_ERR_NO_MEM on allocation failure + * - ESP_ERR_INVALID_STATE if already initialized + */ +esp_err_t ble_esl_init(const ble_esl_config_t *config); + +/** + * @brief Deinitialize the ESL component + * + * Stops advertising, disconnects, removes the GATT database, tears down OTS + * objects, releases all allocated resources, and resets internal state. + * + * @note The global NimBLE Security Manager settings applied by ble_esl_init() + * are left in place; reconfigure @c ble_hs_cfg explicitly if needed. + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_STATE if not initialized + */ +esp_err_t ble_esl_deinit(void); + +/** + * @brief Start ESL operation + * + * Enters the GAP advertising mode appropriate for the current state. + * Must be called after ble_esl_init(). + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_STATE if not initialized or already started + */ +esp_err_t ble_esl_start(void); + +/** + * @brief Stop ESL operation + * + * Stops advertising, disconnects, and stops PAwR synchronization if active. + * The ESL state is preserved. + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_STATE if not started + * - ESP_FAIL if the PAwR sync could not be terminated; the ESL is still + * stopped (advertising off, timers disarmed) and the sync is torn down on + * the next state transition or on ble_esl_deinit() + */ +esp_err_t ble_esl_stop(void); + +/** + * @brief Register the application event callback + * + * All ESL events are dispatched through this single callback. + * Must be called after ble_esl_init() and before ble_esl_start(). + * + * @param[in] callback Application event callback function + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if callback is NULL + * - ESP_ERR_INVALID_STATE if not initialized + */ +esp_err_t ble_esl_register_cb(ble_esl_cb_t callback); + +/** + * @brief Get the current ESL state + * + * Synchronous. Returns a snapshot of the state machine; the state is only + * changed from the NimBLE host task, so the value may already be stale when + * read from another task. Use BLE_ESL_EVT_STATE_CHANGED to react to changes. + * + * @return Current ESL state; BLE_ESL_STATE_UNASSOCIATED if not initialized + */ +ble_esl_state_t ble_esl_get_state(void); + +/** + * @brief Set or clear the Service Needed flag + * + * Controls bit 0 of the Basic State bitmap. Set when the application detects + * a condition requiring AP attention (e.g., low battery, display fault). + * + * @param[in] flag True to set Service Needed, false to clear it + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_STATE if not initialized + */ +esp_err_t ble_esl_set_service_needed(bool flag); + +/** + * @brief Report sensor data in response to a BLE_ESL_EVT_SENSOR_READ event + * + * Must be called once per BLE_ESL_EVT_SENSOR_READ event. On success + * (error_code = 0), a Sensor Value response is sent. On failure, an Error + * response with the given error code is sent. + * + * @param[in] sensor_index Index of the sensor (must match the event's sensor_index) + * @param[in] error_code 0 = success; non-zero = ESL error code + * @param[in] data Pointer to sensor data (ignored when error_code != 0) + * @param[in] data_len Length of sensor data (1–15, ignored when error_code != 0) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if data is NULL or data_len invalid when error_code = 0 + * - ESP_ERR_INVALID_STATE if no sensor read is pending for sensor_index + */ +esp_err_t ble_esl_report_sensor_data(uint8_t sensor_index, uint8_t error_code, + const uint8_t *data, uint8_t data_len); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_ap.h b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_ap.h new file mode 100644 index 00000000000..c9da5071d7b --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_ap.h @@ -0,0 +1,651 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap.h + * @brief Electronic Shelf Label (ESL) Profile — Access Point (AP) Public API + * + * Implements the GATT Client / GAP Central role of the ESL Profile v1.0.1. + * Manages ESL scanning, connection, configuration, PAwR broadcasting, + * command dispatch, and per-ESL lifecycle state tracking. + */ + +#ifndef BLE_ESL_AP_H +#define BLE_ESL_AP_H + +#include +#include +#include "esp_err.h" +#include "ble_esl_common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== Kconfig Defaults ========================== */ + +#ifndef CONFIG_BLE_ESL_AP_MAX_CONNECTIONS +#define CONFIG_BLE_ESL_AP_MAX_CONNECTIONS 8 /*!< Max simultaneous ACL connections */ +#endif + +#ifndef CONFIG_BLE_ESL_AP_MAX_ESLS +#define CONFIG_BLE_ESL_AP_MAX_ESLS 100 /*!< Max tracked ESLs across all states */ +#endif + +/* ========================== Event Types ========================== */ + +/** + * @brief AP event types delivered through the application callback + */ +typedef enum { + /* Connection events (ap_connection.c) */ + BLE_ESL_AP_EVT_SCAN_RESULT = 0, /*!< ESL discovered during scanning */ + BLE_ESL_AP_EVT_CONNECTED, /*!< ACL connected + service discovery done */ + BLE_ESL_AP_EVT_DISCONNECTED, /*!< ACL disconnected or connection failed */ + /* Lifecycle events (ap_lifecycle.c) */ + BLE_ESL_AP_EVT_ESL_INFO, /*!< ESL info characteristics read complete */ + BLE_ESL_AP_EVT_CONFIGURED, /*!< ESL configuration write complete */ + BLE_ESL_AP_EVT_IMAGE_TRANSFERRED, /*!< Image transfer via OTS complete */ + BLE_ESL_AP_EVT_SYNCHRONIZED, /*!< ESL transitioned to Synchronized state */ + BLE_ESL_AP_EVT_STATE_CHANGED, /*!< ESL tracked state changed */ + /* Command events (ap_command.c) */ + BLE_ESL_AP_EVT_RESPONSE, /*!< Command response received from ESL */ + BLE_ESL_AP_EVT_CMD_TIMEOUT, /*!< ECP procedure timeout (30 seconds) */ +} ble_esl_ap_evt_t; + +/* ========================== Response Types ========================== */ + +/** + * @brief ESL response type identifiers + */ +typedef enum { + ESL_AP_RESP_ERROR = 0, /*!< Error Response (opcode 0x00) */ + ESL_AP_RESP_LED_STATE, /*!< LED State Response (opcode 0x01) */ + ESL_AP_RESP_BASIC_STATE, /*!< Basic State Response (opcode 0x10) */ + ESL_AP_RESP_DISPLAY_STATE, /*!< Display State Response (opcode 0x11) */ + ESL_AP_RESP_SENSOR_VALUE, /*!< Sensor Value Response (opcode 0x_E) */ + ESL_AP_RESP_VENDOR, /*!< Vendor-specific Response (opcode 0x_F) */ +} esl_ap_response_type_t; + +/* ========================== Callback Type ========================== */ + +/** + * @brief Application event callback type + * + * All AP events are delivered through a single callback. The application + * casts @p param to the appropriate struct based on @p event. + * + * @param event Event type from ble_esl_ap_evt_t + * @param param Pointer to event-specific data structure + */ +typedef void (*ble_esl_ap_cb_t)(ble_esl_ap_evt_t event, void *param); + +/* ========================== Configuration Structures ========================== */ + +/** + * @brief PAwR timing configuration + */ +typedef struct { + uint16_t periodic_adv_interval_min; /*!< Min PA interval (units: 1.25 ms) */ + uint16_t periodic_adv_interval_max; /*!< Max PA interval (units: 1.25 ms) */ + uint8_t num_subevents; /*!< Subevents per interval (1–128) */ + uint8_t subevent_interval; /*!< Interval between subevents (units: 1.25 ms) */ + uint8_t response_slot_delay; /*!< Delay to first response slot (units: 1.25 ms) */ + uint8_t response_slot_spacing; /*!< Response slot spacing (units: 0.125 ms) */ + uint8_t num_response_slots; /*!< Response slots per subevent (1–255) */ +} ble_esl_ap_pawr_config_t; + +/** + * @brief AP initialization configuration + */ +typedef struct { + ble_esl_ap_cb_t callback; /*!< Application event callback (must not be NULL) */ + ble_esl_ap_pawr_config_t pawr_config; /*!< PAwR timing parameters */ +} ble_esl_ap_config_t; + +/* ========================== Connection Event Structures ========================== */ + +/** + * @brief Scan result event data (BLE_ESL_AP_EVT_SCAN_RESULT) + */ +typedef struct { + uint8_t addr[6]; /*!< BLE address (little-endian) */ + uint8_t addr_type; /*!< Address type (0 = public, 1 = random) */ + int8_t rssi; /*!< Received signal strength (dBm) */ + const uint8_t *adv_data; /*!< Raw advertising data (valid during callback) */ + uint16_t adv_data_len; /*!< Advertising data length in octets */ + bool is_associated; /*!< true = Unsynchronized (known), false = Unassociated */ +} ble_esl_ap_scan_result_t; + +/** + * @brief Connected event data (BLE_ESL_AP_EVT_CONNECTED) + * + * Populated after service and characteristic discovery completes. + */ +typedef struct { + uint16_t conn_handle; /*!< ACL connection handle */ + uint8_t addr[6]; /*!< BLE address of connected ESL */ + uint8_t addr_type; /*!< Address type */ + bool has_display_info; /*!< ESL Display Information (0x2BFA) discovered */ + bool has_image_info; /*!< ESL Image Information (0x2BFB) discovered */ + bool has_sensor_info; /*!< ESL Sensor Information (0x2BFC) discovered */ + bool has_led_info; /*!< ESL LED Information (0x2BFD) discovered */ + bool has_dis; /*!< Device Information Service discovered */ + bool has_pnp_id; /*!< PnP ID (0x2A50) discovered within DIS */ +} ble_esl_ap_conn_info_t; + +/** + * @brief Disconnected event data (BLE_ESL_AP_EVT_DISCONNECTED) + */ +typedef struct { + uint16_t conn_handle; /*!< ACL connection handle */ + uint8_t addr[6]; /*!< BLE address of the ESL */ + uint8_t reason; /*!< HCI disconnection reason code */ +} ble_esl_ap_disconn_info_t; + +/* ========================== Lifecycle Structures ========================== */ + +/** + * @brief ESL configuration parameters for ble_esl_ap_configure() + */ +typedef struct { + uint8_t esl_id; /*!< ESL_ID to assign (0x00–0xFE) */ + uint8_t group_id; /*!< Group_ID to assign (0x00–0x7F) */ + ble_esl_key_material_t ap_sync_key; /*!< AP Sync Key Material (session key + IV) */ + ble_esl_key_material_t resp_key; /*!< ESL Response Key Material (session key + IV) */ +} ble_esl_ap_esl_config_t; + +/** + * @brief ESL info event data (BLE_ESL_AP_EVT_ESL_INFO) + * + * Populated by ble_esl_ap_read_info(). All pointers are valid only during the + * callback invocation. + */ +typedef struct { + uint16_t conn_handle; /*!< Connection handle */ + esp_err_t status; /*!< Overall read status */ + uint8_t num_displays; /*!< Number of displays (0 if char absent) */ + const ble_esl_display_info_t *displays; /*!< Display info array (5 octets each) */ + uint8_t max_image_index; /*!< Max Image_Index (0xFF if char absent) */ + uint8_t num_sensors; /*!< Number of sensors (0 if char absent) */ + const uint8_t *sensors; /*!< Raw sensor info data */ + uint16_t sensors_len; /*!< Sensor info buffer length */ + uint8_t num_leds; /*!< Number of LEDs (0 if char absent) */ + const uint8_t *leds; /*!< LED info octets (1 per LED) */ + bool has_pnp_id; /*!< PnP ID was successfully read */ + uint8_t pnp_id[7]; /*!< PnP ID value (valid if has_pnp_id) */ +} ble_esl_ap_esl_info_t; + +/** + * @brief Image transfer parameters for ble_esl_ap_transfer_image() + */ +typedef struct { + uint16_t conn_handle; /*!< Connection handle */ + uint8_t image_index; /*!< Target Image_Index on the ESL */ + const uint8_t *data; /*!< Pointer to image data buffer */ + uint32_t data_len; /*!< Image data length in octets */ + bool truncate; /*!< If true, set Truncate bit in OTS Mode */ +} ble_esl_ap_image_transfer_params_t; + +/** + * @brief Configured event data (BLE_ESL_AP_EVT_CONFIGURED) + */ +typedef struct { + uint16_t conn_handle; /*!< Connection handle */ + esp_err_t status; /*!< ESP_OK if all mandatory chars written */ +} ble_esl_ap_configured_t; + +/** + * @brief Image transferred event data (BLE_ESL_AP_EVT_IMAGE_TRANSFERRED) + */ +typedef struct { + uint16_t conn_handle; /*!< Connection handle */ + uint8_t image_index; /*!< Image index that was transferred */ + esp_err_t status; /*!< ESP_OK on success */ +} ble_esl_ap_image_transferred_t; + +/** + * @brief Synchronized event data (BLE_ESL_AP_EVT_SYNCHRONIZED) + */ +typedef struct { + ble_esl_address_t esl_addr; /*!< ESL Address (ESL_ID + Group_ID) */ + esp_err_t status; /*!< ESP_OK on success */ +} ble_esl_ap_synchronized_t; + +/** + * @brief State changed event data (BLE_ESL_AP_EVT_STATE_CHANGED) + */ +typedef struct { + uint16_t conn_handle; /*!< Connection handle (0xFFFF if disconnected) */ + ble_esl_address_t esl_addr; /*!< ESL Address (ESL_ID + Group_ID) */ + ble_esl_state_t old_state; /*!< Previous state */ + ble_esl_state_t new_state; /*!< New state */ +} ble_esl_ap_state_changed_t; + +/* ========================== Response Data Structures ========================== */ + +/** + * @brief Parsed 16-bit Basic State bitmap + */ +typedef struct { + bool service_needed; /*!< Bit 0: ESL needs service */ + bool synchronized; /*!< Bit 1: ESL is synchronized to AP */ + bool active_led; /*!< Bit 2: One or more LEDs active */ + bool pending_led_update; /*!< Bit 3: Timed LED command pending */ + bool pending_display_update; /*!< Bit 4: Timed display command pending */ +} esl_ap_basic_state_t; + +/** + * @brief Error response info + */ +typedef struct { + uint8_t error_code; /*!< ESL error code from BLE_ESL_ERR_* */ +} esl_ap_error_info_t; + +/** + * @brief LED state response info + */ +typedef struct { + uint8_t led_index; /*!< Echo of the commanded LED_Index */ +} esl_ap_led_state_info_t; + +/** + * @brief Display state response info + */ +typedef struct { + uint8_t display_index; /*!< Echo of commanded Display_Index */ + uint8_t image_index; /*!< Image currently shown on the display */ +} esl_ap_display_state_info_t; + +/** + * @brief Sensor value response info + */ +typedef struct { + uint8_t sensor_index; /*!< Echo of commanded Sensor_Index */ + const uint8_t *data; /*!< Raw sensor data (valid during callback) */ + uint8_t data_len; /*!< Sensor data length (0–15 octets) */ +} esl_ap_sensor_value_info_t; + +/** + * @brief Vendor-specific response info + */ +typedef struct { + const uint8_t *data; /*!< Vendor data (valid during callback) */ + uint8_t data_len; /*!< Data length (1–16 octets) */ +} esl_ap_vendor_response_info_t; + +/** + * @brief Command response delivered via BLE_ESL_AP_EVT_RESPONSE + */ +typedef struct { + uint8_t esl_id; /*!< Responding ESL_ID (0x00–0xFE) */ + uint8_t group_id; /*!< Responding Group_ID (0x00–0x7F) */ + esl_ap_response_type_t type; /*!< Response type selector */ + union { + esl_ap_error_info_t error; /*!< Valid when type == ESL_AP_RESP_ERROR */ + esl_ap_led_state_info_t led_state; /*!< Valid when type == ESL_AP_RESP_LED_STATE */ + esl_ap_basic_state_t basic_state; /*!< Valid when type == ESL_AP_RESP_BASIC_STATE */ + esl_ap_display_state_info_t display_state; /*!< Valid when type == ESL_AP_RESP_DISPLAY_STATE */ + esl_ap_sensor_value_info_t sensor_value; /*!< Valid when type == ESL_AP_RESP_SENSOR_VALUE */ + esl_ap_vendor_response_info_t vendor; /*!< Valid when type == ESL_AP_RESP_VENDOR */ + }; +} esl_ap_response_t; + +/** + * @brief Command timeout event data (BLE_ESL_AP_EVT_CMD_TIMEOUT) + */ +typedef struct { + uint8_t esl_id; /*!< ESL_ID of the timed-out ESL */ + uint8_t group_id; /*!< Group_ID of the timed-out ESL */ +} esl_ap_cmd_timeout_t; + +/* ========================== Command Parameter Structures ========================== */ + +/** + * @brief LED control settings for ble_esl_ap_led_control() and ble_esl_ap_led_timed_control() + */ +typedef struct { + uint8_t color_red; /*!< 2-bit red component (0–3) */ + uint8_t color_green; /*!< 2-bit green component (0–3) */ + uint8_t color_blue; /*!< 2-bit blue component (0–3) */ + uint8_t brightness; /*!< 2-bit brightness (0=25%, 1=50%, 2=75%, 3=100%) */ + uint8_t flashing_pattern[7]; /*!< 56-bit (7-byte) flashing pattern */ + uint8_t repeat_type; /*!< 0 = count-based, 1 = duration-based */ + uint16_t repeats_duration; /*!< 15-bit repeat count or duration (seconds) */ +} esl_ap_led_settings_t; + +/* ========================== Public APIs: Connection ========================== */ +/* Implemented in ble_esl_ap_connection.c */ + +/** + * @brief Initialize the AP module + * + * Registers the application callback, stores PAwR timing parameters, + * and allocates internal resources. Must be called before any other API. + * + * @param config Pointer to AP configuration (callback + PAwR timing) + * @return ESP_OK on success; ESP_ERR_INVALID_ARG if config/callback is NULL or + * num_subevents is outside 1–128; + * ESP_ERR_INVALID_STATE if already initialized + */ +esp_err_t ble_esl_ap_init(const ble_esl_ap_config_t *config); + +/** + * @brief Deinitialize the AP module + * + * Disconnects all ESLs, stops scanning/PAwR, and frees all resources. + * + * @note If a connection cannot be terminated, scanning and PAwR are left + * stopped, the still connected links stay tracked and ESP_FAIL is + * returned; the caller may retry this function. + * + * @return ESP_OK on success; ESP_ERR_INVALID_STATE if not initialized; + * ESP_FAIL if an active connection could not be terminated + */ +esp_err_t ble_esl_ap_deinit(void); + +/** + * @brief Start AP operation (scanning + PAwR broadcasting) + * + * Begins GAP General Discovery for ESLs and starts PAwR broadcasting. + * Discovered ESLs are reported via BLE_ESL_AP_EVT_SCAN_RESULT. + * + * @note This function does not automatically initiate connections. The caller must handle + * scan results and call ble_esl_ap_connect() to establish connections. + * + * @return ESP_OK on success; ESP_ERR_INVALID_STATE if not initialized or already started + */ +esp_err_t ble_esl_ap_start(void); + +/** + * @brief Stop AP operation (scanning + PAwR broadcasting) + * + * Existing ACL connections are not affected. + * + * @return ESP_OK on success; ESP_ERR_INVALID_STATE if not started + */ +esp_err_t ble_esl_ap_stop(void); + +/** + * @brief Initiate ACL connection to an ESL + * + * Asynchronous — returns immediately. + * Result is delivered via BLE_ESL_AP_EVT_CONNECTED on success or + * BLE_ESL_AP_EVT_DISCONNECTED on any failure along the way. + * + * @note This function performs the following steps automatically in sequence: + * 1. Initiated ACL connection with peer + * 2. Initiated SMP pairing and encryption + * 3. Discovery service, characteristic and descriptor (ESL Service, optional DIS, OTS) + * 4. Subscribes to indications or notifications + * + * @param addr 6-byte BLE address of the target ESL + * @param addr_type Address type (0 = public, 1 = random) + * @return ESP_OK if connection initiation accepted; ESP_ERR_INVALID_ARG if + * addr NULL; ESP_ERR_NO_MEM if max connections reached + */ +esp_err_t ble_esl_ap_connect(const uint8_t *addr, uint8_t addr_type); + +/** + * @brief Connect to a Synchronized ESL through the PAwR train + * + * Implements the Periodic Advertising Connection procedure (Core [Vol 3], + * Part C, §9.5.5): the AP sends an AUX_CONNECT_REQ in the ESL's PAwR subevent + * (== its Group_ID) to pull an already-Synchronized ESL — which is not + * advertising — into an ACL connection without waiting for it to become + * connectable. Encryption is restored from the bond's stored LTK, after which + * the ESL enters the Updating state. + * + * Use this to move an ESL from Synchronized to Updating (e.g. for image + * transfer or reconfiguration). The ESL must be tracked and currently in the + * Synchronized state. Asynchronous — result delivered via + * BLE_ESL_AP_EVT_CONNECTED on success (after service discovery) or + * BLE_ESL_AP_EVT_DISCONNECTED on failure, mirroring ble_esl_ap_connect(). + * + * @note Requires controller + host support for PAwR connection establishment + * (CONFIG_BT_NIMBLE_PERIODIC_ADV_WITH_RESPONSES). + * + * @param esl_addr ESL Address of the target ESL (see ble_esl_addr_make()) + * @return ESP_OK if the connection was initiated; ESP_ERR_NOT_FOUND if the ESL + * is not tracked; ESP_ERR_INVALID_STATE if it is not Synchronized; + * ESP_ERR_NO_MEM if the connection table is full + */ +esp_err_t ble_esl_ap_connect_synced(ble_esl_address_t esl_addr); + +/** + * @brief Disconnect from a connected ESL + * + * Asynchronous — BLE_ESL_AP_EVT_DISCONNECTED delivered on completion. + * + * @param conn_handle ACL connection handle + * @return ESP_OK if disconnect initiated; ESP_ERR_NOT_FOUND if handle unknown + */ +esp_err_t ble_esl_ap_disconnect(uint16_t conn_handle); + +/* ========================== Public APIs: Lifecycle ========================== */ +/* Implemented in ble_esl_ap_lifecycle.c */ + +/** + * @brief Write ESL configuration characteristics + * + * Writes ESL Address, AP Sync Key Material, ESL Response Key Material, and + * ESL Current Absolute Time in sequence. Async — result via + * BLE_ESL_AP_EVT_CONFIGURED. + * + * @param conn_handle Connection handle of the connected ESL + * @param config Pointer to configuration parameters + * @return ESP_OK if operation initiated; error code on failure + */ +esp_err_t ble_esl_ap_configure(uint16_t conn_handle, + const ble_esl_ap_esl_config_t *config); + +/** + * @brief Read ESL information characteristics + * + * Reads Display Info, Image Info, Sensor Info, LED Info, and PnP ID + * (insofar as present). Async — result via BLE_ESL_AP_EVT_ESL_INFO. + * + * @param conn_handle Connection handle of the connected ESL + * @return ESP_OK if operation initiated; error code on failure + */ +esp_err_t ble_esl_ap_read_info(uint16_t conn_handle); + +/** + * @brief Transfer image data to an ESL via OTS + * + * Uses OTS Go To + Write Object Contents. Object_ID is derived from + * image_index as 0x000000000100 + image_index. Async — result via + * BLE_ESL_AP_EVT_IMAGE_TRANSFERRED. + * + * @param params Pointer to image transfer parameters + * @return ESP_OK if operation initiated; error code on failure + */ +esp_err_t ble_esl_ap_transfer_image(const ble_esl_ap_image_transfer_params_t *params); + +/** + * @brief Transition a connected ESL to the Synchronized state + * + * Sends Update Complete command via ECP, then initiates PAST. ESL + * synchronizes to PAwR and disconnects. Async — result via + * BLE_ESL_AP_EVT_SYNCHRONIZED. + * + * @param conn_handle Connection handle of the connected ESL + * @return ESP_OK if operation initiated; error code on failure + */ +esp_err_t ble_esl_ap_synchronize(uint16_t conn_handle); + +/** + * @brief Get the AP's tracked state for a specific ESL + * + * Synchronous. Returns BLE_ESL_STATE_UNASSOCIATED if not found. + * + * @param esl_addr ESL Address of the ESL (see ble_esl_addr_make()) + * @return Current tracked state + */ +ble_esl_state_t ble_esl_ap_get_esl_state(ble_esl_address_t esl_addr); + +/* ========================== Public APIs: Command ========================== */ +/* Implemented in ble_esl_ap_command.c */ + +/** + * @brief Send Ping command (opcode 0x00) + * + * Verifies ESL reachability. Expected response: Basic State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_ping(uint8_t esl_id, uint8_t group_id); + +/** + * @brief Send Display Image command (opcode 0x20) + * + * Immediately display a stored image. Expected response: Display State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param display_index Index of the target display + * @param image_index Index of the stored image to show + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_display_image(uint8_t esl_id, uint8_t group_id, + uint8_t display_index, uint8_t image_index); + +/** + * @brief Send Display Timed Image command (opcode 0x60) + * + * Schedule an image display at a future time. 0x00000000 cancels pending. + * Expected response: Display State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param display_index Index of the target display + * @param image_index Index of the stored image + * @param absolute_time Scheduled time in ms (0 = cancel) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_display_timed_image(uint8_t esl_id, uint8_t group_id, + uint8_t display_index, uint8_t image_index, + uint32_t absolute_time); + +/** + * @brief Send Refresh Display command (opcode 0x11) + * + * Refresh current display without changing image. Expected response: Display State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param display_index Index of the display to refresh + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_refresh_display(uint8_t esl_id, uint8_t group_id, + uint8_t display_index); + +/** + * @brief Send LED Control command (opcode 0xB0) + * + * Immediately control LED color, brightness, and flashing pattern. + * Expected response: LED State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param led_index Index of the target LED + * @param settings Pointer to LED control settings + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_led_control(uint8_t esl_id, uint8_t group_id, + uint8_t led_index, + const esl_ap_led_settings_t *settings); + +/** + * @brief Send LED Timed Control command (opcode 0xF0) + * + * Schedule LED control at a future time. 0x00000000 cancels pending. + * Expected response: LED State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param led_index Index of the target LED + * @param settings Pointer to LED control settings + * @param absolute_time Scheduled time in ms (0 = cancel) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_led_timed_control(uint8_t esl_id, uint8_t group_id, + uint8_t led_index, + const esl_ap_led_settings_t *settings, + uint32_t absolute_time); + +/** + * @brief Send Read Sensor Data command (opcode 0x10) + * + * Request sensor data. Expected response: Sensor Value. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param sensor_index Index of the sensor to read + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_read_sensor(uint8_t esl_id, uint8_t group_id, + uint8_t sensor_index); + +/** + * @brief Send Unassociate from AP command (opcode 0x01) + * + * Disassociates the ESL. Expected response: Basic State. On success, + * the ESL's tracked state transitions to Unassociated. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_unassociate(uint8_t esl_id, uint8_t group_id); + +/** + * @brief Send Service Reset command (opcode 0x02) + * + * Clears the Service Needed flag. Expected response: Basic State. + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_service_reset(uint8_t esl_id, uint8_t group_id); + +/** + * @brief Send Factory Reset command (opcode 0x03) — ECP only + * + * Connection-oriented only; returns ESP_ERR_INVALID_STATE if ESL is + * in Synchronized state. No response expected; ESL disconnects after. + * + * @param esl_id Target ESL_ID (0x00–0xFE) + * @param group_id Group_ID (0x00–0x7F) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_factory_reset(uint8_t esl_id, uint8_t group_id); + +/** + * @brief Send vendor-specific command (Tag nibble = 0xF) + * + * Opcode constructed as (params_len << 4) | 0x0F: the Length nibble is derived + * internally from params_len, since ESL_ID is prepended as the first parameter + * byte and the Length nibble encodes (parameter count - 1). + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Group_ID (0x00–0x7F) + * @param params Vendor-specific parameter bytes (ESL_ID prepended internally), + * may be NULL only when params_len is 0 + * @param params_len Length of params (0x00–0x0F) + * @return ESP_OK on successful dispatch; error code on failure + */ +esp_err_t ble_esl_ap_vendor_command(uint8_t esl_id, uint8_t group_id, + const uint8_t *params, uint8_t params_len); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_AP_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_common.h b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_common.h new file mode 100644 index 00000000000..80e07381a5c --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/include/ble_esl_common.h @@ -0,0 +1,529 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_common.h + * @brief Electronic Shelf Label (ESL) Service v1.0.1 — Common Protocol Definitions + * + * Shared protocol artifacts used by both AP (Access Point) and ESL roles: + * service/characteristic UUIDs, TLV wire format, command/response opcodes, + * error codes, data format structures, and cross-layer encode/decode utilities. + * + * All multi-byte fields are little-endian. Bit fields within a byte use + * LSB-first numbering unless stated otherwise. + */ + +#ifndef BLE_ESL_COMMON_H +#define BLE_ESL_COMMON_H + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== ESL States ========================== */ + +/** + * @brief ESL state enumeration + */ +typedef enum { + BLE_ESL_STATE_UNASSOCIATED = 0, /*!< ESL not yet configured by an AP */ + BLE_ESL_STATE_CONFIGURING = 1, /*!< ESL connected and being configured for the first time */ + BLE_ESL_STATE_SYNCHRONIZED = 2, /*!< ESL receiving commands via PAwR periodic advertising */ + BLE_ESL_STATE_UPDATING = 3, /*!< ESL connected for reconfiguration / image transfer */ + BLE_ESL_STATE_UNSYNCHRONIZED = 4, /*!< ESL associated but not connected and not synchronized */ +} ble_esl_state_t; + +/* ========================== Service & Characteristic UUIDs ========================== */ + +#define BLE_ESL_SVC_UUID 0x1857 /*!< Electronic Shelf Label Service UUID */ + +#define BLE_ESL_CHR_UUID_ESL_ADDRESS 0x2BF6 /*!< ESL Address characteristic UUID */ +#define BLE_ESL_CHR_UUID_AP_SYNC_KEY 0x2BF7 /*!< AP Sync Key Material characteristic UUID */ +#define BLE_ESL_CHR_UUID_RESP_KEY 0x2BF8 /*!< ESL Response Key Material characteristic UUID */ +#define BLE_ESL_CHR_UUID_CURRENT_ABS_TIME 0x2BF9 /*!< ESL Current Absolute Time characteristic UUID */ +#define BLE_ESL_CHR_UUID_DISPLAY_INFO 0x2BFA /*!< ESL Display Information characteristic UUID */ +#define BLE_ESL_CHR_UUID_IMAGE_INFO 0x2BFB /*!< ESL Image Information characteristic UUID */ +#define BLE_ESL_CHR_UUID_SENSOR_INFO 0x2BFC /*!< ESL Sensor Information characteristic UUID */ +#define BLE_ESL_CHR_UUID_LED_INFO 0x2BFD /*!< ESL LED Information characteristic UUID */ +#define BLE_ESL_CHR_UUID_ECP 0x2BFE /*!< ESL Control Point (ECP) characteristic UUID */ + +/* ========================== Command Opcodes ========================== */ + +#define BLE_ESL_CMD_PING 0x00 /*!< Ping command */ +#define BLE_ESL_CMD_UNASSOCIATE 0x01 /*!< Unassociate from AP command */ +#define BLE_ESL_CMD_SERVICE_RESET 0x02 /*!< Service Reset command */ +#define BLE_ESL_CMD_FACTORY_RESET 0x03 /*!< Factory Reset command */ +#define BLE_ESL_CMD_UPDATE_COMPLETE 0x04 /*!< Update Complete command */ +#define BLE_ESL_CMD_READ_SENSOR 0x10 /*!< Read Sensor Data command */ +#define BLE_ESL_CMD_REFRESH_DISPLAY 0x11 /*!< Refresh Display command */ +#define BLE_ESL_CMD_DISPLAY_IMAGE 0x20 /*!< Display Image command */ +#define BLE_ESL_CMD_DISPLAY_TIMED_IMAGE 0x60 /*!< Display Timed Image command */ +#define BLE_ESL_CMD_LED_CONTROL 0xB0 /*!< LED Control command */ +#define BLE_ESL_CMD_LED_TIMED_CONTROL 0xF0 /*!< LED Timed Control command */ + +/** @brief Vendor-specific command tag nibble. Any opcode with Tag=0xF is vendor-specific. */ +#define BLE_ESL_CMD_VENDOR_TAG 0x0F + +/** @brief ESL Broadcast Address (addresses all ESLs in a group) */ +#define BLE_ESL_BROADCAST_ADDRESS 0xFF + +/* ========================== Response Opcodes ========================== */ + +#define BLE_ESL_RESP_ERROR 0x00 /*!< Error Response */ +#define BLE_ESL_RESP_LED_STATE 0x01 /*!< LED State Response */ +#define BLE_ESL_RESP_BASIC_STATE 0x10 /*!< Basic State Response */ +#define BLE_ESL_RESP_DISPLAY_STATE 0x11 /*!< Display State Response */ + +/** @brief Sensor Value Response tag nibble (0xE). Opcode = (Length << 4) | 0x0E */ +#define BLE_ESL_RESP_SENSOR_VALUE_TAG 0x0E + +/** @brief Vendor-specific Response tag nibble (0xF). Opcode = (Length << 4) | 0x0F */ +#define BLE_ESL_RESP_VENDOR_TAG 0x0F + +/* ========================== Error Codes ========================== */ + +#define BLE_ESL_ERR_UNSPECIFIED 0x01 /*!< Unspecified Error */ +#define BLE_ESL_ERR_INVALID_OPCODE 0x02 /*!< Invalid Opcode */ +#define BLE_ESL_ERR_INVALID_STATE 0x03 /*!< Invalid State */ +#define BLE_ESL_ERR_INVALID_IMAGE_INDEX 0x04 /*!< Invalid Image_Index */ +#define BLE_ESL_ERR_IMAGE_NOT_AVAILABLE 0x05 /*!< Image Not Available */ +#define BLE_ESL_ERR_INVALID_PARAMS 0x06 /*!< Invalid Parameter(s) */ +#define BLE_ESL_ERR_CAPACITY_LIMIT 0x07 /*!< Capacity Limit */ +#define BLE_ESL_ERR_INSUFFICIENT_BATTERY 0x08 /*!< Insufficient Battery */ +#define BLE_ESL_ERR_INSUFFICIENT_RESOURCES 0x09 /*!< Insufficient Resources */ +#define BLE_ESL_ERR_RETRY 0x0A /*!< Retry */ +#define BLE_ESL_ERR_QUEUE_FULL 0x0B /*!< Queue Full */ +#define BLE_ESL_ERR_IMPLAUSIBLE_ABS_TIME 0x0C /*!< Implausible Absolute Time */ + +/* ========================== Basic State Bitmap Bits ========================== */ + +#define BLE_ESL_BASIC_STATE_SERVICE_NEEDED (1 << 0) /*!< Bit 0: Service Needed */ +#define BLE_ESL_BASIC_STATE_SYNCHRONIZED (1 << 1) /*!< Bit 1: Synchronized */ +#define BLE_ESL_BASIC_STATE_ACTIVE_LED (1 << 2) /*!< Bit 2: Active LED */ +#define BLE_ESL_BASIC_STATE_PENDING_LED_UPDATE (1 << 3) /*!< Bit 3: Pending LED Update */ +#define BLE_ESL_BASIC_STATE_PENDING_DISP_UPDATE (1 << 4) /*!< Bit 4: Pending Display Update */ + +/* ========================== TLV Format Helpers ========================== */ + +/** @brief Extract the Tag nibble (bits [3:0]) from an opcode byte */ +#define BLE_ESL_TLV_TAG(opcode) ((opcode) & 0x0F) + +/** @brief Extract the Length nibble (bits [7:4]) from an opcode byte */ +#define BLE_ESL_TLV_LENGTH(opcode) (((opcode) >> 4) & 0x0F) + +/** @brief Compute the parameter byte count from an opcode: Length + 1 */ +#define BLE_ESL_TLV_PARAMS_LEN(opcode) (BLE_ESL_TLV_LENGTH(opcode) + 1) + +/** @brief Compute the total TLV size from an opcode: Length + 2 */ +#define BLE_ESL_TLV_TOTAL_LEN(opcode) (BLE_ESL_TLV_LENGTH(opcode) + 2) + +/** @brief Build an opcode byte from tag and length nibbles */ +#define BLE_ESL_TLV_OPCODE(tag, length) ((uint8_t)(((length) << 4) | ((tag) & 0x0F))) + +/** @brief Maximum TLV total size (Length nibble = 0xF → 15+2 = 17) */ +#define BLE_ESL_TLV_MAX_SIZE 17 + +/** @brief Minimum TLV total size (Length nibble = 0x0 → 0+2 = 2) */ +#define BLE_ESL_TLV_MIN_SIZE 2 + +/* ========================== ESL Payload ========================== */ + +/** @brief Maximum ESL Payload size in octets */ +#define BLE_ESL_PAYLOAD_MAX_SIZE 48 + +/** @brief Maximum Group_ID value (7-bit field) */ +#define BLE_ESL_GROUP_ID_MAX 0x7F + +/* ========================== Advertising Data Types ========================== */ + +#define BLE_ESL_AD_TYPE_ENCRYPTED_DATA 0x31 /*!< Encrypted Data AD type */ +#define BLE_ESL_AD_TYPE_ESL 0x34 /*!< ESL data AD type */ + +/* ========================== ECP Timeout ========================== */ + +/** @brief ECP Procedure Timeout in seconds */ +#define BLE_ESL_ECP_TIMEOUT_SEC 30 + +/* ========================== Display Type Enumeration ========================== */ + +#define BLE_ESL_DISPLAY_TYPE_BLACK_WHITE 0x01 /*!< Black White */ +#define BLE_ESL_DISPLAY_TYPE_THREE_GRAY 0x02 /*!< Three Gray Scale */ +#define BLE_ESL_DISPLAY_TYPE_FOUR_GRAY 0x03 /*!< Four Gray Scale */ +#define BLE_ESL_DISPLAY_TYPE_EIGHT_GRAY 0x04 /*!< Eight Gray Scale */ +#define BLE_ESL_DISPLAY_TYPE_SIXTEEN_GRAY 0x05 /*!< Sixteen Gray Scale */ +#define BLE_ESL_DISPLAY_TYPE_RED_BLACK_WHITE 0x06 /*!< Red Black White */ +#define BLE_ESL_DISPLAY_TYPE_YELLOW_BLACK_WHITE 0x07 /*!< Yellow Black White */ +#define BLE_ESL_DISPLAY_TYPE_RED_YELLOW_BLACK_WHITE 0x08 /*!< Red Yellow Black White */ +#define BLE_ESL_DISPLAY_TYPE_SEVEN_COLOR 0x09 /*!< Seven Color */ +#define BLE_ESL_DISPLAY_TYPE_SIXTEEN_COLOR 0x0A /*!< Sixteen Color */ +#define BLE_ESL_DISPLAY_TYPE_FULL_RGB 0x0B /*!< Full RGB */ + +/* ========================== LED Information Bit Fields ========================== */ + +#define BLE_ESL_LED_INFO_RED_MASK 0x03 /*!< Bits [1:0]: Red component */ +#define BLE_ESL_LED_INFO_RED_SHIFT 0 +#define BLE_ESL_LED_INFO_GREEN_MASK 0x0C /*!< Bits [3:2]: Green component */ +#define BLE_ESL_LED_INFO_GREEN_SHIFT 2 +#define BLE_ESL_LED_INFO_BLUE_MASK 0x30 /*!< Bits [5:4]: Blue component */ +#define BLE_ESL_LED_INFO_BLUE_SHIFT 4 +#define BLE_ESL_LED_INFO_TYPE_MASK 0xC0 /*!< Bits [7:6]: LED type */ +#define BLE_ESL_LED_INFO_TYPE_SHIFT 6 + +/** @brief LED Type: sRGB multi-color */ +#define BLE_ESL_LED_TYPE_SRGB 0x00 +/** @brief LED Type: Monochrome single-color */ +#define BLE_ESL_LED_TYPE_MONOCHROME 0x01 + +/* ========================== Sensor Information Sizes ========================== */ + +/** @brief Sensor Information short format size indicator */ +#define BLE_ESL_SENSOR_INFO_SIZE_SHORT 0x00 +/** @brief Sensor Information long format size indicator */ +#define BLE_ESL_SENSOR_INFO_SIZE_LONG 0x01 + +/** @brief Sensor Information short format total octets */ +#define BLE_ESL_SENSOR_INFO_SHORT_LEN 3 +/** @brief Sensor Information long format total octets */ +#define BLE_ESL_SENSOR_INFO_LONG_LEN 5 + +/* ========================== Key Material Size ========================== */ + +/** @brief Key Material total size (Session_Key 16 + IV 8) */ +#define BLE_ESL_KEY_MATERIAL_SIZE 24 +/** @brief Session Key size in octets */ +#define BLE_ESL_SESSION_KEY_SIZE 16 +/** @brief IV size in octets */ +#define BLE_ESL_IV_SIZE 8 + +/* ========================== Encryption Parameters ========================== */ + +/** @brief CCM Nonce size (Randomizer 5 + IV 8) */ +#define BLE_ESL_CCM_NONCE_SIZE 13 +/** @brief Randomizer size in octets */ +#define BLE_ESL_RANDOMIZER_SIZE 5 +/** @brief MIC size in octets */ +#define BLE_ESL_MIC_SIZE 4 + +/* ========================== OTS Object ID ========================== */ + +/** @brief Base Object ID for ESL images (48-bit): Object_ID = base + Image_Index */ +#define BLE_ESL_OTS_OBJECT_ID_BASE 0x000000000100ULL + +/* ========================== Implausible Time Threshold ========================== */ + +/** @brief Maximum future time considered plausible: 48 days in ms */ +#define BLE_ESL_IMPLAUSIBLE_TIME_MS 4147200000UL + +/* ========================== Data Structures ========================== */ + +/** + * @brief ESL Address (2 octets) + * + * ESL_ID (bits 0-7) + Group_ID (bits 8-14) + RFU (bit 15) + */ +typedef struct __attribute__((packed)) { + uint8_t esl_id; /*!< ESL identifier (0x00-0xFE valid, 0xFF = broadcast) */ + uint8_t group_id_rfu; /*!< Group_ID in bits [6:0], RFU in bit 7 (shall be 0) */ +} ble_esl_address_t; + +/** + * @brief Key Material (24 octets) + * + * 128-bit AES session key + 64-bit initialization vector. + * Shared layout of the AP Sync Key Material and the ESL Response Key Material. + */ +typedef struct __attribute__((packed)) { + uint8_t session_key[BLE_ESL_SESSION_KEY_SIZE]; /*!< 128-bit AES session key */ + uint8_t iv[BLE_ESL_IV_SIZE]; /*!< 64-bit initialization vector */ +} ble_esl_key_material_t; + +/** + * @brief ESL Current Absolute Time (4 octets) + */ +typedef uint32_t ble_esl_abs_time_t; + +/** + * @brief Display Data Structure (5 octets per display) + */ +typedef struct __attribute__((packed)) { + uint16_t width; /*!< Display width in pixels */ + uint16_t height; /*!< Display height in pixels */ + uint8_t display_type; /*!< Display type enumeration */ +} ble_esl_display_info_t; + +/** + * @brief Image Information (1 octet) + */ +typedef struct __attribute__((packed)) { + uint8_t max_image_index; /*!< Numerically highest Image_Index supported */ +} ble_esl_image_info_t; + +/** + * @brief Sensor Information — Short format (3 octets) + */ +typedef struct __attribute__((packed)) { + uint8_t size; /*!< 0x00 = short format */ + uint16_t sensor_type; /*!< 16-bit Property ID from Mesh Device Properties */ +} ble_esl_sensor_info_short_t; + +/** + * @brief Sensor Information — Long format (5 octets) + */ +typedef struct __attribute__((packed)) { + uint8_t size; /*!< 0x01 = long format */ + uint16_t company_id; /*!< Bluetooth SIG assigned Company ID (bits 0-15 of Sensor_Type) */ + uint16_t sensor_code; /*!< Vendor-assigned sensor type code (bits 16-31 of Sensor_Type) */ +} ble_esl_sensor_info_long_t; + +/** + * @brief Flashing Pattern (56 bits = 7 octets) + * + * Pattern (40 bits) + Bit_Off_Period (8 bits) + Bit_On_Period (8 bits) + * Stored in little-endian byte order. + */ +typedef struct __attribute__((packed)) { + uint8_t pattern[5]; /*!< 40-bit on/off pattern (MSB examined first) */ + uint8_t bit_off_period; /*!< Off-time per bit = value × 2 ms (2-510 ms, 0 invalid) */ + uint8_t bit_on_period; /*!< On-time per bit = value × 2 ms (2-510 ms, 0 invalid) */ +} ble_esl_flashing_pattern_t; + +/** + * @brief LED Control Settings + * + * Contains color, brightness, flashing pattern, and repeat configuration. + * Total: 10 octets (1 byte color+brightness + 7 bytes flashing + 2 bytes repeat) + */ +typedef struct __attribute__((packed)) { + uint8_t color_brightness; /*!< Red[1:0], Green[3:2], Blue[5:4], Brightness[7:6] */ + ble_esl_flashing_pattern_t flashing; /*!< 56-bit flashing pattern (7 octets) */ + uint16_t repeat; /*!< Repeat_Type (bit 0) + Repeats_Duration (bits 15:1) */ +} ble_esl_led_control_t; + +/* ========================== LED Control Helpers ========================== */ + +/** @brief Extract Red component (2 bits) from color_brightness byte */ +#define BLE_ESL_LED_CTRL_RED(cb) ((cb) & 0x03) +/** @brief Extract Green component (2 bits) from color_brightness byte */ +#define BLE_ESL_LED_CTRL_GREEN(cb) (((cb) >> 2) & 0x03) +/** @brief Extract Blue component (2 bits) from color_brightness byte */ +#define BLE_ESL_LED_CTRL_BLUE(cb) (((cb) >> 4) & 0x03) +/** @brief Extract Brightness (2 bits) from color_brightness byte */ +#define BLE_ESL_LED_CTRL_BRIGHTNESS(cb) (((cb) >> 6) & 0x03) + +/** @brief Build color_brightness byte from components */ +#define BLE_ESL_LED_CTRL_COLOR_BRIGHTNESS(r, g, b, bright) \ + ((uint8_t)(((r) & 0x03) | (((g) & 0x03) << 2) | (((b) & 0x03) << 4) | (((bright) & 0x03) << 6))) + +/** @brief Extract Repeat_Type (bit 0) from repeat field */ +#define BLE_ESL_LED_CTRL_REPEAT_TYPE(rep) ((rep) & 0x01) +/** @brief Extract Repeats_Duration (bits 15:1) from repeat field */ +#define BLE_ESL_LED_CTRL_REPEATS_DURATION(rep) (((rep) >> 1) & 0x7FFF) + +/** @brief Build repeat field from type and duration */ +#define BLE_ESL_LED_CTRL_REPEAT(type, duration) \ + ((uint16_t)(((type) & 0x01) | (((duration) & 0x7FFF) << 1))) + +/* ========================== ESL Address Helpers ========================== */ + +/** @brief Extract Group_ID (7 bits) from group_id_rfu byte */ +#define BLE_ESL_ADDR_GROUP_ID(addr) ((addr).group_id_rfu & 0x7F) + +/** @brief Set Group_ID in an ble_esl_address_t (clears RFU bit) */ +#define BLE_ESL_ADDR_SET_GROUP_ID(addr, gid) \ + do { (addr).group_id_rfu = (uint8_t)((gid) & 0x7F); } while (0) + +/** + * @brief Build an ESL Address from its ESL_ID and Group_ID components + * + * @param esl_id ESL identifier (0x00-0xFE valid, 0xFF = broadcast) + * @param group_id Group identifier (0x00-0x7F); the RFU bit is always cleared + * @return Assembled ESL Address + */ +static inline ble_esl_address_t ble_esl_addr_make(uint8_t esl_id, uint8_t group_id) +{ + ble_esl_address_t addr = { + .esl_id = esl_id, + .group_id_rfu = (uint8_t)(group_id & 0x7F), + }; + return addr; +} + +/* ========================== Cross-Layer Utility Functions ========================== */ + +/** + * @brief Encode a single command or response into TLV wire format + * + * @param[in] opcode TLV opcode byte (Tag in bits [3:0], Length in bits [7:4]) + * @param[in] params Pointer to parameter bytes (Length+1 octets) + * @param[in] params_len Length of params in octets (must equal (opcode >> 4) + 1) + * @param[out] out_buf Output buffer to receive the encoded TLV (min size: params_len + 1) + * @param[out] out_len On output, set to total TLV size written (params_len + 1) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if params_len does not match opcode Length nibble, + * or if any pointer argument is NULL + */ +esp_err_t ble_esl_tlv_encode(uint8_t opcode, const uint8_t *params, + uint8_t params_len, uint8_t *out_buf, + uint8_t *out_len); + +/** + * @brief Decode a single TLV element from a byte buffer + * + * @param[in] in_buf Input buffer containing the TLV + * @param[in] in_len Length of input buffer + * @param[out] opcode On output, the opcode byte + * @param[out] params On output, pointer into in_buf at the start of parameters + * @param[out] params_len On output, parameter length in octets + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_SIZE if buffer too short for the TLV + * - ESP_ERR_INVALID_ARG if any pointer argument is NULL or TLV is malformed + */ +esp_err_t ble_esl_tlv_decode(const uint8_t *in_buf, uint8_t in_len, + uint8_t *opcode, const uint8_t **params, + uint8_t *params_len); + +/** + * @brief Pack Group_ID header and pre-encoded TLVs into an ESL Payload buffer + * + * Used by the AP to build command sync packets (max 48 octets). + * + * @param[in] group_id 7-bit Group_ID value (0x00–0x7F) + * @param[in] tlvs Array of pointers to pre-encoded TLV buffers + * @param[in] tlv_lens Array of TLV lengths (one per TLV) + * @param[in] tlv_count Number of TLVs to pack + * @param[out] out_buf Output buffer (min 48 octets recommended) + * @param[out] out_len On output, total payload size written + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_SIZE if total exceeds 48 octets + * - ESP_ERR_INVALID_ARG if group_id > 0x7F or any pointer argument is NULL + */ +esp_err_t ble_esl_payload_encode(uint8_t group_id, const uint8_t *tlvs[], + const uint8_t tlv_lens[], uint8_t tlv_count, + uint8_t *out_buf, uint8_t *out_len); + +/** + * @brief Unpack an ESL Payload into Group_ID and individual TLV elements + * + * Used by the ESL to parse received command payloads. + * + * @param[in] in_buf Input ESL Payload buffer + * @param[in] in_len Input buffer length + * @param[out] group_id On output, the 7-bit Group_ID + * @param[out] tlv_count On output, number of TLVs found + * @param[out] tlv_offsets Array of offsets into in_buf where each TLV starts + * @param[out] tlv_lens Array of total lengths of each TLV + * @param[in] max_tlvs Maximum number of TLVs that can be reported (array size) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_SIZE if payload too short or malformed + * - ESP_ERR_INVALID_ARG if any pointer argument is NULL + * - ESP_ERR_NO_MEM if more TLVs found than max_tlvs + */ +esp_err_t ble_esl_payload_decode(const uint8_t *in_buf, uint8_t in_len, + uint8_t *group_id, uint8_t *tlv_count, + uint8_t *tlv_offsets, uint8_t *tlv_lens, + uint8_t max_tlvs); + +/* ========================== Encrypted Advertising Data ========================== */ + +/** + * @brief PAwR traffic direction, selects the CCM nonce directionBit + * + * The directionBit is the MSB of the last Randomizer octet (Supplement to the + * Bluetooth Core Specification v11, Part A, 1.23.3). + */ +typedef enum { + BLE_ESL_EAD_DIR_AP_TO_ESL = 0, /*!< Sync packets sent by the AP (directionBit = 0) */ + BLE_ESL_EAD_DIR_ESL_TO_AP = 1, /*!< Responses sent by the ESL (directionBit = 1) */ +} ble_esl_ead_direction_t; + +/** + * @brief Seed a Randomizer for one PAwR direction + * + * Fills the Randomizer with random octets and sets its directionBit to match + * @p direction. Call once per key material lifetime; afterwards + * ble_esl_ead_encrypt() increments the Randomizer for every packet, so it must + * not be re-seeded while the same session key and IV remain in use. + * + * @param[out] randomizer Randomizer buffer of BLE_ESL_RANDOMIZER_SIZE octets + * @param[in] direction Direction the Randomizer is used for + */ +void ble_esl_ead_randomizer_init(uint8_t *randomizer, + ble_esl_ead_direction_t direction); + +/** + * @brief Encrypt an ESL payload as Encrypted Advertising Data (AES-CCM) + * + * On success the Randomizer is incremented as a little-endian counter with its + * directionBit preserved, so every packet encrypted with the same key material + * uses a fresh nonce. + * + * @param[in] session_key Session key (BLE_ESL_SESSION_KEY_SIZE octets) + * @param[in] iv Initialization vector (BLE_ESL_IV_SIZE octets) + * @param[in,out] randomizer Randomizer (BLE_ESL_RANDOMIZER_SIZE octets), + * seeded by ble_esl_ead_randomizer_init() + * @param[in] payload Plaintext (inner AD structure) to encrypt + * @param[in] payload_len Size of @p payload in octets + * @param[out] encrypted_payload Output buffer receiving + * [Randomizer(5)][Ciphertext(payload_len)][MIC(4)] + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if any pointer argument is NULL or payload_len is 0 + * - ESP_FAIL if the AES-CCM operation fails + */ +esp_err_t ble_esl_ead_encrypt(const uint8_t *session_key, const uint8_t *iv, + uint8_t *randomizer, const uint8_t *payload, + size_t payload_len, uint8_t *encrypted_payload); + +/** + * @brief Decrypt Encrypted Advertising Data into an ESL payload (AES-CCM) + * + * Counterpart of ble_esl_ead_encrypt(). The Randomizer is taken from the first + * BLE_ESL_RANDOMIZER_SIZE octets of @p encrypted_payload, so no Randomizer + * state has to be kept for the receive direction. The MIC is verified before + * the plaintext is reported as valid. + * + * @param[in] session_key Session key (BLE_ESL_SESSION_KEY_SIZE octets) + * @param[in] iv Initialization vector (BLE_ESL_IV_SIZE octets) + * @param[in] encrypted_payload Encrypted Advertising Data value, i.e. + * [Randomizer(5)][Ciphertext(N)][MIC(4)], + * without the outer AD length and type octets + * @param[in] encrypted_payload_len Size of @p encrypted_payload in octets + * @param[out] payload Buffer receiving the plaintext + * (inner AD structure) + * @param[in] payload_size Capacity of @p payload in octets + * @param[out] payload_len Number of plaintext octets written + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if any pointer argument is NULL + * - ESP_ERR_INVALID_SIZE if @p encrypted_payload_len is too short to hold a + * Randomizer, a MIC and at least one ciphertext octet, or if @p payload + * is too small for the plaintext + * - ESP_ERR_INVALID_RESPONSE if MIC verification fails + */ +esp_err_t ble_esl_ead_decrypt(const uint8_t *session_key, const uint8_t *iv, + const uint8_t *encrypted_payload, + size_t encrypted_payload_len, uint8_t *payload, + size_t payload_size, size_t *payload_len); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_COMMON_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_command.c b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_command.c new file mode 100644 index 00000000000..0f603470cae --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_command.c @@ -0,0 +1,1104 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap_command.c + * @brief ESL AP — Command dispatch and response parsing + * + * Implements transport selection (ECP vs PAwR), TLV command encoding, + * ECP procedure timeout management, and response parsing for all six + * ESL response types. + */ + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "ble_esl_ap.h" +#include "ble_esl_ap_int.h" +#include "ble_esl_common.h" + +static const char *TAG = "esl_ap_cmd"; + +/* ========================== Constants ========================== */ + +/** ECP procedure timeout in microseconds (30 seconds) */ +#define ECP_TIMEOUT_US ((uint64_t)BLE_ESL_ECP_TIMEOUT_SEC * 1000000ULL) + +/* ========================== Per-Connection Command Context ========================== */ + +/** + * @brief Per-connection ECP command context + * + * Tracks the pending ECP command, its target ESL address, the opcode + * (for Unassociate state transition), and the 30-second timeout timer. + */ +typedef struct { + bool in_use; /*!< Context is active */ + uint16_t conn_handle; /*!< ACL connection handle */ + uint8_t esl_id; /*!< Target ESL_ID */ + uint8_t group_id; /*!< Target Group_ID */ + uint8_t cmd_opcode; /*!< Command opcode sent */ + esp_timer_handle_t timer; /*!< 30-second ECP timeout timer */ +} esl_ap_ecp_cmd_ctx_t; + +/** Mutex protecting the ECP context array from concurrent access across tasks */ +static SemaphoreHandle_t s_ecp_ctx_mutex; + +/* ========================== PAwR Response Slot Mapping ========================== */ + +/** Maximum number of response slots tracked per subevent */ +#define PAWR_MAX_RESPONSE_SLOTS 16 + +/** Invalid ESL address sentinel for slot mapping */ +#define ESL_ADDR_INVALID 0xFFFF + +/** + * @brief Per-group PAwR response slot to ESL address mapping + * + * When the AP sends a PAwR payload containing individually-addressed TLVs, + * it records which ESL address is expected to respond in each slot. + * The response handler uses this mapping to look up the correct ESL entry. + */ +typedef struct { + bool valid; /*!< Mapping is active for this group */ + uint8_t group_id; /*!< Group ID (subevent) */ + uint8_t num_slots; /*!< Number of slots populated */ + uint16_t slot_to_esl_addr[PAWR_MAX_RESPONSE_SLOTS]; /*!< slot index → ESL address */ +} pawr_slot_map_t; + +/* ========================== Module Runtime Context ========================== */ + +/** + * @brief Aggregate heap-allocated runtime context for the command sub-module + * + * Holds the RAM-heavy tables that were previously statically allocated in + * .bss. Allocated once from the heap on init and freed on deinit, referenced + * through a single pointer (s_ap_command). + * + * @note The mutex protecting @ref ecp_ctx is intentionally NOT part of this + * struct. It is kept as a separate static handle (s_ecp_ctx_mutex) + * because it is the synchronization primitive that guards the lifecycle + * of this very block: the module's "is-initialized" checks and the timer + * / notification callbacks rely on the mutex being valid independently of + * the heap block it protects. Embedding the handle inside the memory it + * guards would be fragile (a stale ctx pointer into a freed block would + * have to be dereferenced just to reach the lock). + */ +typedef struct { + /** Array of per-connection ECP command contexts */ + esl_ap_ecp_cmd_ctx_t ecp_ctx[CONFIG_BLE_ESL_AP_MAX_CONNECTIONS]; + /** Per-group slot mapping table (one entry per possible concurrent group) */ + pawr_slot_map_t pawr_slot_map[BLE_ESL_GROUP_ID_MAX + 1]; +} ap_command_ctx_t; + +/** Single heap-allocated runtime context (NULL until init) */ +static ap_command_ctx_t *s_ap_command; + +/** + * @brief Record the response slot mapping for a PAwR send + * + * For a single unicast TLV, the ESL's response slot is always 0 + * (TLV #1 → slot = 1 - 1 = 0). + */ +static void pawr_record_slot_mapping(uint8_t group_id, uint8_t esl_id) +{ + if (s_ap_command == NULL || group_id > BLE_ESL_GROUP_ID_MAX) { + return; + } + pawr_slot_map_t *map = &s_ap_command->pawr_slot_map[group_id]; + /* Clear previous mapping for this group */ + memset(map->slot_to_esl_addr, 0xFF, sizeof(map->slot_to_esl_addr)); + map->valid = true; + map->group_id = group_id; + map->num_slots = 1; + /* Single unicast TLV → slot 0 */ + map->slot_to_esl_addr[0] = BLE_ESL_AP_MAKE_ADDR(esl_id, group_id); +} + +/** + * @brief Look up the ESL address for a given PAwR response slot + * + * @param group_id Group ID (subevent) + * @param response_slot Response slot index + * @return ESL address, or ESL_ADDR_INVALID if not found + */ +static uint16_t pawr_lookup_slot(uint8_t group_id, uint8_t response_slot) +{ + if (s_ap_command == NULL || group_id > BLE_ESL_GROUP_ID_MAX) { + return ESL_ADDR_INVALID; + } + pawr_slot_map_t *map = &s_ap_command->pawr_slot_map[group_id]; + if (!map->valid || response_slot >= PAWR_MAX_RESPONSE_SLOTS) { + return ESL_ADDR_INVALID; + } + return map->slot_to_esl_addr[response_slot]; +} + +/* ========================== Forward Declarations ========================== */ + +static void ecp_timeout_cb(void *arg); +static void ecp_response_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void parse_response(uint8_t esl_id, uint8_t group_id, + const uint8_t *data, uint8_t data_len, + uint8_t cmd_opcode); +static esp_err_t dispatch_command(uint8_t esl_id, uint8_t group_id, + uint8_t opcode, const uint8_t *params, + uint8_t params_len, bool ecp_only); +static esp_err_t build_led_params(uint8_t esl_id, uint8_t led_index, + const esl_ap_led_settings_t *settings, + uint8_t *out_params, uint8_t *out_len); + +/* ========================== Context Helpers ========================== */ + +/** + * @brief Find a free ECP command context slot + */ +static esl_ap_ecp_cmd_ctx_t *alloc_ecp_ctx(void) +{ + if (s_ap_command == NULL) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (!s_ap_command->ecp_ctx[i].in_use) { + return &s_ap_command->ecp_ctx[i]; + } + } + return NULL; +} + +/** + * @brief Find an ECP command context by connection handle + */ +static esl_ap_ecp_cmd_ctx_t *find_ecp_ctx_by_conn(uint16_t conn_handle) +{ + if (s_ap_command == NULL) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (s_ap_command->ecp_ctx[i].in_use && s_ap_command->ecp_ctx[i].conn_handle == conn_handle) { + return &s_ap_command->ecp_ctx[i]; + } + } + return NULL; +} + +/** + * @brief Free an ECP command context, stopping and deleting its timer + * + * Also clears the associated connection's pending ECP state to prevent + * late-arriving notifications from accessing stale context pointers. + * + * @note Caller MUST hold s_ecp_ctx_mutex. + */ +static void free_ecp_ctx(esl_ap_ecp_cmd_ctx_t *ctx) +{ + if (ctx == NULL) { + return; + } + + if (!ctx->in_use) { + return; + } + + /* Clear the connection's pending ECP state only if we own it */ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(ctx->conn_handle); + if (conn != NULL && conn->ecp_user_data == ctx) { + conn->ecp_pending = false; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + } + + if (ctx->timer != NULL) { + esp_timer_handle_t timer = ctx->timer; + ctx->timer = NULL; + esp_timer_stop(timer); /* Ignore error if not running */ + esp_timer_delete(timer); + } + memset(ctx, 0, sizeof(*ctx)); +} + +/* ========================== Init / Deinit ========================== */ + +esp_err_t ble_esl_ap_command_init(void) +{ + /* Allocate the aggregate runtime context once (calloc zero-initializes, + * replacing the previous memset of the static tables). Plain internal + * heap is fine: these tables have no DMA / special-capability needs. */ + s_ap_command = calloc(1, sizeof(ap_command_ctx_t)); + if (s_ap_command == NULL) { + ESP_LOGE(TAG, "Failed to allocate command context"); + return ESP_ERR_NO_MEM; + } + + s_ecp_ctx_mutex = xSemaphoreCreateMutex(); + if (s_ecp_ctx_mutex == NULL) { + ESP_LOGE(TAG, "Failed to create ECP context mutex"); + free(s_ap_command); + s_ap_command = NULL; + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "Command sub-module initialized"); + return ESP_OK; +} + +void ble_esl_ap_command_deinit(void) +{ + if (s_ap_command == NULL) { + return; + } + + /* + * First pass: stop all timers with blocking wait OUTSIDE the mutex + * to avoid deadlock (timer callbacks also take the mutex). + * This ensures no callbacks are in-flight after this loop. + */ + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (s_ap_command->ecp_ctx[i].in_use && s_ap_command->ecp_ctx[i].timer != NULL) { + esp_timer_stop_blocking(s_ap_command->ecp_ctx[i].timer, portMAX_DELAY); + } + } + + /* Second pass: clean up contexts under the mutex */ + if (s_ecp_ctx_mutex != NULL) { + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + } + + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (s_ap_command->ecp_ctx[i].in_use) { + free_ecp_ctx(&s_ap_command->ecp_ctx[i]); + } + } + + if (s_ecp_ctx_mutex != NULL) { + xSemaphoreGive(s_ecp_ctx_mutex); + vSemaphoreDelete(s_ecp_ctx_mutex); + s_ecp_ctx_mutex = NULL; + } + + /* Release the aggregate context last, once no callback can reach it. */ + free(s_ap_command); + s_ap_command = NULL; + + ESP_LOGI(TAG, "Command sub-module deinitialized"); +} + +/* ========================== Connection Cleanup ========================== */ + +void ble_esl_ap_command_cleanup_conn(uint16_t conn_handle) +{ + if (s_ecp_ctx_mutex == NULL) { + return; + } + + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + esl_ap_ecp_cmd_ctx_t *ctx = find_ecp_ctx_by_conn(conn_handle); + if (ctx != NULL) { + ESP_LOGW(TAG, "Cleaning up pending ECP context for conn_handle=0x%04x", conn_handle); + free_ecp_ctx(ctx); + } + xSemaphoreGive(s_ecp_ctx_mutex); +} + +/* ========================== ECP Timeout Callback ========================== */ + +/** + * @brief ECP procedure timeout callback (30 seconds) + * + * Fires BLE_ESL_AP_EVT_CMD_TIMEOUT to the application. The AP shall not + * start any new ECP procedure on this link. + */ +static void ecp_timeout_cb(void *arg) +{ + esl_ap_ecp_cmd_ctx_t *ctx = (esl_ap_ecp_cmd_ctx_t *)arg; + assert(ctx != NULL); + + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + + /* Check if context is still active (may have been freed by response cb) */ + if (!ctx->in_use) { + xSemaphoreGive(s_ecp_ctx_mutex); + return; + } + + ESP_LOGW(TAG, "ECP timeout: esl_id=0x%02X group_id=0x%02X conn=0x%04X", + ctx->esl_id, ctx->group_id, ctx->conn_handle); + + /* Capture event data before freeing context */ + uint8_t esl_id = ctx->esl_id; + uint8_t group_id = ctx->group_id; + + /* Clean up the context */ + free_ecp_ctx(ctx); + + xSemaphoreGive(s_ecp_ctx_mutex); + + /* Deliver timeout event to application */ + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + esl_ap_cmd_timeout_t timeout_evt = { + .esl_id = esl_id, + .group_id = group_id, + }; + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CMD_TIMEOUT, &timeout_evt); + } +} + +/* ========================== ECP Response Callback ========================== */ + +/** + * @brief ECP notification callback — invoked when the ESL responds via ECP + * + * Stops the timeout timer, parses the response, and delivers the event. + */ +static void ecp_response_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + esl_ap_ecp_cmd_ctx_t *ctx = (esl_ap_ecp_cmd_ctx_t *)user_data; + if (ctx == NULL) { + ESP_LOGE(TAG, "ECP response callback with NULL context"); + return; + } + + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + + /* Check if context is still active (may have been freed by timeout cb) */ + if (!ctx->in_use) { + xSemaphoreGive(s_ecp_ctx_mutex); + return; + } + + /* Stop the timeout timer */ + if (ctx->timer != NULL) { + esp_timer_stop(ctx->timer); /* Ignore error if already stopped */ + } + + if (status != ESP_OK) { + ESP_LOGE(TAG, "ECP write/notification failed: status=0x%X, conn=0x%04X", + status, conn_handle); + free_ecp_ctx(ctx); + xSemaphoreGive(s_ecp_ctx_mutex); + return; + } + + if (data == NULL || data_len == 0) { + /* Factory Reset has no response — this is expected */ + ESP_LOGD(TAG, "ECP response with no data (conn=0x%04X)", conn_handle); + free_ecp_ctx(ctx); + xSemaphoreGive(s_ecp_ctx_mutex); + return; + } + + /* Capture context data before freeing */ + uint8_t esl_id = ctx->esl_id; + uint8_t group_id = ctx->group_id; + uint8_t cmd_opcode = ctx->cmd_opcode; + + /* Clean up */ + free_ecp_ctx(ctx); + + xSemaphoreGive(s_ecp_ctx_mutex); + + /* Parse and deliver the response (outside lock) */ + parse_response(esl_id, group_id, data, (uint8_t)(data_len > 255 ? 255 : data_len), cmd_opcode); +} + +/* ========================== Response Parsing ========================== */ + +/** + * @brief Parse a response TLV and deliver BLE_ESL_AP_EVT_RESPONSE + * + * Decodes the TLV, determines the response type from the tag/length nibbles, + * populates esl_ap_response_t, and fires the event callback. + * + * @param esl_id ESL_ID of the responding ESL + * @param group_id Group_ID of the responding ESL + * @param data Raw response TLV data + * @param data_len Length of data + * @param cmd_opcode The command opcode that was sent (for state transitions) + */ +static void parse_response(uint8_t esl_id, uint8_t group_id, + const uint8_t *data, uint8_t data_len, + uint8_t cmd_opcode) +{ + if (data == NULL || data_len < BLE_ESL_TLV_MIN_SIZE) { + ESP_LOGE(TAG, "Response too short: len=%u", data_len); + return; + } + + uint8_t opcode = 0; + const uint8_t *params = NULL; + uint8_t params_len = 0; + + esp_err_t ret = ble_esl_tlv_decode(data, data_len, &opcode, ¶ms, ¶ms_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "TLV decode failed: 0x%X", ret); + return; + } + + uint8_t tag_nibble = BLE_ESL_TLV_TAG(opcode); + uint8_t len_nibble = BLE_ESL_TLV_LENGTH(opcode); + + esl_ap_response_t response; + memset(&response, 0, sizeof(response)); + response.esl_id = esl_id; + response.group_id = group_id; + + if (tag_nibble == 0x00 && len_nibble == 0x00) { + /* Error Response (opcode 0x00) */ + response.type = ESL_AP_RESP_ERROR; + if (params_len >= 1) { + response.error.error_code = params[0]; + } + ESP_LOGW(TAG, "Error response: esl_id=0x%02X error_code=0x%02X", + esl_id, response.error.error_code); + + } else if (tag_nibble == 0x01 && len_nibble == 0x00) { + /* LED State Response (opcode 0x01) */ + response.type = ESL_AP_RESP_LED_STATE; + if (params_len >= 1) { + response.led_state.led_index = params[0]; + } + ESP_LOGD(TAG, "LED state response: esl_id=0x%02X led_index=%u", + esl_id, response.led_state.led_index); + + } else if (tag_nibble == 0x00 && len_nibble == 0x01) { + /* Basic State Response (opcode 0x10) */ + response.type = ESL_AP_RESP_BASIC_STATE; + if (params_len >= 2) { + uint16_t bitmap = (uint16_t)(params[0] | (params[1] << 8)); + response.basic_state.service_needed = + (bitmap & BLE_ESL_BASIC_STATE_SERVICE_NEEDED) != 0; + response.basic_state.synchronized = + (bitmap & BLE_ESL_BASIC_STATE_SYNCHRONIZED) != 0; + response.basic_state.active_led = + (bitmap & BLE_ESL_BASIC_STATE_ACTIVE_LED) != 0; + response.basic_state.pending_led_update = + (bitmap & BLE_ESL_BASIC_STATE_PENDING_LED_UPDATE) != 0; + response.basic_state.pending_display_update = + (bitmap & BLE_ESL_BASIC_STATE_PENDING_DISP_UPDATE) != 0; + } + ESP_LOGD(TAG, "Basic state response: esl_id=0x%02X svc_needed=%d sync=%d", + esl_id, response.basic_state.service_needed, + response.basic_state.synchronized); + + /* For Unassociate command, transition ESL to Unassociated state */ + if (cmd_opcode == BLE_ESL_CMD_UNASSOCIATE) { + uint16_t esl_addr = BLE_ESL_AP_MAKE_ADDR(esl_id, group_id); + esp_err_t err = ble_esl_ap_update_esl_state(esl_addr, + BLE_ESL_STATE_UNASSOCIATED); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update ESL state to Unassociated: 0x%X", err); + } + } + + } else if (tag_nibble == 0x01 && len_nibble == 0x01) { + /* Display State Response (opcode 0x11) */ + response.type = ESL_AP_RESP_DISPLAY_STATE; + if (params_len >= 2) { + response.display_state.display_index = params[0]; + response.display_state.image_index = params[1]; + } + ESP_LOGD(TAG, "Display state response: esl_id=0x%02X disp=%u img=%u", + esl_id, response.display_state.display_index, + response.display_state.image_index); + + } else if (tag_nibble == 0x0E) { + /* Sensor Value Response (opcode 0x_E) */ + response.type = ESL_AP_RESP_SENSOR_VALUE; + if (params_len >= 1) { + response.sensor_value.sensor_index = params[0]; + if (params_len > 1) { + response.sensor_value.data = ¶ms[1]; + response.sensor_value.data_len = params_len - 1; + } else { + response.sensor_value.data = NULL; + response.sensor_value.data_len = 0; + } + } + ESP_LOGD(TAG, "Sensor value response: esl_id=0x%02X sensor=%u data_len=%u", + esl_id, response.sensor_value.sensor_index, + response.sensor_value.data_len); + + } else if (tag_nibble == 0x0F) { + /* Vendor-specific Response (opcode 0x_F) */ + response.type = ESL_AP_RESP_VENDOR; + response.vendor.data = params; + response.vendor.data_len = params_len; + ESP_LOGD(TAG, "Vendor response: esl_id=0x%02X data_len=%u", + esl_id, response.vendor.data_len); + + } else { + ESP_LOGW(TAG, "Unknown response opcode: 0x%02X (tag=0x%X len=0x%X)", + opcode, tag_nibble, len_nibble); + return; + } + + /* Deliver the response event to the application */ + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_RESPONSE, &response); + } +} + +/* ========================== Transport Dispatch ========================== */ + +/** + * @brief Core command dispatch logic — selects transport and sends the command + * + * @param esl_id Target ESL_ID (0x00–0xFE, or 0xFF for broadcast) + * @param group_id Target Group_ID (0x00–0x7F) + * @param opcode Command opcode + * @param params Command parameter bytes (first byte is esl_id) + * @param params_len Length of params + * @param ecp_only If true, command is restricted to ECP transport + * @return ESP_OK on success; error code on failure + */ +static esp_err_t dispatch_command(uint8_t esl_id, uint8_t group_id, + uint8_t opcode, const uint8_t *params, + uint8_t params_len, bool ecp_only) +{ + if (g_esl_ap == NULL || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + if (group_id > BLE_ESL_GROUP_ID_MAX) { + ESP_LOGE(TAG, "Invalid group_id: 0x%02X", group_id); + return ESP_ERR_INVALID_ARG; + } + + /* Encode the command into TLV wire format */ + uint8_t tlv_buf[BLE_ESL_TLV_MAX_SIZE]; + uint8_t tlv_len = 0; + esp_err_t ret = ble_esl_tlv_encode(opcode, params, params_len, + tlv_buf, &tlv_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "TLV encode failed: 0x%X", ret); + return ret; + } + + /* Broadcast: always use PAwR */ + if (esl_id == BLE_ESL_BROADCAST_ADDRESS) { + if (ecp_only) { + ESP_LOGE(TAG, "Command 0x%02X cannot be broadcast", opcode); + return ESP_ERR_INVALID_ARG; + } + + /* Build ESL payload and send via PAwR */ + uint8_t payload_buf[BLE_ESL_PAYLOAD_MAX_SIZE]; + uint8_t payload_len = 0; + const uint8_t *tlv_ptrs[] = { tlv_buf }; + const uint8_t tlv_lens[] = { tlv_len }; + + ret = ble_esl_payload_encode(group_id, tlv_ptrs, tlv_lens, 1, + payload_buf, &payload_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Payload encode failed: 0x%X", ret); + return ret; + } + + ret = ble_esl_ap_pawr_send(group_id, payload_buf, payload_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "PAwR send failed: 0x%X", ret); + } + return ret; + } + + /* Unicast: determine transport based on ESL tracked state */ + uint16_t esl_addr = BLE_ESL_AP_MAKE_ADDR(esl_id, group_id); + ble_esl_state_t state = ble_esl_ap_get_esl_state(ble_esl_addr_make(esl_id, group_id)); + + switch (state) { + case BLE_ESL_STATE_CONFIGURING: + case BLE_ESL_STATE_UPDATING: { + /* ECP transport (connection-oriented) */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(esl_addr); + if (esl == NULL || esl->conn_handle == BLE_ESL_AP_CONN_HANDLE_INVALID) { + ESP_LOGE(TAG, "No active connection for ESL 0x%04X", esl_addr); + return ESP_ERR_INVALID_STATE; + } + + uint16_t conn_handle = esl->conn_handle; + + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + + /* Check if there's already a pending ECP command on this connection */ + esl_ap_ecp_cmd_ctx_t *existing = find_ecp_ctx_by_conn(conn_handle); + if (existing != NULL) { + xSemaphoreGive(s_ecp_ctx_mutex); + ESP_LOGE(TAG, "ECP already pending on conn 0x%04X", conn_handle); + return ESP_ERR_INVALID_STATE; + } + + /* Allocate a command context */ + esl_ap_ecp_cmd_ctx_t *ctx = alloc_ecp_ctx(); + if (ctx == NULL) { + xSemaphoreGive(s_ecp_ctx_mutex); + ESP_LOGE(TAG, "No free ECP command context"); + return ESP_ERR_NO_MEM; + } + + ctx->in_use = true; + ctx->conn_handle = conn_handle; + ctx->esl_id = esl_id; + ctx->group_id = group_id; + ctx->cmd_opcode = opcode; + + /* Create the 30-second timeout timer */ + esp_timer_create_args_t timer_args = { + .callback = ecp_timeout_cb, + .arg = ctx, + .dispatch_method = ESP_TIMER_TASK, + .name = "ecp_timeout", + }; + ret = esp_timer_create(&timer_args, &ctx->timer); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create ECP timeout timer: 0x%X", ret); + free_ecp_ctx(ctx); + xSemaphoreGive(s_ecp_ctx_mutex); + return ret; + } + + /* Start the 30-second timeout timer BEFORE initiating the write + * to prevent a race where a fast response frees the context + * before the timer is started (Issue 7). */ + ret = esp_timer_start_once(ctx->timer, ECP_TIMEOUT_US); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start ECP timeout timer: 0x%X", ret); + free_ecp_ctx(ctx); + xSemaphoreGive(s_ecp_ctx_mutex); + return ret; + } + + xSemaphoreGive(s_ecp_ctx_mutex); + + /* Write the TLV to ECP characteristic */ + ret = ble_esl_ap_ecp_write(conn_handle, tlv_buf, tlv_len, + ecp_response_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "ECP write failed: 0x%X", ret); + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + free_ecp_ctx(ctx); + xSemaphoreGive(s_ecp_ctx_mutex); + return ret; + } + + return ESP_OK; + } + + case BLE_ESL_STATE_SYNCHRONIZED: { + /* ECP-only commands cannot be sent via PAwR */ + if (ecp_only) { + ESP_LOGE(TAG, "Command 0x%02X is ECP-only, cannot send in Synchronized state", + opcode); + return ESP_ERR_INVALID_STATE; + } + + /* PAwR transport (connectionless) */ + uint8_t payload_buf[BLE_ESL_PAYLOAD_MAX_SIZE]; + uint8_t payload_len = 0; + const uint8_t *tlv_ptrs[] = { tlv_buf }; + const uint8_t tlv_lens[] = { tlv_len }; + + ret = ble_esl_payload_encode(group_id, tlv_ptrs, tlv_lens, 1, + payload_buf, &payload_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Payload encode failed: 0x%X", ret); + return ret; + } + + /* Store the command opcode for response correlation */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(esl_addr); + if (esl != NULL) { + esl->pending_pawr_cmd_opcode = opcode; + } + + /* Record the response slot mapping for this group (Issue 9) */ + pawr_record_slot_mapping(group_id, esl_id); + + ret = ble_esl_ap_pawr_send(group_id, payload_buf, payload_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "PAwR send failed: 0x%X", ret); + } + return ret; + } + + case BLE_ESL_STATE_UNASSOCIATED: + case BLE_ESL_STATE_UNSYNCHRONIZED: + default: + ESP_LOGE(TAG, "ESL 0x%04X in state %d — cannot dispatch command", + esl_addr, state); + return ESP_ERR_INVALID_STATE; + } +} + +/* ========================== LED Parameter Builder ========================== */ + +/** + * @brief Build LED control parameter bytes from settings + * + * LED Control params: esl_id(1) + led_index(1) + color_brightness(1) + + * flashing_pattern(7) + repeat(2) = 12 bytes + * + * @param esl_id Target ESL_ID + * @param led_index LED index + * @param settings LED control settings + * @param out_params Output buffer (must be >= 12 bytes) + * @param out_len Output: number of bytes written + * @return ESP_OK on success + */ +static esp_err_t build_led_params(uint8_t esl_id, uint8_t led_index, + const esl_ap_led_settings_t *settings, + uint8_t *out_params, uint8_t *out_len) +{ + if (settings == NULL || out_params == NULL || out_len == NULL) { + return ESP_ERR_INVALID_ARG; + } + + uint8_t pos = 0; + + /* ESL_ID */ + out_params[pos++] = esl_id; + + /* LED_Index */ + out_params[pos++] = led_index; + + /* Color + Brightness byte */ + out_params[pos++] = BLE_ESL_LED_CTRL_COLOR_BRIGHTNESS( + settings->color_red, settings->color_green, + settings->color_blue, settings->brightness); + + /* Flashing pattern (7 bytes) */ + memcpy(&out_params[pos], settings->flashing_pattern, 7); + pos += 7; + + /* Repeat field (2 bytes, little-endian) */ + uint16_t repeat = BLE_ESL_LED_CTRL_REPEAT(settings->repeat_type, + settings->repeats_duration); + out_params[pos++] = (uint8_t)(repeat & 0xFF); + out_params[pos++] = (uint8_t)((repeat >> 8) & 0xFF); + + *out_len = pos; /* Should be 12 */ + return ESP_OK; +} + +/* ========================== Public Command APIs ========================== */ + +esp_err_t ble_esl_ap_ping(uint8_t esl_id, uint8_t group_id) +{ + /* Ping: opcode 0x00, params = [esl_id] (1 byte) */ + uint8_t params[1] = { esl_id }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_PING, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_unassociate(uint8_t esl_id, uint8_t group_id) +{ + /* Unassociate: opcode 0x01, ECP only, no broadcast. + * Per ESL Profile spec (Section 6.1.4), Unassociate is a connection-oriented + * command that shall only be written to the ECP characteristic. */ + if (esl_id == BLE_ESL_BROADCAST_ADDRESS) { + ESP_LOGE(TAG, "Unassociate cannot be broadcast"); + return ESP_ERR_INVALID_ARG; + } + if (group_id > BLE_ESL_GROUP_ID_MAX) { + return ESP_ERR_INVALID_ARG; + } + + uint8_t params[1] = { esl_id }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_UNASSOCIATE, + params, sizeof(params), true); +} + +esp_err_t ble_esl_ap_service_reset(uint8_t esl_id, uint8_t group_id) +{ + /* Service Reset: opcode 0x02, params = [esl_id] (1 byte) */ + uint8_t params[1] = { esl_id }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_SERVICE_RESET, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_factory_reset(uint8_t esl_id, uint8_t group_id) +{ + /* Factory Reset: opcode 0x03, ECP only, no broadcast */ + if (esl_id == BLE_ESL_BROADCAST_ADDRESS) { + ESP_LOGE(TAG, "Factory Reset cannot be broadcast"); + return ESP_ERR_INVALID_ARG; + } + if (group_id > BLE_ESL_GROUP_ID_MAX) { + return ESP_ERR_INVALID_ARG; + } + + uint8_t params[1] = { esl_id }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_FACTORY_RESET, + params, sizeof(params), true); +} + +esp_err_t ble_esl_ap_read_sensor(uint8_t esl_id, uint8_t group_id, + uint8_t sensor_index) +{ + /* Read Sensor: opcode 0x10, params = [esl_id, sensor_index] (2 bytes) */ + uint8_t params[2] = { esl_id, sensor_index }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_READ_SENSOR, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_refresh_display(uint8_t esl_id, uint8_t group_id, + uint8_t display_index) +{ + /* Refresh Display: opcode 0x11, params = [esl_id, display_index] (2 bytes) */ + uint8_t params[2] = { esl_id, display_index }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_REFRESH_DISPLAY, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_display_image(uint8_t esl_id, uint8_t group_id, + uint8_t display_index, uint8_t image_index) +{ + /* Display Image: opcode 0x20, params = [esl_id, display_index, image_index] (3 bytes) */ + uint8_t params[3] = { esl_id, display_index, image_index }; + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_DISPLAY_IMAGE, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_display_timed_image(uint8_t esl_id, uint8_t group_id, + uint8_t display_index, uint8_t image_index, + uint32_t absolute_time) +{ + /* Display Timed Image: opcode 0x60, + * params = [esl_id, display_index, image_index, abs_time(4)] (7 bytes) */ + uint8_t params[7]; + params[0] = esl_id; + params[1] = display_index; + params[2] = image_index; + params[3] = (uint8_t)(absolute_time & 0xFF); + params[4] = (uint8_t)((absolute_time >> 8) & 0xFF); + params[5] = (uint8_t)((absolute_time >> 16) & 0xFF); + params[6] = (uint8_t)((absolute_time >> 24) & 0xFF); + + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_DISPLAY_TIMED_IMAGE, + params, sizeof(params), false); +} + +esp_err_t ble_esl_ap_led_control(uint8_t esl_id, uint8_t group_id, + uint8_t led_index, + const esl_ap_led_settings_t *settings) +{ + if (settings == NULL) { + return ESP_ERR_INVALID_ARG; + } + + /* LED Control: opcode 0xB0, params = 12 bytes */ + uint8_t params[12]; + uint8_t params_len = 0; + esp_err_t ret = build_led_params(esl_id, led_index, settings, + params, ¶ms_len); + if (ret != ESP_OK) { + return ret; + } + + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_LED_CONTROL, + params, params_len, false); +} + +esp_err_t ble_esl_ap_led_timed_control(uint8_t esl_id, uint8_t group_id, + uint8_t led_index, + const esl_ap_led_settings_t *settings, + uint32_t absolute_time) +{ + if (settings == NULL) { + return ESP_ERR_INVALID_ARG; + } + + /* LED Timed Control: opcode 0xF0, params = 16 bytes (12 LED + 4 abs_time) */ + uint8_t params[16]; + uint8_t led_len = 0; + esp_err_t ret = build_led_params(esl_id, led_index, settings, + params, &led_len); + if (ret != ESP_OK) { + return ret; + } + + /* Append absolute_time (4 bytes, little-endian) */ + params[led_len] = (uint8_t)(absolute_time & 0xFF); + params[led_len + 1] = (uint8_t)((absolute_time >> 8) & 0xFF); + params[led_len + 2] = (uint8_t)((absolute_time >> 16) & 0xFF); + params[led_len + 3] = (uint8_t)((absolute_time >> 24) & 0xFF); + + return dispatch_command(esl_id, group_id, BLE_ESL_CMD_LED_TIMED_CONTROL, + params, led_len + 4, false); +} + +esp_err_t ble_esl_ap_vendor_command(uint8_t esl_id, uint8_t group_id, + const uint8_t *params, uint8_t params_len) +{ + if (params == NULL && params_len > 0) { + return ESP_ERR_INVALID_ARG; + } + + /* Total parameter count is 1 (ESL_ID) + params_len, and the opcode Length + * nibble encodes (parameter count - 1), so params_len must fit a nibble */ + if (params_len > 0x0F) { + return ESP_ERR_INVALID_ARG; + } + + /* Build the vendor opcode: Tag nibble = 0xF, Length nibble = params_len */ + uint8_t opcode = BLE_ESL_TLV_OPCODE(BLE_ESL_CMD_VENDOR_TAG, params_len); + + /* Build full params: esl_id + vendor params */ + uint8_t full_params[BLE_ESL_TLV_MAX_SIZE]; + uint8_t full_len = 0; + + full_params[full_len++] = esl_id; + if (params_len > 0) { + memcpy(&full_params[full_len], params, params_len); + full_len += params_len; + } + + return dispatch_command(esl_id, group_id, opcode, + full_params, full_len, false); +} + +/* ========================== ECP Notification Handler ========================== */ + +void ble_esl_ap_command_handle_ecp_notification(uint16_t conn_handle, + const uint8_t *data, + uint16_t data_len) +{ + if (s_ecp_ctx_mutex == NULL) { + return; + } + + /* Find the connection context */ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn == NULL) { + ESP_LOGW(TAG, "ECP notification on unknown conn 0x%04X", conn_handle); + return; + } + + xSemaphoreTake(s_ecp_ctx_mutex, portMAX_DELAY); + + /* Find the pending ECP command context */ + esl_ap_ecp_cmd_ctx_t *ctx = find_ecp_ctx_by_conn(conn_handle); + if (ctx == NULL) { + xSemaphoreGive(s_ecp_ctx_mutex); + ESP_LOGW(TAG, "ECP notification with no pending command on conn 0x%04X", + conn_handle); + return; + } + + /* Check if context is still active */ + if (!ctx->in_use) { + xSemaphoreGive(s_ecp_ctx_mutex); + return; + } + + /* Stop the timeout timer */ + if (ctx->timer != NULL) { + esp_timer_stop(ctx->timer); /* Ignore error if already stopped */ + } + + /* Capture context data before freeing */ + uint8_t esl_id = ctx->esl_id; + uint8_t group_id = ctx->group_id; + uint8_t cmd_opcode = ctx->cmd_opcode; + + /* Clean up the context */ + free_ecp_ctx(ctx); + + xSemaphoreGive(s_ecp_ctx_mutex); + + /* Parse and deliver the response (outside lock) */ + if (data != NULL && data_len > 0) { + parse_response(esl_id, group_id, data, + (uint8_t)(data_len > 255 ? 255 : data_len), + cmd_opcode); + } +} + +/* ========================== PAwR Response Handler ========================== */ + +void ble_esl_ap_command_handle_pawr_response(uint8_t group_id, + uint8_t response_slot, + const uint8_t *data, + uint8_t data_len) +{ + if (g_esl_ap == NULL) { + return; + } + + if (data == NULL || data_len == 0) { + ESP_LOGD(TAG, "Empty PAwR response slot %u in group %u", + response_slot, group_id); + return; + } + + /* + * Look up the ESL entry that was assigned to this response slot. + * The response slot is determined by the position of the last + * individually-addressed TLV for that ESL in the transmitted payload. + * We use the slot-to-ESL mapping recorded during PAwR send to find + * the correct ESL entry. + */ + uint16_t esl_addr = pawr_lookup_slot(group_id, response_slot); + ble_esl_ap_esl_entry_t *esl = NULL; + if (esl_addr != ESL_ADDR_INVALID) { + esl = ble_esl_ap_find_esl(esl_addr); + } + + if (esl == NULL) { + ESP_LOGW(TAG, "PAwR response from unknown ESL: group=%u slot=%u", + group_id, response_slot); + return; + } + + esl_addr = esl->esl_addr; + uint8_t esl_id = BLE_ESL_AP_ADDR_ESL_ID(esl_addr); + + /* Retrieve and clear the stored command opcode for response correlation */ + uint8_t cmd_opcode = esl->pending_pawr_cmd_opcode; + esl->pending_pawr_cmd_opcode = 0; + + /* Decrypt and parse the PAwR response */ + ble_esl_ap_parsed_response_t parsed; + esp_err_t ret = ble_esl_ap_pawr_parse_response(esl_addr, response_slot, + data, data_len, &parsed); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "PAwR response parse failed for ESL 0x%04X: 0x%X", + esl_addr, ret); + return; + } + + /* Parse each response TLV from the decrypted payload */ + if (parsed.payload != NULL && parsed.payload_len > 0) { + uint8_t offset = 0; + while (offset < parsed.payload_len) { + uint8_t remaining = parsed.payload_len - offset; + if (remaining < BLE_ESL_TLV_MIN_SIZE) { + break; + } + + /* Determine TLV size from the opcode at current offset */ + uint8_t resp_opcode = parsed.payload[offset]; + uint8_t tlv_total = BLE_ESL_TLV_TOTAL_LEN(resp_opcode); + if (tlv_total > remaining) { + ESP_LOGW(TAG, "Truncated TLV in PAwR response at offset %u", offset); + break; + } + + /* Parse this individual response TLV */ + parse_response(esl_id, group_id, &parsed.payload[offset], + tlv_total, cmd_opcode); + offset += tlv_total; + } + } +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_connection.c b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_connection.c new file mode 100644 index 00000000000..5c99dbaf03b --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_connection.c @@ -0,0 +1,979 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap_connection.c + * @brief ESL AP — Connection management, service/characteristic discovery, + * and internal GATT read/write/ECP helpers. + */ + +#include +#include "esp_log.h" +#include "esp_err.h" + +#include "nimble/ble.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" + +#include "ble_esl_ap.h" +#include "ble_esl_ap_int.h" + +static const char *TAG = "esl_ap_conn"; + +/** PAwR advertising instance (shared with ap_pawr.c / ap_lifecycle.c) */ +#define PAWR_ADV_INSTANCE 0 + +/** Connection-establishment timeout for the PAwR connection procedure (ms) */ +#define PAWR_CONNECT_TIMEOUT_MS 30000 + +/* ========================== Global State ========================== */ + +ble_esl_ap_state_t *g_esl_ap = NULL; + +/* ========================== Forward Declarations ========================== */ + +static int ble_esl_ap_gap_event(struct ble_gap_event *event, void *arg); +static void handle_scan_result(struct ble_gap_event *event); +static void handle_connect_event(struct ble_gap_event *event, void *arg); +static void handle_disconnect_event(struct ble_gap_event *event); +static void handle_enc_change(struct ble_gap_event *event); +static void handle_pairing_complete(struct ble_gap_event *event); +static void handle_notify_rx(struct ble_gap_event *event); + + +/* ========================== Helper Functions ========================== */ + +ble_esl_ap_conn_t *ble_esl_ap_find_conn(uint16_t conn_handle) +{ + if (!g_esl_ap) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (g_esl_ap->conns[i].in_use && + g_esl_ap->conns[i].conn_handle == conn_handle) { + return &g_esl_ap->conns[i]; + } + } + return NULL; +} + +ble_esl_ap_conn_t *ble_esl_ap_alloc_conn(void) +{ + if (!g_esl_ap) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + if (!g_esl_ap->conns[i].in_use) { + memset(&g_esl_ap->conns[i], 0, sizeof(ble_esl_ap_conn_t)); + g_esl_ap->conns[i].in_use = true; + g_esl_ap->conns[i].conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + g_esl_ap->conns[i].esl_addr = BLE_ESL_AP_CONN_HANDLE_INVALID; + return &g_esl_ap->conns[i]; + } + } + return NULL; +} + +void ble_esl_ap_free_conn(ble_esl_ap_conn_t *conn) +{ + if (conn) { + memset(conn, 0, sizeof(ble_esl_ap_conn_t)); + conn->in_use = false; + conn->conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + } +} + +ble_esl_ap_esl_entry_t *ble_esl_ap_find_esl(uint16_t esl_addr) +{ + if (!g_esl_ap) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + if (g_esl_ap->esls[i].in_use && + g_esl_ap->esls[i].esl_addr == esl_addr) { + return &g_esl_ap->esls[i]; + } + } + return NULL; +} + +ble_esl_ap_esl_entry_t *ble_esl_ap_find_esl_by_ble_addr(const uint8_t *addr, + uint8_t addr_type) +{ + if (!g_esl_ap || !addr) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + if (g_esl_ap->esls[i].in_use && + g_esl_ap->esls[i].ble_addr_type == addr_type && + memcmp(g_esl_ap->esls[i].ble_addr, addr, 6) == 0) { + return &g_esl_ap->esls[i]; + } + } + return NULL; +} + +ble_esl_ap_esl_entry_t *ble_esl_ap_alloc_esl(void) +{ + if (!g_esl_ap) { + return NULL; + } + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + if (!g_esl_ap->esls[i].in_use) { + memset(&g_esl_ap->esls[i], 0, sizeof(ble_esl_ap_esl_entry_t)); + g_esl_ap->esls[i].in_use = true; + g_esl_ap->esls[i].conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + return &g_esl_ap->esls[i]; + } + } + return NULL; +} + +bool ble_esl_ap_is_associated(const uint8_t *addr, uint8_t addr_type) +{ + return (ble_esl_ap_find_esl_by_ble_addr(addr, addr_type) != NULL); +} + +/* ========================== Public APIs ========================== */ + +esp_err_t ble_esl_ap_init(const ble_esl_ap_config_t *config) +{ + if (!config || !config->callback) { + return ESP_ERR_INVALID_ARG; + } + /* Zero subevents would leave the PAwR train with nothing to transmit in and + * is used as a divisor when servicing subevent data requests. */ + if (config->pawr_config.num_subevents == 0 || + config->pawr_config.num_subevents > BLE_ESL_AP_PAWR_MAX_SUBEVENTS) { + ESP_LOGE(TAG, "num_subevents %u out of range (1..%d)", + config->pawr_config.num_subevents, BLE_ESL_AP_PAWR_MAX_SUBEVENTS); + return ESP_ERR_INVALID_ARG; + } + if (g_esl_ap) { + ESP_LOGE(TAG, "Already initialized"); + return ESP_ERR_INVALID_STATE; + } + + g_esl_ap = calloc(1, sizeof(ble_esl_ap_state_t)); + if (!g_esl_ap) { + ESP_LOGE(TAG, "Failed to allocate AP state"); + return ESP_ERR_NO_MEM; + } + + g_esl_ap->app_cb = config->callback; + g_esl_ap->pawr_config = config->pawr_config; + g_esl_ap->initialized = true; + g_esl_ap->started = false; + g_esl_ap->pawr_active = false; + + /* Initialize connection table */ + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + g_esl_ap->conns[i].in_use = false; + g_esl_ap->conns[i].conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + } + + /* Initialize ESL tracking table */ + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + g_esl_ap->esls[i].in_use = false; + g_esl_ap->esls[i].conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + } + + /* Initialize sub-modules */ + esp_err_t ret = ble_esl_ap_lifecycle_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to init lifecycle sub-module: %s", esp_err_to_name(ret)); + free(g_esl_ap); + g_esl_ap = NULL; + return ret; + } + + ret = ble_esl_ap_pawr_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to init PAwR sub-module: %s", esp_err_to_name(ret)); + ble_esl_ap_lifecycle_deinit(); + free(g_esl_ap); + g_esl_ap = NULL; + return ret; + } + + ret = ble_esl_ap_command_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to init command sub-module: %s", esp_err_to_name(ret)); + ble_esl_ap_pawr_deinit(); + ble_esl_ap_lifecycle_deinit(); + free(g_esl_ap); + g_esl_ap = NULL; + return ret; + } + + ret = ble_esl_ap_gattc_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to init GATT client: %s", esp_err_to_name(ret)); + ble_esl_ap_command_deinit(); + ble_esl_ap_pawr_deinit(); + ble_esl_ap_lifecycle_deinit(); + free(g_esl_ap); + g_esl_ap = NULL; + return ret; + } + + ESP_LOGI(TAG, "ESL AP initialized"); + return ESP_OK; +} + +/** + * @brief Run the disconnect cleanup for one established link during deinit + * + * ble_gap_terminate() is asynchronous, so the BLE_GAP_EVENT_DISCONNECT for a + * link torn down by ble_esl_ap_deinit() is delivered after the module state has + * been freed and is therefore dropped by the GAP event handler. The per-link + * cleanup normally performed by handle_disconnect_event() must consequently be + * done synchronously here. + * + * @param conn Connection context with a valid conn_handle + */ +static void deinit_teardown_conn(ble_esl_ap_conn_t *conn) +{ + uint16_t conn_handle = conn->conn_handle; + + /* Fire disconnected event */ + if (g_esl_ap->app_cb) { + ble_esl_ap_disconn_info_t info; + info.conn_handle = conn_handle; + memcpy(info.addr, conn->addr, 6); + info.reason = BLE_ERR_REM_USER_CONN_TERM; + g_esl_ap->app_cb(BLE_ESL_AP_EVT_DISCONNECTED, &info); + } + + /* Invoke any pending ECP callback with error before clearing the slot */ + if (conn->ecp_pending && conn->ecp_cb) { + ble_esl_ap_gatt_cb_t cb = conn->ecp_cb; + void *user_data = conn->ecp_user_data; + conn->ecp_pending = false; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + cb(conn_handle, ESP_FAIL, NULL, 0, user_data); + } + + /* Clean up any pending ECP command context and timer in the command module */ + ble_esl_ap_command_cleanup_conn(conn_handle); + + /* Let lifecycle module complete any pending synchronize procedure + * and handle link-loss state transitions */ + ble_esl_ap_lifecycle_handle_disconnect(conn_handle); +} + +esp_err_t ble_esl_ap_deinit(void) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + /* Stop scanning and PAwR if active */ + if (g_esl_ap->started) { + ble_esl_ap_stop(); + } + + /* Mark the module as no longer operational before touching connections: + * from here on the GAP event handler drops every asynchronously delivered + * event, so no callback can dereference the state (nor the conns[] slot + * passed as GAP cb_arg) while it is being torn down and freed below. */ + g_esl_ap->initialized = false; + + /* Cancel any pending (not yet established) connection attempts */ + ble_gap_conn_cancel(); + + /* Disconnect all active connections and free every slot synchronously */ + esp_err_t ret = ESP_OK; + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_CONNECTIONS; i++) { + ble_esl_ap_conn_t *conn = &g_esl_ap->conns[i]; + if (!conn->in_use) { + continue; + } + if (conn->conn_handle != BLE_ESL_AP_CONN_HANDLE_INVALID) { + int rc = ble_gap_terminate(conn->conn_handle, BLE_ERR_REM_USER_CONN_TERM); + /* ENOTCONN (link already gone), EALREADY (termination already in + * progress) and EDISABLED (host stopped, so no link survives) all + * mean the link is on its way out: cleaning the slot up is safe. + * Any other error means the controller link is still up — keep the + * slot so the link stays tracked and abort the deinit below. */ + if (rc != 0 && rc != BLE_HS_ENOTCONN && rc != BLE_HS_EALREADY && + rc != BLE_HS_EDISABLED) { + ESP_LOGE(TAG, "Failed to terminate conn_handle=%u; rc=%d", + conn->conn_handle, rc); + ret = ESP_FAIL; + continue; + } + deinit_teardown_conn(conn); + } + ble_esl_ap_free_conn(conn); + } + + if (ret != ESP_OK) { + /* At least one link could not be terminated. Tearing the module down + * now would leak it: the application would be told the link is gone + * while the controller keeps it up, and the later GAP events would be + * dropped. Return to the initialized (stopped) state instead so the + * remaining links stay tracked and the caller can retry deinit. */ + g_esl_ap->initialized = true; + ESP_LOGE(TAG, "Deinit aborted: connection termination failed"); + return ret; + } + + /* Deinitialize sub-modules */ + ble_esl_ap_gattc_deinit(); + ble_esl_ap_command_deinit(); + ble_esl_ap_pawr_deinit(); + ble_esl_ap_lifecycle_deinit(); + + /* Free global state — set pointer to NULL first to prevent + * asynchronous callbacks from dereferencing freed memory */ + ble_esl_ap_state_t *ap = g_esl_ap; + g_esl_ap = NULL; + free(ap); + + ESP_LOGI(TAG, "ESL AP deinitialized"); + return ESP_OK; +} + +esp_err_t ble_esl_ap_start(void) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + if (g_esl_ap->started) { + ESP_LOGW(TAG, "Already started"); + return ESP_ERR_INVALID_STATE; + } + + /* Start GAP General Discovery (scanning) */ + uint8_t own_addr_type; + int rc = ble_hs_id_infer_auto(0, &own_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to determine address type; rc=%d", rc); + return ESP_FAIL; + } + + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; /* Active scan for General Discovery */ + disc_params.itvl = 0; /* Use defaults */ + disc_params.window = 0; + disc_params.filter_policy = 0; + disc_params.limited = 0; + + rc = ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start GAP discovery; rc=%d", rc); + return ESP_FAIL; + } + + /* Start PAwR broadcasting */ + esp_err_t ret = ble_esl_ap_pawr_start(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start PAwR: %s", esp_err_to_name(ret)); + ble_gap_disc_cancel(); + return ret; + } + + g_esl_ap->started = true; + ESP_LOGI(TAG, "ESL AP started (scanning + PAwR)"); + return ESP_OK; +} + +esp_err_t ble_esl_ap_stop(void) +{ + if (!g_esl_ap || !g_esl_ap->initialized || !g_esl_ap->started) { + return ESP_ERR_INVALID_STATE; + } + + /* Cancel any pending (not yet established) connection attempts */ + ble_gap_conn_cancel(); + + /* Stop scanning */ + if (ble_gap_disc_active()) { + int rc = ble_gap_disc_cancel(); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "Failed to cancel discovery; rc=%d", rc); + } + } + + /* Stop PAwR broadcasting */ + esp_err_t ret = ble_esl_ap_pawr_stop(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to stop PAwR: %s", esp_err_to_name(ret)); + } + + g_esl_ap->started = false; + ESP_LOGI(TAG, "ESL AP stopped"); + return ESP_OK; +} + +esp_err_t ble_esl_ap_connect(const uint8_t *addr, uint8_t addr_type) +{ + if (!addr) { + return ESP_ERR_INVALID_ARG; + } + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + /* Check if we have a free connection slot */ + ble_esl_ap_conn_t *conn = ble_esl_ap_alloc_conn(); + if (!conn) { + ESP_LOGE(TAG, "Max connections reached"); + return ESP_ERR_NO_MEM; + } + + /* Store address in the pre-allocated slot */ + memcpy(conn->addr, addr, 6); + conn->addr_type = addr_type; + + /* Build peer address */ + ble_addr_t peer_addr; + peer_addr.type = addr_type; + memcpy(peer_addr.val, addr, 6); + + uint8_t own_addr_type; + int rc = ble_hs_id_infer_auto(0, &own_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to determine address type; rc=%d", rc); + ble_esl_ap_free_conn(conn); + return ESP_FAIL; + } + + /* Stop scanning before connecting (NimBLE requirement unless + * BLE_HOST_ALLOW_CONNECT_WITH_SCAN is enabled) */ + if (ble_gap_disc_active()) { + rc = ble_gap_disc_cancel(); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "Failed to cancel scan before connect; rc=%d", rc); + } + } + + /* Initiate connection with default parameters, 30s timeout. + * Pass conn as cb_arg so the GAP event handler can directly + * identify the connection slot without guessing. */ + rc = ble_gap_connect(own_addr_type, &peer_addr, 30000, NULL, + ble_esl_ap_gap_event, conn); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to initiate connection; rc=%d", rc); + ble_esl_ap_free_conn(conn); + + /* Resume scanning if we were started */ + if (g_esl_ap->started) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Connection initiated to %02x:%02x:%02x:%02x:%02x:%02x", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]); + return ESP_OK; +} + +esp_err_t ble_esl_ap_connect_synced(ble_esl_address_t esl_addr) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + uint16_t addr_key = BLE_ESL_AP_ADDR_PACK(esl_addr); + + /* The ESL must be tracked and currently Synchronized to our PAwR train. + * Only a Synchronized ESL is reachable via the Periodic Advertising + * Connection procedure; in other states use ble_esl_ap_connect(). */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(addr_key); + if (!esl) { + ESP_LOGE(TAG, "connect_synced: ESL 0x%04X not tracked", addr_key); + return ESP_ERR_NOT_FOUND; + } + if (esl->state != BLE_ESL_STATE_SYNCHRONIZED) { + ESP_LOGE(TAG, "connect_synced: ESL 0x%04X not Synchronized (state=%d)", + addr_key, esl->state); + return ESP_ERR_INVALID_STATE; + } + + /* Reserve a connection slot; the GAP event handler identifies it via cb_arg. */ + ble_esl_ap_conn_t *conn = ble_esl_ap_alloc_conn(); + if (!conn) { + ESP_LOGE(TAG, "connect_synced: max connections reached"); + return ESP_ERR_NO_MEM; + } + memcpy(conn->addr, esl->ble_addr, 6); + conn->addr_type = esl->ble_addr_type; + conn->esl_addr = addr_key; + + ble_addr_t peer_addr; + peer_addr.type = esl->ble_addr_type; + memcpy(peer_addr.val, esl->ble_addr, 6); + + uint8_t own_addr_type; + int rc = ble_hs_id_infer_auto(0, &own_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "connect_synced: failed to determine address type; rc=%d", rc); + ble_esl_ap_free_conn(conn); + return ESP_FAIL; + } + + /* Initiating a connection is not possible while scanning is in progress. */ + if (ble_gap_disc_active()) { + rc = ble_gap_disc_cancel(); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "connect_synced: failed to cancel scan; rc=%d", rc); + } + } + + /* Periodic Advertising Connection procedure: send the AUX_CONNECT_REQ from + * the subevent this ESL responds in — its Group_ID — on our own PAwR train + * (PAWR_ADV_INSTANCE). NULL phy params request controller defaults. */ + uint8_t subevent = BLE_ESL_ADDR_GROUP_ID(esl_addr); + rc = ble_gap_connect_with_synced(own_addr_type, PAWR_ADV_INSTANCE, subevent, + &peer_addr, PAWR_CONNECT_TIMEOUT_MS, + BLE_GAP_LE_PHY_1M_MASK, NULL, NULL, NULL, + ble_esl_ap_gap_event, conn); + if (rc != 0) { + ESP_LOGE(TAG, "connect_synced: ble_gap_connect_with_synced failed; rc=%d", rc); + ble_esl_ap_free_conn(conn); + + /* Resume scanning if we were started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + return ESP_FAIL; + } + + ESP_LOGI(TAG, "connect_synced: PAwR connection initiated to ESL 0x%04X (subevent=%u)", + addr_key, subevent); + return ESP_OK; +} + +esp_err_t ble_esl_ap_disconnect(uint16_t conn_handle) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return ESP_ERR_NOT_FOUND; + } + + int rc = ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to terminate connection; rc=%d", rc); + return ESP_FAIL; + } + + return ESP_OK; +} + +/* ========================== GAP Event Handler ========================== */ + +static int ble_esl_ap_gap_event(struct ble_gap_event *event, void *arg) +{ + /* ble_esl_ap_deinit() clears `initialized` before tearing the module down, + * so any event delivered from that point on must be dropped: the state — + * including the conns[] slot passed as `arg` — is being freed. */ + if (!g_esl_ap || !g_esl_ap->initialized) { + if (event->type == BLE_GAP_EVENT_CONNECT && event->connect.status == 0) { + /* A link came up while shutting down; drop it so it is not leaked */ + ble_gap_terminate(event->connect.conn_handle, + BLE_ERR_REM_USER_CONN_TERM); + } + return 0; + } + + switch (event->type) { + case BLE_GAP_EVENT_DISC: + case BLE_GAP_EVENT_EXT_DISC: + handle_scan_result(event); + return 0; + + case BLE_GAP_EVENT_CONNECT: + handle_connect_event(event, arg); + return 0; + + case BLE_GAP_EVENT_DISCONNECT: + handle_disconnect_event(event); + return 0; + + case BLE_GAP_EVENT_ENC_CHANGE: + handle_enc_change(event); + return 0; + + case BLE_GAP_EVENT_PARING_COMPLETE: + handle_pairing_complete(event); + return 0; + + case BLE_GAP_EVENT_NOTIFY_RX: + handle_notify_rx(event); + return 0; + + case BLE_GAP_EVENT_DISC_COMPLETE: + ESP_LOGI(TAG, "Discovery complete; reason=%d", event->disc_complete.reason); + return 0; + + case BLE_GAP_EVENT_MTU: + ESP_LOGD(TAG, "MTU update; conn_handle=%d mtu=%d", + event->mtu.conn_handle, event->mtu.value); + return 0; + + default: + return 0; + } +} + +/* ========================== Scan Result Handling ========================== */ + +static void handle_scan_result(struct ble_gap_event *event) +{ + if (!g_esl_ap || !g_esl_ap->app_cb) { + return; + } + + ble_addr_t addr; + int8_t rssi; + const uint8_t *data; + uint8_t length_data; + + if (event->type == BLE_GAP_EVENT_EXT_DISC) { + const struct ble_gap_ext_disc_desc *ext = &event->ext_disc; + addr = ext->addr; + rssi = ext->rssi; + data = ext->data; + length_data = ext->length_data; + } else { + const struct ble_gap_disc_desc *disc = &event->disc; + addr = disc->addr; + rssi = disc->rssi; + data = disc->data; + length_data = disc->length_data; + } + + bool associated = ble_esl_ap_is_associated(addr.val, addr.type); + + ble_esl_ap_scan_result_t result; + memcpy(result.addr, addr.val, 6); + result.addr_type = addr.type; + result.rssi = rssi; + result.adv_data = data; + result.adv_data_len = length_data; + result.is_associated = associated; + + g_esl_ap->app_cb(BLE_ESL_AP_EVT_SCAN_RESULT, &result); +} + +/* ========================== Connect Event Handling ========================== */ + +static void handle_connect_event(struct ble_gap_event *event, void *arg) +{ + if (!g_esl_ap) { + return; + } + + uint16_t conn_handle = event->connect.conn_handle; + ble_esl_ap_conn_t *conn = (ble_esl_ap_conn_t *)arg; + + if (event->connect.status != 0) { + /* Connection failed */ + ESP_LOGE(TAG, "Connection failed; status=%d", event->connect.status); + + /* Use the conn pointer passed as cb_arg to directly identify the slot */ + if (conn && conn->in_use && + conn->conn_handle == BLE_ESL_AP_CONN_HANDLE_INVALID) { + /* Fire disconnected event */ + if (g_esl_ap->app_cb) { + ble_esl_ap_disconn_info_t info; + info.conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + memcpy(info.addr, conn->addr, 6); + info.reason = (uint8_t)event->connect.status; + g_esl_ap->app_cb(BLE_ESL_AP_EVT_DISCONNECTED, &info); + } + ble_esl_ap_free_conn(conn); + } + + /* Resume scanning if started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + uint8_t own_addr_type; + if (ble_hs_id_infer_auto(0, &own_addr_type) == 0) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + } + return; + } + + /* Connection established successfully */ + ESP_LOGI(TAG, "Connection established; conn_handle=%d", conn_handle); + + /* Get connection descriptor to retrieve peer identity address */ + struct ble_gap_conn_desc desc; + int rc = ble_gap_conn_find(conn_handle, &desc); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to find connection desc; rc=%d", rc); + + /* Free the pre-allocated connection slot to prevent a leak */ + if (conn && conn->in_use && + conn->conn_handle == BLE_ESL_AP_CONN_HANDLE_INVALID) { + if (g_esl_ap->app_cb) { + ble_esl_ap_disconn_info_t info; + info.conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + memcpy(info.addr, conn->addr, 6); + info.reason = BLE_ERR_UNSPECIFIED; + g_esl_ap->app_cb(BLE_ESL_AP_EVT_DISCONNECTED, &info); + } + ble_esl_ap_free_conn(conn); + } + + ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM); + + /* Resume scanning if started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + uint8_t own_addr_type; + if (ble_hs_id_infer_auto(0, &own_addr_type) == 0) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + } + return; + } + + if (!conn || !conn->in_use || + conn->conn_handle != BLE_ESL_AP_CONN_HANDLE_INVALID) { + ESP_LOGE(TAG, "No valid pre-allocated conn slot for handle=%d", conn_handle); + ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM); + + /* Resume scanning if started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + uint8_t own_addr_type; + if (ble_hs_id_infer_auto(0, &own_addr_type) == 0) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + } + return; + } + + /* Populate connection context using the stable identity address + * (peer_id_addr) instead of the temporary OTA address (peer_ota_addr) + * to ensure correct ESL tracking across reconnections with RPAs. */ + conn->conn_handle = conn_handle; + memcpy(conn->addr, desc.peer_id_addr.val, 6); + conn->addr_type = desc.peer_id_addr.type; + memset(&conn->handles, 0, sizeof(ble_esl_ap_char_handles_t)); + conn->ecp_pending = false; + + /* Start security */ + rc = ble_gap_security_initiate(conn_handle); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "Failed to initiate encryption; conn_handle=%u rc=%d", + conn_handle, rc); + } + ESP_LOGI(TAG, "Initiate encryption; conn_handle=%u", conn_handle); + + /* Resume scanning if started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + uint8_t own_addr_type; + if (ble_hs_id_infer_auto(0, &own_addr_type) == 0) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + } +} + +/* ========================== Disconnect Event Handling ========================== */ + +static void handle_disconnect_event(struct ble_gap_event *event) +{ + if (!g_esl_ap) { + return; + } + + uint16_t conn_handle = event->disconnect.conn.conn_handle; + int reason = event->disconnect.reason; + + ESP_LOGI(TAG, "Disconnected; conn_handle=%d reason=%d", conn_handle, reason); + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + ESP_LOGW(TAG, "Disconnect for unknown conn_handle=%d", conn_handle); + return; + } + + /* Fire disconnected event */ + if (g_esl_ap->app_cb) { + ble_esl_ap_disconn_info_t info; + info.conn_handle = conn_handle; + memcpy(info.addr, conn->addr, 6); + info.reason = (uint8_t)reason; + g_esl_ap->app_cb(BLE_ESL_AP_EVT_DISCONNECTED, &info); + } + + /* Invoke any pending ECP callback with error before clearing the slot */ + if (conn->ecp_pending && conn->ecp_cb) { + ble_esl_ap_gatt_cb_t cb = conn->ecp_cb; + void *user_data = conn->ecp_user_data; + conn->ecp_pending = false; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + cb(conn_handle, ESP_FAIL, NULL, 0, user_data); + } + + /* Clean up any pending ECP command context and timer in the command module */ + ble_esl_ap_command_cleanup_conn(conn_handle); + + /* Let lifecycle module complete any pending synchronize procedure + * and handle link-loss state transitions */ + ble_esl_ap_lifecycle_handle_disconnect(conn_handle); + + /* Free connection slot */ + ble_esl_ap_free_conn(conn); + + /* Resume scanning if started */ + if (g_esl_ap->started && !ble_gap_disc_active()) { + uint8_t own_addr_type; + if (ble_hs_id_infer_auto(0, &own_addr_type) == 0) { + struct ble_gap_disc_params disc_params = {0}; + disc_params.filter_duplicates = 1; + disc_params.passive = 0; + ble_gap_disc(own_addr_type, BLE_HS_FOREVER, &disc_params, + ble_esl_ap_gap_event, NULL); + } + } +} + +/* ======================================================================== */ +/* Handle encryption change */ +/* ======================================================================== */ +static void handle_enc_change(struct ble_gap_event *event) +{ + uint16_t conn_handle = event->enc_change.conn_handle; + uint8_t status = event->enc_change.status; + ESP_LOGI(TAG, "Encryption change; conn_handle=%u status=%d", + conn_handle, status); + + if (status != 0) { + ESP_LOGW(TAG, "Encryption failed for conn_handle=%u; terminating connection", conn_handle); + ble_gap_terminate(conn_handle, BLE_ERR_AUTH_FAIL); + return; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + /* If the encrypted link is to an already-associated ESL — reconnected from + * Unsynchronized (connectable advertising) or from Synchronized (Periodic + * Advertising Connection procedure) — the ESL has entered the Updating + * state. Track it so ble_esl_ap_configure()/synchronize() see a valid state + * and link-loss recovery routes it back to Unsynchronized. */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl_by_ble_addr(conn->addr, + conn->addr_type); + if (esl != NULL && + (esl->state == BLE_ESL_STATE_SYNCHRONIZED || + esl->state == BLE_ESL_STATE_UNSYNCHRONIZED)) { + esl->conn_handle = conn_handle; + conn->esl_addr = esl->esl_addr; + ble_esl_ap_update_esl_state(esl->esl_addr, BLE_ESL_STATE_UPDATING); + } + + if (conn->disc_done) { + ESP_LOGD(TAG, "Encryption change on already-discovered conn_handle=%u; skipping discovery", + conn_handle); + return; + } + + /* Start discovery */ + ble_esl_ap_start_svc_discovery(conn_handle); +} + +/* ======================================================================== */ +/* Handle pairing complete */ +/* ======================================================================== */ +static void handle_pairing_complete(struct ble_gap_event *event) +{ + uint16_t conn_handle = event->pairing_complete.conn_handle; + uint8_t status = event->pairing_complete.status; + ESP_LOGI(TAG, "Pairing complete; conn_handle=%u status=%d", + conn_handle, status); +} + +/* ========================== Notification Handling ========================== */ + +static void handle_notify_rx(struct ble_gap_event *event) +{ + if (!g_esl_ap) { + return; + } + + uint16_t conn_handle = event->notify_rx.conn_handle; + uint16_t attr_handle = event->notify_rx.attr_handle; + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + /* Check if this is an ECP notification */ + if (attr_handle == conn->handles.ecp_handle && conn->ecp_pending) { + /* Extract notification data from mbuf */ + uint16_t data_len = OS_MBUF_PKTLEN(event->notify_rx.om); + uint8_t *data = NULL; + uint8_t buf[BLE_ESL_AP_READ_BUF_MAX]; + esp_err_t status = ESP_OK; + + if (data_len > 0 && data_len <= sizeof(buf)) { + int rc = os_mbuf_copydata(event->notify_rx.om, 0, data_len, buf); + if (rc == 0) { + data = buf; + } else { + data_len = 0; + } + } else if (data_len > sizeof(buf)) { + ESP_LOGW(TAG, "ECP notification too large: %u > %zu", data_len, sizeof(buf)); + data_len = 0; + status = ESP_FAIL; + } + + /* Clear pending state and invoke callback */ + conn->ecp_pending = false; + ble_esl_ap_gatt_cb_t cb = conn->ecp_cb; + void *user_data = conn->ecp_user_data; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + + if (cb) { + cb(conn_handle, status, data, data_len, user_data); + } else if (data && data_len > 0) { + /* Route to command module only when no registered callback handled it, + * to avoid double-processing and spurious 'no pending command' warnings */ + ble_esl_ap_command_handle_ecp_notification(conn_handle, data, data_len); + } + } +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_gattc.c b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_gattc.c new file mode 100644 index 00000000000..274490c7aa9 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_gattc.c @@ -0,0 +1,932 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ap_gattc.c + * @brief ESL AP — GATT client: service/characteristic discovery and + * internal GATT read/write/ECP helpers. + */ + +#include +#include "esp_log.h" +#include "esp_err.h" + +#include "nimble/ble.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "host/ble_gatt.h" +#include "host/ble_uuid.h" +#include "host/util/util.h" + +#include "ble_esl_ap.h" +#include "ble_esl_ap_int.h" +#include "ble_ots_client.h" + +static const char *TAG = "esl_ap_gattc"; + +/* ========================== Forward Declarations ========================== */ +/* Discovery state machine steps */ +static int disc_svc_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_svc *service, void *arg); +static void disc_start_esl_chr_discovery(uint16_t conn_handle); +static int disc_esl_chr_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, void *arg); +static void disc_start_dis_chr_discovery(uint16_t conn_handle); +static int disc_dis_chr_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, void *arg); +static void disc_start_ecp_dsc_discovery(uint16_t conn_handle); +static int disc_ecp_dsc_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + uint16_t chr_val_handle, const struct ble_gatt_dsc *dsc, + void *arg); +static void disc_enable_ecp_notifications(uint16_t conn_handle); +static int disc_cccd_write_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg); +static void disc_start_ots(uint16_t conn_handle); +static void disc_complete(uint16_t conn_handle, bool success); + +/* GATT operation callbacks */ +static int gatt_read_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg); +static int gatt_read_long_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg); +static int gatt_write_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg); +static int ecp_write_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg); + +/* Characteristic handle resolver */ +static uint16_t resolve_char_handle(ble_esl_ap_conn_t *conn, uint16_t char_uuid); + +/* ========================== OTS Client Callback ========================== */ + +static void ble_esl_ap_ots_event_cb(uint16_t conn_id, + ble_ots_client_event_t event, + const void *param) +{ + if (event == BLE_OTS_CLIENT_EVT_DISCOVER_COMPLETE) { + const ble_ots_client_discover_complete_t *d = param; + ESP_LOGI(TAG, "OTS discovery %s; conn_id=%d", + d->status == 0 ? "complete" : "failed", conn_id); + disc_complete(conn_id, d->status == 0); + return; + } + + ble_esl_ap_lifecycle_handle_ots_event(conn_id, (int)event, param); +} + +/* ========================== Service Discovery State Machine ========================== */ + +/** + * @brief Step 1: Discover all primary services + */ +void ble_esl_ap_start_svc_discovery(uint16_t conn_handle) +{ + ESP_LOGD(TAG, "Starting service discovery; conn_handle=%d", conn_handle); + + int rc = ble_gattc_disc_all_svcs(conn_handle, disc_svc_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start service discovery; rc=%d", rc); + disc_complete(conn_handle, false); + } +} + +/** + * @brief Service discovery callback — collects ESL Service and DIS handles + */ +static int disc_svc_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_svc *service, void *arg) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return BLE_HS_ENOTCONN; + } + + if (error->status == 0 && service != NULL) { + uint16_t uuid16 = ble_uuid_u16(&service->uuid.u); + + if (uuid16 == BLE_ESL_SVC_UUID) { + conn->handles.esl_svc_start = service->start_handle; + conn->handles.esl_svc_end = service->end_handle; + ESP_LOGD(TAG, "Found ESL Service: start=%d end=%d", + service->start_handle, service->end_handle); + } else if (uuid16 == BLE_ESL_AP_DIS_UUID) { + conn->handles.dis_svc_start = service->start_handle; + conn->handles.dis_svc_end = service->end_handle; + ESP_LOGD(TAG, "Found DIS: start=%d end=%d", + service->start_handle, service->end_handle); + } else if (uuid16 == BLE_OTS_UUID_OTS_SERVICE) { + conn->has_ots = true; + ESP_LOGD(TAG, "Found OTS Service: start=%d end=%d", + service->start_handle, service->end_handle); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + /* Service discovery complete — check mandatory ESL Service */ + if (conn->handles.esl_svc_start == 0) { + ESP_LOGE(TAG, "ESL Service not found; disconnecting"); + disc_complete(conn_handle, false); + return 0; + } + /* Proceed to ESL characteristic discovery */ + disc_start_esl_chr_discovery(conn_handle); + return 0; + } + + /* Error during discovery */ + ESP_LOGE(TAG, "Service discovery error; status=%d", error->status); + disc_complete(conn_handle, false); + return 0; +} + +/** + * @brief Step 2: Discover all characteristics within the ESL Service + */ +static void disc_start_esl_chr_discovery(uint16_t conn_handle) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + ESP_LOGD(TAG, "Discovering ESL Service characteristics; conn_handle=%d", conn_handle); + + int rc = ble_gattc_disc_all_chrs(conn_handle, + conn->handles.esl_svc_start, + conn->handles.esl_svc_end, + disc_esl_chr_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start ESL chr discovery; rc=%d", rc); + disc_complete(conn_handle, false); + } +} + +/** + * @brief ESL Service characteristic discovery callback + */ +static int disc_esl_chr_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, void *arg) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return BLE_HS_ENOTCONN; + } + + if (error->status == 0 && chr != NULL) { + uint16_t uuid16 = ble_uuid_u16(&chr->uuid.u); + + switch (uuid16) { + case BLE_ESL_CHR_UUID_ESL_ADDRESS: + conn->handles.esl_addr_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_AP_SYNC_KEY: + conn->handles.ap_sync_key_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_RESP_KEY: + conn->handles.resp_key_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_CURRENT_ABS_TIME: + conn->handles.abs_time_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_DISPLAY_INFO: + conn->handles.display_info_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_IMAGE_INFO: + conn->handles.image_info_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_SENSOR_INFO: + conn->handles.sensor_info_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_LED_INFO: + conn->handles.led_info_handle = chr->val_handle; + break; + case BLE_ESL_CHR_UUID_ECP: + conn->handles.ecp_handle = chr->val_handle; + break; + default: + break; + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + /* Verify mandatory characteristics */ + if (conn->handles.esl_addr_handle == 0 || + conn->handles.ap_sync_key_handle == 0 || + conn->handles.resp_key_handle == 0 || + conn->handles.abs_time_handle == 0 || + conn->handles.ecp_handle == 0) { + ESP_LOGE(TAG, "Missing mandatory ESL characteristics; disconnecting"); + disc_complete(conn_handle, false); + return 0; + } + + /* Proceed to DIS characteristic discovery or ECP descriptor discovery */ + if (conn->handles.dis_svc_start != 0) { + disc_start_dis_chr_discovery(conn_handle); + } else { + disc_start_ecp_dsc_discovery(conn_handle); + } + return 0; + } + + ESP_LOGE(TAG, "ESL chr discovery error; status=%d", error->status); + disc_complete(conn_handle, false); + return 0; +} + +/** + * @brief Step 3: Discover DIS characteristics (PnP ID) + */ +static void disc_start_dis_chr_discovery(uint16_t conn_handle) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + ESP_LOGD(TAG, "Discovering DIS characteristics; conn_handle=%d", conn_handle); + + int rc = ble_gattc_disc_all_chrs(conn_handle, + conn->handles.dis_svc_start, + conn->handles.dis_svc_end, + disc_dis_chr_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start DIS chr discovery; rc=%d", rc); + /* DIS is optional; proceed to ECP descriptor discovery */ + disc_start_ecp_dsc_discovery(conn_handle); + } +} + +/** + * @brief DIS characteristic discovery callback + */ +static int disc_dis_chr_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + const struct ble_gatt_chr *chr, void *arg) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return BLE_HS_ENOTCONN; + } + + if (error->status == 0 && chr != NULL) { + uint16_t uuid16 = ble_uuid_u16(&chr->uuid.u); + if (uuid16 == BLE_ESL_AP_PNP_ID_UUID) { + conn->handles.pnp_id_handle = chr->val_handle; + ESP_LOGD(TAG, "Found PnP ID characteristic; handle=%d", chr->val_handle); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + /* Proceed to ECP descriptor discovery */ + disc_start_ecp_dsc_discovery(conn_handle); + return 0; + } + + ESP_LOGW(TAG, "DIS chr discovery error; status=%d", error->status); + /* DIS is optional; proceed anyway */ + disc_start_ecp_dsc_discovery(conn_handle); + return 0; +} + +/** + * @brief Step 4: Discover ECP CCCD descriptor + */ +static void disc_start_ecp_dsc_discovery(uint16_t conn_handle) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + ESP_LOGD(TAG, "Discovering ECP descriptors; conn_handle=%d", conn_handle); + + /* Discover descriptors between ECP value handle and ESL service end handle. + * ble_gattc_disc_all_dscs expects the characteristic value handle as + * start_handle; it internally starts the Find Information Request at + * start_handle + 1, so passing ecp_handle ensures the CCCD at + * ecp_handle + 1 is found. */ + uint16_t start = conn->handles.ecp_handle; + uint16_t end = conn->handles.esl_svc_end; + + if (start >= end) { + ESP_LOGE(TAG, "Invalid ECP descriptor range"); + disc_complete(conn_handle, false); + return; + } + + int rc = ble_gattc_disc_all_dscs(conn_handle, start, end, + disc_ecp_dsc_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start ECP dsc discovery; rc=%d", rc); + disc_complete(conn_handle, false); + } +} + +/** + * @brief ECP descriptor discovery callback — looks for CCCD + */ +static int disc_ecp_dsc_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + uint16_t chr_val_handle, const struct ble_gatt_dsc *dsc, + void *arg) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return BLE_HS_ENOTCONN; + } + + if (error->status == 0 && dsc != NULL) { + uint16_t uuid16 = ble_uuid_u16(&dsc->uuid.u); + if (uuid16 == BLE_GATT_DSC_CLT_CFG_UUID16) { + conn->handles.ecp_cccd_handle = dsc->handle; + ESP_LOGD(TAG, "Found ECP CCCD; handle=%d", dsc->handle); + } + return 0; + } + + if (error->status == BLE_HS_EDONE) { + if (conn->handles.ecp_cccd_handle == 0) { + ESP_LOGE(TAG, "ECP CCCD not found; disconnecting"); + disc_complete(conn_handle, false); + return 0; + } + /* Enable notifications on ECP */ + disc_enable_ecp_notifications(conn_handle); + return 0; + } + + ESP_LOGE(TAG, "ECP dsc discovery error; status=%d", error->status); + disc_complete(conn_handle, false); + return 0; +} + +/** + * @brief Step 5: Write CCCD to enable ECP notifications + */ +static void disc_enable_ecp_notifications(uint16_t conn_handle) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + ESP_LOGD(TAG, "Enabling ECP notifications; conn_handle=%d", conn_handle); + + uint8_t value[2] = {1, 0}; /* Enable notifications */ + int rc = ble_gattc_write_flat(conn_handle, conn->handles.ecp_cccd_handle, + value, sizeof(value), + disc_cccd_write_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to write ECP CCCD; rc=%d", rc); + disc_complete(conn_handle, false); + } +} + +/** + * @brief CCCD write completion callback + */ +static int disc_cccd_write_cb(uint16_t conn_handle, + const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg) +{ + if (error->status != 0) { + ESP_LOGE(TAG, "CCCD write failed; status=%d", error->status); + disc_complete(conn_handle, false); + return 0; + } + + ESP_LOGD(TAG, "ECP notifications enabled; conn_handle=%d", conn_handle); + disc_start_ots(conn_handle); + return 0; +} + +/** + * @brief Step 6: Start OTS service discovery if the tag exposes OTS, + * otherwise go straight to disc_complete. + * + * BLE_OTS_CLIENT_EVT_DISCOVER_COMPLETE (intercepted in ble_esl_ap_ots_event_cb) + * will call disc_complete once OTS discovery finishes. + */ +static void disc_start_ots(uint16_t conn_handle) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + if (!conn->has_ots) { + disc_complete(conn_handle, true); + return; + } + + ESP_LOGD(TAG, "Starting OTS discovery; conn_handle=%d", conn_handle); + int rc = ble_ots_client_discover_service(conn_handle); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start OTS discovery; rc=%d", rc); + disc_complete(conn_handle, false); + } + /* On success: wait for BLE_OTS_CLIENT_EVT_DISCOVER_COMPLETE */ +} + +/** + * @brief Discovery complete — fire connected or disconnected event + */ +static void disc_complete(uint16_t conn_handle, bool success) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return; + } + + if (!success) { + /* Disconnect and let the disconnect handler fire the event */ + ESP_LOGW(TAG, "Discovery failed; disconnecting conn_handle=%d", conn_handle); + ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM); + return; + } + + conn->disc_done = true; + + /* Build connected event info */ + if (g_esl_ap && g_esl_ap->app_cb) { + ble_esl_ap_conn_info_t info; + memset(&info, 0, sizeof(info)); + info.conn_handle = conn_handle; + memcpy(info.addr, conn->addr, 6); + info.addr_type = conn->addr_type; + info.has_display_info = (conn->handles.display_info_handle != 0); + info.has_image_info = (conn->handles.image_info_handle != 0); + info.has_sensor_info = (conn->handles.sensor_info_handle != 0); + info.has_led_info = (conn->handles.led_info_handle != 0); + info.has_dis = (conn->handles.dis_svc_start != 0); + info.has_pnp_id = (conn->handles.pnp_id_handle != 0); + + ESP_LOGI(TAG, "Discovery complete; conn_handle=%d display=%d image=%d " + "sensor=%d led=%d dis=%d pnp=%d", + conn_handle, info.has_display_info, info.has_image_info, + info.has_sensor_info, info.has_led_info, info.has_dis, + info.has_pnp_id); + + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CONNECTED, &info); + } +} + +/* ========================== Characteristic Handle Resolver ========================== */ + +/** + * @brief Resolve a 16-bit characteristic UUID to its discovered attribute handle + * + * @param conn Connection context + * @param char_uuid 16-bit UUID + * @return Attribute handle, or 0 if not found + */ +static uint16_t resolve_char_handle(ble_esl_ap_conn_t *conn, uint16_t char_uuid) +{ + if (!conn) { + return 0; + } + + switch (char_uuid) { + case BLE_ESL_CHR_UUID_ESL_ADDRESS: + return conn->handles.esl_addr_handle; + case BLE_ESL_CHR_UUID_AP_SYNC_KEY: + return conn->handles.ap_sync_key_handle; + case BLE_ESL_CHR_UUID_RESP_KEY: + return conn->handles.resp_key_handle; + case BLE_ESL_CHR_UUID_CURRENT_ABS_TIME: + return conn->handles.abs_time_handle; + case BLE_ESL_CHR_UUID_DISPLAY_INFO: + return conn->handles.display_info_handle; + case BLE_ESL_CHR_UUID_IMAGE_INFO: + return conn->handles.image_info_handle; + case BLE_ESL_CHR_UUID_SENSOR_INFO: + return conn->handles.sensor_info_handle; + case BLE_ESL_CHR_UUID_LED_INFO: + return conn->handles.led_info_handle; + case BLE_ESL_CHR_UUID_ECP: + return conn->handles.ecp_handle; + case BLE_ESL_AP_PNP_ID_UUID: + return conn->handles.pnp_id_handle; + default: + return 0; + } +} + +/* ========================== Internal GATT Helpers ========================== */ + +/** + * @brief Context passed through GATT read callbacks + */ +typedef struct { + ble_esl_ap_gatt_cb_t cb; + void *user_data; + uint16_t attr_handle; + bool use_read_long; +} gatt_read_ctx_t; + +/** + * @brief Callback for ble_gattc_read (single-shot read) + * + * If the read value fills the entire ATT_MTU-1, we switch to Read Long + * to get the complete value. + */ +static int gatt_read_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg) +{ + gatt_read_ctx_t *ctx = (gatt_read_ctx_t *)arg; + if (!ctx) { + return 0; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + + if (error->status != 0) { + /* Read failed */ + if (conn) { + conn->read_pending = false; + } + if (ctx->cb) { + ctx->cb(conn_handle, ESP_FAIL, NULL, 0, ctx->user_data); + } + free(ctx); + return 0; + } + + /* Copy data from mbuf */ + uint16_t data_len = OS_MBUF_PKTLEN(attr->om); + + if (conn && data_len > 0) { + uint16_t copy_len = data_len; + if (copy_len > BLE_ESL_AP_READ_BUF_MAX) { + copy_len = BLE_ESL_AP_READ_BUF_MAX; + } + int rc = os_mbuf_copydata(attr->om, 0, copy_len, conn->read_buf); + if (rc != 0) { + conn->read_pending = false; + if (ctx->cb) { + ctx->cb(conn_handle, ESP_FAIL, NULL, 0, ctx->user_data); + } + free(ctx); + return 0; + } + conn->read_offset = copy_len; + + /* Check if we need Read Long: only if the data fills exactly + * ATT_MTU-1 bytes (indicating more data may be available) and + * we still have buffer space. Use the negotiated MTU dynamically. */ + uint16_t mtu = ble_att_mtu(conn_handle); + if (mtu > 0 && data_len == (mtu - 1) && + conn->read_offset < BLE_ESL_AP_READ_BUF_MAX) { + /* Switch to Read Long to get the rest */ + int rl_rc = ble_gattc_read_long(conn_handle, ctx->attr_handle, + copy_len, gatt_read_long_cb, ctx); + if (rl_rc == 0) { + return 0; /* ctx ownership transferred to read_long_cb */ + } + /* If Read Long fails to start, deliver what we have */ + } + + /* Deliver the data we have */ + conn->read_pending = false; + if (ctx->cb) { + ctx->cb(conn_handle, ESP_OK, conn->read_buf, conn->read_offset, + ctx->user_data); + } + } else { + /* No data or no connection */ + if (conn) { + conn->read_pending = false; + } + if (ctx->cb) { + ctx->cb(conn_handle, ESP_OK, NULL, 0, ctx->user_data); + } + } + + free(ctx); + return 0; +} + +/** + * @brief Callback for ble_gattc_read_long — called multiple times per fragment, + * then once with error->status == BLE_HS_EDONE on completion. + */ +static int gatt_read_long_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg) +{ + gatt_read_ctx_t *ctx = (gatt_read_ctx_t *)arg; + if (!ctx) { + return 0; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + + if (error->status == 0 && attr != NULL) { + /* Fragment received — append to read buffer */ + if (conn) { + uint16_t frag_len = OS_MBUF_PKTLEN(attr->om); + uint16_t space = BLE_ESL_AP_READ_BUF_MAX - conn->read_offset; + uint16_t copy_len = (frag_len < space) ? frag_len : space; + + if (copy_len > 0) { + int rc = os_mbuf_copydata(attr->om, 0, copy_len, + conn->read_buf + conn->read_offset); + if (rc == 0) { + conn->read_offset += copy_len; + } + } + } + return 0; /* More fragments may follow */ + } + + if (error->status == BLE_HS_EDONE) { + /* Read Long complete — deliver full value */ + if (conn) { + conn->read_pending = false; + } + if (ctx->cb && conn) { + ctx->cb(conn_handle, ESP_OK, conn->read_buf, conn->read_offset, + ctx->user_data); + } else if (ctx->cb) { + /* Connection lost — report failure */ + ctx->cb(conn_handle, ESP_FAIL, NULL, 0, ctx->user_data); + } + free(ctx); + return 0; + } + + /* Error — clear read_pending */ + if (conn) { + conn->read_pending = false; + } + if (conn && conn->read_offset > 0 && + (error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_INVALID_OFFSET) || + error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_ATTR_NOT_LONG))) { + /* ATT error indicates end-of-attribute — deliver partial data as complete */ + if (ctx->cb) { + ctx->cb(conn_handle, ESP_OK, conn->read_buf, conn->read_offset, + ctx->user_data); + } + } else { + /* Real GATT error (timeout, disconnect, etc.) — propagate failure */ + if (ctx->cb) { + ctx->cb(conn_handle, ESP_FAIL, NULL, 0, ctx->user_data); + } + } + free(ctx); + return 0; +} + +esp_err_t ble_esl_ap_gatt_read(uint16_t conn_handle, uint16_t char_uuid, + ble_esl_ap_gatt_cb_t cb, void *user_data) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return ESP_ERR_INVALID_STATE; + } + + uint16_t attr_handle = resolve_char_handle(conn, char_uuid); + if (attr_handle == 0) { + return ESP_ERR_NOT_FOUND; + } + + /* Prevent concurrent GATT reads on the same connection */ + if (conn->read_pending) { + ESP_LOGW(TAG, "GATT read already in progress on conn_handle=%d", conn_handle); + return ESP_ERR_INVALID_STATE; + } + + /* Allocate read context */ + gatt_read_ctx_t *ctx = calloc(1, sizeof(gatt_read_ctx_t)); + if (!ctx) { + return ESP_ERR_NO_MEM; + } + ctx->cb = cb; + ctx->user_data = user_data; + ctx->attr_handle = attr_handle; + ctx->use_read_long = false; + + /* Mark read as in progress and reset read buffer */ + conn->read_pending = true; + conn->read_offset = 0; + + /* For characteristics known to potentially exceed ATT_MTU-1, + * use Read Long directly */ + if (char_uuid == BLE_ESL_CHR_UUID_DISPLAY_INFO || + char_uuid == BLE_ESL_CHR_UUID_SENSOR_INFO || + char_uuid == BLE_ESL_CHR_UUID_LED_INFO) { + ctx->use_read_long = true; + int rc = ble_gattc_read_long(conn_handle, attr_handle, 0, + gatt_read_long_cb, ctx); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start read long; rc=%d", rc); + conn->read_pending = false; + free(ctx); + return ESP_FAIL; + } + return ESP_OK; + } + + /* Standard single-shot read */ + int rc = ble_gattc_read(conn_handle, attr_handle, gatt_read_cb, ctx); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start read; rc=%d", rc); + conn->read_pending = false; + free(ctx); + return ESP_FAIL; + } + + return ESP_OK; +} + +/** + * @brief Context passed through GATT write callbacks + */ +typedef struct { + ble_esl_ap_gatt_cb_t cb; + void *user_data; +} gatt_write_ctx_t; + +/** + * @brief Callback for ble_gattc_write_flat + */ +static int gatt_write_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg) +{ + gatt_write_ctx_t *ctx = (gatt_write_ctx_t *)arg; + if (!ctx) { + return 0; + } + + esp_err_t status = (error->status == 0) ? ESP_OK : ESP_FAIL; + + if (ctx->cb) { + ctx->cb(conn_handle, status, NULL, 0, ctx->user_data); + } + + free(ctx); + return 0; +} + +esp_err_t ble_esl_ap_gatt_write(uint16_t conn_handle, uint16_t char_uuid, + const uint8_t *data, uint16_t data_len, + ble_esl_ap_gatt_cb_t cb, void *user_data) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return ESP_ERR_INVALID_STATE; + } + + uint16_t attr_handle = resolve_char_handle(conn, char_uuid); + if (attr_handle == 0) { + return ESP_ERR_NOT_FOUND; + } + + /* Allocate write context */ + gatt_write_ctx_t *ctx = calloc(1, sizeof(gatt_write_ctx_t)); + if (!ctx) { + return ESP_ERR_NO_MEM; + } + ctx->cb = cb; + ctx->user_data = user_data; + + /* A single Write Request carries at most ATT_MTU - 3 bytes of value + * (1 byte opcode + 2 byte handle). If the payload exceeds that, fall back + * to the Write Long procedure. Use the negotiated MTU dynamically. */ + uint16_t mtu = ble_att_mtu(conn_handle); + if (mtu < 3) { + /* ble_att_mtu() returns 0 when the ATT channel is gone (e.g. the peer + * disconnected while this write was being prepared). Bail out instead + * of deriving a bogus payload limit from it. */ + ESP_LOGE(TAG, "Invalid MTU for write; conn_handle=%d mtu=%d", conn_handle, mtu); + free(ctx); + return ESP_ERR_INVALID_STATE; + } + + int rc; + if (data_len > (mtu - 3)) { + /* Write Long — payload must be carried in an mbuf */ + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, data_len); + if (!om) { + ESP_LOGE(TAG, "Failed to allocate mbuf for write long"); + free(ctx); + return ESP_ERR_NO_MEM; + } + rc = ble_gattc_write_long(conn_handle, attr_handle, 0, om, + gatt_write_cb, ctx); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start write long; rc=%d", rc); + /* om is consumed by ble_gattc_write_long regardless of outcome */ + free(ctx); + return ESP_FAIL; + } + } else { + rc = ble_gattc_write_flat(conn_handle, attr_handle, + data, data_len, + gatt_write_cb, ctx); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start write; rc=%d", rc); + free(ctx); + return ESP_FAIL; + } + } + + return ESP_OK; +} + +/** + * @brief Callback for ECP write completion + * + * After the write completes, we wait for the ECP notification. + * If the write itself fails, invoke the ECP callback with error. + */ +static int ecp_write_cb(uint16_t conn_handle, const struct ble_gatt_error *error, + struct ble_gatt_attr *attr, void *arg) +{ + (void)arg; + + if (error->status != 0) { + ESP_LOGE(TAG, "ECP write failed; status=%d", error->status); + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn && conn->ecp_pending) { + conn->ecp_pending = false; + ble_esl_ap_gatt_cb_t cb = conn->ecp_cb; + void *user_data = conn->ecp_user_data; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + + if (cb) { + cb(conn_handle, ESP_FAIL, NULL, 0, user_data); + } + } + } + /* On success, we wait for the notification (handled in handle_notify_rx) */ + return 0; +} + +esp_err_t ble_esl_ap_ecp_write(uint16_t conn_handle, const uint8_t *tlv_data, + uint8_t tlv_len, ble_esl_ap_gatt_cb_t cb, + void *user_data) +{ + if (!g_esl_ap || !g_esl_ap->initialized) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (!conn) { + return ESP_ERR_INVALID_STATE; + } + + if (conn->handles.ecp_handle == 0) { + return ESP_ERR_NOT_FOUND; + } + + if (conn->ecp_pending) { + ESP_LOGW(TAG, "ECP operation already pending on conn_handle=%d", conn_handle); + return ESP_ERR_INVALID_STATE; + } + + /* Set up ECP pending state */ + conn->ecp_pending = true; + conn->ecp_cb = cb; + conn->ecp_user_data = user_data; + + /* Write to ECP characteristic */ + int rc = ble_gattc_write_flat(conn_handle, conn->handles.ecp_handle, + tlv_data, tlv_len, + ecp_write_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to write ECP; rc=%d", rc); + conn->ecp_pending = false; + conn->ecp_cb = NULL; + conn->ecp_user_data = NULL; + return ESP_FAIL; + } + + return ESP_OK; +} + +esp_err_t ble_esl_ap_gattc_init(void) +{ + int rc = ble_ots_client_init(ble_esl_ap_ots_event_cb); + return (rc == 0) ? ESP_OK : ESP_FAIL; +} + +void ble_esl_ap_gattc_deinit(void) +{ + ble_ots_client_deinit(); +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_lifecycle.c b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_lifecycle.c new file mode 100644 index 00000000000..6e146dcead8 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_lifecycle.c @@ -0,0 +1,1389 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap_lifecycle.c + * @brief ESL AP Lifecycle & State Management + * + * Implements per-ESL state tracking, configuration writes, info reads, + * OTS image transfer, PAST-based synchronization, and 60-minute timeout + * monitoring for Synchronized → Unsynchronized and Unsynchronized → Unassociated + * transitions. + */ + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "host/ble_gap.h" + +#include "ble_esl_ap.h" +#include "ble_esl_ap_int.h" +#include "ble_esl_common.h" +#include "ble_ots_client.h" + +static const char *TAG = "esl_ap_lifecycle"; + +/* ========================== Constants ========================== */ + +/** Timeout check period: 60 seconds in microseconds */ +#define LIFECYCLE_TIMER_PERIOD_US (60ULL * 1000000ULL) + +/** 60-minute timeout in microseconds */ +#define LIFECYCLE_TIMEOUT_US (60ULL * 60ULL * 1000000ULL) + +/** OTS write mode: Truncate bit (bit 1) */ +#define OTS_WRITE_MODE_TRUNCATE 0x02 + +/** PAwR advertising instance (assumed instance 0 for PAST set info) */ +#define PAWR_ADV_INSTANCE 0 + +/* The key material struct is written to the peer as-is, so its packed layout + * must match the 24-octet on-air Key Material characteristic value. */ +_Static_assert(sizeof(ble_esl_key_material_t) == BLE_ESL_KEY_MATERIAL_SIZE, + "ble_esl_key_material_t must match the on-air Key Material layout"); + +/* ========================== Internal Context Structures ========================== */ + +/** + * @brief Context for chained configure writes + */ +typedef struct { + uint16_t conn_handle; + ble_esl_ap_esl_config_t config; + uint16_t esl_addr; +} configure_ctx_t; + +/** + * @brief Context for chained info reads + */ +typedef struct { + uint16_t conn_handle; + ble_esl_ap_esl_info_t info; + /* Dynamic buffers for read data */ + uint8_t *display_buf; + uint16_t display_buf_len; + uint8_t *image_buf; + uint16_t image_buf_len; + uint8_t *sensor_buf; + uint16_t sensor_buf_len; + uint8_t *led_buf; + uint16_t led_buf_len; +} read_info_ctx_t; + +/** + * @brief Context for image transfer + */ +typedef struct { + uint16_t conn_handle; + uint8_t image_index; + const uint8_t *data; + uint32_t data_len; + uint32_t sent_offset; /*!< Number of bytes already sent */ + uint16_t otc_mtu; /*!< Negotiated L2CAP OTC MTU (max SDU size) */ + bool truncate; + bool otc_open; /*!< true once the L2CAP OTC channel is open */ +} image_transfer_ctx_t; + +/** + * @brief Context for synchronize procedure + */ +typedef struct { + uint16_t conn_handle; + uint16_t esl_addr; + bool past_initiated; /*!< True after PAST has been successfully initiated */ +} synchronize_ctx_t; + +/* ========================== Forward Declarations ========================== */ + +static void lifecycle_timeout_cb(void *arg); + +/* Configure chain callbacks */ +static void configure_write_addr_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void configure_write_sync_key_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void configure_write_resp_key_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void configure_write_abs_time_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); + +/* Read info chain callbacks */ +static void read_info_display_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void read_info_image_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void read_info_sensor_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void read_info_led_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void read_info_pnp_id_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); +static void read_info_complete(read_info_ctx_t *ctx, esp_err_t status); + +/* Read info chain: advance to next characteristic */ +static void read_info_try_image(read_info_ctx_t *ctx); +static void read_info_try_sensor(read_info_ctx_t *ctx); +static void read_info_try_led(read_info_ctx_t *ctx); +static void read_info_try_pnp_id(read_info_ctx_t *ctx); + +/* Synchronize callbacks */ +static void synchronize_ecp_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); + +/* OTS client event handler for image transfer (called from OTS callback dispatcher) */ +void ble_esl_ap_lifecycle_handle_ots_event(uint16_t conn_id, + int event, + const void *param); + +/* ========================== Static State ========================== */ + +/** Active image transfer context (one at a time) */ +static image_transfer_ctx_t *s_image_ctx = NULL; + +/** Active synchronize context (only one at a time; new requests are rejected while active) */ +static synchronize_ctx_t *s_sync_ctx = NULL; + +/* ========================== Lifecycle Init / Deinit ========================== */ + +esp_err_t ble_esl_ap_lifecycle_init(void) +{ + assert(g_esl_ap != NULL); + + esp_timer_create_args_t timer_args = { + .callback = lifecycle_timeout_cb, + .arg = NULL, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_ap_timeout", + .skip_unhandled_events = true, + }; + + esp_err_t ret = esp_timer_create(&timer_args, &g_esl_ap->timeout_timer); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create lifecycle timer: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_timer_start_periodic(g_esl_ap->timeout_timer, LIFECYCLE_TIMER_PERIOD_US); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start lifecycle timer: %s", esp_err_to_name(ret)); + esp_timer_delete(g_esl_ap->timeout_timer); + g_esl_ap->timeout_timer = NULL; + return ret; + } + + ESP_LOGI(TAG, "Lifecycle sub-module initialized (60s check period)"); + return ESP_OK; +} + +void ble_esl_ap_lifecycle_deinit(void) +{ + if (g_esl_ap == NULL) { + return; + } + + if (g_esl_ap->timeout_timer != NULL) { + esp_err_t ret = esp_timer_stop_blocking(g_esl_ap->timeout_timer, portMAX_DELAY); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to stop lifecycle timer: %s", esp_err_to_name(ret)); + } + esp_timer_delete(g_esl_ap->timeout_timer); + g_esl_ap->timeout_timer = NULL; + } + + /* Free any outstanding transfer context */ + if (s_image_ctx != NULL) { + free(s_image_ctx); + s_image_ctx = NULL; + } + if (s_sync_ctx != NULL) { + free(s_sync_ctx); + s_sync_ctx = NULL; + } + + ESP_LOGI(TAG, "Lifecycle sub-module deinitialized"); +} + +/* ========================== Timeout Callback ========================== */ + +/** + * @brief Periodic timer callback — checks for 60-minute timeouts + * + * Iterates the ESL tracking table: + * - Synchronized ESLs: if last_sync_time_us is older than 60 min → Unsynchronized + * - Unsynchronized ESLs: if unsync_entry_time_us is older than 60 min → Unassociated + */ +static void lifecycle_timeout_cb(void *arg) +{ + (void)arg; + + if (g_esl_ap == NULL) { + return; + } + + int64_t now_us = esp_timer_get_time(); + + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + ble_esl_ap_esl_entry_t *esl = &g_esl_ap->esls[i]; + if (!esl->in_use) { + continue; + } + + if (esl->state == BLE_ESL_STATE_SYNCHRONIZED) { + /* Check 60-minute sync timeout */ + if (esl->last_sync_time_us != 0 && + (now_us - esl->last_sync_time_us) >= (int64_t)LIFECYCLE_TIMEOUT_US) { + ESP_LOGW(TAG, "ESL 0x%04X: sync timeout (60 min), transitioning to Unsynchronized", + esl->esl_addr); + ble_esl_ap_update_esl_state(esl->esl_addr, + BLE_ESL_STATE_UNSYNCHRONIZED); + } + } else if (esl->state == BLE_ESL_STATE_UNSYNCHRONIZED) { + /* Check 60-minute reconnect timeout */ + if (esl->unsync_entry_time_us != 0 && + (now_us - esl->unsync_entry_time_us) >= (int64_t)LIFECYCLE_TIMEOUT_US) { + ESP_LOGW(TAG, "ESL 0x%04X: unsync timeout (60 min), transitioning to Unassociated", + esl->esl_addr); + ble_esl_ap_update_esl_state(esl->esl_addr, + BLE_ESL_STATE_UNASSOCIATED); + } + } + } +} + +/* ========================== Update ESL State ========================== */ + +esp_err_t ble_esl_ap_update_esl_state(uint16_t esl_addr, + ble_esl_state_t new_state) +{ + if (g_esl_ap == NULL) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(esl_addr); + if (esl == NULL) { + ESP_LOGW(TAG, "update_esl_state: ESL 0x%04X not found", esl_addr); + return ESP_ERR_NOT_FOUND; + } + + ble_esl_state_t old_state = esl->state; + esl->state = new_state; + + ESP_LOGI(TAG, "ESL 0x%04X state: %d -> %d", esl_addr, old_state, new_state); + + /* Record timestamps for timeout tracking */ + if (new_state == BLE_ESL_STATE_UNSYNCHRONIZED) { + esl->unsync_entry_time_us = esp_timer_get_time(); + } + + if (new_state == BLE_ESL_STATE_SYNCHRONIZED) { + esl->last_sync_time_us = esp_timer_get_time(); + esl->unsync_entry_time_us = 0; + } + + /* Fire state changed event to application */ + ble_esl_ap_state_changed_t evt = { + .conn_handle = esl->conn_handle, + .esl_addr = ble_esl_ap_addr_unpack(esl_addr), + .old_state = old_state, + .new_state = new_state, + }; + + if (g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_STATE_CHANGED, &evt); + } + + /* Handle Unassociated cleanup */ + if (new_state == BLE_ESL_STATE_UNASSOCIATED) { + ESP_LOGI(TAG, "ESL 0x%04X: cleaning up tracking entry (Unassociated)", esl_addr); + memset(esl, 0, sizeof(*esl)); + esl->in_use = false; + esl->conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + } + + return ESP_OK; +} + +/* ========================== Get ESL State ========================== */ + +ble_esl_state_t ble_esl_ap_get_esl_state(ble_esl_address_t esl_addr) +{ + if (g_esl_ap == NULL) { + return BLE_ESL_STATE_UNASSOCIATED; + } + + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(BLE_ESL_AP_ADDR_PACK(esl_addr)); + if (esl == NULL) { + return BLE_ESL_STATE_UNASSOCIATED; + } + + return esl->state; +} + +/* ========================== Configure ========================== */ + +esp_err_t ble_esl_ap_configure(uint16_t conn_handle, + const ble_esl_ap_esl_config_t *config) +{ + if (g_esl_ap == NULL) { + return ESP_ERR_INVALID_STATE; + } + + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (config->group_id > BLE_ESL_GROUP_ID_MAX) { + ESP_LOGE(TAG, "configure: invalid group_id 0x%02X", config->group_id); + return ESP_ERR_INVALID_ARG; + } + + /* Validate connection handle */ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn == NULL) { + ESP_LOGE(TAG, "configure: conn_handle 0x%04X not found", conn_handle); + return ESP_ERR_NOT_FOUND; + } + + /* Allocate context for the chained write sequence */ + configure_ctx_t *ctx = calloc(1, sizeof(configure_ctx_t)); + if (ctx == NULL) { + return ESP_ERR_NO_MEM; + } + + ctx->conn_handle = conn_handle; + memcpy(&ctx->config, config, sizeof(ble_esl_ap_esl_config_t)); + ctx->esl_addr = BLE_ESL_AP_MAKE_ADDR(config->esl_id, config->group_id); + + /* Store ESL address in connection context for cross-reference */ + conn->esl_addr = ctx->esl_addr; + + /* Find or create ESL tracking entry */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(ctx->esl_addr); + if (esl == NULL) { + /* Try to find by BLE address (may already exist from a prior association) */ + esl = ble_esl_ap_find_esl_by_ble_addr(conn->addr, conn->addr_type); + if (esl == NULL) { + esl = ble_esl_ap_alloc_esl(); + if (esl == NULL) { + ESP_LOGE(TAG, "configure: ESL tracking table full"); + free(ctx); + return ESP_ERR_NO_MEM; + } + } + } + + /* Initialize/update the tracking entry */ + esl->in_use = true; + esl->esl_addr = ctx->esl_addr; + memcpy(esl->ble_addr, conn->addr, 6); + esl->ble_addr_type = conn->addr_type; + esl->conn_handle = conn_handle; + esl->config_complete = false; + esl->state = BLE_ESL_STATE_CONFIGURING; + + /* Store key material in the ESL tracking entry */ + esl->resp_key = config->resp_key; + + /* Set PAwR sync key (shared across all ESLs) */ + ble_esl_ap_pawr_set_sync_key(&config->ap_sync_key); + + /* Set per-ESL response key */ + esp_err_t ret = ble_esl_ap_pawr_set_response_key(ctx->esl_addr, + &config->resp_key); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "configure: failed to set response key for 0x%04X: %s", + ctx->esl_addr, esp_err_to_name(ret)); + /* Non-fatal — continue with configuration */ + } + + /* Step 1: Write ESL Address (2 bytes: esl_id + group_id) */ + uint8_t addr_data[2]; + addr_data[0] = config->esl_id; + addr_data[1] = config->group_id & 0x7F; /* RFU bit 7 = 0 */ + + ret = ble_esl_ap_gatt_write(conn_handle, BLE_ESL_CHR_UUID_ESL_ADDRESS, + addr_data, sizeof(addr_data), + configure_write_addr_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "configure: failed to initiate ESL Address write: %s", + esp_err_to_name(ret)); + ble_esl_ap_update_esl_state(ctx->esl_addr, BLE_ESL_STATE_UNASSOCIATED); + free(ctx); + return ret; + } + + return ESP_OK; +} + +/* ---- Configure chain callbacks ---- */ + +static void configure_write_addr_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + configure_ctx_t *ctx = (configure_ctx_t *)user_data; + assert(ctx != NULL); + + if (status != ESP_OK) { + ESP_LOGE(TAG, "configure: ESL Address write failed: %s", esp_err_to_name(status)); + goto fail; + } + + ESP_LOGD(TAG, "configure: ESL Address written, writing AP Sync Key Material"); + + /* Step 2: Write AP Sync Key Material (24 bytes: 16-byte key + 8-byte IV) */ + esp_err_t ret = ble_esl_ap_gatt_write(conn_handle, BLE_ESL_CHR_UUID_AP_SYNC_KEY, + (const uint8_t *)&ctx->config.ap_sync_key, + sizeof(ctx->config.ap_sync_key), + configure_write_sync_key_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "configure: failed to initiate AP Sync Key write: %s", + esp_err_to_name(ret)); + goto fail; + } + return; + +fail: + { + ble_esl_ap_update_esl_state(ctx->esl_addr, BLE_ESL_STATE_UNASSOCIATED); + ble_esl_ap_configured_t evt = { + .conn_handle = conn_handle, + .status = (status != ESP_OK) ? status : ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CONFIGURED, &evt); + } + free(ctx); + } +} + +static void configure_write_sync_key_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + configure_ctx_t *ctx = (configure_ctx_t *)user_data; + assert(ctx != NULL); + + if (status != ESP_OK) { + ESP_LOGE(TAG, "configure: AP Sync Key write failed: %s", esp_err_to_name(status)); + goto fail; + } + + ESP_LOGD(TAG, "configure: AP Sync Key written, writing Response Key Material"); + + /* Step 3: Write ESL Response Key Material (24 bytes) */ + esp_err_t ret = ble_esl_ap_gatt_write(conn_handle, BLE_ESL_CHR_UUID_RESP_KEY, + (const uint8_t *)&ctx->config.resp_key, + sizeof(ctx->config.resp_key), + configure_write_resp_key_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "configure: failed to initiate Response Key write: %s", + esp_err_to_name(ret)); + goto fail; + } + return; + +fail: + { + ble_esl_ap_update_esl_state(ctx->esl_addr, BLE_ESL_STATE_UNASSOCIATED); + ble_esl_ap_configured_t evt = { + .conn_handle = conn_handle, + .status = (status != ESP_OK) ? status : ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CONFIGURED, &evt); + } + free(ctx); + } +} + +static void configure_write_resp_key_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + configure_ctx_t *ctx = (configure_ctx_t *)user_data; + assert(ctx != NULL); + + if (status != ESP_OK) { + ESP_LOGE(TAG, "configure: Response Key write failed: %s", esp_err_to_name(status)); + goto fail; + } + + ESP_LOGD(TAG, "configure: Response Key written, writing Absolute Time"); + + /* Step 4: Write ESL Current Absolute Time (4 bytes from esp_timer_get_time) */ + int64_t now_us = esp_timer_get_time(); + /* Convert microseconds to milliseconds for ESL Absolute Time */ + uint32_t abs_time_ms = (uint32_t)(now_us / 1000ULL); + uint8_t abs_time_data[4]; + abs_time_data[0] = (uint8_t)(abs_time_ms & 0xFF); + abs_time_data[1] = (uint8_t)((abs_time_ms >> 8) & 0xFF); + abs_time_data[2] = (uint8_t)((abs_time_ms >> 16) & 0xFF); + abs_time_data[3] = (uint8_t)((abs_time_ms >> 24) & 0xFF); + + esp_err_t ret = ble_esl_ap_gatt_write(conn_handle, BLE_ESL_CHR_UUID_CURRENT_ABS_TIME, + abs_time_data, sizeof(abs_time_data), + configure_write_abs_time_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "configure: failed to initiate Absolute Time write: %s", + esp_err_to_name(ret)); + goto fail; + } + return; + +fail: + { + ble_esl_ap_update_esl_state(ctx->esl_addr, BLE_ESL_STATE_UNASSOCIATED); + ble_esl_ap_configured_t evt = { + .conn_handle = conn_handle, + .status = (status != ESP_OK) ? status : ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CONFIGURED, &evt); + } + free(ctx); + } +} + +static void configure_write_abs_time_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + configure_ctx_t *ctx = (configure_ctx_t *)user_data; + assert(ctx != NULL); + + if (status != ESP_OK) { + ESP_LOGE(TAG, "configure: Absolute Time write failed: %s", esp_err_to_name(status)); + ble_esl_ap_update_esl_state(ctx->esl_addr, BLE_ESL_STATE_UNASSOCIATED); + } else { + ESP_LOGI(TAG, "configure: all 4 mandatory characteristics written for ESL 0x%04X", + ctx->esl_addr); + + /* Mark configuration as complete in the tracking entry */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(ctx->esl_addr); + if (esl != NULL) { + esl->config_complete = true; + } + } + + /* Fire BLE_ESL_AP_EVT_CONFIGURED */ + ble_esl_ap_configured_t evt = { + .conn_handle = conn_handle, + .status = status, + }; + + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_CONFIGURED, &evt); + } + + free(ctx); +} + +/* ========================== Read Info ========================== */ + +esp_err_t ble_esl_ap_read_info(uint16_t conn_handle) +{ + if (g_esl_ap == NULL) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn == NULL) { + ESP_LOGE(TAG, "read_info: conn_handle 0x%04X not found", conn_handle); + return ESP_ERR_NOT_FOUND; + } + + /* Allocate context for the chained read sequence */ + read_info_ctx_t *ctx = calloc(1, sizeof(read_info_ctx_t)); + if (ctx == NULL) { + return ESP_ERR_NO_MEM; + } + + ctx->conn_handle = conn_handle; + ctx->info.conn_handle = conn_handle; + ctx->info.status = ESP_OK; + ctx->info.max_image_index = 0xFF; /* Default if char absent */ + + /* Start with Display Information if present */ + if (conn->handles.display_info_handle != 0) { + esp_err_t ret = ble_esl_ap_gatt_read(conn_handle, BLE_ESL_CHR_UUID_DISPLAY_INFO, + read_info_display_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "read_info: failed to read Display Info: %s", + esp_err_to_name(ret)); + free(ctx); + return ret; + } + } else { + /* Skip to Image Info */ + read_info_try_image(ctx); + } + + return ESP_OK; +} + +static void read_info_try_image(read_info_ctx_t *ctx) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(ctx->conn_handle); + if (conn == NULL) { + read_info_complete(ctx, ESP_ERR_NOT_FOUND); + return; + } + + if (conn->handles.image_info_handle != 0) { + esp_err_t ret = ble_esl_ap_gatt_read(ctx->conn_handle, BLE_ESL_CHR_UUID_IMAGE_INFO, + read_info_image_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "read_info: failed to read Image Info: %s", esp_err_to_name(ret)); + read_info_try_sensor(ctx); + } + } else { + read_info_try_sensor(ctx); + } +} + +static void read_info_try_sensor(read_info_ctx_t *ctx) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(ctx->conn_handle); + if (conn == NULL) { + read_info_complete(ctx, ESP_ERR_NOT_FOUND); + return; + } + + if (conn->handles.sensor_info_handle != 0) { + esp_err_t ret = ble_esl_ap_gatt_read(ctx->conn_handle, BLE_ESL_CHR_UUID_SENSOR_INFO, + read_info_sensor_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "read_info: failed to read Sensor Info: %s", esp_err_to_name(ret)); + read_info_try_led(ctx); + } + } else { + read_info_try_led(ctx); + } +} + +static void read_info_try_led(read_info_ctx_t *ctx) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(ctx->conn_handle); + if (conn == NULL) { + read_info_complete(ctx, ESP_ERR_NOT_FOUND); + return; + } + + if (conn->handles.led_info_handle != 0) { + esp_err_t ret = ble_esl_ap_gatt_read(ctx->conn_handle, BLE_ESL_CHR_UUID_LED_INFO, + read_info_led_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "read_info: failed to read LED Info: %s", esp_err_to_name(ret)); + read_info_try_pnp_id(ctx); + } + } else { + read_info_try_pnp_id(ctx); + } +} + +static void read_info_try_pnp_id(read_info_ctx_t *ctx) +{ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(ctx->conn_handle); + if (conn == NULL) { + read_info_complete(ctx, ESP_ERR_NOT_FOUND); + return; + } + + if (conn->handles.pnp_id_handle != 0) { + esp_err_t ret = ble_esl_ap_gatt_read(ctx->conn_handle, BLE_ESL_AP_PNP_ID_UUID, + read_info_pnp_id_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "read_info: failed to read PnP ID: %s", esp_err_to_name(ret)); + read_info_complete(ctx, ESP_OK); + } + } else { + read_info_complete(ctx, ESP_OK); + } +} + +/* ---- Read info chain callbacks ---- */ + +static void read_info_display_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + read_info_ctx_t *ctx = (read_info_ctx_t *)user_data; + assert(ctx != NULL); + + if (status == ESP_OK && data != NULL && data_len > 0) { + /* Each display info is 5 bytes (ble_esl_display_info_t) */ + uint8_t num_displays = data_len / sizeof(ble_esl_display_info_t); + if (num_displays > 0) { + ctx->display_buf = malloc(data_len); + if (ctx->display_buf != NULL) { + memcpy(ctx->display_buf, data, data_len); + ctx->display_buf_len = data_len; + ctx->info.num_displays = num_displays; + ctx->info.displays = (const ble_esl_display_info_t *)ctx->display_buf; + } + } + } else if (status != ESP_OK) { + ESP_LOGW(TAG, "read_info: Display Info read failed: %s", esp_err_to_name(status)); + } + + read_info_try_image(ctx); +} + +static void read_info_image_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + read_info_ctx_t *ctx = (read_info_ctx_t *)user_data; + assert(ctx != NULL); + + if (status == ESP_OK && data != NULL && data_len >= 1) { + ctx->info.max_image_index = data[0]; + } else if (status != ESP_OK) { + ESP_LOGW(TAG, "read_info: Image Info read failed: %s", esp_err_to_name(status)); + } + + read_info_try_sensor(ctx); +} + +static void read_info_sensor_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + read_info_ctx_t *ctx = (read_info_ctx_t *)user_data; + assert(ctx != NULL); + + if (status == ESP_OK && data != NULL && data_len > 0) { + ctx->sensor_buf = malloc(data_len); + if (ctx->sensor_buf != NULL) { + memcpy(ctx->sensor_buf, data, data_len); + ctx->sensor_buf_len = data_len; + ctx->info.sensors = ctx->sensor_buf; + ctx->info.sensors_len = data_len; + + /* Count sensors: iterate variable-length sensor info structures */ + uint8_t count = 0; + uint16_t offset = 0; + while (offset < data_len) { + if (data[offset] == BLE_ESL_SENSOR_INFO_SIZE_SHORT) { + if (offset + BLE_ESL_SENSOR_INFO_SHORT_LEN > data_len) { + /* Truncated short sensor structure, stop parsing */ + break; + } + offset += BLE_ESL_SENSOR_INFO_SHORT_LEN; + } else if (data[offset] == BLE_ESL_SENSOR_INFO_SIZE_LONG) { + if (offset + BLE_ESL_SENSOR_INFO_LONG_LEN > data_len) { + /* Truncated long sensor structure, stop parsing */ + break; + } + offset += BLE_ESL_SENSOR_INFO_LONG_LEN; + } else { + /* Unknown format, skip remaining */ + break; + } + count++; + } + ctx->info.num_sensors = count; + } + } else if (status != ESP_OK) { + ESP_LOGW(TAG, "read_info: Sensor Info read failed: %s", esp_err_to_name(status)); + } + + read_info_try_led(ctx); +} + +static void read_info_led_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + read_info_ctx_t *ctx = (read_info_ctx_t *)user_data; + assert(ctx != NULL); + + if (status == ESP_OK && data != NULL && data_len > 0) { + if (data_len > UINT8_MAX) { + ESP_LOGW(TAG, "read_info: LED Info data_len %u exceeds max (255), clamping", + data_len); + data_len = UINT8_MAX; + } + ctx->led_buf = malloc(data_len); + if (ctx->led_buf != NULL) { + memcpy(ctx->led_buf, data, data_len); + ctx->led_buf_len = data_len; + ctx->info.num_leds = (uint8_t)data_len; /* 1 octet per LED */ + ctx->info.leds = ctx->led_buf; + } + } else if (status != ESP_OK) { + ESP_LOGW(TAG, "read_info: LED Info read failed: %s", esp_err_to_name(status)); + } + + read_info_try_pnp_id(ctx); +} + +static void read_info_pnp_id_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + read_info_ctx_t *ctx = (read_info_ctx_t *)user_data; + assert(ctx != NULL); + + if (status == ESP_OK && data != NULL && data_len >= BLE_ESL_AP_PNP_ID_LEN) { + ctx->info.has_pnp_id = true; + memcpy(ctx->info.pnp_id, data, BLE_ESL_AP_PNP_ID_LEN); + } else if (status != ESP_OK) { + ESP_LOGW(TAG, "read_info: PnP ID read failed: %s", esp_err_to_name(status)); + /* Check if connection was lost */ + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn == NULL) { + read_info_complete(ctx, ESP_ERR_NOT_FOUND); + return; + } + } + + read_info_complete(ctx, ESP_OK); +} + +static void read_info_complete(read_info_ctx_t *ctx, esp_err_t status) +{ + ctx->info.status = status; + + /* Fire BLE_ESL_AP_EVT_ESL_INFO */ + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_ESL_INFO, &ctx->info); + } + + /* Free dynamic buffers */ + free(ctx->display_buf); + free(ctx->image_buf); + free(ctx->sensor_buf); + free(ctx->led_buf); + free(ctx); +} + +/* ========================== Transfer Image ========================== */ + +esp_err_t ble_esl_ap_transfer_image(const ble_esl_ap_image_transfer_params_t *params) +{ + if (g_esl_ap == NULL) { + return ESP_ERR_INVALID_STATE; + } + + if (params == NULL || params->data == NULL || params->data_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(params->conn_handle); + if (conn == NULL) { + ESP_LOGE(TAG, "transfer_image: conn_handle 0x%04X not found", params->conn_handle); + return ESP_ERR_NOT_FOUND; + } + + /* Only one image transfer at a time */ + if (s_image_ctx != NULL) { + ESP_LOGE(TAG, "transfer_image: another transfer is in progress"); + return ESP_ERR_INVALID_STATE; + } + + /* Allocate transfer context */ + s_image_ctx = calloc(1, sizeof(image_transfer_ctx_t)); + if (s_image_ctx == NULL) { + return ESP_ERR_NO_MEM; + } + + s_image_ctx->conn_handle = params->conn_handle; + s_image_ctx->image_index = params->image_index; + s_image_ctx->data = params->data; + s_image_ctx->data_len = params->data_len; + s_image_ctx->truncate = params->truncate; + s_image_ctx->otc_mtu = 256; /* Default L2CAP OTC MTU; updated on CHANNEL_OPEN */ + + /* Step 1: Select the target object via OLCP Go To (over GATT). + * Per the OTS spec, the Current Object must be selected *before* the + * Object Transfer Channel is opened and OACP Write is issued. Opening the + * OTC first breaks the procedure ordering — e.g. PTS rejects the premature + * LE Credit Based Connection with "LE_PSM not supported" (BLE_HS_ENOTSUP). + * The OLCP response handler opens the OTC; CHANNEL_OPEN then issues OACP + * Write. */ + uint64_t object_id = BLE_ESL_OTS_OBJECT_ID_BASE + s_image_ctx->image_index; + int rc = ble_ots_client_goto_object(params->conn_handle, object_id); + if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: ble_ots_client_goto_object failed; rc=%d", rc); + free(s_image_ctx); + s_image_ctx = NULL; + return ESP_FAIL; + } + + ESP_LOGI(TAG, "transfer_image: OLCP Go To issued (image_index=%u)", + params->image_index); + return ESP_OK; +} + +/** + * @brief Terminate the current image transfer and release its resources. + * + * Closes the L2CAP Object Transfer Channel (if one was opened), reports the + * final status to the application, and frees the transfer context. Safe to + * call from any terminal point of the transfer state machine. + * + * The BLE_OTS_CLIENT_EVT_CHANNEL_CLOSED that results from closing the channel + * is a no-op, because s_image_ctx is cleared here before it can be delivered. + * + * @param conn_id Connection the transfer ran on + * @param status Final transfer status reported to the application + */ +static void image_transfer_finish(uint16_t conn_id, esp_err_t status) +{ + if (s_image_ctx == NULL) { + return; + } + + ble_esl_ap_image_transferred_t evt = { + .conn_handle = conn_id, + .image_index = s_image_ctx->image_index, + .status = status, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_IMAGE_TRANSFERRED, &evt); + } + + /* Release the Object Transfer Channel we opened so it is not leaked across + * transfers. Skipped when no channel was opened (e.g. a failure before + * CHANNEL_OPEN) or when it was already torn down (CHANNEL_CLOSED). */ + /* should not closed (Cause PTS case fail) + if (s_image_ctx->otc_open) { + int rc = ble_ots_client_close_channel(conn_id); + if (rc != 0) { + ESP_LOGW(TAG, "transfer_image: close_channel failed; rc=%d", rc); + } + } */ + + free(s_image_ctx); + s_image_ctx = NULL; +} + +/** + * @brief OTS client event handler for image transfer tracking + * + * Called from the OTS client callback dispatcher (registered during AP init). + * Drives the transfer sequence: OLCP Go To (select object, in transfer_image) + * → OLCP response (success → open OTC channel) → channel open (→ OACP Write) + * → OACP response (Write success → send data) → data sent (track progress), + * plus timeout/close/error handling. Every terminal path funnels through + * image_transfer_finish(), which also closes the OTC channel. + */ +void ble_esl_ap_lifecycle_handle_ots_event(uint16_t conn_id, + int event, + const void *param) +{ + if (s_image_ctx == NULL || conn_id != s_image_ctx->conn_handle) { + return; + } + + switch (event) { + + case BLE_OTS_CLIENT_EVT_CHANNEL_OPEN: { + /* L2CAP OTC channel is open — store negotiated MTU for chunking */ + const ble_ots_client_channel_info_t *ch_info = (const ble_ots_client_channel_info_t *)param; + if (ch_info && ch_info->mtu > 0) { + s_image_ctx->otc_mtu = ch_info->mtu; + } + s_image_ctx->otc_open = true; + /* Object already selected via OLCP — now issue OACP Write Object Contents */ + ESP_LOGD(TAG, "transfer_image: OTC channel open (mtu=%u), issuing OACP Write (image_index=%u)", + s_image_ctx->otc_mtu, s_image_ctx->image_index); + uint8_t mode = s_image_ctx->truncate ? OTS_WRITE_MODE_TRUNCATE : 0; + int rc = ble_ots_client_write_object_content(conn_id, 0, + s_image_ctx->data_len, mode); + if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: write_object_content failed; rc=%d", rc); + image_transfer_finish(conn_id, ESP_FAIL); + } + break; + } + + case BLE_OTS_CLIENT_EVT_OLCP_RESPONSE: { + const ble_ots_client_olcp_response_t *olcp = (const ble_ots_client_olcp_response_t *)param; + bool failed = false; + + if (olcp->result_code != 0x01) { /* not Success */ + ESP_LOGE(TAG, "transfer_image: Go To failed, result_code=%d", olcp->result_code); + failed = true; + } else { + /* Step 2: Object selected — open the L2CAP Object Transfer Channel. + * If it was already opened by a previous transfer on this + * connection (BLE_HS_EALREADY), skip straight to OACP Write; + * otherwise wait for BLE_OTS_CLIENT_EVT_CHANNEL_OPEN. */ + ESP_LOGD(TAG, "transfer_image: Go To succeeded, opening OTC channel"); + int rc = ble_ots_client_open_channel(conn_id); + if (rc == BLE_HS_EALREADY) { + ESP_LOGD(TAG, "transfer_image: OTC already open, issuing OACP Write"); + s_image_ctx->otc_open = true; + uint8_t mode = s_image_ctx->truncate ? OTS_WRITE_MODE_TRUNCATE : 0; + rc = ble_ots_client_write_object_content(conn_id, 0, + s_image_ctx->data_len, mode); + if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: write_object_content failed; rc=%d", rc); + failed = true; + } + } else if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: open_channel failed; rc=%d", rc); + failed = true; + } + /* else: wait for CHANNEL_OPEN to issue OACP Write */ + } + + if (failed) { + image_transfer_finish(conn_id, ESP_FAIL); + } + break; + } + + case BLE_OTS_CLIENT_EVT_OACP_RESPONSE: { + const ble_ots_client_oacp_response_t *oacp = (const ble_ots_client_oacp_response_t *)param; + if (oacp->request_opcode == 0x06 && oacp->result_code == 0x01) { + /* OACP Write success — send the first chunk of data. + * One send_data() call becomes one L2CAP SDU, so it must not + * exceed the negotiated OTC MTU (max SDU size) or ble_l2cap_send + * rejects it with BLE_HS_EBADDATA. Chunk by otc_mtu. */ + ESP_LOGD(TAG, "transfer_image: OACP Write accepted, sending data"); + uint16_t chunk_len = (s_image_ctx->data_len > s_image_ctx->otc_mtu) ? + s_image_ctx->otc_mtu : (uint16_t)s_image_ctx->data_len; + s_image_ctx->sent_offset = chunk_len; + int rc = ble_ots_client_send_data(conn_id, s_image_ctx->data, chunk_len); + if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: send_data failed; rc=%d", rc); + image_transfer_finish(conn_id, ESP_FAIL); + } + } else if (oacp->result_code != 0x01) { + ESP_LOGE(TAG, "transfer_image: OACP failed, opcode=%d result=%d", + oacp->request_opcode, oacp->result_code); + image_transfer_finish(conn_id, ESP_FAIL); + } + break; + } + + case BLE_OTS_CLIENT_EVT_DATA_SENT: { + const ble_ots_client_data_sent_t *sent = (const ble_ots_client_data_sent_t *)param; + if (sent->remaining == 0) { + /* Transfer complete */ + ESP_LOGI(TAG, "transfer_image: image transfer complete for index %d", + s_image_ctx->image_index); + image_transfer_finish(conn_id, ESP_OK); + } else { + /* Send the next chunk, capped at the negotiated OTC MTU (one SDU). */ + uint32_t offset = s_image_ctx->data_len - sent->remaining; + uint16_t chunk_len = (sent->remaining > s_image_ctx->otc_mtu) ? + s_image_ctx->otc_mtu : (uint16_t)sent->remaining; + s_image_ctx->sent_offset = offset + chunk_len; + ESP_LOGD(TAG, "transfer_image: sending next chunk at offset %lu, %u bytes " + "(%lu remaining)", (unsigned long)offset, chunk_len, + (unsigned long)sent->remaining); + int rc = ble_ots_client_send_data(conn_id, + s_image_ctx->data + offset, + chunk_len); + if (rc != 0) { + ESP_LOGE(TAG, "transfer_image: send_data failed for chunk; rc=%d", rc); + image_transfer_finish(conn_id, ESP_FAIL); + } + } + break; + } + + case BLE_OTS_CLIENT_EVT_TRANSFER_TIMEOUT: + case BLE_OTS_CLIENT_EVT_CP_TIMEOUT: { + ESP_LOGE(TAG, "transfer_image: timeout during transfer"); + image_transfer_finish(conn_id, ESP_ERR_TIMEOUT); + break; + } + + case BLE_OTS_CLIENT_EVT_CHANNEL_CLOSED: { + /* If transfer was in progress, report failure. The channel is already + * torn down, so clear otc_open to avoid a redundant close. */ + if (s_image_ctx != NULL && conn_id == s_image_ctx->conn_handle) { + ESP_LOGW(TAG, "transfer_image: channel closed during transfer"); + s_image_ctx->otc_open = false; + image_transfer_finish(conn_id, ESP_FAIL); + } + break; + } + + default: + break; + } +} + +/* ========================== Synchronize ========================== */ + +esp_err_t ble_esl_ap_synchronize(uint16_t conn_handle) +{ + if (g_esl_ap == NULL) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_ap_conn_t *conn = ble_esl_ap_find_conn(conn_handle); + if (conn == NULL) { + ESP_LOGE(TAG, "synchronize: conn_handle 0x%04X not found", conn_handle); + return ESP_ERR_NOT_FOUND; + } + + /* Need a valid ESL address to proceed */ + ble_esl_ap_esl_entry_t *esl = ble_esl_ap_find_esl(conn->esl_addr); + if (esl == NULL) { + ESP_LOGE(TAG, "synchronize: ESL address 0x%04X not tracked for conn 0x%04X", + conn->esl_addr, conn_handle); + return ESP_ERR_INVALID_STATE; + } + + /* Only one synchronize procedure at a time */ + if (s_sync_ctx != NULL) { + ESP_LOGE(TAG, "synchronize: another synchronization is in progress"); + return ESP_ERR_INVALID_STATE; + } + + /* Allocate synchronize context */ + synchronize_ctx_t *ctx = calloc(1, sizeof(synchronize_ctx_t)); + if (ctx == NULL) { + return ESP_ERR_NO_MEM; + } + + ctx->conn_handle = conn_handle; + ctx->esl_addr = conn->esl_addr; + s_sync_ctx = ctx; + + /* Build Update Complete TLV: opcode 0x04, parameter = ESL_ID (1 byte) */ + uint8_t esl_id = BLE_ESL_AP_ADDR_ESL_ID(conn->esl_addr); + uint8_t tlv_buf[BLE_ESL_TLV_MAX_SIZE]; + uint8_t tlv_len = 0; + + /* Update Complete opcode = 0x04, Length nibble = 0 (1 param byte: ESL_ID) */ + uint8_t opcode = BLE_ESL_TLV_OPCODE(BLE_ESL_CMD_UPDATE_COMPLETE, 0); + esp_err_t ret = ble_esl_tlv_encode(opcode, &esl_id, 1, tlv_buf, &tlv_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "synchronize: TLV encode failed: %s", esp_err_to_name(ret)); + free(ctx); + s_sync_ctx = NULL; + return ret; + } + + /* + * Write Update Complete directly to the ECP characteristic. + * Per ESL spec Section 3.9.2.5.2, the Update Complete command has no + * response, so we use ble_esl_ap_gatt_write (which fires the callback + * on write completion) instead of ble_esl_ap_ecp_write (which waits + * for a notification that will never arrive). + */ + ret = ble_esl_ap_gatt_write(conn_handle, BLE_ESL_CHR_UUID_ECP, + tlv_buf, tlv_len, + synchronize_ecp_cb, ctx); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "synchronize: ECP write failed: %s", esp_err_to_name(ret)); + free(ctx); + s_sync_ctx = NULL; + return ret; + } + + ESP_LOGI(TAG, "synchronize: Update Complete sent for ESL 0x%04X", conn->esl_addr); + return ESP_OK; +} + +/** + * @brief GATT write completion callback for Update Complete command + * + * On successful write, initiates PAST procedure immediately (no notification + * expected per ESL spec). The ESL will synchronize to the AP's periodic + * advertising and then disconnect. + */ +static void synchronize_ecp_cb(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data) +{ + synchronize_ctx_t *ctx = (synchronize_ctx_t *)user_data; + assert(ctx != NULL); + + if (status != ESP_OK) { + ESP_LOGE(TAG, "synchronize: Update Complete write failed: %s", esp_err_to_name(status)); + ble_esl_ap_synchronized_t evt = { + .esl_addr = ble_esl_ap_addr_unpack(ctx->esl_addr), + .status = status, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_SYNCHRONIZED, &evt); + } + if (s_sync_ctx == ctx) { + s_sync_ctx = NULL; + } + free(ctx); + return; + } + + ESP_LOGI(TAG, "synchronize: Update Complete written, initiating PAST"); + + /* + * Initiate PAST: transfer our own periodic advertising set info + * to the connected ESL so it can synchronize to our PAwR train. + * service_data = esl_addr for application-level context. + */ + int rc = ble_gap_periodic_adv_sync_set_info(PAWR_ADV_INSTANCE, + conn_handle, + ctx->esl_addr); + if (rc != 0) { + ESP_LOGE(TAG, "synchronize: PAST failed; rc=%d", rc); + ble_esl_ap_synchronized_t evt = { + .esl_addr = ble_esl_ap_addr_unpack(ctx->esl_addr), + .status = ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_SYNCHRONIZED, &evt); + } + if (s_sync_ctx == ctx) { + s_sync_ctx = NULL; + } + free(ctx); + return; + } + + /* + * PAST initiated successfully. Mark the context so the disconnect handler + * knows PAST was completed before the link dropped. + */ + ctx->past_initiated = true; + + /* + * The ESL will synchronize to our PAwR train + * and then disconnect. The disconnect event handler (in ble_esl_ap_connection.c) + * should detect that s_sync_ctx is active for this connection and: + * 1. Call ble_esl_ap_update_esl_state(addr, SYNCHRONIZED) + * 2. Fire BLE_ESL_AP_EVT_SYNCHRONIZED + * + * We keep s_sync_ctx alive until the disconnect event arrives. + */ + ESP_LOGI(TAG, "synchronize: PAST initiated for ESL 0x%04X, awaiting disconnect", + ctx->esl_addr); +} + +/** + * @brief Called by the connection module when a disconnect occurs for a + * connection that has an active synchronize procedure. + * + * This function checks if the disconnected connection matches the active + * synchronize context and completes the synchronization procedure. + * + * Note: This is intended to be called from the disconnect handler in + * ble_esl_ap_connection.c. If the connection module does not call this + * directly, the lifecycle module can also detect it via the state tracking. + */ +void ble_esl_ap_lifecycle_handle_disconnect(uint16_t conn_handle) +{ + /* Step 1: Look up the ESL entry connected on this conn_handle and handle + * link-loss state transitions for Configuring / Updating states. + * Skip link-loss transitions if this disconnect belongs to an active + * synchronize procedure — Step 2 will handle the state change cleanly. */ + bool is_sync_disconnect = (s_sync_ctx != NULL && + s_sync_ctx->conn_handle == conn_handle); + + if (g_esl_ap != NULL) { + for (int i = 0; i < CONFIG_BLE_ESL_AP_MAX_ESLS; i++) { + ble_esl_ap_esl_entry_t *esl = &g_esl_ap->esls[i]; + if (!esl->in_use || esl->conn_handle != conn_handle) { + continue; + } + + /* Clear the connection handle */ + esl->conn_handle = BLE_ESL_AP_CONN_HANDLE_INVALID; + + if (!is_sync_disconnect) { + if (esl->state == BLE_ESL_STATE_CONFIGURING) { + if (esl->config_complete) { + ESP_LOGI(TAG, "link-loss in Configuring (config complete) for ESL 0x%04X, " + "transitioning to Unsynchronized", esl->esl_addr); + ble_esl_ap_update_esl_state(esl->esl_addr, + BLE_ESL_STATE_UNSYNCHRONIZED); + } else { + ESP_LOGI(TAG, "link-loss in Configuring (config incomplete) for ESL 0x%04X, " + "transitioning to Unassociated", esl->esl_addr); + ble_esl_ap_update_esl_state(esl->esl_addr, + BLE_ESL_STATE_UNASSOCIATED); + } + } else if (esl->state == BLE_ESL_STATE_UPDATING) { + ESP_LOGI(TAG, "link-loss in Updating for ESL 0x%04X, " + "transitioning to Unsynchronized", esl->esl_addr); + ble_esl_ap_update_esl_state(esl->esl_addr, + BLE_ESL_STATE_UNSYNCHRONIZED); + } + } + break; + } + } + + /* Step 2: Handle synchronize procedure completion if this disconnect + * belongs to an active synchronize context. */ + if (s_sync_ctx != NULL && s_sync_ctx->conn_handle == conn_handle) { + synchronize_ctx_t *ctx = s_sync_ctx; + s_sync_ctx = NULL; + + if (ctx->past_initiated) { + /* PAST was successfully initiated — complete synchronization */ + ESP_LOGI(TAG, "synchronize: disconnect received for ESL 0x%04X, completing sync", + ctx->esl_addr); + + esp_err_t ret = ble_esl_ap_update_esl_state(ctx->esl_addr, + BLE_ESL_STATE_SYNCHRONIZED); + + ble_esl_ap_synchronized_t evt = { + .esl_addr = ble_esl_ap_addr_unpack(ctx->esl_addr), + .status = (ret == ESP_OK) ? ESP_OK : ret, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_SYNCHRONIZED, &evt); + } + } else { + /* Premature disconnect before PAST was initiated — report failure */ + ESP_LOGW(TAG, "synchronize: premature disconnect for ESL 0x%04X before PAST", + ctx->esl_addr); + + ble_esl_ap_update_esl_state(ctx->esl_addr, + BLE_ESL_STATE_UNSYNCHRONIZED); + + ble_esl_ap_synchronized_t evt = { + .esl_addr = ble_esl_ap_addr_unpack(ctx->esl_addr), + .status = ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_SYNCHRONIZED, &evt); + } + } + + free(ctx); + } + + /* Step 3: Clean up image transfer context if this disconnect + * belongs to an active image transfer. */ + if (s_image_ctx != NULL && s_image_ctx->conn_handle == conn_handle) { + ESP_LOGW(TAG, "transfer_image: disconnect during image transfer"); + ble_esl_ap_image_transferred_t img_evt = { + .conn_handle = conn_handle, + .image_index = s_image_ctx->image_index, + .status = ESP_FAIL, + }; + if (g_esl_ap != NULL && g_esl_ap->app_cb != NULL) { + g_esl_ap->app_cb(BLE_ESL_AP_EVT_IMAGE_TRANSFERRED, &img_evt); + } + free(s_image_ctx); + s_image_ctx = NULL; + } +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_pawr.c b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_pawr.c new file mode 100644 index 00000000000..74d7c55bc3d --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ap_pawr.c @@ -0,0 +1,639 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap_pawr.c + * @brief ESL AP PAwR broadcasting and AES-CCM encryption/decryption + * + * Implements PAwR broadcaster lifecycle (init/deinit/start/stop), + * encrypted sync packet assembly (AP → ESL), and encrypted response + * packet parsing (ESL → AP) using AES-CCM with per-direction key material. + */ + +#include +#include +#include + +#include "esp_log.h" +#include "esp_err.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "nimble/ble.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "os/os_mbuf.h" + +#include "host/ble_ead.h" + +#include "ble_esl_ap_int.h" +#include "ble_esl_common.h" + +static const char *TAG = "esl_ap_pawr"; + +/** Mutex protecting the per-subevent pending TX buffers (g_esl_ap->pawr_pending) */ +static SemaphoreHandle_t s_pawr_mutex; + +/** Advertising instance used for PAwR */ +#define PAWR_ADV_INSTANCE 0 + +/** + * Number of consecutive PAwR events a queued sync packet is transmitted before + * its pending slot is cleared. >1 improves delivery reliability at the cost of + * the ESL seeing (and responding to) the same command multiple times. + * 1 == transmit exactly once. + */ +#define PAWR_TX_REPEATS 3 + +/* ========================== Forward Declarations ========================== */ + +static int pawr_gap_event_cb(struct ble_gap_event *event, void *arg); +static void pawr_handle_subev_data_req(struct ble_gap_event *event); +static void pawr_handle_subev_response(struct ble_gap_event *event); + +/* ========================== PAwR Init / Deinit ========================== */ + +esp_err_t ble_esl_ap_pawr_init(void) +{ + assert(g_esl_ap != NULL); + + ble_esl_ead_randomizer_init(g_esl_ap->randomizer, BLE_ESL_EAD_DIR_AP_TO_ESL); + g_esl_ap->pawr_active = false; + g_esl_ap->pawr_pending = NULL; + + if (s_pawr_mutex == NULL) { + s_pawr_mutex = xSemaphoreCreateMutex(); + if (s_pawr_mutex == NULL) { + ESP_LOGE(TAG, "Failed to create PAwR mutex"); + return ESP_ERR_NO_MEM; + } + } + + ESP_LOGI(TAG, "PAwR sub-module initialized"); + return ESP_OK; +} + +void ble_esl_ap_pawr_deinit(void) +{ + assert(g_esl_ap != NULL); + + if (g_esl_ap->pawr_active) { + ble_esl_ap_pawr_stop(); + } + + if (s_pawr_mutex != NULL) { + vSemaphoreDelete(s_pawr_mutex); + s_pawr_mutex = NULL; + } + + ESP_LOGI(TAG, "PAwR sub-module deinitialized"); +} + +/* ========================== PAwR Start / Stop ========================== */ + +esp_err_t ble_esl_ap_pawr_start(void) +{ + assert(g_esl_ap != NULL); + + int rc; + + if (g_esl_ap->pawr_active) { + ESP_LOGW(TAG, "PAwR already active"); + return ESP_ERR_INVALID_STATE; + } + + /* Step 1: Configure extended advertising (non-connectable, non-scannable) */ + struct ble_gap_ext_adv_params ext_params; + memset(&ext_params, 0, sizeof(ext_params)); + ext_params.connectable = 0; + ext_params.scannable = 0; + ext_params.legacy_pdu = 0; + ext_params.anonymous = 0; + ext_params.include_tx_power = 0; + ext_params.itvl_min = 0; + ext_params.itvl_max = 0; + ext_params.primary_phy = BLE_HCI_LE_PHY_1M; + ext_params.secondary_phy = BLE_HCI_LE_PHY_1M; + ext_params.own_addr_type = BLE_OWN_ADDR_PUBLIC; + ext_params.sid = 0; + ext_params.tx_power = 127; /* Host selects max */ + + int8_t selected_tx_power = 0; + rc = ble_gap_ext_adv_configure(PAWR_ADV_INSTANCE, &ext_params, + &selected_tx_power, + pawr_gap_event_cb, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ext_adv_configure failed: %d", rc); + return ESP_FAIL; + } + + /* Step 2: Configure periodic advertising with PAwR parameters */ + struct ble_gap_periodic_adv_params pparams; + memset(&pparams, 0, sizeof(pparams)); + pparams.include_tx_power = 0; + pparams.itvl_min = g_esl_ap->pawr_config.periodic_adv_interval_min; + pparams.itvl_max = g_esl_ap->pawr_config.periodic_adv_interval_max; + pparams.num_subevents = g_esl_ap->pawr_config.num_subevents; + pparams.subevent_interval = g_esl_ap->pawr_config.subevent_interval; + pparams.response_slot_delay = g_esl_ap->pawr_config.response_slot_delay; + pparams.response_slot_spacing = g_esl_ap->pawr_config.response_slot_spacing; + pparams.num_response_slots = g_esl_ap->pawr_config.num_response_slots; + + rc = ble_gap_periodic_adv_configure(PAWR_ADV_INSTANCE, &pparams); + if (rc != 0) { + ESP_LOGE(TAG, "periodic_adv_configure failed: %d", rc); + /* Roll back the instance configured in Step 1, otherwise it stays + * configured in the host and a retry may be rejected. */ + ble_gap_ext_adv_remove(PAWR_ADV_INSTANCE); + return ESP_FAIL; + } + + /* Step 3: Start periodic advertising */ + rc = ble_gap_periodic_adv_start(PAWR_ADV_INSTANCE); + if (rc != 0) { + ESP_LOGE(TAG, "periodic_adv_start failed: %d", rc); + ble_gap_ext_adv_remove(PAWR_ADV_INSTANCE); + return ESP_FAIL; + } + + /* Step 4: Start extended advertising (required for periodic adv train) */ + rc = ble_gap_ext_adv_start(PAWR_ADV_INSTANCE, 0, 0); + if (rc != 0) { + ESP_LOGE(TAG, "ext_adv_start failed: %d", rc); + ble_gap_periodic_adv_stop(PAWR_ADV_INSTANCE); + ble_gap_ext_adv_remove(PAWR_ADV_INSTANCE); + return ESP_FAIL; + } + + /* Step 5: Allocate one pending TX slot per subevent. PAwR is pull-based: + * ble_esl_ap_pawr_send() stores encrypted packets here and the + * BLE_GAP_EVENT_PER_SUBEV_DATA_REQ callback drains them into subevents. */ + uint8_t num_subevents = g_esl_ap->pawr_config.num_subevents; + g_esl_ap->pawr_pending = calloc(num_subevents, + sizeof(ble_esl_ap_pawr_pending_t)); + if (g_esl_ap->pawr_pending == NULL) { + ESP_LOGE(TAG, "Failed to allocate %u pending TX buffers", num_subevents); + ble_gap_ext_adv_stop(PAWR_ADV_INSTANCE); + ble_gap_periodic_adv_stop(PAWR_ADV_INSTANCE); + ble_gap_ext_adv_remove(PAWR_ADV_INSTANCE); + return ESP_ERR_NO_MEM; + } + + g_esl_ap->pawr_active = true; + ESP_LOGI(TAG, "PAwR broadcaster started (subevents=%u, resp_slots=%u)", + g_esl_ap->pawr_config.num_subevents, + g_esl_ap->pawr_config.num_response_slots); + + return ESP_OK; +} + +esp_err_t ble_esl_ap_pawr_stop(void) +{ + assert(g_esl_ap != NULL); + + if (!g_esl_ap->pawr_active) { + ESP_LOGW(TAG, "PAwR not active"); + return ESP_ERR_INVALID_STATE; + } + + int rc; + + /* Stop periodic advertising */ + rc = ble_gap_periodic_adv_stop(PAWR_ADV_INSTANCE); + if (rc != 0) { + ESP_LOGW(TAG, "periodic_adv_stop failed: %d", rc); + } + + /* Stop extended advertising */ + rc = ble_gap_ext_adv_stop(PAWR_ADV_INSTANCE); + if (rc != 0) { + ESP_LOGW(TAG, "ext_adv_stop failed: %d", rc); + } + + /* Release pending TX buffers under the lock so the data-request callback + * (host task) never dereferences a freed pointer. */ + xSemaphoreTake(s_pawr_mutex, portMAX_DELAY); + g_esl_ap->pawr_active = false; + free(g_esl_ap->pawr_pending); + g_esl_ap->pawr_pending = NULL; + xSemaphoreGive(s_pawr_mutex); + + ESP_LOGI(TAG, "PAwR broadcaster stopped"); + + return ESP_OK; +} + +/* ========================== Key Material ========================== */ + +void ble_esl_ap_pawr_set_sync_key(const ble_esl_key_material_t *key_mat) +{ + assert(g_esl_ap != NULL); + assert(key_mat != NULL); + + g_esl_ap->ap_sync_key = *key_mat; + + ESP_LOGI(TAG, "AP Sync Key Material set"); +} + +esp_err_t ble_esl_ap_pawr_set_response_key(uint16_t esl_addr, + const ble_esl_key_material_t *key_mat) +{ + assert(g_esl_ap != NULL); + + if (key_mat == NULL) { + return ESP_ERR_INVALID_ARG; + } + + ble_esl_ap_esl_entry_t *entry = ble_esl_ap_find_esl(esl_addr); + if (entry == NULL) { + ESP_LOGE(TAG, "ESL 0x%04x not found for response key", esl_addr); + return ESP_ERR_NOT_FOUND; + } + + entry->resp_key = *key_mat; + + ESP_LOGI(TAG, "Response Key Material set for ESL 0x%04x", esl_addr); + return ESP_OK; +} + +/* ========================== PAwR Send (Encrypt & Transmit) ========================== */ + +esp_err_t ble_esl_ap_pawr_send(uint8_t group_id, const uint8_t *payload, + uint8_t payload_len) +{ + assert(g_esl_ap != NULL); + + /* Validate arguments */ + if (group_id > BLE_ESL_GROUP_ID_MAX) { + ESP_LOGE(TAG, "Invalid group_id: 0x%02x", group_id); + return ESP_ERR_INVALID_ARG; + } + if (payload == NULL || payload_len == 0 || payload_len > BLE_ESL_PAYLOAD_MAX_SIZE) { + ESP_LOGE(TAG, "Invalid payload (ptr=%p, len=%u)", payload, payload_len); + return ESP_ERR_INVALID_ARG; + } + if (!g_esl_ap->pawr_active) { + ESP_LOGE(TAG, "PAwR broadcaster not active"); + return ESP_ERR_INVALID_STATE; + } + + /* + * Step 1: Build inner AD structure (plaintext) + * [len_byte] [0x34] [ESL Payload] + * len_byte = 1 (tag) + payload_len + */ + uint8_t inner_ad_len = 1 + payload_len; /* ESL Tag + ESL Payload */ + uint8_t plaintext_len = 1 + inner_ad_len; /* Len byte + inner_ad_len */ + uint8_t plaintext[1 + 1 + BLE_ESL_PAYLOAD_MAX_SIZE]; /* len + tag + payload */ + + plaintext[0] = inner_ad_len; /* Len byte */ + plaintext[1] = BLE_ESL_AD_TYPE_ESL; /* ESL Tag 0x34 */ + memcpy(&plaintext[2], payload, payload_len); /* ESL Payload */ + + /* + * Step 2: Encrypt with the AP Sync Key Material (AES-CCM) + * Input: plaintext (inner AD) + * Output: encrypted_payload = [Randomizer(5)] [Ciphertext(N)] [MIC(4)] + */ + uint8_t enc_payload_len = BLE_EAD_ENCRYPTED_PAYLOAD_SIZE(plaintext_len); + uint8_t encrypted_payload[BLE_EAD_ENCRYPTED_PAYLOAD_SIZE(1 + 1 + BLE_ESL_PAYLOAD_MAX_SIZE)]; + + /* The Randomizer is read and advanced under the lock: this function is a + * public command path and may be called from any application task. */ + xSemaphoreTake(s_pawr_mutex, portMAX_DELAY); + esp_err_t enc_err = ble_esl_ead_encrypt(g_esl_ap->ap_sync_key.session_key, + g_esl_ap->ap_sync_key.iv, + g_esl_ap->randomizer, + plaintext, + plaintext_len, + encrypted_payload); + xSemaphoreGive(s_pawr_mutex); + if (enc_err != ESP_OK) { + ESP_LOGE(TAG, "EAD encrypt failed: 0x%x", enc_err); + return enc_err; + } + + /* + * Step 3: Build outer AD structure (Encrypted Data) + * [total_len] [0x31] [encrypted_payload] + * total_len = 1 (ED tag) + enc_payload_len + */ + uint8_t ed_content_len = 1 + enc_payload_len; + uint8_t outer_ad[BLE_ESL_AP_PAWR_MAX_AD_BUF_SIZE]; + uint8_t pos = 0; + + outer_ad[pos++] = ed_content_len; /* Len */ + outer_ad[pos++] = BLE_ESL_AD_TYPE_ENCRYPTED_DATA; /* ED Tag 0x31 */ + memcpy(&outer_ad[pos], encrypted_payload, enc_payload_len); /* Randomizer + Ciphertext + MIC */ + pos += enc_payload_len; + + /* + * Step 4: Queue the encrypted packet for the target subevent (= group_id). + * + * PAwR subevent data can only be handed to the controller in response to a + * BLE_GAP_EVENT_PER_SUBEV_DATA_REQ event; pushing it here directly makes the + * controller reject the HCI command with Command Disallowed (0x0C). So we + * store it in the pending slot and let pawr_handle_subev_data_req() drain it. + */ + if (group_id >= g_esl_ap->pawr_config.num_subevents) { + ESP_LOGE(TAG, "group_id %u exceeds configured subevents (%u)", + group_id, g_esl_ap->pawr_config.num_subevents); + return ESP_ERR_INVALID_ARG; + } + if (pos > BLE_ESL_AP_PAWR_MAX_AD_BUF_SIZE) { + ESP_LOGE(TAG, "Outer AD too large: %u", pos); + return ESP_ERR_INVALID_SIZE; + } + + xSemaphoreTake(s_pawr_mutex, portMAX_DELAY); + if (g_esl_ap->pawr_pending == NULL) { + xSemaphoreGive(s_pawr_mutex); + ESP_LOGE(TAG, "PAwR pending buffers not allocated"); + return ESP_ERR_INVALID_STATE; + } + ble_esl_ap_pawr_pending_t *slot = &g_esl_ap->pawr_pending[group_id]; + memcpy(slot->data, outer_ad, pos); + slot->len = pos; + slot->repeats_left = PAWR_TX_REPEATS; + slot->valid = true; + xSemaphoreGive(s_pawr_mutex); + + ESP_LOGD(TAG, "PAwR sync packet queued for group %u (payload_len=%u, ad_len=%u)", + group_id, payload_len, pos); + return ESP_OK; +} + +/* ========================== PAwR Parse Response (Decrypt) ========================== */ + +esp_err_t ble_esl_ap_pawr_parse_response(uint16_t esl_addr, + uint8_t response_slot, + const uint8_t *enc_data, + uint8_t enc_data_len, + ble_esl_ap_parsed_response_t *out_response) +{ + assert(g_esl_ap != NULL); + + /* Validate arguments */ + if (enc_data == NULL || out_response == NULL) { + return ESP_ERR_INVALID_ARG; + } + + /* + * PAwR response data is a raw AD structure: + * [AD Len(1)] [AD Type 0x31(1)] [Randomizer(5)] [Ciphertext(N)] [MIC(4)] + * ble_esl_ead_decrypt() expects only the AD value (Randomizer..MIC), so the + * outer length + type octets must be parsed off first (mirrors the ESL + * receive path in handle_gap_periodic_report). + * + * Minimum enc_data_len: + * 2 (AD hdr) + 5 (randomizer) + 4 (MIC) + 1 (min ciphertext) = 12 + */ + if (enc_data_len < (2 + BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE + 1)) { + ESP_LOGE(TAG, "enc_data too short: %u", enc_data_len); + return ESP_ERR_INVALID_ARG; + } + + uint8_t outer_len = enc_data[0]; /* AD type octet + encrypted payload */ + uint8_t outer_type = enc_data[1]; + if (outer_type != BLE_ESL_AD_TYPE_ENCRYPTED_DATA || + outer_len < 1 + BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE || + (uint16_t)(outer_len + 1) > enc_data_len) { + ESP_LOGE(TAG, "Not a valid Encrypted Data AD (type=0x%02x len=%u)", + outer_type, outer_len); + return ESP_ERR_INVALID_ARG; + } + + /* Strip the outer AD length + type octets to get the EAD payload. */ + const uint8_t *enc_payload = &enc_data[2]; + uint8_t enc_payload_len = outer_len - 1; + + /* Step 1: Look up ESL entry by esl_addr */ + ble_esl_ap_esl_entry_t *entry = ble_esl_ap_find_esl(esl_addr); + if (entry == NULL) { + ESP_LOGE(TAG, "ESL 0x%04x not found for response decryption", esl_addr); + return ESP_ERR_NOT_FOUND; + } + + /* Step 2: Decrypt and verify MIC via ble_esl_ead_decrypt + * Input: enc_payload = [Randomizer(5)] [Ciphertext(N)] [MIC(4)] + * Output: plaintext (inner AD), ciphertext_len octets + */ + uint8_t plaintext[BLE_ESL_PAYLOAD_MAX_SIZE + 2]; /* inner AD max */ + size_t decrypted_len = 0; + esp_err_t err = ble_esl_ead_decrypt(entry->resp_key.session_key, + entry->resp_key.iv, + enc_payload, + enc_payload_len, + plaintext, sizeof(plaintext), + &decrypted_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Response decryption failed for ESL 0x%04x (err=%d)", + esl_addr, (int)err); + return err; + } + + uint8_t ciphertext_len = (uint8_t)decrypted_len; + + /* + * Step 3: Strip inner AD wrapper + * plaintext[0] = Len (length of ESL Tag + response payload) + * plaintext[1] = ESL Tag (must be 0x34) + * plaintext[2..] = response TLVs + */ + if (ciphertext_len < 2) { + ESP_LOGE(TAG, "Decrypted data too short for inner AD"); + return ESP_ERR_INVALID_SIZE; + } + + uint8_t inner_len = plaintext[0]; + uint8_t inner_tag = plaintext[1]; + + if (inner_tag != BLE_ESL_AD_TYPE_ESL) { + ESP_LOGE(TAG, "Invalid inner AD tag: 0x%02x (expected 0x34)", inner_tag); + return ESP_ERR_INVALID_SIZE; + } + + /* inner_len = 1 (tag) + response_payload_len */ + if (inner_len < 1 || (1 + inner_len) > ciphertext_len) { + ESP_LOGE(TAG, "Inner AD length mismatch: inner_len=%u, ciphertext_len=%u", + inner_len, ciphertext_len); + return ESP_ERR_INVALID_SIZE; + } + + uint8_t response_payload_len = inner_len - 1; /* subtract ESL Tag */ + + /* Step 4: Copy decrypted data into owned buffer and populate output structure */ + memcpy(out_response->payload_buf, plaintext, ciphertext_len); + out_response->payload = &out_response->payload_buf[2]; + uint8_t *response_payload = out_response->payload; + + out_response->esl_addr = esl_addr; + out_response->response_slot = response_slot; + out_response->payload_len = response_payload_len; + out_response->auth_success = true; + + /* Step 5: Count TLVs by iterating with ble_esl_tlv_decode */ + uint8_t tlv_count = 0; + uint8_t offset = 0; + + while (offset < response_payload_len) { + uint8_t opcode; + const uint8_t *params; + uint8_t params_len; + + esp_err_t dec_rc = ble_esl_tlv_decode(&response_payload[offset], + response_payload_len - offset, + &opcode, ¶ms, ¶ms_len); + if (dec_rc != ESP_OK) { + ESP_LOGW(TAG, "TLV decode failed at offset %u: 0x%x", offset, dec_rc); + break; + } + + uint8_t tlv_total = BLE_ESL_TLV_TOTAL_LEN(opcode); + offset += tlv_total; + tlv_count++; + } + + out_response->tlv_count = tlv_count; + + ESP_LOGD(TAG, "Parsed response from ESL 0x%04x: slot=%u, tlvs=%u, len=%u", + esl_addr, response_slot, tlv_count, response_payload_len); + + return ESP_OK; +} + +/* ========================== GAP Event Handling ========================== */ + +/** + * @brief GAP event callback for PAwR-related events + * + * Handles subevent data requests and subevent responses. + */ +static int pawr_gap_event_cb(struct ble_gap_event *event, void *arg) +{ + switch (event->type) { + case BLE_GAP_EVENT_PER_SUBEV_DATA_REQ: + pawr_handle_subev_data_req(event); + return 0; + + case BLE_GAP_EVENT_PER_SUBEV_RESP: + pawr_handle_subev_response(event); + return 0; + + default: + return 0; + } +} + +/** + * @brief Handle subevent data request from the controller + * + * The controller requests subevent data before transmission. For PAwR with ESL, + * the actual data is set via ble_esl_ap_pawr_send() when commands are queued. + * Here we provide empty data for subevents that have no pending commands. + */ +static void pawr_handle_subev_data_req(struct ble_gap_event *event) +{ + uint8_t start = event->periodic_adv_subev_data_req.subevent_start; + uint8_t count = event->periodic_adv_subev_data_req.subevent_data_count; + uint8_t adv_handle = event->periodic_adv_subev_data_req.adv_handle; + + if (count == 0) { + return; + } + + /* + * Allocate params array on stack. For each requested subevent we supply the + * queued sync packet (if any) or an empty mbuf; the NimBLE stack rejects + * NULL data pointers. Note: ble_gap_set_periodic_adv_subev_data always frees + * the mbufs. + */ + struct ble_gap_set_periodic_adv_subev_data_params sub_params[count]; + memset(sub_params, 0, sizeof(sub_params)); + + for (uint8_t i = 0; i < count; i++) { + uint8_t sub = (start + i) % g_esl_ap->pawr_config.num_subevents; + sub_params[i].subevent = sub; + sub_params[i].response_slot_start = 0; + sub_params[i].response_slot_count = g_esl_ap->pawr_config.num_response_slots; + + /* Snapshot this subevent's pending slot under the lock, then release it + * before touching the mbuf pool / HCI. */ + uint8_t pkt[BLE_ESL_AP_PAWR_MAX_AD_BUF_SIZE]; + uint8_t pkt_len = 0; + + xSemaphoreTake(s_pawr_mutex, portMAX_DELAY); + if (g_esl_ap->pawr_pending != NULL) { + ble_esl_ap_pawr_pending_t *slot = &g_esl_ap->pawr_pending[sub]; + if (slot->valid && slot->len > 0) { + pkt_len = slot->len; + memcpy(pkt, slot->data, pkt_len); + if (--slot->repeats_left == 0) { + slot->valid = false; + slot->len = 0; + } + } + } + xSemaphoreGive(s_pawr_mutex); + + /* Allocate an mbuf (sized to the packet, or empty for idle subevents) */ + struct os_mbuf *mbuf = os_msys_get_pkthdr(pkt_len, 0); + if (mbuf == NULL) { + ESP_LOGE(TAG, "Failed to allocate mbuf for subevent %u", sub); + /* Free already-allocated mbufs before returning */ + for (uint8_t j = 0; j < i; j++) { + os_mbuf_free_chain(sub_params[j].data); + } + return; + } + if (pkt_len > 0 && os_mbuf_append(mbuf, pkt, pkt_len) != 0) { + ESP_LOGE(TAG, "os_mbuf_append failed for subevent %u", sub); + os_mbuf_free_chain(mbuf); + for (uint8_t j = 0; j < i; j++) { + os_mbuf_free_chain(sub_params[j].data); + } + return; + } + sub_params[i].data = mbuf; + } + + /* ble_gap_set_periodic_adv_subev_data always frees the mbufs */ + int rc = ble_gap_set_periodic_adv_subev_data(adv_handle, count, sub_params); + if (rc != 0) { + ESP_LOGW(TAG, "set_periodic_adv_subev_data failed: %d", rc); + } +} + +/** + * @brief Handle a PAwR response received in a response slot + * + * Extracts the response data and forwards it to the command module + * for decryption and dispatch. + */ +static void pawr_handle_subev_response(struct ble_gap_event *event) +{ + const struct ble_gap_periodic_adv_response *resp = &event->periodic_adv_response; + + /* Only process complete data */ + if (resp->data_status != BLE_GAP_PER_ADV_DATA_STATUS_COMPLETE) { + return; + } + + if (resp->data == NULL || resp->data_length == 0) { + return; + } + + uint8_t group_id = resp->subevent; + uint8_t response_slot = resp->response_slot; + + ESP_LOGD(TAG, "PAwR response: subevent=%u, slot=%u, len=%u", + group_id, response_slot, resp->data_length); + + /* Forward to command module for decryption and dispatch */ + ble_esl_ap_command_handle_pawr_response(group_id, response_slot, + resp->data, resp->data_length); +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/ap/ble_esl_ap_int.h b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ble_esl_ap_int.h new file mode 100644 index 00000000000..027c0afbce0 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/ap/ble_esl_ap_int.h @@ -0,0 +1,584 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_ap_int.h + * @brief ESL AP module — Internal header shared across source files + * + * Defines internal data structures, per-connection context, per-ESL tracking + * entry, and cross-module function declarations used by: + * - ap_connection.c + * - ap_gattc.c + * - ap_lifecycle.c + * - ap_command.c + * - ap_pawr.c + */ + +#ifndef BLE_ESL_AP_INT_H +#define BLE_ESL_AP_INT_H + +#include +#include +#include "esp_err.h" +#include "esp_timer.h" +#include "ble_esl_ap.h" +#include "ble_esl_common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== PAwR Response Structures ========================== */ + +/** + * @brief Decrypted and parsed PAwR response from a single ESL + */ +typedef struct { + uint16_t esl_addr; /*!< Full ESL Address (ESL_ID + Group_ID) */ + uint8_t response_slot; /*!< Response slot number in which received */ + uint8_t tlv_count; /*!< Number of response TLVs parsed */ + uint8_t payload_buf[BLE_ESL_PAYLOAD_MAX_SIZE + 2]; /*!< Owned buffer for decrypted inner AD (len + tag + payload); payload points into this at offset 2 */ + uint8_t *payload; /*!< Pointer to decrypted response payload (TLVs) */ + uint8_t payload_len; /*!< Length of decrypted response payload */ + bool auth_success; /*!< True if MIC authenticated successfully */ +} ble_esl_ap_parsed_response_t; + +/* ========================== Constants ========================== */ + +/** Invalid/unassigned connection handle sentinel */ +#define BLE_ESL_AP_CONN_HANDLE_INVALID 0xFFFF + +/** Maximum number of PAwR subevents a periodic advertising train may use + * (Core Spec: Num_Subevents range 0x01-0x80) */ +#define BLE_ESL_AP_PAWR_MAX_SUBEVENTS 128 + +/** DIS UUID */ +#define BLE_ESL_AP_DIS_UUID 0x180A + +/** PnP ID UUID */ +#define BLE_ESL_AP_PNP_ID_UUID 0x2A50 + +/** PnP ID characteristic value length */ +#define BLE_ESL_AP_PNP_ID_LEN 7 + +/** Maximum characteristic value for Read Long reassembly buffer */ +#define BLE_ESL_AP_READ_BUF_MAX 256 + +/* + * Internally the AP stores an ESL Address packed into a single 16-bit key + * (ESL_ID | Group_ID << 8) so that tracking entries can be compared and looked + * up with a plain integer comparison. It carries exactly the same information + * as the public ble_esl_address_t; use the helpers below to convert between + * both forms at the public API boundary. + */ + +/** ESL Address helper: build 16-bit ESL Address key from esl_id and group_id */ +#define BLE_ESL_AP_MAKE_ADDR(esl_id, group_id) \ + ((uint16_t)((uint8_t)(esl_id) | (((uint8_t)(group_id) & 0x7F) << 8))) + +/** Extract ESL_ID from 16-bit ESL Address key */ +#define BLE_ESL_AP_ADDR_ESL_ID(addr) ((uint8_t)((addr) & 0xFF)) + +/** Extract Group_ID from 16-bit ESL Address key */ +#define BLE_ESL_AP_ADDR_GROUP_ID(addr) ((uint8_t)(((addr) >> 8) & 0x7F)) + +/** Pack a public ble_esl_address_t into the internal 16-bit ESL Address key */ +#define BLE_ESL_AP_ADDR_PACK(addr) \ + BLE_ESL_AP_MAKE_ADDR((addr).esl_id, BLE_ESL_ADDR_GROUP_ID(addr)) + +/** + * @brief Unpack an internal 16-bit ESL Address key into a ble_esl_address_t + * + * @param esl_addr 16-bit ESL Address key (ESL_ID | Group_ID << 8) + * @return Equivalent public ESL Address structure + */ +static inline ble_esl_address_t ble_esl_ap_addr_unpack(uint16_t esl_addr) +{ + return ble_esl_addr_make(BLE_ESL_AP_ADDR_ESL_ID(esl_addr), + BLE_ESL_AP_ADDR_GROUP_ID(esl_addr)); +} + +/* ========================== Internal Callback Type ========================== */ + +/** + * @brief GATT operation completion callback (internal) + * + * @param conn_handle ACL connection handle + * @param status ESP_OK on success + * @param data Read value / ECP notification payload (NULL for writes) + * @param data_len Length of data (0 for writes) + * @param user_data Opaque pointer from the caller + */ +typedef void (*ble_esl_ap_gatt_cb_t)(uint16_t conn_handle, esp_err_t status, + const uint8_t *data, uint16_t data_len, + void *user_data); + +/* ========================== Per-Connection Characteristic Handles ========================== */ + +/** + * @brief Discovered characteristic handle cache for one ESL connection + */ +typedef struct { + /* ESL Service characteristics */ + uint16_t esl_svc_start; /*!< ESL Service start handle */ + uint16_t esl_svc_end; /*!< ESL Service end handle */ + uint16_t esl_addr_handle; /*!< ESL Address (0x2BF6) value handle */ + uint16_t ap_sync_key_handle; /*!< AP Sync Key Material (0x2BF7) value handle */ + uint16_t resp_key_handle; /*!< ESL Response Key Material (0x2BF8) value handle */ + uint16_t abs_time_handle; /*!< ESL Current Absolute Time (0x2BF9) value handle */ + uint16_t display_info_handle;/*!< ESL Display Information (0x2BFA) value handle, 0 if absent */ + uint16_t image_info_handle; /*!< ESL Image Information (0x2BFB) value handle, 0 if absent */ + uint16_t sensor_info_handle; /*!< ESL Sensor Information (0x2BFC) value handle, 0 if absent */ + uint16_t led_info_handle; /*!< ESL LED Information (0x2BFD) value handle, 0 if absent */ + uint16_t ecp_handle; /*!< ESL Control Point (0x2BFE) value handle */ + uint16_t ecp_cccd_handle; /*!< ECP CCCD handle */ + /* DIS characteristics */ + uint16_t dis_svc_start; /*!< DIS start handle, 0 if absent */ + uint16_t dis_svc_end; /*!< DIS end handle, 0 if absent */ + uint16_t pnp_id_handle; /*!< PnP ID (0x2A50) value handle, 0 if absent */ +} ble_esl_ap_char_handles_t; + +/* ========================== Per-Connection Context ========================== */ + +/** + * @brief Per-connection context for a connected ESL + */ +typedef struct { + bool in_use; /*!< Entry is active */ + uint16_t conn_handle; /*!< ACL connection handle */ + uint8_t addr[6]; /*!< BLE device address */ + uint8_t addr_type; /*!< Address type (0=public, 1=random) */ + ble_esl_ap_char_handles_t handles; /*!< Discovered characteristic handles */ + + /* ECP state */ + bool ecp_pending; /*!< ECP write in progress (waiting for notification) */ + ble_esl_ap_gatt_cb_t ecp_cb; /*!< ECP notification callback */ + void *ecp_user_data; /*!< ECP callback user data */ + + /* GATT read/write state */ + bool read_pending; /*!< A GATT read is in progress */ + ble_esl_ap_gatt_cb_t gatt_cb; /*!< Active GATT read/write callback */ + void *gatt_user_data; /*!< GATT callback user data */ + uint8_t read_buf[BLE_ESL_AP_READ_BUF_MAX]; /*!< Read Long reassembly buffer */ + uint16_t read_offset; /*!< Current offset in read_buf */ + + /* OTS capability flag (set during service discovery) */ + bool has_ots; /*!< OTS service was found on this connection */ + bool disc_done; /*!< Service discovery completed successfully */ + + /* ESL tracking cross-reference */ + uint16_t esl_addr; /*!< Assigned ESL Address (valid after configure) */ +} ble_esl_ap_conn_t; + +/* ========================== Per-ESL Tracking Entry ========================== */ + +/** + * @brief Per-ESL tracking entry in the AP's ESL tracking table + */ +typedef struct { + bool in_use; /*!< Entry is active */ + uint16_t esl_addr; /*!< Assigned ESL Address (ESL_ID | Group_ID << 8) */ + uint8_t ble_addr[6]; /*!< BLE device address */ + uint8_t ble_addr_type; /*!< BLE address type */ + ble_esl_state_t state; /*!< Current tracked state */ + bool config_complete; /*!< All 4 mandatory chars written successfully */ + int64_t last_sync_time_us; /*!< Last successful PAwR exchange timestamp (us) */ + int64_t unsync_entry_time_us; /*!< Time ESL entered Unsynchronized state (us) */ + uint16_t conn_handle; /*!< Active connection handle (INVALID if not connected) */ + uint8_t pending_pawr_cmd_opcode; /*!< Last command opcode sent to this ESL via PAwR, for response correlation */ + + /* Key material for PAwR response decryption */ + ble_esl_key_material_t resp_key; /*!< ESL Response Key Material */ +} ble_esl_ap_esl_entry_t; + +/* ========================== PAwR Pending TX Buffer ========================== */ + +/** + * Maximum outer AD structure size for one PAwR subevent: + * 1 (len) + 1 (ED tag 0x31) + 5 (randomizer) + (2 + 48) (inner AD) + 4 (MIC) = 61 + * Rounded up to a generous buffer. + */ +#define BLE_ESL_AP_PAWR_MAX_AD_BUF_SIZE 80 + +/** + * @brief Pending subevent data for the PAwR pull model. + * + * PAwR subevent data may only be handed to the controller in response to a + * BLE_GAP_EVENT_PER_SUBEV_DATA_REQ event. ble_esl_ap_pawr_send() therefore does + * not push data directly; it encrypts the packet and stores it here, and the + * data-request callback drains it into the requested subevent. One entry per + * subevent (indexed by group_id); allocated by ble_esl_ap_pawr_start(). + */ +typedef struct { + uint8_t data[BLE_ESL_AP_PAWR_MAX_AD_BUF_SIZE]; /*!< Encrypted outer AD bytes */ + uint8_t len; /*!< Valid byte count in data (0 = none) */ + uint8_t repeats_left; /*!< Remaining PAwR events to transmit */ + bool valid; /*!< Slot holds a packet awaiting TX */ +} ble_esl_ap_pawr_pending_t; + +/* ========================== Module Global State ========================== */ + +/** + * @brief Top-level AP module state (allocated dynamically by init, freed by deinit) + */ +typedef struct { + bool initialized; /*!< Module has been initialized */ + bool started; /*!< Scanning + PAwR broadcasting active */ + + /* Application callback */ + ble_esl_ap_cb_t app_cb; + + /* PAwR configuration */ + ble_esl_ap_pawr_config_t pawr_config; + + /* Connection table */ + ble_esl_ap_conn_t conns[CONFIG_BLE_ESL_AP_MAX_CONNECTIONS]; + + /* ESL tracking table */ + ble_esl_ap_esl_entry_t esls[CONFIG_BLE_ESL_AP_MAX_ESLS]; + + /* PAwR broadcaster state */ + ble_esl_key_material_t ap_sync_key; /*!< AP Sync Key Material */ + uint8_t randomizer[BLE_ESL_RANDOMIZER_SIZE]; /*!< Current AP Randomizer (5 octets, LE) */ + bool pawr_active; /*!< PAwR broadcaster is running */ + ble_esl_ap_pawr_pending_t *pawr_pending; /*!< Per-subevent pending TX buffers (num_subevents entries) */ + + /* Timeout monitoring timer */ + esp_timer_handle_t timeout_timer; /*!< Periodic timer for 60-min checks */ +} ble_esl_ap_state_t; + +/** Global pointer to the AP module state (allocated by init, freed by deinit) */ +extern ble_esl_ap_state_t *g_esl_ap; + +/* ========================== Helpers ========================== */ + +/** + * @brief Find a connection context by connection handle + * + * @param conn_handle ACL connection handle + * @return Pointer to connection context, or NULL if not found + */ +ble_esl_ap_conn_t *ble_esl_ap_find_conn(uint16_t conn_handle); + +/** + * @brief Find an ESL tracking entry by ESL Address + * + * @param esl_addr ESL Address (ESL_ID | Group_ID << 8) + * @return Pointer to ESL entry, or NULL if not found + */ +ble_esl_ap_esl_entry_t *ble_esl_ap_find_esl(uint16_t esl_addr); + +/** + * @brief Find an ESL tracking entry by BLE address + * + * @param addr 6-byte BLE address + * @param addr_type Address type + * @return Pointer to ESL entry, or NULL if not found + */ +ble_esl_ap_esl_entry_t *ble_esl_ap_find_esl_by_ble_addr(const uint8_t *addr, + uint8_t addr_type); + +/** + * @brief Allocate a free connection context slot + * + * @return Pointer to free slot, or NULL if all slots in use + */ +ble_esl_ap_conn_t *ble_esl_ap_alloc_conn(void); + +/** + * @brief Free a connection context slot + * + * @param conn Pointer to connection context + */ +void ble_esl_ap_free_conn(ble_esl_ap_conn_t *conn); + +/** + * @brief Allocate a free ESL tracking entry + * + * @return Pointer to free entry, or NULL if table is full + */ +ble_esl_ap_esl_entry_t *ble_esl_ap_alloc_esl(void); + +/** + * @brief Check if a BLE address is in the associated-address list + * + * Used during scanning to determine Unassociated vs Unsynchronized. + * + * @param addr 6-byte BLE address + * @param addr_type Address type + * @return true if the address is tracked (associated), false otherwise + */ +bool ble_esl_ap_is_associated(const uint8_t *addr, uint8_t addr_type); + +/* ---- ap_gattc.c ---- */ + +/** + * @brief Initialize the GATT client sub-module (OTS client registration) + * + * Called from ble_esl_ap_init(). + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_gattc_init(void); + +/** + * @brief Deinitialize the GATT client sub-module + * + * Called from ble_esl_ap_deinit(). + */ +void ble_esl_ap_gattc_deinit(void); + +/** + * @brief Start primary service discovery on a newly encrypted connection + * + * Triggers the full discovery state machine: services → ESL characteristics → + * DIS characteristics → ECP CCCD → OTS → disc_complete. + * + * @param conn_handle ACL connection handle + */ +void ble_esl_ap_start_svc_discovery(uint16_t conn_handle); + +/** + * @brief Read a GATT characteristic value from a connected ESL + * + * Uses Read Long when value > (ATT_MTU − 1). Callback receives full value. + * + * @param conn_handle ACL connection handle + * @param char_uuid 16-bit UUID of the characteristic + * @param cb Completion callback + * @param user_data Opaque pointer forwarded to cb + * @return ESP_OK if read initiated; ESP_ERR_NOT_FOUND if char not discovered + */ +esp_err_t ble_esl_ap_gatt_read(uint16_t conn_handle, uint16_t char_uuid, + ble_esl_ap_gatt_cb_t cb, void *user_data); + +/** + * @brief Write a GATT characteristic value to a connected ESL + * + * Uses GATT Write Characteristic Value sub-procedure. + * + * @param conn_handle ACL connection handle + * @param char_uuid 16-bit UUID of the characteristic + * @param data Value to write + * @param data_len Length in octets + * @param cb Completion callback (data/data_len = NULL/0) + * @param user_data Opaque pointer forwarded to cb + * @return ESP_OK if write initiated; ESP_ERR_NOT_FOUND if char not discovered + */ +esp_err_t ble_esl_ap_gatt_write(uint16_t conn_handle, uint16_t char_uuid, + const uint8_t *data, uint16_t data_len, + ble_esl_ap_gatt_cb_t cb, void *user_data); + +/** + * @brief Write a TLV command to the ECP characteristic and wait for notification + * + * Only one outstanding ECP write per connection. Notification payload + * delivered via cb(data, data_len). 30s timeout managed by caller. + * + * @param conn_handle ACL connection handle + * @param tlv_data TLV-encoded command + * @param tlv_len TLV data length + * @param cb Notification callback + * @param user_data Opaque pointer forwarded to cb + * @return ESP_OK if write initiated; ESP_ERR_INVALID_STATE if ECP busy + */ +esp_err_t ble_esl_ap_ecp_write(uint16_t conn_handle, const uint8_t *tlv_data, + uint8_t tlv_len, ble_esl_ap_gatt_cb_t cb, + void *user_data); + +/* ---- ble_esl_ap_lifecycle.c ---- */ + +/** + * @brief Update per-ESL tracked state and fire BLE_ESL_AP_EVT_STATE_CHANGED + * + * @param esl_addr ESL Address (ESL_ID | Group_ID << 8) + * @param new_state New state to record + * @return ESP_OK on success; ESP_ERR_NOT_FOUND if ESL not tracked + */ +esp_err_t ble_esl_ap_update_esl_state(uint16_t esl_addr, + ble_esl_state_t new_state); + +/** + * @brief Initialize the lifecycle sub-module (timers, etc.) + * + * Called from ble_esl_ap_init(). + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_lifecycle_init(void); + +/** + * @brief Deinitialize the lifecycle sub-module + * + * Called from ble_esl_ap_deinit(). + */ +void ble_esl_ap_lifecycle_deinit(void); + +/** + * @brief Handle disconnect event for lifecycle tracking + * + * Called by connection module when an ESL disconnects, to complete + * any pending synchronize procedure and update tracked state. + * + * @param conn_handle Connection handle of the disconnected ESL + */ +void ble_esl_ap_lifecycle_handle_disconnect(uint16_t conn_handle); + +/** + * @brief Route OTS client events to lifecycle for image transfer tracking + * + * Must only be called from the OTS client callback context. Internally + * casts @p event to ble_ots_client_event_t. + * + * @param conn_id Connection identifier + * @param event OTS client event code (ble_ots_client_event_t) + * @param param Event-specific parameter + */ +void ble_esl_ap_lifecycle_handle_ots_event(uint16_t conn_id, + int event, + const void *param); + +/* ---- ap_pawr.c ---- */ + +/** + * @brief Initialize the PAwR sub-module + * + * Called from ble_esl_ap_init(). + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_pawr_init(void); + +/** + * @brief Deinitialize the PAwR sub-module + * + * Called from ble_esl_ap_deinit(). + */ +void ble_esl_ap_pawr_deinit(void); + +/** + * @brief Start PAwR broadcasting + * + * Configures and enables the PAwR broadcaster using stored pawr_config. + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_pawr_start(void); + +/** + * @brief Stop PAwR broadcasting + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_pawr_stop(void); + +/** + * @brief Set the AP Sync Key Material used for encrypting sync packets + * + * @param key_mat AP Sync Key Material (session key + IV) + */ +void ble_esl_ap_pawr_set_sync_key(const ble_esl_key_material_t *key_mat); + +/** + * @brief Store per-ESL Response Key Material for decrypting responses + * + * @param esl_addr ESL Address + * @param key_mat ESL Response Key Material (session key + IV) + * @return ESP_OK on success; ESP_ERR_NOT_FOUND if ESL not tracked + */ +esp_err_t ble_esl_ap_pawr_set_response_key(uint16_t esl_addr, + const ble_esl_key_material_t *key_mat); + +/** + * @brief Assemble and transmit an encrypted PAwR sync packet + * + * @param group_id Target Group_ID (0x00–0x7F) + * @param payload Pre-assembled ESL Payload (from ble_esl_payload_encode()) + * @param payload_len ESL Payload length (1–48) + * @return ESP_OK on success; error code on failure + */ +esp_err_t ble_esl_ap_pawr_send(uint8_t group_id, const uint8_t *payload, + uint8_t payload_len); + +/** + * @brief Decrypt and parse a PAwR response packet + * + * @param esl_addr ESL Address of the responding ESL + * @param response_slot Response slot number + * @param enc_data Raw Encrypted Data AD payload + * @param enc_data_len Length of enc_data + * @param out_response Output parsed response + * @return ESP_OK on success; ESP_ERR_INVALID_RESPONSE on MIC failure + */ +esp_err_t ble_esl_ap_pawr_parse_response(uint16_t esl_addr, + uint8_t response_slot, + const uint8_t *enc_data, + uint8_t enc_data_len, + ble_esl_ap_parsed_response_t *out_response); + +/* ---- ap_command.c ---- */ + +/** + * @brief Initialize the command sub-module + * + * Called from ble_esl_ap_init(). + * + * @return ESP_OK on success + */ +esp_err_t ble_esl_ap_command_init(void); + +/** + * @brief Deinitialize the command sub-module + * + * Called from ble_esl_ap_deinit(). + */ +void ble_esl_ap_command_deinit(void); + +/** + * @brief Handle an ECP notification received on a connection + * + * Called by the connection module when an ECP notification arrives. + * Routes to the pending ECP callback. + * + * @param conn_handle Connection handle + * @param data Notification payload (response TLV) + * @param data_len Payload length + */ +void ble_esl_ap_command_handle_ecp_notification(uint16_t conn_handle, + const uint8_t *data, + uint16_t data_len); + +/** + * @brief Clean up any pending ECP command context for a disconnected connection + * + * Finds the ECP command context associated with the given connection handle, + * stops its timer, and frees it. Called from the disconnect handler to prevent + * stale contexts and timer leaks. + * + * @param conn_handle Connection handle of the disconnected connection + */ +void ble_esl_ap_command_cleanup_conn(uint16_t conn_handle); + +/** + * @brief Handle a PAwR response slot data reception + * + * Called by the PAwR module when response data is received in a slot. + * + * @param group_id Group_ID (subevent number) + * @param response_slot Response slot number + * @param data Raw received data (Encrypted Data AD) + * @param data_len Data length + */ +void ble_esl_ap_command_handle_pawr_response(uint8_t group_id, + uint8_t response_slot, + const uint8_t *data, + uint8_t data_len); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_AP_INT_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/common/esl_common.c b/components/bt/ble_profiles/nimble/ble_esl/src/common/esl_common.c new file mode 100644 index 00000000000..7e1724f62dd --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/common/esl_common.c @@ -0,0 +1,361 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_common.c + * @brief Implementation of ESL common protocol TLV and payload encode/decode utilities + * + * Protocol layer shared by both roles: TLV / ESL Payload encode and decode, + * plus the Encrypted Advertising Data (AES-CCM) transmit and receive helpers + * used by the AP sync and ESL response PAwR paths. + */ + +#include +#include +#include "esp_log.h" +#include "esp_random.h" +#include "host/ble_aes_ccm.h" +#include "ble_esl_common.h" + +static const char *TAG = "ble_esl_common"; + +/** Bit position of the directionBit within the last Randomizer octet */ +#define ESL_EAD_DIRECTION_BIT 7 +/** Mask of the directionBit within the last Randomizer octet */ +#define ESL_EAD_DIRECTION_BIT_MASK (1U << ESL_EAD_DIRECTION_BIT) + +/** + * Additional authenticated data of the Encrypted Advertising Data AD type + * (Supplement to the Bluetooth Core Specification v11, Part A, 1.23.3) + */ +static const uint8_t s_ead_aad[] = {0xEA}; + +/* ========================== Forward Declarations ========================== */ + +static void esl_ead_increment_randomizer(uint8_t *randomizer); + +esp_err_t ble_esl_tlv_encode(uint8_t opcode, const uint8_t *params, + uint8_t params_len, uint8_t *out_buf, + uint8_t *out_len) +{ + /* Validate pointer arguments */ + if (params == NULL || out_buf == NULL || out_len == NULL) { + ESP_LOGE(TAG, "tlv_encode: NULL argument"); + return ESP_ERR_INVALID_ARG; + } + + /* Validate that params_len matches the Length nibble in the opcode */ + uint8_t expected_params_len = BLE_ESL_TLV_PARAMS_LEN(opcode); + if (params_len != expected_params_len) { + ESP_LOGE(TAG, "tlv_encode: params_len %u does not match opcode 0x%02X " + "(expected %u)", params_len, opcode, expected_params_len); + return ESP_ERR_INVALID_ARG; + } + + /* Write the opcode byte */ + out_buf[0] = opcode; + + /* Copy parameter bytes after the opcode */ + memcpy(&out_buf[1], params, params_len); + + /* Total TLV size = 1 (opcode) + params_len */ + *out_len = (uint8_t)(1 + params_len); + + return ESP_OK; +} + +esp_err_t ble_esl_tlv_decode(const uint8_t *in_buf, uint8_t in_len, + uint8_t *opcode, const uint8_t **params, + uint8_t *params_len) +{ + /* Validate pointer arguments */ + if (in_buf == NULL || opcode == NULL || params == NULL || params_len == NULL) { + ESP_LOGE(TAG, "tlv_decode: NULL argument"); + return ESP_ERR_INVALID_ARG; + } + + /* Need at least the minimum TLV size (opcode + 1 param byte) */ + if (in_len < BLE_ESL_TLV_MIN_SIZE) { + ESP_LOGE(TAG, "tlv_decode: buffer too short (%u < %u)", + in_len, BLE_ESL_TLV_MIN_SIZE); + return ESP_ERR_INVALID_SIZE; + } + + /* Extract the opcode */ + uint8_t opc = in_buf[0]; + uint8_t expected_total = BLE_ESL_TLV_TOTAL_LEN(opc); + + /* Verify the buffer contains enough data for the full TLV */ + if (in_len < expected_total) { + ESP_LOGE(TAG, "tlv_decode: buffer too short for TLV " + "(have %u, need %u for opcode 0x%02X)", + in_len, expected_total, opc); + return ESP_ERR_INVALID_SIZE; + } + + *opcode = opc; + *params = &in_buf[1]; + *params_len = BLE_ESL_TLV_PARAMS_LEN(opc); + + return ESP_OK; +} + +esp_err_t ble_esl_payload_encode(uint8_t group_id, const uint8_t *tlvs[], + const uint8_t tlv_lens[], uint8_t tlv_count, + uint8_t *out_buf, uint8_t *out_len) +{ + /* Validate pointer arguments */ + if (out_buf == NULL || out_len == NULL) { + ESP_LOGE(TAG, "payload_encode: NULL output argument"); + return ESP_ERR_INVALID_ARG; + } + + if (tlv_count == 0) { + ESP_LOGE(TAG, "payload_encode: tlv_count must be greater than 0"); + return ESP_ERR_INVALID_ARG; + } + + if (tlvs == NULL || tlv_lens == NULL) { + ESP_LOGE(TAG, "payload_encode: NULL TLV argument"); + return ESP_ERR_INVALID_ARG; + } + + /* Validate Group_ID range (7-bit) */ + if (group_id > BLE_ESL_GROUP_ID_MAX) { + ESP_LOGE(TAG, "payload_encode: group_id 0x%02X exceeds max 0x%02X", + group_id, BLE_ESL_GROUP_ID_MAX); + return ESP_ERR_INVALID_ARG; + } + + /* Calculate total payload size: 1 (Group_ID header) + sum of all TLV lengths */ + uint16_t total = 1; /* Group_ID header byte */ + for (uint8_t i = 0; i < tlv_count; i++) { + total += tlv_lens[i]; + } + + if (total > BLE_ESL_PAYLOAD_MAX_SIZE) { + ESP_LOGE(TAG, "payload_encode: total size %u exceeds max %u", + total, BLE_ESL_PAYLOAD_MAX_SIZE); + return ESP_ERR_INVALID_SIZE; + } + + /* Write Group_ID header byte: Group_ID in bits [6:0], RFU bit 7 = 0 */ + out_buf[0] = group_id & 0x7F; + + /* Concatenate all TLV buffers */ + uint8_t offset = 1; + for (uint8_t i = 0; i < tlv_count; i++) { + if (tlvs[i] == NULL) { + ESP_LOGE(TAG, "payload_encode: tlvs[%u] is NULL", i); + return ESP_ERR_INVALID_ARG; + } + if (tlv_lens[i] < BLE_ESL_TLV_MIN_SIZE || tlv_lens[i] > BLE_ESL_TLV_MAX_SIZE) { + ESP_LOGE(TAG, "payload_encode: tlv_lens[%u] = %u out of valid range [%u, %u]", + i, tlv_lens[i], BLE_ESL_TLV_MIN_SIZE, BLE_ESL_TLV_MAX_SIZE); + return ESP_ERR_INVALID_ARG; + } + memcpy(&out_buf[offset], tlvs[i], tlv_lens[i]); + offset += tlv_lens[i]; + } + + *out_len = offset; + + return ESP_OK; +} + +esp_err_t ble_esl_payload_decode(const uint8_t *in_buf, uint8_t in_len, + uint8_t *group_id, uint8_t *tlv_count, + uint8_t *tlv_offsets, uint8_t *tlv_lens, + uint8_t max_tlvs) +{ + /* Validate pointer arguments */ + if (in_buf == NULL || group_id == NULL || tlv_count == NULL || + tlv_offsets == NULL || tlv_lens == NULL) { + ESP_LOGE(TAG, "payload_decode: NULL argument"); + return ESP_ERR_INVALID_ARG; + } + + /* Need at least 1 byte for Group_ID header + minimum TLV (2 bytes) */ + if (in_len < 1 + BLE_ESL_TLV_MIN_SIZE) { + ESP_LOGE(TAG, "payload_decode: payload too short (%u bytes)", in_len); + return ESP_ERR_INVALID_SIZE; + } + + /* Extract Group_ID (bits [6:0] of first byte) */ + *group_id = in_buf[0] & 0x7F; + + /* Parse concatenated TLVs starting after the Group_ID header */ + uint8_t offset = 1; + uint8_t count = 0; + + while (offset < in_len) { + /* Need at least a minimum-size TLV in the remaining buffer */ + if ((uint16_t)offset + BLE_ESL_TLV_MIN_SIZE > in_len) { + break; + } + + uint8_t opc = in_buf[offset]; + uint8_t tlv_total = BLE_ESL_TLV_TOTAL_LEN(opc); + + /* Verify the TLV fits within the remaining buffer */ + if ((uint16_t)offset + tlv_total > in_len) { + ESP_LOGE(TAG, "payload_decode: malformed TLV at offset %u " + "(need %u bytes, have %u remaining)", + offset, tlv_total, in_len - offset); + return ESP_ERR_INVALID_SIZE; + } + + /* Check if we have room to report this TLV */ + if (count >= max_tlvs) { + ESP_LOGE(TAG, "payload_decode: found more TLVs than max_tlvs (%u)", + max_tlvs); + *tlv_count = count; + return ESP_ERR_NO_MEM; + } + + /* Record this TLV's offset and length */ + tlv_offsets[count] = offset; + tlv_lens[count] = tlv_total; + count++; + + offset += tlv_total; + } + + /* If no TLVs were found, the payload is malformed */ + if (count == 0) { + ESP_LOGE(TAG, "payload_decode: no TLVs found in payload"); + return ESP_ERR_INVALID_SIZE; + } + + *tlv_count = count; + + return ESP_OK; +} + +void ble_esl_ead_randomizer_init(uint8_t *randomizer, + ble_esl_ead_direction_t direction) +{ + assert(randomizer != NULL); + + esp_fill_random(randomizer, BLE_ESL_RANDOMIZER_SIZE); + + uint8_t *last = &randomizer[BLE_ESL_RANDOMIZER_SIZE - 1]; + if (direction == BLE_ESL_EAD_DIR_ESL_TO_AP) { + *last |= ESL_EAD_DIRECTION_BIT_MASK; + } else { + *last &= (uint8_t)~ESL_EAD_DIRECTION_BIT_MASK; + } +} + +esp_err_t ble_esl_ead_encrypt(const uint8_t *session_key, const uint8_t *iv, + uint8_t *randomizer, const uint8_t *payload, + size_t payload_len, uint8_t *encrypted_payload) +{ + /* Validate arguments */ + if (session_key == NULL || iv == NULL || randomizer == NULL || + payload == NULL || payload_len == 0 || encrypted_payload == NULL) { + ESP_LOGE(TAG, "ead_encrypt: invalid argument"); + return ESP_ERR_INVALID_ARG; + } + + /* + * Nonce is the concatenation of Randomizer and IV. The Randomizer is also + * transmitted in clear ahead of the ciphertext so the peer can rebuild the + * same nonce. + */ + uint8_t nonce[BLE_ESL_CCM_NONCE_SIZE]; + + memcpy(nonce, randomizer, BLE_ESL_RANDOMIZER_SIZE); + memcpy(&nonce[BLE_ESL_RANDOMIZER_SIZE], iv, BLE_ESL_IV_SIZE); + memcpy(encrypted_payload, randomizer, BLE_ESL_RANDOMIZER_SIZE); + + int rc = ble_aes_ccm_encrypt(session_key, nonce, payload, payload_len, + s_ead_aad, sizeof(s_ead_aad), + &encrypted_payload[BLE_ESL_RANDOMIZER_SIZE], + BLE_ESL_MIC_SIZE); + if (rc != 0) { + ESP_LOGE(TAG, "ead_encrypt: AES-CCM failed: %d", rc); + return ESP_FAIL; + } + + esl_ead_increment_randomizer(randomizer); + + return ESP_OK; +} + +esp_err_t ble_esl_ead_decrypt(const uint8_t *session_key, const uint8_t *iv, + const uint8_t *encrypted_payload, + size_t encrypted_payload_len, uint8_t *payload, + size_t payload_size, size_t *payload_len) +{ + /* Validate arguments */ + if (session_key == NULL || iv == NULL || encrypted_payload == NULL || + payload == NULL || payload_len == NULL) { + ESP_LOGE(TAG, "ead_decrypt: invalid argument"); + return ESP_ERR_INVALID_ARG; + } + + /* + * The plaintext must hold at least one AD structure, so the encrypted + * payload carries at least one ciphertext octet on top of the Randomizer + * and the MIC (CSS v11, Part A, 1.23.2). + */ + if (encrypted_payload_len < BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE + 1) { + ESP_LOGE(TAG, "ead_decrypt: encrypted payload too short: %u", + (unsigned)encrypted_payload_len); + return ESP_ERR_INVALID_SIZE; + } + + size_t plaintext_len = encrypted_payload_len - + (BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE); + if (plaintext_len > payload_size) { + ESP_LOGE(TAG, "ead_decrypt: output buffer too small (%u < %u)", + (unsigned)payload_size, (unsigned)plaintext_len); + return ESP_ERR_INVALID_SIZE; + } + + /* + * Rebuild the nonce from the Randomizer sent in clear ahead of the + * ciphertext and the locally stored IV. + */ + uint8_t nonce[BLE_ESL_CCM_NONCE_SIZE]; + + memcpy(nonce, encrypted_payload, BLE_ESL_RANDOMIZER_SIZE); + memcpy(&nonce[BLE_ESL_RANDOMIZER_SIZE], iv, BLE_ESL_IV_SIZE); + + int rc = ble_aes_ccm_decrypt(session_key, nonce, + &encrypted_payload[BLE_ESL_RANDOMIZER_SIZE], + plaintext_len, s_ead_aad, sizeof(s_ead_aad), + payload, BLE_ESL_MIC_SIZE); + if (rc != 0) { + ESP_LOGW(TAG, "ead_decrypt: MIC authentication failed: %d", rc); + return ESP_ERR_INVALID_RESPONSE; + } + + *payload_len = plaintext_len; + + return ESP_OK; +} + +static void esl_ead_increment_randomizer(uint8_t *randomizer) +{ + uint8_t direction_bit = randomizer[BLE_ESL_RANDOMIZER_SIZE - 1] & + ESL_EAD_DIRECTION_BIT_MASK; + + for (uint8_t i = 0; i < BLE_ESL_RANDOMIZER_SIZE; i++) { + if (i == BLE_ESL_RANDOMIZER_SIZE - 1) { + /* Last octet: 7-bit counter, directionBit restored */ + uint8_t counter = (uint8_t)((randomizer[i] + 1) & 0x7F); + randomizer[i] = (uint8_t)(counter | direction_bit); + break; + } + + randomizer[i]++; + if (randomizer[i] != 0) { + break; /* No carry into the next octet */ + } + } +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_int.h b/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_int.h new file mode 100644 index 00000000000..c93055b0d11 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_int.h @@ -0,0 +1,473 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_int.h + * @brief ESL component internal header — shared interfaces across source files + * + * Not part of the public API. Groups internal declarations by source file. + */ + +#ifndef BLE_ESL_INT_H +#define BLE_ESL_INT_H + +#include +#include +#include "esp_err.h" +#include "ble_esl.h" +#include "ble_esl_common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== Constants ========================== */ + +/** @brief Sync/Unsync state timeout: 60 minutes in microseconds (for esp_timer) */ +#define ESL_STATE_TIMEOUT_US (60ULL * 60ULL * 1000000ULL) + +/** @brief ECP Procedure Timeout in microseconds */ +#define ESL_ECP_TIMEOUT_US (30ULL * 1000000ULL) + +/** @brief Maximum number of TLVs in a single ESL Payload */ +#define ESL_MAX_TLVS_PER_PAYLOAD 24 + +/** @brief Sensor transport context: ECP */ +#define ESL_TRANSPORT_ECP 0 + +/** @brief Sensor transport context: PAwR */ +#define ESL_TRANSPORT_PAWR 1 + +/* ---- esl_state.c ---- */ + +/** + * @brief Return the active ACL connection handle + * @return Connection handle, or BLE_HS_CONN_HANDLE_NONE if not connected + */ +uint16_t esl_state_get_conn_handle(void); + +/** + * @brief Check whether the ESL is synchronized to the AP's PAwR train + * + * Reflects the actual synchronization condition rather than the state machine + * state: the sync is established on entry to Synchronized and retained while in + * the Updating state (where PAwR data is ignored but sync is not lost). It is + * cleared on Unsynchronized, Configuring and Unassociated. + * + * @return true if synchronized to the AP + */ +bool esl_is_pawr_synced(void); + +/** + * @brief Execute a state transition with associated side-effects + * + * Validates legality, changes GAP mode, arms/disarms timeouts, deletes data + * on unassociate/factory-reset, and fires BLE_ESL_EVT_STATE_CHANGED callback. + * + * @param[in] new_state Target state + * @return ESP_OK on success; ESP_ERR_INVALID_STATE if transition not permitted + */ +esp_err_t esl_state_transition(ble_esl_state_t new_state); + +/** + * @brief Get the current ESL absolute time in milliseconds + * + * The time counter is set via the ESL Current Absolute Time characteristic + * and increments by 1 every millisecond, wrapping at 0xFFFFFFFF. + * + * @return Current absolute time in milliseconds + */ +uint32_t esl_get_abs_time(void); + +/** + * @brief Fire an ESL event to the application callback + * + * @param[in] event Event type + * @param[in] param Event data (may be NULL) + */ +void esl_notify_app(ble_esl_event_t event, ble_esl_cb_param_t *param); + +/** + * @brief Get the ESL's configured address + * + * @param[out] esl_id ESL_ID (0x00–0xFE) + * @param[out] group_id Group_ID (7-bit) + * @return ESP_OK if address is configured; ESP_ERR_INVALID_STATE if not + */ +esp_err_t esl_get_address(uint8_t *esl_id, uint8_t *group_id); + +/** + * @brief Get the ESL's stored configuration + * + * @return Pointer to the stored ble_esl_config_t (valid while initialized) + */ +const ble_esl_config_t *esl_get_config(void); + +/** + * @brief Get the stored AP Sync Key Material + * + * @return Pointer to 24-byte key material, or NULL if not configured + */ +const ble_esl_key_material_t *esl_get_ap_sync_key(void); + +/** + * @brief Get the stored ESL Response Key Material + * + * @return Pointer to 24-byte key material, or NULL if not configured + */ +const ble_esl_key_material_t *esl_get_resp_key(void); + +/** + * @brief Clear all stored configuration data (keys, address, timed commands) + * + * Called during unassociate, factory reset, and unsync timeout transitions. + * + * @param[in] clear_images If true, also clear stored image data (factory reset only) + */ +void esl_clear_stored_data(bool clear_images); + +/** + * @brief Notify the state machine that the Update Complete command was received + * + * In the Updating state, both PAST and Update Complete are required before + * transitioning to Synchronized. This function records that Update Complete + * has been received and triggers the transition if PAST was already received. + */ +void esl_notify_update_complete(void); + +/* ---- esl_gatts.c ---- */ +/** + * @brief Send one response TLV as an ECP characteristic notification + * + * @param[in] response_tlv Pointer to the encoded response TLV buffer + * @param[in] response_len Length of the response TLV in bytes + * @return ESP_OK on success; ESP_FAIL if notification could not be sent + */ +esp_err_t esl_send_ecp_response(const uint8_t *response_tlv, uint8_t response_len); + +/** + * @brief Handle completion of an ECP notification transmission + * + * Used by the GAP event handler to run any command cleanup that must happen + * after the ECP response notification has actually been transmitted. + */ +void esl_handle_ecp_notify_tx(uint16_t conn_handle, uint16_t attr_handle, + int status, uint8_t indication); + +/* ---- esl_cmd_lifecycle.c ---- */ + +/** + * @brief Result of command dispatch + */ +typedef struct { + bool has_response; /*!< True if command produces a response TLV */ + uint8_t resp_opcode; /*!< Response opcode byte */ + uint8_t resp_params[BLE_ESL_TLV_MAX_SIZE]; /*!< Response parameter bytes */ + uint8_t resp_params_len; /*!< Length of resp_params in octets */ +} ble_esl_cmd_result_t; + +/** + * @brief Central command dispatch function + * + * Validates ESL_ID, checks for unrecognized opcodes, and routes to the + * appropriate per-opcode handler. Used by both ECP and PAwR paths. + * + * @param[in] opcode Command opcode byte + * @param[in] params Pointer to command parameter bytes (first byte is ESL_ID) + * @param[in] params_len Length of params in octets + * @param[out] result Output: populated with the response + * @param[in] transport Transport context: ESL_TRANSPORT_ECP or ESL_TRANSPORT_PAWR + * @return ESP_OK if the command was processed — including rejections that carry + * an Error response in @p result; ESP_ERR_INVALID_STATE if the module is + * not initialized + */ +esp_err_t esl_cmd_dispatch(uint8_t opcode, const uint8_t *params, + uint8_t params_len, ble_esl_cmd_result_t *result, + uint8_t transport); + +/** + * @brief Assemble the 16-bit Basic State response bitmap + * + * Queries Service Needed, Synchronized, Active LED, Pending LED Update, + * and Pending Display Update flags from respective modules. + * + * @param[out] bitmap_out The 16-bit Basic State bitmap + * @return ESP_OK on success + */ +esp_err_t esl_build_basic_state(uint16_t *bitmap_out); + +/** + * @brief Check if factory reset is pending + * @return true if factory reset guard flag is set + */ +bool esl_is_factory_reset_pending(void); + +/** + * @brief Clear the factory reset pending flag + * + * Called after factory reset cleanup is complete (e.g., after clearing stored + * data and transitioning to Unassociated state in the disconnect handler). + */ +void esl_clear_factory_reset_pending(void); + +/** + * @brief Initialize the lifecycle command module + * @return ESP_OK on success + */ +esp_err_t esl_cmd_lifecycle_init(void); + +/** + * @brief Deinitialize the lifecycle command module + */ +void esl_cmd_lifecycle_deinit(void); + +/* ---- esl_cmd_display.c ---- */ + +/** + * @brief Handle Read Sensor Data command (opcode 0x10) + * + * @param[in] params Command parameters (after opcode) + * @param[in] params_len Length of params + * @param[out] result Command result + * @param[in] transport ESL_TRANSPORT_ECP or ESL_TRANSPORT_PAWR + * @return ESP_OK on success + */ +esp_err_t esl_cmd_read_sensor(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result, uint8_t transport); + +/** + * @brief Handle Refresh Display command (opcode 0x11) + * + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_refresh_display(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/** + * @brief Handle Display Image command (opcode 0x20) + * + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_display_image(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/** + * @brief Handle Display Timed Image command (opcode 0x60) + * + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_display_timed_image(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/** + * @brief Get the Pending Display Update flag for Basic State bitmap + * @return true if at least one display has a pending timed image command + */ +bool esl_get_display_pending(void); + +/** + * @brief Initialize the display/sensor command module + * @return ESP_OK on success + */ +esp_err_t esl_cmd_display_init(void); + +/** + * @brief Deinitialize the display/sensor command module + */ +void esl_cmd_display_deinit(void); + +/** + * @brief Cancel all pending timed display commands + * + * Called during unassociate / factory reset. + */ +void esl_cmd_display_cancel_all(void); + +/* ---- esl_cmd_led.c ---- */ + +/** + * @brief LED status flags for Basic State bitmap + */ +typedef struct { + bool active_led; /*!< True if any LED is currently active */ + bool pending_led_update; /*!< True if any LED timed command is pending */ +} esl_led_status_t; + +/** + * @brief Handle LED Control command (opcode 0xB0) + * + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_led_control(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/** + * @brief Handle LED Timed Control command (opcode 0xF0) + * + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_led_timed_control(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/** + * @brief Handle Vendor-specific command (opcodes 0x_F) + * + * @param[in] opcode Full opcode byte + * @param[in] params Command parameters + * @param[in] params_len Length of params + * @param[out] result Command result + * @return ESP_OK on success + */ +esp_err_t esl_cmd_vendor_specific(uint8_t opcode, const uint8_t *params, + uint8_t params_len, ble_esl_cmd_result_t *result); + +/** + * @brief Get Active LED and Pending LED Update flags + * @return esl_led_status_t with current flags + */ +esl_led_status_t esl_get_led_status(void); + +/** + * @brief Initialize the LED command module + * @return ESP_OK on success + */ +esp_err_t esl_cmd_led_init(void); + +/** + * @brief Deinitialize the LED command module + */ +void esl_cmd_led_deinit(void); + +/** + * @brief Cancel all pending timed LED commands + * + * Called during unassociate / factory reset. + */ +void esl_cmd_led_cancel_all(void); + +/* ---- esl_pawr.c ---- */ + +/** + * @brief Slot info tracked during PAwR packet processing + */ +typedef struct { + uint8_t slot_number; /*!< PAwR response slot */ + uint8_t tlv_count; /*!< Number of individually-addressed TLVs */ + uint8_t broadcast_count; /*!< Number of broadcast TLVs */ +} esl_pawr_slot_info_t; + +/** + * @brief Process a received PAwR sync packet + * + * Strips the inner AD wrapper ([len][0x34]), decodes the ESL Payload, + * validates Group_ID, dispatches commands, calculates response slot, + * assembles and encrypts response, and transmits. + * + * @param[in] subevent PAwR subevent number + * @param[in] data Pointer to the decrypted plaintext, i.e. the inner AD + * structure [inner_len][ESL Tag 0x34][ESL Payload] + * @param[in] data_len Length of the decrypted plaintext + * @return ESP_OK on success, error code on failure + */ +esp_err_t esl_pawr_process_sync_packet(uint8_t subevent, const uint8_t *data, + uint8_t data_len); + +/** + * @brief Calculate the response slot for this ESL + * + * @param[in] data Pointer to the decrypted ESL Payload buffer + * @param[in] tlv_offsets Array of offsets for each command TLV + * @param[in] tlv_lens Array of lengths for each command TLV + * @param[in] tlv_count Total number of command TLVs + * @param[in] esl_id This ESL's ESL_ID + * @return Response slot number (>= 0), or -1 if not individually addressed + */ +int16_t esl_pawr_calc_response_slot(const uint8_t *data, + const uint8_t *tlv_offsets, + const uint8_t *tlv_lens, + uint8_t tlv_count, uint8_t esl_id); + +/** + * @brief Assemble response TLVs into a single ESL Payload + * + * @param[in] resp_tlvs Array of pointers to encoded response TLV buffers + * @param[in] resp_tlv_lens Array of response TLV lengths + * @param[in] resp_count Number of response TLVs + * @param[out] out_buf Output buffer (min 48 octets) + * @param[out] out_len Total payload size written + * @return ESP_OK on success; ESP_ERR_INVALID_SIZE if payload exceeds 48 octets + */ +esp_err_t esl_pawr_assemble_response(const uint8_t *resp_tlvs[], + const uint8_t resp_tlv_lens[], + uint8_t resp_count, + uint8_t *out_buf, uint8_t *out_len); + +/** + * @brief Encrypt and send response in the assigned PAwR response slot + * + * @param[in] slot Response slot number + * @param[in] payload Assembled response ESL Payload + * @param[in] payload_len Length of the payload + * @return ESP_OK on success, error code on failure + */ +esp_err_t esl_pawr_encrypt_and_send(uint8_t slot, const uint8_t *payload, + uint8_t payload_len); + +/** + * @brief Check if an Unassociate post-response cleanup is pending + * @return true if Unassociate cleanup needs to run after ECP response sent + */ +bool esl_is_unassociate_pending(void); + +/** + * @brief Execute deferred Unassociate cleanup after ECP response is sent + * + * Clears stored data, transitions to Unassociated, fires UNASSOCIATE event. + */ +void esl_execute_unassociate_cleanup(void); + +/** + * @brief Set PAwR sync context before processing a sync packet + * + * Must be called from the PAwR sync-received event handler before + * calling esl_pawr_process_sync_packet(). + * + * @param[in] sync_handle PAwR sync handle + * @param[in] event_counter PAwR event counter + */ +void esl_pawr_set_sync_context(uint16_t sync_handle, uint16_t event_counter); + +/** + * @brief Initialize the PAwR module + * @return ESP_OK on success + */ +esp_err_t esl_pawr_init(void); + +/** + * @brief Deinitialize the PAwR module + */ +void esl_pawr_deinit(void); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_INT_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_state_int.h b/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_state_int.h new file mode 100644 index 00000000000..92f2fdda63b --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/ble_esl_state_int.h @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file ble_esl_state_int.h + * @brief ESL component internal header — shared interfaces across source files + * + * Not part of the public API. Groups internal declarations by source file. + */ + +#ifndef BLE_ESL_STATE_INT_H +#define BLE_ESL_STATE_INT_H + +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "esp_err.h" +#include "esp_timer.h" +#include "nimble/ble.h" +#include "ble_esl.h" +#include "ble_esl_common.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ========================== Configuration Bitmask ========================== */ + +#define CONFIG_BIT_ADDRESS (1 << 0) +#define CONFIG_BIT_AP_SYNC_KEY (1 << 1) +#define CONFIG_BIT_RESP_KEY (1 << 2) +#define CONFIG_BIT_ABS_TIME (1 << 3) +#define CONFIG_COMPLETE_MASK (CONFIG_BIT_ADDRESS | CONFIG_BIT_AP_SYNC_KEY | \ + CONFIG_BIT_RESP_KEY | CONFIG_BIT_ABS_TIME) + +/* ========================== Internal State Context ========================== */ +typedef struct { + ble_esl_state_t state; /* Current ESL state */ + ble_esl_config_t config; /* Copy of init config */ + ble_esl_address_t esl_address; /* Stored ESL address */ + ble_esl_key_material_t ap_sync_key; /* AP Sync Key Material */ + ble_esl_key_material_t resp_key; /* ESL Response Key Material */ + uint32_t abs_time_base; /* Written absolute time value */ + int64_t abs_time_offset_us; /* esp_timer_get_time() at write */ + uint8_t config_complete; /* 4-bit bitmask */ + uint16_t conn_handle; /* Active ACL connection handle */ + ble_esl_cb_t app_cb; /* Application callback */ + bool started; /* Whether ble_esl_start() called */ + bool initialized; /* Whether ble_esl_init() called */ + esp_timer_handle_t sync_timer; /* 60-min sync timeout */ + esp_timer_handle_t unsync_timer; /* 60-min unsync timeout */ + esp_timer_handle_t ecp_timer; /* 30-sec ECP procedure timeout */ + uint16_t ecp_val_handle; /* ECP characteristic value handle */ + ble_addr_t bonded_peer_addr; /* Address of bonded AP */ + bool has_bonded_peer; /* Whether we have a bonded peer */ + bool ap_sync_key_valid; /* AP sync key written */ + bool resp_key_valid; /* Response key written */ + bool address_valid; /* ESL address written */ + uint16_t pawr_sync_handle; /* Active PAwR periodic sync handle; BLE_HS_CONN_HANDLE_NONE if none */ + bool past_received; /* PAST completed in Updating state */ + bool pawr_synced; /* Synchronized to the AP's PAwR train (retained across Updating) */ + bool past_pending; /* PAST re-arm deferred until SYNC_LOST frees pool slot */ + bool update_complete_received; /* Update Complete cmd received in Updating state */ + bool deinit_pending; /* ble_esl_deinit() is waiting for disconnect */ + SemaphoreHandle_t deinit_sem; /* Signaled when disconnect completes during deinit */ +} esl_state_ctx_t; + +/* ---- esl_state.c ---- */ + +/** + * @brief Return the singleton ESL state context + * @return Pointer to the global esl_state_ctx_t instance + */ +esl_state_ctx_t *esl_state_ctx_get(void); + +/* ---- esl_gatts.c ---- */ + +/** + * @brief Initialize the GATT server and register the ESL Service + * + * @param[in] config Pointer to the user-supplied ESL configuration + * @return ESP_OK on success + */ +esp_err_t esl_gatts_init(const ble_esl_config_t *config); + +/** + * @brief Deregister the ESL Service and free GATT server resources + * @return ESP_OK on success + */ +esp_err_t esl_gatts_deinit(void); + +#ifdef __cplusplus +} +#endif + +#endif /* BLE_ESL_STATE_INT_H */ diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_display.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_display.c new file mode 100644 index 00000000000..4e7028e905b --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_display.c @@ -0,0 +1,904 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_cmd_display.c + * @brief ESL Display & Sensor command handlers + * + * Implements Read Sensor Data, Refresh Display, Display Image, and + * Display Timed Image command processing, including timed command + * scheduling, sensor read deferral, and Pending Display Update flag + * management. + */ + +#include +#include +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "ble_esl_int.h" + +static const char *TAG = "esl_cmd_display"; + +/* ========================== Internal Data Structures ========================== */ + +/** @brief Per-display internal state */ +typedef struct { + bool has_active_image; /*!< Whether an image is currently displayed */ + uint8_t current_image_index; /*!< Image index currently displayed (valid if has_active_image) */ + bool timed_pending; /*!< Whether a timed display command is pending */ + uint8_t timed_image_index; /*!< Image index for the pending timed command */ + uint32_t timed_absolute_time; /*!< Absolute time for the pending timed command */ + esp_timer_handle_t timer; /*!< Timer handle for timed display commands */ +} display_state_t; + +/** @brief Maximum sensor data length (ESL spec: up to 15 bytes) */ +#define SENSOR_DATA_CACHE_MAX_LEN 15 + +/** @brief Per-sensor internal state */ +typedef struct { + bool read_pending; /*!< Whether a sensor read is in progress */ + uint8_t transport_context; /*!< ESL_TRANSPORT_ECP or ESL_TRANSPORT_PAWR */ + bool cache_valid; /*!< Whether cached sensor data is available (PAwR) */ + uint8_t cache_error_code; /*!< Cached error code (0 = success) */ + uint8_t cache_data[SENSOR_DATA_CACHE_MAX_LEN]; /*!< Cached sensor data */ + uint8_t cache_data_len; /*!< Length of cached sensor data */ +} sensor_state_t; + +/** @brief Aggregated display/sensor module context */ +typedef struct { + display_state_t *displays; /*!< Array of per-display state [num_displays] */ + sensor_state_t *sensors; /*!< Array of per-sensor state [num_sensors] */ + uint8_t num_displays; /*!< Number of displays from config */ + uint8_t num_images; /*!< Number of images from config */ + uint8_t num_sensors; /*!< Number of sensors from config */ +} display_ctx_t; + +static display_ctx_t *s_ctx = NULL; + +/** @brief File-scope mutex protecting s_ctx and all shared display/sensor state. + * Decoupled from s_ctx lifetime so the lock remains valid even after s_ctx is freed. */ +static SemaphoreHandle_t s_lock = NULL; + +/* ========================== Forward Declarations ========================== */ + +static void timed_display_timer_cb(void *arg); +static void build_error_response(ble_esl_cmd_result_t *result, uint8_t error_code); +static void build_display_state_response(ble_esl_cmd_result_t *result, + uint8_t display_index, uint8_t image_index); +static bool is_image_available(uint8_t image_index); +static void restore_sensor_pending(uint8_t sensor_index, uint8_t transport); + +/* ========================== Helper Functions ========================== */ + +/** + * @brief Build an Error response (opcode 0x00) into the result structure + */ +static void build_error_response(ble_esl_cmd_result_t *result, uint8_t error_code) +{ + result->has_response = true; + result->resp_opcode = BLE_ESL_RESP_ERROR; + result->resp_params[0] = error_code; + result->resp_params_len = 1; +} + +/** + * @brief Build a Display State response (opcode 0x11) into the result structure + */ +static void build_display_state_response(ble_esl_cmd_result_t *result, + uint8_t display_index, uint8_t image_index) +{ + result->has_response = true; + result->resp_opcode = BLE_ESL_RESP_DISPLAY_STATE; + result->resp_params[0] = display_index; + result->resp_params[1] = image_index; + result->resp_params_len = 2; +} + +/** + * @brief Check if an image slot contains valid data + * + * An image is considered available if: + * - The image_writable_mask is NULL: all images are read-only (static/pre-loaded) + * and therefore always available. + * - The image_writable_mask entry is false: the image is read-only (protected) + * and therefore always available. + * - The image_writable_mask entry is true: the image is writable and assumed + * to have been written by the AP. + * + * In other words, any valid image index within range is considered available. + */ +static bool is_image_available(uint8_t image_index) +{ + const ble_esl_config_t *config = esl_get_config(); + if (config == NULL) { + return false; + } + if (image_index >= config->num_images) { + return false; + } + /* All valid image indices are considered available: + * - Read-only images (no writable mask, or mask[i] == false) are + * static/pre-loaded and always available. + * - Writable images (mask[i] == true) are assumed written by the AP. */ + return true; +} + +/** + * @brief Restore sensor pending state after a failed response send + * + * Re-acquires the lock, validates that the context and sensor index are + * still valid, and restores read_pending and transport_context so the + * application can retry calling ble_esl_report_sensor_data(). + * + * @param[in] sensor_index Index of the sensor to restore + * @param[in] transport Original transport context to restore + */ +static void restore_sensor_pending(uint8_t sensor_index, uint8_t transport) +{ + xSemaphoreTake(s_lock, portMAX_DELAY); + if (s_ctx != NULL && sensor_index < s_ctx->num_sensors) { + s_ctx->sensors[sensor_index].read_pending = true; + s_ctx->sensors[sensor_index].transport_context = transport; + } + xSemaphoreGive(s_lock); +} + +/* ========================== Timer Callback ========================== */ + +/** + * @brief Timer callback for Display Timed Image execution + * + * Runs in timer task context — keeps work minimal: updates state and + * fires the application callback. + */ +static void timed_display_timer_cb(void *arg) +{ + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + /* Context was freed while we waited for the lock or before — nothing to do. */ + xSemaphoreGive(s_lock); + return; + } + + /* arg encodes the display index */ + uint8_t display_index = (uint8_t)(uintptr_t)arg; + if (display_index >= s_ctx->num_displays) { + xSemaphoreGive(s_lock); + return; + } + + display_state_t *disp = &s_ctx->displays[display_index]; + if (!disp->timed_pending) { + xSemaphoreGive(s_lock); + return; + } + + /* Update per-display current image tracking */ + disp->has_active_image = true; + disp->current_image_index = disp->timed_image_index; + uint8_t image_index = disp->current_image_index; + + /* Clear pending state */ + disp->timed_pending = false; + + xSemaphoreGive(s_lock); + + /* Fire BLE_ESL_EVT_DISPLAY_IMAGE callback outside the lock to avoid + * potential deadlocks in application code that may call back into ESL. */ + ble_esl_cb_param_t param; + memset(¶m, 0, sizeof(param)); + param.display_image.display_index = display_index; + param.display_image.image_index = image_index; + esl_notify_app(BLE_ESL_EVT_DISPLAY_IMAGE, ¶m); + + ESP_LOGI(TAG, "Timed display executed: display=%u image=%u", + display_index, image_index); +} + +/* ========================== Init / Deinit ========================== */ + +esp_err_t esl_cmd_display_init(void) +{ + if (s_ctx != NULL) { + ESP_LOGW(TAG, "Already initialized"); + return ESP_ERR_INVALID_STATE; + } + + const ble_esl_config_t *config = esl_get_config(); + if (config == NULL) { + ESP_LOGE(TAG, "Config not available"); + return ESP_ERR_INVALID_STATE; + } + + /* Create the file-scope lock once (survives init/deinit cycles) */ + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) { + ESP_LOGE(TAG, "Failed to create display module mutex"); + return ESP_ERR_NO_MEM; + } + } + + s_ctx = calloc(1, sizeof(display_ctx_t)); + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Failed to allocate display context"); + return ESP_ERR_NO_MEM; + } + + s_ctx->num_displays = config->num_displays; + s_ctx->num_images = config->num_images; + s_ctx->num_sensors = config->num_sensors; + + /* Allocate per-display state */ + if (s_ctx->num_displays > 0) { + s_ctx->displays = calloc(s_ctx->num_displays, sizeof(display_state_t)); + if (s_ctx->displays == NULL) { + ESP_LOGE(TAG, "Failed to allocate display state array"); + free(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + + /* Create esp_timers for each display slot */ + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + esp_timer_create_args_t timer_args = { + .callback = timed_display_timer_cb, + .arg = (void *)(uintptr_t)i, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_disp_timed", + }; + esp_err_t ret = esp_timer_create(&timer_args, &s_ctx->displays[i].timer); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create timer for display %u: %s", i, esp_err_to_name(ret)); + /* Clean up previously created timers */ + for (uint8_t j = 0; j < i; j++) { + esp_timer_delete(s_ctx->displays[j].timer); + } + free(s_ctx->displays); + free(s_ctx); + s_ctx = NULL; + return ret; + } + } + } + + /* Allocate per-sensor state */ + if (s_ctx->num_sensors > 0) { + s_ctx->sensors = calloc(s_ctx->num_sensors, sizeof(sensor_state_t)); + if (s_ctx->sensors == NULL) { + ESP_LOGE(TAG, "Failed to allocate sensor state array"); + /* Clean up display timers */ + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + esp_timer_delete(s_ctx->displays[i].timer); + } + free(s_ctx->displays); + free(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + } + + ESP_LOGI(TAG, "Display/sensor module initialized: displays=%u images=%u sensors=%u", + s_ctx->num_displays, s_ctx->num_images, s_ctx->num_sensors); + return ESP_OK; +} + +void esl_cmd_display_deinit(void) +{ + if (s_lock == NULL) { + return; + } + + /* Acquire the file-scope lock before touching any s_ctx field so that the + * timer stop/delete operations and the teardown of the state arrays are + * serialised against concurrent callers (e.g., esl_get_display_pending, + * esl_cmd_display_cancel_all, esl_cmd_display_timed_image) which also + * operate on the same timer handles under this lock. + * Holding the lock across esp_timer_delete() is safe: for ESP_TIMER_TASK + * dispatch the delete is processed by the timer task, so it does not wait + * inline for timed_display_timer_cb() (which itself takes s_lock). */ + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + xSemaphoreGive(s_lock); + return; + } + + /* Stop all display timers first to prevent new callback invocations. + * esp_timer_stop only removes the timer from the armed list; an + * already-dispatched callback may still be running — it will observe + * s_ctx == NULL once it acquires the lock we hold here. */ + if (s_ctx->displays != NULL) { + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + if (s_ctx->displays[i].timer != NULL) { + esp_timer_stop(s_ctx->displays[i].timer); + } + } + } + + /* Delete all display timers. */ + if (s_ctx->displays != NULL) { + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + if (s_ctx->displays[i].timer != NULL) { + esp_timer_delete(s_ctx->displays[i].timer); + s_ctx->displays[i].timer = NULL; + } + } + } + + /* Free display state */ + if (s_ctx->displays != NULL) { + free(s_ctx->displays); + } + + /* Free sensor state */ + if (s_ctx->sensors != NULL) { + free(s_ctx->sensors); + } + + free(s_ctx); + s_ctx = NULL; + + xSemaphoreGive(s_lock); + + /* s_lock is intentionally NOT deleted — it is a file-scope static that + * survives init/deinit cycles so that late callers safely see s_ctx == NULL + * under the lock instead of accessing a deleted semaphore. */ + + ESP_LOGI(TAG, "Display/sensor module deinitialized"); +} + +/* ========================== Command Handlers ========================== */ + +/** + * @brief Consume cached PAwR sensor data and build the ESL response. + * + * Caller must hold s_lock. If valid cached data is present it is consumed + * (the cache is cleared) and either a Sensor Value or an Error response is + * built into @p result. + * + * @return true if cached data was consumed and a response was built. + */ +static bool serve_cached_sensor_locked(sensor_state_t *sensor, + uint8_t sensor_index, + ble_esl_cmd_result_t *result) +{ + if (!sensor->cache_valid) { + return false; + } + + uint8_t cached_error = sensor->cache_error_code; + uint8_t cached_data_len = sensor->cache_data_len; + + /* Clear the cache */ + sensor->cache_valid = false; + + if (cached_error == 0) { + /* Build Sensor Value response */ + uint8_t resp_opcode = (uint8_t)((cached_data_len << 4) | BLE_ESL_RESP_SENSOR_VALUE_TAG); + result->has_response = true; + result->resp_opcode = resp_opcode; + result->resp_params[0] = sensor_index; + memcpy(&result->resp_params[1], sensor->cache_data, cached_data_len); + result->resp_params_len = cached_data_len + 1; + } else { + /* Build Error response with cached error code */ + build_error_response(result, cached_error); + } + + return true; +} + +esp_err_t esl_cmd_read_sensor(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result, uint8_t transport) +{ + assert(params != NULL); + assert(result != NULL); + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Module not initialized"); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + /* params[0] = Sensor_Index */ + uint8_t sensor_index = params[0]; + + /* Validate Sensor_Index */ + if (sensor_index >= s_ctx->num_sensors) { + ESP_LOGW(TAG, "Invalid sensor index: %u (max %u)", sensor_index, s_ctx->num_sensors); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + sensor_state_t *sensor = &s_ctx->sensors[sensor_index]; + + /* For PAwR transport, if cached sensor data from a previous read is + * available, return it directly instead of issuing another Retry. */ + if (transport == ESL_TRANSPORT_PAWR && sensor->cache_valid) { + uint8_t cached_error = sensor->cache_error_code; + serve_cached_sensor_locked(sensor, sensor_index, result); + xSemaphoreGive(s_lock); + + ESP_LOGI(TAG, "Sensor read served from cache (PAwR): index=%u error=0x%02x", + sensor_index, cached_error); + return ESP_OK; + } + + /* Mark sensor read as pending with transport context */ + sensor->read_pending = true; + sensor->transport_context = transport; + + xSemaphoreGive(s_lock); + + /* Fire BLE_ESL_EVT_SENSOR_READ callback outside the lock */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + cb_param.sensor_read.sensor_index = sensor_index; + esl_notify_app(BLE_ESL_EVT_SENSOR_READ, &cb_param); + + if (transport == ESL_TRANSPORT_PAWR) { + /* If the app reported the sensor data synchronously from within the + * callback above, the cache is now valid — serve the Sensor Value in + * this same response and avoid forcing the AP through a Retry + + * re-poll round trip. */ + xSemaphoreTake(s_lock, portMAX_DELAY); + /* Re-validate sensor_index: the module may have been deinitialised and + * re-initialised with fewer sensors while the lock was released. */ + bool served = (s_ctx != NULL) && (sensor_index < s_ctx->num_sensors) && + serve_cached_sensor_locked(&s_ctx->sensors[sensor_index], + sensor_index, result); + xSemaphoreGive(s_lock); + + if (served) { + ESP_LOGI(TAG, "Sensor read served synchronously (PAwR): index=%u", + sensor_index); + } else { + /* The read is still pending (app will report it later). Respond + * with Retry so the AP re-issues the command in a subsequent + * subevent, at which point the cached value is returned. */ + build_error_response(result, BLE_ESL_ERR_RETRY); + ESP_LOGI(TAG, "Sensor read deferred (PAwR): index=%u, responding Retry", + sensor_index); + } + } else { + /* ECP transport: response is deferred — will be sent when app + * calls ble_esl_report_sensor_data() */ + result->has_response = false; + ESP_LOGI(TAG, "Sensor read requested: index=%u transport=ECP", sensor_index); + } + + return ESP_OK; +} + +esp_err_t esl_cmd_refresh_display(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + assert(params != NULL); + assert(result != NULL); + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Module not initialized"); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + /* params[0] = Display_Index */ + uint8_t display_index = params[0]; + + /* Validate Display_Index */ + if (display_index >= s_ctx->num_displays) { + ESP_LOGW(TAG, "Invalid display index: %u (max %u)", display_index, s_ctx->num_displays); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + /* Check that an image is currently being displayed */ + display_state_t *disp = &s_ctx->displays[display_index]; + if (!disp->has_active_image) { + ESP_LOGW(TAG, "No active image on display %u", display_index); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_IMAGE_NOT_AVAILABLE); + return ESP_OK; + } + + /* Save image index to local before releasing lock to avoid data race */ + uint8_t image_index = disp->current_image_index; + + /* Build Display State response */ + build_display_state_response(result, display_index, image_index); + + xSemaphoreGive(s_lock); + + /* Fire BLE_ESL_EVT_REFRESH_DISPLAY callback outside the lock */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + cb_param.refresh_display.display_index = display_index; + esl_notify_app(BLE_ESL_EVT_REFRESH_DISPLAY, &cb_param); + + ESP_LOGI(TAG, "Refresh display: index=%u image=%u", display_index, image_index); + return ESP_OK; +} + +esp_err_t esl_cmd_display_image(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + assert(params != NULL); + assert(result != NULL); + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Module not initialized"); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + /* params[0] = Display_Index, params[1] = Image_Index */ + uint8_t display_index = params[0]; + uint8_t image_index = params[1]; + + /* Validate Display_Index */ + if (display_index >= s_ctx->num_displays) { + ESP_LOGW(TAG, "Invalid display index: %u (max %u)", display_index, s_ctx->num_displays); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + /* Validate Image_Index range */ + if (image_index >= s_ctx->num_images) { + ESP_LOGW(TAG, "Invalid image index: %u (max %u)", image_index, s_ctx->num_images); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_IMAGE_INDEX); + return ESP_OK; + } + + /* Check image availability */ + if (!is_image_available(image_index)) { + ESP_LOGW(TAG, "Image not available: index=%u", image_index); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_IMAGE_NOT_AVAILABLE); + return ESP_OK; + } + + /* Update internal state */ + display_state_t *disp = &s_ctx->displays[display_index]; + disp->has_active_image = true; + disp->current_image_index = image_index; + + /* Build Display State response */ + build_display_state_response(result, display_index, image_index); + + xSemaphoreGive(s_lock); + + /* Fire BLE_ESL_EVT_DISPLAY_IMAGE callback outside the lock */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + cb_param.display_image.display_index = display_index; + cb_param.display_image.image_index = image_index; + esl_notify_app(BLE_ESL_EVT_DISPLAY_IMAGE, &cb_param); + + ESP_LOGI(TAG, "Display image: display=%u image=%u", display_index, image_index); + return ESP_OK; +} + +esp_err_t esl_cmd_display_timed_image(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + assert(params != NULL); + assert(result != NULL); + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Module not initialized"); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + /* params[0] = Display_Index, params[1] = Image_Index, params[2..5] = Absolute_Time (LE) */ + uint8_t display_index = params[0]; + uint8_t image_index = params[1]; + uint32_t absolute_time = (uint32_t)params[2] | + ((uint32_t)params[3] << 8) | + ((uint32_t)params[4] << 16) | + ((uint32_t)params[5] << 24); + + /* Validate Display_Index */ + if (display_index >= s_ctx->num_displays) { + ESP_LOGW(TAG, "Invalid display index: %u (max %u)", display_index, s_ctx->num_displays); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + /* Validate Image_Index range */ + if (image_index >= s_ctx->num_images) { + ESP_LOGW(TAG, "Invalid image index: %u (max %u)", image_index, s_ctx->num_images); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_INVALID_IMAGE_INDEX); + return ESP_OK; + } + + /* Check image availability */ + if (!is_image_available(image_index)) { + ESP_LOGW(TAG, "Image not available: index=%u", image_index); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_IMAGE_NOT_AVAILABLE); + return ESP_OK; + } + + display_state_t *disp = &s_ctx->displays[display_index]; + + /* Handle Absolute_Time = 0 (cancellation) */ + if (absolute_time == 0x00000000) { + ESP_LOGI(TAG, "Cancelling timed display for display %u", display_index); + if (disp->timed_pending) { + esp_timer_stop(disp->timer); + disp->timed_pending = false; + } + /* Send Display State response with command's Display_Index and Image_Index */ + build_display_state_response(result, display_index, image_index); + xSemaphoreGive(s_lock); + return ESP_OK; + } + + /* Validate Absolute_Time plausibility */ + uint32_t current_time = esl_get_abs_time(); + uint32_t delta; + if (absolute_time >= current_time) { + delta = absolute_time - current_time; + } else { + /* Wrap-around: time until wrap + time after wrap */ + delta = (0xFFFFFFFF - current_time) + absolute_time + 1; + } + + if (delta > BLE_ESL_IMPLAUSIBLE_TIME_MS) { + ESP_LOGW(TAG, "Implausible absolute time: target=%lu current=%lu delta=%lu", + (unsigned long)absolute_time, (unsigned long)current_time, (unsigned long)delta); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_IMPLAUSIBLE_ABS_TIME); + return ESP_OK; + } + + /* Handle pending timed command conflict */ + if (disp->timed_pending) { + if (absolute_time == disp->timed_absolute_time) { + /* Same time → replace the pending command (update Image_Index) */ + ESP_LOGI(TAG, "Replacing timed display for display %u: new image=%u", + display_index, image_index); + disp->timed_image_index = image_index; + /* Timer is already running for the same time, no need to restart */ + build_display_state_response(result, display_index, image_index); + xSemaphoreGive(s_lock); + return ESP_OK; + } else { + /* Different time → Queue Full error */ + ESP_LOGW(TAG, "Queue full for display %u: pending_time=%lu new_time=%lu", + display_index, (unsigned long)disp->timed_absolute_time, + (unsigned long)absolute_time); + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_QUEUE_FULL); + return ESP_OK; + } + } + + /* Schedule timed execution */ + disp->timed_pending = true; + disp->timed_image_index = image_index; + disp->timed_absolute_time = absolute_time; + + /* Convert delta from ms to us for esp_timer */ + uint64_t timeout_us = (uint64_t)delta * 1000ULL; + if (timeout_us == 0) { + /* If delta is 0 (target time is now), use a minimal timeout */ + timeout_us = 1; + } + + esp_err_t ret = esp_timer_start_once(disp->timer, timeout_us); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start timed display timer: %s", esp_err_to_name(ret)); + disp->timed_pending = false; + xSemaphoreGive(s_lock); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + ESP_LOGI(TAG, "Timed display scheduled: display=%u image=%u time=%lu delta=%lu ms", + display_index, image_index, (unsigned long)absolute_time, (unsigned long)delta); + + /* Send Display State response immediately */ + build_display_state_response(result, display_index, image_index); + xSemaphoreGive(s_lock); + return ESP_OK; +} + +/* ========================== Pending Display Update Flag ========================== */ + +bool esl_get_display_pending(void) +{ + if (s_lock == NULL) { + return false; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL || s_ctx->displays == NULL) { + xSemaphoreGive(s_lock); + return false; + } + + bool pending = false; + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + if (s_ctx->displays[i].timed_pending) { + pending = true; + break; + } + } + + xSemaphoreGive(s_lock); + return pending; +} + +/* ========================== Cancel All Timed Display Commands ========================== */ + +void esl_cmd_display_cancel_all(void) +{ + if (s_lock == NULL) { + return; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL || s_ctx->displays == NULL) { + xSemaphoreGive(s_lock); + return; + } + + for (uint8_t i = 0; i < s_ctx->num_displays; i++) { + if (s_ctx->displays[i].timed_pending) { + esp_timer_stop(s_ctx->displays[i].timer); + s_ctx->displays[i].timed_pending = false; + ESP_LOGI(TAG, "Cancelled timed display for display %u", i); + } + } + + xSemaphoreGive(s_lock); +} + +/* ========================== Sensor Data Report (Public API) ========================== */ + +esp_err_t ble_esl_report_sensor_data(uint8_t sensor_index, uint8_t error_code, + const uint8_t *data, uint8_t data_len) +{ + xSemaphoreTake(s_lock, portMAX_DELAY); + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Module not initialized"); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + /* Validate sensor_index */ + if (sensor_index >= s_ctx->num_sensors) { + ESP_LOGE(TAG, "Invalid sensor index: %u", sensor_index); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_ARG; + } + + /* Check that a read is pending for this sensor */ + sensor_state_t *sensor = &s_ctx->sensors[sensor_index]; + if (!sensor->read_pending) { + ESP_LOGE(TAG, "No sensor read pending for index %u", sensor_index); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_STATE; + } + + /* Validate data parameters when error_code is 0 (success) */ + if (error_code == 0) { + if (data == NULL) { + ESP_LOGE(TAG, "Sensor data pointer is NULL"); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_ARG; + } + if (data_len == 0 || data_len > SENSOR_DATA_CACHE_MAX_LEN) { + ESP_LOGE(TAG, "Invalid sensor data length: %u (must be 1-%u)", + data_len, SENSOR_DATA_CACHE_MAX_LEN); + xSemaphoreGive(s_lock); + return ESP_ERR_INVALID_ARG; + } + } + + uint8_t transport = sensor->transport_context; + + /* Clear pending state */ + sensor->read_pending = false; + + if (transport == ESL_TRANSPORT_PAWR) { + /* PAwR transport: cache the sensor data so that when the AP + * re-issues the Read Sensor Data command in a subsequent PAwR + * subevent, the cached data can be returned directly instead + * of another Retry. */ + sensor->cache_valid = true; + sensor->cache_error_code = error_code; + if (error_code == 0) { + memcpy(sensor->cache_data, data, data_len); + sensor->cache_data_len = data_len; + } else { + sensor->cache_data_len = 0; + } + + xSemaphoreGive(s_lock); + + ESP_LOGI(TAG, "PAwR sensor data cached (index=%u error=0x%02x), awaiting AP re-poll", + sensor_index, error_code); + return ESP_OK; + } + + xSemaphoreGive(s_lock); + + /* ECP transport: build and send the response immediately */ + uint8_t tlv_buf[BLE_ESL_TLV_MAX_SIZE]; + uint8_t tlv_len = 0; + + if (error_code == 0) { + /* Build Sensor Value response: + * Opcode = (data_len << 4) | 0x0E + * Params = [sensor_index, data[0], data[1], ..., data[data_len-1]] + */ + uint8_t resp_opcode = (uint8_t)((data_len << 4) | BLE_ESL_RESP_SENSOR_VALUE_TAG); + uint8_t resp_params[16]; /* max 1 + 15 = 16 */ + resp_params[0] = sensor_index; + memcpy(&resp_params[1], data, data_len); + uint8_t resp_params_len = data_len + 1; + + esp_err_t ret = ble_esl_tlv_encode(resp_opcode, resp_params, resp_params_len, + tlv_buf, &tlv_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to encode sensor value TLV: %s", esp_err_to_name(ret)); + restore_sensor_pending(sensor_index, transport); + return ret; + } + + ESP_LOGI(TAG, "Sensor value response: index=%u data_len=%u", sensor_index, data_len); + } else { + /* Build Error response: opcode 0x00, params = [error_code] */ + uint8_t resp_params[1] = { error_code }; + esp_err_t ret = ble_esl_tlv_encode(BLE_ESL_RESP_ERROR, resp_params, 1, + tlv_buf, &tlv_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to encode error TLV: %s", esp_err_to_name(ret)); + restore_sensor_pending(sensor_index, transport); + return ret; + } + + ESP_LOGW(TAG, "Sensor error response: index=%u error=0x%02x", sensor_index, error_code); + } + + /* Send response via ECP */ + esp_err_t ret = esl_send_ecp_response(tlv_buf, tlv_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to send ECP sensor response: %s", esp_err_to_name(ret)); + restore_sensor_pending(sensor_index, transport); + return ret; + } + + return ESP_OK; +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_led.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_led.c new file mode 100644 index 00000000000..87f770224a2 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_led.c @@ -0,0 +1,850 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_cmd_led.c + * @brief ESL LED Control, LED Timed Control, and Vendor-Specific command handlers + * + * Implements: LED Control (opcode 0xB0), LED Timed Control (opcode 0xF0), + * Vendor-specific tag (opcodes 0x_F), LED status query for Basic State bitmap, + * pattern expiry via esp_timer, and timed LED scheduling. + */ + +#include +#include +#include "esp_log.h" +#include "esp_timer.h" +#include "ble_esl.h" +#include "ble_esl_common.h" +#include "ble_esl_int.h" + +static const char *TAG = "esl_cmd_led"; + +/* ========================================================================== */ +/* Per-LED Internal State */ +/* ========================================================================== */ + +/** @brief Stored LED control settings for timed commands */ +typedef struct { + ble_esl_led_control_t settings; /*!< Raw LED control settings from command */ + uint32_t absolute_time; /*!< Target absolute time for execution */ +} esl_led_timed_params_t; + +/** @brief Per-LED state tracking */ +typedef struct { + bool active; /*!< LED is currently executing a command */ + bool timed_pending; /*!< A timed LED command is pending */ + esl_led_timed_params_t timed_params;/*!< Stored timed command parameters */ + ble_esl_led_control_evt_param_t current_params; /*!< Current active LED parameters */ + esp_timer_handle_t pattern_timer; /*!< Timer for pattern expiry */ + esp_timer_handle_t timed_timer; /*!< Timer for timed command execution */ +} esl_led_state_t; + +/* ========================================================================== */ +/* Module Context */ +/* ========================================================================== */ + +/** @brief Aggregate internal state for the LED command module */ +typedef struct { + esl_led_state_t leds[CONFIG_BLE_ESL_MAX_LEDS]; /*!< Per-LED state array */ + bool initialized; /*!< Module initialized flag */ +} esl_cmd_led_ctx_t; + +static esl_cmd_led_ctx_t *s_ctx = NULL; + +/* ========================================================================== */ +/* Forward Declarations */ +/* ========================================================================== */ + +static void pattern_expiry_timer_cb(void *arg); +static void timed_led_timer_cb(void *arg); +static void build_led_state_response(uint8_t led_index, ble_esl_cmd_result_t *result); +static void build_error_response(uint8_t error_code, ble_esl_cmd_result_t *result); +static void parse_led_control_settings(uint8_t led_index, + const ble_esl_led_control_t *settings, + ble_esl_led_control_evt_param_t *out_params); +static void apply_led_command(uint8_t led_index, const ble_esl_led_control_evt_param_t *params); +static uint64_t compute_pattern_duration_us(const ble_esl_led_control_evt_param_t *params); + +/* ========================================================================== */ +/* Helper: Build LED State Response */ +/* ========================================================================== */ + +/** + * @brief Build an LED State response (opcode 0x01) into the result struct + * + * @param led_index LED index to include in the response + * @param result Output result + */ +static void build_led_state_response(uint8_t led_index, ble_esl_cmd_result_t *result) +{ + result->has_response = true; + result->resp_opcode = BLE_ESL_RESP_LED_STATE; + result->resp_params[0] = led_index; + result->resp_params_len = 1; +} + +/* ========================================================================== */ +/* Helper: Build Error Response */ +/* ========================================================================== */ + +/** + * @brief Build an Error response (opcode 0x00) into the result struct + * + * @param error_code ESL error code + * @param result Output result + */ +static void build_error_response(uint8_t error_code, ble_esl_cmd_result_t *result) +{ + result->has_response = true; + result->resp_opcode = BLE_ESL_RESP_ERROR; + result->resp_params[0] = error_code; + result->resp_params_len = 1; +} + +/* ========================================================================== */ +/* Helper: Parse LED Control Settings */ +/* ========================================================================== */ + +/** + * @brief Parse raw LED control settings into the application-facing params struct + * + * @param led_index LED index + * @param settings Raw LED control settings from the command + * @param out_params Output parsed parameters + */ +static void parse_led_control_settings(uint8_t led_index, + const ble_esl_led_control_t *settings, + ble_esl_led_control_evt_param_t *out_params) +{ + memset(out_params, 0, sizeof(*out_params)); + + out_params->led_index = led_index; + + /* Parse color and brightness from color_brightness byte */ + uint8_t cb = settings->color_brightness; + out_params->color_red = BLE_ESL_LED_CTRL_RED(cb); + out_params->color_green = BLE_ESL_LED_CTRL_GREEN(cb); + out_params->color_blue = BLE_ESL_LED_CTRL_BLUE(cb); + out_params->brightness = BLE_ESL_LED_CTRL_BRIGHTNESS(cb); + + /* Parse flashing pattern: 40-bit pattern stored in 5 bytes (little-endian) */ + uint64_t pattern = 0; + for (int i = 0; i < 5; i++) { + pattern |= (uint64_t)settings->flashing.pattern[i] << (8 * i); + } + out_params->pattern = pattern; + + out_params->bit_off_period = settings->flashing.bit_off_period; + out_params->bit_on_period = settings->flashing.bit_on_period; + + /* Parse repeat field */ + uint16_t repeat = settings->repeat; + out_params->repeat_type = BLE_ESL_LED_CTRL_REPEAT_TYPE(repeat); + out_params->repeats_duration = BLE_ESL_LED_CTRL_REPEATS_DURATION(repeat); + + out_params->is_off = false; +} + +/* ========================================================================== */ +/* Helper: Compute Pattern Duration in Microseconds */ +/* ========================================================================== */ + +/** + * @brief Compute the total duration of the LED pattern for timer arming + * + * For repeat_type=0 (count): estimates total time based on pattern bits and repeat count. + * For repeat_type=1 (duration): uses the duration in seconds directly. + * + * @param params Parsed LED control parameters + * @return Duration in microseconds, or 0 if continuous/off + */ +static uint64_t compute_pattern_duration_us(const ble_esl_led_control_evt_param_t *params) +{ + if (params->repeats_duration == 0) { + /* Special cases: continuous on or off — no finite duration */ + return 0; + } + + if (params->repeat_type == 1) { + /* Duration mode: repeats_duration is in 1-second increments */ + return (uint64_t)params->repeats_duration * 1000000ULL; + } + + /* Count mode: estimate total time from pattern bits and periods */ + /* Find the meaningful pattern length (from first '1' bit to last bit) */ + uint64_t pat = params->pattern; + if (pat == 0) { + /* No pattern bits set — treat as immediate off after count */ + return 0; + } + + /* Count meaningful bits: from MSB first '1' to LSB */ + int first_one = -1; + for (int i = 39; i >= 0; i--) { + if (pat & (1ULL << i)) { + first_one = i; + break; + } + } + + if (first_one < 0) { + return 0; + } + + /* Count on-bits and off-bits in the meaningful range */ + int on_bits = 0; + int off_bits = 0; + for (int i = first_one; i >= 0; i--) { + if (pat & (1ULL << i)) { + on_bits++; + } else { + off_bits++; + } + } + + /* Time per pattern cycle in ms */ + uint32_t cycle_ms = (uint32_t)on_bits * (uint32_t)params->bit_on_period * 2 + + (uint32_t)off_bits * (uint32_t)params->bit_off_period * 2; + + /* Total time = cycle_ms * repeats_duration (count) */ + uint64_t total_ms = (uint64_t)cycle_ms * (uint64_t)params->repeats_duration; + + return total_ms * 1000ULL; /* Convert to microseconds */ +} + +/* ========================================================================== */ +/* Helper: Apply LED Command */ +/* ========================================================================== */ + +/** + * @brief Apply an LED command: update state, fire callback, arm pattern timer + * + * @param led_index LED index + * @param params Parsed LED control parameters + */ +static void apply_led_command(uint8_t led_index, const ble_esl_led_control_evt_param_t *params) +{ + assert(led_index < CONFIG_BLE_ESL_MAX_LEDS); + esl_led_state_t *led = &s_ctx->leds[led_index]; + + /* Stop any running pattern expiry timer (superseding) */ + esp_timer_stop(led->pattern_timer); + + /* Store current parameters */ + memcpy(&led->current_params, params, sizeof(ble_esl_led_control_evt_param_t)); + + /* Determine special cases based on repeat_type and repeats_duration */ + if (params->repeat_type == 0 && params->repeats_duration == 0) { + /* Continuous off: LED should be turned off */ + led->active = false; + led->current_params.is_off = true; + + /* Fire callback with is_off = true */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + cb_param.led_control.is_off = true; + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); + + ESP_LOGI(TAG, "LED %u: turned off (repeat_type=0, duration=0)", led_index); + return; + } + + if (params->repeat_type == 1 && params->repeats_duration == 0) { + /* Continuous on: LED illuminated steadily, no pattern timer */ + led->active = true; + + /* Fire callback — pattern is ignored, steady illumination */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); + + ESP_LOGI(TAG, "LED %u: continuous on (repeat_type=1, duration=0)", led_index); + return; + } + + /* Normal case: finite pattern with count or duration */ + + /* Compute pattern duration before committing state */ + uint64_t duration_us = compute_pattern_duration_us(params); + + if (duration_us == 0) { + /* Zero duration (e.g. all-zero pattern): treat as immediate off */ + led->active = false; + led->current_params.is_off = true; + + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + cb_param.led_control.is_off = true; + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); + + ESP_LOGI(TAG, "LED %u: zero-duration pattern, immediately off", led_index); + return; + } + + led->active = true; + + /* Fire callback with LED parameters */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); + + /* Arm pattern expiry timer */ + { + esp_err_t ret = esp_timer_start_once(led->pattern_timer, duration_us); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "LED %u: failed to start pattern timer: %s", + led_index, esp_err_to_name(ret)); + + /* Roll back: mark LED inactive and notify app to turn off */ + led->active = false; + led->current_params.is_off = true; + + ble_esl_cb_param_t off_param; + memset(&off_param, 0, sizeof(off_param)); + memcpy(&off_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + off_param.led_control.is_off = true; + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &off_param); + } else { + ESP_LOGI(TAG, "LED %u: pattern timer armed for %" PRIu64 " us", + led_index, duration_us); + } + } +} + +/* ========================================================================== */ +/* Pattern Expiry Timer Callback */ +/* ========================================================================== */ + +/** + * @brief esp_timer callback for pattern expiry + * + * Sets the LED inactive, fires BLE_ESL_EVT_LED_CONTROL with is_off=true, + * and updates the active flag. + * + * @param arg LED index (cast from uintptr_t) + */ +static void pattern_expiry_timer_cb(void *arg) +{ + uint8_t led_index = (uint8_t)(uintptr_t)arg; + + if (s_ctx == NULL) { + return; + } + + if (led_index >= CONFIG_BLE_ESL_MAX_LEDS) { + ESP_LOGE(TAG, "Pattern expiry callback: invalid led_index %u", led_index); + return; + } + + esl_led_state_t *led = &s_ctx->leds[led_index]; + + /* Mark LED as inactive */ + led->active = false; + + ESP_LOGI(TAG, "LED %u: pattern expired, turning off", led_index); + + /* Fire callback with is_off = true */ + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + cb_param.led_control.is_off = true; + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); +} + +/* ========================================================================== */ +/* Timed LED Timer Callback */ +/* ========================================================================== */ + +/** + * @brief esp_timer callback for timed LED command execution + * + * When the scheduled Absolute_Time arrives, parses the stored parameters, + * applies the LED state, fires the callback, and arms the pattern expiry + * timer if the duration is finite. + * + * @param arg LED index (cast from uintptr_t) + */ +static void timed_led_timer_cb(void *arg) +{ + uint8_t led_index = (uint8_t)(uintptr_t)arg; + + if (s_ctx == NULL) { + return; + } + + if (led_index >= CONFIG_BLE_ESL_MAX_LEDS) { + ESP_LOGE(TAG, "Timed LED callback: invalid led_index %u", led_index); + return; + } + + esl_led_state_t *led = &s_ctx->leds[led_index]; + + if (!led->timed_pending) { + ESP_LOGW(TAG, "Timed LED callback: no pending command for LED %u", led_index); + return; + } + + /* Clear pending state */ + led->timed_pending = false; + + ESP_LOGI(TAG, "Timed LED executed: LED %u", led_index); + + /* Parse the stored settings */ + ble_esl_led_control_evt_param_t params; + parse_led_control_settings(led_index, &led->timed_params.settings, ¶ms); + + /* Apply the LED command (handles special cases, fires callback, arms pattern timer) */ + apply_led_command(led_index, ¶ms); +} + +/* ========================================================================== */ +/* Command Handler: LED Control (opcode 0xB0) */ +/* ========================================================================== */ + +esp_err_t esl_cmd_led_control(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + assert(s_ctx != NULL); + assert(params != NULL); + assert(result != NULL); + + memset(result, 0, sizeof(*result)); + + /* + * LED Control command parameters (after ESL_ID, which is already stripped + * by the dispatch layer): + * [LED_Index(1), LED_Control_Settings(10)] + * LED_Control_Settings = color_brightness(1) + flashing_pattern(7) + repeat(2) + * Total params_len expected: 1 + 10 = 11 + */ + if (params_len < 1 + (uint8_t)sizeof(ble_esl_led_control_t)) { + ESP_LOGE(TAG, "LED Control: insufficient params (len=%u, need %u)", + params_len, (unsigned)(1 + sizeof(ble_esl_led_control_t))); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + + uint8_t led_index = params[0]; + + /* Validate LED_Index */ + const ble_esl_config_t *config = esl_get_config(); + if (config == NULL) { + ESP_LOGE(TAG, "LED Control: config not available"); + build_error_response(BLE_ESL_ERR_UNSPECIFIED, result); + return ESP_OK; + } + + if (led_index >= CONFIG_BLE_ESL_MAX_LEDS || led_index >= config->num_leds) { + ESP_LOGW(TAG, "LED Control: invalid led_index %u (max=%d, num_leds=%u)", + led_index, CONFIG_BLE_ESL_MAX_LEDS, config->num_leds); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + + /* Parse LED Control Settings */ + const ble_esl_led_control_t *settings = + (const ble_esl_led_control_t *)¶ms[1]; + + /* Parse into application-facing params */ + ble_esl_led_control_evt_param_t led_params; + parse_led_control_settings(led_index, settings, &led_params); + + /* Validate Bit_On_Period / Bit_Off_Period when flashing is required */ + if (led_params.repeats_duration > 0) { + if (led_params.bit_on_period == 0 || led_params.bit_off_period == 0) { + ESP_LOGW(TAG, "LED Control: invalid bit_on/off_period (on=%u, off=%u) " + "with repeats_duration=%u", + led_params.bit_on_period, led_params.bit_off_period, + led_params.repeats_duration); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + } + + /* Apply the LED command */ + apply_led_command(led_index, &led_params); + + /* Build LED State response */ + build_led_state_response(led_index, result); + + ESP_LOGI(TAG, "LED Control: led=%u, R=%u G=%u B=%u, bright=%u, " + "repeat_type=%u, duration=%u", + led_index, led_params.color_red, led_params.color_green, + led_params.color_blue, led_params.brightness, + led_params.repeat_type, led_params.repeats_duration); + + return ESP_OK; +} + +/* ========================================================================== */ +/* Command Handler: LED Timed Control (opcode 0xF0) */ +/* ========================================================================== */ + +esp_err_t esl_cmd_led_timed_control(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + assert(s_ctx != NULL); + assert(params != NULL); + assert(result != NULL); + + memset(result, 0, sizeof(*result)); + + /* + * LED Timed Control command parameters (after ESL_ID): + * [LED_Index(1), LED_Control_Settings(10), Absolute_Time(4 LE)] + * Total params_len expected: 1 + 10 + 4 = 15 + */ + uint8_t expected_len = 1 + (uint8_t)sizeof(ble_esl_led_control_t) + 4; + if (params_len < expected_len) { + ESP_LOGE(TAG, "LED Timed Control: insufficient params (len=%u, need %u)", + params_len, expected_len); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + + uint8_t led_index = params[0]; + + /* Validate LED_Index */ + const ble_esl_config_t *config = esl_get_config(); + if (config == NULL) { + ESP_LOGE(TAG, "LED Timed Control: config not available"); + build_error_response(BLE_ESL_ERR_UNSPECIFIED, result); + return ESP_OK; + } + + if (led_index >= CONFIG_BLE_ESL_MAX_LEDS || led_index >= config->num_leds) { + ESP_LOGW(TAG, "LED Timed Control: invalid led_index %u (max=%d, num_leds=%u)", + led_index, CONFIG_BLE_ESL_MAX_LEDS, config->num_leds); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + + /* Parse LED Control Settings */ + const ble_esl_led_control_t *settings = + (const ble_esl_led_control_t *)¶ms[1]; + + /* Validate Bit_On_Period / Bit_Off_Period when flashing is required */ + { + ble_esl_led_control_evt_param_t tmp_params; + parse_led_control_settings(led_index, settings, &tmp_params); + if (tmp_params.repeats_duration > 0) { + if (tmp_params.bit_on_period == 0 || tmp_params.bit_off_period == 0) { + ESP_LOGW(TAG, "LED Timed Control: invalid bit_on/off_period (on=%u, off=%u) " + "with repeats_duration=%u", + tmp_params.bit_on_period, tmp_params.bit_off_period, + tmp_params.repeats_duration); + build_error_response(BLE_ESL_ERR_INVALID_PARAMS, result); + return ESP_OK; + } + } + } + + /* Parse Absolute_Time (little-endian, 4 bytes after LED_Control_Settings) */ + uint8_t abs_time_offset = 1 + (uint8_t)sizeof(ble_esl_led_control_t); + uint32_t absolute_time = (uint32_t)params[abs_time_offset] | + ((uint32_t)params[abs_time_offset + 1] << 8) | + ((uint32_t)params[abs_time_offset + 2] << 16) | + ((uint32_t)params[abs_time_offset + 3] << 24); + + esl_led_state_t *led = &s_ctx->leds[led_index]; + + /* Handle Absolute_Time = 0 (cancellation) */ + if (absolute_time == 0x00000000) { + if (led->timed_pending) { + /* Stop the timed timer */ + esp_err_t ret = esp_timer_stop(led->timed_timer); + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to stop timed LED timer: %s", esp_err_to_name(ret)); + } + led->timed_pending = false; + ESP_LOGI(TAG, "LED Timed Control: cancelled for LED %u", led_index); + } + /* Respond with LED State response confirming deletion */ + build_led_state_response(led_index, result); + return ESP_OK; + } + + /* Validate Absolute_Time plausibility */ + uint32_t current_time = esl_get_abs_time(); + uint32_t delta; + if (absolute_time >= current_time) { + delta = absolute_time - current_time; + } else { + /* Time wraps around */ + delta = (0xFFFFFFFFUL - current_time) + absolute_time + 1; + } + + if (delta > BLE_ESL_IMPLAUSIBLE_TIME_MS) { + ESP_LOGW(TAG, "LED Timed Control: implausible abs_time=0x%08" PRIx32 + " (delta=%" PRIu32 " ms)", absolute_time, delta); + build_error_response(BLE_ESL_ERR_IMPLAUSIBLE_ABS_TIME, result); + return ESP_OK; + } + + /* Handle pending timed command conflict */ + if (led->timed_pending) { + if (led->timed_params.absolute_time == absolute_time) { + /* Same time: replace the pending command */ + memcpy(&led->timed_params.settings, settings, sizeof(ble_esl_led_control_t)); + ESP_LOGI(TAG, "LED Timed Control: replaced pending for LED %u", led_index); + } else { + /* Different time and non-zero: reject with Queue Full */ + ESP_LOGW(TAG, "LED Timed Control: queue full for LED %u", led_index); + build_error_response(BLE_ESL_ERR_QUEUE_FULL, result); + return ESP_OK; + } + } else { + /* No pending command: accept and schedule */ + led->timed_pending = true; + memcpy(&led->timed_params.settings, settings, sizeof(ble_esl_led_control_t)); + led->timed_params.absolute_time = absolute_time; + + /* Schedule the timed timer */ + uint64_t timeout_us = (uint64_t)delta * 1000ULL; + + /* Stop timer if it happens to be running (safety) */ + esp_timer_stop(led->timed_timer); + + esp_err_t ret = esp_timer_start_once(led->timed_timer, timeout_us); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start timed LED timer: %s", esp_err_to_name(ret)); + led->timed_pending = false; + build_error_response(BLE_ESL_ERR_INSUFFICIENT_RESOURCES, result); + return ESP_OK; + } + + ESP_LOGI(TAG, "LED Timed Control: scheduled LED=%u, abs_time=0x%08" PRIx32 + " (delta=%" PRIu32 " ms)", led_index, absolute_time, delta); + } + + /* Respond immediately with LED State response */ + build_led_state_response(led_index, result); + + return ESP_OK; +} + +/* ========================================================================== */ +/* Command Handler: Vendor-Specific (opcodes 0x_F) */ +/* ========================================================================== */ + +esp_err_t esl_cmd_vendor_specific(uint8_t opcode, const uint8_t *params, + uint8_t params_len, ble_esl_cmd_result_t *result) +{ + assert(result != NULL); + + memset(result, 0, sizeof(*result)); + + (void)params; + (void)params_len; + +#if CONFIG_BLE_ESL_VENDOR_SPECIFIC + /* + * Vendor-specific support is enabled, but no vendor handlers are registered + * in this base implementation. Return Invalid Opcode. + */ + ESP_LOGW(TAG, "Vendor-specific opcode 0x%02X: no handler registered", opcode); + build_error_response(BLE_ESL_ERR_INVALID_OPCODE, result); +#else + /* Vendor-specific support disabled: return Invalid Opcode */ + ESP_LOGW(TAG, "Vendor-specific opcode 0x%02X: vendor support disabled", opcode); + build_error_response(BLE_ESL_ERR_INVALID_OPCODE, result); +#endif /* CONFIG_BLE_ESL_VENDOR_SPECIFIC */ + + return ESP_OK; +} + +/* ========================================================================== */ +/* Internal Interface: esl_get_led_status() */ +/* ========================================================================== */ + +esl_led_status_t esl_get_led_status(void) +{ + esl_led_status_t status = { + .active_led = false, + .pending_led_update = false, + }; + + if (s_ctx == NULL) { + return status; + } + + for (int i = 0; i < CONFIG_BLE_ESL_MAX_LEDS; i++) { + if (s_ctx->leds[i].active) { + status.active_led = true; + } + if (s_ctx->leds[i].timed_pending) { + status.pending_led_update = true; + } + /* Early exit if both flags are already set */ + if (status.active_led && status.pending_led_update) { + break; + } + } + + return status; +} + +/* ========================================================================== */ +/* Internal Interface: esl_cmd_led_cancel_all() */ +/* ========================================================================== */ + +void esl_cmd_led_cancel_all(void) +{ + if (s_ctx == NULL) { + return; + } + + for (int i = 0; i < CONFIG_BLE_ESL_MAX_LEDS; i++) { + esl_led_state_t *led = &s_ctx->leds[i]; + + /* Stop pattern expiry timer */ + if (led->pattern_timer != NULL) { + esp_timer_stop(led->pattern_timer); + } + + /* Stop timed command timer */ + if (led->timed_timer != NULL) { + esp_timer_stop(led->timed_timer); + } + + /* Notify application to turn off active LEDs */ + if (led->active) { + ble_esl_cb_param_t cb_param; + memset(&cb_param, 0, sizeof(cb_param)); + memcpy(&cb_param.led_control, &led->current_params, sizeof(ble_esl_led_control_evt_param_t)); + cb_param.led_control.is_off = true; + esl_notify_app(BLE_ESL_EVT_LED_CONTROL, &cb_param); + } + + /* Clear all state */ + led->active = false; + led->timed_pending = false; + memset(&led->timed_params, 0, sizeof(led->timed_params)); + memset(&led->current_params, 0, sizeof(led->current_params)); + } + + ESP_LOGI(TAG, "All LED commands cancelled"); +} + +/* ========================================================================== */ +/* Init / Deinit */ +/* ========================================================================== */ + +esp_err_t esl_cmd_led_init(void) +{ + if (s_ctx != NULL) { + ESP_LOGW(TAG, "LED command module already initialized"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx = calloc(1, sizeof(esl_cmd_led_ctx_t)); + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Failed to allocate LED command context"); + return ESP_ERR_NO_MEM; + } + + /* Create esp_timers for each LED slot (pattern expiry + timed command) */ + esp_err_t ret = ESP_OK; + for (int i = 0; i < CONFIG_BLE_ESL_MAX_LEDS; i++) { + /* Pattern expiry timer */ + esp_timer_create_args_t pattern_timer_args = { + .callback = pattern_expiry_timer_cb, + .arg = (void *)(uintptr_t)i, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_led_pattern", + }; + + ret = esp_timer_create(&pattern_timer_args, + &s_ctx->leds[i].pattern_timer); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create pattern timer for LED %d: %s", + i, esp_err_to_name(ret)); + goto cleanup; + } + + /* Timed command timer */ + esp_timer_create_args_t timed_timer_args = { + .callback = timed_led_timer_cb, + .arg = (void *)(uintptr_t)i, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_led_timed", + }; + + ret = esp_timer_create(&timed_timer_args, &s_ctx->leds[i].timed_timer); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create timed timer for LED %d: %s", + i, esp_err_to_name(ret)); + /* Delete the pattern timer we just created for this LED */ + esp_timer_delete(s_ctx->leds[i].pattern_timer); + s_ctx->leds[i].pattern_timer = NULL; + goto cleanup; + } + } + + s_ctx->initialized = true; + ESP_LOGI(TAG, "LED command module initialized (%d LEDs)", CONFIG_BLE_ESL_MAX_LEDS); + + return ESP_OK; + +cleanup: + /* Clean up already created timers */ + for (int j = 0; j < CONFIG_BLE_ESL_MAX_LEDS; j++) { + if (s_ctx->leds[j].pattern_timer != NULL) { + esp_timer_delete(s_ctx->leds[j].pattern_timer); + s_ctx->leds[j].pattern_timer = NULL; + } + if (s_ctx->leds[j].timed_timer != NULL) { + esp_timer_delete(s_ctx->leds[j].timed_timer); + s_ctx->leds[j].timed_timer = NULL; + } + } + free(s_ctx); + s_ctx = NULL; + return ret; +} + +void esl_cmd_led_deinit(void) +{ + if (s_ctx == NULL) { + return; + } + + /* Notify application to turn off all active LEDs before teardown */ + esl_cmd_led_cancel_all(); + + /* Stop and delete all timers */ + for (int i = 0; i < CONFIG_BLE_ESL_MAX_LEDS; i++) { + if (s_ctx->leds[i].pattern_timer != NULL) { + esp_timer_stop(s_ctx->leds[i].pattern_timer); + esp_err_t ret = esp_timer_delete(s_ctx->leds[i].pattern_timer); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to delete pattern timer for LED %d: %s", + i, esp_err_to_name(ret)); + } + s_ctx->leds[i].pattern_timer = NULL; + } + + if (s_ctx->leds[i].timed_timer != NULL) { + esp_timer_stop(s_ctx->leds[i].timed_timer); + esp_err_t ret = esp_timer_delete(s_ctx->leds[i].timed_timer); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to delete timed timer for LED %d: %s", + i, esp_err_to_name(ret)); + } + s_ctx->leds[i].timed_timer = NULL; + } + } + + free(s_ctx); + s_ctx = NULL; + + ESP_LOGI(TAG, "LED command module deinitialized"); +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_lifecycle.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_lifecycle.c new file mode 100644 index 00000000000..5e88dec7b4b --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_cmd_lifecycle.c @@ -0,0 +1,536 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_cmd_lifecycle.c + * @brief ESL ECP command dispatch pipeline, lifecycle commands, and Basic State + * bitmap construction. + * + * Handles command routing (ESL_ID validation, opcode recognition, factory-reset + * guard), lifecycle commands (Ping, Unassociate, Service Reset, Factory Reset, + * Update Complete), and the Basic State bitmap assembly. + */ + +#include +#include + +#include "esp_log.h" +#include "esp_err.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" + +#include "ble_esl.h" +#include "ble_esl_common.h" +#include "ble_esl_int.h" + +static const char *TAG = "esl_cmd_lifecycle"; + +/* ========================== Internal Context ========================== */ + +/** + * @brief Lifecycle module internal state + */ +typedef struct { + bool service_needed; /*!< Service Needed flag (Basic State bit 0) */ + bool factory_reset_pending; /*!< Factory Reset guard — reject all subsequent cmds */ + bool unassociate_pending; /*!< Post-response cleanup flag for Unassociate */ +} lifecycle_ctx_t; + +static lifecycle_ctx_t *s_ctx = NULL; + +/* ========================== Forward Declarations ========================== */ + +static void build_error_response(ble_esl_cmd_result_t *result, uint8_t error_code); +static void build_basic_state_response(ble_esl_cmd_result_t *result, uint16_t bitmap); +static bool is_valid_opcode(uint8_t opcode); + +static esp_err_t handle_ping(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); +static esp_err_t handle_unassociate(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); +static esp_err_t handle_service_reset(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); +static esp_err_t handle_factory_reset(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); +static esp_err_t handle_update_complete(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result); + +/* ========================== Helper Functions ========================== */ + +/** + * @brief Build an Error response TLV into the result structure + * + * @param[out] result Command result to populate + * @param[in] error_code ESL error code + */ +static void build_error_response(ble_esl_cmd_result_t *result, uint8_t error_code) +{ + result->has_response = true; + /* Error Response opcode: Tag=0x0, Length=0x0 → opcode = 0x00 */ + result->resp_opcode = BLE_ESL_RESP_ERROR; + result->resp_params[0] = error_code; + result->resp_params_len = 1; +} + +/** + * @brief Build a Basic State response TLV into the result structure + * + * @param[out] result Command result to populate + * @param[in] bitmap 16-bit Basic State bitmap (little-endian) + */ +static void build_basic_state_response(ble_esl_cmd_result_t *result, uint16_t bitmap) +{ + result->has_response = true; + /* Basic State Response opcode: Tag=0x0, Length=0x1 → opcode = 0x10 */ + result->resp_opcode = BLE_ESL_RESP_BASIC_STATE; + result->resp_params[0] = (uint8_t)(bitmap & 0xFF); + result->resp_params[1] = (uint8_t)((bitmap >> 8) & 0xFF); + result->resp_params_len = 2; +} + +/** + * @brief Check if an opcode is a recognized ESL command opcode + * + * @param[in] opcode Command opcode byte + * @return true if recognized, false otherwise + */ +static bool is_valid_opcode(uint8_t opcode) +{ + /* Check fixed opcodes */ + switch (opcode) { + case BLE_ESL_CMD_PING: + case BLE_ESL_CMD_UNASSOCIATE: + case BLE_ESL_CMD_SERVICE_RESET: + case BLE_ESL_CMD_FACTORY_RESET: + case BLE_ESL_CMD_UPDATE_COMPLETE: + case BLE_ESL_CMD_READ_SENSOR: + case BLE_ESL_CMD_REFRESH_DISPLAY: + case BLE_ESL_CMD_DISPLAY_IMAGE: + case BLE_ESL_CMD_DISPLAY_TIMED_IMAGE: + case BLE_ESL_CMD_LED_CONTROL: + case BLE_ESL_CMD_LED_TIMED_CONTROL: + return true; + default: + break; + } + + /* Check vendor-specific: Tag nibble (bits [3:0]) == 0x0F */ + if (BLE_ESL_TLV_TAG(opcode) == BLE_ESL_CMD_VENDOR_TAG) { +#if CONFIG_BLE_ESL_VENDOR_SPECIFIC + return true; +#else + return false; +#endif + } + + return false; +} + +/* ========================== Lifecycle Command Handlers ========================== */ + +/** + * @brief Handle Ping command (opcode 0x00) + * + * No side effects. Always returns a Basic State response. + */ +static esp_err_t handle_ping(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + uint16_t bitmap = 0; + esp_err_t ret = esl_build_basic_state(&bitmap); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to build basic state for Ping: %d", ret); + /* Ping shall never produce an Error response, build best-effort bitmap */ + bitmap = 0; + } + + build_basic_state_response(result, bitmap); + return ESP_OK; +} + +/** + * @brief Handle Unassociate from AP command (opcode 0x01) + * + * Builds a Basic State response. The actual cleanup (bonding removal, state + * transition to Unassociated, event notification) happens AFTER the response + * is sent — signaled via the unassociate_pending flag. + */ +static esp_err_t handle_unassociate(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + uint16_t bitmap = 0; + esp_err_t ret = esl_build_basic_state(&bitmap); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to build basic state for Unassociate: %d", ret); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + build_basic_state_response(result, bitmap); + + /* Set post-response flag: esl_state.c checks this after sending ECP response */ + s_ctx->unassociate_pending = true; + + return ESP_OK; +} + +/** + * @brief Handle Service Reset command (opcode 0x02) + * + * Attempts to clear the Service Needed flag. If no persistent condition remains, + * the flag is cleared. Always returns a Basic State response. + */ +static esp_err_t handle_service_reset(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + /* + * Attempt to clear service_needed. Per spec, the flag is only cleared if + * the underlying condition no longer persists. Since we don't track the + * persistent condition separately, we simply clear the flag here. + * The application can re-set it via ble_esl_set_service_needed(true) if + * the condition still persists. + */ + if (s_ctx->service_needed) { + s_ctx->service_needed = false; + ESP_LOGI(TAG, "Service Needed flag cleared by Service Reset"); + } + + uint16_t bitmap = 0; + esp_err_t ret = esl_build_basic_state(&bitmap); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to build basic state for Service Reset: %d", ret); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + build_basic_state_response(result, bitmap); + return ESP_OK; +} + +/** + * @brief Handle Factory Reset command (opcode 0x03) + * + * Sets the factory_reset_pending guard, initiates disconnect, and fires the + * FACTORY_RESET event. Produces no response (has_response = false). + */ +static esp_err_t handle_factory_reset(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + /* Set guard flag — all subsequent commands will be rejected */ + s_ctx->factory_reset_pending = true; + + /* No response for Factory Reset */ + result->has_response = false; + result->resp_params_len = 0; + + /* Initiate disconnection using the actual active connection handle. + * We terminate with Remote User Terminated Connection reason. */ + uint16_t conn_handle = esl_state_get_conn_handle(); + if (conn_handle != BLE_HS_CONN_HANDLE_NONE) { + int rc = ble_gap_terminate(conn_handle, BLE_ERR_REM_USER_CONN_TERM); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "Failed to initiate disconnect for factory reset: %d", rc); + } + } else { + ESP_LOGW(TAG, "No active connection found for factory reset disconnect"); + } + + /* Fire FACTORY_RESET event to the application */ + esl_notify_app(BLE_ESL_EVT_FACTORY_RESET, NULL); + + ESP_LOGI(TAG, "Factory Reset initiated"); + return ESP_OK; +} + +/** + * @brief Handle Update Complete command (opcode 0x04) + * + * Records that Update Complete was received and lets the state module decide + * when to advance to Synchronized. The transition must NOT happen until PAST + * (PAwR sync transfer) has also completed, because entering Synchronized tears + * down the ACL link over which PAST is delivered — advancing early would leave + * the ESL un-synced to the PAwR train and unable to answer commands. Per ESL + * spec Section 3.9.2.5.2, the Update Complete command has no response. + */ +static esp_err_t handle_update_complete(const uint8_t *params, uint8_t params_len, + ble_esl_cmd_result_t *result) +{ + /* No response for Update Complete per spec */ + result->has_response = false; + result->resp_params_len = 0; + + /* Defer the SYNCHRONIZED transition to the state module, which gates it on + * PAST reception (and, in Configuring, on the mandatory configuration + * writes). */ + esl_notify_update_complete(); + + ESP_LOGI(TAG, "Update Complete processed"); + return ESP_OK; +} + +/* ========================== Public / Internal API ========================== */ + +esp_err_t esl_cmd_lifecycle_init(void) +{ + if (s_ctx != NULL) { + ESP_LOGW(TAG, "Lifecycle module already initialized"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx = calloc(1, sizeof(lifecycle_ctx_t)); + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Failed to allocate lifecycle context"); + return ESP_ERR_NO_MEM; + } + + s_ctx->service_needed = false; + s_ctx->factory_reset_pending = false; + s_ctx->unassociate_pending = false; + + ESP_LOGI(TAG, "Lifecycle command module initialized"); + return ESP_OK; +} + +void esl_cmd_lifecycle_deinit(void) +{ + if (s_ctx != NULL) { + free(s_ctx); + s_ctx = NULL; + } + ESP_LOGI(TAG, "Lifecycle command module deinitialized"); +} + +esp_err_t esl_cmd_dispatch(uint8_t opcode, const uint8_t *params, + uint8_t params_len, ble_esl_cmd_result_t *result, + uint8_t transport) +{ + assert(result != NULL); + + /* Initialize result to safe defaults */ + memset(result, 0, sizeof(ble_esl_cmd_result_t)); + result->has_response = false; + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Lifecycle module not initialized"); + return ESP_ERR_INVALID_STATE; + } + + /* Factory Reset Pending Guard: reject all commands with Unspecified Error. + * The rejection is a normal, spec-mandated outcome that carries an Error + * response, so return ESP_OK — as every other rejection path below does — + * so callers actually transmit the response instead of treating it as a + * dispatch failure. */ + if (s_ctx->factory_reset_pending) { + ESP_LOGW(TAG, "Factory reset pending, rejecting command opcode=0x%02X", opcode); + build_error_response(result, BLE_ESL_ERR_UNSPECIFIED); + return ESP_OK; + } + + /* Validate ESL_ID (first parameter byte) */ + if (params == NULL || params_len < 1) { + ESP_LOGE(TAG, "Command has no parameters (missing ESL_ID)"); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + uint8_t cmd_esl_id = params[0]; + uint8_t my_esl_id = 0; + uint8_t my_group_id = 0; + esp_err_t ret = esl_get_address(&my_esl_id, &my_group_id); + + if (cmd_esl_id == BLE_ESL_BROADCAST_ADDRESS) { + if (transport == ESL_TRANSPORT_ECP) { + /* Broadcast Address (0xFF) is only valid over PAwR, not ECP. */ + ESP_LOGW(TAG, "Broadcast address 0xFF rejected on ECP"); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + /* Over PAwR, broadcast address is valid — skip ESL_ID matching */ + } else { + if (ret != ESP_OK) { + /* ESL address not configured — cannot validate ESL_ID */ + ESP_LOGW(TAG, "ESL address not configured, rejecting command"); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + + if (cmd_esl_id != my_esl_id) { + /* ESL_ID mismatch */ + ESP_LOGW(TAG, "ESL_ID mismatch: received=0x%02X, own=0x%02X", cmd_esl_id, my_esl_id); + build_error_response(result, BLE_ESL_ERR_INVALID_PARAMS); + return ESP_OK; + } + } + + /* Check for unrecognized opcode */ + if (!is_valid_opcode(opcode)) { + ESP_LOGW(TAG, "Unrecognized opcode: 0x%02X", opcode); + build_error_response(result, BLE_ESL_ERR_INVALID_OPCODE); + return ESP_OK; + } + + /* Strip ESL_ID byte before delegating to sub-handlers */ + const uint8_t *cmd_params = params + 1; + uint8_t cmd_params_len = params_len - 1; + + /* Route to appropriate handler */ + switch (opcode) { + /* Lifecycle commands — handled inline */ + case BLE_ESL_CMD_PING: + return handle_ping(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_UNASSOCIATE: + if (transport != ESL_TRANSPORT_ECP) { + ESP_LOGW(TAG, "Unassociate rejected: only valid over ECP"); + build_error_response(result, BLE_ESL_ERR_INVALID_STATE); + return ESP_OK; + } + return handle_unassociate(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_SERVICE_RESET: + return handle_service_reset(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_FACTORY_RESET: + return handle_factory_reset(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_UPDATE_COMPLETE: + return handle_update_complete(cmd_params, cmd_params_len, result); + + /* Display/Sensor commands — delegated to esl_cmd_display.c */ + case BLE_ESL_CMD_READ_SENSOR: + return esl_cmd_read_sensor(cmd_params, cmd_params_len, result, transport); + + case BLE_ESL_CMD_REFRESH_DISPLAY: + return esl_cmd_refresh_display(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_DISPLAY_IMAGE: + return esl_cmd_display_image(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_DISPLAY_TIMED_IMAGE: + return esl_cmd_display_timed_image(cmd_params, cmd_params_len, result); + + /* LED commands — delegated to esl_cmd_led.c */ + case BLE_ESL_CMD_LED_CONTROL: + return esl_cmd_led_control(cmd_params, cmd_params_len, result); + + case BLE_ESL_CMD_LED_TIMED_CONTROL: + return esl_cmd_led_timed_control(cmd_params, cmd_params_len, result); + + default: + /* Vendor-specific commands (Tag nibble == 0x0F) */ + if (BLE_ESL_TLV_TAG(opcode) == BLE_ESL_CMD_VENDOR_TAG) { + return esl_cmd_vendor_specific(opcode, cmd_params, cmd_params_len, result); + } + /* Should not reach here due to is_valid_opcode check above */ + build_error_response(result, BLE_ESL_ERR_INVALID_OPCODE); + return ESP_OK; + } +} + +esp_err_t esl_build_basic_state(uint16_t *bitmap_out) +{ + if (bitmap_out == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Lifecycle module not initialized"); + return ESP_ERR_INVALID_STATE; + } + + uint16_t bitmap = 0; + + /* Bit 0: Service Needed */ + if (s_ctx->service_needed) { + bitmap |= BLE_ESL_BASIC_STATE_SERVICE_NEEDED; + } + + /* Bit 1: Synchronized — per spec this reports whether the ESL is + * synchronized to the AP, not whether it sits in the Synchronized state. + * The Updating state keeps the PAwR sync (it only ignores the data), so an + * ESL that entered Updating from Synchronized must still report bit 1. */ + ble_esl_state_t state = ble_esl_get_state(); + if (state == BLE_ESL_STATE_SYNCHRONIZED || + (state == BLE_ESL_STATE_UPDATING && esl_is_pawr_synced())) { + bitmap |= BLE_ESL_BASIC_STATE_SYNCHRONIZED; + } + + /* Bits 2-3: Active LED and Pending LED Update */ + esl_led_status_t led_status = esl_get_led_status(); + if (led_status.active_led) { + bitmap |= BLE_ESL_BASIC_STATE_ACTIVE_LED; + } + if (led_status.pending_led_update) { + bitmap |= BLE_ESL_BASIC_STATE_PENDING_LED_UPDATE; + } + + /* Bit 4: Pending Display Update */ + if (esl_get_display_pending()) { + bitmap |= BLE_ESL_BASIC_STATE_PENDING_DISP_UPDATE; + } + + /* Bits 5-15: RFU, set to 0 (already 0 from initialization) */ + + *bitmap_out = bitmap; + return ESP_OK; +} + +esp_err_t ble_esl_set_service_needed(bool flag) +{ + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Lifecycle module not initialized"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx->service_needed = flag; + ESP_LOGI(TAG, "Service Needed flag set to %s", flag ? "true" : "false"); + return ESP_OK; +} + +bool esl_is_factory_reset_pending(void) +{ + if (s_ctx == NULL) { + return false; + } + return s_ctx->factory_reset_pending; +} + +void esl_clear_factory_reset_pending(void) +{ + if (s_ctx != NULL) { + s_ctx->factory_reset_pending = false; + ESP_LOGI(TAG, "Factory reset pending flag cleared"); + } +} + +bool esl_is_unassociate_pending(void) +{ + if (s_ctx == NULL) { + return false; + } + return s_ctx->unassociate_pending; +} + +void esl_execute_unassociate_cleanup(void) +{ + if (s_ctx == NULL || !s_ctx->unassociate_pending) { + return; + } + + /* Clear the pending flag first */ + s_ctx->unassociate_pending = false; + + ESP_LOGI(TAG, "Executing post-response Unassociate cleanup"); + + /* Transition to Unassociated state (internally clears stored data) */ + esp_err_t ret = esl_state_transition(BLE_ESL_STATE_UNASSOCIATED); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to transition to Unassociated state: %d", ret); + } + + /* Fire UNASSOCIATE event to the application */ + esl_notify_app(BLE_ESL_EVT_UNASSOCIATE, NULL); +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_gatts.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_gatts.c new file mode 100644 index 00000000000..935a1f241a0 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_gatts.c @@ -0,0 +1,861 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_gatts.c + * @brief ESL GATT server, OTS setup, DIS PnP ID. + */ + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "nimble/ble.h" +#include "nimble/nimble_port.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "host/ble_gatt.h" +#include "host/ble_uuid.h" +#include "host/ble_store.h" +#include "services/dis/ble_svc_dis.h" + +#include "ble_esl.h" +#include "ble_esl_int.h" +#include "ble_esl_state_int.h" + +#if CONFIG_BLE_ESL_OTS_SUPPORT +#include "ble_ots_server.h" +#endif + +static const char *TAG = "esl_gatts"; + +/* ========================== Forward Declarations ========================== */ +static int esl_gatt_access_cb(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg); + +/* ========================== GATT Service Definition ========================== */ + +/* + * We build the GATT service table dynamically at init time because + * conditional characteristics depend on config element counts. + * Maximum: 9 characteristics + 1 terminator = 10 entries. + */ +#define ESL_MAX_CHRS 10 + +/* Error response TLV: opcode byte (Tag=0, Length=0) + error code byte */ +#define ESL_ECP_ERROR_RESP_SIZE 2 + +static const ble_uuid16_t s_esl_svc_uuid = BLE_UUID16_INIT(BLE_ESL_SVC_UUID); +static const ble_uuid16_t s_uuid_address = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_ESL_ADDRESS); +static const ble_uuid16_t s_uuid_ap_sync = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_AP_SYNC_KEY); +static const ble_uuid16_t s_uuid_resp_key = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_RESP_KEY); +static const ble_uuid16_t s_uuid_abs_time = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_CURRENT_ABS_TIME); +static const ble_uuid16_t s_uuid_disp_info = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_DISPLAY_INFO); +static const ble_uuid16_t s_uuid_img_info = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_IMAGE_INFO); +static const ble_uuid16_t s_uuid_sensor_info = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_SENSOR_INFO); +static const ble_uuid16_t s_uuid_led_info = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_LED_INFO); +static const ble_uuid16_t s_uuid_ecp = BLE_UUID16_INIT(BLE_ESL_CHR_UUID_ECP); + +/* Index mapping for characteristic handles */ +enum { + CHR_IDX_ADDRESS = 0, + CHR_IDX_AP_SYNC, + CHR_IDX_RESP_KEY, + CHR_IDX_ABS_TIME, + CHR_IDX_ECP, + /* Conditional chars start here; indices assigned dynamically */ +}; + +/* + * All of this module's mutable static state is aggregated into a single + * heap-allocated context, so it costs no RAM until the ESL GATT server is + * initialized. NimBLE retains pointers into esl_chrs / esl_svcs / chr_handles + * after ble_gatts_add_svcs(), so this context must live from esl_gatts_init() + * until esl_gatts_deinit(). + */ +typedef struct { + esl_state_ctx_t *ctx; /* borrowed state context (not owned) */ + + struct ble_gatt_chr_def esl_chrs[ESL_MAX_CHRS]; + uint16_t chr_handles[9]; /* val handles for up to 9 chars */ + struct ble_gatt_svc_def esl_svcs[2]; /* service + terminator */ + uint8_t ecp_chr_idx; /* actual index of ECP in array */ + +#if CONFIG_BLE_ESL_OTS_SUPPORT + ble_ots_obj_id_t ots_obj_ids[CONFIG_BLE_ESL_MAX_IMAGES]; /* reverse map */ + uint8_t ots_obj_count; + bool ots_initialized; /* OTS server init state */ +#endif +} esl_gatts_ctx_t; + +static esl_gatts_ctx_t *s_esl_gatts = NULL; + +/** + * @brief Build the ESL GATT service definition based on config + */ +static void esl_build_gatt_svcs(const ble_esl_config_t *config) +{ + int idx = 0; + + /* Mandatory: ESL Address (Write, Encrypted) */ + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_address.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_ENC; + idx++; + + /* Mandatory: AP Sync Key Material (Write, Encrypted) */ + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_ap_sync.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_ENC; + idx++; + + /* Mandatory: ESL Response Key Material (Write, Encrypted) */ + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_resp_key.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_ENC; + idx++; + + /* Mandatory: ESL Current Absolute Time (Write, Encrypted) */ + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_abs_time.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_ENC; + idx++; + + /* Conditional: Display Information (Read, Encrypted) */ + if (config->num_displays > 0) { + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_disp_info.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_READ_ENC; + idx++; + } + + /* Conditional: Image Information (Read, Encrypted) — requires both display and image support */ + if (config->num_displays > 0 && config->num_images > 0) { + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_img_info.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_READ_ENC; + idx++; + } + + /* Conditional: Sensor Information (Read, Encrypted) */ + if (config->num_sensors > 0) { + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_sensor_info.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_READ_ENC; + idx++; + } + + /* Conditional: LED Information (Read, Encrypted) */ + if (config->num_leds > 0) { + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_led_info.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_READ_ENC; + idx++; + } + + /* Mandatory: ECP (Write Without Response, Write, Notify, Encrypted) */ + s_esl_gatts->ecp_chr_idx = idx; + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + s_esl_gatts->esl_chrs[idx].uuid = &s_uuid_ecp.u; + s_esl_gatts->esl_chrs[idx].access_cb = esl_gatt_access_cb; + s_esl_gatts->esl_chrs[idx].val_handle = &s_esl_gatts->chr_handles[idx]; + s_esl_gatts->esl_chrs[idx].flags = BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_WRITE | + BLE_GATT_CHR_F_NOTIFY | + BLE_GATT_CHR_F_WRITE_ENC; + idx++; + + /* Terminator */ + memset(&s_esl_gatts->esl_chrs[idx], 0, sizeof(struct ble_gatt_chr_def)); + + /* Build service definition */ + memset(s_esl_gatts->esl_svcs, 0, sizeof(s_esl_gatts->esl_svcs)); + s_esl_gatts->esl_svcs[0].type = BLE_GATT_SVC_TYPE_PRIMARY; + s_esl_gatts->esl_svcs[0].uuid = &s_esl_svc_uuid.u; + s_esl_gatts->esl_svcs[0].characteristics = s_esl_gatts->esl_chrs; + /* s_esl_gatts->esl_svcs[1] is zero-terminated */ +} + +/* ========================== GATT Access Callback ========================== */ + +/** + * @brief Helper to extract flat data from an mbuf + */ +static int esl_gatt_write_flat(struct os_mbuf *om, void *dst, + uint16_t min_len, uint16_t max_len, + uint16_t *out_len) +{ + uint16_t om_len = OS_MBUF_PKTLEN(om); + if (om_len < min_len || om_len > max_len) { + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + int rc = ble_hs_mbuf_to_flat(om, dst, max_len, out_len); + if (rc != 0) { + return BLE_ATT_ERR_UNLIKELY; + } + return 0; +} + +/** + * @brief Handle write to ESL Address characteristic + */ +static int handle_write_esl_address(struct os_mbuf *om) +{ + ble_esl_address_t addr; + uint16_t len; + int rc = esl_gatt_write_flat(om, &addr, sizeof(addr), sizeof(addr), &len); + if (rc != 0) { + return rc; + } + + if (addr.esl_id == BLE_ESL_BROADCAST_ADDRESS) { + ESP_LOGW(TAG, "Rejected broadcast address as ESL address"); + return BLE_ATT_ERR_VALUE_NOT_ALLOWED; + } + + s_esl_gatts->ctx->esl_address = addr; + s_esl_gatts->ctx->address_valid = true; + s_esl_gatts->ctx->config_complete |= CONFIG_BIT_ADDRESS; + ESP_LOGI(TAG, "ESL Address set: id=0x%02x group=0x%02x", + addr.esl_id, BLE_ESL_ADDR_GROUP_ID(addr)); + return 0; +} + +/** + * @brief Handle write to AP Sync Key Material characteristic + */ +static int handle_write_ap_sync_key(struct os_mbuf *om) +{ + uint16_t len; + int rc = esl_gatt_write_flat(om, &s_esl_gatts->ctx->ap_sync_key, + BLE_ESL_KEY_MATERIAL_SIZE, + BLE_ESL_KEY_MATERIAL_SIZE, &len); + if (rc != 0) { + return rc; + } + + s_esl_gatts->ctx->ap_sync_key_valid = true; + s_esl_gatts->ctx->config_complete |= CONFIG_BIT_AP_SYNC_KEY; + ESP_LOGI(TAG, "AP Sync Key Material written"); + return 0; +} + +/** + * @brief Handle write to ESL Response Key Material characteristic + */ +static int handle_write_resp_key(struct os_mbuf *om) +{ + uint16_t len; + int rc = esl_gatt_write_flat(om, &s_esl_gatts->ctx->resp_key, + BLE_ESL_KEY_MATERIAL_SIZE, + BLE_ESL_KEY_MATERIAL_SIZE, &len); + if (rc != 0) { + return rc; + } + + s_esl_gatts->ctx->resp_key_valid = true; + s_esl_gatts->ctx->config_complete |= CONFIG_BIT_RESP_KEY; + ESP_LOGI(TAG, "ESL Response Key Material written"); + return 0; +} + +/** + * @brief Handle write to ESL Current Absolute Time characteristic + */ +static int handle_write_abs_time(struct os_mbuf *om) +{ + uint32_t time_val; + uint16_t len; + int rc = esl_gatt_write_flat(om, &time_val, sizeof(time_val), + sizeof(time_val), &len); + if (rc != 0) { + return rc; + } + + s_esl_gatts->ctx->abs_time_base = time_val; + s_esl_gatts->ctx->abs_time_offset_us = esp_timer_get_time(); + s_esl_gatts->ctx->config_complete |= CONFIG_BIT_ABS_TIME; + ESP_LOGI(TAG, "ESL Absolute Time set: %" PRIu32 " ms", time_val); + return 0; +} + +/** + * @brief Handle write to ECP characteristic — decode TLV, dispatch, respond + */ +static int handle_write_ecp(uint16_t conn_handle, struct os_mbuf *om) +{ + uint8_t buf[BLE_ESL_TLV_MAX_SIZE]; + uint16_t om_len = OS_MBUF_PKTLEN(om); + + if (om_len < BLE_ESL_TLV_MIN_SIZE || om_len > BLE_ESL_TLV_MAX_SIZE) { + ESP_LOGW(TAG, "ECP write: invalid length %u", om_len); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + uint16_t flat_len; + int rc = ble_hs_mbuf_to_flat(om, buf, sizeof(buf), &flat_len); + if (rc != 0) { + return BLE_ATT_ERR_UNLIKELY; + } + + /* Decode TLV */ + uint8_t opcode; + const uint8_t *params; + uint8_t params_len; + esp_err_t err = ble_esl_tlv_decode(buf, flat_len, &opcode, ¶ms, ¶ms_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ECP: TLV decode failed"); + return BLE_ATT_ERR_UNLIKELY; + } + + /* Start ECP procedure timeout timer */ + esp_timer_stop(s_esl_gatts->ctx->ecp_timer); + esp_timer_start_once(s_esl_gatts->ctx->ecp_timer, ESL_ECP_TIMEOUT_US); + + /* Dispatch command */ + ble_esl_cmd_result_t result; + memset(&result, 0, sizeof(result)); + err = esl_cmd_dispatch(opcode, params, params_len, &result, ESL_TRANSPORT_ECP); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ECP: command dispatch failed: %d", (int)err); + } + + /* Send response notification if command produced one */ + if (result.has_response) { + /* Stop ECP timeout — response is being sent now */ + esp_timer_stop(s_esl_gatts->ctx->ecp_timer); + uint8_t resp_buf[BLE_ESL_TLV_MAX_SIZE]; + uint8_t resp_len; + err = ble_esl_tlv_encode(result.resp_opcode, result.resp_params, + result.resp_params_len, + resp_buf, &resp_len); + if (err != ESP_OK) { + /* The AP must always get a response (the timeout timer is already + * stopped), so fall back to an Unspecified Error TLV — same + * defensive handling as the PAwR transport. */ + ESP_LOGW(TAG, "ECP: response TLV encode failed, sending Unspecified Error"); + resp_buf[0] = BLE_ESL_RESP_ERROR; + resp_buf[1] = BLE_ESL_ERR_UNSPECIFIED; + resp_len = ESL_ECP_ERROR_RESP_SIZE; + } + err = esl_send_ecp_response(resp_buf, resp_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "ECP: response send failed: %d", (int)err); + /* No notification was enqueued, so neither the notify callback nor + * the notify-tx event will run the deferred Unassociate cleanup. */ + if (esl_is_unassociate_pending()) { + esl_execute_unassociate_cleanup(); + } + } + } else { + /* No response expected — stop the ECP timeout timer */ + esp_timer_stop(s_esl_gatts->ctx->ecp_timer); + } + + return 0; +} + +/** + * @brief Handle read of Display Information characteristic + */ +static int handle_read_display_info(struct ble_gatt_access_ctxt *ctxt) +{ + if (s_esl_gatts->ctx->config.display_info == NULL || s_esl_gatts->ctx->config.num_displays == 0) { + return BLE_ATT_ERR_UNLIKELY; + } + uint16_t len = (uint16_t)(s_esl_gatts->ctx->config.num_displays * sizeof(ble_esl_display_info_t)); + int rc = os_mbuf_append(ctxt->om, s_esl_gatts->ctx->config.display_info, len); + return (rc == 0) ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; +} + +/** + * @brief Handle read of Image Information characteristic + */ +static int handle_read_image_info(struct ble_gatt_access_ctxt *ctxt) +{ + if (s_esl_gatts->ctx->config.num_images == 0) { + return BLE_ATT_ERR_UNLIKELY; + } + uint8_t max_image_index = s_esl_gatts->ctx->config.num_images - 1; + int rc = os_mbuf_append(ctxt->om, &max_image_index, 1); + return (rc == 0) ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; +} + +/** + * @brief Handle read of Sensor Information characteristic + */ +static int handle_read_sensor_info(struct ble_gatt_access_ctxt *ctxt) +{ + if (s_esl_gatts->ctx->config.sensor_info == NULL || s_esl_gatts->ctx->config.sensor_info_len == 0) { + return BLE_ATT_ERR_UNLIKELY; + } + int rc = os_mbuf_append(ctxt->om, s_esl_gatts->ctx->config.sensor_info, + s_esl_gatts->ctx->config.sensor_info_len); + return (rc == 0) ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; +} + +/** + * @brief Handle read of LED Information characteristic + */ +static int handle_read_led_info(struct ble_gatt_access_ctxt *ctxt) +{ + if (s_esl_gatts->ctx->config.led_info == NULL || s_esl_gatts->ctx->config.num_leds == 0) { + return BLE_ATT_ERR_UNLIKELY; + } + int rc = os_mbuf_append(ctxt->om, s_esl_gatts->ctx->config.led_info, + s_esl_gatts->ctx->config.num_leds); + return (rc == 0) ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; +} + +/** + * @brief GATT access callback for all ESL Service characteristics + */ +static int esl_gatt_access_cb(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + if (s_esl_gatts == NULL || s_esl_gatts->ctx == NULL) { + ESP_LOGW(TAG, "GATT access while context is NULL"); + return BLE_ATT_ERR_UNLIKELY; + } + + const ble_uuid_t *uuid; + + switch (ctxt->op) { + case BLE_GATT_ACCESS_OP_WRITE_CHR: + uuid = ctxt->chr->uuid; + if (ble_uuid_cmp(uuid, &s_uuid_address.u) == 0 || + ble_uuid_cmp(uuid, &s_uuid_ap_sync.u) == 0 || + ble_uuid_cmp(uuid, &s_uuid_resp_key.u) == 0 || + ble_uuid_cmp(uuid, &s_uuid_abs_time.u) == 0) { + /* ESL spec: configurable characteristics may only be written + * in Configuring or Updating states */ + if (s_esl_gatts->ctx->state != BLE_ESL_STATE_CONFIGURING && + s_esl_gatts->ctx->state != BLE_ESL_STATE_UPDATING) { + ESP_LOGW(TAG, "GATT write rejected: invalid state %d", s_esl_gatts->ctx->state); + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + } + if (ble_uuid_cmp(uuid, &s_uuid_address.u) == 0) { + return handle_write_esl_address(ctxt->om); + } + if (ble_uuid_cmp(uuid, &s_uuid_ap_sync.u) == 0) { + return handle_write_ap_sync_key(ctxt->om); + } + if (ble_uuid_cmp(uuid, &s_uuid_resp_key.u) == 0) { + return handle_write_resp_key(ctxt->om); + } + if (ble_uuid_cmp(uuid, &s_uuid_abs_time.u) == 0) { + return handle_write_abs_time(ctxt->om); + } + if (ble_uuid_cmp(uuid, &s_uuid_ecp.u) == 0) { + return handle_write_ecp(conn_handle, ctxt->om); + } + break; + + case BLE_GATT_ACCESS_OP_READ_CHR: + uuid = ctxt->chr->uuid; + if (ble_uuid_cmp(uuid, &s_uuid_disp_info.u) == 0) { + return handle_read_display_info(ctxt); + } + if (ble_uuid_cmp(uuid, &s_uuid_img_info.u) == 0) { + return handle_read_image_info(ctxt); + } + if (ble_uuid_cmp(uuid, &s_uuid_sensor_info.u) == 0) { + return handle_read_sensor_info(ctxt); + } + if (ble_uuid_cmp(uuid, &s_uuid_led_info.u) == 0) { + return handle_read_led_info(ctxt); + } + break; + + default: + break; + } + + return BLE_ATT_ERR_UNLIKELY; +} + +/* ========================== OTS Setup ========================== */ + +#if CONFIG_BLE_ESL_OTS_SUPPORT + +/* OTS object-ID reverse map, count and init state live in esl_gatts_ctx_t. */ + +/** + * @brief OTS event callback — handles write-complete events, + * maps object ID to image index and notifies app + */ +static void esl_ots_write_cb(ble_ots_server_event_t event, + ble_ots_server_cb_param_t *param) +{ + if (event != BLE_OTS_SERVER_EVT_WRITE_COMPLETE || param == NULL) { + return; + } + + ble_ots_obj_id_t obj_id = param->write_complete.object_id; + uint32_t offset = param->write_complete.offset; + uint32_t length = param->write_complete.bytes_received; + + if (s_esl_gatts == NULL || s_esl_gatts->ctx == NULL) { + return; + } + /* Map obj_id back to image_index through the reverse map built by + * esl_setup_ots(). The OTS server assigns IDs from its own monotonic + * counter, so they are not guaranteed to start at + * BLE_ESL_OTS_OBJECT_ID_BASE nor to be contiguous. */ + uint8_t image_index = 0; + bool found = false; + for (uint8_t i = 0; i < s_esl_gatts->ots_obj_count; i++) { + if (s_esl_gatts->ots_obj_ids[i] == obj_id) { + image_index = i; + found = true; + break; + } + } + if (!found) { + ESP_LOGW(TAG, "OTS write: unknown obj_id=0x%06llx", (unsigned long long)obj_id); + return; + } + + ble_esl_cb_param_t cb_param = { + .image_write = { + .image_index = image_index, + .data = NULL, + .length = length, + .offset = offset, + } + }; + esl_notify_app(BLE_ESL_EVT_IMAGE_WRITE, &cb_param); +} + +static esp_err_t esl_setup_ots(const ble_esl_config_t *config) +{ + if (config->num_images == 0) { + return ESP_OK; + } + + s_esl_gatts->ots_obj_count = 0; + + ble_ots_server_config_t ots_config = { + .oacp_features = BLE_OTS_OACP_FEAT_WRITE | BLE_OTS_OACP_FEAT_TRUNCATE, + .olcp_features = BLE_OTS_OLCP_FEAT_GO_TO, + .supported_types = NULL, + .num_supported_types = 0, + .include_obj_first_created = false, + .include_obj_last_modified = false, + .include_obj_changed = false, + .include_obj_list_filter = false, + .has_realtime_clock = false, + .obj_name_writable = false, + .obj_properties_writable = false, + }; + + int rc = ble_ots_server_init(&ots_config); + if (rc != 0) { + ESP_LOGE(TAG, "OTS server init failed: rc=%d", rc); + return ESP_FAIL; + } + + s_esl_gatts->ots_initialized = true; + + /* Pre-create objects for each image slot */ + for (uint8_t i = 0; i < config->num_images; i++) { + char name[16]; + snprintf(name, sizeof(name), "Image_%u", i); + + uint32_t props = BLE_OTS_OBJ_PROP_READ; + if (config->image_writable_mask != NULL && config->image_writable_mask[i]) { + props |= BLE_OTS_OBJ_PROP_WRITE | BLE_OTS_OBJ_PROP_TRUNCATE; + } + + ble_ots_obj_type_entry_t obj_type = { .uuid_len = 2, .uuid = {0} }; + ble_ots_server_obj_params_t obj_params = { + .name = name, + .name_len = strlen(name), + .type = obj_type, + .properties = props, + .first_created = {0}, + .last_modified = {0}, + .data = NULL, + .data_len = 0, + .allocated_size = CONFIG_BLE_ESL_MAX_IMAGE_SIZE, + }; + + ble_ots_obj_id_t obj_id; + rc = ble_ots_server_add_object(&obj_params, &obj_id); + if (rc != 0) { + ESP_LOGE(TAG, "OTS add object %u failed: rc=%d", i, rc); + return ESP_FAIL; + } + if (s_esl_gatts->ots_obj_count < CONFIG_BLE_ESL_MAX_IMAGES) { + s_esl_gatts->ots_obj_ids[s_esl_gatts->ots_obj_count++] = obj_id; + } + ESP_LOGI(TAG, "OTS object created: Image_%u, obj_id=0x%06llx", + i, (unsigned long long)obj_id); + } + + /* Register write callback for image data reception */ + rc = ble_ots_server_register_cb(esl_ots_write_cb); + if (rc != 0) { + ESP_LOGE(TAG, "OTS register write cb failed: rc=%d", rc); + return ESP_FAIL; + } + + return ESP_OK; +} + +static void esl_teardown_ots(void) +{ + if (s_esl_gatts->ots_initialized) { + ble_ots_server_deinit(); + s_esl_gatts->ots_initialized = false; + } +} +#endif /* CONFIG_BLE_ESL_OTS_SUPPORT */ + +/* ========================== DIS PnP ID Setup ========================== */ + +#if CONFIG_BLE_ESL_VENDOR_SPECIFIC +/** + * @brief Static buffer for PnP ID to work around NimBLE DIS strlen limitation. + * + * NimBLE's ble_svc_dis uses strlen() on the PnP ID pointer, which truncates + * binary data containing 0x00 bytes. We copy the 7-octet PnP ID into a static + * buffer padded with 0xFF to avoid premature truncation by strlen, and cap at + * 7 bytes on the read side (NimBLE clamps len to 7). + */ +static uint8_t s_pnp_id_buf[8]; /* 7 octets + padding byte */ + +static esp_err_t esl_setup_dis_pnp(const ble_esl_config_t *config) +{ + if (!config->vendor_specific || config->pnp_id == NULL) { + return ESP_OK; + } + + ble_svc_dis_init(); + + /* Copy 7-octet binary PnP ID and pad with non-zero byte to prevent + * strlen from reading past the buffer. Note: if any of the 7 data + * bytes is 0x00, NimBLE's strlen-based read will still truncate the + * value. This is a known NimBLE limitation for binary characteristics. */ + memcpy(s_pnp_id_buf, config->pnp_id, 7); + s_pnp_id_buf[7] = 0xFF; /* non-zero padding to bound strlen */ + + /* Check for embedded zero bytes and warn */ + for (int i = 0; i < 7; i++) { + if (s_pnp_id_buf[i] == 0x00) { + ESP_LOGW(TAG, "PnP ID contains 0x00 at offset %d — NimBLE DIS may " + "truncate the value due to strlen usage", i); + break; + } + } + + int rc = ble_svc_dis_pnp_id_set((const char *)s_pnp_id_buf); + if (rc != 0) { + ESP_LOGE(TAG, "DIS PnP ID set failed: rc=%d", rc); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "DIS PnP ID registered"); + return ESP_OK; +} +#endif /* CONFIG_BLE_ESL_VENDOR_SPECIFIC */ + +/* ========================== ECP Response ========================== */ + +/* + * The ECP response notification must not be sent synchronously from within a + * GATT access callback (the ATT Write Response for the ECP write has not yet + * been transmitted). The notification mbuf is built up-front (the caller's + * TLV buffer may live on the stack) and the actual ble_gatts_notify_custom() + * call is deferred to a NimBLE host-task event. + */ +struct esl_ecp_notify_ctx { + struct ble_npl_event ev; + uint16_t conn_handle; + uint16_t val_handle; + struct os_mbuf *om; +}; + +static void esl_ecp_notify_event_cb(struct ble_npl_event *ev) +{ + struct esl_ecp_notify_ctx *ctx = + (struct esl_ecp_notify_ctx *)ble_npl_event_get_arg(ev); + if (ctx == NULL) { + return; + } + + /* ble_gatts_notify_custom() consumes the mbuf on all return paths. */ + int rc = ble_gatts_notify_custom(ctx->conn_handle, ctx->val_handle, ctx->om); + if (rc != 0) { + ESP_LOGW(TAG, "ECP response: notify failed rc=%d", rc); + if (esl_is_unassociate_pending()) { + esl_execute_unassociate_cleanup(); + } + } + ble_npl_event_deinit(ev); + free(ctx); +} + +void esl_handle_ecp_notify_tx(uint16_t conn_handle, uint16_t attr_handle, + int status, uint8_t indication) +{ + if (s_esl_gatts == NULL || s_esl_gatts->ctx == NULL || indication || + s_esl_gatts->ctx->conn_handle != conn_handle || + attr_handle != s_esl_gatts->chr_handles[s_esl_gatts->ecp_chr_idx] || + !esl_is_unassociate_pending()) { + return; + } + + if (status != 0) { + ESP_LOGW(TAG, "ECP response notify-tx failed: status=%d", status); + } + + esl_execute_unassociate_cleanup(); +} + +esp_err_t esl_send_ecp_response(const uint8_t *response_tlv, uint8_t response_len) +{ + if (s_esl_gatts == NULL || s_esl_gatts->ctx == NULL || + response_tlv == NULL || response_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + /* Stop ECP procedure timeout — response is being sent */ + esp_timer_stop(s_esl_gatts->ctx->ecp_timer); + + if (s_esl_gatts->ctx->conn_handle == BLE_HS_CONN_HANDLE_NONE) { + ESP_LOGW(TAG, "ECP response: no active connection"); + return ESP_FAIL; + } + + struct os_mbuf *om = ble_hs_mbuf_from_flat(response_tlv, response_len); + if (om == NULL) { + ESP_LOGE(TAG, "ECP response: mbuf alloc failed"); + return ESP_ERR_NO_MEM; + } + + /* Defer the actual notification to a NimBLE host-task event so it is not + * sent from within a GATT access callback. */ + struct esl_ecp_notify_ctx *ctx = malloc(sizeof(*ctx)); + if (ctx == NULL) { + os_mbuf_free_chain(om); + ESP_LOGE(TAG, "ECP response: event context alloc failed"); + return ESP_ERR_NO_MEM; + } + ctx->conn_handle = s_esl_gatts->ctx->conn_handle; + ctx->val_handle = s_esl_gatts->chr_handles[s_esl_gatts->ecp_chr_idx]; + ctx->om = om; + ble_npl_event_init(&ctx->ev, esl_ecp_notify_event_cb, ctx); + ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &ctx->ev); + + return ESP_OK; +} + +esp_err_t esl_gatts_init(const ble_esl_config_t *config) +{ + esp_err_t err; + + if (s_esl_gatts != NULL) { + ESP_LOGW(TAG, "esl_gatts already initialized"); + return ESP_FAIL; + } + + /* Allocate the module context once. NimBLE will retain pointers into the + * GATT tables held here, so it must persist until esl_gatts_deinit(). */ + s_esl_gatts = calloc(1, sizeof(*s_esl_gatts)); + if (s_esl_gatts == NULL) { + ESP_LOGE(TAG, "Failed to allocate esl_gatts context"); + return ESP_ERR_NO_MEM; + } + s_esl_gatts->ecp_chr_idx = CHR_IDX_ECP; + + s_esl_gatts->ctx = esl_state_ctx_get(); + if (s_esl_gatts->ctx == NULL) { + free(s_esl_gatts); + s_esl_gatts = NULL; + return ESP_FAIL; + } + + /* Build and register GATT service */ + esl_build_gatt_svcs(config); + + int rc = ble_gatts_count_cfg(s_esl_gatts->esl_svcs); + if (rc != 0) { + free(s_esl_gatts); + s_esl_gatts = NULL; + return ESP_FAIL; + } + + rc = ble_gatts_add_svcs(s_esl_gatts->esl_svcs); + if (rc != 0) { + free(s_esl_gatts); + s_esl_gatts = NULL; + return ESP_FAIL; + } + +#if CONFIG_BLE_ESL_OTS_SUPPORT + err = esl_setup_ots(config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTS setup failed"); + /* Leave the module in its pre-init state: drop any partial OTS state + * and the already-registered GATT service. */ + esl_teardown_ots(); + ble_gatts_delete_svc(&s_esl_svc_uuid.u); + free(s_esl_gatts); + s_esl_gatts = NULL; + return err; + } +#endif + +#if CONFIG_BLE_ESL_VENDOR_SPECIFIC + err = esl_setup_dis_pnp(config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "DIS PnP setup failed (non-fatal)"); + } +#endif + + return ESP_OK; +} + +esp_err_t esl_gatts_deinit(void) +{ + if (s_esl_gatts == NULL) { + return ESP_OK; + } + +#if CONFIG_BLE_ESL_OTS_SUPPORT + esl_teardown_ots(); +#endif + + /* Remove GATT service */ + ble_gatts_delete_svc(&s_esl_svc_uuid.u); + + free(s_esl_gatts); + s_esl_gatts = NULL; + + return ESP_OK; +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_pawr.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_pawr.c new file mode 100644 index 00000000000..c0c4e95aedb --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_pawr.c @@ -0,0 +1,627 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_pawr.c + * @brief ESL PAwR (Periodic Advertising with Responses) module + * + * Handles PAwR sync packet reception, command dispatch, response slot + * calculation, response assembly with capacity-limit handling, AES-CCM + * encryption, and response transmission via NimBLE PAwR response API. + */ + +#include +#include + +#include "esp_log.h" +#include "esp_err.h" + +#include "nimble/ble.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "os/os_mbuf.h" + +#include "host/ble_ead.h" + +#include "ble_esl.h" +#include "ble_esl_common.h" +#include "ble_esl_int.h" + +static const char *TAG = "esl_pawr"; + +/* ========================== Constants ========================== */ + +/** Maximum response ESL Payload size in octets */ +#define ESL_PAWR_MAX_RESPONSE_PAYLOAD BLE_ESL_PAYLOAD_MAX_SIZE /* 48 */ + +/** Error response TLV size: opcode (1) + error_code (1) = 2 bytes */ +#define ESL_ERROR_RESP_SIZE 2 + +/** Error response opcode byte */ +#define ESL_ERROR_RESP_OPCODE BLE_ESL_RESP_ERROR /* 0x00 */ + + +/* ========================== Internal Context ========================== */ + +/** + * @brief PAwR module internal state + */ +typedef struct { + uint8_t resp_buf[ESL_PAWR_MAX_RESPONSE_PAYLOAD]; /*!< Response assembly buffer */ + uint8_t response_randomizer[BLE_ESL_RANDOMIZER_SIZE]; /*!< ESL -> AP EAD Randomizer */ + uint16_t sync_handle; /*!< Sync handle for the current PAwR sync (set externally before processing) */ + uint16_t event_counter; /*!< Event counter for the current PAwR subevent report */ + uint8_t subevent; /*!< Subevent number for the current PAwR report */ +} esl_pawr_ctx_t; + +static esl_pawr_ctx_t *s_esl_pawr = NULL; + +/* ========================== Forward Declarations ========================== */ + +static void build_error_response_tlv(uint8_t error_code, uint8_t *out_buf, + uint8_t *out_len); +static bool is_forbidden_sync_opcode(uint8_t opcode); + +/* ========================== Helper Functions ========================== */ + +/** + * @brief Build a 2-byte Error response TLV + * + * @param[in] error_code ESL error code + * @param[out] out_buf Output buffer (min 2 bytes) + * @param[out] out_len Set to 2 on output + */ +static void build_error_response_tlv(uint8_t error_code, uint8_t *out_buf, + uint8_t *out_len) +{ + /* Error response: opcode = 0x00 (Tag=0, Length=0), param = error_code */ + out_buf[0] = ESL_ERROR_RESP_OPCODE; + out_buf[1] = error_code; + *out_len = ESL_ERROR_RESP_SIZE; +} + +/** + * @brief Check if an opcode is forbidden in the Synchronized state + * + * Factory Reset (0x03) and Update Complete (0x04) are not allowed via PAwR. + * + * @param[in] opcode Command opcode byte + * @return true if the opcode is forbidden + */ +static bool is_forbidden_sync_opcode(uint8_t opcode) +{ + return (opcode == BLE_ESL_CMD_FACTORY_RESET || + opcode == BLE_ESL_CMD_UPDATE_COMPLETE); +} + +/* ========================== Public Functions ========================== */ + +esp_err_t esl_pawr_init(void) +{ + if (s_esl_pawr != NULL) { + ESP_LOGW(TAG, "PAwR module already initialized"); + return ESP_ERR_INVALID_STATE; + } + + s_esl_pawr = calloc(1, sizeof(esl_pawr_ctx_t)); + if (s_esl_pawr == NULL) { + ESP_LOGE(TAG, "Failed to allocate PAwR context"); + return ESP_ERR_NO_MEM; + } + + ble_esl_ead_randomizer_init(s_esl_pawr->response_randomizer, + BLE_ESL_EAD_DIR_ESL_TO_AP); + + ESP_LOGI(TAG, "PAwR module initialized"); + return ESP_OK; +} + +void esl_pawr_deinit(void) +{ + if (s_esl_pawr != NULL) { + free(s_esl_pawr); + s_esl_pawr = NULL; + } + ESP_LOGI(TAG, "PAwR module deinitialized"); +} + +void esl_pawr_set_sync_context(uint16_t sync_handle, uint16_t event_counter) +{ + if (s_esl_pawr == NULL) { + ESP_LOGE(TAG, "PAwR module not initialized"); + return; + } + + s_esl_pawr->sync_handle = sync_handle; + s_esl_pawr->event_counter = event_counter; +} + +int16_t esl_pawr_calc_response_slot(const uint8_t *data, + const uint8_t *tlv_offsets, + const uint8_t *tlv_lens, + uint8_t tlv_count, uint8_t esl_id) +{ + /* + * Response slot allocation algorithm: + * 1. Number TLVs sequentially 1..N (all TLVs, including broadcast). + * 2. Skip broadcast TLVs (ESL_ID = 0xFF) — they don't produce responses. + * 3. Find the last individually-addressed TLV for this esl_id. + * 4. If that TLV is number N (1-based), response slot = N - 1 (0-based). + */ + if (data == NULL || tlv_offsets == NULL || + tlv_lens == NULL || tlv_count == 0) { + return -1; + } + + int16_t last_tlv_number = -1; /* 1-based TLV number of last match */ + + for (uint8_t i = 0; i < tlv_count; i++) { + /* + * TLV layout in the payload: [opcode (1 byte)] [params (Length+1 bytes)] + * ESL_ID is the first parameter byte, located at payload[tlv_offsets[i] + 1]. + */ + if (tlv_lens[i] < BLE_ESL_TLV_MIN_SIZE) { + /* TLV too short to contain ESL_ID — skip */ + continue; + } + + uint8_t cmd_esl_id = data[tlv_offsets[i] + 1]; + + /* Skip broadcast TLVs — they don't count for slot allocation */ + if (cmd_esl_id == BLE_ESL_BROADCAST_ADDRESS) { + continue; + } + + /* Check if addressed to our ESL */ + if (cmd_esl_id == esl_id) { + last_tlv_number = (int16_t)(i + 1); /* 1-based */ + } + } + + if (last_tlv_number < 0) { + return -1; /* Not individually addressed */ + } + + return (int16_t)(last_tlv_number - 1); /* 0-based slot */ +} + +esp_err_t esl_pawr_assemble_response(const uint8_t *resp_tlvs[], + const uint8_t resp_tlv_lens[], + uint8_t resp_count, + uint8_t *out_buf, uint8_t *out_len) +{ + if (resp_tlvs == NULL || resp_tlv_lens == NULL || + out_buf == NULL || out_len == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (resp_count == 0) { + *out_len = 0; + return ESP_OK; + } + + /* + * First pass: calculate total size to determine if capacity limit + * substitution is needed. + */ + uint16_t total_size = 0; + for (uint8_t i = 0; i < resp_count; i++) { + total_size += resp_tlv_lens[i]; + } + + if (total_size <= ESL_PAWR_MAX_RESPONSE_PAYLOAD) { + /* All responses fit — concatenate directly */ + uint8_t offset = 0; + for (uint8_t i = 0; i < resp_count; i++) { + memcpy(out_buf + offset, resp_tlvs[i], resp_tlv_lens[i]); + offset += resp_tlv_lens[i]; + } + *out_len = offset; + return ESP_OK; + } + + /* + * Responses exceed 48 bytes. Substitute trailing TLVs with + * Error: Capacity Limit (2 bytes: opcode 0x00, param 0x07). + * + * Strategy: keep as many leading TLVs as possible, then replace + * the rest with 2-byte Capacity Limit error responses. + */ + uint8_t offset = 0; + uint8_t keep_count = 0; + + /* + * Determine how many TLVs from the front we can keep while still + * fitting the remaining substituted error TLVs within the limit. + */ + for (uint8_t i = 0; i < resp_count; i++) { + /* How many TLVs remain after this one (to be substituted) */ + uint8_t remaining_subs = resp_count - i - 1; + uint16_t remaining_error_bytes = (uint16_t)remaining_subs * ESL_ERROR_RESP_SIZE; + uint16_t projected = (uint16_t)offset + resp_tlv_lens[i] + remaining_error_bytes; + + if (projected <= ESL_PAWR_MAX_RESPONSE_PAYLOAD) { + /* This TLV can be kept */ + memcpy(out_buf + offset, resp_tlvs[i], resp_tlv_lens[i]); + offset += resp_tlv_lens[i]; + keep_count = i + 1; + } else { + /* This TLV and all subsequent must be replaced */ + break; + } + } + + /* Fill remaining slots with Capacity Limit error responses */ + for (uint8_t i = keep_count; i < resp_count; i++) { + if ((uint16_t)offset + ESL_ERROR_RESP_SIZE > ESL_PAWR_MAX_RESPONSE_PAYLOAD) { + /* Should not happen if algorithm is correct, but guard anyway */ + break; + } + out_buf[offset++] = ESL_ERROR_RESP_OPCODE; + out_buf[offset++] = BLE_ESL_ERR_CAPACITY_LIMIT; + } + + *out_len = offset; + return ESP_OK; +} + +esp_err_t esl_pawr_encrypt_and_send(uint8_t slot, const uint8_t *payload, + uint8_t payload_len) +{ + if (s_esl_pawr == NULL) { + ESP_LOGE(TAG, "PAwR module not initialized"); + return ESP_ERR_INVALID_STATE; + } + + if (payload == NULL || payload_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (payload_len > ESL_PAWR_MAX_RESPONSE_PAYLOAD) { + ESP_LOGE(TAG, "payload_len %u exceeds max %u", payload_len, ESL_PAWR_MAX_RESPONSE_PAYLOAD); + return ESP_ERR_INVALID_ARG; + } + + /* Get the ESL Response Key Material */ + const ble_esl_key_material_t *resp_key = esl_get_resp_key(); + if (resp_key == NULL) { + ESP_LOGE(TAG, "Response key material not configured"); + return ESP_ERR_INVALID_STATE; + } + + /* + * Step 1: Build inner AD structure (plaintext to encrypt) + * [inner_len (1)] [ESL Tag 0x34 (1)] [ESL Payload (payload_len)] + * inner_len = 1 (tag) + payload_len + * The AP strips this wrapper after decryption (ble_esl_ap_pawr_parse_response), + * mirroring the AP transmit path (ble_esl_ap_pawr_send). Without it the AP + * reads the first response TLV bytes as the inner Len/Tag and rejects the + * response (tag != 0x34). + */ + uint8_t inner_len = 1 + payload_len; /* ESL Tag + ESL Payload */ + uint8_t plaintext_len = 1 + inner_len; /* Len octet + inner_len */ + uint8_t plaintext[1 + 1 + ESL_PAWR_MAX_RESPONSE_PAYLOAD]; + + plaintext[0] = inner_len; + plaintext[1] = BLE_ESL_AD_TYPE_ESL; /* 0x34 */ + memcpy(&plaintext[2], payload, payload_len); + + /* + * Step 2: Output packet layout (outer AD structure) + * [AD Length (1)] [AD Type 0x31 (1)] [Randomizer (5)] [Ciphertext (plaintext_len)] [MIC (4)] + * + * AD Length = 1 (type) + 5 (randomizer) + plaintext_len (ciphertext) + 4 (MIC) + */ + uint16_t ad_length = 1 + BLE_EAD_RANDOMIZER_SIZE + plaintext_len + BLE_EAD_MIC_SIZE; + uint16_t pkt_total = 1 + ad_length; /* 1 byte for AD Length field itself */ + uint8_t pkt_buf[1 + 1 + BLE_EAD_RANDOMIZER_SIZE + (1 + 1 + ESL_PAWR_MAX_RESPONSE_PAYLOAD) + BLE_EAD_MIC_SIZE]; + uint8_t pkt_len = 0; + + assert(pkt_total <= sizeof(pkt_buf)); + + /* Outer AD structure header */ + pkt_buf[pkt_len++] = ad_length; + pkt_buf[pkt_len++] = BLE_ESL_AD_TYPE_ENCRYPTED_DATA; /* 0x31 */ + + /* Keep response timing tight by avoiding HCI RAND in this PAwR callback. + * Only this host-task callback touches the Randomizer, so no lock needed. */ + esp_err_t enc_err = ble_esl_ead_encrypt(resp_key->session_key, resp_key->iv, + s_esl_pawr->response_randomizer, + plaintext, plaintext_len, + &pkt_buf[pkt_len]); + if (enc_err != ESP_OK) { + ESP_LOGE(TAG, "PAwR response EAD encrypt failed: 0x%x", enc_err); + return enc_err; + } + + pkt_len += BLE_EAD_RANDOMIZER_SIZE + plaintext_len + BLE_EAD_MIC_SIZE; + + /* Transmit via NimBLE PAwR response API */ + struct ble_gap_periodic_adv_response_params params = { + .request_event = s_esl_pawr->event_counter, + .request_subevent = s_esl_pawr->subevent, + .response_subevent = s_esl_pawr->subevent, + .response_slot = slot, + }; + + struct os_mbuf *om = os_msys_get_pkthdr(pkt_len, 0); + if (om == NULL) { + ESP_LOGE(TAG, "Failed to allocate mbuf for response"); + return ESP_ERR_NO_MEM; + } + + int rc = os_mbuf_append(om, pkt_buf, pkt_len); + if (rc != 0) { + ESP_LOGE(TAG, "os_mbuf_append failed: %d", rc); + os_mbuf_free_chain(om); + return ESP_FAIL; + } + + rc = ble_gap_periodic_adv_set_response_data(s_esl_pawr->sync_handle, ¶ms, om); + os_mbuf_free_chain(om); + + if (rc != 0) { + ESP_LOGE(TAG, "ble_gap_periodic_adv_set_response_data failed: 0x%x", rc); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "PAwR response queued: event=%u subevent=%u slot=%u payload_len=%u pkt_len=%u", + s_esl_pawr->event_counter, s_esl_pawr->subevent, slot, payload_len, pkt_len); + + return ESP_OK; +} + +esp_err_t esl_pawr_process_sync_packet(uint8_t subevent, const uint8_t *data, + uint8_t data_len) +{ + if (s_esl_pawr == NULL) { + ESP_LOGE(TAG, "PAwR module not initialized"); + return ESP_ERR_INVALID_STATE; + } + + if (data == NULL || data_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + /* + * --- Step 0: Strip the inner AD structure wrapping the ESL Payload --- + * + * The decrypted plaintext is an AD structure (CSS Encrypted Data type): + * [inner_len(1)] [ESL Tag 0x34(1)] [ESL Payload] + * The ESL Payload (Group_ID + TLVs) begins after this 2-byte header. + * This mirrors the AP transmit/parse paths (ble_esl_ap_pawr_send / + * ble_esl_ap_pawr_parse_response); without it the header bytes get + * misread as Group_ID / opcode and payload decode fails. + */ + if (data_len < 2 || data[1] != BLE_ESL_AD_TYPE_ESL) { + ESP_LOGW(TAG, "Invalid inner AD tag: 0x%02x (expected 0x%02x)", + data_len < 2 ? 0 : data[1], BLE_ESL_AD_TYPE_ESL); + return ESP_ERR_INVALID_SIZE; + } + + uint8_t inner_len = data[0]; /* length of ESL Tag + ESL Payload */ + if (inner_len < 1 || (uint16_t)(1 + inner_len) > data_len) { + ESP_LOGW(TAG, "Inner AD length mismatch: inner_len=%u, data_len=%u", + inner_len, data_len); + return ESP_ERR_INVALID_SIZE; + } + + /* Advance past the [inner_len][0x34] header to the ESL Payload itself */ + data += 2; + data_len = inner_len - 1; /* subtract the ESL Tag octet */ + + /* --- Step 1: Decode the ESL Payload to extract Group_ID and TLVs --- */ + uint8_t group_id = 0; + uint8_t tlv_count = 0; + uint8_t tlv_offsets[ESL_MAX_TLVS_PER_PAYLOAD]; + uint8_t tlv_lens[ESL_MAX_TLVS_PER_PAYLOAD]; + + ESP_LOG_BUFFER_HEX("PA data", data, data_len); + + esp_err_t err = ble_esl_payload_decode(data, data_len, &group_id, + &tlv_count, tlv_offsets, tlv_lens, + ESL_MAX_TLVS_PER_PAYLOAD); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Payload decode failed: 0x%x", err); + return err; + } + + /* --- Step 2: Validate Group_ID against ESL's configured address --- */ + uint8_t my_esl_id = 0; + uint8_t my_group_id = 0; + err = esl_get_address(&my_esl_id, &my_group_id); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ESL address not configured"); + return ESP_ERR_INVALID_STATE; + } + + if (group_id != my_group_id) { + ESP_LOGD(TAG, "Group_ID mismatch: received %u, expected %u", + group_id, my_group_id); + return ESP_OK; /* Silently discard */ + } + + /* --- Step 3: Validate ESL is in Synchronized state --- */ + ble_esl_state_t state = ble_esl_get_state(); + if (state != BLE_ESL_STATE_SYNCHRONIZED) { + ESP_LOGW(TAG, "Not in Synchronized state (current: %d), ignoring", + state); + return ESP_ERR_INVALID_STATE; + } + + /* Store subevent for use by encrypt_and_send */ + s_esl_pawr->subevent = subevent; + + /* --- Step 4: Iterate TLVs, dispatch commands, collect responses --- */ + + /* Response collection arrays */ + uint8_t resp_bufs[ESL_MAX_TLVS_PER_PAYLOAD][BLE_ESL_TLV_MAX_SIZE]; + uint8_t resp_lens[ESL_MAX_TLVS_PER_PAYLOAD]; + const uint8_t *resp_ptrs[ESL_MAX_TLVS_PER_PAYLOAD]; + uint8_t resp_count = 0; + bool has_individual = false; + uint8_t individual_tlv_count = 0; + uint8_t broadcast_tlv_count = 0; + + for (uint8_t i = 0; i < tlv_count; i++) { + /* Decode each TLV */ + uint8_t opcode = 0; + const uint8_t *params = NULL; + uint8_t params_len = 0; + + err = ble_esl_tlv_decode(data + tlv_offsets[i], tlv_lens[i], + &opcode, ¶ms, ¶ms_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "TLV %u decode failed: 0x%x, skipping", i, err); + /* Use Length field for forward compatibility — skip this TLV */ + continue; + } + + /* ESL_ID is the first parameter byte */ + if (params_len < 1) { + ESP_LOGW(TAG, "TLV %u has no ESL_ID parameter, skipping", i); + continue; + } + uint8_t cmd_esl_id = params[0]; + + if (cmd_esl_id == my_esl_id) { + /* ---- Individually addressed to this ESL ---- */ + has_individual = true; + individual_tlv_count++; + + /* Forbidden over PAwR (Factory Reset / Update Complete): do NOT + * execute the command, but since it is individually addressed to + * this ESL we must still send an Error: Invalid State (0x03) + * response in the assigned slot (per esl_pawr.md step 6c and + * ESLS/SR/UPD/BV-01-C). Only broadcast forbidden commands are + * dropped without a response (see the broadcast branch below). */ + if (is_forbidden_sync_opcode(opcode)) { + ESP_LOGW(TAG, "Forbidden opcode 0x%02x (unicast) in Synchronized state, responding Invalid State", + opcode); + uint8_t err_len = 0; + build_error_response_tlv(BLE_ESL_ERR_INVALID_STATE, + resp_bufs[resp_count], &err_len); + resp_lens[resp_count] = err_len; + resp_ptrs[resp_count] = resp_bufs[resp_count]; + resp_count++; + continue; + } + + /* Dispatch the command and collect response */ + ble_esl_cmd_result_t result; + memset(&result, 0, sizeof(result)); + + err = esl_cmd_dispatch(opcode, params, params_len, &result, ESL_TRANSPORT_PAWR); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Command dispatch failed for TLV %u: 0x%x", + i, err); + /* Build unspecified error response */ + uint8_t err_len = 0; + build_error_response_tlv(BLE_ESL_ERR_UNSPECIFIED, + resp_bufs[resp_count], &err_len); + resp_lens[resp_count] = err_len; + resp_ptrs[resp_count] = resp_bufs[resp_count]; + resp_count++; + continue; + } + + if (result.has_response) { + /* Encode the response TLV */ + uint8_t encoded_len = 0; + err = ble_esl_tlv_encode(result.resp_opcode, + result.resp_params, + result.resp_params_len, + resp_bufs[resp_count], + &encoded_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Response TLV encode failed for TLV %u", i); + uint8_t err_len = 0; + build_error_response_tlv(BLE_ESL_ERR_UNSPECIFIED, + resp_bufs[resp_count], &err_len); + resp_lens[resp_count] = err_len; + } else { + resp_lens[resp_count] = encoded_len; + } + resp_ptrs[resp_count] = resp_bufs[resp_count]; + resp_count++; + } + + } else if (cmd_esl_id == BLE_ESL_BROADCAST_ADDRESS) { + /* ---- Broadcast message ---- */ + broadcast_tlv_count++; + + /* Forbidden broadcast commands: just skip, no response */ + if (is_forbidden_sync_opcode(opcode)) { + ESP_LOGW(TAG, "Forbidden broadcast opcode 0x%02x, skipping", + opcode); + continue; + } + + /* Dispatch broadcast command, discard result */ + ble_esl_cmd_result_t result; + memset(&result, 0, sizeof(result)); + err = esl_cmd_dispatch(opcode, params, params_len, &result, ESL_TRANSPORT_PAWR); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Broadcast dispatch failed for TLV %u: 0x%x", + i, err); + } + /* No response for broadcast */ + + } else { + /* ---- Command for a different ESL — skip ---- */ + individual_tlv_count++; + ESP_LOGD(TAG, "TLV %u for ESL_ID 0x%02x, skipping", i, cmd_esl_id); + } + } + + /* --- Step 5: If no individually-addressed commands, do not transmit --- */ + if (!has_individual || resp_count == 0) { + ESP_LOGD(TAG, "No individually-addressed commands for this ESL"); + return ESP_OK; + } + + /* --- Step 6: Calculate response slot --- */ + int16_t slot = esl_pawr_calc_response_slot(data, tlv_offsets, tlv_lens, + tlv_count, my_esl_id); + if (slot < 0) { + ESP_LOGW(TAG, "Response slot calculation returned -1, not transmitting"); + return ESP_OK; + } + + /* Track slot info for logging/diagnostics */ + esl_pawr_slot_info_t slot_info = { + .slot_number = (uint8_t)slot, + .tlv_count = individual_tlv_count, + .broadcast_count = broadcast_tlv_count, + }; + ESP_LOGD(TAG, "Slot info: slot=%u, individual_tlvs=%u, broadcast_tlvs=%u", + slot_info.slot_number, slot_info.tlv_count, slot_info.broadcast_count); + + /* --- Step 7: Assemble response payload --- */ + uint8_t assembled_payload[ESL_PAWR_MAX_RESPONSE_PAYLOAD]; + uint8_t assembled_len = 0; + + err = esl_pawr_assemble_response(resp_ptrs, resp_lens, resp_count, + assembled_payload, &assembled_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Response assembly failed: 0x%x", err); + return err; + } + + if (assembled_len == 0) { + ESP_LOGW(TAG, "Assembled response is empty"); + return ESP_OK; + } + + /* --- Step 8: Encrypt and send --- */ + err = esl_pawr_encrypt_and_send((uint8_t)slot, assembled_payload, + assembled_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Encrypt and send failed: 0x%x", err); + } + + return err; +} diff --git a/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_state.c b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_state.c new file mode 100644 index 00000000000..5832bfa1299 --- /dev/null +++ b/components/bt/ble_profiles/nimble/ble_esl/src/esl/esl_state.c @@ -0,0 +1,1453 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file esl_state.c + * @brief ESL state machine, lifecycle APIs, security, and timeout handling. + */ + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "nimble/ble.h" +#include "host/ble_hs.h" +#include "host/ble_gap.h" +#include "host/ble_gatt.h" +#include "host/ble_uuid.h" +#include "host/ble_store.h" + +#include "ble_esl.h" +#include "ble_esl_int.h" +#include "ble_esl_state_int.h" + +static const char *TAG = "esl_state"; + +#define ESL_EXT_ADV_INSTANCE 0 + +static esl_state_ctx_t *s_ctx = NULL; + +/* ========================== Forward Declarations ========================== */ + +static int esl_gap_event_handler(struct ble_gap_event *event, void *arg); +static void sync_timeout_cb(void *arg); +static void unsync_timeout_cb(void *arg); +static void ecp_timeout_cb(void *arg); +static esp_err_t esl_start_advertising(void); +static esp_err_t esl_stop_advertising(void); +static bool is_transition_valid(ble_esl_state_t from, ble_esl_state_t to); + +/* ========================== State Machine ========================== */ + +/** + * @brief Check if a state transition is valid + */ +static bool is_transition_valid(ble_esl_state_t from, ble_esl_state_t to) +{ + switch (from) { + case BLE_ESL_STATE_UNASSOCIATED: + return (to == BLE_ESL_STATE_CONFIGURING); + case BLE_ESL_STATE_CONFIGURING: + return (to == BLE_ESL_STATE_UNASSOCIATED || + to == BLE_ESL_STATE_SYNCHRONIZED || + to == BLE_ESL_STATE_UNSYNCHRONIZED); + case BLE_ESL_STATE_SYNCHRONIZED: + return (to == BLE_ESL_STATE_UPDATING || + to == BLE_ESL_STATE_UNSYNCHRONIZED); + case BLE_ESL_STATE_UPDATING: + return (to == BLE_ESL_STATE_UNASSOCIATED || + to == BLE_ESL_STATE_SYNCHRONIZED || + to == BLE_ESL_STATE_UNSYNCHRONIZED); + case BLE_ESL_STATE_UNSYNCHRONIZED: + return (to == BLE_ESL_STATE_UNASSOCIATED || + to == BLE_ESL_STATE_UPDATING); + default: + return false; + } +} + +esl_state_ctx_t *esl_state_ctx_get(void) +{ + return s_ctx; +} + +uint16_t esl_state_get_conn_handle(void) +{ + if (s_ctx == NULL) { + return BLE_HS_CONN_HANDLE_NONE; + } + return s_ctx->conn_handle; +} + +bool esl_is_pawr_synced(void) +{ + if (s_ctx == NULL) { + return false; + } + return s_ctx->pawr_synced; +} + +/** + * @brief Terminate the tracked ACL link, if any + * + * On success the handle is deliberately kept so the disconnect event handler can + * match it and run its cleanup. BLE_HS_ENOTCONN means the host has no such + * connection: the handle is stale and no disconnect event will ever arrive, so it + * is cleared here. Any other failure may leave the link up (e.g. BLE_HS_EALREADY, + * termination already in progress), so the handle is kept to match the disconnect + * event that still follows. + */ +static void esl_terminate_active_conn(void) +{ + if (s_ctx->conn_handle == BLE_HS_CONN_HANDLE_NONE) { + return; + } + + int rc = ble_gap_terminate(s_ctx->conn_handle, BLE_ERR_REM_USER_CONN_TERM); + if (rc == BLE_HS_ENOTCONN) { + ESP_LOGW(TAG, "Connection %u already gone — clearing stale handle", + s_ctx->conn_handle); + s_ctx->conn_handle = BLE_HS_CONN_HANDLE_NONE; + } else if (rc != 0) { + ESP_LOGW(TAG, "Failed to terminate connection %u: rc=%d", + s_ctx->conn_handle, rc); + } +} + +esp_err_t esl_state_transition(ble_esl_state_t new_state) +{ + if (s_ctx == NULL) { + return ESP_ERR_INVALID_STATE; + } + + ble_esl_state_t old_state = s_ctx->state; + if (old_state == new_state) { + return ESP_OK; + } + + if (!is_transition_valid(old_state, new_state)) { + ESP_LOGW(TAG, "Invalid state transition: %d -> %d", old_state, new_state); + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "State transition: %d -> %d", old_state, new_state); + + /* Disarm timers from old state */ + if (old_state == BLE_ESL_STATE_SYNCHRONIZED) { + esp_timer_stop(s_ctx->sync_timer); + } + if (old_state == BLE_ESL_STATE_UNSYNCHRONIZED) { + esp_timer_stop(s_ctx->unsync_timer); + } + + /* Execute side effects for the new state */ + switch (new_state) { + case BLE_ESL_STATE_UNASSOCIATED: + /* Stop active advertising first to allow unpair to succeed + * and to ensure advertising restarts with correct parameters */ + esl_stop_advertising(); + /* Clear stored data (keys, address, timed commands) */ + esl_clear_stored_data(false); + s_ctx->config_complete = 0; + s_ctx->pawr_synced = false; + /* Delete bonding info (requires advertising to be stopped) */ + if (s_ctx->has_bonded_peer) { + int unpair_rc = ble_gap_unpair(&s_ctx->bonded_peer_addr); + if (unpair_rc != 0) { + ESP_LOGW(TAG, "ble_gap_unpair failed: rc=%d", unpair_rc); + } + s_ctx->has_bonded_peer = false; + } + /* Start advertising with Unassociated parameters if started */ + s_ctx->state = new_state; + if (s_ctx->started) { + esl_start_advertising(); + } + break; + + case BLE_ESL_STATE_CONFIGURING: + s_ctx->config_complete = 0; + /* Reset PAST/Update Complete tracking so a stale flag from a previous + * provisioning cycle cannot prematurely advance to Synchronized. */ + s_ctx->past_received = false; + s_ctx->update_complete_received = false; + s_ctx->pawr_synced = false; + s_ctx->state = new_state; + /* No advertising — ACL link is active */ + break; + + case BLE_ESL_STATE_SYNCHRONIZED: + s_ctx->state = new_state; + s_ctx->pawr_synced = true; + /* Stop legacy advertising if any */ + esl_stop_advertising(); + /* Disconnect ACL link if one is active */ + esl_terminate_active_conn(); + /* Arm 60-minute sync timeout only if service is running */ + if (s_ctx->started) { + esp_timer_start_once(s_ctx->sync_timer, ESL_STATE_TIMEOUT_US); + } + break; + + case BLE_ESL_STATE_UPDATING: + s_ctx->state = new_state; + /* Reset PAST/Update Complete tracking flags. pawr_synced is deliberately + * left untouched: while Updating, the ESL only ignores PAwR data — the + * sync to the AP's train is not lost, so the Basic State Synchronized + * bit must keep reporting it. */ + s_ctx->past_received = false; + s_ctx->update_complete_received = false; + /* Disarm sync timeout (already done above if from Synchronized) */ + /* No advertising — ACL link is active */ + break; + + case BLE_ESL_STATE_UNSYNCHRONIZED: + s_ctx->state = new_state; + s_ctx->pawr_synced = false; + /* Arm 60-minute unsync timeout only if service is running */ + if (s_ctx->started) { + esp_timer_start_once(s_ctx->unsync_timer, ESL_STATE_TIMEOUT_US); + /* Start connectable advertising */ + esl_start_advertising(); + } + break; + + default: + return ESP_ERR_INVALID_ARG; + } + + /* Fire state changed callback */ + esl_notify_app(BLE_ESL_EVT_STATE_CHANGED, &(ble_esl_cb_param_t){ + .state_changed = { + .prev_state = old_state, + .new_state = new_state, + } + }); + + return ESP_OK; +} + +/* ========================== Absolute Time ========================== */ + +uint32_t esl_get_abs_time(void) +{ + if (s_ctx == NULL) { + return 0; + } + int64_t now_us = esp_timer_get_time(); + int64_t elapsed_ms = (now_us - s_ctx->abs_time_offset_us) / 1000; + return (uint32_t)(s_ctx->abs_time_base + (uint32_t)elapsed_ms); +} + +/* ========================== App Callback ========================== */ + +void esl_notify_app(ble_esl_event_t event, ble_esl_cb_param_t *param) +{ + if (s_ctx != NULL && s_ctx->app_cb != NULL) { + s_ctx->app_cb(event, param); + } +} + +/* ========================== Getters ========================== */ + +esp_err_t esl_get_address(uint8_t *esl_id, uint8_t *group_id) +{ + if (s_ctx == NULL || !s_ctx->address_valid) { + return ESP_ERR_INVALID_STATE; + } + if (esl_id != NULL) { + *esl_id = s_ctx->esl_address.esl_id; + } + if (group_id != NULL) { + *group_id = BLE_ESL_ADDR_GROUP_ID(s_ctx->esl_address); + } + return ESP_OK; +} + +const ble_esl_config_t *esl_get_config(void) +{ + if (s_ctx == NULL) { + return NULL; + } + return &s_ctx->config; +} + +const ble_esl_key_material_t *esl_get_ap_sync_key(void) +{ + if (s_ctx == NULL || !s_ctx->ap_sync_key_valid) { + return NULL; + } + return &s_ctx->ap_sync_key; +} + +const ble_esl_key_material_t *esl_get_resp_key(void) +{ + if (s_ctx == NULL || !s_ctx->resp_key_valid) { + return NULL; + } + return &s_ctx->resp_key; +} + +/* ========================== Update Complete Notification ========================== */ + +void esl_notify_update_complete(void) +{ + if (s_ctx == NULL) { + return; + } + + s_ctx->update_complete_received = true; + + /* Advance to Synchronized only once PAST (PAwR sync transfer) has also + * completed. Entering Synchronized disconnects the ACL link that carries + * PAST, so transitioning on Update Complete alone would leave the ESL + * un-synced to the PAwR train and unable to answer commands (e.g. Ping). + * Whichever of {Update Complete, PAST} arrives last triggers the + * transition — the PAST path performs the symmetric check in + * handle_gap_periodic_transfer(). */ + switch (s_ctx->state) { + case BLE_ESL_STATE_CONFIGURING: + /* First provisioning also requires all mandatory configuration writes. */ + if (s_ctx->past_received && + (s_ctx->config_complete & CONFIG_COMPLETE_MASK) == CONFIG_COMPLETE_MASK) { + esl_state_transition(BLE_ESL_STATE_SYNCHRONIZED); + } else { + ESP_LOGI(TAG, "Update Complete received in Configuring — waiting for PAST / config"); + } + break; + + case BLE_ESL_STATE_UPDATING: + if (s_ctx->past_received) { + esl_state_transition(BLE_ESL_STATE_SYNCHRONIZED); + } else { + ESP_LOGI(TAG, "Update Complete received — waiting for PAST"); + } + break; + + default: + ESP_LOGW(TAG, "Update Complete received in unexpected state %d", s_ctx->state); + break; + } +} + +/* ========================== PAwR Sync Teardown ========================== */ + +/** + * @brief Terminate the active PAwR periodic sync (if any) and clear its handle + * + * @return ESP_OK if no sync is active or the sync is gone (terminated here or + * already unknown to the host), ESP_FAIL if the host still owns the sync + */ +static esp_err_t esl_terminate_pawr_sync(void) +{ + if (s_ctx == NULL || s_ctx->pawr_sync_handle == BLE_HS_CONN_HANDLE_NONE) { + return ESP_OK; + } + + int rc = ble_gap_periodic_adv_sync_terminate(s_ctx->pawr_sync_handle); + if (rc != 0 && rc != BLE_HS_ENOTCONN) { + /* Anything other than ENOTCONN (host no longer knows this sync, e.g. it + * was lost concurrently) means the host still holds the periodic sync. + * Keep the handle so the caller can retry, and keep pawr_synced so the + * Basic State Synchronized bit stays truthful. */ + ESP_LOGW(TAG, "Failed to terminate PAwR sync %u: rc=%d", + s_ctx->pawr_sync_handle, rc); + return ESP_FAIL; + } + + s_ctx->pawr_sync_handle = BLE_HS_CONN_HANDLE_NONE; + s_ctx->pawr_synced = false; + return ESP_OK; +} + +/* ========================== Clear Stored Data ========================== */ + +void esl_clear_stored_data(bool clear_images) +{ + if (s_ctx == NULL) { + return; + } + + /* Zero keys */ + memset(&s_ctx->ap_sync_key, 0, sizeof(s_ctx->ap_sync_key)); + memset(&s_ctx->resp_key, 0, sizeof(s_ctx->resp_key)); + s_ctx->ap_sync_key_valid = false; + s_ctx->resp_key_valid = false; + + /* Zero address */ + memset(&s_ctx->esl_address, 0, sizeof(s_ctx->esl_address)); + s_ctx->address_valid = false; + + /* Drop the PAwR synchronization along with the key material that feeds it. + * A failure is logged by the helper, which then keeps the handle so a later + * teardown (state transition, stop, deinit) can retry. */ + (void)esl_terminate_pawr_sync(); + + /* Cancel all timed commands */ + esl_cmd_display_cancel_all(); + esl_cmd_led_cancel_all(); + + (void)clear_images; + /* Image clearing would be handled by the application via factory reset event */ +} + +/* ========================== GAP Advertising ========================== */ + +static esp_err_t esl_start_advertising(void) +{ + if (ble_gap_ext_adv_active(ESL_EXT_ADV_INSTANCE)) { + return ESP_OK; + } + + struct ble_gap_ext_adv_params params; + struct os_mbuf *data; + int rc; + + ble_esl_state_t state = s_ctx->state; + + if (state == BLE_ESL_STATE_UNASSOCIATED) { + /* Undirected Connectable, General Discoverable with ESL Service UUID */ + memset(¶ms, 0, sizeof(params)); + params.connectable = 1; + params.own_addr_type = BLE_OWN_ADDR_PUBLIC; + params.primary_phy = BLE_HCI_LE_PHY_1M; + params.secondary_phy = BLE_HCI_LE_PHY_1M; + params.tx_power = 127; + params.sid = 1; + + rc = ble_gap_ext_adv_configure(ESL_EXT_ADV_INSTANCE, ¶ms, NULL, + esl_gap_event_handler, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to configure ext adv: rc=%d", rc); + return ESP_FAIL; + } + + /* Build advertising data: Flags + ESL Service UUID */ + uint8_t adv_data[] = { + 0x02, BLE_HS_ADV_TYPE_FLAGS, + BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP, + 0x03, BLE_HS_ADV_TYPE_COMP_UUIDS16, + (uint8_t)(BLE_ESL_SVC_UUID & 0xFF), + (uint8_t)(BLE_ESL_SVC_UUID >> 8), + }; + + data = os_msys_get_pkthdr(sizeof(adv_data), 0); + if (data == NULL) { + ESP_LOGE(TAG, "Failed to allocate mbuf for adv data"); + return ESP_ERR_NO_MEM; + } + + rc = os_mbuf_append(data, adv_data, sizeof(adv_data)); + if (rc != 0) { + os_mbuf_free_chain(data); + ESP_LOGE(TAG, "Failed to append adv data to mbuf: rc=%d", rc); + return ESP_FAIL; + } + + rc = ble_gap_ext_adv_set_data(ESL_EXT_ADV_INSTANCE, data); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to set ext adv data: rc=%d", rc); + return ESP_FAIL; + } + + /* Start advertising indefinitely (duration=0, max_events=0) */ + rc = ble_gap_ext_adv_start(ESL_EXT_ADV_INSTANCE, 0, 0); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start ext adv: rc=%d", rc); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Extended advertising started (Unassociated, bondable)"); + + } else if (state == BLE_ESL_STATE_UNSYNCHRONIZED) { + /* GAP Connectable mode — no specific AD type mandated */ + memset(¶ms, 0, sizeof(params)); + params.connectable = 1; + params.own_addr_type = BLE_OWN_ADDR_PUBLIC; + params.primary_phy = BLE_HCI_LE_PHY_1M; + params.secondary_phy = BLE_HCI_LE_PHY_1M; + params.tx_power = 127; + params.sid = 1; + + rc = ble_gap_ext_adv_configure(ESL_EXT_ADV_INSTANCE, ¶ms, NULL, + esl_gap_event_handler, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to configure ext adv: rc=%d", rc); + return ESP_FAIL; + } + + /* Build advertising data: Flags only */ + uint8_t adv_data[] = { + 0x02, BLE_HS_ADV_TYPE_FLAGS, BLE_HS_ADV_F_BREDR_UNSUP, + }; + + data = os_msys_get_pkthdr(sizeof(adv_data), 0); + if (data == NULL) { + ESP_LOGE(TAG, "Failed to allocate mbuf for adv data"); + return ESP_ERR_NO_MEM; + } + + rc = os_mbuf_append(data, adv_data, sizeof(adv_data)); + if (rc != 0) { + os_mbuf_free_chain(data); + ESP_LOGE(TAG, "Failed to append adv data to mbuf: rc=%d", rc); + return ESP_FAIL; + } + + rc = ble_gap_ext_adv_set_data(ESL_EXT_ADV_INSTANCE, data); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to set ext adv data: rc=%d", rc); + return ESP_FAIL; + } + + /* Start advertising indefinitely (duration=0, max_events=0) */ + rc = ble_gap_ext_adv_start(ESL_EXT_ADV_INSTANCE, 0, 0); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to start ext adv: rc=%d", rc); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Extended advertising started (Unsynchronized, connectable)"); + } + + return ESP_OK; +} + +static esp_err_t esl_stop_advertising(void) +{ + if (ble_gap_ext_adv_active(ESL_EXT_ADV_INSTANCE)) { + int rc = ble_gap_ext_adv_stop(ESL_EXT_ADV_INSTANCE); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGW(TAG, "Failed to stop ext adv: rc=%d", rc); + return ESP_FAIL; + } + } + return ESP_OK; +} + +/* ========================== GAP Event Handler ========================== */ + +/** + * @brief Handle a new connection event + */ +static void handle_gap_connect(struct ble_gap_event *event) +{ + if (event->connect.status != 0) { + ESP_LOGW(TAG, "Connection failed: status=%d", event->connect.status); + /* Restart advertising if needed */ + if (s_ctx->started && + (s_ctx->state == BLE_ESL_STATE_UNASSOCIATED || + s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED)) { + esl_start_advertising(); + } + return; + } + + /* Reject secondary connections if one is already active */ + if (s_ctx->conn_handle != BLE_HS_CONN_HANDLE_NONE) { + ESP_LOGW(TAG, "Already connected (handle=%d) — terminating new connection %d", + s_ctx->conn_handle, event->connect.conn_handle); + ble_gap_terminate(event->connect.conn_handle, BLE_ERR_REM_USER_CONN_TERM); + return; + } + + s_ctx->conn_handle = event->connect.conn_handle; + ESP_LOGI(TAG, "Connected: handle=%d, state=%d", + s_ctx->conn_handle, s_ctx->state); + + /* When reconnected by the trusted AP — from Unsynchronized via connectable + * advertising, or from Synchronized via the Periodic Advertising Connection + * procedure (AUX_CONNECT_REQ in our PAwR subevent) — request security so the + * stored LTK is used to encrypt the link. */ + if (s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED || + s_ctx->state == BLE_ESL_STATE_SYNCHRONIZED) { + int rc = ble_gap_security_initiate(s_ctx->conn_handle); + if (rc != 0) { + ESP_LOGW(TAG, "Security initiation failed: rc=%d", rc); + } + } +} + +/** + * @brief Handle a disconnect event + */ +static void handle_gap_disconnect(struct ble_gap_event *event) +{ + uint16_t handle = event->disconnect.conn.conn_handle; + ESP_LOGI(TAG, "Disconnected: handle=%d reason=%d state=%d", + handle, event->disconnect.reason, s_ctx->state); + + /* Ignore disconnect for a connection we are not tracking */ + if (handle != s_ctx->conn_handle) { + ESP_LOGW(TAG, "Disconnect for unknown handle %d (active=%d) — ignoring", + handle, s_ctx->conn_handle); + return; + } + + s_ctx->conn_handle = BLE_HS_CONN_HANDLE_NONE; + + /* If deinit is pending, signal the semaphore and skip state transitions */ + if (s_ctx->deinit_pending) { + if (s_ctx->deinit_sem != NULL) { + xSemaphoreGive(s_ctx->deinit_sem); + } + return; + } + + /* If factory reset is pending, clear all data and transition to Unassociated */ + if (esl_is_factory_reset_pending()) { + esl_clear_stored_data(true); + esl_state_transition(BLE_ESL_STATE_UNASSOCIATED); + esl_clear_factory_reset_pending(); + return; + } + + switch (s_ctx->state) { + case BLE_ESL_STATE_CONFIGURING: + if ((s_ctx->config_complete & CONFIG_COMPLETE_MASK) == CONFIG_COMPLETE_MASK) { + /* Configuration complete — go to Unsynchronized */ + esl_state_transition(BLE_ESL_STATE_UNSYNCHRONIZED); + } else { + /* Configuration incomplete — discard and go to Unassociated */ + esl_state_transition(BLE_ESL_STATE_UNASSOCIATED); + } + break; + + case BLE_ESL_STATE_UPDATING: + /* Link loss during Updating — go to Unsynchronized */ + esl_state_transition(BLE_ESL_STATE_UNSYNCHRONIZED); + break; + + default: + /* For other states, restart advertising if needed */ + if (s_ctx->started && + (s_ctx->state == BLE_ESL_STATE_UNASSOCIATED || + s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED)) { + esl_start_advertising(); + } + break; + } +} + +static void enable_past_reception(uint16_t conn_handle) +{ + struct ble_gap_periodic_sync_params sync_params = {0}; + sync_params.skip = 0; + sync_params.sync_timeout = 1000; /* 10 s in 10 ms units */ + sync_params.reports_disabled = 0; + + int rc = ble_gap_periodic_adv_sync_receive(conn_handle, &sync_params, + esl_gap_event_handler, NULL); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gap_periodic_adv_sync_receive failed: rc=%d", rc); + } + + ESP_LOGI(TAG, "Enable PAST reception: conn_handle=%u", conn_handle); +} + +/** + * @brief Handle encryption change event + */ +static void handle_gap_enc_change(struct ble_gap_event *event) +{ + if (event->enc_change.status != 0) { + ESP_LOGW(TAG, "Encryption change failed: status=%d state=%d", + event->enc_change.status, s_ctx->state); + return; + } + + /* Ignore encryption events for connections we are not tracking */ + if (event->enc_change.conn_handle != s_ctx->conn_handle) { + ESP_LOGW(TAG, "Enc change for unknown handle %d (active=%d) — ignoring", + event->enc_change.conn_handle, s_ctx->conn_handle); + return; + } + + struct ble_gap_conn_desc desc; + int rc = ble_gap_conn_find(event->enc_change.conn_handle, &desc); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to find connection for enc change"); + return; + } + + ESP_LOGI(TAG, "Encryption enabled: bonded=%d, authenticated=%d, state=%d", + desc.sec_state.bonded, desc.sec_state.authenticated, s_ctx->state); + + if ((s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED || + s_ctx->state == BLE_ESL_STATE_SYNCHRONIZED) && desc.sec_state.bonded) { + /* Verify this is the trusted bonded peer */ + if (s_ctx->has_bonded_peer && + ble_addr_cmp(&desc.peer_id_addr, &s_ctx->bonded_peer_addr) == 0) { + /* Trusted AP (re)connected: + * - from Unsynchronized via connectable advertising, or + * - from Synchronized via the Periodic Advertising Connection + * procedure. + * Move to Updating and re-arm PAST reception so the AP can + * re-synchronize us afterwards. */ + esl_state_transition(BLE_ESL_STATE_UPDATING); + enable_past_reception(event->enc_change.conn_handle); + } else { + ESP_LOGW(TAG, "Enc change: bonded peer mismatch — disconnecting untrusted device"); + ble_gap_terminate(event->enc_change.conn_handle, BLE_ERR_REM_USER_CONN_TERM); + } + } +} + +/** + * @brief Handle pairing complete event + */ +static void handle_gap_pairing_complete(struct ble_gap_event *event) +{ + if (event->pairing_complete.status != 0) { + ESP_LOGW(TAG, "Pairing failed: status=%d state=%d", + event->pairing_complete.status, s_ctx->state); + return; + } + + ESP_LOGI(TAG, "Pairing complete: handle=%d, state=%d", + event->pairing_complete.conn_handle, s_ctx->state); + + struct ble_gap_conn_desc desc; + int rc = ble_gap_conn_find(event->pairing_complete.conn_handle, &desc); + if (rc != 0) { + ESP_LOGE(TAG, "Failed to find connection after pairing"); + return; + } + + if (desc.sec_state.bonded) { + if (s_ctx->state == BLE_ESL_STATE_UNASSOCIATED) { + /* Store bonded peer address only in Unassociated state */ + s_ctx->bonded_peer_addr = desc.peer_id_addr; + s_ctx->has_bonded_peer = true; + /* Bond formed from Unassociated — transition to Configuring */ + esl_state_transition(BLE_ESL_STATE_CONFIGURING); + enable_past_reception(event->pairing_complete.conn_handle); + } else if ((s_ctx->state == BLE_ESL_STATE_UPDATING || + s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED || + s_ctx->state == BLE_ESL_STATE_SYNCHRONIZED) && + s_ctx->has_bonded_peer && + ble_addr_cmp(&desc.peer_id_addr, &s_ctx->bonded_peer_addr) == 0) { + /* Trusted AP reconnected and re-paired. Just refresh the stored + * address; the encryption-change handler drives the state change + * (UNSYNCHRONIZED -> UPDATING). Rejecting here would tear down a + * legitimate reconnection and delete the bond, leaving the ESL + * permanently unreachable for updates. */ + s_ctx->bonded_peer_addr = desc.peer_id_addr; + } else { + /* Reject untrusted pairing in other states — delete peer and disconnect */ + ESP_LOGW(TAG, "Pairing rejected: not in Unassociated state (%d)", s_ctx->state); + ble_store_util_delete_peer(&desc.peer_id_addr); + ble_gap_terminate(event->pairing_complete.conn_handle, + BLE_ERR_REM_USER_CONN_TERM); + } + } +} + +/** + * @brief Handle repeat pairing event + */ +static int handle_gap_repeat_pairing(struct ble_gap_event *event) +{ + /* In Updating, Unsynchronized, or Synchronized state (the latter reachable + * via the Periodic Advertising Connection procedure), allow re-pairing only + * from the trusted bonded peer and reject all others. */ + if (s_ctx->state == BLE_ESL_STATE_UPDATING || + s_ctx->state == BLE_ESL_STATE_UNSYNCHRONIZED || + s_ctx->state == BLE_ESL_STATE_SYNCHRONIZED) { + struct ble_gap_conn_desc desc; + int rc = ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc); + if (rc == 0 && s_ctx->has_bonded_peer) { + /* Check if this is the trusted peer */ + if (ble_addr_cmp(&desc.peer_id_addr, &s_ctx->bonded_peer_addr) == 0) { + /* Trusted peer — allow re-pairing */ + ble_store_util_delete_peer(&desc.peer_id_addr); + return BLE_GAP_REPEAT_PAIRING_RETRY; + } + } + /* Reject pairing from untrusted device */ + return BLE_GAP_REPEAT_PAIRING_IGNORE; + } + + /* For other states, allow re-pairing */ + struct ble_gap_conn_desc desc; + int rc = ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc); + if (rc != 0) { + ESP_LOGW(TAG, "Repeat pairing: conn_find failed rc=%d — ignoring", rc); + return BLE_GAP_REPEAT_PAIRING_IGNORE; + } + ble_store_util_delete_peer(&desc.peer_id_addr); + return BLE_GAP_REPEAT_PAIRING_RETRY; +} + +/** + * @brief Handle PAST (Periodic Advertising Sync Transfer) received event + */ +static void handle_gap_periodic_transfer(struct ble_gap_event *event) +{ + if (event->periodic_transfer.status != 0) { + ESP_LOGW(TAG, "PAST failed: status=%d — retrying PAST reception", event->periodic_transfer.status); + /* The controller failed to sync to the PAwR train (e.g. HCI 0x3E). + * NimBLE already freed the psync entry, so re-arm PAST reception. */ + if (s_ctx->conn_handle != BLE_HS_CONN_HANDLE_NONE) { + enable_past_reception(s_ctx->conn_handle); + } + return; + } + + uint16_t sync_handle = event->periodic_transfer.sync_handle; + ESP_LOGI(TAG, "PAST received: sync_handle=%d, state=%d", sync_handle, s_ctx->state); + + /* Terminate the previous PAwR sync if one was still alive (kept during + * SYNCHRONIZED → UPDATING because ble_gap_periodic_adv_sync_terminate + * defers pool-entry freeing and would cause enable_past_reception to fail + * with ENOMEM if freed synchronously). */ + if (s_ctx->pawr_sync_handle != BLE_HS_CONN_HANDLE_NONE && + s_ctx->pawr_sync_handle != sync_handle) { + int term_rc = ble_gap_periodic_adv_sync_terminate(s_ctx->pawr_sync_handle); + if (term_rc != 0 && term_rc != BLE_HS_ENOTCONN) { + /* Not fatal: the new sync from PAST supersedes the old one, which is + * dropped when the host tears down its stale entry. */ + ESP_LOGW(TAG, "Failed to terminate stale PAwR sync %u: rc=%d", + s_ctx->pawr_sync_handle, term_rc); + } + } + s_ctx->pawr_sync_handle = sync_handle; + + /* Enable PAwR subevent reception and response slots for our group/subevent. */ + uint8_t group_id = BLE_ESL_ADDR_GROUP_ID(s_ctx->esl_address); + int rc = ble_gap_periodic_adv_sync_subev(sync_handle, 0, 1, &group_id); + if (rc != 0) { + ESP_LOGW(TAG, "PAST: sync_subev failed: rc=%d", rc); + } else { + ESP_LOGI(TAG, "PAwR response slots enabled for subevent %d", group_id); + } + + /* Eagerly initialise the PAwR module with the correct sync handle so that + * esl_pawr_encrypt_and_send() can use it before the first report arrives. */ + esl_pawr_set_sync_context(sync_handle, group_id); + + if (s_ctx->state == BLE_ESL_STATE_CONFIGURING) { + /* In Configuring, all four mandatory writes AND Update Complete are + * required before SYNCHRONIZED so premature/out-of-order PAST cannot + * advance the state machine before provisioning finishes. */ + s_ctx->past_received = true; + if (s_ctx->update_complete_received && + (s_ctx->config_complete & CONFIG_COMPLETE_MASK) == CONFIG_COMPLETE_MASK) { + esl_state_transition(BLE_ESL_STATE_SYNCHRONIZED); + } else { + ESP_LOGI(TAG, "PAST received in Configuring — waiting for Update Complete / config"); + } + } else if (s_ctx->state == BLE_ESL_STATE_SYNCHRONIZED) { + /* PAST received while already Synchronized; record that PAwR sync is + * established for any subsequent update cycle. */ + ESP_LOGI(TAG, "PAST received in Synchronized — PAwR sync established"); + s_ctx->past_received = true; + } else if (s_ctx->state == BLE_ESL_STATE_UPDATING) { + /* In Updating, both PAST and Update Complete are required */ + s_ctx->past_received = true; + if (s_ctx->update_complete_received) { + esl_state_transition(BLE_ESL_STATE_SYNCHRONIZED); + } else { + ESP_LOGI(TAG, "PAST received in Updating — waiting for Update Complete"); + } + } +} + +/** + * @brief Handle PAwR sync data received + */ +static void handle_gap_periodic_report(struct ble_gap_event *event) +{ + /* Only process in Synchronized state */ + if (s_ctx->state != BLE_ESL_STATE_SYNCHRONIZED) { + return; + } + + uint16_t sync_handle = event->periodic_report.sync_handle; + uint16_t event_counter = event->periodic_report.event_counter; + uint8_t subevent = event->periodic_report.subevent; + uint8_t data_length = event->periodic_report.data_length; + const uint8_t *data = event->periodic_report.data; + + if (event->periodic_report.data_status != BLE_GAP_PER_ADV_DATA_STATUS_COMPLETE) { + return; + } + + /* PAwR subevent data is a raw AD structure: + * [AD Len(1)] [AD Type 0x31(1)] [Randomizer(5)] [Ciphertext(n)] [MIC(4)] + * ble_esl_ead_decrypt() expects only the AD value (Randomizer..MIC), so the + * length and type octets must be parsed off first. */ + /* Empty subevent data is normal: the AP transmits an empty subevent for any + * group with no queued command, so ignore it silently instead of warning. */ + if (data == NULL || data_length == 0) { + return; + } + if (data_length < 2 + BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE + 1) { + ESP_LOGW(TAG, "PAwR report: data too short (%u bytes)", data_length); + return; + } + + uint8_t ad_len = data[0]; /* counts the AD type octet + encrypted payload */ + uint8_t ad_type = data[1]; + + if (ad_type != BLE_ESL_AD_TYPE_ENCRYPTED_DATA || + ad_len < 1 + BLE_ESL_RANDOMIZER_SIZE + BLE_ESL_MIC_SIZE || + (uint16_t)(ad_len + 1) > data_length) { + ESP_LOGW(TAG, "PAwR report: not a valid Encrypted Data AD (type=0x%02x len=%u)", + ad_type, ad_len); + return; + } + + /* Strip the AD length + type octets to get the EAD payload. */ + const uint8_t *enc_payload = &data[2]; + uint8_t enc_payload_len = ad_len - 1; + + /* Get AP Sync Key Material for decryption */ + const ble_esl_key_material_t *key_mat = esl_get_ap_sync_key(); + if (key_mat == NULL) { + ESP_LOGW(TAG, "PAwR report: AP Sync Key not available"); + return; + } + + /* enc_payload layout: [Randomizer(5)] [Ciphertext(n)] [MIC(4)] */ + uint8_t decrypted[BLE_ESL_PAYLOAD_MAX_SIZE]; + size_t payload_len = 0; + esp_err_t err = ble_esl_ead_decrypt(key_mat->session_key, key_mat->iv, + enc_payload, enc_payload_len, + decrypted, sizeof(decrypted), + &payload_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "PAwR report: decryption failed: %d", (int)err); + return; + } + + /* Decryption and MIC verification succeeded — process the packet */ + esl_pawr_set_sync_context(sync_handle, event_counter); + err = esl_pawr_process_sync_packet(subevent, decrypted, (uint8_t)payload_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "PAwR report: process sync packet failed: %d", (int)err); + } + + /* Reset sync timeout on valid data */ + esp_timer_stop(s_ctx->sync_timer); + esp_timer_start_once(s_ctx->sync_timer, ESL_STATE_TIMEOUT_US); +} + +static int esl_gap_event_handler(struct ble_gap_event *event, void *arg) +{ + if (s_ctx == NULL) { + return 0; + } + + switch (event->type) { + case BLE_GAP_EVENT_CONNECT: + handle_gap_connect(event); + return 0; + + case BLE_GAP_EVENT_DISCONNECT: + handle_gap_disconnect(event); + return 0; + + case BLE_GAP_EVENT_ENC_CHANGE: + handle_gap_enc_change(event); + return 0; + + case BLE_GAP_EVENT_PARING_COMPLETE: + handle_gap_pairing_complete(event); + return 0; + + case BLE_GAP_EVENT_REPEAT_PAIRING: + return handle_gap_repeat_pairing(event); + + case BLE_GAP_EVENT_PERIODIC_TRANSFER_V2: + /* fall through — same struct layout as V1; dispatched when PAwR is enabled */ + case BLE_GAP_EVENT_PERIODIC_TRANSFER: + handle_gap_periodic_transfer(event); + return 0; + + case BLE_GAP_EVENT_PERIODIC_REPORT: + handle_gap_periodic_report(event); + return 0; + + case BLE_GAP_EVENT_PERIODIC_SYNC_LOST: + /* Fired asynchronously after ble_gap_periodic_adv_sync_terminate frees + * the periodic sync pool slot. If PAST was deferred, arm it now. */ + if (s_ctx->past_pending && + s_ctx->state == BLE_ESL_STATE_UPDATING && + s_ctx->conn_handle != BLE_HS_CONN_HANDLE_NONE) { + s_ctx->past_pending = false; + ESP_LOGI(TAG, "SYNC_LOST: re-arming PAST reception (conn=%d)", + s_ctx->conn_handle); + enable_past_reception(s_ctx->conn_handle); + } + return 0; + + case BLE_GAP_EVENT_NOTIFY_TX: + esl_handle_ecp_notify_tx(event->notify_tx.conn_handle, + event->notify_tx.attr_handle, + event->notify_tx.status, + event->notify_tx.indication); + return 0; + + case BLE_GAP_EVENT_SUBSCRIBE: + ESP_LOGI(TAG, "Subscribe: handle=%d attr=%d notify=%d indicate=%d", + event->subscribe.conn_handle, event->subscribe.attr_handle, + event->subscribe.cur_notify, event->subscribe.cur_indicate); + return 0; + + case BLE_GAP_EVENT_MTU: + ESP_LOGI(TAG, "MTU update: handle=%d mtu=%d", + event->mtu.conn_handle, event->mtu.value); + return 0; + + default: + return 0; + } +} + +/* ========================== Timer Callbacks ========================== */ + +static void sync_timeout_cb(void *arg) +{ + if (s_ctx == NULL || s_ctx->state != BLE_ESL_STATE_SYNCHRONIZED) { + ESP_LOGW(TAG, "Sync timeout fired in unexpected state — ignoring"); + return; + } + ESP_LOGW(TAG, "Sync timeout (60 min) — transitioning to Unsynchronized"); + esl_state_transition(BLE_ESL_STATE_UNSYNCHRONIZED); +} + +static void unsync_timeout_cb(void *arg) +{ + if (s_ctx == NULL || s_ctx->state != BLE_ESL_STATE_UNSYNCHRONIZED) { + ESP_LOGW(TAG, "Unsync timeout fired in unexpected state — ignoring"); + return; + } + ESP_LOGW(TAG, "Unsync timeout (60 min) — clearing data, transitioning to Unassociated"); + esl_state_transition(BLE_ESL_STATE_UNASSOCIATED); +} + +static void ecp_timeout_cb(void *arg) +{ + ESP_LOGW(TAG, "ECP procedure timeout (30s) — sending Retry error response"); + + /* Build Error response TLV: opcode=0x00 (Tag=0, Length=0), params=[error code] */ + uint8_t resp_params[1] = { BLE_ESL_ERR_RETRY }; + uint8_t resp_buf[BLE_ESL_TLV_MAX_SIZE]; + uint8_t resp_len; + esp_err_t err = ble_esl_tlv_encode(BLE_ESL_RESP_ERROR, resp_params, + sizeof(resp_params), resp_buf, &resp_len); + if (err == ESP_OK) { + esl_send_ecp_response(resp_buf, resp_len); + } +} + +/* ========================== Config Validation ========================== */ + +static esp_err_t esl_validate_config(const ble_esl_config_t *config) +{ + if (config == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (config->num_displays > CONFIG_BLE_ESL_MAX_DISPLAYS) { + ESP_LOGE(TAG, "num_displays=%u exceeds CONFIG_BLE_ESL_MAX_DISPLAYS=%u", + config->num_displays, CONFIG_BLE_ESL_MAX_DISPLAYS); + return ESP_ERR_INVALID_ARG; + } + + if (config->num_displays > 0 && config->display_info == NULL) { + ESP_LOGE(TAG, "display_info NULL with num_displays=%u", config->num_displays); + return ESP_ERR_INVALID_ARG; + } + + /* Image slots are tracked by a fixed-size OTS object id map in esl_gatts.c */ + if (config->num_images > CONFIG_BLE_ESL_MAX_IMAGES) { + ESP_LOGE(TAG, "num_images=%u exceeds CONFIG_BLE_ESL_MAX_IMAGES=%u", + config->num_images, CONFIG_BLE_ESL_MAX_IMAGES); + return ESP_ERR_INVALID_ARG; + } + + /* A NULL image_writable_mask is a valid configuration even with num_images > 0: + * it declares every image slot read-only (static/pre-loaded content), so no + * OTS write property is granted. The mask length itself cannot be checked here + * because ble_esl_config_t carries no length field for it — the caller contract + * (num_images entries) is documented in ble_esl.h. */ + if (config->num_images > 0 && config->image_writable_mask == NULL) { + ESP_LOGI(TAG, "image_writable_mask NULL with num_images=%u — all images are read-only", + config->num_images); + } + + if (config->num_leds > CONFIG_BLE_ESL_MAX_LEDS) { + ESP_LOGE(TAG, "num_leds=%u exceeds CONFIG_BLE_ESL_MAX_LEDS=%u", + config->num_leds, CONFIG_BLE_ESL_MAX_LEDS); + return ESP_ERR_INVALID_ARG; + } + + if (config->num_leds > 0 && config->led_info == NULL) { + ESP_LOGE(TAG, "led_info NULL with num_leds=%u", config->num_leds); + return ESP_ERR_INVALID_ARG; + } + + if (config->num_sensors > CONFIG_BLE_ESL_MAX_SENSORS) { + ESP_LOGE(TAG, "num_sensors=%u exceeds CONFIG_BLE_ESL_MAX_SENSORS=%u", + config->num_sensors, CONFIG_BLE_ESL_MAX_SENSORS); + return ESP_ERR_INVALID_ARG; + } + + if (config->num_sensors > 0 && + (config->sensor_info == NULL || config->sensor_info_len == 0)) { + ESP_LOGE(TAG, "sensor_info invalid with num_sensors=%u", config->num_sensors); + return ESP_ERR_INVALID_ARG; + } + + if (config->vendor_specific && config->pnp_id == NULL) { + ESP_LOGE(TAG, "pnp_id NULL with vendor_specific=true"); + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; +} + +/* ========================== Public APIs ========================== */ + +esp_err_t ble_esl_init(const ble_esl_config_t *config) +{ + if (s_ctx != NULL) { + ESP_LOGE(TAG, "Already initialized"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = esl_validate_config(config); + if (err != ESP_OK) { + return err; + } + + /* Allocate context */ + s_ctx = calloc(1, sizeof(esl_state_ctx_t)); + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Failed to allocate state context"); + return ESP_ERR_NO_MEM; + } + + /* Copy config */ + memcpy(&s_ctx->config, config, sizeof(ble_esl_config_t)); + s_ctx->state = BLE_ESL_STATE_UNASSOCIATED; + s_ctx->conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_ctx->pawr_sync_handle = BLE_HS_CONN_HANDLE_NONE; + s_ctx->initialized = true; + + /* Configure security as mandated by the ESL Profile: LE Secure Connections + * with bonding, No Input No Output. + * + * WARNING: ble_hs_cfg is GLOBAL host state, not per-profile. These writes + * override whatever the application or another profile configured, and + * ble_esl_deinit() does not restore the previous values. The caller-facing + * contract is documented on ble_esl_init() in ble_esl.h. */ + ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; + ble_hs_cfg.sm_bonding = 1; + ble_hs_cfg.sm_mitm = 0; + ble_hs_cfg.sm_sc = 1; /* LE Secure Connections */ + ble_hs_cfg.sm_our_key_dist |= BLE_SM_PAIR_KEY_DIST_ENC; + ble_hs_cfg.sm_their_key_dist |= BLE_SM_PAIR_KEY_DIST_ENC; + + /* Create timers */ + esp_timer_create_args_t sync_timer_args = { + .callback = sync_timeout_cb, + .arg = NULL, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_sync_tmr", + }; + err = esp_timer_create(&sync_timer_args, &s_ctx->sync_timer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to create sync timer: %s", esp_err_to_name(err)); + free(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + + esp_timer_create_args_t unsync_timer_args = { + .callback = unsync_timeout_cb, + .arg = NULL, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_unsync_tmr", + }; + err = esp_timer_create(&unsync_timer_args, &s_ctx->unsync_timer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to create unsync timer: %s", esp_err_to_name(err)); + esp_timer_delete(s_ctx->sync_timer); + free(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + + esp_timer_create_args_t ecp_timer_args = { + .callback = ecp_timeout_cb, + .arg = NULL, + .dispatch_method = ESP_TIMER_TASK, + .name = "esl_ecp_tmr", + }; + err = esp_timer_create(&ecp_timer_args, &s_ctx->ecp_timer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to create ECP timer: %s", esp_err_to_name(err)); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + free(s_ctx); + s_ctx = NULL; + return ESP_ERR_NO_MEM; + } + + /* Build and register GATT service */ + err = esl_gatts_init(config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "GATT service init failed: %s", esp_err_to_name(err)); + esl_gatts_deinit(); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + free(s_ctx); + s_ctx = NULL; + return err; + } + + /* Initialize sub-modules */ + err = esl_cmd_lifecycle_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Lifecycle init failed"); + esl_gatts_deinit(); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + free(s_ctx); + s_ctx = NULL; + return err; + } + + err = esl_cmd_display_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Display init failed"); + esl_cmd_lifecycle_deinit(); + esl_gatts_deinit(); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + free(s_ctx); + s_ctx = NULL; + return err; + } + + err = esl_cmd_led_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "LED init failed"); + esl_cmd_display_deinit(); + esl_cmd_lifecycle_deinit(); + esl_gatts_deinit(); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + free(s_ctx); + s_ctx = NULL; + return err; + } + + err = esl_pawr_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "PAwR init failed"); + esl_cmd_led_deinit(); + esl_cmd_display_deinit(); + esl_cmd_lifecycle_deinit(); + esl_gatts_deinit(); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + free(s_ctx); + s_ctx = NULL; + return err; + } + + ESP_LOGI(TAG, "ESL initialized: displays=%u images=%u leds=%u sensors=%u", + config->num_displays, config->num_images, + config->num_leds, config->num_sensors); + + return ESP_OK; +} + +esp_err_t ble_esl_deinit(void) +{ + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Not initialized"); + return ESP_ERR_INVALID_STATE; + } + + /* If there is an active connection, wait for the async disconnect to complete */ + if (s_ctx->started && s_ctx->conn_handle != BLE_HS_CONN_HANDLE_NONE) { + s_ctx->deinit_sem = xSemaphoreCreateBinary(); + if (s_ctx->deinit_sem != NULL) { + s_ctx->deinit_pending = true; + /* ble_esl_stop will call ble_gap_terminate. A failed PAwR teardown + * is logged there and retried below, so deinit continues either way. */ + (void)ble_esl_stop(); + /* Wait for disconnect event (up to 5 seconds). If the handle was + * already stale, ble_esl_stop() cleared it and no event is coming, + * so there is nothing to wait for. */ + if (s_ctx->conn_handle != BLE_HS_CONN_HANDLE_NONE && + xSemaphoreTake(s_ctx->deinit_sem, pdMS_TO_TICKS(5000)) != pdTRUE) { + ESP_LOGW(TAG, "Timed out waiting for disconnect during deinit"); + } + vSemaphoreDelete(s_ctx->deinit_sem); + s_ctx->deinit_sem = NULL; + s_ctx->deinit_pending = false; + } else { + ESP_LOGW(TAG, "Failed to create deinit semaphore — proceeding without wait"); + (void)ble_esl_stop(); + } + } else if (s_ctx->started) { + (void)ble_esl_stop(); + } + + /* Stop timers */ + esp_timer_stop(s_ctx->sync_timer); + esp_timer_stop(s_ctx->unsync_timer); + esp_timer_stop(s_ctx->ecp_timer); + esp_timer_delete(s_ctx->sync_timer); + esp_timer_delete(s_ctx->unsync_timer); + esp_timer_delete(s_ctx->ecp_timer); + + /* Tear down the controller-level PAwR sync before dropping the context so no + * periodic report/sync-lost event can reference freed state. This also acts + * as the retry for a teardown that ble_esl_stop() could not complete. */ + if (esl_terminate_pawr_sync() != ESP_OK) { + ESP_LOGW(TAG, "PAwR sync %u still active while deinitializing", + s_ctx->pawr_sync_handle); + } + + /* Deinit sub-modules */ + esl_pawr_deinit(); + esl_cmd_led_deinit(); + esl_cmd_display_deinit(); + esl_cmd_lifecycle_deinit(); + esl_gatts_deinit(); + + /* Free context */ + free(s_ctx); + s_ctx = NULL; + + ESP_LOGI(TAG, "ESL deinitialized"); + return ESP_OK; +} + +esp_err_t ble_esl_start(void) +{ + if (s_ctx == NULL || !s_ctx->initialized) { + ESP_LOGE(TAG, "Not initialized"); + return ESP_ERR_INVALID_STATE; + } + + if (s_ctx->started) { + ESP_LOGW(TAG, "Already started"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx->started = true; + + /* Resume behavior based on current state */ + switch (s_ctx->state) { + case BLE_ESL_STATE_UNASSOCIATED: + { + esp_err_t err = esl_start_advertising(); + if (err != ESP_OK) { + s_ctx->started = false; + return err; + } + } + break; + + case BLE_ESL_STATE_UNSYNCHRONIZED: + { + esp_err_t err = esl_start_advertising(); + if (err != ESP_OK) { + s_ctx->started = false; + return err; + } + /* Re-arm unsync timeout that was stopped by ble_esl_stop() */ + esp_timer_start_once(s_ctx->unsync_timer, ESL_STATE_TIMEOUT_US); + } + break; + + case BLE_ESL_STATE_SYNCHRONIZED: + /* Re-arm sync timeout that was stopped by ble_esl_stop() */ + esp_timer_start_once(s_ctx->sync_timer, ESL_STATE_TIMEOUT_US); + break; + + default: + /* For other states (CONFIGURING, UPDATING), no-op */ + break; + } + + ESP_LOGI(TAG, "ESL started"); + return ESP_OK; +} + +esp_err_t ble_esl_stop(void) +{ + if (s_ctx == NULL || !s_ctx->started) { + ESP_LOGE(TAG, "Not started"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx->started = false; + + /* Stop advertising */ + esl_stop_advertising(); + + /* Stop PAwR synchronization if active. On failure the handle and the synced + * flag are retained (the host still owns the sync), so the stop is reported + * as incomplete instead of pretending we are unsynced. */ + esp_err_t sync_err = esl_terminate_pawr_sync(); + + /* Disconnect if connected. The handle is normally cleared by the disconnect + * event handler so that it can still match the event and run its cleanup. */ + esl_terminate_active_conn(); + + /* Stop timers but preserve state */ + esp_timer_stop(s_ctx->sync_timer); + esp_timer_stop(s_ctx->unsync_timer); + + if (sync_err != ESP_OK) { + ESP_LOGW(TAG, "ESL stopped, but PAwR sync %u could not be terminated", + s_ctx->pawr_sync_handle); + return sync_err; + } + + ESP_LOGI(TAG, "ESL stopped"); + return ESP_OK; +} + +ble_esl_state_t ble_esl_get_state(void) +{ + if (s_ctx == NULL) { + return BLE_ESL_STATE_UNASSOCIATED; + } + return s_ctx->state; +} + +esp_err_t ble_esl_register_cb(ble_esl_cb_t callback) +{ + if (callback == NULL) { + return ESP_ERR_INVALID_ARG; + } + + if (s_ctx == NULL) { + ESP_LOGE(TAG, "Not initialized — call ble_esl_init() first"); + return ESP_ERR_INVALID_STATE; + } + + s_ctx->app_cb = callback; + ESP_LOGI(TAG, "Application callback registered"); + return ESP_OK; +}