diff --git a/adv_esp/README.md b/adv_esp/README.md index da13e9b..08e8ca8 100644 --- a/adv_esp/README.md +++ b/adv_esp/README.md @@ -105,40 +105,39 @@ The packet is constructed in `hci_cmd_send_ble_set_adv_data`. It uses Manufactur | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **0** | 3 | `0xFF, 0xFF, 0xFF` | Manufacturer ID | -| **3** | 2 | `0x4C, 0x44` | unique code (LD) | -| **5** | 1 | `cmd_type` | Command Type | -| **6** | 8 | `target_mask` | 64-bit Target Mask | -| **14** | 4 | `delay_us` | **Dynamic** Remaining Time (Big Endian) | +| **0** | 3 | `AD type` + `UUID` | AD type + UUID (remember to change it to real one) | +| **3** | 1 | `cmd_type` | Command Type | +| **4** | 8 | `target_mask` | 64-bit Target Mask | +| **12** | 4 | `delay_us` | **Dynamic** Remaining Time (Big Endian) | The remaining bytes: * `PLAY` | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **18** | 4 | `prep_led_us` | Preparation Time (Big Endian) | +| **16** | 4 | `prep_led_us` | Preparation Time (Big Endian) | * `TEST` | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **18** | 3 | `data[3]` | Extra Data (e.g., RGB) | -| **21** | 1 | `0` | padding | +| **16** | 3 | `data[3]` | Extra Data (e.g., RGB) | +| **19** | 1 | `0` | padding | * `CANCEL` | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **18** | 1 | `cmd_id` | the cmd id that you want to cancel | -| **19** | 3 | `0` | padding | +| **16** | 1 | `cmd_id` | the cmd id that you want to cancel | +| **17** | 3 | `0` | padding | * Other command | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **18** | 4 | `0` | padding | +| **16** | 4 | `0` | padding | -**Total Length**: 22 Bytes of Manufacturer Data. +**Total Length**: 20 Bytes. ## Operating Principles diff --git a/adv_esp/main/bt_sender.c b/adv_esp/main/bt_sender.c index 12c7ea9..24b4d5c 100644 --- a/adv_esp/main/bt_sender.c +++ b/adv_esp/main/bt_sender.c @@ -16,6 +16,7 @@ #define MAX_ACTIVE_TASKS 16 /* --- HCI Command Opcodes & Groups --- */ +// Defines raw HCI (Host Controller Interface) opcodes to bypass the standard Bluetooth stack for lower latency. #ifndef HCI_GRP_HOST_CONT_BASEBAND_CMDS #define HCI_GRP_HOST_CONT_BASEBAND_CMDS (0x03 << 10) #endif @@ -34,6 +35,8 @@ #ifndef HCIC_PARAM_SIZE_SET_EVENT_MASK #define HCIC_PARAM_SIZE_SET_EVENT_MASK (8) #endif +#define UUID1 -1 // change into real uuid +#define UUID2 -1 // change into real uuid static const char *TAG = "BT_SENDER"; static volatile int64_t last_measured_latency = 0; @@ -44,13 +47,14 @@ static bool is_checking = false; // Flag indicating if currently in CHECK // Structure to store an active broadcast task typedef struct { bool active; // Is this slot currently in use? - int64_t end_time_us; // Absolute timestamp when broadcasting should stop + int64_t end_time_us; // Absolute hardware timestamp when broadcasting should stop + int64_t prep_led_end_time_us; // Absolute timestamp when preparation LED should turn off (for PLAY commands) bt_sender_config_t config; // The command configuration (cmd, delay, target, etc.) } active_task_t; static active_task_t s_tasks[MAX_ACTIVE_TASKS]; // Array of active broadcast tasks (slots) -static SemaphoreHandle_t s_task_mutex = NULL; // Mutex to protect s_tasks array -static int s_rr_index = 0; // Round-Robin Index to ensure fair broadcasting +static SemaphoreHandle_t s_task_mutex = NULL; // Mutex to protect s_tasks array in RTOS multi-thread environment +static int s_rr_index = 0; // Round-Robin Index to ensure fair broadcasting across multiple active commands /* ======================================================== * Helper functions to format and send low-level HCI commands @@ -61,45 +65,51 @@ static void hci_cmd_send_ble_set_adv_data(uint8_t cmd_type, uint32_t delay_ms, u uint8_t raw_adv_data[31]; uint8_t idx = 0; - raw_adv_data[idx++] = 2; raw_adv_data[idx++] = 0x01; raw_adv_data[idx++] = 0x06; + // --- Payload Structure: Standard BLE Flags --- + raw_adv_data[idx++] = 2; // Length: 2 bytes + raw_adv_data[idx++] = 0x01; // AD Type: Flags + raw_adv_data[idx++] = 0x06; // LE General Discoverable Mode | BR/EDR Not Supported - raw_adv_data[idx++] = 22; - raw_adv_data[idx++] = 0xFF; - raw_adv_data[idx++] = 0xFF; - raw_adv_data[idx++] = 0xFF; + // --- Payload Structure: Service Data (0x16) --- + raw_adv_data[idx++] = 20; // Length: 20 bytes for the following payload + raw_adv_data[idx++] = 0x16; // AD Type: Service Data - 16-bit UUID - raw_adv_data[idx++] = 0x4C; // 'L' - raw_adv_data[idx++] = 0x44; // 'D' + // 2-byte UUID (Little-Endian) + raw_adv_data[idx++] = UUID1; + raw_adv_data[idx++] = UUID2; + + // 1-byte Command Type (e.g., 0x01 for PLAY) raw_adv_data[idx++] = cmd_type; - // Target Mask (8 bytes) + // 8-byte Target Mask (Specifies which receivers should execute the command) for(int i = 0; i < 8; i++) { raw_adv_data[idx++] = (uint8_t)((target_mask >> (i * 8)) & 0xFF); } - // Delay MS (4 bytes) + // 4-byte Remaining Delay in MS (Big-Endian format) raw_adv_data[idx++] = (delay_ms >> 24) & 0xFF; raw_adv_data[idx++] = (delay_ms >> 16) & 0xFF; raw_adv_data[idx++] = (delay_ms >> 8) & 0xFF; raw_adv_data[idx++] = (delay_ms) & 0xFF; + // Command-Specific Variable Payload (up to 4 bytes padding) uint8_t base_cmd = cmd_type & 0x0F; - if (base_cmd == 0x01) { // PLAY: 4 bytes + if (base_cmd == 0x01) { // PLAY: 4 bytes indicating LED preparation time raw_adv_data[idx++] = (prep_led_ms >> 24) & 0xFF; raw_adv_data[idx++] = (prep_led_ms >> 16) & 0xFF; raw_adv_data[idx++] = (prep_led_ms >> 8) & 0xFF; raw_adv_data[idx++] = (prep_led_ms) & 0xFF; - } else if (base_cmd == 0x05) { // TEST: 3 bytes + 1 byte pad - raw_adv_data[idx++] = data[0]; - raw_adv_data[idx++] = data[1]; - raw_adv_data[idx++] = data[2]; + } else if (base_cmd == 0x05) { // TEST: 3 bytes for RGB values + 1 byte pad + raw_adv_data[idx++] = data[0]; // R + raw_adv_data[idx++] = data[1]; // G + raw_adv_data[idx++] = data[2]; // B raw_adv_data[idx++] = 0x00; - } else if (base_cmd == 0x06) { // CANCEL: 1 byte + 3 bytes pad + } else if (base_cmd == 0x06) { // CANCEL: 1 byte for target slot + 3 bytes pad raw_adv_data[idx++] = data[0]; raw_adv_data[idx++] = 0x00; raw_adv_data[idx++] = 0x00; raw_adv_data[idx++] = 0x00; - } else { // 4 bytes pad + } else { // Generic 4-byte pad for other commands raw_adv_data[idx++] = 0x00; raw_adv_data[idx++] = 0x00; raw_adv_data[idx++] = 0x00; @@ -110,7 +120,7 @@ static void hci_cmd_send_ble_set_adv_data(uint8_t cmd_type, uint32_t delay_ms, u if (esp_vhci_host_check_send_available()) esp_vhci_host_send_packet(hci_cmd_buf, sz); } -// Set basic advertising parameters +// Set basic advertising parameters (Intervals, Channel Map) static void hci_cmd_send_ble_set_adv_param(void) { uint8_t peer_addr[6] = {0}; uint16_t sz = make_cmd_ble_set_adv_param(hci_cmd_buf, 0x20, 0x20, 0x03, 0, 0, peer_addr, 0x07, 0); @@ -137,11 +147,11 @@ static void hci_cmd_send_reset(void) { static void controller_rcv_pkt_ready(void) {} -// Parse incoming packets (Used only during 'CHECK' scan mode to receive ACKs) +// Parse incoming packets (Used only during 'CHECK' scan mode to receive status ACKs from receivers) static int host_rcv_pkt(uint8_t *data, uint16_t len) { - if(!is_checking) return ESP_OK; // Ignore packets if not in CHECK mode + if(!is_checking) return ESP_OK; // Ignore incoming packets if the sender is not actively polling - // Basic HCI LE Meta Event header check + // Validate HCI LE Meta Event header (0x04 = Event, 0x3E = LE Meta Event, 0x02 = LE Advertising Report) if(data[0] != 0x04 || data[1] != 0x3E || data[3] != 0x02) return ESP_OK; uint8_t num_reports = data[4]; @@ -155,21 +165,29 @@ static int host_rcv_pkt(uint8_t *data, uint16_t len) { uint8_t ad_len = adv_data[offset++]; if(ad_len == 0) break; uint8_t ad_type = adv_data[offset++]; - if(ad_type == 0xFF && (adv_data[offset] == 0xFF && adv_data[offset + 1] == 0xFF) && (adv_data[offset+2] == 0x4C && adv_data[offset + 3] == 0x44)) { + + // Expected ACK Payload Structure: + // Type 0xFF (Manufacturer Specific), followed by 0xFFFF company ID, then our custom UUID + if(ad_type == 0xFF && (adv_data[offset] == 0xFF && adv_data[offset + 1] == 0xFF) && (adv_data[offset+2] == UUID1 && adv_data[offset + 3] == UUID2)) { + + // Ensure it is an ACK command (0x07) and length matches expected ACK payload (14 bytes) if (adv_data[offset+4] == 0x07 && ad_len == 14) { uint8_t target_id = adv_data[offset+5]; uint8_t cmd_id = adv_data[offset+6]; uint8_t cmd_type = adv_data[offset+7]; + + // Reconstruct 4-byte delay (Big-Endian) uint32_t delay_ms = (adv_data[offset+8] << 24) | (adv_data[offset+9] << 16) | (adv_data[offset+10] << 8) | adv_data[offset+11]; uint8_t state = adv_data[offset+12]; + // Output format expected by the Host PC Python script printf("FOUND:%d,%d,%d,%lu,%d\n", target_id, cmd_id, cmd_type, delay_ms, state); } } - offset += (ad_len - 1); + offset += (ad_len - 1); // Jump to next AD Structure block } - payload += (12 + data_len + 1); + payload += (12 + data_len + 1); // Jump to next report in this LE Meta Event } return ESP_OK; } @@ -202,7 +220,7 @@ static void broadcast_scheduler_task(void *arg) { ESP_LOGD(TAG, "Broadcast Scheduler Started (20ms cycle)"); while (1) { - // Pause broadcasting if system is currently scanning for ACKs + // Pause broadcasting if system is currently scanning for ACKs to avoid RF collision if (is_checking) { vTaskDelay(pdMS_TO_TICKS(20)); continue; @@ -214,18 +232,18 @@ static void broadcast_scheduler_task(void *arg) { xSemaphoreTake(s_task_mutex, portMAX_DELAY); - // Check and expire finished tasks + // Phase 1: Clean up expired tasks for (int i = 0; i < MAX_ACTIVE_TASKS; i++) { if (s_tasks[i].active) { if (now_us >= s_tasks[i].end_time_us) { - s_tasks[i].active = false; // Expire task + s_tasks[i].active = false; // Expire task once its target time is reached } else { active_count++; } } } - // Find the next task to broadcast using Round-Robin (to ensure fairness) + // Phase 2: Find the next task to broadcast using Round-Robin (to ensure fairness among multiple commands) if (active_count > 0) { for (int k = 0; k < MAX_ACTIVE_TASKS; k++) { int idx = (s_rr_index + k) % MAX_ACTIVE_TASKS; @@ -238,24 +256,29 @@ static void broadcast_scheduler_task(void *arg) { } xSemaphoreGive(s_task_mutex); - // Execute the chosen broadcast task + // Phase 3: Execute the chosen broadcast task if (task_index_to_run != -1) { active_task_t *t = &s_tasks[task_index_to_run]; + // Recalculate remaining delay dynamically right before sending int32_t remain_ms = (int32_t)((t->end_time_us - now_us) / 1000); if (remain_ms < 0) remain_ms = 0; - hci_cmd_send_ble_set_adv_data(t->config.cmd_type, remain_ms, t->config.prep_led_ms, t->config.target_mask, t->config.data); + int32_t remain_prep_led_ms = (int32_t)((t->prep_led_end_time_us - now_us) / 1000); + if (remain_prep_led_ms < 0) remain_prep_led_ms = 0; + + hci_cmd_send_ble_set_adv_data(t->config.cmd_type, remain_ms, remain_prep_led_ms, t->config.target_mask, t->config.data); + // Non-RTOS delay (Busy-wait): Needed to let BT hardware digest the new ADV payload. // We MUST use esp_rom_delay_us() here because 500us is smaller than the FreeRTOS minimum tick resolution (typically 1ms). // Using vTaskDelay() would force a minimum 1ms yield, introducing jitter and slowing down the strict broadcast rhythm. esp_rom_delay_us(500); hci_cmd_send_ble_adv_start(); - vTaskDelay(pdMS_TO_TICKS(10)); // Advertise for 10ms + vTaskDelay(pdMS_TO_TICKS(10)); // Advertise the command for exactly 10ms hci_cmd_send_ble_adv_stop(); } - vTaskDelay(pdMS_TO_TICKS(10)); // Cycle delay + vTaskDelay(pdMS_TO_TICKS(10)); // Base loop cycle delay to yield CPU } } @@ -263,7 +286,7 @@ static void broadcast_scheduler_task(void *arg) { esp_err_t bt_sender_init(void) { if (is_initialized) return ESP_OK; - // NVS Initialization (Required for BT controller) + // NVS Initialization (Required for BT controller to store calibration data) esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); @@ -271,7 +294,7 @@ esp_err_t bt_sender_init(void) { } ESP_ERROR_CHECK(ret); - // Controller Initialization + // Controller Initialization directly at the HCI level (bypassing host stack) esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); esp_bt_controller_init(&bt_cfg); @@ -287,7 +310,7 @@ esp_err_t bt_sender_init(void) { hci_cmd_send_ble_set_adv_param(); vTaskDelay(100 / portTICK_PERIOD_MS); - // Init Mutex and Scheduler Task + // Init Mutex and launch Scheduler Task s_task_mutex = xSemaphoreCreateMutex(); for(int i=0; iprep_led_ms * 1000ULL); + // Lock in the absolute execution time s_tasks[slot].end_time_us = esp_timer_get_time() + ((uint64_t)config->delay_ms * 1000ULL); s_tasks[slot].active = true; ESP_LOGD(TAG, "Task added to slot %d (Type 0x%02X)", slot, config->cmd_type); @@ -330,10 +355,11 @@ void bt_sender_start_check(uint32_t duration_ms) { is_checking = true; + // Halt outgoing commands hci_cmd_send_ble_adv_stop(); vTaskDelay(pdMS_TO_TICKS(20)); - // Configure Scan Parameters (Interval 100ms, Window 100ms) + // Configure Scan Parameters (Interval 100ms, Window 100ms for continuous capture) uint8_t buf[128]; make_cmd_ble_set_scan_params(buf, 0, 0x00A0, 0x00A0, 0, 0); esp_vhci_host_send_packet(buf, 7 + 4); @@ -343,7 +369,7 @@ void bt_sender_start_check(uint32_t duration_ms) { make_cmd_ble_set_scan_enable(buf, 1, 0); esp_vhci_host_send_packet(buf, 2 + 4); - // Wait for the duration (collecting FOUND packets in ISR) + // Wait for the duration (collecting FOUND packets in ISR via host_rcv_pkt) vTaskDelay(pdMS_TO_TICKS(duration_ms)); // Disable Scanning @@ -351,7 +377,9 @@ void bt_sender_start_check(uint32_t duration_ms) { esp_vhci_host_send_packet(buf, 2 + 4); is_checking = false; - printf("CHECK_DONE\n"); // Signal PC Python script that scan is finished + + // Signal PC Python script that scan is finished via stdout + printf("CHECK_DONE\n"); } // Remove/Cancel a specific command slot (used by CANCEL command) diff --git a/lps-ctrl/README.md b/lps-ctrl/README.md index 821f657..35f5e43 100644 --- a/lps-ctrl/README.md +++ b/lps-ctrl/README.md @@ -15,8 +15,9 @@ It is recommended to create a virtual environment in the `lps-ctrl` directory (w python3 -m venv venv # or try: python -m venv venv source venv/bin/activate -# or try: .\venv\Scripts\Activate.ps1 -# or try: .\venv\Scripts\activate.bat +# or try: +# Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +# .\venv\Scripts\Activate.ps1 python.exe -m pip install --upgrade pip pip install -e . python .\examples\lps_ctrl_ex.py @@ -315,4 +316,15 @@ async def main(): if __name__ == '__main__': asyncio.run(main()) -``` \ No newline at end of file +``` +## Alternative: PC-Based Software Broadcasting (No Extra Hardware) + +In addition to the hardware-based `ESP32BTSender`, this project also provides software-only tools to broadcast control commands directly from your PC's internal Bluetooth adapter, eliminating the need for an external ESP32 sender module. These tools include an interactive Python script (`pc_adv_ex.py`) and a native Windows PowerShell script (`LPS_advertiser.ps1`). + +### Architecture Note: GATT Server vs. Pure Broadcaster + +When developing PC-based BLE applications, standard libraries typically default to a **GATT Server** architecture. A GATT Server is designed for two-way, connection-based communication. However, it forces the host OS to inject mandatory metadata (like Service UUIDs and Device Names) into the BLE advertisement payload. + +To maintain maximum efficiency and synchronization for stage lighting, our receivers operate in **Passive Scanning** mode. Therefore, both `pc_adv_ex.py` and `LPS_advertiser.ps1` intentionally bypass the GATT Server architecture. Instead, they leverage native Windows WinRT APIs to function as **Pure Broadcasters**. This approach directly injects raw data into the primary advertisement packet, perfectly mimicking the lightweight and instant broadcast behavior of our hardware ESP32 sender. + +Furthermore, the Python version (`pc_adv_ex.py`) extends this architecture by temporarily utilizing the WinRT Watcher API when issuing the `CHECK` command, allowing the PC to briefly listen for receiver status reports (ACK packets) without breaking the pure broadcaster paradigm. \ No newline at end of file diff --git a/lps-ctrl/docs/powershell_script_readme.md b/lps-ctrl/docs/powershell_script_readme.md new file mode 100644 index 0000000..626b0d6 --- /dev/null +++ b/lps-ctrl/docs/powershell_script_readme.md @@ -0,0 +1,70 @@ +# ESP32 BLE LPS Controller CLI + +This is a PowerShell-based command-line tool used to broadcast commands to the ESP32 LPS controller via BLE (Bluetooth Low Energy). + +This script sends Bluetooth Service Data (`0x16`) advertisement packets by calling the Windows WinRT API, and utilizes .NET Reflection to directly invoke the CLR, bypassing the common type-casting errors in PowerShell 5.1 when handling WinRT collection types. + +## System Requirements + +* **Operating System**: Windows 10 or Windows 11 (must support WinRT API). +* **Hardware**: Bluetooth-supported network card or receiver (please ensure Windows Bluetooth is turned on before execution). +* **Environment**: PowerShell 5.1 or higher (running as Administrator is recommended to ensure Bluetooth broadcasting permissions). + +## Parameter Description + +When executing the script, you can customize the broadcast commands using the following parameters: + +| Parameter | Alias | Type | Default Value | Description | +| --- | --- | --- | --- | --- | +| `-CmdType` | `-c` | Int | **(Required)** | Command type. Valid values: `1`:PLAY, `2`:PAUSE, `3`:STOP, `4`:RELEASE, `5`:TEST, `6`:CANCEL, `7`:CHECK, `8`:UPLOAD, `9`:RESET | +| `-TargetIds` | `-t` | String | **(Required)** | Target device IDs. Supports multi-target via comma-separated string (e.g., `"1,2,5"`). Use `"all"` or `"-1"` for Global Broadcast. | +| `-CmdId` | | Int | `0` | Command ID (`0-15`). | +| `-DelayMs` | | Int | `2000` | Delay time in milliseconds (ms). | +| `-PrepMs` | | Int | `1000` | Preparation time in milliseconds (ms). **Only applicable for the `PLAY` (Type 1) command.** | +| `-R` | | Byte | `0` | Red color value (`0-255`). **Only applicable for the `TEST` (Type 5) command.** | +| `-G` | | Byte | `0` | Green color value (`0-255`). **Only applicable for the `TEST` (Type 5) command.** | +| `-B` | | Byte | `0` | Blue color value (`0-255`). **Only applicable for the `TEST` (Type 5) command.** | +| `-CancelId` | | Int | `0` | Slot ID to cancel. **Only applicable for the `CANCEL` (Type 6) command.** | + +## Usage Examples + +**1. Global Play (PLAY)** +Send a PLAY command to all devices using the default delay and preparation time: + +```powershell +.\LPS_advertiser.ps1 -CmdType 1 -TargetIds "all" +``` + +**2. Multi-Device Test (TEST)** +Send a TEST command to devices 1, 3, and 5, and light up the red LED (R:255, G:0, B:0): + +```powershell +.\LPS_advertiser.ps1 -CmdType 5 -TargetIds "1,3,5" -R 255 -G 0 -B 0 +``` + +**3. Cancel Specific Schedule (CANCEL)** +Send a CANCEL command, specifying to cancel the task with Slot ID 3: + +```powershell +.\LPS_advertiser.ps1 -CmdType 6 -TargetIds "all" -CancelId 3 +``` + +## Payload Structure + +Under the hood, the script assembles the parameters into a 19-byte Service Data packet with the following structure: + +* **Byte 0-1**: Magic Bytes (`0x4C`, `0x44` representing "LD"). +* **Byte 2**: Command Info (combined from the high 4 bits of `CmdId` and the low 4 bits of `CmdType`). +* **Byte 3-10**: Target Mask (8-byte mask dynamically generated from the `-TargetIds` string). +* **Byte 11-14**: Delay time (4 Bytes, Big-Endian). +* **Byte 15-18**: Specific command payload (4 Bytes, dynamically determined by `CmdType`): +* `Type 1 (PLAY)`: Passes `PrepMs` (Big-Endian) +* `Type 5 (TEST)`: Passes RGB values (`R`, `G`, `B`) +* `Type 6 (CANCEL)`: Passes `CancelId` + + + +## Troubleshooting + +* **Broadcast failure or exception error**: Please confirm that Windows Bluetooth is turned on. If an access denied issue occurs, try opening the PowerShell window as an "Administrator" before running the script. +* **ESP32 not receiving signals**: Please check if the UUID parsing on the ESP32 side matches the `0x4C` and `0x44` (Magic bytes) declared at the beginning of the script. diff --git a/lps-ctrl/docs/winrt_broadcaster_readme.md b/lps-ctrl/docs/winrt_broadcaster_readme.md new file mode 100644 index 0000000..2006838 --- /dev/null +++ b/lps-ctrl/docs/winrt_broadcaster_readme.md @@ -0,0 +1,100 @@ +# ESP32 BLE WinRT Broadcaster (`pc_adv_ex.py`) + +This is an interactive Python command-line tool designed to broadcast BLE commands to ESP32 receivers directly from a Windows PC. + +By utilizing native Windows WinRT APIs (`BluetoothLEAdvertisementPublisher`), this script acts as a **Pure Broadcaster**. It bypasses standard GATT Server overhead, allowing the custom 19-byte command payload to be embedded directly into the primary advertisement packet (`ADV_IND`) using the Service Data (`0x16`) format. This ensures zero-latency synchronization for receiver nodes operating in Passive Scanning mode. + +## System Requirements + +* **Operating System**: Windows 10 or Windows 11 (Requires WinRT API support). +* **Hardware**: Built-in Bluetooth adapter or USB Bluetooth dongle. +* **Python**: Python 3.10 is highly recommended to ensure maximum compatibility with the C++ bindings of the `winrt` library. + +## Installation + +This script requires specific Windows Runtime bindings. Ensure your `pyproject.toml` is configured with the following dependencies: + +```toml +dependencies = [ + "winrt-Windows.Foundation", + "winrt-Windows.Foundation.Collections", + "winrt-Windows.Devices.Bluetooth", + "winrt-Windows.Devices.Bluetooth.Advertisement", + "winrt-Windows.Storage.Streams" +] +``` + +It is recommended to create a virtual environment in the `lps-ctrl` directory (where `pyproject.toml` is located) and install the required packages. + +```bash +pip install -e . +``` + +## Usage (Interactive Mode) + +Start the interactive terminal by running the script: + +```bash +python examples/pc_adv_ex.py +``` + +The script will guide you through a step-by-step prompt to assemble and broadcast your commands: + +**Step 1: Command Code** +Enter a number from `1` to `9` to select the action (e.g., `1` for PLAY, `5` for TEST). + +**Step 2: Target IDs (Multi-select supported)** +You can control specific players or all of them at once: + +* Type `all` to broadcast to the entire network. +* Type a single ID (e.g., `2`) to target Player 2. +* Type comma-separated IDs (e.g., `1, 3, 5`) to target Player 1, 3, and 5 simultaneously. + +**Step 3: Timing Controls** + +* **Delay Time (ms)**: Set how long the ESP32 should wait before executing the command (Default is `2000`ms). Just press `Enter` to use the default. +* **Prep Time (ms)**: *(Only for PLAY commands)* Set how long the red preparation LED should light up before playback starts (Default is `1000`ms). + +**Step 4: Special Parameters** + +* **For TEST (`5`)**: Enter RGB values like `255,0,0` for Red. Press `Enter` to use the default breathing light pattern. +* **For CANCEL (`6`)**: Enter the specific `CMD_ID` (0-15) you want to abort. + +### 📡 Active Listener Mode (CHECK Command) + +When issuing the **CHECK** (`7`) command, the script automatically switches to an Active Listener mode (`BluetoothLEAdvertisementWatcher`). It will temporarily stop broadcasting, listen for incoming ACK packets (Manufacturer Data containing `0xFFFF` and `0x07`) from the receivers, and print out their current states and remaining delay times directly in the terminal. + +### Available Commands + +| Code | Command | Description | +| --- | --- | --- | +| `1` | **PLAY** | Start timeline/playback. | +| `2` | **PAUSE** | Pause playback. | +| `3` | **STOP** | Stop and reset position. | +| `4` | **RELEASE** | Release memory/Unload. | +| `5` | **TEST** | LED Color Test Mode. | +| `6` | **CANCEL** | Cancel a specific pending command. | +| `7` | **CHECK** | Request status report. Triggers Listener Mode. | +| `8` | **UPLOAD** | Trigger OTA update sequence. | +| `9` | **RESET** | System Reboot. | + +## Payload Structure (Service Data `0x16`) + +The script dynamically generates a 19-byte payload appended to the `0x16` Service Data section: + +* **Byte 0-1**: Magic Bytes (`0x4C`, `0x44` representing "LD"). +* **Byte 2**: Command Info (High 4 bits = Command ID sequence, Low 4 bits = Command Type). +* **Byte 3-10**: Target Mask (8 Bytes, Little-Endian bitmask dynamically generated). +* **Byte 11-14**: Delay Time (4 Bytes, Big-Endian in milliseconds). +* **Byte 15-18**: Specific Parameters (4 Bytes): +* `PLAY`: Preparation time in milliseconds. +* `TEST`: RGB values (`R`, `G`, `B`). +* `CANCEL`: Target Command ID to cancel. + + + +## Troubleshooting + +* **Script crashes immediately**: Ensure you are running Python 3.10 and all the `winrt` dependencies are properly installed (including `Foundation` and `Collections`). +* **ESP32 does not respond**: Verify that your ESP32 receiver firmware is configured to scan for Service Data (`0x16`) and that its target UUID check matches `0x4C` and `0x44`. Also, ensure the ESP32 is using Passive Scanning (`0x00`) for optimal reception. +* **CHECK command yields no reports**: Ensure your PC's Bluetooth adapter is fully active and not blocked by background Windows services. The ESP32 must be properly configured to broadcast its ACK packet back to the host. \ No newline at end of file diff --git a/lps-ctrl/examples/pc_adv_ex.py b/lps-ctrl/examples/pc_adv_ex.py new file mode 100644 index 0000000..2111479 --- /dev/null +++ b/lps-ctrl/examples/pc_adv_ex.py @@ -0,0 +1,233 @@ +import time +from winrt.windows.devices.bluetooth.advertisement import ( + BluetoothLEAdvertisementPublisher, + BluetoothLEAdvertisementWatcher, + BluetoothLEScanningMode, + BluetoothLEAdvertisementDataSection +) +from winrt.windows.storage.streams import DataWriter, DataReader + +# Define command mapping +COMMANDS = { + 1: "PLAY", 2: "PAUSE", 3: "STOP", 4: "RELEASE", + 5: "TEST", 6: "CANCEL", 7: "CHECK", 8: "UPLOAD", 9: "RESET" +} + +# Define state mapping (Reference from lps_ctrl.py) +STATE_MAP = { + 0: "UNLOADED", 1: "READY", 2: "PLAYING", 3: "PAUSE", 4: "TEST" +} + +# Replace with your actual UUIDs +UUID1 = 0x4C +UUID2 = 0x44 + +# Global set to filter duplicate reports within a single scan window +seen_devices = set() + +def on_advertisement_received(sender, args): + """ + Callback function triggered when a BLE advertisement is detected. + Filters for our custom ACK packet (Manufacturer Data 0xFFFF -> LD 0x07) + """ + global seen_devices + adv = args.advertisement + for man_data in adv.manufacturer_data: + # Check for our specific Company ID (0xFFFF) + if man_data.company_id == 0xFFFF: + reader = DataReader.from_buffer(man_data.data) + buffer = bytearray(man_data.data.length) + reader.read_bytes(buffer) + data_bytes = bytes(buffer) + + # Verify Magic Bytes (0x4C, 0x44) and ACK Command Type (0x07) + if len(data_bytes) >= 11 and data_bytes[0] == 0x4C and data_bytes[1] == 0x44 and data_bytes[2] == 0x07: + player_id = data_bytes[3] + + # Deduplication check + if player_id in seen_devices: + return + seen_devices.add(player_id) + + cmd_id = data_bytes[4] + cmd_type = data_bytes[5] + delay_ms = int.from_bytes(data_bytes[6:10], byteorder='big') + state_raw = data_bytes[10] + + cmd_name = COMMANDS.get(cmd_type, "UNKNOWN") + state_name = STATE_MAP.get(state_raw, f"UNKNOWN({state_raw})") + rssi = args.raw_signal_strength_in_dbm + + print(f" [REPORT] Player {player_id:02d} | State: {state_name} | Locked CMD: {cmd_name} (ID:{cmd_id}) | Remaining Delay: {delay_ms}ms | RSSI: {rssi}dBm") + + +def create_payload(cmd_id, cmd_type, target_mask, delay_ms=2000, prep_ms=1000, extra_data=b''): + # 1. Magic Bytes (2 Bytes): "LD" (0x4C, 0x44 for ESP32) + magic_bytes = b'\x4C\x44' + + # 2. CMD Info (1 Byte): High 4-bit is CMD_ID, Low 4-bit is CMD_TYPE + cmd_info = ((cmd_id & 0x0F) << 4) | (cmd_type & 0x0F) + + # 3. Target Mask (8 Bytes, Little Endian) + mask_bytes = target_mask.to_bytes(8, byteorder='little') + + # 4. Delay Time (4 Bytes, Big Endian) in milliseconds (ms) + delay_bytes = delay_ms.to_bytes(4, byteorder='big') + + # 5. Spec Data (4 Bytes) - Pad to 19 Bytes total + spec_bytes = bytearray(4) + if cmd_type == 1: # PLAY (Requires 4 Bytes for prep_ms, Big Endian) + spec_bytes[:] = prep_ms.to_bytes(4, byteorder='big') + elif cmd_type == 5: # TEST (Requires 3 Bytes for RGB) + if len(extra_data) >= 3: + spec_bytes[0:3] = extra_data[0:3] + elif cmd_type == 6: # CANCEL (Requires 1 Byte for target CMD_ID to cancel) + if len(extra_data) >= 1: + spec_bytes[0] = extra_data[0] + + # Combine into a 19-byte payload + return magic_bytes + bytes([cmd_info]) + mask_bytes + delay_bytes + bytes(spec_bytes) + +def main(): + global seen_devices + # Initialize Publisher + publisher = BluetoothLEAdvertisementPublisher() + + # Initialize Watcher for receiving ACKs + watcher = BluetoothLEAdvertisementWatcher() + watcher.scanning_mode = BluetoothLEScanningMode.ACTIVE + watcher.add_received(on_advertisement_received) + + current_cmd_id = 0 + + print("=== ESP32 BLE LPS Controller (Interactive Mode) ===") + + while True: + try: + print("\n" + "="*40) + print("Available Commands:", ", ".join([f"{k}:{v}" for k, v in COMMANDS.items()])) + + # --- 1. Enter Command --- + cmd_input = input("Enter command code (1-9), or 'q' to quit: ").strip() + if cmd_input.lower() == 'q': + break + cmd_type = int(cmd_input) + if cmd_type not in COMMANDS: + print("Error: Invalid command code!") + continue + + # --- 2. Enter Target IDs --- + target_input = input("Enter Target IDs (e.g., 0,1,2), or 'all' to broadcast to all: ").strip() + target_mask = 0 + + if target_input.lower() == 'all': + target_mask = 0xFFFFFFFFFFFFFFFF + else: + target_list = target_input.split(',') + for tid_str in target_list: + if tid_str.strip(): + tid = int(tid_str.strip()) + if 0 <= tid <= 63: + target_mask |= (1 << tid) + else: + print(f"Warning: Target ID {tid} is out of range (0-63) and will be ignored.") + + if target_mask == 0: + print("Error: No valid targets specified!") + continue + + # --- 3. Enter Delay and Prep Time --- + if cmd_type == 7: # CHECK command requires shorter default delay + delay_input = input("Enter Delay time in ms [Default: 1500 for CHECK]: ").strip() + delay_ms = int(delay_input) if delay_input else 1500 + else: + delay_input = input("Enter Delay time in ms [Default: 2000]: ").strip() + delay_ms = int(delay_input) if delay_input else 2000 + + prep_ms = 1000 + if cmd_type == 1: # PLAY only + prep_input = input("Enter Prep LED time in ms [Default: 1000]: ").strip() + prep_ms = int(prep_input) if prep_input else 1000 + + # --- 4. Handle Special Commands (TEST / CANCEL) --- + extra_data = b'' + if cmd_type == 5: # LPS_CMD_TEST + rgb_input = input("Enter RGB values (Format: R,G,B, e.g., 255,0,0) or press Enter for default breathing: ").strip() + if rgb_input: + r, g, b = map(int, rgb_input.split(',')) + extra_data = bytes([r & 0xFF, g & 0xFF, b & 0xFF]) + elif cmd_type == 6: # LPS_CMD_CANCEL + cancel_input = input("Enter the CMD_ID to cancel (0-15): ").strip() + if cancel_input: + cancel_id = int(cancel_input) + extra_data = bytes([cancel_id & 0x0F]) + + # Generate Payload + payload = create_payload(current_cmd_id, cmd_type, target_mask, delay_ms, prep_ms, extra_data=extra_data) + + # --- 5. Update and Broadcast --- + if publisher.status == 2: + publisher.stop() + time.sleep(0.1) + + publisher.advertisement.manufacturer_data.clear() + adv = publisher.advertisement + adv.data_sections.clear() + + service_uuid = bytes([UUID1, UUID2]) + writer = DataWriter() + writer.write_bytes(service_uuid + payload[2:]) + + section = BluetoothLEAdvertisementDataSection( + 0x16, # Service Data + writer.detach_buffer() + ) + adv.data_sections.append(section) + + # --- 6. Broadcast and Optional Listening --- + publisher.start() + + if cmd_type == 7: + # For CHECK, broadcast briefly, then switch to listener mode + time.sleep(1.0) + publisher.stop() + + listen_time = (delay_ms / 1000.0) + 1.5 + print(f"\n[INFO] Broadcast sent. Listening for device reports for {listen_time:.1f} seconds...") + + # Clear the seen devices set before starting a new scan + seen_devices.clear() + + watcher.start() + time.sleep(listen_time) + watcher.stop() + print("[INFO] Listening finished.") + else: + # For standard commands, broadcast for 1 full second to ensure detection + time.sleep(1) + publisher.stop() + print(f"\n[INFO] Broadcast sent!") + + print(f" Command: {COMMANDS[cmd_type]} (CMD_ID: {current_cmd_id})") + print(f" Target Mask: {hex(target_mask)}") + print(f" Delay: {delay_ms}ms" + (f", Prep: {prep_ms}ms" if cmd_type == 1 else "")) + print(f" Raw Payload (Hex): {payload.hex().upper()}") + + # Increment CMD_ID (0-15 loop) + current_cmd_id = (current_cmd_id + 1) % 16 + + except ValueError: + print("Error: Please enter valid numbers! (Check your commas and values)") + except KeyboardInterrupt: + break + except Exception as e: + print(f"Unknown error occurred: {e}") + + # Ensure services are stopped on exit + print("\nStopping services...") + publisher.stop() + watcher.stop() + print("Services stopped. Exiting.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lps-ctrl/pyproject.toml b/lps-ctrl/pyproject.toml index a08757c..68e90b0 100644 --- a/lps-ctrl/pyproject.toml +++ b/lps-ctrl/pyproject.toml @@ -7,8 +7,18 @@ name = "lps-ctrl" version = "0.1.0" description = "Light Playback System Controller for ESP32" readme = "README.md" -requires-python = ">=3.8" dependencies = [ "pyserial>=3.5", -] \ No newline at end of file + "winrt-Windows.Foundation", + "winrt-Windows.Foundation.Collections", + "winrt-Windows.Devices.Bluetooth", + "winrt-Windows.Devices.Bluetooth.Advertisement", + "winrt-Windows.Storage.Streams" +] + +[project.scripts] +lps-bridge = "lps_ctrl.bridge_server:main" + +[tool.setuptools.packages.find] +where = ["src"] \ No newline at end of file diff --git a/lps-ctrl/scripts/LPS_advertiser.ps1 b/lps-ctrl/scripts/LPS_advertiser.ps1 new file mode 100644 index 0000000..3665c37 --- /dev/null +++ b/lps-ctrl/scripts/LPS_advertiser.ps1 @@ -0,0 +1,165 @@ +<# +.SYNOPSIS +ESP32 BLE LPS Controller CLI (Multi-Target Supported) + +.DESCRIPTION +Broadcasts BLE commands via PowerShell. Uses WinRT API to send Service Data (0x16) advertisements. +Bypasses PowerShell 5.1 type-casting issues by invoking the .NET CLR directly for WinRT collections. + +.EXAMPLE +.\LPS_advertiser.ps1 -CmdType 1 -TargetIds "all" +.\LPS_advertiser.ps1 -CmdType 5 -TargetIds "1,3,5" -R 255 -G 0 -B 0 +.\LPS_advertiser.ps1 -CmdType 6 -CancelId 3 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true, HelpMessage="CMD (1:PLAY, 2:PAUSE, 3:STOP, 4:RELEASE, 5:TEST, 6:CANCEL, 7:CHECK, 8:UPLOAD, 9:RESET)")] + [Alias('c')] + [ValidateRange(1,9)] + [int]$CmdType, + + [Parameter(Mandatory=$true, HelpMessage="Target IDs (e.g., '0,1,2'. Use '-1' or 'all' for global broadcast.)")] + [Alias('t')] + [string]$TargetIds, + + [Parameter(Mandatory=$false, HelpMessage="Command ID (0-15)")] + [ValidateRange(0,15)] + [int]$CmdId = 0, + + [Parameter(Mandatory=$false)] + [int]$DelayMs = 2000, + + [Parameter(Mandatory=$false)] + [int]$PrepMs = 1000, + + [Parameter(Mandatory=$false)] + [byte]$R = 0, + + [Parameter(Mandatory=$false)] + [byte]$G = 0, + + [Parameter(Mandatory=$false)] + [byte]$B = 0, + + [Parameter(Mandatory=$false)] + [int]$CancelId = 0 +) + +# --- 1. Payload Assembly --- +$uuid1 = [byte]0x00 +$uuid2 = [byte]0x00 + +# Combine CmdId (High 4 bits) and CmdType (Low 4 bits) +$cmdInfo = [byte]((($CmdId -band 0x0F) -shl 4) -bor ($CmdType -band 0x0F)) + +# Generate 8-byte Target Mask from comma-separated string +$mask = [uint64]0 +if ($TargetIds.ToLower() -eq 'all' -or $TargetIds -eq '-1') { + $mask = [uint64]::MaxValue +} else { + $idArray = $TargetIds -split ',' + foreach ($idStr in $idArray) { + $idStr = $idStr.Trim() + if (-not [string]::IsNullOrEmpty($idStr)) { + $id = [int]$idStr + if ($id -ge 0 -and $id -le 63) { + $mask = $mask -bor ([uint64]1 -shl $id) + } else { + Write-Warning "Target ID $id is out of range (0-63) and will be ignored." + } + } + } +} + +if ($mask -eq 0) { + Write-Error "No valid targets specified!" + exit +} + +$maskBytes = [BitConverter]::GetBytes([uint64]$mask) +if (-not [BitConverter]::IsLittleEndian) { [Array]::Reverse($maskBytes) } + +# Convert Delay to 4-byte Big-Endian +$delayBytes = [BitConverter]::GetBytes([uint32]$DelayMs) +if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($delayBytes) } + +# Handle Command-Specific Payload (4 bytes) +$specBytes = New-Object byte[] 4 +if ($CmdType -eq 1) { + # PLAY: Prep LED time + $prepBytes = [BitConverter]::GetBytes([uint32]$PrepMs) + if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($prepBytes) } + $specBytes = $prepBytes +} elseif ($CmdType -eq 5) { + # TEST: RGB colors + $specBytes[0] = $R + $specBytes[1] = $G + $specBytes[2] = $B +} elseif ($CmdType -eq 6) { + # CANCEL: Slot ID + $specBytes[0] = [byte]($CancelId -band 0x0F) +} + +# Construct Final Byte Array +$payloadList = [System.Collections.Generic.List[byte]]::new() +$payloadList.Add($uuid1) +$payloadList.Add($uuid2) +$payloadList.Add($cmdInfo) +$payloadList.AddRange($maskBytes) +$payloadList.AddRange($delayBytes) +$payloadList.AddRange($specBytes) + +$payload = $payloadList.ToArray() +$hexString = [BitConverter]::ToString($payload) -replace '-' +Write-Host "Assembled Payload (Hex): $hexString" -ForegroundColor Cyan +Write-Host "Target Mask (Hex): $($mask.ToString('X16'))" -ForegroundColor Cyan + +# --- 2. WinRT BLE Advertising --- + +# Define required Windows Metadata assemblies +$assemblies = @( + "System.Runtime.WindowsRuntime", + "$env:windir\System32\WinMetadata\Windows.Foundation.winmd", + "$env:windir\System32\WinMetadata\Windows.Devices.winmd" +) + +try { + # Load WindowsRuntime assembly for IBuffer support + Add-Type -AssemblyName System.Runtime.WindowsRuntime + + # Resolve WinRT types + $advPublisherType = [Type]::GetType("Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementPublisher, Windows.Devices.Bluetooth, ContentType=WindowsRuntime") + $advDataSectionType = [Type]::GetType("Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementDataSection, Windows.Devices.Bluetooth, ContentType=WindowsRuntime") + + # Wrap the payload into a WinRT IBuffer + $buffer = [System.Runtime.InteropServices.WindowsRuntime.WindowsRuntimeBuffer]::Create($payload, 0, $payload.Length, $payload.Length) + + # Instantiate Publisher and Data Section (Service Data 0x16) + $publisher = [Activator]::CreateInstance($advPublisherType) + $section = [Activator]::CreateInstance($advDataSectionType) + $section.DataType = 0x16 + $section.Data = $buffer + + # 1. Create a generic ICollection interface + $collectionType = [System.Collections.Generic.ICollection`1].MakeGenericType($advDataSectionType) + + # 2. Extract the 'Add' method from the interface + $addMethod = $collectionType.GetMethod("Add") + + # 3. Use .NET Reflection to invoke 'Add' (Avoids PowerShell's WinRT adapter errors) + $addMethod.Invoke($publisher.Advertisement.DataSections, [object[]]@($section)) + + # Start broadcasting + Write-Host "Broadcasting BLE Advertisement..." -ForegroundColor Green + $publisher.Start() + + # Keep the broadcast active for 1 second + Start-Sleep -Seconds 1 + + $publisher.Stop() + Write-Host "Broadcast complete." -ForegroundColor Yellow + +} catch { + Write-Error "BLE Advertisement failed: $_" +} \ No newline at end of file