From 4754f39cd513c9e03aa78990a805bcbc0081b5dc Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Thu, 12 Mar 2026 20:07:23 +0800 Subject: [PATCH 01/13] change ad_type and package content to match gatt service format --- adv_esp/README.md | 23 +++++++++++------------ adv_esp/main/bt_sender.c | 6 ++---- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/adv_esp/README.md b/adv_esp/README.md index da13e9b..26dc7ad 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 | `0x16, 0x4C, 0x44` | UUID | +| **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..2a9ea5d 100644 --- a/adv_esp/main/bt_sender.c +++ b/adv_esp/main/bt_sender.c @@ -63,10 +63,8 @@ static void hci_cmd_send_ble_set_adv_data(uint8_t cmd_type, uint32_t delay_ms, u raw_adv_data[idx++] = 2; raw_adv_data[idx++] = 0x01; raw_adv_data[idx++] = 0x06; - raw_adv_data[idx++] = 22; - raw_adv_data[idx++] = 0xFF; - raw_adv_data[idx++] = 0xFF; - raw_adv_data[idx++] = 0xFF; + raw_adv_data[idx++] = 20; + raw_adv_data[idx++] = 0x16; raw_adv_data[idx++] = 0x4C; // 'L' raw_adv_data[idx++] = 0x44; // 'D' From cf3908ed778b7644d0cfad68023ade612152ea3e Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Fri, 13 Mar 2026 22:59:57 +0800 Subject: [PATCH 02/13] define an ugly uuid to force user manually input uuid --- adv_esp/README.md | 2 +- adv_esp/main/bt_sender.c | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/adv_esp/README.md b/adv_esp/README.md index 26dc7ad..e0c06df 100644 --- a/adv_esp/README.md +++ b/adv_esp/README.md @@ -105,7 +105,7 @@ The packet is constructed in `hci_cmd_send_ble_set_adv_data`. It uses Manufactur | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **0** | 3 | `0x16, 0x4C, 0x44` | UUID | +| **0** | 3 | UUID | 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) | diff --git a/adv_esp/main/bt_sender.c b/adv_esp/main/bt_sender.c index 2a9ea5d..9376f8e 100644 --- a/adv_esp/main/bt_sender.c +++ b/adv_esp/main/bt_sender.c @@ -34,6 +34,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; @@ -66,8 +68,8 @@ static void hci_cmd_send_ble_set_adv_data(uint8_t cmd_type, uint32_t delay_ms, u raw_adv_data[idx++] = 20; raw_adv_data[idx++] = 0x16; - raw_adv_data[idx++] = 0x4C; // 'L' - raw_adv_data[idx++] = 0x44; // 'D' + raw_adv_data[idx++] = UUID1; // change into real uuid + raw_adv_data[idx++] = UUID2; // change into real uuid raw_adv_data[idx++] = cmd_type; // Target Mask (8 bytes) @@ -153,7 +155,7 @@ 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)) { + if(ad_type == 0xFF && (adv_data[offset] == 0xFF && adv_data[offset + 1] == 0xFF) && (adv_data[offset+2] == UUID1 && adv_data[offset + 3] == UUID2)) { 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]; From f657d5b94376be417071061d6576261356713909 Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Sun, 15 Mar 2026 15:23:30 +0800 Subject: [PATCH 03/13] add more specific comment in bt_sender.cpp --- adv_esp/README.md | 2 +- adv_esp/main/bt_sender.c | 97 +++++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 38 deletions(-) diff --git a/adv_esp/README.md b/adv_esp/README.md index e0c06df..08e8ca8 100644 --- a/adv_esp/README.md +++ b/adv_esp/README.md @@ -105,7 +105,7 @@ The packet is constructed in `hci_cmd_send_ble_set_adv_data`. It uses Manufactur | Offset | Length | Value | Description | | --- | --- | --- | --- | -| **0** | 3 | UUID | UUID (remember to change it to real one) | +| **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) | diff --git a/adv_esp/main/bt_sender.c b/adv_esp/main/bt_sender.c index 9376f8e..84a538c 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 @@ -46,13 +47,13 @@ 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 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 @@ -63,43 +64,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++] = 20; - raw_adv_data[idx++] = 0x16; + // --- 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++] = UUID1; // change into real uuid - raw_adv_data[idx++] = UUID2; // change into real uuid + // 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 +119,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 +146,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 +164,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++]; + + // 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 +219,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 +231,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 +255,26 @@ 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); + // 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 +282,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 +290,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 +306,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; idelay_ms * 1000ULL); s_tasks[slot].active = true; ESP_LOGD(TAG, "Task added to slot %d (Type 0x%02X)", slot, config->cmd_type); @@ -330,10 +350,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 +364,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 +372,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) From 966038ff006cfd894ec022222753565b96d4766b Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Sun, 15 Mar 2026 18:15:51 +0800 Subject: [PATCH 04/13] enable PC as gatt server --- lps-ctrl/docs/bridge_server_readme.md | 130 +++++++++++++++++ lps-ctrl/scripts/bridge_server.py | 195 ++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 lps-ctrl/docs/bridge_server_readme.md create mode 100644 lps-ctrl/scripts/bridge_server.py diff --git a/lps-ctrl/docs/bridge_server_readme.md b/lps-ctrl/docs/bridge_server_readme.md new file mode 100644 index 0000000..3df5fa6 --- /dev/null +++ b/lps-ctrl/docs/bridge_server_readme.md @@ -0,0 +1,130 @@ +# ESP32 LPS Gateway: BLE GATT Server to Serial Bridge + +This repository contains a cross-platform Python script (`bridge_server.py`) that acts as a communication bridge between a mobile device (BLE GATT Client) and an ESP32 hardware sender connected via USB Serial. + +It establishes a local BLE GATT Server on your PC, receives highly compressed custom byte payloads from a mobile app, translates them into the required format, and forwards them to the ESP32 Sender via UART. The ESP32 Sender then broadcasts low-latency hardware-level BLE commands to multiple remote ESP32 Receiver nodes. + +--- + +## 1. System Architecture + +The following diagram illustrates the data flow and the role of this Python bridge in the overall system: + +```text +[Mobile Phone / Tablet] (GATT Client) + | + | 1. Sends custom HEX payloads (e.g., 0x01...) via BLE GATT + | 2. Reads/Receives JSON status reports via BLE Notify/Read + v +[PC / Laptop] (GATT Server - bridge_server.py) + | + | 3. Parses the HEX payload, unpacks Target Masks, and formats the data + | 4. Forwards the expanded command via USB Serial (UART) + v +[ESP32 Sender Node] (Hardware Gateway) + | + | 5. Packages data into HCI Connectionless Broadcast (Manufacturer/Service Data) + | 6. Transmits via BLE Advertising + v +[ESP32 Receiver Nodes] (Stage Lights / End Devices) + +``` + +--- + +## 2. Environment Setup & Installation + +### Prerequisites + +* **Python:** Python 3.10 is strictly recommended for Windows users to avoid C++ build errors related to the underlying Windows BLE libraries (`bleak-winrt`). +* **Hardware:** An ESP32 flashed with the sender firmware, connected to the host PC via USB. + +### Installation Steps + +1. Open your terminal or command prompt. +2. Install the required Python packages using `pip`: +```bash +pip install bless pyserial +``` + + +* Note for Windows users: If you encounter an error regarding `pysetupdi` missing, install it directly from the source: `pip install https://github.com/gwangyi/pysetupdi/archive/refs/heads/master.zip` + +--- + +## 3. Usage Guide + +### Starting the Server + +Run the script from the command line, specifying the serial port your ESP32 is connected to. + +**Windows Example:** + +```bash +python bridge_server.py -p COM3 +``` + +**macOS / Linux Example:** + +```bash +python bridge_server.py -p /dev/ttyUSB0 +``` + +### Command Line Arguments + +You can customize the server configuration using the following arguments: + +| Argument | Short | Default | Description | +| --- | --- | --- | --- | +| `--port` | `-p` | **(Required)** | Serial port connected to the ESP32 Sender. | +| `--baud` | `-b` | `115200` | Serial baud rate. | +| `--name` | `-n` | `LPS_Gateway` | BLE device name broadcasted over the air. | +| `--service-uuid` | | `0000AAAA-...` | Custom Service UUID. | +| `--ctrl-uuid` | | `0000AA01-...` | Control Characteristic UUID (Write). | +| `--report-uuid` | | `0000AA02-...` | Report Characteristic UUID (Read/Notify). | +--- + +## 4. Mobile App Interaction (GATT Protocol) + +Once the server is running, use a BLE scanner app (e.g., nRF Connect) to connect to `LPS_Gateway`. + +### Characteristics + +* **Control (Write) - `0xAA01`:** Write HEX byte arrays here to trigger actions. +* **Report (Read/Notify) - `0xAA02`:** Read or subscribe to this characteristic to receive JSON-formatted status reports triggered by the `CHECK` command. Ensure your MTU is set to at least 512 bytes on the client app to receive full JSON payloads. + +Here is the updated **Payload Structures** section with the specific timing constraints added. You can directly replace this section in your README. + +### Payload Structures (Control Characteristic) + +The server utilizes a variable-length protocol to minimize BLE overhead. All numerical values must be sent in **Big-Endian** format. The `Target Mask` is an 8-byte (64-bit) bitmask where each bit represents a target ID. Use `FFFFFFFFFFFFFFFF` for a global broadcast. + +**Timing Constraints:** + +* **Delay MS:** Must be strictly greater than 1000 ms (1 second). +* **Prep LED MS:** Must be exactly 0 (disabled) or strictly greater than 1000 ms (1 second). + +**1. PLAY (17 Bytes)** + +* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Prep LED MS: 4 bytes] + [Target Mask: 8 bytes]` +* Example (Global, 2000ms delay, 1000ms prep): `01 000007D0 000003E8 FFFFFFFFFFFFFFFF` + +**2. PAUSE / STOP / RELEASE / UPLOAD / RESET (13 Bytes)** + +* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes]` +* Example (PAUSE, Global, 2000ms delay): `02 000007D0 FFFFFFFFFFFFFFFF` + +**3. TEST (16 Bytes)** + +* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes] + [RGB: 3 bytes]` +* Example (TEST, 2000ms delay, ID 1 and 2, Red): `05 000007D0 0000000000000003 FF0000` + +**4. CANCEL (14 Bytes)** + +* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes] + [Target id: 1 byte]` +* Example (CANCEL, 2000ms delay, Global, id 1): `06 000007D0 FFFFFFFFFFFFFFFF 01` + +**5. CHECK (9 Bytes)** + +* Format: `[CMD: 1 byte] + [Target Mask: 8 bytes]` +* Example (CHECK, Global): `07 FFFFFFFFFFFFFFFF` \ No newline at end of file diff --git a/lps-ctrl/scripts/bridge_server.py b/lps-ctrl/scripts/bridge_server.py new file mode 100644 index 0000000..a10e118 --- /dev/null +++ b/lps-ctrl/scripts/bridge_server.py @@ -0,0 +1,195 @@ +import asyncio +import json +import argparse +import sys +from bless import ( + BlessServer, + BlessGATTCharacteristic, + GATTCharacteristicProperties, + GATTAttributePermissions +) +from lps_ctrl import ESP32BTSender + +async def run_server(args): + loop = asyncio.get_running_loop() + + # 1. Initialize ESP32 Sender + print(f"Connecting to ESP32 Sender (Port: {args.port}, Baud: {args.baud})...") + try: + sender = ESP32BTSender(port=args.port, baud_rate=args.baud) + sender.connect() + print("ESP32 Sender connected via Serial.") + except Exception as e: + print(f"Failed to connect to Serial Port {args.port}: {e}") + sys.exit(1) + + # 2. Initialize GATT Server + server = BlessServer(name=args.name) + await server.add_new_service(args.service_uuid) + + # Add Control Characteristic (Write) + await server.add_new_characteristic( + args.service_uuid, + args.ctrl_uuid, + GATTCharacteristicProperties.write, + bytearray(b"\x00"), + GATTAttributePermissions.writeable + ) + + # Add Report Characteristic (Read/Notify) + await server.add_new_characteristic( + args.service_uuid, + args.report_uuid, + GATTCharacteristicProperties.read | GATTCharacteristicProperties.notify, + bytearray(b"{}"), + GATTAttributePermissions.readable + ) + + # 3. Command Processing Logic + async def process_command(value: bytearray): + if len(value) < 1: + return + + cmd_int = value[0] + cmd_map_inv = {v: k for k, v in sender.CMD_MAP.items()} + cmd_name = cmd_map_inv.get(cmd_int, None) + + if not cmd_name: + print(f"Unknown command code received: 0x{cmd_int:02X}") + return + + # Default values + delay_ms = 0 + prep_ms = 0 + data = [0, 0, 0] + target_mask = 0xFFFFFFFFFFFFFFFF # Default to global broadcast + + try: + # Parse dynamic length payloads based on command type + if cmd_int == 0x01: # PLAY (17 Bytes) + # Format: [CMD:1] [Delay:4] [Prep:4] [Mask:8] + delay_ms = int.from_bytes(value[1:5], byteorder='big') + prep_ms = int.from_bytes(value[5:9], byteorder='big') + target_mask = int.from_bytes(value[9:17], byteorder='big') + + elif cmd_int in [0x02, 0x03, 0x04, 0x08, 0x09]: # PAUSE, STOP, RELEASE, UPLOAD, RESET (13 Bytes) + # Format: [CMD:1] [Delay:4] [Mask:8] + delay_ms = int.from_bytes(value[1:5], byteorder='big') + target_mask = int.from_bytes(value[5:13], byteorder='big') + + elif cmd_int == 0x05: # TEST (16 Bytes) + # Format: [CMD:1] [Delay:4] [Mask:8] [RGB:3] + delay_ms = int.from_bytes(value[1:5], byteorder='big') + target_mask = int.from_bytes(value[5:13], byteorder='big') + data = list(value[13:16]) # Extract RGB + + elif cmd_int == 0x06: # CANCEL (14 Bytes) + # Format: [CMD:1] [Delay:4] [Mask:8] [Slot:1] + delay_ms = int.from_bytes(value[1:5], byteorder='big') + target_mask = int.from_bytes(value[5:13], byteorder='big') + data[0] = value[13] # Store slot to cancel in data[0] + + elif cmd_int == 0x07: # CHECK (9 Bytes) + # Format: [CMD:1] [Mask:8] + target_mask = int.from_bytes(value[1:9], byteorder='big') + + except IndexError: + print(f"Insufficient payload length! Cannot parse {len(value)} bytes for [{cmd_name}]: {value.hex()}") + return + + # Convert bitmask back to target ID list for lps_ctrl.py + if target_mask == 0xFFFFFFFFFFFFFFFF: + target_ids = None # Global broadcast + elif target_mask == 0: + target_ids = [0] + else: + target_ids = [] + for i in range(64): + if target_mask & (1 << i): + target_ids.append(i) + + delay_sec = delay_ms / 1000.0 + prep_led_sec = prep_ms / 1000.0 + + print(f"Executing: [{cmd_name}], Delay={delay_sec}s, PrepLED={prep_led_sec}s, Data={data}, Targets={target_ids}") + + # Execute corresponding sender function + if cmd_name == "CHECK": + print("Starting CHECK status scan...") + await asyncio.to_thread(sender.trigger_check, target_ids if target_ids else []) + report = await asyncio.to_thread(sender.get_latest_report) + report_str = json.dumps(report) + print(f"Scan result: {report_str}") + + # Update characteristic value for client to read/notify + report_bytes = bytearray(report_str.encode('utf-8')[:512]) + server.get_characteristic(args.report_uuid).value = report_bytes + server.update_value(args.service_uuid, args.report_uuid) + else: + await asyncio.to_thread( + sender.send_burst, + cmd_input=cmd_name, + delay_sec=delay_sec, + prep_led_sec=prep_led_sec, + target_ids=target_ids, + data=data + ) + + # 4. Bind BLE event callbacks + def write_request(char: BlessGATTCharacteristic, value: bytearray, **kwargs): + if char.uuid.lower() == args.ctrl_uuid.lower(): + # Dispatch command processing safely to the main event loop + asyncio.run_coroutine_threadsafe(process_command(value), loop) + char.value = value + + def read_request(char: BlessGATTCharacteristic, **kwargs) -> bytearray: + if char.uuid.lower() == args.report_uuid.lower(): + report = sender.get_latest_report() + return bytearray(json.dumps(report).encode('utf-8')[:512]) + return char.value + + server.read_request_func = read_request + server.write_request_func = write_request + + # 5. Start Server + await server.start() + print("==================================================") + print("GATT Server started!") + print(f" Device Name: {args.name}") + print(f" Service UUID: {args.service_uuid}") + print(f" Control UUID: {args.ctrl_uuid} (Write)") + print(f" Report UUID: {args.report_uuid} (Read/Notify)") + print("==================================================") + print("Press Ctrl+C to stop the server...") + + try: + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + pass + finally: + print("\nShutting down system...") + await server.stop() + sender.close() + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="ESP32 LPS Gateway - BLE GATT Server to Serial Bridge", + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + parser.add_argument("-p", "--port", type=str, required=True, + help="Serial port for ESP32 connection (e.g., COM3, /dev/ttyUSB0)") + parser.add_argument("-b", "--baud", type=int, default=115200, + help="Serial baud rate") + parser.add_argument("-n", "--name", type=str, default="LPS_Gateway", + help="BLE device name to broadcast") + parser.add_argument("--service-uuid", type=str, default="0000AAAA-0000-1000-8000-00805F9B34FB", + help="Custom Service UUID") + parser.add_argument("--ctrl-uuid", type=str, default="0000AA01-0000-1000-8000-00805F9B34FB", + help="Custom Control Characteristic UUID") + parser.add_argument("--report-uuid", type=str, default="0000AA02-0000-1000-8000-00805F9B34FB", + help="Custom Report Characteristic UUID") + + args = parser.parse_args() + asyncio.run(run_server(args)) \ No newline at end of file From e28a2e91c284ade3cc91218303c6d4cb3b815623 Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Sun, 15 Mar 2026 19:28:12 +0800 Subject: [PATCH 05/13] enable powershell script as GATT server for windows PC --- lps-ctrl/README.md | 5 +- lps-ctrl/scripts/LPS_advertiser.ps1 | 151 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 lps-ctrl/scripts/LPS_advertiser.ps1 diff --git a/lps-ctrl/README.md b/lps-ctrl/README.md index 821f657..f8882d9 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 diff --git a/lps-ctrl/scripts/LPS_advertiser.ps1 b/lps-ctrl/scripts/LPS_advertiser.ps1 new file mode 100644 index 0000000..cb310ad --- /dev/null +++ b/lps-ctrl/scripts/LPS_advertiser.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS +ESP32 BLE LPS Controller CLI + +.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 -TargetId -1 +.\LPS_advertiser.ps1 -CmdType 5 -TargetId 2 -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=$false, HelpMessage="Target ID (0-63). Use -1 for global broadcast.")] + [Alias('t')] + [ValidateRange(-1,63)] + [int]$TargetId = -1, + + [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 (Update UUID as needed) --- +$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 +$maskBytes = New-Object byte[] 8 +if ($TargetId -eq -1) { + # Set all bits to 1 for global broadcast + for ($i = 0; $i -lt 8; $i++) { $maskBytes[$i] = 0xFF } +} else { + # Shift bit 1 to the target ID position + $mask = [uint64]1 -shl $TargetId + $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 + + +# --- 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 From 09ee51c0f1d1eb80762beaa5a8b92b0699390088 Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Sun, 15 Mar 2026 20:16:45 +0800 Subject: [PATCH 06/13] update install methods --- lps-ctrl/docs/bridge_server_readme.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lps-ctrl/docs/bridge_server_readme.md b/lps-ctrl/docs/bridge_server_readme.md index 3df5fa6..4fdf330 100644 --- a/lps-ctrl/docs/bridge_server_readme.md +++ b/lps-ctrl/docs/bridge_server_readme.md @@ -44,12 +44,13 @@ The following diagram illustrates the data flow and the role of this Python brid 1. Open your terminal or command prompt. 2. Install the required Python packages using `pip`: ```bash -pip install bless pyserial +py -3.10 -m pip install bless pyserial +py -3.10 -m pip install pysetupdi +py -3.10 -m pip install https://github.com/gwangyi/pysetupdi/archive/refs/heads/master.zip +py -3.10 .\scripts\bridge_server.py -p COM3 ``` -* Note for Windows users: If you encounter an error regarding `pysetupdi` missing, install it directly from the source: `pip install https://github.com/gwangyi/pysetupdi/archive/refs/heads/master.zip` - --- ## 3. Usage Guide @@ -61,13 +62,13 @@ Run the script from the command line, specifying the serial port your ESP32 is c **Windows Example:** ```bash -python bridge_server.py -p COM3 +py -3.10 bridge_server.py -p COM3 ``` **macOS / Linux Example:** ```bash -python bridge_server.py -p /dev/ttyUSB0 +py -3.10 bridge_server.py -p /dev/ttyUSB0 ``` ### Command Line Arguments From 948940fad079a839e6d6eb2820eeb92d233ac582 Mon Sep 17 00:00:00 2001 From: Jun Date: Sun, 15 Mar 2026 21:30:37 +0800 Subject: [PATCH 07/13] add readme and modify example of powershell script --- lps-ctrl/docs/powershell_script_readme.md | 73 +++++++++++++++++++++++ lps-ctrl/scripts/LPS_advertiser.ps1 | 6 +- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 lps-ctrl/docs/powershell_script_readme.md diff --git a/lps-ctrl/docs/powershell_script_readme.md b/lps-ctrl/docs/powershell_script_readme.md new file mode 100644 index 0000000..b291374 --- /dev/null +++ b/lps-ctrl/docs/powershell_script_readme.md @@ -0,0 +1,73 @@ +# ESP32 BLE LPS Controller CLI + +這是一個基於 PowerShell 的命令列工具,用於透過 BLE(藍牙低功耗)向 ESP32 LPS 控制器廣播指令。 + +本腳本透過呼叫 Windows WinRT API 發送藍牙 Service Data (`0x16`) 廣播封包,並利用 .NET 反射(Reflection)技術直接呼叫 CLR,繞過了 PowerShell 5.1 在處理 WinRT 集合型別時常見的轉型錯誤。 + +## 系統需求 + +* **作業系統**: Windows 10 或 Windows 11(需支援 WinRT API)。 +* **硬體**: 支援藍牙的網卡或接收器(執行前請確認 Windows 藍牙功能已開啟)。 +* **環境**: PowerShell 5.1 或更高版本(建議使用系統管理員身分執行以確保藍牙廣播權限)。 + +## 參數說明 + +執行腳本時,可以透過以下參數來客製化廣播指令: + +| 參數 | 縮寫 | 型別 | 預設值 | 說明 | +| --- | --- | --- | --- | --- | +| `-CmdType` | `-c` | Int | **(必填)** | 指令類型。有效值:`1`:PLAY, `2`:PAUSE, `3`:STOP, `4`:RELEASE, `5`:TEST, `6`:CANCEL, `7`:CHECK, `8`:UPLOAD, `9`:RESET | +| `-TargetId` | `-t` | Int | `-1` | 目標設備 ID (`0-63`)。若設為 `-1` 則代表全域廣播 (Global Broadcast)。 | +| `-CmdId` | | Int | `0` | 指令的 ID (`0-15`)。 | +| `-DelayMs` | | Int | `2000` | 延遲時間,單位為毫秒 (ms)。 | +| `-PrepMs` | | Int | `1000` | 準備時間,單位為毫秒 (ms)。**僅適用於 `PLAY` (Type 1) 指令。** | +| `-R` | | Byte | `0` | 紅色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | +| `-G` | | Byte | `0` | 綠色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | +| `-B` | | Byte | `0` | 藍色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | +| `-CancelId` | | Int | `0` | 要取消的 Slot ID。**僅適用於 `CANCEL` (Type 6) 指令。** | + +## 使用範例 + +**1. 全域播放 (PLAY)** +對所有設備發送 PLAY 指令,並使用預設的延遲與準備時間: + +```powershell +.\LPS_advertiser.ps1 -CmdType 1 -TargetId -1 + +``` + +**2. 指定設備測試 (TEST)** +對 ID 為 2 的設備發送 TEST 指令,並點亮紅色 LED (R:255, G:0, B:0): + +```powershell +.\LPS_advertiser.ps1 -CmdType 5 -TargetId 2 -R 255 -G 0 -B 0 + +``` + +**3. 取消特定排程 (CANCEL)** +發送 CANCEL 指令,並指定取消 Slot ID 為 3 的任務: + +```powershell +.\LPS_advertiser.ps1 -CmdType 6 -CancelId 3 + +``` + +## 封包結構解析 (Payload Structure) + +腳本在底層會將參數組裝成 19 Bytes 的 Service Data 封包,結構如下: + +* **Byte 0-1**: UUID (`0x01`, `0x02` - 可於腳本內自定義) +* **Byte 2**: 指令資訊 (由 CmdId 的高 4 位元與 CmdType 的低 4 位元組合而成) +* **Byte 3-10**: Target Mask (8 Bytes 遮罩。若為 `-1` 則全為 `0xFF`;若指定目標,則將 Bit 1 左移至對應 ID 位置) +* **Byte 11-14**: Delay 延遲時間 (4 Bytes, Big-Endian) +* **Byte 15-18**: 特定指令負載 (4 Bytes, 依據 `CmdType` 動態決定): +* `Type 1 (PLAY)`: 傳遞 `PrepMs` (Big-Endian) +* `Type 5 (TEST)`: 傳遞 RGB 數值 (`R`, `G`, `B`) +* `Type 6 (CANCEL)`: 傳遞 `CancelId` + + + +## 故障排除 + +* **廣播失敗或發生例外錯誤**:請確認 Windows 的藍牙已開啟。如果出現存取被拒的問題,請嘗試以「系統管理員身分」開啟 PowerShell 視窗再執行腳本。 +* **ESP32 沒收到訊號**:請檢查 ESP32 端的 UUID 解析是否與腳本開頭宣告的 `$uuid1` 和 `$uuid2` 一致。 \ No newline at end of file diff --git a/lps-ctrl/scripts/LPS_advertiser.ps1 b/lps-ctrl/scripts/LPS_advertiser.ps1 index cb310ad..27217f0 100644 --- a/lps-ctrl/scripts/LPS_advertiser.ps1 +++ b/lps-ctrl/scripts/LPS_advertiser.ps1 @@ -7,7 +7,7 @@ Broadcasts BLE commands via PowerShell. Uses WinRT API to send Service Data (0x1 Bypasses PowerShell 5.1 type-casting issues by invoking the .NET CLR directly for WinRT collections. .EXAMPLE -.\LPS_advertiser.ps1 -CmdType 1 -TargetId -1 +.\LPS_advertiser.ps1 -CmdType 1 -TargetId -1 (or .\LPS_advertiser.ps1 -c 1 -t -1) .\LPS_advertiser.ps1 -CmdType 5 -TargetId 2 -R 255 -G 0 -B 0 .\LPS_advertiser.ps1 -CmdType 6 -CancelId 3 #> @@ -19,10 +19,10 @@ param( [ValidateRange(1,9)] [int]$CmdType, - [Parameter(Mandatory=$false, HelpMessage="Target ID (0-63). Use -1 for global broadcast.")] + [Parameter(Mandatory=$true, HelpMessage="Target ID (0-63). Use -1 for global broadcast.")] [Alias('t')] [ValidateRange(-1,63)] - [int]$TargetId = -1, + [int]$TargetId, [Parameter(Mandatory=$false, HelpMessage="Command ID (0-15)")] [ValidateRange(0,15)] From 3965c78656e3e548a129d060f2e88762237f170c Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Tue, 17 Mar 2026 09:21:16 +0800 Subject: [PATCH 08/13] revise winrt broadcaster and powershell gatt server --- lps-ctrl/README.md | 13 +- lps-ctrl/docs/bridge_server_readme.md | 131 -------------- lps-ctrl/docs/powershell_script_readme.md | 85 +++++---- lps-ctrl/docs/winrt_broadcaster_readme.md | 100 +++++++++++ lps-ctrl/examples/pc_adv_ex.py | 210 ++++++++++++++++++++++ lps-ctrl/pyproject.toml | 15 +- lps-ctrl/scripts/LPS_advertiser.ps1 | 48 +++-- lps-ctrl/scripts/bridge_server.py | 195 -------------------- 8 files changed, 407 insertions(+), 390 deletions(-) delete mode 100644 lps-ctrl/docs/bridge_server_readme.md create mode 100644 lps-ctrl/docs/winrt_broadcaster_readme.md create mode 100644 lps-ctrl/examples/pc_adv_ex.py delete mode 100644 lps-ctrl/scripts/bridge_server.py diff --git a/lps-ctrl/README.md b/lps-ctrl/README.md index f8882d9..35f5e43 100644 --- a/lps-ctrl/README.md +++ b/lps-ctrl/README.md @@ -316,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/bridge_server_readme.md b/lps-ctrl/docs/bridge_server_readme.md deleted file mode 100644 index 4fdf330..0000000 --- a/lps-ctrl/docs/bridge_server_readme.md +++ /dev/null @@ -1,131 +0,0 @@ -# ESP32 LPS Gateway: BLE GATT Server to Serial Bridge - -This repository contains a cross-platform Python script (`bridge_server.py`) that acts as a communication bridge between a mobile device (BLE GATT Client) and an ESP32 hardware sender connected via USB Serial. - -It establishes a local BLE GATT Server on your PC, receives highly compressed custom byte payloads from a mobile app, translates them into the required format, and forwards them to the ESP32 Sender via UART. The ESP32 Sender then broadcasts low-latency hardware-level BLE commands to multiple remote ESP32 Receiver nodes. - ---- - -## 1. System Architecture - -The following diagram illustrates the data flow and the role of this Python bridge in the overall system: - -```text -[Mobile Phone / Tablet] (GATT Client) - | - | 1. Sends custom HEX payloads (e.g., 0x01...) via BLE GATT - | 2. Reads/Receives JSON status reports via BLE Notify/Read - v -[PC / Laptop] (GATT Server - bridge_server.py) - | - | 3. Parses the HEX payload, unpacks Target Masks, and formats the data - | 4. Forwards the expanded command via USB Serial (UART) - v -[ESP32 Sender Node] (Hardware Gateway) - | - | 5. Packages data into HCI Connectionless Broadcast (Manufacturer/Service Data) - | 6. Transmits via BLE Advertising - v -[ESP32 Receiver Nodes] (Stage Lights / End Devices) - -``` - ---- - -## 2. Environment Setup & Installation - -### Prerequisites - -* **Python:** Python 3.10 is strictly recommended for Windows users to avoid C++ build errors related to the underlying Windows BLE libraries (`bleak-winrt`). -* **Hardware:** An ESP32 flashed with the sender firmware, connected to the host PC via USB. - -### Installation Steps - -1. Open your terminal or command prompt. -2. Install the required Python packages using `pip`: -```bash -py -3.10 -m pip install bless pyserial -py -3.10 -m pip install pysetupdi -py -3.10 -m pip install https://github.com/gwangyi/pysetupdi/archive/refs/heads/master.zip -py -3.10 .\scripts\bridge_server.py -p COM3 -``` - - ---- - -## 3. Usage Guide - -### Starting the Server - -Run the script from the command line, specifying the serial port your ESP32 is connected to. - -**Windows Example:** - -```bash -py -3.10 bridge_server.py -p COM3 -``` - -**macOS / Linux Example:** - -```bash -py -3.10 bridge_server.py -p /dev/ttyUSB0 -``` - -### Command Line Arguments - -You can customize the server configuration using the following arguments: - -| Argument | Short | Default | Description | -| --- | --- | --- | --- | -| `--port` | `-p` | **(Required)** | Serial port connected to the ESP32 Sender. | -| `--baud` | `-b` | `115200` | Serial baud rate. | -| `--name` | `-n` | `LPS_Gateway` | BLE device name broadcasted over the air. | -| `--service-uuid` | | `0000AAAA-...` | Custom Service UUID. | -| `--ctrl-uuid` | | `0000AA01-...` | Control Characteristic UUID (Write). | -| `--report-uuid` | | `0000AA02-...` | Report Characteristic UUID (Read/Notify). | ---- - -## 4. Mobile App Interaction (GATT Protocol) - -Once the server is running, use a BLE scanner app (e.g., nRF Connect) to connect to `LPS_Gateway`. - -### Characteristics - -* **Control (Write) - `0xAA01`:** Write HEX byte arrays here to trigger actions. -* **Report (Read/Notify) - `0xAA02`:** Read or subscribe to this characteristic to receive JSON-formatted status reports triggered by the `CHECK` command. Ensure your MTU is set to at least 512 bytes on the client app to receive full JSON payloads. - -Here is the updated **Payload Structures** section with the specific timing constraints added. You can directly replace this section in your README. - -### Payload Structures (Control Characteristic) - -The server utilizes a variable-length protocol to minimize BLE overhead. All numerical values must be sent in **Big-Endian** format. The `Target Mask` is an 8-byte (64-bit) bitmask where each bit represents a target ID. Use `FFFFFFFFFFFFFFFF` for a global broadcast. - -**Timing Constraints:** - -* **Delay MS:** Must be strictly greater than 1000 ms (1 second). -* **Prep LED MS:** Must be exactly 0 (disabled) or strictly greater than 1000 ms (1 second). - -**1. PLAY (17 Bytes)** - -* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Prep LED MS: 4 bytes] + [Target Mask: 8 bytes]` -* Example (Global, 2000ms delay, 1000ms prep): `01 000007D0 000003E8 FFFFFFFFFFFFFFFF` - -**2. PAUSE / STOP / RELEASE / UPLOAD / RESET (13 Bytes)** - -* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes]` -* Example (PAUSE, Global, 2000ms delay): `02 000007D0 FFFFFFFFFFFFFFFF` - -**3. TEST (16 Bytes)** - -* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes] + [RGB: 3 bytes]` -* Example (TEST, 2000ms delay, ID 1 and 2, Red): `05 000007D0 0000000000000003 FF0000` - -**4. CANCEL (14 Bytes)** - -* Format: `[CMD: 1 byte] + [Delay MS: 4 bytes] + [Target Mask: 8 bytes] + [Target id: 1 byte]` -* Example (CANCEL, 2000ms delay, Global, id 1): `06 000007D0 FFFFFFFFFFFFFFFF 01` - -**5. CHECK (9 Bytes)** - -* Format: `[CMD: 1 byte] + [Target Mask: 8 bytes]` -* Example (CHECK, Global): `07 FFFFFFFFFFFFFFFF` \ No newline at end of file diff --git a/lps-ctrl/docs/powershell_script_readme.md b/lps-ctrl/docs/powershell_script_readme.md index b291374..626b0d6 100644 --- a/lps-ctrl/docs/powershell_script_readme.md +++ b/lps-ctrl/docs/powershell_script_readme.md @@ -1,73 +1,70 @@ # ESP32 BLE LPS Controller CLI -這是一個基於 PowerShell 的命令列工具,用於透過 BLE(藍牙低功耗)向 ESP32 LPS 控制器廣播指令。 +This is a PowerShell-based command-line tool used to broadcast commands to the ESP32 LPS controller via BLE (Bluetooth Low Energy). -本腳本透過呼叫 Windows WinRT API 發送藍牙 Service Data (`0x16`) 廣播封包,並利用 .NET 反射(Reflection)技術直接呼叫 CLR,繞過了 PowerShell 5.1 在處理 WinRT 集合型別時常見的轉型錯誤。 +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 -* **作業系統**: Windows 10 或 Windows 11(需支援 WinRT API)。 -* **硬體**: 支援藍牙的網卡或接收器(執行前請確認 Windows 藍牙功能已開啟)。 -* **環境**: PowerShell 5.1 或更高版本(建議使用系統管理員身分執行以確保藍牙廣播權限)。 +* **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 | **(必填)** | 指令類型。有效值:`1`:PLAY, `2`:PAUSE, `3`:STOP, `4`:RELEASE, `5`:TEST, `6`:CANCEL, `7`:CHECK, `8`:UPLOAD, `9`:RESET | -| `-TargetId` | `-t` | Int | `-1` | 目標設備 ID (`0-63`)。若設為 `-1` 則代表全域廣播 (Global Broadcast)。 | -| `-CmdId` | | Int | `0` | 指令的 ID (`0-15`)。 | -| `-DelayMs` | | Int | `2000` | 延遲時間,單位為毫秒 (ms)。 | -| `-PrepMs` | | Int | `1000` | 準備時間,單位為毫秒 (ms)。**僅適用於 `PLAY` (Type 1) 指令。** | -| `-R` | | Byte | `0` | 紅色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | -| `-G` | | Byte | `0` | 綠色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | -| `-B` | | Byte | `0` | 藍色色值 (`0-255`)。**僅適用於 `TEST` (Type 5) 指令。** | -| `-CancelId` | | Int | `0` | 要取消的 Slot ID。**僅適用於 `CANCEL` (Type 6) 指令。** | +| `-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. 全域播放 (PLAY)** -對所有設備發送 PLAY 指令,並使用預設的延遲與準備時間: +**1. Global Play (PLAY)** +Send a PLAY command to all devices using the default delay and preparation time: ```powershell -.\LPS_advertiser.ps1 -CmdType 1 -TargetId -1 - +.\LPS_advertiser.ps1 -CmdType 1 -TargetIds "all" ``` -**2. 指定設備測試 (TEST)** -對 ID 為 2 的設備發送 TEST 指令,並點亮紅色 LED (R:255, G:0, B:0): +**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 -TargetId 2 -R 255 -G 0 -B 0 - +.\LPS_advertiser.ps1 -CmdType 5 -TargetIds "1,3,5" -R 255 -G 0 -B 0 ``` -**3. 取消特定排程 (CANCEL)** -發送 CANCEL 指令,並指定取消 Slot ID 為 3 的任務: +**3. Cancel Specific Schedule (CANCEL)** +Send a CANCEL command, specifying to cancel the task with Slot ID 3: ```powershell -.\LPS_advertiser.ps1 -CmdType 6 -CancelId 3 - +.\LPS_advertiser.ps1 -CmdType 6 -TargetIds "all" -CancelId 3 ``` -## 封包結構解析 (Payload Structure) +## Payload Structure -腳本在底層會將參數組裝成 19 Bytes 的 Service Data 封包,結構如下: +Under the hood, the script assembles the parameters into a 19-byte Service Data packet with the following structure: -* **Byte 0-1**: UUID (`0x01`, `0x02` - 可於腳本內自定義) -* **Byte 2**: 指令資訊 (由 CmdId 的高 4 位元與 CmdType 的低 4 位元組合而成) -* **Byte 3-10**: Target Mask (8 Bytes 遮罩。若為 `-1` 則全為 `0xFF`;若指定目標,則將 Bit 1 左移至對應 ID 位置) -* **Byte 11-14**: Delay 延遲時間 (4 Bytes, Big-Endian) -* **Byte 15-18**: 特定指令負載 (4 Bytes, 依據 `CmdType` 動態決定): -* `Type 1 (PLAY)`: 傳遞 `PrepMs` (Big-Endian) -* `Type 5 (TEST)`: 傳遞 RGB 數值 (`R`, `G`, `B`) -* `Type 6 (CANCEL)`: 傳遞 `CancelId` +* **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 -* **廣播失敗或發生例外錯誤**:請確認 Windows 的藍牙已開啟。如果出現存取被拒的問題,請嘗試以「系統管理員身分」開啟 PowerShell 視窗再執行腳本。 -* **ESP32 沒收到訊號**:請檢查 ESP32 端的 UUID 解析是否與腳本開頭宣告的 `$uuid1` 和 `$uuid2` 一致。 \ No newline at end of file +* **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..a131750 --- /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" +] +``` + +To install the required environment, activate your virtual environment and run: + +```bash +pip install -e . +``` + +## Usage (Interactive Mode) + +Start the interactive terminal by running the script: + +```bash +python 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..038ac4f --- /dev/null +++ b/lps-ctrl/examples/pc_adv_ex.py @@ -0,0 +1,210 @@ +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" +} + +# Replace with your actual UUIDs +UUID1 = -1 +UUID2 = -1 + +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) + """ + 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) + data_bytes = reader.read_bytes(man_data.data.length) + + # 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] + cmd_id = data_bytes[4] + cmd_type = data_bytes[5] + delay_ms = int.from_bytes(data_bytes[6:10], byteorder='big') + state = data_bytes[10] + + cmd_name = COMMANDS.get(cmd_type, "UNKNOWN") + rssi = args.raw_signal_strength_in_d_bm + + print(f" [REPORT] Player {player_id:02d} | State: {state} | 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(): + # 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: 100 for CHECK]: ").strip() + delay_ms = int(delay_input) if delay_input else 100 + 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(0.4) + publisher.stop() + + listen_time = (delay_ms / 1000.0) + 1.5 # Listen slightly longer than the requested delay + print(f"\n[INFO] Broadcast sent. Listening for device reports for {listen_time:.1f} seconds...") + 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..b14d7a5 100644 --- a/lps-ctrl/pyproject.toml +++ b/lps-ctrl/pyproject.toml @@ -7,8 +7,19 @@ name = "lps-ctrl" version = "0.1.0" description = "Light Playback System Controller for ESP32" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10, <3.11" 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 index 27217f0..f9c6357 100644 --- a/lps-ctrl/scripts/LPS_advertiser.ps1 +++ b/lps-ctrl/scripts/LPS_advertiser.ps1 @@ -1,14 +1,14 @@ <# .SYNOPSIS -ESP32 BLE LPS Controller CLI +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 -TargetId -1 (or .\LPS_advertiser.ps1 -c 1 -t -1) -.\LPS_advertiser.ps1 -CmdType 5 -TargetId 2 -R 255 -G 0 -B 0 +.\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 #> @@ -19,10 +19,9 @@ param( [ValidateRange(1,9)] [int]$CmdType, - [Parameter(Mandatory=$true, HelpMessage="Target ID (0-63). Use -1 for global broadcast.")] + [Parameter(Mandatory=$true, HelpMessage="Target IDs (e.g., '0,1,2'. Use '-1' or 'all' for global broadcast.)")] [Alias('t')] - [ValidateRange(-1,63)] - [int]$TargetId, + [string]$TargetIds, [Parameter(Mandatory=$false, HelpMessage="Command ID (0-15)")] [ValidateRange(0,15)] @@ -47,25 +46,40 @@ param( [int]$CancelId = 0 ) -# --- 1. Payload Assembly (Update UUID as needed) --- +# --- 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 -$maskBytes = New-Object byte[] 8 -if ($TargetId -eq -1) { - # Set all bits to 1 for global broadcast - for ($i = 0; $i -lt 8; $i++) { $maskBytes[$i] = 0xFF } +# Generate 8-byte Target Mask from comma-separated string +$mask = [uint64]0 +if ($TargetIds.ToLower() -eq 'all' -or $TargetIds -eq '-1') { + $mask = 0xFFFFFFFFFFFFFFFF } else { - # Shift bit 1 to the target ID position - $mask = [uint64]1 -shl $TargetId - $maskBytes = [BitConverter]::GetBytes([uint64]$mask) - if (-not [BitConverter]::IsLittleEndian) { [Array]::Reverse($maskBytes) } + $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) } @@ -99,7 +113,7 @@ $payloadList.AddRange($specBytes) $payload = $payloadList.ToArray() $hexString = [BitConverter]::ToString($payload) -replace '-' Write-Host "Assembled Payload (Hex): $hexString" -ForegroundColor Cyan - +Write-Host "Target Mask (Hex): $([Convert]::ToString([long]$mask, 16))" -ForegroundColor Cyan # --- 2. WinRT BLE Advertising --- diff --git a/lps-ctrl/scripts/bridge_server.py b/lps-ctrl/scripts/bridge_server.py deleted file mode 100644 index a10e118..0000000 --- a/lps-ctrl/scripts/bridge_server.py +++ /dev/null @@ -1,195 +0,0 @@ -import asyncio -import json -import argparse -import sys -from bless import ( - BlessServer, - BlessGATTCharacteristic, - GATTCharacteristicProperties, - GATTAttributePermissions -) -from lps_ctrl import ESP32BTSender - -async def run_server(args): - loop = asyncio.get_running_loop() - - # 1. Initialize ESP32 Sender - print(f"Connecting to ESP32 Sender (Port: {args.port}, Baud: {args.baud})...") - try: - sender = ESP32BTSender(port=args.port, baud_rate=args.baud) - sender.connect() - print("ESP32 Sender connected via Serial.") - except Exception as e: - print(f"Failed to connect to Serial Port {args.port}: {e}") - sys.exit(1) - - # 2. Initialize GATT Server - server = BlessServer(name=args.name) - await server.add_new_service(args.service_uuid) - - # Add Control Characteristic (Write) - await server.add_new_characteristic( - args.service_uuid, - args.ctrl_uuid, - GATTCharacteristicProperties.write, - bytearray(b"\x00"), - GATTAttributePermissions.writeable - ) - - # Add Report Characteristic (Read/Notify) - await server.add_new_characteristic( - args.service_uuid, - args.report_uuid, - GATTCharacteristicProperties.read | GATTCharacteristicProperties.notify, - bytearray(b"{}"), - GATTAttributePermissions.readable - ) - - # 3. Command Processing Logic - async def process_command(value: bytearray): - if len(value) < 1: - return - - cmd_int = value[0] - cmd_map_inv = {v: k for k, v in sender.CMD_MAP.items()} - cmd_name = cmd_map_inv.get(cmd_int, None) - - if not cmd_name: - print(f"Unknown command code received: 0x{cmd_int:02X}") - return - - # Default values - delay_ms = 0 - prep_ms = 0 - data = [0, 0, 0] - target_mask = 0xFFFFFFFFFFFFFFFF # Default to global broadcast - - try: - # Parse dynamic length payloads based on command type - if cmd_int == 0x01: # PLAY (17 Bytes) - # Format: [CMD:1] [Delay:4] [Prep:4] [Mask:8] - delay_ms = int.from_bytes(value[1:5], byteorder='big') - prep_ms = int.from_bytes(value[5:9], byteorder='big') - target_mask = int.from_bytes(value[9:17], byteorder='big') - - elif cmd_int in [0x02, 0x03, 0x04, 0x08, 0x09]: # PAUSE, STOP, RELEASE, UPLOAD, RESET (13 Bytes) - # Format: [CMD:1] [Delay:4] [Mask:8] - delay_ms = int.from_bytes(value[1:5], byteorder='big') - target_mask = int.from_bytes(value[5:13], byteorder='big') - - elif cmd_int == 0x05: # TEST (16 Bytes) - # Format: [CMD:1] [Delay:4] [Mask:8] [RGB:3] - delay_ms = int.from_bytes(value[1:5], byteorder='big') - target_mask = int.from_bytes(value[5:13], byteorder='big') - data = list(value[13:16]) # Extract RGB - - elif cmd_int == 0x06: # CANCEL (14 Bytes) - # Format: [CMD:1] [Delay:4] [Mask:8] [Slot:1] - delay_ms = int.from_bytes(value[1:5], byteorder='big') - target_mask = int.from_bytes(value[5:13], byteorder='big') - data[0] = value[13] # Store slot to cancel in data[0] - - elif cmd_int == 0x07: # CHECK (9 Bytes) - # Format: [CMD:1] [Mask:8] - target_mask = int.from_bytes(value[1:9], byteorder='big') - - except IndexError: - print(f"Insufficient payload length! Cannot parse {len(value)} bytes for [{cmd_name}]: {value.hex()}") - return - - # Convert bitmask back to target ID list for lps_ctrl.py - if target_mask == 0xFFFFFFFFFFFFFFFF: - target_ids = None # Global broadcast - elif target_mask == 0: - target_ids = [0] - else: - target_ids = [] - for i in range(64): - if target_mask & (1 << i): - target_ids.append(i) - - delay_sec = delay_ms / 1000.0 - prep_led_sec = prep_ms / 1000.0 - - print(f"Executing: [{cmd_name}], Delay={delay_sec}s, PrepLED={prep_led_sec}s, Data={data}, Targets={target_ids}") - - # Execute corresponding sender function - if cmd_name == "CHECK": - print("Starting CHECK status scan...") - await asyncio.to_thread(sender.trigger_check, target_ids if target_ids else []) - report = await asyncio.to_thread(sender.get_latest_report) - report_str = json.dumps(report) - print(f"Scan result: {report_str}") - - # Update characteristic value for client to read/notify - report_bytes = bytearray(report_str.encode('utf-8')[:512]) - server.get_characteristic(args.report_uuid).value = report_bytes - server.update_value(args.service_uuid, args.report_uuid) - else: - await asyncio.to_thread( - sender.send_burst, - cmd_input=cmd_name, - delay_sec=delay_sec, - prep_led_sec=prep_led_sec, - target_ids=target_ids, - data=data - ) - - # 4. Bind BLE event callbacks - def write_request(char: BlessGATTCharacteristic, value: bytearray, **kwargs): - if char.uuid.lower() == args.ctrl_uuid.lower(): - # Dispatch command processing safely to the main event loop - asyncio.run_coroutine_threadsafe(process_command(value), loop) - char.value = value - - def read_request(char: BlessGATTCharacteristic, **kwargs) -> bytearray: - if char.uuid.lower() == args.report_uuid.lower(): - report = sender.get_latest_report() - return bytearray(json.dumps(report).encode('utf-8')[:512]) - return char.value - - server.read_request_func = read_request - server.write_request_func = write_request - - # 5. Start Server - await server.start() - print("==================================================") - print("GATT Server started!") - print(f" Device Name: {args.name}") - print(f" Service UUID: {args.service_uuid}") - print(f" Control UUID: {args.ctrl_uuid} (Write)") - print(f" Report UUID: {args.report_uuid} (Read/Notify)") - print("==================================================") - print("Press Ctrl+C to stop the server...") - - try: - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - pass - finally: - print("\nShutting down system...") - await server.stop() - sender.close() - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="ESP32 LPS Gateway - BLE GATT Server to Serial Bridge", - formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - - parser.add_argument("-p", "--port", type=str, required=True, - help="Serial port for ESP32 connection (e.g., COM3, /dev/ttyUSB0)") - parser.add_argument("-b", "--baud", type=int, default=115200, - help="Serial baud rate") - parser.add_argument("-n", "--name", type=str, default="LPS_Gateway", - help="BLE device name to broadcast") - parser.add_argument("--service-uuid", type=str, default="0000AAAA-0000-1000-8000-00805F9B34FB", - help="Custom Service UUID") - parser.add_argument("--ctrl-uuid", type=str, default="0000AA01-0000-1000-8000-00805F9B34FB", - help="Custom Control Characteristic UUID") - parser.add_argument("--report-uuid", type=str, default="0000AA02-0000-1000-8000-00805F9B34FB", - help="Custom Report Characteristic UUID") - - args = parser.parse_args() - asyncio.run(run_server(args)) \ No newline at end of file From 990f79b9ee7546e7c6638377b053948251910740 Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Tue, 17 Mar 2026 10:50:50 +0800 Subject: [PATCH 09/13] enable user to get check report --- lps-ctrl/examples/pc_adv_ex.py | 43 ++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/lps-ctrl/examples/pc_adv_ex.py b/lps-ctrl/examples/pc_adv_ex.py index 038ac4f..2111479 100644 --- a/lps-ctrl/examples/pc_adv_ex.py +++ b/lps-ctrl/examples/pc_adv_ex.py @@ -13,34 +13,52 @@ 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 = -1 -UUID2 = -1 +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) - data_bytes = reader.read_bytes(man_data.data.length) + 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 = data_bytes[10] + state_raw = data_bytes[10] cmd_name = COMMANDS.get(cmd_type, "UNKNOWN") - rssi = args.raw_signal_strength_in_d_bm + 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} | Locked CMD: {cmd_name} (ID:{cmd_id}) | Remaining Delay: {delay_ms}ms | RSSI: {rssi}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''): @@ -71,6 +89,7 @@ def create_payload(cmd_id, cmd_type, target_mask, delay_ms=2000, prep_ms=1000, e return magic_bytes + bytes([cmd_info]) + mask_bytes + delay_bytes + bytes(spec_bytes) def main(): + global seen_devices # Initialize Publisher publisher = BluetoothLEAdvertisementPublisher() @@ -119,8 +138,8 @@ def main(): # --- 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: 100 for CHECK]: ").strip() - delay_ms = int(delay_input) if delay_input else 100 + 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 @@ -170,11 +189,15 @@ def main(): if cmd_type == 7: # For CHECK, broadcast briefly, then switch to listener mode - time.sleep(0.4) + time.sleep(1.0) publisher.stop() - listen_time = (delay_ms / 1000.0) + 1.5 # Listen slightly longer than the requested delay + 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() From 06054af50de7e15d394c6fcd8670ac3e4cb543fb Mon Sep 17 00:00:00 2001 From: Jun Date: Tue, 17 Mar 2026 13:13:35 +0800 Subject: [PATCH 10/13] modify readme --- lps-ctrl/docs/winrt_broadcaster_readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lps-ctrl/docs/winrt_broadcaster_readme.md b/lps-ctrl/docs/winrt_broadcaster_readme.md index a131750..2006838 100644 --- a/lps-ctrl/docs/winrt_broadcaster_readme.md +++ b/lps-ctrl/docs/winrt_broadcaster_readme.md @@ -24,7 +24,7 @@ dependencies = [ ] ``` -To install the required environment, activate your virtual environment and run: +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 . @@ -35,7 +35,7 @@ pip install -e . Start the interactive terminal by running the script: ```bash -python pc_adv_ex.py +python examples/pc_adv_ex.py ``` The script will guide you through a step-by-step prompt to assemble and broadcast your commands: From bdb99ea3db69a6c1341719f97fa4db4291343b45 Mon Sep 17 00:00:00 2001 From: Jun Date: Tue, 17 Mar 2026 13:13:58 +0800 Subject: [PATCH 11/13] target id fix --- lps-ctrl/scripts/LPS_advertiser.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lps-ctrl/scripts/LPS_advertiser.ps1 b/lps-ctrl/scripts/LPS_advertiser.ps1 index f9c6357..3665c37 100644 --- a/lps-ctrl/scripts/LPS_advertiser.ps1 +++ b/lps-ctrl/scripts/LPS_advertiser.ps1 @@ -56,7 +56,7 @@ $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 = 0xFFFFFFFFFFFFFFFF + $mask = [uint64]::MaxValue } else { $idArray = $TargetIds -split ',' foreach ($idStr in $idArray) { @@ -113,7 +113,7 @@ $payloadList.AddRange($specBytes) $payload = $payloadList.ToArray() $hexString = [BitConverter]::ToString($payload) -replace '-' Write-Host "Assembled Payload (Hex): $hexString" -ForegroundColor Cyan -Write-Host "Target Mask (Hex): $([Convert]::ToString([long]$mask, 16))" -ForegroundColor Cyan +Write-Host "Target Mask (Hex): $($mask.ToString('X16'))" -ForegroundColor Cyan # --- 2. WinRT BLE Advertising --- From 57cf5ce216b61b208a382389c64a9ead0c87c4d4 Mon Sep 17 00:00:00 2001 From: Jun Date: Tue, 17 Mar 2026 13:14:25 +0800 Subject: [PATCH 12/13] remove python version limitation --- lps-ctrl/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/lps-ctrl/pyproject.toml b/lps-ctrl/pyproject.toml index b14d7a5..68e90b0 100644 --- a/lps-ctrl/pyproject.toml +++ b/lps-ctrl/pyproject.toml @@ -7,7 +7,6 @@ name = "lps-ctrl" version = "0.1.0" description = "Light Playback System Controller for ESP32" readme = "README.md" -requires-python = ">=3.10, <3.11" dependencies = [ "pyserial>=3.5", From 080bcc2253852070889df1b15fbd180d2eff345c Mon Sep 17 00:00:00 2001 From: JuuuuuJu Date: Wed, 18 Mar 2026 20:06:34 +0800 Subject: [PATCH 13/13] modify prep_delay_ms --- adv_esp/main/bt_sender.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/adv_esp/main/bt_sender.c b/adv_esp/main/bt_sender.c index 84a538c..24b4d5c 100644 --- a/adv_esp/main/bt_sender.c +++ b/adv_esp/main/bt_sender.c @@ -48,6 +48,7 @@ static bool is_checking = false; // Flag indicating if currently in CHECK typedef struct { bool active; // Is this slot currently in use? 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; @@ -263,8 +264,11 @@ static void broadcast_scheduler_task(void *arg) { 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. @@ -332,6 +336,7 @@ int bt_sender_add_task(const bt_sender_config_t *config) { if (slot != -1) { s_tasks[slot].config = *config; + s_tasks[slot].prep_led_end_time_us = esp_timer_get_time() + ((uint64_t)config->prep_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;