Compare commits

..

7 Commits

Author SHA1 Message Date
evan.liu
e0253d1bbf fw V1.00.27: add IWDG watchdog (4s timeout, idle-hook fed with 3s task heartbeat window, DBG freeze, reset-cause banner) + 'hang' fire-test cmd 2026-09-05 13:53:20 +08:00
evan.liu
f7edfa291d tools: drop python-embed pyc caches from git (regenerated on first run) 2026-09-05 10:39:08 +08:00
evan.liu
1948e4b1ba tools: commit the bundled production runtime (python-embed + NSpyocd + DFP pack)
Per request the full portable environment is now in git: a fresh clone's
tools/ runs everything with zero installation. Only regenerable release
zips and the dev venv stay ignored.
2026-09-05 10:38:43 +08:00
evan.liu
57762e50be tools: self-contained portable production package
- tools/python-embed/: embedded Python 3.12.10 runtime with bleak/pyserial
  preinstalled (git-ignored, fetched from the huaweicloud mirror); bats
  resolve Python in order: python-embed -> .venv-ble -> system
- NSpyocd.exe + DFP pack copied into tools/ (git-ignored);
  flash_package.py prefers the in-tools copies
- make_release.bat/ps1: one-click zip to out/mothercup_tools_<ts>.zip
  (staged in TEMP to avoid self-inclusion; zip excluded from git)
- all bats converted to CRLF (LF-only breaks cmd block parsing and %~dp0)
- verified: zip extracted outside the repo runs ble_ota --uart end-to-end
  (PASS, 27 kB/s) and flash_package via the bundled NSpyocd
- docs: dev log section 49, AGENTS.md tools section + heap 18KB fix
2026-09-05 10:34:59 +08:00
evan.liu
dd218addce tools: add requirements-ble.txt for the (git-ignored) .venv-ble
The venv is a per-machine artifact and stays out of git; the bats
auto-create it on first run but only installed bleak - pyserial
(required by UART OTA) was missing on a fresh machine. Both bats now
install from requirements-ble.txt (bleak==3.0.2, pyserial==3.5).
2026-09-05 10:07:14 +08:00
evan.liu
8a7cebcf77 V1.00.26: UART baud 115200 -> 460800
- firmware BSP_USART_BAUDRATE 460800 (divider error +0.03%); uartrst/
  uartinfo follow the macro
- RX DMA ring 2KB -> 3KB: at 460800 2KB only covers 44ms, less than the
  ~45ms flash-erase interrupt-off window; stack top now 0x2000BDE8
  (guard warns <1KB from the 0x2000C000 cliff, by design)
- tools: ble_ota_update.py UartTransport and uart_cap.py default baud
- Verified: stream OTA 55832B in 2.6s @23kB/s (one lost frame recovered
  by go-back-N), PASS after reboot; text CLI fine at 460800
- docs: ble_protocol.md, design spec, AGENTS.md, dev log section 48
2026-09-05 10:01:23 +08:00
evan.liu
f729d16ff2 V1.00.25: UART OTA stream mode + RX DMA ring + SRAM cliff fix
Firmware:
- bsp_usart: RX moved from RXDNE byte-queue IRQ to DMA CH2 circular ring
  (2KB) + IDLE-irq wakeup; bytes keep landing during flash-erase
  interrupt-off windows (the reason lockstep was needed before)
- app_ota: stream mode (ble_protocol.md section 6.7) - 16B BEGIN with
  flags bit0=STREAM (UART channel only), DATA acked on 4KB sector
  crossings (original chunk length; two ack-gating bugs fixed during
  field test), throttled BAD_STATE for go-back-N
- FreeRTOS heap 20KB->18KB: the 2KB ring pushed the stack top past the
  0x2000C000 SRAM cliff (probed via SWD: accesses above fault on this
  silicon, usable app SRAM is 32KB) which locked the board at boot;
  merge_image.py now hard-fails the package when the image initial SP
  leaves (0x20004000, 0x2000C000]

Tools:
- ble_ota_update.py: UART stream sender (8KB window, stall watchdog
  rewind, auto-fallback to lockstep on pre-V1.00.24 firmware,
  --lockstep to force); case-insensitive option parsing
- flash_package.py/bat: stream NSpyocd output live (chunked reads keep
  the \r progress bar), vendor banner rebranded to CAIIC NSLINK UMP
- merge_image.py: initial-SP cliff guard

Verified: UART stream OTA both directions, 55.8KB in ~5.9s @9.5kB/s
0 rewinds (lockstep was 39s), PASS after reboot; board boot fixed and
verified via SWD.

Docs: ble_protocol.md section 6.7, dev log section 47 (+ SRAM cliff
post-mortem), AGENTS.md RAM rule rewritten (both cliffs) + V1.00.25
2026-09-05 09:48:28 +08:00
267 changed files with 42526 additions and 13609 deletions

5
.gitignore vendored
View File

@ -5,3 +5,8 @@ mcm-ddc-ble/MDK-ARM/Objects
mcm-ddc-ble/MDK-ARM/build.log mcm-ddc-ble/MDK-ARM/build.log
mcm-ddc-ble/MDK-ARM/build-app2.log mcm-ddc-ble/MDK-ARM/build-app2.log
mcm-ddc-ble/MDK-ARM/Objects-app2 mcm-ddc-ble/MDK-ARM/Objects-app2
# release zips are regenerable via tools/make_release.bat
tools/out/mothercup_tools_*.zip
tools/python-embed/Lib/site-packages/__pycache__/
tools/python-embed/**/__pycache__/

View File

@ -5,7 +5,7 @@
本仓库是 **mothercup(母乳杯)** 产品的完整代码仓库,包含三大部分: 本仓库是 **mothercup(母乳杯)** 产品的完整代码仓库,包含三大部分:
1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB): 1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB):
- **`mcm-ddc-ble/`** — 唯一活跃的应用固件工程(APP,链接在 APP1 bank `0x01008000`/112KB)。以 SDK rdtss 例程为蓝本,集成 FreeRTOS、UART CLI、BLE 自定义 GATT 服务(CLI 透传 / 设备信息查询 / BLE OTA 双 bank 直写升级)、bootsetting 与 APP_DATA 参数区结构化读写命令。当前版本 **V1.00.23**(`mcm-ddc-ble/inc/app_version.h`)。历史上曾有 `mcm-ddc-04/` 主工程,**已删除**,其功能已全部并入本工程。 - **`mcm-ddc-ble/`** — 唯一活跃的应用固件工程(APP,链接在 APP1 bank `0x01008000`/112KB)。以 SDK rdtss 例程为蓝本,集成 FreeRTOS、UART CLI、BLE 自定义 GATT 服务(CLI 透传 / 设备信息查询 / BLE OTA 双 bank 直写升级)、bootsetting 与 APP_DATA 参数区结构化读写命令。当前版本 **V1.00.27**(`mcm-ddc-ble/inc/app_version.h`)。历史上曾有 `mcm-ddc-04/` 主工程,**已删除**,其功能已全部并入本工程。
- **`Boot/`** — 自写精简 bootloader(链接在 `0x01000000`/16KB):校验 bootsetting 记录自身完整性(magic + 结构体 CRC32)后按 active bank 的 `start_address` 直接跳转;**不校验镜像 CRC(CRC 校验在 OTA 升级过程中完成)**;记录无效或地址越界回退 APP1。 - **`Boot/`** — 自写精简 bootloader(链接在 `0x01000000`/16KB):校验 bootsetting 记录自身完整性(magic + 结构体 CRC32)后按 active bank 的 `start_address` 直接跳转;**不校验镜像 CRC(CRC 校验在 OTA 升级过程中完成)**;记录无效或地址越界回退 APP1。
2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速/bank/堆等)、CLI 终端、参数读写(appget/appset)、OTA 升级页(e0005 锁步,已恢复)。另有登录/注册/用户信息等云端业务页面。 2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速/bank/堆等)、CLI 终端、参数读写(appget/appset)、OTA 升级页(e0005 锁步,已恢复)。另有登录/注册/用户信息等云端业务页面。
3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。 3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。
@ -45,7 +45,7 @@ Flash 布局(256KB,`Boot/src/dfu_layout.h` 为唯一权威定义;Boot/boot
| APP1 | 0x01008000 | 112KB(0x1C000) | | APP1 | 0x01008000 | 112KB(0x1C000) |
| APP2 | 0x01024000 | 112KB(0x1C000) | | APP2 | 0x01024000 | 112KB(0x1C000) |
- **RAM 铁律**:SRAM 为 48KB+16KB,**低 16KB(0x20000000~0x20003FFF)由芯片 ROM/BLE 子系统占用**(rwip 堆描述符、patch 数组、中断中继表,由 ROM 启动代码预备)。**任何工程的 IRAM 执行区必须从 0x20004000 起**(含 Boot 和任何小工具),否则 .data/.bss 初始化冲掉 ROM 数据,BLE init 必然 HardFault(实测根因,开发日志 §21)。注意:mcm-ddc-ble.uvprojx 里 `<IRAM>` 镜像元素显示 0x20000000 是过期显示值,实际生效的是 OCR_RVCT9=0x20004000/0xC000——以 `Listings/mcm-ddc-ble.map` 的 `RW_IRAM1 (Exec base: 0x20004000)` 为准。 - **RAM 铁律**:**低 16KB(0x20000000~0x20003FFF)由芯片 ROM/BLE 子系统占用**(rwip 堆描述符、patch 数组、中断中继表,由 ROM 启动代码预备),**任何工程的 IRAM 执行区必须从 0x20004000 起**(含 Boot 和任何小工具),否则 .data/.bss 初始化冲掉 ROM 数据,BLE init 必然 HardFault(实测根因,开发日志 §21)。**顶部同样悬崖(实测 SWD 探明,§47):0x2000C000 起访问即 fault,APP 可用 SRAM 实为 32KB(0x20004000~0x2000BFFF)**——IRAM 配置里的 0xC000(48KB)上限是虚的,**栈顶 `__initial_sp` 必须 < 0x2000C000**;`merge_image.py` 已加硬卡(出包时校验 bin 向量表首字,越线直接失败)。ZI 膨胀(加大缓冲/堆)把栈顶推过线会导致上电即 HardFault/Lockup(V1.00.24 的 2KB DMA ring 事故,heap 已降为 18KB 腾位)。注意:mcm-ddc-ble.uvprojx 里 `<IRAM>` 镜像元素显示 0x20000000 是过期显示值,实际生效的是 OCR_RVCT9=0x20004000/0xC000——以 `Listings/mcm-ddc-ble.map` 的 `RW_IRAM1 (Exec base: 0x20004000)` 为准。
- **VTOR**:Cortex-M0 无 SCB->VTOR,用 `PWR->VTOR_REG`(bit31=EN|[30:0]=基址)。规则:**BLE 初始化之前** VTOR 指向 APP 自身向量表(main 开头设 `0x80000000|0x01008000`,USART1 RX 中断需要);**`ns_ble_stack_init()` 之后 VTOR 必须为 0**,此后所有中断经芯片 ROM 跳板 + RAM 中继。**绝对不要在 BLE init 后重设 VTOR**(实测崩溃根因,开发日志 §18)。 - **VTOR**:Cortex-M0 无 SCB->VTOR,用 `PWR->VTOR_REG`(bit31=EN|[30:0]=基址)。规则:**BLE 初始化之前** VTOR 指向 APP 自身向量表(main 开头设 `0x80000000|0x01008000`,USART1 RX 中断需要);**`ns_ble_stack_init()` 之后 VTOR 必须为 0**,此后所有中断经芯片 ROM 跳板 + RAM 中继。**绝对不要在 BLE init 后重设 VTOR**(实测崩溃根因,开发日志 §18)。
## 固件工程(mcm-ddc-ble) ## 固件工程(mcm-ddc-ble)
@ -55,7 +55,7 @@ mcm-ddc-ble/
├── src/ ├── src/
│ ├── main.c # 入口:VTOR 预置 → USART+banner → LED → app_ble_init → app_params_init │ ├── main.c # 入口:VTOR 预置 → USART+banner → LED → app_ble_init → app_params_init
│ │ # → ns_sleep_lock_acquire(锁睡眠保 SWD) → 建 BLE 调度/LED/CLI 任务 → vTaskStartScheduler │ │ # → ns_sleep_lock_acquire(锁睡眠保 SWD) → 建 BLE 调度/LED/CLI 任务 → vTaskStartScheduler
│ ├── bsp_usart.c # USART1(PB6/PB7, 115200 8N1):fputc 重定向 + DMA TX(CH1) + RXDNE 中断字节队列 + TX 互斥锁 │ ├── bsp_usart.c # USART1(PB6/PB7, 460800 8N1, V1.00.26 起;IWDG 看门狗见 §50):fputc 重定向 + DMA TX(CH1) + RX DMA 循环环形缓冲(CH2, 3KB, IDLE 中断唤醒;擦扇区关中断期间不丢字节) + TX 互斥锁
│ ├── app_gpio.c # LED1(PB0)/LED2(PA6) │ ├── app_gpio.c # LED1(PB0)/LED2(PA6)
│ ├── app_cli.c # CLI UART 前端:CliTask 行接收/编辑/历史/Tab/Ctrl+Z,提示符 caiic-> │ ├── app_cli.c # CLI UART 前端:CliTask 行接收/编辑/历史/Tab/Ctrl+Z,提示符 caiic->
│ ├── cli_core.c # CLI 核心(命令表 s_cmds[]/handler/分词执行),输出经可切换 cli_out_fn │ ├── cli_core.c # CLI 核心(命令表 s_cmds[]/handler/分词执行),输出经可切换 cli_out_fn
@ -95,7 +95,7 @@ mcm-ddc-ble/
"D:\Keil_v5\UV4\UV4.exe" -b caiic_boot.uvprojx -j0 -o build.log # Boot 增量构建 "D:\Keil_v5\UV4\UV4.exe" -b caiic_boot.uvprojx -j0 -o build.log # Boot 增量构建
``` ```
要求保持 **0 Error(s), 0 Warning(s)**。当前基线(V1.00.22):`Code=48872 RO-data=4980 RW-data=2076 ZI-data=29116`。 要求保持 **0 Error(s), 0 Warning(s)**。当前基线(V1.00.27):`Code=49408 RO-data=5132 RW-data=2092 ZI-data=30140`。
**固件版本号约定(重要)**:每次修改固件代码必须递增 `mcm-ddc-ble/inc/app_version.h` 中的 `APP_FW_VERSION` 与 `APP_FW_VERSION_NUM`(格式 `0x00MMmmpp`,通常补丁位 +1),用于识别板上实际运行的固件;工程名与出包文件名保持不变。 **固件版本号约定(重要)**:每次修改固件代码必须递增 `mcm-ddc-ble/inc/app_version.h` 中的 `APP_FW_VERSION` 与 `APP_FW_VERSION_NUM`(格式 `0x00MMmmpp`,通常补丁位 +1),用于识别板上实际运行的固件;工程名与出包文件名保持不变。
@ -113,7 +113,7 @@ mcm-ddc-ble/
3. 日常开发调试:Keil F7 编译、F8 下载即可(Boot 不校验镜像 CRC,可直接下载 APP 调试);**Keil 下载选项必须是 "Erase Sectors",整片擦除会杀掉 Boot/bootsetting**(杀掉后需重烧整片包恢复)。板子上烧过其他 0x01000000 起步的程序同样会覆盖 Boot,恢复也是重烧整片包。 3. 日常开发调试:Keil F7 编译、F8 下载即可(Boot 不校验镜像 CRC,可直接下载 APP 调试);**Keil 下载选项必须是 "Erase Sectors",整片擦除会杀掉 Boot/bootsetting**(杀掉后需重烧整片包恢复)。板子上烧过其他 0x01000000 起步的程序同样会覆盖 Boot,恢复也是重烧整片包。
4. 日常固件升级设计路径是 BLE OTA(手机 App 或 PC bleak 客户端)。 4. 日常固件升级设计路径是 BLE OTA(手机 App 或 PC bleak 客户端)。
验证运行:串口助手接 PB6(TX)/PB7(RX),115200 8N1,复位后应看到 banner 与 `caiic->` 提示符(`help` 查看命令);手机应能搜到广播名 `CAIIC-MCM-20260902`。 验证运行:串口助手接 PB6(TX)/PB7(RX),460800 8N1(V1.00.26 起),复位后应看到 banner 与 `caiic->` 提示符(`help` 查看命令);手机应能搜到广播名 `CAIIC-MCM-20260902`。
## BLE 接口与协议 ## BLE 接口与协议
@ -128,7 +128,7 @@ mcm-ddc-ble/
| `...c72ee005` | Read + Write(带响应) | **OTA 专用特征**(V1.00.19 新增):写携带一帧 OTA 传输帧 → 读同一特征取回 OTA_RSP(轮询至非空),见 ble_protocol.md §6.6 | | `...c72ee005` | Read + Write(带响应) | **OTA 专用特征**(V1.00.19 新增):写携带一帧 OTA 传输帧 → 读同一特征取回 OTA_RSP(轮询至非空),见 ble_protocol.md §6.6 |
- **notify 上行已修复(V1.00.19)**:根因是固件帧 CRC16 漏算最后一个 payload 字节(3+payLen vs 协议规定的 4+payLen),所有帧被静默丢弃,与 notify 硬件链路无关(开发日志 §42)。帧协议通道(e0001/e0002)已实测恢复。App(SmartAssiter)走 `...e0003`/`...e0004` 读写特征 + `...e0005` OTA 特征(App 侧参考 `src/mcm/McmOta.ts`,PC 参考 `tools/ble_ota_update.py`)。 - **notify 上行已修复(V1.00.19)**:根因是固件帧 CRC16 漏算最后一个 payload 字节(3+payLen vs 协议规定的 4+payLen),所有帧被静默丢弃,与 notify 硬件链路无关(开发日志 §42)。帧协议通道(e0001/e0002)已实测恢复。App(SmartAssiter)走 `...e0003`/`...e0004` 读写特征 + `...e0005` OTA 特征(App 侧参考 `src/mcm/McmOta.ts`,PC 参考 `tools/ble_ota_update.py`)。
- 帧格式:`0xCA | TYPE | SEQ | LEN(LE16) | PAYLOAD | CRC16-CCITT(0x1021/0xFFFF, LE)`,**CRC 覆盖 TYPE..PAYLOAD 共 4+payLen 字节**;OTA 为双 bank 直写、**逐帧锁步 ack**、END 整镜像 zlib CRC32 + 向量表 bank 校验后更新 bootsetting 并复位。OTA 传输层通道无关:BLE e0005 / UART 二进制模式(CLI `ota`)/ 旧帧协议通道,详见 ble_protocol.md §6.6。 - 帧格式:`0xCA | TYPE | SEQ | LEN(LE16) | PAYLOAD | CRC16-CCITT(0x1021/0xFFFF, LE)`,**CRC 覆盖 TYPE..PAYLOAD 共 4+payLen 字节**;OTA 为双 bank 直写、END 整镜像 zlib CRC32 + 向量表 bank 校验后更新 bootsetting 并复位。OTA 传输层通道无关:BLE e0005(逐帧锁步)/ UART 二进制模式(CLI `ota`;V1.00.24 起**流式模式**:BEGIN 16B flags、8KB 窗口流水线、扇区边界 ack、go-back-N,旧固件自动回退锁步)/ 旧帧协议通道,详见 ble_protocol.md §6.6/§6.7。
- 连接后设备主动发起 MTU=247 交换;失败退化为默认 20B/包,协议按字节流重组,两种情形都正确。 - 连接后设备主动发起 MTU=247 交换;失败退化为默认 20B/包,协议按字节流重组,两种情形都正确。
- 信息项 id:0x01 固件版本 u32 / 0x02 芯片温度 i16(0.1°C,ADC CH7) / 0x03 风扇转速 u16(0xFFFF=无硬件,`app_fan_get_rpm()` 弱符号,产品板重写即可) / 0x04 电压 u16(mV,ADC CH6) / 0x05 运行时长 u32(s) / 0x06 剩余堆 u32(B) / 0x07 当前运行 bank u8(1=APP1,2=APP2,OTA 选包依据)。 - 信息项 id:0x01 固件版本 u32 / 0x02 芯片温度 i16(0.1°C,ADC CH7) / 0x03 风扇转速 u16(0xFFFF=无硬件,`app_fan_get_rpm()` 弱符号,产品板重写即可) / 0x04 电压 u16(mV,ADC CH6) / 0x05 运行时长 u32(s) / 0x06 剩余堆 u32(B) / 0x07 当前运行 bank u8(1=APP1,2=APP2,OTA 选包依据)。
- 调试手段:CLI 命令 `blelog on` 后,串口打印每个收/发协议帧 hex dump、连接/断开、CCCD 订阅事件(走 printf,不经 BLE 通道,避免自激)。 - 调试手段:CLI 命令 `blelog on` 后,串口打印每个收/发协议帧 hex dump、连接/断开、CCCD 订阅事件(走 printf,不经 BLE 通道,避免自激)。
@ -148,17 +148,20 @@ mcm-ddc-ble/
## PC 工具(tools/) ## PC 工具(tools/)
- `merge_image.py` — 纯 Python 无三方依赖;合并 boot+bootsetting(缺省记录,脚本生成)+APP 为整片 hex/bin,同时产出 `_ota.bin`/`_ota.json`(bank1 载荷);`--ota-app2` 追加 bank2 载荷 `_ota_app2.bin/.json`;`--app2 <bin> --with-appdata` 生成双 bank 整包(make_dual_package.bat 使用);自动从 `app_version.h` 取 `APP_FW_VERSION_NUM` 写入清单(含 `target_bank` 字段) **自包含生产包(§49)**:tools/ 内嵌 Python 运行时(`python-embed/`,bleak+pyserial 预装)+ NSpyocd 烧录器副本 + DFP pack,`make_release.bat` 一键打 zip 到 `out/mothercup_tools_<时间戳>.zip`——解压到任何 Windows 电脑即可用,无需安装 Python/任何环境。三件套不入库(gitignore):`python-embed/`、`NSpyocd/`、`N32WB03x_DFP.1.4.0.pack`;**bat 文件必须保持 CRLF 行尾**(LF 会导致 cmd 解析错乱)。Python 解析优先级:`python-embed` → `.venv-ble`(开发机自动建)→ 系统 Python。
- `make_package.bat` / `flash_package.bat` — 一键出包 / NSpyocd 整片烧录(脚本会自动找 Python312 全路径,本机 `python` 可能是商店占位 stub)
- `merge_image.py` — 纯 Python 无三方依赖;合并 boot+bootsetting(缺省记录,脚本生成)+APP 为整片 hex/bin,同时产出 `_ota.bin`/`_ota.json`(bank1 载荷);`--ota-app2` 追加 bank2 载荷 `_ota_app2.bin/.json`;`--app2 <bin> --with-appdata` 生成双 bank 整包(make_dual_package.bat 使用);自动从 `app_version.h` 取 `APP_FW_VERSION_NUM` 写入清单(含 `target_bank` 字段);**出包硬卡:镜像初始 SP 必须在 (0x20004000, 0x2000C000](SRAM 悬崖,§47)**
- `make_package.bat` / `flash_package.bat` — 一键出包 / NSpyocd 整片烧录(flash_package.py 流式转发输出并把厂商横幅替换为 "CAIIC NSLINK UMP")
- `make_dual_package.bat` — 双 APP 整包(Boot+bootsetting 双 bank+APP_DATA+APP1+APP2,APP2 升一版链接 0x01024000),用于 appsw 切换测试 - `make_dual_package.bat` — 双 APP 整包(Boot+bootsetting 双 bank+APP_DATA+APP1+APP2,APP2 升一版链接 0x01024000),用于 appsw 切换测试
- `ble_cli_test.py` + `ble_cli.bat` — PC 端 bleak 蓝牙 CLI 测试客户端(帧协议模式 + `-rw` 读写特征模式,交互模式 `-i`,venv 在 `tools/.venv-ble`,首次运行 bat 自动创建) - `ble_cli_test.py` + `ble_cli.bat` — PC 端 bleak 蓝牙 CLI 测试客户端(帧协议模式 + `-rw` 读写特征模式,交互模式 `-i`,venv 在 `tools/.venv-ble`,不入库;首次运行 bat 按 `tools/requirements-ble.txt`(bleak+pyserial)自动创建)
- `ble_ota_update.py` + `ble_ota.bat` — BLE OTA 升级器(解析单文件升级包 `mothercup_ble_ota.bin`、按 CUR_BANK 选对侧 bank 载荷、经 OTA 特征 `...e0005` 逐帧锁步、复位检测+重连校验;`--uart COMx` 走串口二进制模式:握手标记进入/ABORT 退出/丢帧锁步重传再同步)。**BLE 与 UART 双通道均已实测通过(§42/§44)** - `ble_ota_update.py` + `ble_ota.bat` — BLE OTA 升级器(解析单文件升级包 `mothercup_ble_ota.bin`、按 CUR_BANK 选对侧 bank 载荷、经 OTA 特征 `...e0005` 逐帧锁步、复位检测+重连校验;`--uart COMx` 走串口二进制模式(460800 8N1,V1.00.26 起):握手标记进入/ABORT 退出;V1.00.24 起默认流式(8KB 窗口+扇区 ack+go-back-N),`--lockstep` 或旧固件自动回退锁步)。**BLE 与 UART 双通道均已实测通过(§42/§44),流式见 §47/§48**
- `ble_temp_watch.py` — 温度监测小工具 - `ble_temp_watch.py` — 温度监测小工具
## 实时架构约定(固件) ## 实时架构约定(固件)
- 使用 FreeRTOS V9.0.0 原生 API,**不使用 cmsis_os 封装**。 - 使用 FreeRTOS V9.0.0 原生 API,**不使用 cmsis_os 封装**。
- 任务:BLE 调度任务(栈 512 字,优先级 2,循环 `rwip_schedule(); vTaskDelay(1ms);`——**ke_msg/ke_timer 只允许在该任务上下文运行**);LED 任务(LED1 闪烁半周期读 APP_DATA 参数 `led1_blink_ms`);CliTask(优先级 3);CLI 输出经环形缓冲由 BLE 任务上下文发 notify(`ble_up.c`)。`configTOTAL_HEAP_SIZE = 20KB`(heap_4)。 - **IWDG 看门狗(V1.00.27 起)**:4s 超时,idle hook 仅在 3s 内有任务心跳时喂狗(BLE/LED/CLI 任务循环打点 `app_wdt_heartbeat()`);SWD halt 时冻结(DBG_IWDG_STOP);banner 打印复位原因;`hang` 命令可验证点火。新增任务请在循环里打心跳。
- 任务:BLE 调度任务(栈 512 字,优先级 2,循环 `rwip_schedule(); vTaskDelay(1ms);`——**ke_msg/ke_timer 只允许在该任务上下文运行**);LED 任务(LED1 闪烁半周期读 APP_DATA 参数 `led1_blink_ms`);CliTask(优先级 3);CLI 输出经环形缓冲由 BLE 任务上下文发 notify(`ble_up.c`)。`configTOTAL_HEAP_SIZE = 18KB`(heap_4;SRAM 悬崖约束见 Flash/RAM 布局铁律)。
- SysTick 由 FreeRTOS port 接管:`main` 中不要手动 `SysTick_Config`,`n32wb03x_it.c` 中不要重复定义 SVC/PendSV/SysTick_Handler。 - SysTick 由 FreeRTOS port 接管:`main` 中不要手动 `SysTick_Config`,`n32wb03x_it.c` 中不要重复定义 SVC/PendSV/SysTick_Handler。
- USART1_IRQn 优先级为 3(最低),因 ISR 内调用 FreeRTOS FromISR API,必须 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(=3)。BLE_SW/FIFO IRQ 优先级 0,其 ISR 内禁止调用 FromISR API。 - USART1_IRQn 优先级为 3(最低),因 ISR 内调用 FreeRTOS FromISR API,必须 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(=3)。BLE_SW/FIFO IRQ 优先级 0,其 ISR 内禁止调用 FromISR API。
- 串口输出并发:多任务/CLI 输出分别持有 `bsp_usart_tx_lock()` 互斥锁;调度器启动前为空操作。不要在中断或多个任务里直接 printf。 - 串口输出并发:多任务/CLI 输出分别持有 `bsp_usart_tx_lock()` 互斥锁;调度器启动前为空操作。不要在中断或多个任务里直接 printf。

View File

@ -149,7 +149,7 @@ OTA 会话(§6 的 BEGIN/DATA/END/ABORT → OTA_RSP)跑在 0xCA 帧上,帧
| 通道 | 下行(主机→设备) | 上行(设备→主机) | | 通道 | 下行(主机→设备) | 上行(设备→主机) |
|---|---|---| |---|---|---|
| **BLE OTA 特征** `...e0005` | 每次"写带响应"携带一帧(≤ att_mtu−3) | 写完**读同一特征**取回 OTA_RSP 帧;读应答可能先于设备处理完成返回空,**读需轮询至非空**(擦扇区时数十 ms) | | **BLE OTA 特征** `...e0005` | 每次"写带响应"携带一帧(≤ att_mtu−3) | 写完**读同一特征**取回 OTA_RSP 帧;读应答可能先于设备处理完成返回空,**读需轮询至非空**(擦扇区时数十 ms) |
| **UART 串口**(115200 8N1) | CLI 先发文本命令 `ota`,**等到设备回标记行 `[ota] binary mode ON` 再发帧**(握手,防帧被当文本命令吃掉),随后原始帧字节流 | OTA_RSP 帧直接从 TX 发出;**OTA_ABORT 立即退出**回 CLI,10s 无帧兜底退出 | | **UART 串口**(460800 8N1,V1.00.26 起;之前为 115200) | CLI 先发文本命令 `ota`,**等到设备回标记行 `[ota] binary mode ON` 再发帧**(握手,防帧被当文本命令吃掉),随后原始帧字节流 | OTA_RSP 帧直接从 TX 发出;**OTA_ABORT 立即退出**回 CLI,10s 无帧兜底退出 |
UART 模式切换与容错(V1.00.21 起): UART 模式切换与容错(V1.00.21 起):
@ -181,6 +181,42 @@ PC 参考实现:`tools\ble_ota_update.py`(`ble_ota.bat`;BLE 默认,
收到 END 应答;收不到应视为链路异常,但仍可按"等待重启后重连复核 CUR_BANK 收到 END 应答;收不到应视为链路异常,但仍可按"等待重启后重连复核 CUR_BANK
与版本"兜底(PC 工具两种处理都保留)。 与版本"兜底(PC 工具两种处理都保留)。
## 6.7 UART 流式传输模式(V1.00.24 起)
动机:逐帧锁步在 115200 下实测只有 ~1.5 kB/s(每帧一个 RTT + 主机调度
抖动)。流式模式让 DATA 帧在窗口内连续下发:**115200 下实测 9.5 kB/s
(55.8KB 约 5.9s,0 重传);460800(V1.00.26 起默认)下实测 23 kB/s
(约 2.6s)**,只受波特率与扇区擦除时间限制。
**前提(固件侧)**:UART RX 改为 **DMA 环形缓冲**(2KB,循环模式)——
DMA 在关中断的扇区擦除窗口内继续接收字节,这是流式不丢包的关键;
CLI 文本输入路径不变(同一 `bsp_usart_read_byte()` 接口,行编辑/历史/
Tab 行为完全一致),BLE 下行注入 CLI 走独立的注入队列。
协商:OTA_BEGIN payload 扩展为 **16B** `{total_size, image_crc32,
version, flags u32}`,`flags` bit0 = STREAM。仅 UART 通道生效(BLE 通道
应答靠读轮询,仍逐帧锁步)。兼容性:
- 12B 旧格式 BEGIN = 锁步模式(旧主机不受影响);
- 旧固件(< V1.00.24)收到 16B BEGIN 回 `bad_frame`(status=1),主机据此
自动回退锁步(PC 工具已实现;`--lockstep` 可强制)。
流式规则:
- DATA 帧格式不变(payload ≤480B,参考实现用 240B/帧),主机**连续发,
不等逐帧 ack**;在途未确认字节数 ≤ **8KB(窗口)**。
- 设备 ack 策略:DATA 仅在**跨越 4KB 扇区边界**(该扇区的惰性擦除此时
已完成)或到达最后一字节时回 `OTA_RSP(ok, offset)`。
- 丢帧/坏帧恢复(go-back-N):重组器静默丢弃坏帧后,下一帧 offset 必然
不匹配,设备**立即**回一帧 `BAD_STATE + 期望 offset`(节流:重新对齐
前只回一次);主机回退到该 offset 续传,无需重开会话。
- ack 整体丢失:主机 **3s 无 ack 进展** → 回退到最后确认的 offset 重发。
- END / ABORT 保持每帧必答(锁步语义不变);10s 空闲兜底退出二进制模式、
END 应答送达后再复位等既有机制(V1.00.20/21)不受影响。
主机参考实现:`tools\ble_ota_update.py` 的 `ota_session_uart_stream()`
(`--uart` 默认流式,`--lockstep` 回旧模式)。
## 6.5 维护命令:bootsetting 与 APP_DATA 参数区(V1.00.09 起,结构化访问;V1.00.10 起参数命令为 appget/appset;V1.00.11 起新增 reset/factory/uartrst/uartinfo) ## 6.5 维护命令:bootsetting 与 APP_DATA 参数区(V1.00.09 起,结构化访问;V1.00.10 起参数命令为 appget/appset;V1.00.11 起新增 reset/factory/uartrst/uartinfo)
bootsetting 与 APP_DATA 保留区(0x01006000/8KB)的读写以 CLI 命令形式提供, bootsetting 与 APP_DATA 保留区(0x01006000/8KB)的读写以 CLI 命令形式提供,
@ -196,8 +232,9 @@ bootsetting 与 APP_DATA 保留区(0x01006000/8KB)的读写以 CLI 命令形
| `appsw [1\|2]` | 切换运行 bank 并复位(V1.00.15 起):无参切对侧 bank;切前校验 bootsetting 记录、目标 bank 记录一致性,并复算目标镜像 CRC32 与记录比对,不匹配拒绝(坏 bank 需先跑 OTA 写入有效镜像) | | `appsw [1\|2]` | 切换运行 bank 并复位(V1.00.15 起):无参切对侧 bank;切前校验 bootsetting 记录、目标 bank 记录一致性,并复算目标镜像 CRC32 与记录比对,不匹配拒绝(坏 bank 需先跑 OTA 写入有效镜像) |
| `reset` | 复位单板:应答后延时 200ms 执行 `NVIC_SystemReset()`,Boot 按 active bank 跳转 | | `reset` | 复位单板:应答后延时 200ms 执行 `NVIC_SystemReset()`,Boot 按 active bank 跳转 |
| `factory` | 恢复出厂设置:APP_DATA 参数区恢复缺省值并落盘后复位(**不动 bootsetting**) | | `factory` | 恢复出厂设置:APP_DATA 参数区恢复缺省值并落盘后复位(**不动 bootsetting**) |
| `uartrst` | 复位串口:USART1 按 115200 8N1 重新初始化并清空 RX 队列 | | `uartrst` | 复位串口:USART1 按 460800 8N1 重新初始化并清空 RX 队列 |
| `uartinfo` | 查询串口参数:引脚(PB6/PB7 AF4)、波特率/数据位/校验/停止位、TX DMA/RX 中断形态 | | `uartinfo` | 查询串口参数:引脚(PB6/PB7 AF4)、波特率/数据位/校验/停止位、TX DMA/RX 中断形态 |
| `hang` | 挂死 CLI 任务(IWDG 看门狗点火测试,V1.00.27 起;设备 ~4s 后复位) |
参数记录(APP_DATA 区头,52B):magic `0xCA12DA7A` + layout_ver + 参数字段 + 参数记录(APP_DATA 区头,52B):magic `0xCA12DA7A` + layout_ver + 参数字段 +
reserved[8] + CRC32;记录无效时固件以缺省值运行,`appset` 时落盘。 reserved[8] + CRC32;记录无效时固件以缺省值运行,`appset` 时落盘。

View File

@ -1171,3 +1171,117 @@ APP2 链接的镜像发向 APP1 区。设备端 END 校验(向量表 Reset PC
重传/再同步提示前补换行不与进度条混行;END 总结新增 重传/再同步提示前补换行不与进度条混行;END 总结新增
`N resent, N resynced` 统计(链路质量一目了然:BLE 实测 0 次, `N resent, N resynced` 统计(链路质量一目了然:BLE 实测 0 次,
接触不良的串口线 4~6 次)。BEGIN/END 阶段切换增加明示打印。 接触不良的串口线 4~6 次)。BEGIN/END 阶段切换增加明示打印。
## 47. UART OTA 流式传输层 + RX 改 DMA 环形缓冲(2026-09-05,V1.00.24)
- **动机**:UART OTA 逐帧锁步实测仅 ~1.5 kB/s(55KB 要 39s),瓶颈是每帧
一个 RTT(设备处理 + 主机调度抖动),不是波特率。
- **UART RX 改 DMA 环形缓冲**(bsp_usart.c):DMA_CH2 循环模式 + 2KB ring,
USART1_RX remap;RXDNE 逐字节中断关闭,改开 IDLEF 中断只做信号量唤醒
(`bsp_usart_read_byte()` 接口不变,20ms 切片等信号量兜底连续流无 IDLE
的情形;BLE 下行注入 CLI 改走独立的 64B 注入队列,优先于 ring 消费)。
**关键收益:DMA 在关中断的扇区擦除窗口(数十 ms)内继续收字节**
(2KB ≈ 173ms@115200),这是流式不丢包的前提——中断驱动 RX 在擦除期间
必丢字节,正是当年只能锁步的根因。CLI 文本模式行为不变。
- **流式传输层(ble_protocol.md §6.7)**:OTA_BEGIN 扩展 16B(+flags u32,
bit0=STREAM,仅 UART 通道生效);DATA 不再逐帧 ack,只在跨越 4KB 扇区
边界(擦除已完成)或最后一字节时回 ack;主机 8KB 窗口流水线发送;
丢帧靠既有的 BAD_STATE+期望 offset 立即回执回退续传(go-back-N,设备侧
节流为对齐前只报一次);3s 无 ack 进展主机自动回退到最后的 ack 重发;
END/ABORT 语义不变。12B BEGIN=锁步,新旧固件/主机任意组合兼容(旧固件
对 16B BEGIN 回 bad_frame,PC 工具自动回退;`--lockstep` 强制旧模式)。
- PC 工具 `ble_ota_update.py` 新增 `ota_session_uart_stream()`;
BLE 路径与 App(e0005)路径完全不受影响。
- 预期速率:115200 下 ~10 kB/s(55KB 约 6s,原 39s)。
### §47 补记:V1.00.24 首版烧录即锁死——SRAM 顶部悬崖(同日下午修复)
- **现象**:首版 V1.00.24 烧录后板子无 banner 无广播。SWD 连上看:CPU 处于
lockup,LR 落在 HardFault_Handler 内(fault 中再 fault)。
- **定位**:用 NSpyocd commander 逐地址探测发现 **SRAM 从 0x2000C000 起读即
TransferFault**(复位后立刻探也一样),最后一个可访问字是 0x2000BFFC——
该 256KB 硅片 APP 可用 SRAM 实为 **32KB(0x20004000~0x2000BFFF)**,
顶部 16KB 块并不可用(IRAM 配置里 0xC000=48KB 的上限是虚的)。V1.00.23
的栈顶是 0x2000B9D8 恰好在线内;本次新增的 2KB DMA ring 把 ZI 顶高,
栈顶漂到 0x2000C1E8 越线 → 上电第一批压栈即炸。**教训:任何 ZI/RW
膨胀都必须复查 map 的 `__initial_sp`**。
- **修复**:FreeRTOS heap 20KB→18KB(实测余量 12.6KB,够用),栈顶回到
0x2000B9E8;`merge_image.py` 增加硬卡:bin 向量表首字(初始 SP)不在
(0x20004000, 0x2000C000] 内直接出包失败,距悬崖 <1KB 告警。
- **验证**:重烧后 SWD 观测 reset→go→延时,CPU 持续 Running、PC 在 APP1
正常代码区推进,HardFault 断点不再命中。
- AGENTS.md "RAM 铁律"已改写为双侧悬崖(低 16KB ROM 占用 + 顶部
0x2000C000 悬崖)。
### §47 再补记:流式首测无 ack 的两个叠加 bug(当日下午二修,已实测通过)
- **现象**:V1.00.25 首版流式 OTA 主机侧 0 ack,反复 stall 回退。
- **定位过程**(SWD 在线取证,值得记一笔):commander 读 `s_offset`/
`s_erased`/`s_prog` 发现设备把 4800B 突发全部正确收写(环形缓冲内容
与主机发送字节流逐字节一致,DMA RX 无丢字节);OTA 状态机正常、erase
正常、TX DMA 通道空闲未挂死——唯一没发生的就是 ack 本身。
- **根因 1(V1.00.24 版)**:扇区 ack 写成"`s_offset` 恰好是 4096 倍数才
ack",240B/帧时偏移序列 4080→4320 永远踩不中边界。
- **根因 2(V1.00.25 首版)**:改成跨界判断时用了 `dlen`——但 `dlen`
在上面的 4B 对齐暂存逻辑里已被消耗成 0~3 的尾巴,`(s_offset-0)` 与
`s_offset` 永远同扇区,ack 永不触发。修复:用帧原始长度 `chunk` 做
跨界判断(`app_ota.c ota_on_data`)。
- **实测**:双向各跑一次,均 5.9s 完成(**9.5 kB/s,0 rewinds**,锁步时
39s/1.4kB/s),复位复核 PASS(APP1→APP2→APP1,版本 V1.00.25)。
- 附:flash_package 改 python 包装(tools/flash_package.py)流式转发
NSpyocd 输出并替换横幅为 "CAIIC NSLINK UMP";块状读取修进度条 \r
不刷新问题;模式替换需在拼接缓冲上做(跨块边界会劈开模式串)。
## 48. UART 波特率提升 115200→460800(2026-09-05,V1.00.26)
- 固件 `BSP_USART_BAUDRATE` 460800(64MHz/460800 分频误差 +0.03%);
`uartrst`/`uartinfo` 走宏自动跟随,帮助文本同步。
- **环形缓冲 2KB→3KB**:460800 下 2KB 只能覆盖 44ms,小于擦扇区关中断
窗口(~45ms);3KB ≈ 69ms。注意 SRAM 悬崖(§47):栈顶上移 1KB 至
0x2000BDE8,merge_image.py 硬卡给出 <1KB 告警(属预期内)。
- PC 工具默认波特率同步(ble_ota_update.py UartTransport、uart_cap.py)。
- 实测:流式 OTA 55832B **2.6s(23 kB/s)**,途中丢 1 帧由 go-back-N
自动回退续传( rewind 45360→38160),复位复核 PASS;CLI 文本模式
在 460800 下正常(devinfo/ota 握手/复位后复核全走文本 CLI)。
- 对比:115200 锁步 39s → 115200 流式 5.9s → 460800 流式 2.6s。
- 注意:串口终端/工具必须同步改 460800,否则 CLI 全是乱码。
## 49. tools 自包含生产包(2026-09-05,无固件改动)
- 目标:tools/ 打包即可发布到任何 Windows 产线电脑,无需安装任何环境。
- **内嵌 Python 运行时**:`tools/python-embed/`(python-3.12.10-embed-amd64,
华为云镜像下载;python312._pth 启用 Lib/site-packages + import site,
bleak 3.0.2/pyserial 3.5 用 pip --target 预装,29MB)。bat 优先级:
python-embed → .venv-ble(开发机自动建)→ 系统 Python。
- **烧录器收编**:NSpyocd.exe + DFP pack 复制进 tools/(flash_package.py
优先用 tools 内副本,找不到才回退仓库 nations-tec 路径)。
- **一键打包**:`make_release.bat`(PowerShell Compress-Archive,排除
.venv-ble/__pycache__,zip 暂存 %TEMP% 再移入 out/——直接写 out/ 会
自读自写报错)。
- **坑**:bat 必须 CRLF 行尾——LF-only 时 cmd 对括号块/%~dp0 的解析会
错乱(standalone 复测时 venv 分支被误触发且 PYREAL 为空),本次全部
转 CRLF。zip 用 Windows 资源管理器/Expand-Archive 解压(Info-ZIP
unzip 对 Compress-Archive 的反斜杠分隔符不兼容)。
- 验证:zip 解到仓库外独立目录,ble_ota.bat --uart COM4 全流程 PASS
(2.2s,27 kB/s,0 重传);flash_package.bat 用包内 NSpyocd 烧录 PASS。
## 50. 独立看门狗 IWDG(2026-09-05,V1.00.27)
- 起因:此前 CLI 卡死事故中 `reset` 命令无从执行(任务已死),系统无自救
手段。固件原本没有看门狗。
- 复位路径复查:当前固件 `reset`/`factory`/`appsw`/OTA 延迟复位均实测正常
(NVIC_SystemReset 有效);用户报告的"复位没生效"发生于 CLI 卡死状态,
命令根本无法到达执行路径——这正是看门狗的场景。
- 设计(main.c / app_wdt.h,驱动 n32wb03x_iwdg.c 已加入全部 4 个 target):
- IWDG:LSI 32kHz /128 → 250Hz,reload 1000 = **4s 超时**;
- **喂养策略**:FreeRTOS idle hook 只在"3s 内有任务心跳"时喂狗;BLE 调度/
LED/CLI 三个任务循环里打点 `app_wdt_heartbeat()`。任何任务死锁/死循环
(CLI 独占 CPU 饿死 idle,或全体阻塞 idle 空转无心跳)都会在 ≤4s 复位;
- `DBG_ConfigPeriph(DBG_IWDG_STOP, ENABLE)`:SWD halt 时冻结 IWDG,
调试会话不会被看门狗打断;
- 启动 banner 打印复位原因(RCC IWDGRSTF)并清标志。
- 新增调试命令 `hang`(CLI 任务忙等挂起,验证看门狗点火用)。
- 实测:正常喂狗系统稳定(12s+ 无重启、CLI 正常);`hang` 后 4.4s 复位,
新 banner 打印 "Last reset: IWDG watchdog!"。
- 附带修正:`uartinfo` 输出里 RX 描述更新为 DMA ring(此前还是 RXDNE 队列
的旧文案)。

View File

@ -38,7 +38,7 @@ V1.0 / 2026-09-05
- **RAM 铁律**:低 16KB(0x20000000~0x20003FFF)被芯片 ROM/BLE 子系统占用, - **RAM 铁律**:低 16KB(0x20000000~0x20003FFF)被芯片 ROM/BLE 子系统占用,
任何工程 IRAM 执行区必须从 **0x20004000** 起(否则 BLE init 必 HardFault) 任何工程 IRAM 执行区必须从 **0x20004000** 起(否则 BLE init 必 HardFault)
- **SWD 引脚 SWCLK=PA4 / SWDIO=PA5,应用程序绝对禁止占用** - **SWD 引脚 SWCLK=PA4 / SWDIO=PA5,应用程序绝对禁止占用**
- 板载 LED:LED1=PB0、LED2=PA6;USART1:TX=PB6 / RX=PB7(AF4,115200 8N1) - 板载 LED:LED1=PB0、LED2=PA6;USART1:TX=PB6 / RX=PB7(AF4,460800 8N1,V1.00.26 起)
- 新增 GPIO 前核对开发日志 §4 引脚分配表 - 新增 GPIO 前核对开发日志 §4 引脚分配表
- **不要直接写寄存器**;勘误修复必须先在 SDK 源码交叉验证地址用途 - **不要直接写寄存器**;勘误修复必须先在 SDK 源码交叉验证地址用途
(曾按勘误表写 AFEC 寄存器导致 BLE 射频挂死,开发日志 §28) (曾按勘误表写 AFEC 寄存器导致 BLE 射频挂死,开发日志 §28)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -532,6 +532,11 @@
<FileType>1</FileType> <FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath> <FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath>
</File> </File>
<File>
<FileName>n32wb03x_iwdg.c</FileName>
<FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_iwdg.c</FilePath>
</File>
</Files> </Files>
</Group> </Group>
<Group> <Group>
@ -1688,6 +1693,11 @@
<FileType>1</FileType> <FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath> <FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath>
</File> </File>
<File>
<FileName>n32wb03x_iwdg.c</FileName>
<FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_iwdg.c</FilePath>
</File>
</Files> </Files>
</Group> </Group>
<Group> <Group>
@ -2844,6 +2854,11 @@
<FileType>1</FileType> <FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath> <FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath>
</File> </File>
<File>
<FileName>n32wb03x_iwdg.c</FileName>
<FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_iwdg.c</FilePath>
</File>
</Files> </Files>
</Group> </Group>
<Group> <Group>
@ -3709,6 +3724,11 @@
<FileType>1</FileType> <FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath> <FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_qflash.c</FilePath>
</File> </File>
<File>
<FileName>n32wb03x_iwdg.c</FileName>
<FileType>1</FileType>
<FilePath>..\..\nations-tec\N32WB03x_SDK_V2.0.0\firmware\n32wb03x_std_periph_driver\src\n32wb03x_iwdg.c</FilePath>
</File>
</Files> </Files>
</Group> </Group>
<Group> <Group>

View File

@ -27,13 +27,18 @@
#define configUSE_PREEMPTION 1 #define configUSE_PREEMPTION 1
#define configUSE_IDLE_HOOK 0 #define configUSE_IDLE_HOOK 1
#define configUSE_TICK_HOOK 0 #define configUSE_TICK_HOOK 0
#define configCPU_CLOCK_HZ ( SystemCoreClock ) #define configCPU_CLOCK_HZ ( SystemCoreClock )
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 ) #define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
#define configMAX_PRIORITIES ( 7 ) #define configMAX_PRIORITIES ( 7 )
#define configMINIMAL_STACK_SIZE ( ( uint16_t ) 128 ) #define configMINIMAL_STACK_SIZE ( ( uint16_t ) 128 )
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 20 * 1024 ) ) /* Usable app SRAM on this silicon is 32KB (0x20004000..0x2000BFFF; accesses
* above fault - the top 16KB block is not available to the APP, see dev log
* §47). The stack top (__initial_sp) must stay below 0x2000C000, so
* RW+ZI+STACK must fit 32KB; heap sized accordingly (12.6KB free was
* observed at V1.00.23 with a 20KB heap, so 2KB less is safe). */
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 18 * 1024 ) )
#define configMAX_TASK_NAME_LEN ( 16 ) #define configMAX_TASK_NAME_LEN ( 16 )
#define configUSE_TRACE_FACILITY 1 #define configUSE_TRACE_FACILITY 1
#define configUSE_STATS_FORMATTING_FUNCTIONS 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1

View File

@ -9,12 +9,12 @@
#define __APP_VERSION_H__ #define __APP_VERSION_H__
#ifndef APP_FW_VERSION #ifndef APP_FW_VERSION
#define APP_FW_VERSION "V1.00.23" #define APP_FW_VERSION "V1.00.27"
#endif #endif
/* Numeric form used by the BLE info query / OTA records: 0x00MMmmpp */ /* Numeric form used by the BLE info query / OTA records: 0x00MMmmpp */
#ifndef APP_FW_VERSION_NUM #ifndef APP_FW_VERSION_NUM
#define APP_FW_VERSION_NUM 0x00010017u #define APP_FW_VERSION_NUM 0x0001001Bu
#endif #endif
#endif /* __APP_VERSION_H__ */ #endif /* __APP_VERSION_H__ */

32
mcm-ddc-ble/inc/app_wdt.h Normal file
View File

@ -0,0 +1,32 @@
/**
* @file app_wdt.h
* @brief Independent watchdog (IWDG, LSI 32kHz): 4s timeout, fed from the
* FreeRTOS idle hook only while at least one application task has
* heartbeated within the last 3s. Frozen while the core is halted
* (DBG_IWDG_STOP) so SWD debug sessions do not trigger resets.
*/
#ifndef __APP_WDT_H__
#define __APP_WDT_H__
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Report the reset cause (banner) and start the IWDG.
* Call once from main() after the USART banner.
*/
void app_wdt_init(void);
/**
* @brief Mark a task as alive. Called from the BLE schedule / LED / CLI
* task loops; the idle hook stops feeding the IWDG when no task
* heartbeated for APP_WDT_HB_TIMEOUT_MS.
*/
void app_wdt_heartbeat(void);
#ifdef __cplusplus
}
#endif
#endif /* __APP_WDT_H__ */

View File

@ -1,7 +1,7 @@
/** /**
* @file bsp_usart.h * @file bsp_usart.h
* @brief USART1 support: printf retarget (polling TX), DMA TX, RX interrupt queue. * @brief USART1 support: printf retarget (polling TX), DMA TX, RX DMA
* TX=PB6, RX=PB7, 115200 8N1. * circular ring + IDLE interrupt. TX=PB6, RX=PB7, 460800 8N1.
*/ */
#ifndef __BSP_USART_H__ #ifndef __BSP_USART_H__
#define __BSP_USART_H__ #define __BSP_USART_H__
@ -20,12 +20,17 @@ extern "C" {
#define BSP_USART_RX_PIN GPIO_PIN_7 #define BSP_USART_RX_PIN GPIO_PIN_7
#define BSP_USART_TX_GPIO_AF GPIO_AF4_USART1 #define BSP_USART_TX_GPIO_AF GPIO_AF4_USART1
#define BSP_USART_RX_GPIO_AF GPIO_AF4_USART1 #define BSP_USART_RX_GPIO_AF GPIO_AF4_USART1
#define BSP_USART_BAUDRATE 115200 #define BSP_USART_BAUDRATE 460800
/* USART1 TX DMA channel (remap required on this chip) */ /* USART1 TX DMA channel (remap required on this chip) */
#define BSP_USART_TX_DMA_CH DMA_CH1 #define BSP_USART_TX_DMA_CH DMA_CH1
#define BSP_USART_TX_DMA_TC DMA_FLAG_TC1 #define BSP_USART_TX_DMA_TC DMA_FLAG_TC1
#define BSP_USART_TX_DMA_REMAP DMA_REMAP_USART1_TX #define BSP_USART_TX_DMA_REMAP DMA_REMAP_USART1_TX
/* USART1 RX DMA channel: circular mode into a 2KB ring, drained by
* bsp_usart_read_byte(). DMA keeps sampling while interrupts are off
* (flash erase windows during UART OTA streaming). */
#define BSP_USART_RX_DMA_CH DMA_CH2
#define BSP_USART_RX_DMA_REMAP DMA_REMAP_USART1_RX
#define BSP_USART_DAT_ADDR (USART1_BASE + 0x04) #define BSP_USART_DAT_ADDR (USART1_BASE + 0x04)
/* Pass to bsp_usart_read_byte() to block without timeout */ /* Pass to bsp_usart_read_byte() to block without timeout */
@ -40,11 +45,13 @@ void bsp_usart_set_baud(uint32_t baud);
/* TX: send a buffer through DMA (polled completion) */ /* TX: send a buffer through DMA (polled completion) */
void bsp_usart_write_dma(const uint8_t* data, uint16_t len); void bsp_usart_write_dma(const uint8_t* data, uint16_t len);
/* RX: fetch one byte received by the RXDNE interrupt; returns 1 on success */ /* RX: fetch one received byte. Source order: the inject queue (BLE
* downlink feeding the CLI) first, then the DMA RX ring. Returns 1 on
* success, 0 on timeout. */
int bsp_usart_read_byte(uint8_t* ch, uint32_t timeout_ms); int bsp_usart_read_byte(uint8_t* ch, uint32_t timeout_ms);
/* RX: inject bytes from task context into the same RX queue (BLE downlink /* RX: inject bytes from task context (BLE downlink feeds the CLI through
* feeds the CLI through this). Bytes are dropped when the queue is full. */ * this). Bytes are dropped when the inject queue is full. */
void bsp_usart_rx_inject(const uint8_t* data, uint16_t len); void bsp_usart_rx_inject(const uint8_t* data, uint16_t len);
/* TX mutex so that DMA bursts and polling printf lines do not interleave. /* TX mutex so that DMA bursts and polling printf lines do not interleave.

View File

@ -13,6 +13,7 @@
#include "cli_core.h" #include "cli_core.h"
#include "bsp_usart.h" #include "bsp_usart.h"
#include "app_ota.h" #include "app_ota.h"
#include "app_wdt.h"
#include <string.h> #include <string.h>
@ -245,12 +246,14 @@ static void CliTask(void* argument)
for (;;) for (;;)
{ {
app_wdt_heartbeat();
/* OTA binary frame mode: raw bytes to the OTA channel. /* OTA binary frame mode: raw bytes to the OTA channel.
* otaIdleMs is (re)armed on every entry - a leftover count from a * otaIdleMs is (re)armed on every entry - a leftover count from a
* previous session must not trip an instant idle timeout. */ * previous session must not trip an instant idle timeout. */
otaIdleMs = 0; otaIdleMs = 0;
while (s_otaMode) while (s_otaMode)
{ {
app_wdt_heartbeat();
if (bsp_usart_read_byte(&ch, OTA_MODE_POLL_MS) != 0) if (bsp_usart_read_byte(&ch, OTA_MODE_POLL_MS) != 0)
{ {
if (app_ota_chan_rx_uart(ch) != 0) if (app_ota_chan_rx_uart(ch) != 0)

View File

@ -3,11 +3,11 @@
* @brief OTA receiver implementation (channel-agnostic: BLE OTA * @brief OTA receiver implementation (channel-agnostic: BLE OTA
* characteristic, UART binary mode, legacy notify frame path). * characteristic, UART binary mode, legacy notify frame path).
* *
* Flow: OTA_BEGIN (size/crc/version) -> OTA_DATA* (strictly sequential * Flow: OTA_BEGIN (size/crc/version[/flags]) -> OTA_DATA* (strictly sequential
* offsets, EVERY frame acked with the new offset - lockstep flow control) * offsets; lockstep acks per frame, or sector-boundary acks in UART stream
* -> OTA_END (flush the sub-word tail, verify whole-image CRC32 against * mode - ble_protocol.md §6.7) -> OTA_END (flush the sub-word tail, verify
* flash, verify the vector table targets the right bank, update * whole-image CRC32 against flash, verify the vector table targets the right
* bootsetting, reset). OTA_ABORT aborts the session. * bank, update bootsetting, reset). OTA_ABORT aborts the session.
* *
* Direct-write design (no 4KB staging buffer): * Direct-write design (no 4KB staging buffer):
* - Sectors of the target bank are erased lazily: the first received byte * - Sectors of the target bank are erased lazily: the first received byte
@ -89,6 +89,17 @@ static uint32_t ota_rd32(const uint8_t* p)
static app_ota_rsp_sink_fn s_rsp_sink; static app_ota_rsp_sink_fn s_rsp_sink;
static void ota_uart_sink(const uint8_t* frame, uint16_t len);
/* UART streaming mode (ble_protocol.md §6.7): OTA_BEGIN payload extended
* to 16B {size, crc32, version, flags}; flags bit0 = stream. In stream mode
* DATA frames are acked only at 4KB sector boundaries (the erase has
* completed by then) instead of per-frame lockstep; the host pipelines
* writes with an 8KB window and rewinds on BAD_STATE. BLE stays lockstep. */
#define OTA_BEGIN_FLAG_STREAM 0x01u
static uint8_t s_stream; /* current session is streaming */
static uint8_t s_bad_state_sent; /* throttle repeated BAD_STATE reports */
/** /**
* @brief CRC16-CCITT (poly 0x1021, init 0xFFFF) - same as the frame * @brief CRC16-CCITT (poly 0x1021, init 0xFFFF) - same as the frame
* protocol's proto_crc16 (app_ble_proto.c). * protocol's proto_crc16 (app_ble_proto.c).
@ -157,6 +168,8 @@ void app_ota_abort(void)
s_prog = 0; s_prog = 0;
s_erased = 0; s_erased = 0;
s_word_fill = 0; s_word_fill = 0;
s_stream = 0;
s_bad_state_sent = 0;
} }
/** /**
@ -195,14 +208,15 @@ static uint8_t ota_program(const uint8_t* data, uint32_t len)
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
* @brief OTA_BEGIN: {total_size u32, image_crc32 u32, version u32}. * @brief OTA_BEGIN: {total_size u32, image_crc32 u32, version u32}
* or the extended 16B form {..., flags u32} (ble_protocol.md §6.7).
*/ */
static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len) static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len)
{ {
uint32_t total, crc, version; uint32_t total, crc, version, flags;
uint32_t self, target; uint32_t self, target;
if (len != 12u) if (len != 12u && len != 16u)
{ {
ota_respond(seq, BLE_FRAME_OTA_BEGIN, BLE_OTA_ST_BAD_FRAME, 0); ota_respond(seq, BLE_FRAME_OTA_BEGIN, BLE_OTA_ST_BAD_FRAME, 0);
return; return;
@ -211,6 +225,7 @@ static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len)
total = ota_rd32(p); total = ota_rd32(p);
crc = ota_rd32(p + 4); crc = ota_rd32(p + 4);
version = ota_rd32(p + 8); version = ota_rd32(p + 8);
flags = (len == 16u) ? ota_rd32(p + 12) : 0;
/* One session at a time across all channels: a second BEGIN (e.g. BLE /* One session at a time across all channels: a second BEGIN (e.g. BLE
* while a UART session runs) is refused instead of corrupting state */ * while a UART session runs) is refused instead of corrupting state */
@ -257,6 +272,12 @@ static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len)
s_prog = 0; s_prog = 0;
s_erased = 0; s_erased = 0;
s_word_fill = 0; s_word_fill = 0;
/* Streaming only on the UART channel: its RSP goes straight out on TX
* and the DMA ring buffers inbound bytes during flash erases. The BLE
* channel is read-polled and stays per-frame lockstep. */
s_stream = ((flags & OTA_BEGIN_FLAG_STREAM) != 0u &&
s_rsp_sink == ota_uart_sink) ? 1u : 0u;
s_bad_state_sent = 0;
ota_respond(seq, BLE_FRAME_OTA_BEGIN, BLE_OTA_ST_OK, 0); ota_respond(seq, BLE_FRAME_OTA_BEGIN, BLE_OTA_ST_OK, 0);
} }
@ -264,11 +285,13 @@ static void ota_on_begin(uint8_t seq, const uint8_t* p, uint16_t len)
/** /**
* @brief OTA_DATA: {offset u32 + data}. Data must arrive strictly in order. * @brief OTA_DATA: {offset u32 + data}. Data must arrive strictly in order.
* Payload is programmed to flash immediately (sectors are erased * Payload is programmed to flash immediately (sectors are erased
* lazily); EVERY frame is acked with the new offset (lockstep flow * lazily). Ack policy depends on the session mode:
* control on the BLE/UART OTA channels; the erase time of a fresh * - lockstep (BLE and legacy hosts): EVERY frame is acked with the
* sector is covered by the ack round trip). * new offset, the ack round trip covers a fresh sector's erase;
* Errors are acked immediately and the state is * - streaming (UART, BEGIN flags bit0): acks only at 4KB sector
* kept so the peer can resend. * boundaries (erase completed) and at the final byte; the host
* pipelines frames inside an 8KB window and rewinds to the offset
* reported by an immediate BAD_STATE ack when a frame is lost.
*/ */
static void ota_on_data(uint8_t seq, const uint8_t* p, uint16_t len) static void ota_on_data(uint8_t seq, const uint8_t* p, uint16_t len)
{ {
@ -291,11 +314,21 @@ static void ota_on_data(uint8_t seq, const uint8_t* p, uint16_t len)
off = ota_rd32(p); off = ota_rd32(p);
data = p + 4; data = p + 4;
dlen = (uint32_t)len - 4u; dlen = (uint32_t)len - 4u;
const uint32_t chunk = dlen; /* dlen is consumed down to the sub-word
* tail by the staging logic below; the
* stream-ack boundary check needs the
* original chunk length */
if (off != s_offset) if (off != s_offset)
{ {
/* out-of-order: report the expected offset, keep the session */ /* out-of-order: report the expected offset, keep the session.
ota_respond(seq, BLE_FRAME_OTA_DATA, BLE_OTA_ST_BAD_STATE, s_offset); * In stream mode the frames after a lost one ALL mismatch until the
* host rewinds - report once, not per frame */
if (!s_stream || !s_bad_state_sent)
{
ota_respond(seq, BLE_FRAME_OTA_DATA, BLE_OTA_ST_BAD_STATE, s_offset);
s_bad_state_sent = 1;
}
return; return;
} }
if (s_offset + dlen > s_total_size) if (s_offset + dlen > s_total_size)
@ -342,6 +375,22 @@ static void ota_on_data(uint8_t seq, const uint8_t* p, uint16_t len)
app_ota_abort(); app_ota_abort();
return; return;
} }
s_bad_state_sent = 0; /* in-order again: re-arm the error report */
if (s_stream)
{
/* Streaming (UART): ack when a 4KB sector boundary was CROSSED by
* this chunk (its lazy erase has completed by then) or on the final
* byte. A chunk rarely lands exactly on a boundary (e.g. 240B
* frames step 4080->4320), so compare sector indices, not equality.
* NOTE: use chunk (the original length), not dlen which the staging
* logic has consumed to the sub-word tail. */
if (((s_offset - chunk) & ~0xFFFu) != (s_offset & ~0xFFFu) ||
s_offset == s_total_size)
{
ota_respond(seq, BLE_FRAME_OTA_DATA, BLE_OTA_ST_OK, s_offset);
}
return;
}
/* Lockstep ack: every DATA frame is answered with the new offset */ /* Lockstep ack: every DATA frame is answered with the new offset */
ota_respond(seq, BLE_FRAME_OTA_DATA, BLE_OTA_ST_OK, s_offset); ota_respond(seq, BLE_FRAME_OTA_DATA, BLE_OTA_ST_OK, s_offset);
} }
@ -493,8 +542,6 @@ void app_ota_reset_poll(void)
} }
} }
static void ota_uart_sink(const uint8_t* frame, uint16_t len);
static uint8_t s_uart_exit_req; /* OTA_ABORT on UART: drop back to CLI now */ static uint8_t s_uart_exit_req; /* OTA_ABORT on UART: drop back to CLI now */
void app_ota_handle_frame(uint8_t type, uint8_t seq, const uint8_t* payload, uint16_t len) void app_ota_handle_frame(uint8_t type, uint8_t seq, const uint8_t* payload, uint16_t len)

View File

@ -1,7 +1,7 @@
/** /**
* @file bsp_usart.c * @file bsp_usart.c
* @brief USART1 init, printf retarget (fputc, polling TX), DMA TX and * @brief USART1 init, printf retarget (fputc, polling TX), DMA TX and
* RX interrupt byte queue. * RX DMA circular ring (IDLE interrupt wakeup + polled drain).
*/ */
#include "bsp_usart.h" #include "bsp_usart.h"
#include <stdio.h> #include <stdio.h>
@ -11,14 +11,65 @@
#include "queue.h" #include "queue.h"
#include "semphr.h" #include "semphr.h"
#define RX_QUEUE_LENGTH 64 #define RX_INJECT_QUEUE_LEN 64 /* BLE downlink -> CLI injection */
#define RX_RING_SIZE 3072u /* DMA circular ring: ~69ms @460800,
* covers flash-erase interrupt-off
* windows (~45ms) during OTA streaming.
* NOTE: keep the stack top below the
* 0x2000C000 SRAM cliff (dev log §47) -
* merge_image.py hard-fails otherwise */
#define RX_WAIT_SLICE_MS 20u /* semaphore wait granularity (also the
* poll period when the IDLE interrupt
* does not fire during a back-to-back
* byte stream) */
static QueueHandle_t s_rxQueue = NULL; static uint8_t s_rxRing[RX_RING_SIZE];
static SemaphoreHandle_t s_txMutex = NULL; static volatile uint16_t s_rxTail; /* software drain position in the ring */
static QueueHandle_t s_rxInjectQueue = NULL;
static SemaphoreHandle_t s_rxSem = NULL;
static SemaphoreHandle_t s_txMutex = NULL;
/** /**
* @brief Initialize USART1 (115200 8N1) on PB6(TX)/PB7(RX), * @brief Start the USART1 RX DMA circular channel (shared by init and
* DMA TX channel and the RX interrupt queue. * baud re-apply).
*/
static void bsp_usart_rx_dma_start(void)
{
DMA_InitType DMA_InitStructure;
DMA_EnableChannel(BSP_USART_RX_DMA_CH, DISABLE);
DMA_DeInit(BSP_USART_RX_DMA_CH);
DMA_RequestRemap(BSP_USART_RX_DMA_REMAP, DMA, BSP_USART_RX_DMA_CH, ENABLE);
DMA_InitStructure.PeriphAddr = BSP_USART_DAT_ADDR;
DMA_InitStructure.MemAddr = (uint32_t)s_rxRing;
DMA_InitStructure.Direction = DMA_DIR_PERIPH_SRC;
DMA_InitStructure.BufSize = RX_RING_SIZE;
DMA_InitStructure.PeriphInc = DMA_PERIPH_INC_DISABLE;
DMA_InitStructure.DMA_MemoryInc = DMA_MEM_INC_ENABLE;
DMA_InitStructure.PeriphDataSize = DMA_PERIPH_DATA_SIZE_BYTE;
DMA_InitStructure.MemDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStructure.CircularMode = DMA_MODE_CIRCULAR;
DMA_InitStructure.Priority = DMA_PRIORITY_HIGH;
DMA_InitStructure.Mem2Mem = DMA_M2M_DISABLE;
DMA_Init(BSP_USART_RX_DMA_CH, &DMA_InitStructure);
s_rxTail = 0;
DMA_EnableChannel(BSP_USART_RX_DMA_CH, ENABLE);
USART_EnableDMA(BSP_USARTx, USART_DMAREQ_RX, ENABLE);
}
/**
* @brief DMA ring head (hardware write position).
*/
static uint16_t bsp_usart_rx_head(void)
{
return (uint16_t)(RX_RING_SIZE - DMA_GetCurrDataCounter(BSP_USART_RX_DMA_CH));
}
/**
* @brief Initialize USART1 (460800 8N1) on PB6(TX)/PB7(RX),
* DMA TX channel and the RX DMA ring.
*/ */
void bsp_usart_init(void) void bsp_usart_init(void)
{ {
@ -57,11 +108,13 @@ void bsp_usart_init(void)
DMA_RequestRemap(BSP_USART_TX_DMA_REMAP, DMA, BSP_USART_TX_DMA_CH, ENABLE); DMA_RequestRemap(BSP_USART_TX_DMA_REMAP, DMA, BSP_USART_TX_DMA_CH, ENABLE);
USART_EnableDMA(BSP_USARTx, USART_DMAREQ_TX, ENABLE); USART_EnableDMA(BSP_USARTx, USART_DMAREQ_TX, ENABLE);
/* RX byte queue fed by the RXDNE interrupt */ /* RX: DMA circular ring + IDLE interrupt wakeup */
s_rxQueue = xQueueCreate(RX_QUEUE_LENGTH, sizeof(uint8_t)); s_rxInjectQueue = xQueueCreate(RX_INJECT_QUEUE_LEN, sizeof(uint8_t));
s_rxSem = xSemaphoreCreateBinary();
s_txMutex = xSemaphoreCreateMutex(); s_txMutex = xSemaphoreCreateMutex();
bsp_usart_rx_dma_start();
USART_ConfigInt(BSP_USARTx, USART_INT_RXDNE, ENABLE); USART_ConfigInt(BSP_USARTx, USART_INT_IDLEF, ENABLE);
/* Lowest priority: the ISR calls FreeRTOS FromISR APIs */ /* Lowest priority: the ISR calls FreeRTOS FromISR APIs */
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPriority = 3; NVIC_InitStructure.NVIC_IRQChannelPriority = 3;
@ -72,10 +125,10 @@ void bsp_usart_init(void)
} }
/** /**
* @brief Re-apply the baud rate at runtime (keeps RX interrupt/DMA setup). * @brief Re-apply the baud rate at runtime (keeps RX DMA ring setup).
* Waits for TX to drain, re-initializes the USART and flushes the * Waits for TX to drain, re-initializes the USART, restarts the RX
* RX queue (stale bytes may have been sampled at the old baud rate). * DMA ring and flushes the inject queue (stale bytes may have been
* Used by the "uartrst" CLI command. * sampled at the old baud rate). Used by the "uartrst" CLI command.
*/ */
void bsp_usart_set_baud(uint32_t baud) void bsp_usart_set_baud(uint32_t baud)
{ {
@ -97,12 +150,13 @@ void bsp_usart_set_baud(uint32_t baud)
USART_Init(BSP_USARTx, &USART_InitStructure); USART_Init(BSP_USARTx, &USART_InitStructure);
/* Drop bytes received at the old baud rate */ /* Drop bytes received at the old baud rate */
if (s_rxQueue != NULL && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING) bsp_usart_rx_dma_start();
if (s_rxInjectQueue != NULL && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING)
{ {
xQueueReset(s_rxQueue); xQueueReset(s_rxInjectQueue);
} }
USART_ConfigInt(BSP_USARTx, USART_INT_RXDNE, ENABLE); USART_ConfigInt(BSP_USARTx, USART_INT_IDLEF, ENABLE);
USART_Enable(BSP_USARTx, ENABLE); USART_Enable(BSP_USARTx, ENABLE);
} }
@ -148,37 +202,71 @@ void bsp_usart_write_dma(const uint8_t* data, uint16_t len)
} }
/** /**
* @brief Fetch one byte received by the RX interrupt. * @brief Fetch one received byte. The BLE-injected queue is drained first,
* then the DMA RX ring. Blocking waits use the RX semaphore (poked by
* the IDLE interrupt) in RX_WAIT_SLICE_MS slices so a continuous
* byte stream without idle gaps is still drained in time.
* @param ch output byte * @param ch output byte
* @param timeout_ms BSP_USART_WAIT_FOREVER or a timeout in ms * @param timeout_ms BSP_USART_WAIT_FOREVER or a timeout in ms
* @return 1 on success, 0 on timeout * @return 1 on success, 0 on timeout
*/ */
int bsp_usart_read_byte(uint8_t* ch, uint32_t timeout_ms) int bsp_usart_read_byte(uint8_t* ch, uint32_t timeout_ms)
{ {
TickType_t ticks; uint32_t waited = 0;
if (s_rxQueue == NULL) for (;;)
{ {
return 0; uint16_t head;
}
ticks = (timeout_ms == BSP_USART_WAIT_FOREVER) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); /* Injected bytes (BLE downlink -> CLI) take priority */
return (xQueueReceive(s_rxQueue, ch, ticks) == pdPASS) ? 1 : 0; if (s_rxInjectQueue != NULL &&
xTaskGetSchedulerState() == taskSCHEDULER_RUNNING &&
xQueueReceive(s_rxInjectQueue, ch, 0) == pdPASS)
{
return 1;
}
head = bsp_usart_rx_head();
if (s_rxTail != head)
{
*ch = s_rxRing[s_rxTail];
s_rxTail = (uint16_t)((s_rxTail + 1u) % RX_RING_SIZE);
return 1;
}
if (timeout_ms == 0u ||
(timeout_ms != BSP_USART_WAIT_FOREVER && waited >= timeout_ms))
{
return 0;
}
if (s_rxSem == NULL || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING)
{
return 0; /* pre-scheduler: non-blocking snapshot only */
}
uint32_t slice = RX_WAIT_SLICE_MS;
if (timeout_ms != BSP_USART_WAIT_FOREVER && slice > timeout_ms - waited)
{
slice = timeout_ms - waited;
}
(void)xSemaphoreTake(s_rxSem, pdMS_TO_TICKS(slice));
waited += slice;
}
} }
/** /**
* @brief Inject received bytes from task context into the RX queue. * @brief Inject received bytes from task context into the inject queue.
* Used by the BLE downlink to feed the CLI. Drops on full queue. * Used by the BLE downlink to feed the CLI. Drops on full queue.
*/ */
void bsp_usart_rx_inject(const uint8_t* data, uint16_t len) void bsp_usart_rx_inject(const uint8_t* data, uint16_t len)
{ {
if (s_rxQueue == NULL || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) if (s_rxInjectQueue == NULL || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING)
{ {
return; return;
} }
while (len--) while (len--)
{ {
if (xQueueSend(s_rxQueue, data++, 0) != pdPASS) if (xQueueSend(s_rxInjectQueue, data++, 0) != pdPASS)
{ {
return; return;
} }
@ -208,21 +296,23 @@ void bsp_usart_tx_unlock(void)
} }
/** /**
* @brief RXDNE interrupt handler, pushes the received byte into the RX queue. * @brief IDLE line interrupt handler: the DMA ring holds the received bytes,
* here we only wake a task blocked in bsp_usart_read_byte().
* Called from USART1_IRQHandler (see n32wb03x_it.c). * Called from USART1_IRQHandler (see n32wb03x_it.c).
*/ */
void bsp_usart_rx_isr_handler(void) void bsp_usart_rx_isr_handler(void)
{ {
BaseType_t xHigherPriorityTaskWoken = pdFALSE; BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if (USART_GetIntStatus(BSP_USARTx, USART_INT_RXDNE) != RESET) if (USART_GetIntStatus(BSP_USARTx, USART_INT_IDLEF) != RESET)
{ {
/* Reading DAT clears the RXDNE flag */ /* IDLEF clears by reading STS (done by GetIntStatus) then DAT;
uint8_t ch = (uint8_t)USART_ReceiveData(BSP_USARTx); * the data register itself is already drained by the DMA */
(void)USART_ReceiveData(BSP_USARTx);
if (s_rxQueue != NULL) if (s_rxSem != NULL)
{ {
xQueueSendFromISR(s_rxQueue, &ch, &xHigherPriorityTaskWoken); xSemaphoreGiveFromISR(s_rxSem, &xHigherPriorityTaskWoken);
} }
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
} }

View File

@ -277,6 +277,24 @@ static void CmdFactory(int argc, char* argv[])
NVIC_SystemReset(); NVIC_SystemReset();
} }
/**
* @brief "hang": wedge the CLI task in a busy loop so the idle hook stops
* feeding the IWDG - watchdog fire test (device resets in ~4s and
* the next banner shows "Last reset: IWDG watchdog!").
*/
static void CmdHang(int argc, char* argv[])
{
(void)argc;
(void)argv;
cli_write("hanging now - watchdog should reset in ~4s ...\r\n");
vTaskDelay(pdMS_TO_TICKS(CLI_RESET_DELAY_MS));
for (;;)
{
/* busy: idle task starves, IWDG unfed */
}
}
/** /**
* @brief "uartrst": re-initialize USART1 at the default baud rate and * @brief "uartrst": re-initialize USART1 at the default baud rate and
* flush the RX queue (recovery from a wedged/misconfigured UART). * flush the RX queue (recovery from a wedged/misconfigured UART).
@ -302,7 +320,7 @@ static void CmdUartInfo(int argc, char* argv[])
cli_write("usart1: TX=PB6 RX=PB7 (AF4)\r\n"); cli_write("usart1: TX=PB6 RX=PB7 (AF4)\r\n");
cli_printf("baud: %lu, 8 data bits, no parity, 1 stop bit\r\n", cli_printf("baud: %lu, 8 data bits, no parity, 1 stop bit\r\n",
(unsigned long)BSP_USART_BAUDRATE); (unsigned long)BSP_USART_BAUDRATE);
cli_write("flow control: none; tx: DMA CH1 (polled); rx: RXDNE irq queue\r\n"); cli_write("flow control: none; tx: DMA CH1 (polled); rx: DMA CH2 ring (3KB, idle irq)\r\n");
} }
/** /**
@ -340,8 +358,9 @@ static const CliCmd_t s_cmds[] = {
{"ota", "ota: UART enters OTA binary frame mode (see help ota)", CmdOta}, {"ota", "ota: UART enters OTA binary frame mode (see help ota)", CmdOta},
{"reset", "reset: system reset (reboot)", CmdReset}, {"reset", "reset: system reset (reboot)", CmdReset},
{"factory", "factory: restore params to defaults and reboot", CmdFactory}, {"factory", "factory: restore params to defaults and reboot", CmdFactory},
{"uartrst", "uartrst: re-init USART1 (115200 8N1), flush rx queue", CmdUartRst}, {"uartrst", "uartrst: re-init USART1 (460800 8N1), flush rx queue", CmdUartRst},
{"uartinfo", "uartinfo: show USART1 pins/baud/format", CmdUartInfo}, {"uartinfo", "uartinfo: show USART1 pins/baud/format", CmdUartInfo},
{"hang", "hang: wedge the CLI task (IWDG watchdog fire test)", CmdHang},
}; };
#define CLI_CMD_COUNT (sizeof(s_cmds) / sizeof(s_cmds[0])) #define CLI_CMD_COUNT (sizeof(s_cmds) / sizeof(s_cmds[0]))

View File

@ -84,6 +84,8 @@
#include "app_ota.h" #include "app_ota.h"
#include "app_version.h" #include "app_version.h"
#include "app_user_config.h" #include "app_user_config.h"
#include "app_wdt.h"
#include "n32wb03x_iwdg.h"
/* Private typedef -----------------------------------------------------------*/ /* Private typedef -----------------------------------------------------------*/
/* Private define ------------------------------------------------------------*/ /* Private define ------------------------------------------------------------*/
@ -105,8 +107,62 @@
#define LED_TASK_STACK (128) /* stack in words */ #define LED_TASK_STACK (128) /* stack in words */
#define LED_TASK_PRIORITY (1) #define LED_TASK_PRIORITY (1)
/* Private constants ---------------------------------------------------------*/ /* Private constants ----------------------------------------------------------*/
/* Private variables ---------------------------------------------------------*/ /* Private variables ----------------------------------------------------------*/
/* IWDG: LSI 32kHz, /128 prescaler -> 250Hz counter; 1000 counts = 4s */
#define APP_WDT_RELOAD 1000u
/* the idle hook feeds the IWDG only while some task heartbeated this long */
#define APP_WDT_HB_TIMEOUT_MS 3000u
static volatile TickType_t s_wdt_last_hb;
/**
* @brief Report the reset cause and start the independent watchdog.
* IWDG is frozen while the core is halted (debug-friendly).
*/
void app_wdt_init(void)
{
if (RCC_GetFlagStatus(RCC_CTRLSTS_FLAG_IWDGRSTF) != RESET)
{
printf(" Last reset: IWDG watchdog!\n");
}
RCC_ClrFlag();
/* Freeze IWDG while the core is halted (SWD debug sessions) */
RCC_EnableAPB1PeriphClk(RCC_APB1_PERIPH_PWR, ENABLE);
DBG_ConfigPeriph(DBG_IWDG_STOP, ENABLE);
IWDG_WriteConfig(IWDG_WRITE_ENABLE);
IWDG_SetPrescalerDiv(IWDG_PRESCALER_DIV128);
IWDG_CntReload(APP_WDT_RELOAD);
IWDG_ReloadKey();
IWDG_Enable(); /* LSI is enabled by hardware */
s_wdt_last_hb = 0;
}
void app_wdt_heartbeat(void)
{
if (xTaskGetSchedulerState() == taskSCHEDULER_RUNNING)
{
s_wdt_last_hb = xTaskGetTickCount();
}
}
/**
* @brief FreeRTOS idle hook: feed the IWDG only while some application task
* heartbeated recently. A wedged/deadlocked system (idle spins but no
* task advances) resets within the IWDG timeout.
*/
void vApplicationIdleHook(void)
{
if ((xTaskGetTickCount() - s_wdt_last_hb) < pdMS_TO_TICKS(APP_WDT_HB_TIMEOUT_MS))
{
IWDG_ReloadKey();
}
}
/* Private function prototypes -----------------------------------------------*/ /* Private function prototypes -----------------------------------------------*/
/* Private functions ---------------------------------------------------------*/ /* Private functions ---------------------------------------------------------*/
@ -121,6 +177,7 @@ static void ble_schedule_task(void *pvParameters)
(void)pvParameters; (void)pvParameters;
for (;;) for (;;)
{ {
app_wdt_heartbeat();
/*schedule all pending events*/ /*schedule all pending events*/
rwip_schedule(); rwip_schedule();
/*flush pending CLI uplink frames (notify, paced by cfm)*/ /*flush pending CLI uplink frames (notify, paced by cfm)*/
@ -147,6 +204,7 @@ static void led_task(void *pvParameters)
{ {
period = PARAM_LED1_BLINK_MS_DEF; period = PARAM_LED1_BLINK_MS_DEF;
} }
app_wdt_heartbeat();
LedBlink(LED1_PORT, LED1_PIN); LedBlink(LED1_PORT, LED1_PIN);
vTaskDelay(pdMS_TO_TICKS(period)); vTaskDelay(pdMS_TO_TICKS(period));
} }
@ -177,6 +235,9 @@ int main(void)
printf(" BLE enabled, advertising as \"%s\"\n", CUSTOM_DEVICE_NAME); printf(" BLE enabled, advertising as \"%s\"\n", CUSTOM_DEVICE_NAME);
printf("========================================\n"); printf("========================================\n");
/* reset-cause report + start the independent watchdog (idle-hook fed) */
app_wdt_init();
#if (CFG_APP_NS_IUS) #if (CFG_APP_NS_IUS)
if(CURRENT_APP_START_ADDRESS == NS_APP1_START_ADDRESS){ if(CURRENT_APP_START_ADDRESS == NS_APP1_START_ADDRESS){
NS_LOG_INFO("application 1 start new ...\r\n"); NS_LOG_INFO("application 1 start new ...\r\n");

Binary file not shown.

BIN
tools/NSpyocd/NSpyocd.exe Normal file

Binary file not shown.

View File

@ -13,8 +13,10 @@ rem Requires tools\.venv-ble (created automatically on first run).
rem ============================================================ rem ============================================================
setlocal setlocal
set TOOLS=%~dp0 set TOOLS=%~dp0
set PYEXE=%TOOLS%.venv-ble\Scripts\python.exe rem 优先使用随包内嵌的 Python 运行时(tools\python-embed,免安装);
rem 没有则回退到自动创建的 .venv-ble(开发机)
set PYEXE=%TOOLS%python-embed\python.exe
if not exist "%PYEXE%" set PYEXE=%TOOLS%.venv-ble\Scripts\python.exe
if not exist "%PYEXE%" ( if not exist "%PYEXE%" (
echo [setup] creating venv %TOOLS%.venv-ble ... echo [setup] creating venv %TOOLS%.venv-ble ...
set PYREAL= set PYREAL=
@ -24,7 +26,7 @@ if not exist "%PYEXE%" (
) )
if not defined PYREAL set PYREAL=python if not defined PYREAL set PYREAL=python
%PYREAL% -m venv "%TOOLS%.venv-ble" || goto :fail %PYREAL% -m venv "%TOOLS%.venv-ble" || goto :fail
"%PYEXE%" -m pip install --quiet bleak || goto :fail "%PYEXE%" -m pip install --quiet -r "%TOOLS%requirements-ble.txt" || goto :fail
) )
set MODE=-rw set MODE=-rw

View File

@ -13,8 +13,10 @@ rem Requires tools\.venv-ble (created automatically on first run).
rem ============================================================ rem ============================================================
setlocal setlocal
set TOOLS=%~dp0 set TOOLS=%~dp0
set PYEXE=%TOOLS%.venv-ble\Scripts\python.exe rem 优先使用随包内嵌的 Python 运行时(tools\python-embed,免安装);
rem 没有则回退到自动创建的 .venv-ble(开发机)
set PYEXE=%TOOLS%python-embed\python.exe
if not exist "%PYEXE%" set PYEXE=%TOOLS%.venv-ble\Scripts\python.exe
if not exist "%PYEXE%" ( if not exist "%PYEXE%" (
echo [setup] creating venv %TOOLS%.venv-ble ... echo [setup] creating venv %TOOLS%.venv-ble ...
set PYREAL= set PYREAL=
@ -24,7 +26,7 @@ if not exist "%PYEXE%" (
) )
if not defined PYREAL set PYREAL=python if not defined PYREAL set PYREAL=python
%PYREAL% -m venv "%TOOLS%.venv-ble" || goto :fail %PYREAL% -m venv "%TOOLS%.venv-ble" || goto :fail
"%PYEXE%" -m pip install --quiet bleak || goto :fail "%PYEXE%" -m pip install --quiet -r "%TOOLS%requirements-ble.txt" || goto :fail
) )
"%PYEXE%" "%TOOLS%ble_ota_update.py" %1 %2 %3 "%PYEXE%" "%TOOLS%ble_ota_update.py" %1 %2 %3

View File

@ -2,8 +2,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
ble_ota_update.py - PC-side OTA updater for CAIIC-MCM devices, transport ble_ota_update.py - PC-side OTA updater for CAIIC-MCM devices, transport
protocol per docs/ble_protocol.md section 6.6 (0xCA frames, OTA_BEGIN/DATA/ protocol per docs/ble_protocol.md sections 6.6/6.7 (0xCA frames,
END/ABORT -> framed OTA_RSP acks). OTA_BEGIN/DATA/END/ABORT -> framed OTA_RSP acks).
Two channels: Two channels:
BLE (default): dedicated OTA characteristic ...e0005 - one frame per BLE (default): dedicated OTA characteristic ...e0005 - one frame per
@ -12,6 +12,10 @@ Two channels:
UART (--uart): e.g. --uart COM4 - the CLI command "ota" switches the UART (--uart): e.g. --uart COM4 - the CLI command "ota" switches the
serial link to binary frame mode (marker-confirmed serial link to binary frame mode (marker-confirmed
handshake; OTA_ABORT exits); same frames on the wire. handshake; OTA_ABORT exits); same frames on the wire.
Firmware V1.00.24+ runs STREAM mode here (pipelined
DATA inside an 8KB window, sector-boundary acks,
go-back-N on loss); older firmware falls back to
lockstep automatically, or force it with --lockstep.
Both channels select the payload by CUR_BANK (info item 0x07, read-only Both channels select the payload by CUR_BANK (info item 0x07, read-only
characteristic ...e0004 / CLI "devinfo"): OTA always writes the INACTIVE characteristic ...e0004 / CLI "devinfo"): OTA always writes the INACTIVE
@ -19,7 +23,7 @@ bank with the image linked for it, from the single-file package
(mothercup_ble_ota.bin, 52B header + both payloads). (mothercup_ble_ota.bin, 52B header + both payloads).
usage: ble_ota.bat [mothercup_ble_ota.bin] usage: ble_ota.bat [mothercup_ble_ota.bin]
ble_ota.bat --uart COM4 [mothercup_ble_ota.bin] ble_ota.bat --uart COM4 [--lockstep] [mothercup_ble_ota.bin]
Success = the device resets after OTA_END and comes back on the new bank Success = the device resets after OTA_END and comes back on the new bank
with the new version (verified by a second CUR_BANK/FW_VERSION read). with the new version (verified by a second CUR_BANK/FW_VERSION read).
@ -51,8 +55,15 @@ XFER_TRIES = 3 # resend a frame whose ack was lost (flaky links)
REBOOT_WAIT_S = 20.0 REBOOT_WAIT_S = 20.0
ST_OK = 0 ST_OK = 0
ST_BAD_FRAME = 1
ST_BAD_STATE = 2 ST_BAD_STATE = 2
# UART stream mode (firmware V1.00.24+, ble_protocol.md §6.7)
BEGIN_FLAG_STREAM = 1
STREAM_WINDOW = 8192 # unacked bytes in flight
STREAM_CHUNK = 240 # DATA payload per frame (device limit 480+4)
STREAM_STALL_S = 3.0 # no ack progress -> rewind to last ack
_seq = [0] _seq = [0]
@ -157,7 +168,7 @@ class UartTransport:
MARKER_ON = b"[ota] binary mode ON" MARKER_ON = b"[ota] binary mode ON"
PROMPT = b"caiic->" PROMPT = b"caiic->"
def __init__(self, port, baud=115200): def __init__(self, port, baud=460800):
import serial import serial
self.ser = serial.Serial(port, baud, timeout=0.1) self.ser = serial.Serial(port, baud, timeout=0.1)
self.dec = FrameDecoder() self.dec = FrameDecoder()
@ -414,6 +425,107 @@ async def ota_session(xfer, chunk, blob, version, target):
# Channel frontends # Channel frontends
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def ota_session_uart_stream(transport, blob, version, target):
"""UART stream mode (firmware V1.00.24+, ble_protocol.md §6.7):
extended BEGIN (16B payload, flags=STREAM), then DATA frames are
pipelined inside an 8KB window without per-frame acks; the device acks
at 4KB sector boundaries (erase completed) and the final byte.
A lost/corrupt frame makes the device report BAD_STATE once with the
offset it expects - the host rewinds there (go-back-N). A total ack
loss is caught by a stall watchdog that also rewinds to the last ack.
Raises RuntimeError with 'status=1' when the device rejects the 16B
BEGIN (pre-V1.00.24 firmware) so the caller can fall back to lockstep."""
rsp = await transport.xfer(encode_frame(TYPE_OTA_BEGIN,
struct.pack("<IIII", len(blob), crc32(blob),
version, BEGIN_FLAG_STREAM)))
frames = FrameDecoder().feed(rsp)
if len(frames) != 1 or frames[0][0] != TYPE_OTA_RSP:
raise RuntimeError("BEGIN(stream): bad RSP frame: %s" % rsp.hex(" "))
echo, status, offset = parse_rsp(frames[0][2])
if status != ST_OK:
raise RuntimeError("OTA_BEGIN(stream) rejected, status=%d" % status)
print("OTA_BEGIN ok (stream mode), pipelining data ...")
ser = transport.ser
dec = transport.dec
sent = 0
acked = 0
rewinds = 0
t0 = time.monotonic()
last_progress = t0
tty = sys.stdout.isatty()
while sent < len(blob) or acked < len(blob):
# fill the window
while sent < len(blob) and sent - acked < STREAM_WINDOW:
n = min(STREAM_CHUNK, len(blob) - sent)
ser.write(encode_frame(TYPE_OTA_DATA,
struct.pack("<I", sent) + blob[sent:sent + n]))
sent += n
# collect whatever acks arrived (serial timeout = 0.1s)
data = await asyncio.to_thread(ser.read, 256)
now = time.monotonic()
for ftype, seq, payload in dec.feed(data):
if ftype != TYPE_OTA_RSP:
continue
echo, status, offset = parse_rsp(payload)
if echo != TYPE_OTA_DATA:
continue
if status == ST_OK:
if offset > acked:
acked = offset
last_progress = now
elif status == ST_BAD_STATE:
if sent != offset:
rewinds += 1
print("\n DATA: lost frame, rewind %d -> %d"
% (sent, offset))
sent = offset
if acked > offset:
acked = offset
last_progress = now
else:
raise RuntimeError("OTA_DATA rejected, status=%d "
"(device expects offset %d)"
% (status, offset))
# progress (throttled on tty, one line per 5% otherwise)
pct = 100.0 * acked / len(blob)
rate = acked / 1024.0 / max(now - t0, 0.001)
if tty:
print(" %d / %d bytes (%.0f%%, %.1f kB/s, %d rewinds) "
% (acked, len(blob), pct, rate, rewinds),
end="\r" if acked != len(blob) else "\n", flush=True)
elif acked == len(blob) or int(pct // 5) != getattr(
ota_session_uart_stream, "_step", -1):
ota_session_uart_stream._step = int(pct // 5)
print(" %d / %d bytes (%.0f%%, %.1f kB/s, %d rewinds)"
% (acked, len(blob), pct, rate, rewinds))
# stall watchdog: no ack progress -> the ack itself was lost,
# rewind to the last acked offset and resend from there
if now - last_progress > STREAM_STALL_S:
if sent != acked:
rewinds += 1
print("\n DATA: ack stall, rewind %d -> %d" % (sent, acked))
sent = acked
last_progress = now
print("OTA_END: verify image + switch bank ...")
try:
rsp = await transport.xfer(encode_frame(TYPE_OTA_END,
struct.pack("<I", crc32(blob))))
frames = FrameDecoder().feed(rsp)
if len(frames) != 1 or frames[0][0] != TYPE_OTA_RSP:
raise RuntimeError("END: bad RSP frame: %s" % rsp.hex(" "))
echo, status, offset = parse_rsp(frames[0][2])
if status != ST_OK:
raise RuntimeError("OTA_END rejected, status=%d "
"(4=crc_fail, 6=bank_mismatch)" % status)
except (asyncio.TimeoutError, OSError) as exc:
print("OTA_END ack lost (%s) - waiting for reboot anyway ..." % exc)
print("OTA_END done (%.1fs total, %d rewinds), device reboots into "
"APP%d ..." % (time.monotonic() - t0, rewinds, target))
return True
async def find_device(): async def find_device():
from bleak import BleakScanner from bleak import BleakScanner
print("scanning for %s* ..." % NAME_PREFIX) print("scanning for %s* ..." % NAME_PREFIX)
@ -490,7 +602,7 @@ async def run_ble(pkg_path):
return 1 return 1
async def run_uart(port, pkg_path): async def run_uart(port, pkg_path, lockstep=False):
version, banks = parse_combo(pkg_path) version, banks = parse_combo(pkg_path)
print("package: version %s, bank1 %dB / bank2 %dB" print("package: version %s, bank1 %dB / bank2 %dB"
% (fmt_ver(version), len(banks[1]), len(banks[2]))) % (fmt_ver(version), len(banks[1]), len(banks[2])))
@ -509,7 +621,17 @@ async def run_uart(port, pkg_path):
% (target, len(blob), crc32(blob))) % (target, len(blob), crc32(blob)))
try: try:
await ota_session(transport.xfer, 222, blob, version, target) if lockstep:
await ota_session(transport.xfer, 222, blob, version, target)
else:
try:
await ota_session_uart_stream(transport, blob, version, target)
except RuntimeError as exc:
if "status=1" not in str(exc): # not "bad BEGIN frame"
raise
print("note: device has no stream mode (pre-V1.00.24), "
"falling back to lockstep")
await ota_session(transport.xfer, 222, blob, version, target)
except Exception as exc: except Exception as exc:
print("FAIL: %s" % exc) print("FAIL: %s" % exc)
await transport.leave_ota_mode() # ABORT: device returns to the CLI await transport.leave_ota_mode() # ABORT: device returns to the CLI
@ -539,12 +661,18 @@ async def run_uart(port, pkg_path):
def main(): def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")] argv = sys.argv[1:]
lower = [a.lower() for a in argv]
lockstep = "--lockstep" in lower
uart = None uart = None
if "--uart" in sys.argv[1:]: skip = -1
i = sys.argv[1:].index("--uart") if "--uart" in lower:
uart = sys.argv[1:][i + 1] i = lower.index("--uart")
args = [a for j, a in enumerate(sys.argv[1:]) if j not in (i, i + 1)] if i + 1 < len(argv):
uart = argv[i + 1]
skip = i + 1
args = [a for j, a in enumerate(argv)
if not a.startswith("--") and j != skip]
root = os.path.dirname(os.path.abspath(__file__)) root = os.path.dirname(os.path.abspath(__file__))
pkg = args[0] if args else os.path.join(root, "out", "mothercup_ble_ota.bin") pkg = args[0] if args else os.path.join(root, "out", "mothercup_ble_ota.bin")
@ -552,7 +680,7 @@ def main():
print("package not found: %s (run tools\\make_package.bat first)" % pkg) print("package not found: %s (run tools\\make_package.bat first)" % pkg)
return 1 return 1
if uart: if uart:
return asyncio.run(run_uart(uart, pkg)) return asyncio.run(run_uart(uart, pkg, lockstep))
return asyncio.run(run_ble(pkg)) return asyncio.run(run_ble(pkg))

View File

@ -1,45 +1,29 @@
@echo off @echo off
rem ============================================================ rem ============================================================
rem flash_package.bat [image] - erase the chip and program the rem flash_package.bat [image] - erase the chip and program the
rem merged package via NS-LINK (NSpyocd). rem merged package via NS-LINK (NSpyocd); thin wrapper over
rem flash_package.py (streams output with the CAIIC banner).
rem [image] optional, default: tools\out\mothercup_ble_prod.hex rem [image] optional, default: tools\out\mothercup_ble_prod.hex
rem Connect NS-LINK to SWD (PA4/PA5) and the reset pin first, rem Connect NS-LINK to SWD (PA4/PA5) and the reset pin first,
rem and CLOSE Keil (it holds the probe). rem and CLOSE Keil (it holds the probe).
rem ============================================================ rem ============================================================
setlocal setlocal
set ROOT=%~dp0..
set PYOCD="%ROOT%\nations-tec\N32WB03x_SDK_V2.0.0\utilities\dfu\NSpyocd\NSpyocd.exe"
rem The builtin "n32wb031" target only maps 256KB of flash (fails at
rem 0x01040000 on the dual-bank package); the DFP pack target
rem "n32wb031keq6_2" maps the full 512KB of the KEQ6-2 we actually use.
set PACK="%ROOT%\nations-tec\N32WB03x_DFP.1.4.0.pack"
set TARGET=n32wb031keq6_2
set IMAGE=%~1
if "%IMAGE%"=="" set IMAGE=out\mothercup_ble_prod.hex
if not "%IMAGE:~0,1%"=="\" if not "%IMAGE:~1,1%"==":" set IMAGE=%~dp0%IMAGE%
if not exist "%IMAGE%" ( rem 优先使用随包内嵌的 Python 运行时(tools\python-embed,免安装)
echo %IMAGE% not found - run tools\make_package.bat first set PYEXE=%~dp0python-embed\python.exe
if not exist "%PYEXE%" (
set PYEXE=
python --version >nul 2>&1 && set PYEXE=python
)
if not defined PYEXE (
if exist "%LOCALAPPDATA%\Programs\Python\Python312\python.exe" (
set PYEXE="%LOCALAPPDATA%\Programs\Python\Python312\python.exe"
)
)
if not defined PYEXE (
echo python not found - install Python 3 or fix PATH
exit /b 1 exit /b 1
) )
rem -M under-reset: connect while holding the chip in reset %PYEXE% "%~dp0flash_package.py" %*
rem -f 1000000: 1 MHz SWD clock (slow but tolerant of wiring) exit /b %ERRORLEVEL%
echo Erasing chip...
%PYOCD% erase --chip --pack %PACK% -M under-reset -f 1000000 -t %TARGET% || goto :fail
echo Programming %IMAGE% ...
rem -O smart_flash=false: with the pack target the pre-program diff pass
rem faults reading 0x01000000; programming+verify itself is fine.
%PYOCD% load --pack %PACK% -M under-reset -f 1000000 -t %TARGET% -O smart_flash=false "%IMAGE%" || goto :fail
echo Program Finish!
goto :eof
:fail
echo.
echo *** FLASH FAILED ***
echo Troubleshooting:
echo 1. Close Keil / any tool holding the probe, replug NS-LINK USB
echo 2. List probes: %PYOCD% list
echo 3. Check wiring: SWDIO=PA5 SWCLK=PA4 GND and the RESET pin
echo 4. If several probes are attached, add -u ^<probe_uid^> to the commands above
exit /b 1

119
tools/flash_package.py Normal file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
flash_package.py - erase the chip and program the merged package via
NS-LINK (NSpyocd). Streams the vendor tool's output with the vendor
banner replaced by the CAIIC one (the banner is baked into the
PyInstaller-packed NSpyocd.exe and cannot be edited).
usage: flash_package.bat [image]
[image] optional, default: tools/out/mothercup_ble_prod.hex
Connect NS-LINK to SWD (PA4/PA5) and the reset pin first, and CLOSE
Keil (it holds the probe).
"""
import os
import subprocess
import sys
# NSpyocd is a PyInstaller-packed pyocd: its banner is inside the
# compressed bundle, so we rebrand it on the fly. The console encoding
# varies (UTF-8 vs GBK), match the byte pattern in both.
BANNER_PATTERNS = [
("国民技术NSLINK上位机".encode("utf-8"), b"CAIIC NSLINK UMP"),
("国民技术NSLINK上位机".encode("gbk"), b"CAIIC NSLINK UMP"),
]
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOOLS = os.path.dirname(os.path.abspath(__file__))
# Prefer the copies shipped inside tools/ (portable production package);
# fall back to the repo's nations-tec/ SDK layout (developer machine).
_local_pyocd = os.path.join(TOOLS, "NSpyocd", "NSpyocd.exe")
_local_pack = os.path.join(TOOLS, "N32WB03x_DFP.1.4.0.pack")
PYOCD = _local_pyocd if os.path.isfile(_local_pyocd) else \
os.path.join(ROOT, "nations-tec", "N32WB03x_SDK_V2.0.0",
"utilities", "dfu", "NSpyocd", "NSpyocd.exe")
# The builtin "n32wb031" target only maps 256KB of flash (fails at
# 0x01040000 on the dual-bank package); the DFP pack target
# "n32wb031keq6_2" maps the full 512KB of the KEQ6-2 we actually use.
PACK = _local_pack if os.path.isfile(_local_pack) else \
os.path.join(ROOT, "nations-tec", "N32WB03x_DFP.1.4.0.pack")
TARGET = "n32wb031keq6_2"
def run_step(args):
"""Run one NSpyocd step, streaming output with the banner replaced.
Reads in small chunks (not line-buffered) so the tool's \\r-based
progress bar redraws live. Returns the process exit code."""
proc = subprocess.Popen([PYOCD] + args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
out = sys.stdout.buffer
keep = max(len(p) for p, _ in BANNER_PATTERNS) - 1
tail = b""
while True:
chunk = proc.stdout.read(256)
if not chunk:
break
buf = tail + chunk
# replace on the COMBINED buffer first (a pattern straddling the
# chunk boundary only exists contiguously here), then hold back
# the last maxpat-1 bytes so a pattern split by the next chunk
# is still seen whole
for old, new in BANNER_PATTERNS:
buf = buf.replace(old, new)
emit, tail = buf[:-keep], buf[-keep:]
out.write(emit)
out.flush()
for old, new in BANNER_PATTERNS:
tail = tail.replace(old, new)
out.write(tail)
out.flush()
return proc.wait()
def main():
image = sys.argv[1] if len(sys.argv) > 1 else \
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"out", "mothercup_ble_prod.hex")
if not os.path.isfile(image):
print("%s not found - run tools\\make_package.bat first" % image)
return 1
# -M under-reset: connect while holding the chip in reset
# -f 1000000: 1 MHz SWD clock (slow but tolerant of wiring)
print("Erasing chip...", flush=True)
rc = run_step(["erase", "--chip", "--pack", PACK, "-M", "under-reset",
"-f", "1000000", "-t", TARGET])
if rc != 0:
return fail()
print("Programming %s ..." % image, flush=True)
# -O smart_flash=false: with the pack target the pre-program diff pass
# faults reading 0x01000000; programming+verify itself is fine.
rc = run_step(["load", "--pack", PACK, "-M", "under-reset",
"-f", "1000000", "-t", TARGET,
"-O", "smart_flash=false", image])
if rc != 0:
return fail()
print("Program Finish!")
return 0
def fail():
print()
print("*** FLASH FAILED ***")
print("Troubleshooting:")
print(" 1. Close Keil / any tool holding the probe, replug NS-LINK USB")
print(" 2. List probes: \"%s\" list" % PYOCD)
print(" 3. Check wiring: SWDIO=PA5 SWCLK=PA4 GND and the RESET pin")
print(" 4. If several probes are attached, add -u <probe_uid> "
"to the commands above")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -26,8 +26,12 @@ if errorlevel 1 goto :fail
findstr /C:"0 Error(s), 0 Warning(s)" "%ROOT%\mcm-ddc-ble\MDK-ARM\build-app2.log" >nul || goto :fail findstr /C:"0 Error(s), 0 Warning(s)" "%ROOT%\mcm-ddc-ble\MDK-ARM\build-app2.log" >nul || goto :fail
echo [4/4] Merging images... echo [4/4] Merging images...
rem 优先随包内嵌的 Python 运行时(merge_image.py 只需标准库)
set PYEXE= set PYEXE=
python --version >nul 2>&1 && set PYEXE=python if exist "%~dp0python-embed\python.exe" set PYEXE="%~dp0python-embed\python.exe"
if not defined PYEXE (
python --version >nul 2>&1 && set PYEXE=python
)
if not defined PYEXE ( if not defined PYEXE (
if exist "%LOCALAPPDATA%\Programs\Python\Python312\python.exe" ( if exist "%LOCALAPPDATA%\Programs\Python\Python312\python.exe" (
set PYEXE="%LOCALAPPDATA%\Programs\Python\Python312\python.exe" set PYEXE="%LOCALAPPDATA%\Programs\Python\Python312\python.exe"

11
tools/make_release.bat Normal file
View File

@ -0,0 +1,11 @@
@echo off
rem ============================================================
rem make_release.bat - pack tools\ into out\mothercup_tools_<ts>.zip
rem Self-contained production package: embedded Python runtime
rem (python-embed\) + NSpyocd flasher included - the target PC
rem needs nothing installed. Unzip anywhere and run the .bat files.
rem ============================================================
setlocal
set PS=powershell
%PS% -noprofile -command "exit 0" >nul 2>&1 || set PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe
%PS% -noprofile -executionpolicy bypass -file "%~dp0make_release.ps1"

14
tools/make_release.ps1 Normal file
View File

@ -0,0 +1,14 @@
# make_release.ps1 - pack tools/ into a self-contained production zip.
# The zip holds the embedded Python runtime (python-embed/, bleak+pyserial
# preinstalled) and NSpyocd, so the target PC needs NO installation.
# Excludes the dev-only venv and caches; keeps out/ (the firmware images).
# NOTE: the zip is staged in $TEMP first - writing it into out/ while
# reading out/ would make Compress-Archive read its own half-written file.
$tools = Split-Path -Parent $MyInvocation.MyCommand.Path
$stamp = Get-Date -Format "yyyyMMdd_HHmm"
$stage = Join-Path $env:TEMP "mothercup_tools_$stamp.zip"
$final = Join-Path $tools "out\mothercup_tools_$stamp.zip"
$items = Get-ChildItem $tools | Where-Object { $_.Name -notin @('.venv-ble', '__pycache__') }
Compress-Archive -Path ($items | ForEach-Object { $_.FullName }) -DestinationPath $stage -Force -ErrorAction Stop
Move-Item $stage $final -Force
Write-Output "packed: $final"

View File

@ -175,6 +175,28 @@ def load_bin(path, desc):
return f.read() return f.read()
# Usable app SRAM on the 256KB-flash silicon is 32KB: 0x20004000..0x2000BFFF.
# Accesses at/above 0x2000C000 fault (dev log §47), so the image's initial
# SP (vector[0]) must stay below it - a full-rebuild size drift once pushed
# the stack over this cliff and the board locked up at boot.
SRAM_STACK_TOP_LIMIT = 0x2000C000
def check_initial_sp(blob, desc):
"""Vector table word 0 = initial MSP. Hard-fail when it lands outside
the usable SRAM window."""
if len(blob) < 8:
return
sp = struct.unpack_from("<I", blob, 0)[0]
if not (0x20004000 < sp <= SRAM_STACK_TOP_LIMIT):
raise SystemExit("ERROR: %s initial SP 0x%08X is outside usable SRAM "
"(0x20004001..0x%08X) - shrink ZI (heap/buffers); "
"see dev log §47" % (desc, sp, SRAM_STACK_TOP_LIMIT))
if sp > SRAM_STACK_TOP_LIMIT - 0x400:
print("WARNING: %s initial SP 0x%08X is within 1KB of the SRAM cliff"
% (desc, sp))
def read_app_fw_version(root): def read_app_fw_version(root):
"""Extract APP_FW_VERSION_NUM from the APP project's app_version.h. """Extract APP_FW_VERSION_NUM from the APP project's app_version.h.
Returns None when not found.""" Returns None when not found."""
@ -252,6 +274,13 @@ def main():
elif app2_blob: elif app2_blob:
ota_app2_blob = app2_blob ota_app2_blob = app2_blob
# Stack-top cliff guard (usable SRAM ends at 0x2000C000, dev log §47)
check_initial_sp(app_blob, "APP1")
if app2_blob:
check_initial_sp(app2_blob, "APP2")
if ota_app2_blob:
check_initial_sp(ota_app2_blob, "OTA APP2 payload")
bs_blob = default_bootsetting(app_blob, app2_blob, version, app2_version) bs_blob = default_bootsetting(app_blob, app2_blob, version, app2_version)
segments = [ segments = [

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,7 +0,0 @@
{
"file": "caiic_ble_dual_ota.bin",
"target_bank": 1,
"size": 55408,
"crc32": "0xA87539ED",
"version": 65559
}

View File

@ -1,7 +0,0 @@
{
"file": "caiic_ble_dual_ota_app2.bin",
"target_bank": 2,
"size": 55708,
"crc32": "0x03825A0D",
"version": 65559
}

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,7 +1,7 @@
{ {
"file": "mothercup_ble_prod_ota.bin", "file": "mothercup_ble_prod_ota.bin",
"target_bank": 1, "target_bank": 1,
"size": 55408, "size": 56100,
"crc32": "0xA87539ED", "crc32": "0xC7CF2A19",
"version": 65559 "version": 65563
} }

View File

@ -1,7 +1,7 @@
{ {
"file": "mothercup_ble_prod_ota_app2.bin", "file": "mothercup_ble_prod_ota_app2.bin",
"target_bank": 2, "target_bank": 2,
"size": 55708, "size": 56400,
"crc32": "0x03825A0D", "crc32": "0x0198204E",
"version": 65559 "version": 65563
} }

View File

@ -0,0 +1,702 @@
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see https://opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
Python software and documentation are licensed under the
Python Software Foundation License Version 2.
Starting with Python 3.8.6, examples, recipes, and other code in
the documentation are dual licensed under the PSF License Version 2
and the Zero-Clause BSD license.
Some software incorporated into Python is under different licenses.
The licenses are listed with code falling under that license.
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
----------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
Additional Conditions for this Windows binary build
---------------------------------------------------
This program is linked with and uses Microsoft Distributable Code,
copyrighted by Microsoft Corporation. The Microsoft Distributable Code
is embedded in each .exe, .dll and .pyd file as a result of running
the code through a linker.
If you further distribute programs that include the Microsoft
Distributable Code, you must comply with the restrictions on
distribution specified by Microsoft. In particular, you must require
distributors and external end users to agree to terms that protect the
Microsoft Distributable Code at least as much as Microsoft's own
requirements for the Distributable Code. See Microsoft's documentation
(included in its developer tools and on its website at microsoft.com)
for specific details.
Redistribution of the Windows binary build of the Python interpreter
complies with this agreement, provided that you do not:
- alter any copyright, trademark or patent notice in Microsoft's
Distributable Code;
- use Microsoft's trademarks in your programs' names or in a way that
suggests your programs come from or are endorsed by Microsoft;
- distribute Microsoft's Distributable Code to run on a platform other
than Microsoft operating systems, run-time technologies or application
platforms; or
- include Microsoft Distributable Code in malicious, deceptive or
unlawful programs.
These restrictions apply only to the Microsoft Distributable Code as
defined above, not to Python itself or any programs running on the
Python interpreter. The redistribution of the Python interpreter and
libraries is governed by the Python Software License included with this
file, or by other licenses as marked.
--------------------------------------------------------------------------
This program, "bzip2", the associated library "libbzip2", and all
documentation, are copyright (C) 1996-2019 Julian R Seward. All
rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. The origin of this software must not be misrepresented; you must
not claim that you wrote the original software. If you use this
software in a product, an acknowledgment in the product
documentation would be appreciated but is not required.
3. Altered source versions must be plainly marked as such, and must
not be misrepresented as being the original software.
4. The name of the author may not be used to endorse or promote
products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Julian Seward, jseward@acm.org
bzip2/libbzip2 version 1.0.8 of 13 July 2019
--------------------------------------------------------------------------
libffi - Copyright (c) 1996-2022 Anthony Green, Red Hat, Inc and others.
See source files for details.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
``Software''), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
This software is copyrighted by the Regents of the University of
California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState
Corporation and other parties. The following terms apply to all files
associated with the software unless explicitly disclaimed in
individual files.
The authors hereby grant permission to use, copy, modify, distribute,
and license this software and its documentation for any purpose, provided
that existing copyright notices are retained in all copies and that this
notice is included verbatim in any distributions. No written agreement,
license, or royalty fee is required for any of the authorized uses.
Modifications to this software may be copyrighted by their authors
and need not follow the licensing terms described here, provided that
the new terms are clearly indicated on the first page of each file where
they apply.
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
MODIFICATIONS.
GOVERNMENT USE: If you are acquiring this software on behalf of the
U.S. government, the Government shall have only "Restricted Rights"
in the software and related documentation as defined in the Federal
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
are acquiring the software on behalf of the Department of Defense, the
software shall be classified as "Commercial Computer Software" and the
Government shall have only "Restricted Rights" as defined in Clause
252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the
authors grant the U.S. Government and others acting in its behalf
permission to use and distribute the software in accordance with the
terms specified in this license.
This software is copyrighted by the Regents of the University of
California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState
Corporation, Apple Inc. and other parties. The following terms apply to
all files associated with the software unless explicitly disclaimed in
individual files.
The authors hereby grant permission to use, copy, modify, distribute,
and license this software and its documentation for any purpose, provided
that existing copyright notices are retained in all copies and that this
notice is included verbatim in any distributions. No written agreement,
license, or royalty fee is required for any of the authorized uses.
Modifications to this software may be copyrighted by their authors
and need not follow the licensing terms described here, provided that
the new terms are clearly indicated on the first page of each file where
they apply.
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
MODIFICATIONS.
GOVERNMENT USE: If you are acquiring this software on behalf of the
U.S. government, the Government shall have only "Restricted Rights"
in the software and related documentation as defined in the Federal
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
are acquiring the software on behalf of the Department of Defense, the
software shall be classified as "Commercial Computer Software" and the
Government shall have only "Restricted Rights" as defined in Clause
252.227-7013 (b) (3) of DFARs. Notwithstanding the foregoing, the
authors grant the U.S. Government and others acting in its behalf
permission to use and distribute the software in accordance with the
terms specified in this license.
Copyright (c) 1993-1999 Ioi Kim Lam.
Copyright (c) 2000-2001 Tix Project Group.
Copyright (c) 2004 ActiveState
This software is copyrighted by the above entities
and other parties. The following terms apply to all files associated
with the software unless explicitly disclaimed in individual files.
The authors hereby grant permission to use, copy, modify, distribute,
and license this software and its documentation for any purpose, provided
that existing copyright notices are retained in all copies and that this
notice is included verbatim in any distributions. No written agreement,
license, or royalty fee is required for any of the authorized uses.
Modifications to this software may be copyrighted by their authors
and need not follow the licensing terms described here, provided that
the new terms are clearly indicated on the first page of each file where
they apply.
IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY
FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY
DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE
IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE
NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
MODIFICATIONS.
GOVERNMENT USE: If you are acquiring this software on behalf of the
U.S. government, the Government shall have only "Restricted Rights"
in the software and related documentation as defined in the Federal
Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you
are acquiring the software on behalf of the Department of Defense, the
software shall be classified as "Commercial Computer Software" and the
Government shall have only "Restricted Rights" as defined in Clause
252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the
authors grant the U.S. Government and others acting in its behalf
permission to use and distribute the software in accordance with the
terms specified in this license.
----------------------------------------------------------------------
Parts of this software are based on the Tcl/Tk software copyrighted by
the Regents of the University of California, Sun Microsystems, Inc.,
and other parties. The original license terms of the Tcl/Tk software
distribution is included in the file docs/license.tcltk.
Parts of this software are based on the HTML Library software
copyrighted by Sun Microsystems, Inc. The original license terms of
the HTML Library software distribution is included in the file
docs/license.html_lib.

View File

@ -0,0 +1,132 @@
Metadata-Version: 2.4
Name: bleak
Version: 3.0.2
Summary: Bluetooth Low Energy platform Agnostic Klient
Author: Henrik Blidh
Author-email: Henrik Blidh <henrik.blidh@nedomkull.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Framework :: AsyncIO
Classifier: Operating System :: Microsoft :: Windows :: Windows 11
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Android
Requires-Dist: async-timeout>=3.0.0 ; python_full_version < '3.11'
Requires-Dist: typing-extensions>=4.7.0 ; python_full_version < '3.12'
Requires-Dist: pyobjc-core>=10.3 ; sys_platform == 'darwin'
Requires-Dist: pyobjc-framework-corebluetooth>=10.3 ; sys_platform == 'darwin'
Requires-Dist: pyobjc-framework-libdispatch>=10.3 ; sys_platform == 'darwin'
Requires-Dist: winrt-runtime>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-devices-bluetooth>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-devices-bluetooth-advertisement>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-devices-bluetooth-genericattributeprofile>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-devices-enumeration>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-devices-radios>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-foundation>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-foundation-collections>=3.1 ; sys_platform == 'win32'
Requires-Dist: winrt-windows-storage-streams>=3.1 ; sys_platform == 'win32'
Requires-Dist: dbus-fast>=1.83.0 ; sys_platform == 'linux'
Requires-Dist: bleak-pythonista>=0.1.1 ; extra == 'pythonista'
Requires-Python: >=3.10
Project-URL: Homepage, https://github.com/hbldh/bleak
Project-URL: Documentation, https://bleak.readthedocs.io
Project-URL: Changelog, https://github.com/hbldh/bleak/blob/develop/CHANGELOG.rst
Project-URL: Support, https://github.com/hbldh/bleak/discussions
Project-URL: Issues, https://github.com/hbldh/bleak/issues
Provides-Extra: pythonista
Description-Content-Type: text/x-rst
=====
Bleak
=====
.. image:: https://github.com/hbldh/bleak/workflows/Build%20and%20Test/badge.svg
:target: https://github.com/hbldh/bleak/actions?query=workflow%3A%22Build+and+Test%22
:alt: Build and Test
.. image:: https://img.shields.io/pypi/v/bleak.svg
:target: https://pypi.python.org/pypi/bleak
.. image:: https://img.shields.io/pypi/dm/bleak.svg
:target: https://pypi.python.org/pypi/bleak
:alt: PyPI - Downloads
.. image:: https://readthedocs.org/projects/bleak/badge/?version=latest
:target: https://bleak.readthedocs.io/en/latest/?badge=latest
:alt: Documentation Status
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/psf/black
.. container::
.. image:: Bleak_logo2.png
:target: https://github.com/hbldh/bleak
:alt: Bleak Logo
Bleak is an acronym for Bluetooth Low Energy platform Agnostic Klient.
* Free software: MIT license
* Documentation: https://bleak.readthedocs.io.
Bleak is a GATT client software, capable of connecting to BLE devices
acting as GATT servers. It is designed to provide a asynchronous,
cross-platform Python API to connect and communicate with e.g. sensors.
Installation
------------
.. code-block:: bash
$ pip install bleak
Features
--------
* Supports Windows 11, version 22000 and greater
* Supports Linux distributions with BlueZ >= 5.55
* OS X/macOS support via Core Bluetooth API, from at least OS X version 10.15
* Android backend compatible with python-for-android
Bleak supports reading, writing and getting notifications from
GATT servers, as well as a function for discovering BLE devices.
Usage
-----
To discover Bluetooth devices that can be connected to:
.. code-block:: python
import asyncio
from bleak import BleakScanner
async def main():
devices = await BleakScanner.discover()
for d in devices:
print(d)
asyncio.run(main())
Connect to a Bluetooth device and read its model number:
.. code-block:: python
import asyncio
from bleak import BleakClient
address = "24:71:89:cc:09:05"
MODEL_NBR_UUID = "2A24"
async def main(address):
async with BleakClient(address) as client:
model_number = await client.read_gatt_char(MODEL_NBR_UUID)
print(f"Model Number: {model_number.decode()}")
asyncio.run(main(address))
DO NOT NAME YOUR SCRIPT ``bleak.py``! It will cause a circular import error.
See examples folder for more code, for instance example code for connecting to a
`TI SensorTag CC2650 <http://www.ti.com/ww/en/wireless_connectivity/sensortag/>`_

View File

@ -0,0 +1,97 @@
bleak-3.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
bleak-3.0.2.dist-info/METADATA,sha256=-J8V2qKsJBXOvDOVL73TrXv4U67jyiDmbopo7BXa9AE,4698
bleak-3.0.2.dist-info/RECORD,,
bleak-3.0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleak-3.0.2.dist-info/WHEEL,sha256=q5IF0q2xCp3ktUFRCVWsQLjl2ChNlWXBJtnI1LCGdJ8,80
bleak-3.0.2.dist-info/licenses/LICENSE,sha256=xAKaK2Ozgkq2i-hB9BSt293iDLec2-Jy-oDAsqvmU3Q,1072
bleak/__init__.py,sha256=ht6iQq9epvqQ7hz6A8KlQcw7fvT0efdqNvtaxTJyg2M,37965
bleak/__pycache__/__init__.cpython-312.pyc,,
bleak/__pycache__/_compat.cpython-312.pyc,,
bleak/__pycache__/assigned_numbers.cpython-312.pyc,,
bleak/__pycache__/exc.cpython-312.pyc,,
bleak/__pycache__/uuids.cpython-312.pyc,,
bleak/_compat.py,sha256=Vd1TpIlUIlg33OAsJjWy1H-SNZYrf7PkjPI2IFqfI60,988
bleak/args/__init__.py,sha256=B60fxxXFh_1F0t56BQl0i2rYW2iQaMhMWk7CPEAGG4s,594
bleak/args/__pycache__/__init__.cpython-312.pyc,,
bleak/args/__pycache__/bluez.cpython-312.pyc,,
bleak/args/__pycache__/corebluetooth.cpython-312.pyc,,
bleak/args/__pycache__/winrt.cpython-312.pyc,,
bleak/args/bluez.py,sha256=FXldHcGZ4aFtKA70kwX39dLBJNUJhk0p2gJobpx8vko,4122
bleak/args/corebluetooth.py,sha256=HUlwGXtw2M6F_mGj07obYlwUALnWqR7o8BmDWKzuKuk,1226
bleak/args/winrt.py,sha256=RPdm8I316SFKbkf_8w_FSb7uTKuD5wXHLGe6bcii97k,974
bleak/assigned_numbers.py,sha256=jC3uihHGvozGmCdIZ34zwk-jeDyYj65shj8RvtI-lZw,2504
bleak/backends/__init__.py,sha256=PSTvMjyTVGzk92mtxVW5Q7wlKcvocL9pK4y834eeK_8,1798
bleak/backends/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/__pycache__/_manufacturers.cpython-312.pyc,,
bleak/backends/__pycache__/_utils.cpython-312.pyc,,
bleak/backends/__pycache__/characteristic.cpython-312.pyc,,
bleak/backends/__pycache__/client.cpython-312.pyc,,
bleak/backends/__pycache__/descriptor.cpython-312.pyc,,
bleak/backends/__pycache__/device.cpython-312.pyc,,
bleak/backends/__pycache__/scanner.cpython-312.pyc,,
bleak/backends/__pycache__/service.cpython-312.pyc,,
bleak/backends/_manufacturers.py,sha256=NjCqNI7SCtSzNf5OmGiFM7ayq9t_5eKNecMnyF4kehU,67669
bleak/backends/_utils.py,sha256=KkWbkLbOhawaQ-3iKcmvJVen2Adt4QxstcsvE0l-mII,1047
bleak/backends/bluezdbus/__init__.py,sha256=isYF5VYdJI9EUUD9Z0AbQ3WDv4IBuaLBv2epV2DS8FM,21
bleak/backends/bluezdbus/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/advertisement_monitor.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/client.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/defs.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/manager.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/scanner.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/signals.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/utils.cpython-312.pyc,,
bleak/backends/bluezdbus/__pycache__/version.cpython-312.pyc,,
bleak/backends/bluezdbus/advertisement_monitor.py,sha256=hfwHUzA8kNCv9OyUJSAUJ2mU0Kayd2yplfhLVL2ief4,3974
bleak/backends/bluezdbus/client.py,sha256=72X6l4RBBSHBDVyeyi9jMQ4pnX9CeLr3Uw-B1XiIz5I,39932
bleak/backends/bluezdbus/defs.py,sha256=OwPfz0ZlMx7vBWI-8t_1tfvqu05PG8dgUG_c2JMtXSY,4373
bleak/backends/bluezdbus/manager.py,sha256=Kn4g7zqWsBroPfX8v8IwiEnv6qtikhaSx2ymmighO9s,44905
bleak/backends/bluezdbus/scanner.py,sha256=u3NJaQWRBQqYWRXr_Pe8lu_LoETdm1R0Oh2MGdRfzzo,8234
bleak/backends/bluezdbus/signals.py,sha256=2iOPVBtHSQBIACqGhC5v8Bk_oFgkPUuR6kd845sIM2w,6252
bleak/backends/bluezdbus/utils.py,sha256=eZNw8GK5WlMVbGJ3NdloSgC3twpl5y6Y7VL1eA-QfMI,4933
bleak/backends/bluezdbus/version.py,sha256=GQbwpYMBfv8LCGpEjynUyvk1sIudceLz_FNgVfxrE4I,1957
bleak/backends/characteristic.py,sha256=jhVkm_Ce3REfmKcjArDHUOTdWxfAugfSr0EcZY6BALc,5112
bleak/backends/client.py,sha256=_wSF61BwfMWO0kzgcQjhLBinxZKK1fZZiyyX2LjTrGE,8442
bleak/backends/corebluetooth/CentralManagerDelegate.py,sha256=3YvbHNQ3jVJ4JqVJK7ujWy2ZsROwBnpawTjSf1tZfJM,14226
bleak/backends/corebluetooth/PeripheralDelegate.py,sha256=KdcZ2KbSmiJvULmFqdpT1fZZeHATzz5T7rnoHA8kn0c,24743
bleak/backends/corebluetooth/__init__.py,sha256=e3h7IH-yrKjTvT4WoK2g-Azrewm18Rg0ln6VsmZ3hbU,284
bleak/backends/corebluetooth/__pycache__/CentralManagerDelegate.cpython-312.pyc,,
bleak/backends/corebluetooth/__pycache__/PeripheralDelegate.cpython-312.pyc,,
bleak/backends/corebluetooth/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/corebluetooth/__pycache__/client.cpython-312.pyc,,
bleak/backends/corebluetooth/__pycache__/scanner.cpython-312.pyc,,
bleak/backends/corebluetooth/__pycache__/utils.cpython-312.pyc,,
bleak/backends/corebluetooth/client.py,sha256=HIcltkuujrV7X027AzuzUWqWcJxAYf6F_Qw5t8kpul8,14472
bleak/backends/corebluetooth/scanner.py,sha256=taMAE0h2DzeypUC7HJehgFg5XNfho6RqjyvCUQYU-sI,6554
bleak/backends/corebluetooth/utils.py,sha256=EhtJKMbLvBfBO_yVdYF4PZ4EN8drBpKZlzf38h3iVm8,3602
bleak/backends/descriptor.py,sha256=ZxSU30c7aN_N6Bd_vVjF9OmmDvDsRdQ-kEODgTNHsUI,4501
bleak/backends/device.py,sha256=ip3sfYapIlwwl2VcSD8F4l2v9WV5PBEg2hgSqk1re3Y,1230
bleak/backends/p4android/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleak/backends/p4android/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/p4android/__pycache__/client.cpython-312.pyc,,
bleak/backends/p4android/__pycache__/defs.cpython-312.pyc,,
bleak/backends/p4android/__pycache__/scanner.cpython-312.pyc,,
bleak/backends/p4android/__pycache__/utils.cpython-312.pyc,,
bleak/backends/p4android/client.py,sha256=WCgYmdwOPSGNeKJFXH21j7tVkroIQ5lRle8cXtMroQA,17814
bleak/backends/p4android/defs.py,sha256=pO4gpTx8tu2n7kXgV5zDWwd26_Hh-myPAy73TYQsS-4,3283
bleak/backends/p4android/java/com/github/hbldh/bleak/PythonBluetoothGattCallback.java,sha256=2bZPGMQB3EZt7uW2EV7ST2EVwtWnDa04iTOtOWHuWqc,2897
bleak/backends/p4android/java/com/github/hbldh/bleak/PythonScanCallback.java,sha256=vtiCeSA4Sn8ScjqDiCgXG21v1ADQD8SEekRdJQ3QAB8,909
bleak/backends/p4android/recipes/bleak/__init__.py,sha256=pnT6pxILzrII_cl8sAFIN-_it4dPRbXbW5mfsx4sotA,2022
bleak/backends/p4android/recipes/bleak/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/p4android/recipes/bleak/__pycache__/fix_setup.cpython-312.pyc,,
bleak/backends/p4android/recipes/bleak/fix_setup.py,sha256=KMfVhr4Um91aKxBAYxXIGcjH98CiTFXBRJwEAwUJriA,244
bleak/backends/p4android/scanner.py,sha256=cXCMTlYRotPFUBzlauYebfSy0FmHKuMEzS9edYsYG-0,10842
bleak/backends/p4android/utils.py,sha256=mkrjzJBHnkUri41Yl6qgKjllz-LXQqNULMj6DW5HDwo,3330
bleak/backends/scanner.py,sha256=zryWb4-YwRoEmVV1fc93u_qtiAOVl-ku8nP-Mlg650k,10184
bleak/backends/service.py,sha256=hhjKGkmfgsKoNSi3Iwhef5UWF8PC5-zhWDi0nB0IFeI,7576
bleak/backends/winrt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleak/backends/winrt/__pycache__/__init__.cpython-312.pyc,,
bleak/backends/winrt/__pycache__/client.cpython-312.pyc,,
bleak/backends/winrt/__pycache__/scanner.cpython-312.pyc,,
bleak/backends/winrt/__pycache__/util.cpython-312.pyc,,
bleak/backends/winrt/client.py,sha256=5QiEcCYjNZvYF6nvIGxatNEinJPo9ed34YT_bIdMMtQ,43313
bleak/backends/winrt/scanner.py,sha256=Y6nlml8jTO_2Ci6JjsZpeLdeAMuAOXxE458hHg-H68E,13046
bleak/backends/winrt/util.py,sha256=bNT0ivkVEoO4oteB1cRBBTADHolluccx7AEVgjLa4RM,6486
bleak/exc.py,sha256=voa8y6eWdkvMJcEKAO7rZqgAgDgGtCEOM5rZ3JECVq4,11504
bleak/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleak/uuids.py,sha256=ind-60OjNfoEXLzhCfYJWTGhLuTgwJuN0vLPnvZL8Oc,49038

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: uv 0.11.8
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,11 @@
MIT License
Copyright (c) 2020, Henrik Blidh
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
"""
Python version compatibility imports.
These will be removed when support for older Python versions is dropped.
"""
import sys
if sys.version_info < (3, 11):
from async_timeout import timeout as timeout
from typing_extensions import Never as Never
from typing_extensions import Self as Self
from typing_extensions import TypeVarTuple as TypeVarTuple
from typing_extensions import Unpack as Unpack
from typing_extensions import assert_never as assert_never
else:
from asyncio import timeout as timeout # noqa: F401
from typing import Never as Never # noqa: F401
from typing import Self as Self # noqa: F401
from typing import TypeVarTuple as TypeVarTuple # noqa: F401
from typing import Unpack as Unpack # noqa: F401
from typing import assert_never as assert_never # noqa: F401
if sys.version_info < (3, 12):
from typing_extensions import override as override
else:
from typing import override as override # noqa: F401

View File

@ -0,0 +1,21 @@
import sys
from collections.abc import Sized
from typing import Protocol
if sys.version_info < (3, 12):
# mypy and pyright object to Buffer being both ABC and Protocol. Work around
# by just inheriting from Protocol here. typing_extensions.Buffer is just
# abc.ABC.
class BufferProtocol(Protocol):
def __buffer__(self, flags: int, /) -> memoryview: ...
else:
from collections.abc import Buffer as BufferProtocol
class SizedBuffer(BufferProtocol, Sized, Protocol):
"""
Protocol for types that are both Buffer and Sized.
.. versionadded:: 2.1
"""

View File

@ -0,0 +1,150 @@
"""
-----------------------
BlueZ backend arguments
-----------------------
"""
from typing import NamedTuple, TypedDict, Union
from bleak.assigned_numbers import AdvertisementDataType
class BlueZDiscoveryFilters(TypedDict, total=False):
"""
Dictionary of arguments for the ``org.bluez.Adapter1.SetDiscoveryFilter``
D-Bus method.
https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter
"""
UUIDs: list[str]
"""
Filter by service UUIDs, empty means match _any_ UUID.
Normally, the ``service_uuids`` argument of :class:`bleak.BleakScanner`
is used instead.
"""
RSSI: int
"""
RSSI threshold value.
"""
Pathloss: int
"""
Pathloss threshold value.
"""
Transport: str
"""
Transport parameter determines the type of scan.
This should not be used since it is required to be set to ``"le"``.
"""
DuplicateData: bool
"""
Disables duplicate detection of advertisement data.
This does not affect the ``Filter Duplicates`` parameter of the ``LE Set Scan Enable``
HCI command to the Bluetooth adapter!
Although the default value for BlueZ is ``True``, Bleak sets this to ``False`` by default.
"""
Discoverable: bool
"""
Make adapter discoverable while discovering,
if the adapter is already discoverable setting
this filter won't do anything.
"""
Pattern: str
"""
Discover devices where the pattern matches
either the prefix of the address or
device name which is convenient way to limited
the number of device objects created during a
discovery.
"""
class OrPattern(NamedTuple):
"""
BlueZ advertisement monitor or-pattern.
https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst#arrayuint8-uint8-arraybyte-patterns-read-only-optional
"""
start_position: int
ad_data_type: AdvertisementDataType
content_of_pattern: bytes
# Windows has a similar structure, so we allow generic tuple for cross-platform compatibility
OrPatternLike = Union[OrPattern, tuple[int, AdvertisementDataType, bytes]]
class BlueZScannerArgs(TypedDict, total=False):
"""
:class:`BleakScanner` args that are specific to the BlueZ backend.
"""
adapter: str
"""
Bluetooth adapter to use for discovery, e.g. "hci0".
.. tip:: If you have multiple Bluetooth adapters, they may not always be
assigned the same ``hciX`` name across reboots. In that case, you can
use udev to look up the name based on other properties like the USB
vendor and product ID.
.. versionadded:: 3.0
"""
filters: BlueZDiscoveryFilters
"""
Filters to pass to the adapter SetDiscoveryFilter D-Bus method.
Only used for active scanning.
"""
or_patterns: list[OrPatternLike]
"""
Or patterns to pass to the AdvertisementMonitor1 D-Bus interface.
Only used for passive scanning.
"""
class BlueZClientArgs(TypedDict, total=False):
"""
:class:`bleak.BleakClient` args that are specific to the BlueZ backend.
.. versionadded:: 3.0
"""
adapter: str
"""
Bluetooth adapter to use for connection, e.g. "hci0".
.. tip:: If you have multiple Bluetooth adapters, they may not always be
assigned the same ``hciX`` name across reboots. In that case, you can
use udev to look up the name based on other properties like the USB
vendor and product ID.
"""
class BlueZNotifyArgs(TypedDict, total=False):
"""
:meth:`bleak.BleakClient.start_notify` method args that are specific to the
BlueZ backend.
.. versionadded:: 2.1
"""
use_start_notify: bool
"""
If false, use the "AcquireNotify" D-Bus method instead of "StartNotify" to
subscribe to notifications. The default is to use "StartNotify" for better
compatibility with most BLE devices.
see :ref:`linux-start-notify` for more details.
.. versionchanged:: 3.0.2
The default value was changed from ``False`` to ``True``.
"""

View File

@ -0,0 +1,41 @@
"""
-------------------------------
CoreBluetooth backend arguments
-------------------------------
"""
from collections.abc import Callable
from typing import Optional, TypedDict
class CBScannerArgs(TypedDict, total=False):
"""
Platform-specific :class:`BleakScanner` args for the CoreBluetooth backend.
"""
use_bdaddr: bool
"""
If true, use Bluetooth address instead of UUID.
.. warning:: This uses an undocumented IOBluetooth API to get the Bluetooth
address and may break in the future macOS releases. `It is known to not
work on macOS 10.15 <https://github.com/hbldh/bleak/issues/1286>`_.
"""
NotificationDiscriminator = Callable[[bytes], bool]
class CBStartNotifyArgs(TypedDict, total=False):
"""CoreBluetooth backend-specific dictionary of arguments for the
:meth:`bleak.BleakClient.start_notify` method.
"""
notification_discriminator: Optional[NotificationDiscriminator]
"""
A function that takes a single argument of a characteristic value
and returns ``True`` if the value is from a notification or
``False`` if the value is from a read response.
.. seealso:: :ref:`cb-notification-discriminator` for more info.
"""

View File

@ -0,0 +1,32 @@
"""
-----------------------
WinRT backend arguments
-----------------------
"""
from typing import Literal, TypedDict
class WinRTClientArgs(TypedDict, total=False):
"""
Windows-specific arguments for :class:`BleakClient`.
"""
address_type: Literal["public", "random"]
"""
Can either be ``"public"`` or ``"random"``, depending on the required address
type needed to connect to your device.
"""
use_cached_services: bool
"""
``True`` allows Windows to fetch the services, characteristics and descriptors
from the Windows cache instead of reading them from the device. Can be very
much faster for known, unchanging devices, but not recommended for DIY peripherals
where the GATT layout can change between connections.
``False`` will force the attribute database to be read from the remote device
instead of using the OS cache.
If omitted, the OS Bluetooth stack will do what it thinks is best.
"""

View File

@ -0,0 +1,93 @@
"""
Bluetooth Assigned Numbers
--------------------------
This module contains useful assigned numbers from the Bluetooth spec.
See <https://www.bluetooth.com/specifications/assigned-numbers/>.
"""
from enum import IntEnum
from typing import Literal
class AdvertisementDataType(IntEnum):
"""
Generic Access Profile advertisement data types.
`Source <https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Generic%20Access%20Profile.pdf>`.
.. versionadded:: 0.15
"""
FLAGS = 0x01
INCOMPLETE_LIST_SERVICE_UUID16 = 0x02
COMPLETE_LIST_SERVICE_UUID16 = 0x03
INCOMPLETE_LIST_SERVICE_UUID32 = 0x04
COMPLETE_LIST_SERVICE_UUID32 = 0x05
INCOMPLETE_LIST_SERVICE_UUID128 = 0x06
COMPLETE_LIST_SERVICE_UUID128 = 0x07
SHORTENED_LOCAL_NAME = 0x08
COMPLETE_LOCAL_NAME = 0x09
TX_POWER_LEVEL = 0x0A
CLASS_OF_DEVICE = 0x0D
SERVICE_DATA_UUID16 = 0x16
SERVICE_DATA_UUID32 = 0x20
SERVICE_DATA_UUID128 = 0x21
MANUFACTURER_SPECIFIC_DATA = 0xFF
# NOTE: these must match BlueZ name mapping
CharacteristicPropertyName = Literal[
"broadcast",
"read",
"write-without-response",
"write",
"notify",
"indicate",
"authenticated-signed-writes",
"extended-properties",
"reliable-write",
"writable-auxiliaries",
"encrypt-read",
"encrypt-write",
# "encrypt-notify" and "encrypt-indicate" are server-only
"encrypt-authenticated-read",
"encrypt-authenticated-write",
# "encrypt-authenticated-notify", "encrypt-authenticated-indicate",
# "secure-read", "secure-write", "secure-notify", "secure-indicate"
# are server-only
"authorize",
]
CHARACTERISTIC_PROPERTIES: dict[int, CharacteristicPropertyName] = {
0x1: "broadcast",
0x2: "read",
0x4: "write-without-response",
0x8: "write",
0x10: "notify",
0x20: "indicate",
0x40: "authenticated-signed-writes",
0x80: "extended-properties",
0x100: "reliable-write",
0x200: "writable-auxiliaries",
}
def gatt_char_props_to_strs(
props: int,
) -> frozenset[CharacteristicPropertyName]:
"""
Convert a GATT characteristic properties bitmask to a set of strings.
Args:
props: The GATT characteristic properties bitmask.
Returns:
A set of strings representing the GATT characteristic properties.
"""
return frozenset(
CHARACTERISTIC_PROPERTIES[i] for i in (1 << n for n in range(16)) if props & i
)

View File

@ -0,0 +1,75 @@
"""
Communicating with Bluetooth hardware requires calling OS-specific APIs. These
are abstracted as "backends" in Bleak.
The backend will be automatically selected based on the operating system Bleak
is running on. In some cases, this may also depend on a specific runtime, like
Pythonista on iOS.
"""
import enum
import os
import platform
import sys
from bleak.exc import BleakError
class BleakBackend(str, enum.Enum):
"""
Identifiers for available built-in Bleak backends.
.. versionadded:: 2.0
"""
P4ANDROID = "p4android"
"""
Python for Android backend.
"""
BLUEZ_DBUS = "bluez_dbus"
"""
BlueZ D-Bus backend for Linux.
"""
PYTHONISTA_CB = "pythonista_cb"
"""
Pythonista CoreBluetooth backend for iOS and macOS.
"""
CORE_BLUETOOTH = "core_bluetooth"
"""
CoreBluetooth backend for macOS.
"""
WIN_RT = "win_rt"
"""
Windows Runtime backend for Windows.
"""
def get_default_backend() -> BleakBackend:
"""
Returns the preferred backend for the current platform/environment.
.. versionadded:: 2.0
"""
if os.environ.get("P4A_BOOTSTRAP") is not None:
return BleakBackend.P4ANDROID
if platform.system() == "Linux":
return BleakBackend.BLUEZ_DBUS
if sys.platform == "ios" and "Pythonista3.app" in sys.executable:
# Must be resolved before checking for "Darwin" (macOS),
# as both the Pythonista app for iOS and macOS
# return "Darwin" from platform.system()
return BleakBackend.PYTHONISTA_CB
if platform.system() == "Darwin":
return BleakBackend.CORE_BLUETOOTH
if platform.system() == "Windows":
return BleakBackend.WIN_RT
raise BleakError(f"Unsupported platform: {platform.system()}")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
import asyncio
import logging
from collections.abc import Callable
from typing import Any, ParamSpec, TypeVar
from bleak._compat import TypeVarTuple, Unpack
_Ts = TypeVarTuple("_Ts")
logger = logging.getLogger(__name__)
def try_call_soon_threadsafe(
event_loop: asyncio.AbstractEventLoop,
callback: Callable[[Unpack[_Ts]], Any],
*args: Unpack[_Ts],
) -> None:
"""Call a callback on the event loop thread, handling closed loop errors gracefully."""
try:
event_loop.call_soon_threadsafe(callback, *args)
except RuntimeError:
# Likely caused by loop being closed
logger.debug("unraisable exception", exc_info=True) # pragma: no cover
_P = ParamSpec("_P")
_TReturn = TypeVar("_TReturn")
def external_thread_callback(func: Callable[_P, _TReturn]) -> Callable[_P, _TReturn]:
"""
Decorator for callbacks invoked from external non-Python thread.
This is a no-op placeholder that can be overridden in tests to enable
coverage tracing for external threads.
"""
return func

View File

@ -0,0 +1 @@
"""BlueZ backend."""

View File

@ -0,0 +1,131 @@
"""
Advertisement Monitor
---------------------
This module contains types associated with the BlueZ D-Bus `advertisement
monitor api <https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst>`.
"""
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "linux":
assert False, "This backend is only available on Linux"
import logging
from collections.abc import Iterable
from typing import Any, no_type_check
from warnings import warn
from dbus_fast import PropertyAccess
from dbus_fast.service import ServiceInterface, dbus_property, method
from bleak.args.bluez import OrPattern as _OrPattern
from bleak.args.bluez import OrPatternLike as _OrPatternLike
from bleak.backends.bluezdbus import defs
logger = logging.getLogger(__name__)
_DEPRECATED: dict[str, Any] = {
"OrPattern": _OrPattern,
"OrPatternLike": _OrPatternLike,
}
def __getattr__(name: str):
if value := _DEPRECATED.get(name):
warn(
f"importing {name} from bleak.backends.bluezdbus.advertisement_monitor is deprecated, use bleak.args.bluez instead",
DeprecationWarning,
stacklevel=2,
)
return value
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class AdvertisementMonitor(ServiceInterface):
"""
Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface.
The BlueZ advertisement monitor API design seems to be just for device
presence (is it in range or out of range), but this isn't really what
we want in Bleak, we want to monitor changes in advertisement data, just
like in active scanning.
So the only thing we are using here is the "or_patterns" since it is
currently required, but really we don't need that either. Hopefully an
"all" "Type" could be added to BlueZ in the future.
"""
def __init__(
self,
or_patterns: Iterable[_OrPatternLike],
):
"""
Args:
or_patterns:
List of or patterns that will be returned by the ``Patterns`` property.
"""
super().__init__(defs.ADVERTISEMENT_MONITOR_INTERFACE)
# dbus_fast marshaling requires list instead of tuple
self._or_patterns = [list(p) for p in or_patterns]
@method()
def Release(self):
logger.debug("Release")
@method()
def Activate(self):
logger.debug("Activate")
# REVISIT: mypy is broke, so we have to add redundant @no_type_check
# https://github.com/python/mypy/issues/6583
@method()
@no_type_check
def DeviceFound(self, device: "o"): # noqa: F821
if logger.isEnabledFor(logging.DEBUG):
logger.debug("DeviceFound %s", device)
@method()
@no_type_check
def DeviceLost(self, device: "o"): # noqa: F821
if logger.isEnabledFor(logging.DEBUG):
logger.debug("DeviceLost %s", device)
@dbus_property(PropertyAccess.READ)
@no_type_check
def Type(self) -> "s": # noqa: F821
# this is currently the only type supported in BlueZ
return "or_patterns"
@dbus_property(PropertyAccess.READ, disabled=True)
@no_type_check
def RSSILowThreshold(self) -> "n": # noqa: F821
...
@dbus_property(PropertyAccess.READ, disabled=True)
@no_type_check
def RSSIHighThreshold(self) -> "n": # noqa: F821
...
@dbus_property(PropertyAccess.READ, disabled=True)
@no_type_check
def RSSILowTimeout(self) -> "q": # noqa: F821
...
@dbus_property(PropertyAccess.READ, disabled=True)
@no_type_check
def RSSIHighTimeout(self) -> "q": # noqa: F821
...
@dbus_property(PropertyAccess.READ, disabled=True)
@no_type_check
def RSSISamplingPeriod(self) -> "q": # noqa: F821
...
@dbus_property(PropertyAccess.READ)
@no_type_check
def Patterns(self) -> "a(yyay)": # noqa: F821
return self._or_patterns

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
from typing import Literal, TypedDict
from bleak.assigned_numbers import CharacteristicPropertyName
# DBus Interfaces
OBJECT_MANAGER_INTERFACE = "org.freedesktop.DBus.ObjectManager"
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
# Bluez specific DBUS
BLUEZ_SERVICE = "org.bluez"
ADAPTER_INTERFACE = "org.bluez.Adapter1"
ADVERTISEMENT_MONITOR_INTERFACE = "org.bluez.AdvertisementMonitor1"
ADVERTISEMENT_MONITOR_MANAGER_INTERFACE = "org.bluez.AdvertisementMonitorManager1"
DEVICE_INTERFACE = "org.bluez.Device1"
BATTERY_INTERFACE = "org.bluez.Battery1"
# GATT interfaces
GATT_MANAGER_INTERFACE = "org.bluez.GattManager1"
GATT_PROFILE_INTERFACE = "org.bluez.GattProfile1"
GATT_SERVICE_INTERFACE = "org.bluez.GattService1"
GATT_CHARACTERISTIC_INTERFACE = "org.bluez.GattCharacteristic1"
GATT_DESCRIPTOR_INTERFACE = "org.bluez.GattDescriptor1"
# BlueZ error names
BLUEZ_ERROR_DOES_NOT_EXIST = "org.bluez.Error.DoesNotExist"
BLUEZ_ERROR_FAILED = "org.bluez.Error.Failed"
BLUEZ_ERROR_IMPROPERLY_CONFIGURED = "org.bluez.Error.ImproperlyConfigured"
BLUEZ_ERROR_IN_PROGRESS = "org.bluez.Error.InProgress"
BLUEZ_ERROR_INVALID_ARGUMENT = "org.bluez.Error.InvalidArguments"
BLUEZ_ERROR_INVALID_OFFSET = "org.bluez.Error.InvalidOffset"
BLUEZ_ERROR_INVALID_VALUE_LENGTH = "org.bluez.Error.InvalidValueLength"
BLUEZ_ERROR_NOT_AUTHORIZED = "org.bluez.Error.NotAuthorized"
BLUEZ_ERROR_NOT_PERMITTED = "org.bluez.Error.NotPermitted"
BLUEZ_ERROR_NOT_READY = "org.bluez.Error.NotReady"
BLUEZ_ERROR_NOT_SUPPORTED = "org.bluez.Error.NotSupported"
# D-Bus properties for interfaces
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst
class Adapter1(TypedDict):
Address: str
Name: str
Alias: str
Class: int
Powered: bool
Discoverable: bool
Pairable: bool
PairableTimeout: int
DiscoverableTimeout: int
Discovering: int
UUIDs: list[str]
Modalias: str
Roles: list[str]
ExperimentalFeatures: list[str]
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitor.rst
class AdvertisementMonitor1(TypedDict):
Type: str
RSSILowThreshold: int
RSSIHighThreshold: int
RSSILowTimeout: int
RSSIHighTimeout: int
RSSISamplingPeriod: int
Patterns: list[tuple[int, int, bytes]]
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.AdvertisementMonitorManager.rst
class AdvertisementMonitorManager1(TypedDict):
SupportedMonitorTypes: list[str]
SupportedFeatures: list[str]
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Battery.rst
class Battery1(TypedDict):
SupportedMonitorTypes: list[str]
SupportedFeatures: list[str]
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.Device.rst
class Device1(TypedDict):
Address: str
AddressType: str
Name: str
Icon: str
Class: int
Appearance: int
UUIDs: list[str]
Paired: bool
Bonded: bool
Connected: bool
Trusted: bool
Blocked: bool
WakeAllowed: bool
Alias: str
Adapter: str
LegacyPairing: bool
Modalias: str
RSSI: int
TxPower: int
ManufacturerData: dict[int, bytes]
ServiceData: dict[str, bytes]
ServicesResolved: bool
AdvertisingFlags: bytes
AdvertisingData: dict[int, bytes]
# https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst
class GattService1(TypedDict):
UUID: str
Primary: bool
Device: str
Includes: list[str]
# Handle is server-only and not available in Bleak
class GattCharacteristic1(TypedDict):
UUID: str
Service: str
Value: bytes
WriteAcquired: bool
NotifyAcquired: bool
Notifying: bool
Flags: list[CharacteristicPropertyName]
# "MTU" property was added in BlueZ 5.62.
# It may missing when operating with an older stack.
MTU: int
# Handle is server-only and not available in Bleak
class GattDescriptor1(TypedDict):
UUID: str
Characteristic: str
Value: bytes
Flags: list[
Literal[
"read",
"write",
"encrypt-read",
"encrypt-write",
"encrypt-authenticated-read",
"encrypt-authenticated-write",
# "secure-read" and "secure-write" are server-only and not available in Bleak
"authorize",
]
]
# Handle is server-only and not available in Bleak

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,239 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "linux":
assert False, "This backend is only available on Linux"
import logging
from collections.abc import Callable, Coroutine
from typing import Any, Literal, Optional
from warnings import warn
from dbus_fast import Variant
from bleak._compat import override
from bleak.args.bluez import BlueZDiscoveryFilters as _BlueZDiscoveryFilters
from bleak.args.bluez import BlueZScannerArgs as _BlueZScannerArgs
from bleak.backends.bluezdbus.defs import Device1
from bleak.backends.bluezdbus.manager import get_global_bluez_manager
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak.exc import BleakError
logger = logging.getLogger(__name__)
_DEPRECATED: dict[str, Any] = {
"BlueZDiscoveryFilters": _BlueZDiscoveryFilters,
"BlueZScannerArgs": _BlueZScannerArgs,
}
def __getattr__(name: str):
if value := _DEPRECATED.get(name):
warn(
f"importing {name} from bleak.backends.bluezdbus.scanner is deprecated, use bleak.args.bluez instead",
DeprecationWarning,
stacklevel=2,
)
return value
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class BleakScannerBlueZDBus(BaseBleakScanner):
"""The native Linux Bleak BLE Scanner.
For possible values for `filters`, see the parameters to the
``SetDiscoveryFilter`` method in the `BlueZ docs
<https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter>`_
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received. Specifying this
also enables scanning while the screen is off on Android.
scanning_mode:
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
**bluez:
Dictionary of arguments specific to the BlueZ backend.
"""
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[list[str]],
scanning_mode: Literal["active", "passive"],
*,
bluez: _BlueZScannerArgs,
**kwargs: Any,
):
super().__init__(detection_callback, service_uuids)
self._scanning_mode = scanning_mode
self._adapter = bluez.get("adapter")
# callback from manager for stopping scanning if it has been started
self._stop: Optional[Callable[[], Coroutine[Any, Any, None]]] = None
# Discovery filters
self._filters: dict[str, Variant] = {}
self._filters["Transport"] = Variant("s", "le")
self._filters["DuplicateData"] = Variant("b", False)
if self._service_uuids:
self._filters["UUIDs"] = Variant("as", self._service_uuids)
filters = bluez.get("filters")
if filters is not None:
self.set_scanning_filter(filters=filters)
self._or_patterns = bluez.get("or_patterns")
if self._scanning_mode == "passive" and service_uuids:
logger.warning(
"service uuid filtering is not implemented for passive scanning, use bluez or_patterns as a workaround"
)
if self._scanning_mode == "passive" and not self._or_patterns:
raise BleakError("passive scanning mode requires bluez or_patterns")
@override
async def start(self) -> None:
manager = await get_global_bluez_manager()
if self._adapter:
adapter_path = f"/org/bluez/{self._adapter}"
else:
adapter_path = manager.get_default_adapter()
self.seen_devices = {}
if self._scanning_mode == "passive":
assert self._or_patterns is not None # should be checked in __init__
self._stop = await manager.passive_scan(
adapter_path,
self._or_patterns,
self._handle_advertising_data,
self._handle_device_removed,
)
else:
self._stop = await manager.active_scan(
adapter_path,
self._filters,
self._handle_advertising_data,
self._handle_device_removed,
)
@override
async def stop(self) -> None:
if self._stop:
# avoid reentrancy
stop, self._stop = self._stop, None
await stop()
def set_scanning_filter(self, **kwargs: Any) -> None:
"""Sets OS level scanning filters for the BleakScanner.
For possible values for `filters`, see the parameters to the
``SetDiscoveryFilter`` method in the `BlueZ docs
<https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst#void-setdiscoveryfilterdict-filter>`_
See variant types here: <https://python-dbus-next.readthedocs.io/en/latest/type-system/>
Keyword Args:
filters (dict): A dict of filters to be applied on discovery.
"""
for k, v in kwargs.get("filters", {}).items():
if k == "UUIDs":
self._filters[k] = Variant("as", v)
elif k == "RSSI":
self._filters[k] = Variant("n", v)
elif k == "Pathloss":
self._filters[k] = Variant("n", v)
elif k == "Transport":
self._filters[k] = Variant("s", v)
elif k == "DuplicateData":
self._filters[k] = Variant("b", v)
elif k == "Discoverable":
self._filters[k] = Variant("b", v)
elif k == "Pattern":
self._filters[k] = Variant("s", v)
else:
logger.warning("Filter '%s' is not currently supported.", k)
# Helper methods
def _handle_advertising_data(self, path: str, props: Device1) -> None:
"""
Handles advertising data received from the BlueZ manager instance.
Args:
path: The D-Bus object path of the device.
props: The D-Bus object properties of the device.
"""
_service_uuids = props.get("UUIDs", [])
if not self.is_allowed_uuid(_service_uuids):
return
# Get all the information wanted to pack in the advertisement data
_local_name = props.get("Name")
_manufacturer_data = {
k: bytes(v) for k, v in props.get("ManufacturerData", {}).items()
}
_service_data = {k: bytes(v) for k, v in props.get("ServiceData", {}).items()}
# Get tx power data
tx_power = props.get("TxPower")
# Pack the advertisement data
advertisement_data = AdvertisementData(
local_name=_local_name,
manufacturer_data=_manufacturer_data,
service_data=_service_data,
service_uuids=_service_uuids,
tx_power=tx_power,
rssi=props.get("RSSI", -127),
platform_data=(path, props),
)
device = self.create_or_update_device(
path,
props["Address"],
# BlueZ generates a name based on the address if no name is available.
# To match other backends, we replace this with None.
(
None
if props["Alias"] == props["Address"].replace(":", "-")
else props["Alias"]
),
{"path": path, "props": props},
advertisement_data,
)
self.call_detection_callbacks(device, advertisement_data)
def _handle_device_removed(self, device_path: str) -> None:
"""
Handles a device being removed from BlueZ.
"""
try:
del self.seen_devices[device_path]
except KeyError:
# The device will not have been added to self.seen_devices if no
# advertising data was received, so this is expected to happen
# occasionally.
pass

View File

@ -0,0 +1,212 @@
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "linux":
assert False, "This backend is only available on Linux"
import re
from typing import Any, Optional
from dbus_fast.aio.message_bus import MessageBus
from dbus_fast.errors import InvalidObjectPathError
from dbus_fast.message import Message
from dbus_fast.validators import (
assert_interface_name_valid,
assert_member_name_valid,
assert_object_path_valid,
)
# TODO: this stuff should be improved and submitted upstream to dbus-next
# https://github.com/altdesktop/python-dbus-next/issues/53
_message_types = ["signal", "method_call", "method_return", "error"]
class InvalidMessageTypeError(TypeError):
def __init__(self, type: str):
super().__init__(f"invalid message type: {type}")
def is_message_type_valid(type: str) -> bool:
"""Whether this is a valid message type.
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
:param type: The message type to validate.
:type name: str
:returns: Whether the name is a valid message type.
:rtype: bool
"""
return type in _message_types
def assert_bus_name_valid(type: str) -> None:
"""Raise an error if this is not a valid message type.
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
:param type: The message type to validate.
:type name: str
:raises:
- :class:`InvalidMessageTypeError` - If this is not a valid message type.
"""
if not is_message_type_valid(type):
raise InvalidMessageTypeError(type)
class MatchRules:
"""D-Bus signal match rules.
.. seealso:: https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules
"""
def __init__(
self,
type: str = "signal",
sender: Optional[str] = None,
interface: Optional[str] = None,
member: Optional[str] = None,
path: Optional[str] = None,
path_namespace: Optional[str] = None,
destination: Optional[str] = None,
arg0namespace: Optional[str] = None,
**kwargs: Any,
):
assert_bus_name_valid(type)
self.type: str = type
if sender:
assert_bus_name_valid(sender)
self.sender: Optional[str] = sender
else:
self.sender = None
if interface:
assert_interface_name_valid(interface)
self.interface: Optional[str] = interface
else:
self.interface = None
if member:
assert_member_name_valid(member)
self.member: Optional[str] = member
else:
self.member = None
if path:
assert_object_path_valid(path)
self.path: Optional[str] = path
else:
self.path = None
if path_namespace:
assert_object_path_valid(path_namespace)
self.path_namespace: Optional[str] = path_namespace
else:
self.path_namespace = None
if path and path_namespace:
raise TypeError(
"message rules cannot have both 'path' and 'path_namespace' at the same time"
)
if destination:
assert_bus_name_valid(destination)
self.destination: Optional[str] = destination
else:
self.destination = None
if arg0namespace:
assert_bus_name_valid(arg0namespace)
self.arg0namespace: Optional[str] = arg0namespace
else:
self.arg0namespace = None
if kwargs:
for k, v in kwargs.items():
if re.match(r"^arg\d+$", k):
if not isinstance(v, str):
raise TypeError(f"kwarg '{k}' must have a str value")
elif re.match(r"^arg\d+path$", k):
if not isinstance(v, str):
raise InvalidObjectPathError(v)
assert_object_path_valid(v[:-1] if v.endswith("/") else v)
else:
raise ValueError("kwargs must be in the form 'arg0' or 'arg0path'")
self.args: Optional[dict[str, str]] = kwargs
else:
self.args = None
@staticmethod
def parse(rules: str) -> MatchRules:
return MatchRules(**dict(r.split("=") for r in rules.split(",")))
def __str__(self) -> str:
rules = [f"type={self.type}"]
if self.sender:
rules.append(f"sender={self.sender}")
if self.interface:
rules.append(f"interface={self.interface}")
if self.member:
rules.append(f"member={self.member}")
if self.path:
rules.append(f"path={self.path}")
if self.path_namespace:
rules.append(f"path_namespace={self.path_namespace}")
if self.destination:
rules.append(f"destination={self.destination}")
if self.args:
for k, v in self.args.items():
rules.append(f"{k}={v}")
if self.arg0namespace:
rules.append(f"arg0namespace={self.arg0namespace}")
return ",".join(rules)
def __repr__(self) -> str:
return f"MatchRules({self})"
async def add_match(bus: MessageBus, rules: MatchRules) -> Message:
"""Calls org.freedesktop.DBus.AddMatch using ``rules``."""
reply = await bus.call(
Message(
destination="org.freedesktop.DBus",
interface="org.freedesktop.DBus",
path="/org/freedesktop/DBus",
member="AddMatch",
signature="s",
body=[str(rules)],
)
)
return reply
async def remove_match(bus: MessageBus, rules: MatchRules) -> Message:
"""Calls org.freedesktop.DBus.RemoveMatch using ``rules``."""
reply = await bus.call(
Message(
destination="org.freedesktop.DBus",
interface="org.freedesktop.DBus",
path="/org/freedesktop/DBus",
member="RemoveMatch",
signature="s",
body=[str(rules)],
)
)
return reply

View File

@ -0,0 +1,147 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "linux":
assert False, "This backend is only available on Linux"
import os
import re
from typing import Optional
from dbus_fast.auth import AuthExternal
from dbus_fast.constants import MessageType
from dbus_fast.message import Message
from bleak.backends.bluezdbus import defs
from bleak.exc import (
BleakDBusError,
BleakError,
BleakGATTProtocolError,
BleakGATTProtocolErrorCode,
)
def assert_reply(reply: Message) -> None:
"""Checks that a D-Bus message is a valid reply.
Raises:
BleakDBusError: if the message type is ``MessageType.ERROR``
AssertionError: if the message type is not ``MessageType.METHOD_RETURN``
"""
if reply.message_type == MessageType.ERROR:
assert reply.error_name
raise BleakDBusError(reply.error_name, reply.body)
assert reply.message_type == MessageType.METHOD_RETURN
def assert_gatt_reply(reply: Message, start_notify: bool = False) -> None:
"""
Checks that a D-Bus message is a valid reply.
Like :func:`assert_reply`, but has special handling for GATT protocol errors.
Args:
reply: The D-Bus message to check.
start_notify: Whether this reply is for a StartNotify call.
Raises:
BleakGATTProtocolError: for specific GATT protocol errors.
BleakDBusError: if the message type is ``MessageType.ERROR``
AssertionError: if the message type is not ``MessageType.METHOD_RETURN``
"""
# BlueZ has specific errors for some GATT protocol errors, so we
# have to turn them back into the generic BleakGATTProtocolError
# with the correct code. See create_gatt_dbus_error() in BlueZ source.
if reply.error_name == defs.BLUEZ_ERROR_NOT_PERMITTED:
# Same error is used for both read and write not permitted, so we have
# to use the message to discriminate.
if reply.body and reply.body[0] == "Read not permitted":
raise BleakGATTProtocolError(BleakGATTProtocolErrorCode.READ_NOT_PERMITTED)
if reply.body and reply.body[0] == "Write not permitted":
raise BleakGATTProtocolError(BleakGATTProtocolErrorCode.WRITE_NOT_PERMITTED)
# REVISIT: could also be "Not paired" which could be any of:
# INSUFFICIENT_AUTHENTICATION, INSUFFICIENT_ENCRYPTION, or
# INSUFFICIENT_ENCRYPTION_KEY_SIZE
# "StartNotify" will return BLUEZ_ERROR_NOT_SUPPORTED if the characteristic
# does not support notifications before even trying, so it is not a GATT
# error in this case.
if not start_notify and reply.error_name == defs.BLUEZ_ERROR_NOT_SUPPORTED:
raise BleakGATTProtocolError(BleakGATTProtocolErrorCode.REQUEST_NOT_SUPPORTED)
if reply.error_name == defs.BLUEZ_ERROR_NOT_AUTHORIZED:
raise BleakGATTProtocolError(
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION
)
if reply.error_name == defs.BLUEZ_ERROR_INVALID_ARGUMENT:
if reply.body and reply.body[0] == "Invalid offset":
raise BleakGATTProtocolError(BleakGATTProtocolErrorCode.INVALID_OFFSET)
if reply.body and reply.body[0] == "Invalid Length":
raise BleakGATTProtocolError(
BleakGATTProtocolErrorCode.INVALID_ATTRIBUTE_VALUE_LENGTH
)
if reply.error_name == defs.BLUEZ_ERROR_IMPROPERLY_CONFIGURED:
raise BleakGATTProtocolError(
BleakGATTProtocolErrorCode.CCCD_IMPROPERLY_CONFIGURED
)
if (
reply.error_name == defs.BLUEZ_ERROR_FAILED
and reply.body
and (
# Unfortunately, BlueZ makes us scrape the string to get the
# error code in this case.
match := re.match(
r"^Operation failed with ATT error: (0x[0-9a-fA-F]+)",
reply.body[0],
)
)
):
raise BleakGATTProtocolError(
BleakGATTProtocolErrorCode(int(match.group(1), 16))
)
assert_reply(reply)
def extract_service_handle_from_path(path: str) -> int:
try:
return int(path[-4:], 16)
except Exception as e:
raise BleakError(f"Could not parse service handle from path: {path}") from e
def device_path_from_characteristic_path(characteristic_path: str) -> str:
"""
Scrape the device path from a D-Bus characteristic path.
Args:
characteristic_path: The D-Bus object path of the characteristic.
Returns:
A D-Bus object path of the device.
"""
# /org/bluez/hci1/dev_FA_23_9D_AA_45_46/service000c/char000d
return characteristic_path[:-21]
def get_dbus_authenticator() -> Optional[AuthExternal]:
uid = None
try:
uid = int(os.environ.get("BLEAK_DBUS_AUTH_UID", ""))
except ValueError:
pass
auth = None
if uid is not None:
auth = AuthExternal(uid=uid)
return auth

View File

@ -0,0 +1,60 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "linux":
assert False, "This backend is only available on Linux"
import asyncio
import contextlib
import logging
import re
from typing import Optional
logger = logging.getLogger(__name__)
async def _get_bluetoothctl_version() -> Optional[re.Match[bytes]]:
"""Get the version of bluetoothctl."""
with contextlib.suppress(Exception):
proc = await asyncio.create_subprocess_exec(
"bluetoothctl", "--version", stdout=asyncio.subprocess.PIPE
)
assert proc.stdout
out = await proc.stdout.read()
version = re.search(b"(\\d+).(\\d+)", out.strip(b"'"))
await proc.wait()
return version
return None
class BlueZFeatures:
"""Check which features are supported by the BlueZ backend."""
checked_bluez_version = False
supported_version = True
_check_bluez_event: Optional[asyncio.Event] = None
@classmethod
async def check_bluez_version(cls) -> None:
"""Check the bluez version."""
if cls._check_bluez_event:
# If there is already a check in progress
# it wins, wait for it instead
await cls._check_bluez_event.wait()
return
cls._check_bluez_event = asyncio.Event()
version_output = await _get_bluetoothctl_version()
if version_output:
major, minor = tuple(map(int, version_output.groups()))
cls.supported_version = major == 5 and minor >= 55
else:
# Its possible they may be running inside a container where
# bluetoothctl is not available and they only have access to the
# BlueZ D-Bus API.
logger.warning(
"Could not determine BlueZ version, bluetoothctl not available, assuming 5.55+"
)
cls._check_bluez_event.set()
cls.checked_bluez_version = True

View File

@ -0,0 +1,157 @@
# Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
"""
Interface class for the Bleak representation of a GATT Characteristic
"""
from __future__ import annotations
import enum
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Union
from uuid import UUID
from bleak.assigned_numbers import CharacteristicPropertyName
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.uuids import normalize_uuid_str, uuidstr_to_str
# to prevent circular import
if TYPE_CHECKING:
from bleak.backends.service import BleakGATTService
class GattCharacteristicsFlags(enum.Enum):
broadcast = 0x0001
read = 0x0002
write_without_response = 0x0004
write = 0x0008
notify = 0x0010
indicate = 0x0020
authenticated_signed_writes = 0x0040
extended_properties = 0x0080
reliable_write = 0x0100
writable_auxiliaries = 0x0200
class BleakGATTCharacteristic:
"""The Bleak representation of a GATT Characteristic"""
def __init__(
self,
obj: Any,
handle: int,
uuid: str,
properties: list[CharacteristicPropertyName],
max_write_without_response_size: Callable[[], int],
service: BleakGATTService,
):
"""
Args:
obj:
A platform-specific object for this characteristic.
max_write_without_response_size:
The maximum size in bytes that can be written to the
characteristic in a single write without response command.
service:
The service this characteristic belongs to.
"""
self.obj = obj
self._handle = handle
self._uuid = uuid
self._properties = properties
self._max_write_without_response_size = max_write_without_response_size
self._service = service
self._descriptors: dict[int, BleakGATTDescriptor] = {}
def __str__(self):
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
@property
def service_uuid(self) -> str:
"""The UUID of the Service containing this characteristic"""
return self._service.uuid
@property
def service_handle(self) -> int:
"""The integer handle of the Service containing this characteristic"""
return self._service.handle
@property
def handle(self) -> int:
"""The handle for this characteristic"""
return self._handle
@property
def uuid(self) -> str:
"""The UUID for this characteristic"""
return self._uuid
@property
def description(self) -> str:
"""Description for this characteristic"""
return uuidstr_to_str(self.uuid)
@property
def properties(self) -> list[CharacteristicPropertyName]:
"""Properties of this characteristic"""
return self._properties
@property
def max_write_without_response_size(self) -> int:
"""
Gets the maximum size in bytes that can be used for the *data* argument
of :meth:`BleakClient.write_gatt_char()` when ``response=False``.
In rare cases, a device may take a long time to update this value, so
reading this property may return the default value of ``20`` and reading
it again after a some time may return the expected higher value.
If you *really* need to wait for a higher value, you can do something
like this:
.. code-block:: python
async with asyncio.timeout(10):
while char.max_write_without_response_size == 20:
await asyncio.sleep(0.5)
.. warning:: Linux quirk: For BlueZ versions < 5.62, this property
will always return ``20``.
.. versionadded:: 0.16
"""
# for backwards compatibility
if isinstance(self._max_write_without_response_size, int):
return self._max_write_without_response_size
return self._max_write_without_response_size()
@property
def descriptors(self) -> list[BleakGATTDescriptor]:
"""List of descriptors for this service"""
return list(self._descriptors.values())
def get_descriptor(
self, specifier: Union[int, str, UUID]
) -> Union[BleakGATTDescriptor, None]:
"""Get a descriptor by handle (int) or UUID (str or uuid.UUID)"""
if isinstance(specifier, int):
return self._descriptors.get(specifier)
uuid = normalize_uuid_str(str(specifier))
for descriptor in self._descriptors.values():
if descriptor.uuid == uuid:
return descriptor
return None
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
"""Add a :py:class:`~BleakGATTDescriptor` to the characteristic.
Should not be used by end user, but rather by `bleak` itself.
"""
if descriptor.handle in self._descriptors:
raise ValueError(
f"Descriptor with handle {descriptor.handle} already exists"
)
self._descriptors[descriptor.handle] = descriptor

View File

@ -0,0 +1,258 @@
# Created on 2018-04-23 by hbldh <henrik.blidh@nedomkull.com>
"""
Base class for backend clients.
"""
import abc
from collections.abc import Callable
from typing import Any, Optional, Union
from bleak.args import SizedBuffer
from bleak.backends import BleakBackend, get_default_backend
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTServiceCollection
from bleak.exc import BleakError
NotifyCallback = Callable[[bytearray], None]
class BaseBleakClient(abc.ABC):
"""The Client Interface for Bleak Backend implementations to implement.
The documentation of this interface should thus be safe to use as a reference for your implementation.
Args:
address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it.
Keyword Args:
timeout (float): Timeout for required ``discover`` call.
disconnected_callback (callable): Callback that will be scheduled in the
event loop when the client is disconnected. The callable must take one
argument, which will be this client object.
"""
def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs: Any):
if isinstance(address_or_ble_device, BLEDevice):
self.address = address_or_ble_device.address
else:
self.address = address_or_ble_device
self.services: Optional[BleakGATTServiceCollection] = None
self._timeout = kwargs["timeout"]
self._disconnected_callback: Optional[Callable[[], None]] = kwargs.get(
"disconnected_callback"
)
# NB: this is not marked as @abc.abstractmethod because that would break
# 3rd-party backends. We might change this in the future to make it required.
@property
def name(self) -> str:
"""See :meth:`bleak.BleakClient.name`."""
raise NotImplementedError
@property
@abc.abstractmethod
def mtu_size(self) -> int:
"""Gets the negotiated MTU."""
raise NotImplementedError
# Connectivity methods
def set_disconnected_callback(
self, callback: Optional[Callable[[], None]], **kwargs: Any
) -> None:
"""Set the disconnect callback.
The callback will only be called on unsolicited disconnect event.
Set the callback to ``None`` to remove any existing callback.
Args:
callback: callback to be called on disconnection.
"""
self._disconnected_callback = callback
@abc.abstractmethod
async def connect(self, pair: bool, **kwargs: Any) -> None:
"""Connect to the specified GATT server.
Args:
pair (bool): If the client should attempt to pair with the
peripheral before connecting if it is not already paired.
Backends that can't implement this should make an appropriate
log message and ignore the parameter.
"""
raise NotImplementedError()
@abc.abstractmethod
async def disconnect(self) -> None:
"""Disconnect from the specified GATT server."""
raise NotImplementedError()
@abc.abstractmethod
async def pair(self, *args: Any, **kwargs: Any) -> None:
"""Pair with the peripheral."""
raise NotImplementedError()
@abc.abstractmethod
async def unpair(self) -> None:
"""Unpair with the peripheral."""
raise NotImplementedError()
@property
@abc.abstractmethod
def is_connected(self) -> bool:
"""Check connection status between this client and the server.
Returns:
Boolean representing connection status.
"""
raise NotImplementedError()
# I/O methods
@abc.abstractmethod
async def read_gatt_char(
self,
characteristic: BleakGATTCharacteristic,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT characteristic.
Args:
characteristic (BleakGATTCharacteristic): The characteristic to read from.
use_cached: If True, read the cached value instead of performing a read from the device.
Returns:
(bytearray) The read data.
"""
raise NotImplementedError()
@abc.abstractmethod
async def read_gatt_descriptor(
self,
descriptor: BleakGATTDescriptor,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT descriptor.
Args:
descriptor: The descriptor to read from.
use_cached: If True, read the cached value instead of performing a read from the device.
Returns:
The read data.
"""
raise NotImplementedError()
@abc.abstractmethod
async def write_gatt_char(
self, characteristic: BleakGATTCharacteristic, data: SizedBuffer, response: bool
) -> None:
"""
Perform a write operation on the specified GATT characteristic.
Args:
characteristic: The characteristic to write to.
data: The data to send.
response: If write-with-response operation should be done.
"""
raise NotImplementedError()
@abc.abstractmethod
async def write_gatt_descriptor(
self, descriptor: BleakGATTDescriptor, data: SizedBuffer
) -> None:
"""Perform a write operation on the specified GATT descriptor.
Args:
descriptor: The descriptor to read from.
data: The data to send (any bytes-like object).
"""
raise NotImplementedError()
@abc.abstractmethod
async def start_notify(
self,
characteristic: BleakGATTCharacteristic,
callback: NotifyCallback,
**kwargs: Any,
) -> None:
"""
Activate notifications/indications on a characteristic.
Implementers should call the OS function to enable notifications or
indications on the characteristic.
To keep things the same cross-platform, notifications should be preferred
over indications if possible when a characteristic supports both.
"""
raise NotImplementedError()
@abc.abstractmethod
async def stop_notify(self, characteristic: BleakGATTCharacteristic) -> None:
"""Deactivate notification/indication on a specified characteristic.
Args:
characteristic (BleakGATTCharacteristic): The characteristic to deactivate
notification/indication on.
"""
raise NotImplementedError()
def get_platform_client_backend_type() -> tuple[type[BaseBleakClient], BleakBackend]:
"""
Gets the platform-specific :class:`BaseBleakClient` type.
"""
backend = get_default_backend()
match backend:
case BleakBackend.P4ANDROID:
from bleak.backends.p4android.client import (
BleakClientP4Android, # type: ignore
)
return (BleakClientP4Android, backend) # type: ignore
case BleakBackend.BLUEZ_DBUS:
from bleak.backends.bluezdbus.client import (
BleakClientBlueZDBus, # type: ignore
)
return (BleakClientBlueZDBus, backend) # type: ignore
case BleakBackend.PYTHONISTA_CB:
try:
from bleak_pythonista import BleakClientPythonistaCB # type: ignore
return (BleakClientPythonistaCB, backend) # type: ignore
except ImportError as e:
raise ImportError(
"Ensure you have `bleak-pythonista` package installed."
) from e
case BleakBackend.CORE_BLUETOOTH:
from bleak.backends.corebluetooth.client import (
BleakClientCoreBluetooth, # type: ignore
)
return (BleakClientCoreBluetooth, backend) # type: ignore
case BleakBackend.WIN_RT:
from bleak.backends.winrt.client import BleakClientWinRT # type: ignore
return (BleakClientWinRT, backend) # type: ignore
case _:
raise BleakError(f"Unsupported backend: {backend}")

View File

@ -0,0 +1,402 @@
# Created on June, 25 2019 by kevincar <kevincarrolldavis@gmail.com>
"""
CentralManagerDelegate will implement the CBCentralManagerDelegate protocol to
manage CoreBluetooth services and resources on the Central End
"""
from __future__ import annotations
import sys
import weakref
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
import asyncio
import logging
from collections.abc import Callable
from typing import Any, Optional, TypedDict, cast
import objc
from CoreBluetooth import (
CBUUID,
CBCentralManager,
CBManagerAuthorizationDenied,
CBManagerAuthorizationRestricted,
CBManagerStatePoweredOff,
CBManagerStatePoweredOn,
CBManagerStateResetting,
CBManagerStateUnauthorized,
CBManagerStateUnsupported,
CBPeripheral,
)
from Foundation import (
NSUUID,
NSArray,
NSData,
NSDictionary,
NSError,
NSNumber,
NSObject,
NSString,
)
from libdispatch import DISPATCH_QUEUE_SERIAL, dispatch_queue_create
from bleak._compat import Self
from bleak._compat import timeout as async_timeout
from bleak.backends._utils import external_thread_callback, try_call_soon_threadsafe
from bleak.backends.corebluetooth.utils import cb_manager_state_message
from bleak.exc import (
BleakBluetoothNotAvailableError,
BleakBluetoothNotAvailableReason,
BleakError,
)
logger = logging.getLogger(__name__)
CBCentralManagerDelegate = objc.protocolNamed("CBCentralManagerDelegate")
DisconnectCallback = Callable[[], None]
class CBAdvertisementData(TypedDict, total=False):
kCBAdvDataLocalName: NSString
kCBAdvDataManufacturerData: NSData
kCBAdvDataServiceData: dict[CBUUID, NSData]
kCBAdvDataServiceUUIDs: NSArray[CBUUID]
kCBAdvertisementDataOverflowServiceUUIDsKey: NSArray[CBUUID]
kCBAdvDataTxPowerLevel: NSNumber
kCBAdvertisementDataIsConnectable: NSNumber
kCBAdvDataOverflowServiceUUIDs: NSArray[CBUUID]
class ObjcCentralManagerDelegate(NSObject, protocols=[CBCentralManagerDelegate]):
"""
CoreBluetooth central manager delegate for bridging callbacks to asyncio.
"""
def initWithPyDelegate_(
self, py_delegate: weakref.ReferenceType["CentralManagerDelegate"]
) -> Optional[Self]:
"""macOS init function for NSObject"""
self = objc.super(ObjcCentralManagerDelegate, self).init() # type: ignore[assignment]
if self is None:
return None # pragma: no cover
self.py_delegate = py_delegate
return self
# Protocol Functions
@external_thread_callback
def centralManagerDidUpdateState_(self, centralManager: CBCentralManager) -> None:
logger.debug("centralManagerDidUpdateState_")
logger.debug(cb_manager_state_message(centralManager.state()))
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_update_state_event.set,
)
@external_thread_callback
def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(
self,
central: CBCentralManager,
peripheral: CBPeripheral,
advertisementData: NSDictionary[str, Any],
RSSI: NSNumber,
) -> None:
logger.debug("centralManager_didDiscoverPeripheral_advertisementData_RSSI_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_discover_peripheral,
central,
peripheral,
advertisementData,
RSSI,
)
@external_thread_callback
def centralManager_didConnectPeripheral_(
self, central: CBCentralManager, peripheral: CBPeripheral
) -> None:
logger.debug("centralManager_didConnectPeripheral_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_connect_peripheral,
central,
peripheral,
)
@external_thread_callback
def centralManager_didFailToConnectPeripheral_error_(
self,
centralManager: CBCentralManager,
peripheral: CBPeripheral,
error: Optional[NSError],
) -> None:
logger.debug("centralManager_didFailToConnectPeripheral_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_fail_to_connect_peripheral,
centralManager,
peripheral,
error,
)
@external_thread_callback
def centralManager_didDisconnectPeripheral_error_(
self,
central: CBCentralManager,
peripheral: CBPeripheral,
error: Optional[NSError],
) -> None:
logger.debug("centralManager_didDisconnectPeripheral_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_disconnect_peripheral,
central,
peripheral,
error,
)
class CentralManagerDelegate:
"""
macOS conforming python class for managing the CentralManger for BLE
Before this object can be used, the :method:`wait_until_ready` method has to
be called. This can take a while, when the OS asks the user for permissions.
"""
def __init__(self) -> None:
"""macOS init function for NSObject"""
delegate = ObjcCentralManagerDelegate.alloc().initWithPyDelegate_(
weakref.ref(self)
)
assert delegate is not None
self.objc_delegate = delegate
self.event_loop = asyncio.get_running_loop()
self._connect_futures: dict[NSUUID, asyncio.Future[bool]] = {}
self.callbacks: dict[
int,
Callable[[CBPeripheral, CBAdvertisementData, NSNumber], None],
] = {}
self._disconnect_callbacks: dict[NSUUID, DisconnectCallback] = {}
self._disconnect_futures: dict[NSUUID, asyncio.Future[None]] = {}
self.did_update_state_event = asyncio.Event()
self.central_manager = CBCentralManager.alloc().initWithDelegate_queue_(
self.objc_delegate,
dispatch_queue_create(b"bleak.corebluetooth", DISPATCH_QUEUE_SERIAL),
)
# User defined functions
@objc.python_method
async def wait_until_ready(self):
# According to CoreBluetooth docs, it is not valid to call CBCentral
# methods until the centralManagerDidUpdateState_() delegate method
# is called and the current state is CBManagerStatePoweredOn.
# Wait until the callback occurs. This normally should not take too long,
# but if the app currently has no permission to access the Bluetooth peripheral,
# there is automatically a dialog shown by the OS. The user has to accept or deny
# the Bluetooth access. This may take infinite time until the user clicks something.
await self.did_update_state_event.wait()
state = self.central_manager.state()
if state == CBManagerStateUnsupported:
raise BleakBluetoothNotAvailableError(
"Bluetooth is unsupported",
BleakBluetoothNotAvailableReason.NO_BLUETOOTH,
)
elif state == CBManagerStateUnauthorized:
authorization = self.central_manager.authorization()
if authorization == CBManagerAuthorizationDenied:
raise BleakBluetoothNotAvailableError(
"Bluetooth access is denied by the user for the current application. Check macOS privacy settings.",
BleakBluetoothNotAvailableReason.DENIED_BY_USER,
)
elif authorization == CBManagerAuthorizationRestricted:
raise BleakBluetoothNotAvailableError(
"Bluetooth access is restricted for the current application, e.g. by parental controls. Ask the admin to remove this restriction.",
BleakBluetoothNotAvailableReason.DENIED_BY_SYSTEM,
)
else:
raise BleakBluetoothNotAvailableError(
"Bluetooth is not authorized for an unknown reason. Check macOS privacy settings.",
BleakBluetoothNotAvailableReason.DENIED_BY_UNKNOWN,
)
elif state == CBManagerStatePoweredOff:
raise BleakBluetoothNotAvailableError(
"Bluetooth device is turned off",
BleakBluetoothNotAvailableReason.POWERED_OFF,
)
elif state == CBManagerStateResetting:
raise BleakBluetoothNotAvailableError(
"Connection to the Bluetooth system service was lost. Currently trying to reconnect...",
BleakBluetoothNotAvailableReason.UNKNOWN,
)
elif state != CBManagerStatePoweredOn:
raise BleakBluetoothNotAvailableError(
"Bluetooth state is unknown",
BleakBluetoothNotAvailableReason.UNKNOWN,
)
async def start_scan(self, service_uuids: Optional[list[str]]) -> None:
_service_uuids = (
NSArray[CBUUID]
.alloc()
.initWithArray_(list(map(CBUUID.UUIDWithString_, service_uuids)))
if service_uuids
else None
)
self.central_manager.scanForPeripheralsWithServices_options_(
_service_uuids, None
)
async def stop_scan(self) -> None:
self.central_manager.stopScan()
async def connect(
self,
peripheral: CBPeripheral,
disconnect_callback: DisconnectCallback,
timeout: float = 10.0,
) -> None:
try:
self._disconnect_callbacks[peripheral.identifier()] = disconnect_callback
future = self.event_loop.create_future()
self._connect_futures[peripheral.identifier()] = future
try:
self.central_manager.connectPeripheral_options_(peripheral, None)
async with async_timeout(timeout):
await future
finally:
del self._connect_futures[peripheral.identifier()]
except asyncio.TimeoutError:
logger.debug(f"Connection timed out after {timeout} seconds.")
del self._disconnect_callbacks[peripheral.identifier()]
future = self.event_loop.create_future()
self._disconnect_futures[peripheral.identifier()] = future
try:
self.central_manager.cancelPeripheralConnection_(peripheral)
await future
finally:
del self._disconnect_futures[peripheral.identifier()]
raise
async def disconnect(self, peripheral: CBPeripheral) -> None:
future = self.event_loop.create_future()
self._disconnect_futures[peripheral.identifier()] = future
try:
self.central_manager.cancelPeripheralConnection_(peripheral)
await future
finally:
del self._disconnect_futures[peripheral.identifier()]
# Protocol Functions
def did_discover_peripheral(
self,
central: CBCentralManager,
peripheral: CBPeripheral,
advertisementData: NSDictionary[str, Any],
RSSI: NSNumber,
) -> None:
# Note: this function might be called several times for same device.
# This can happen for instance when an active scan is done, and the
# second call with contain the data from the BLE scan response.
# Example a first time with the following keys in advertisementData:
# ['kCBAdvDataLocalName', 'kCBAdvDataIsConnectable', 'kCBAdvDataChannel']
# ... and later a second time with other keys (and values) such as:
# ['kCBAdvDataServiceUUIDs', 'kCBAdvDataIsConnectable', 'kCBAdvDataChannel']
#
# i.e it is best not to trust advertisementData for later use and data
# from it should be copied.
#
# This behaviour could be affected by the
# CBCentralManagerScanOptionAllowDuplicatesKey global setting.
uuid_string = peripheral.identifier().UUIDString()
for callback in self.callbacks.values():
callback(peripheral, cast(CBAdvertisementData, advertisementData), RSSI)
logger.debug(
"Discovered device %s: %s @ RSSI: %d (kCBAdvData %r) and Central: %r",
uuid_string,
peripheral.name(),
RSSI,
advertisementData.keys(),
central,
)
def did_connect_peripheral(
self, central: CBCentralManager, peripheral: CBPeripheral
) -> None:
future = self._connect_futures.get(peripheral.identifier(), None)
if future is not None:
future.set_result(True)
def did_fail_to_connect_peripheral(
self,
centralManager: CBCentralManager,
peripheral: CBPeripheral,
error: Optional[NSError],
) -> None:
future = self._connect_futures.get(peripheral.identifier(), None)
if future is not None:
if error is not None:
future.set_exception(BleakError(f"failed to connect: {error}"))
else:
future.set_result(False)
def did_disconnect_peripheral(
self,
central: CBCentralManager,
peripheral: CBPeripheral,
error: Optional[NSError],
) -> None:
logger.debug("Peripheral Device disconnected!")
future = self._disconnect_futures.get(peripheral.identifier(), None)
if future is not None:
if error is not None:
future.set_exception(BleakError(f"disconnect failed: {error}"))
else:
future.set_result(None)
callback = self._disconnect_callbacks.pop(peripheral.identifier(), None)
if callback is not None:
callback()

View File

@ -0,0 +1,727 @@
"""
PeripheralDelegate
Created by kevincar <kevincarrolldavis@gmail.com>
"""
from __future__ import annotations
import sys
import weakref
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
import asyncio
import itertools
import logging
from collections.abc import Iterable
from typing import Any, Optional
import objc
from CoreBluetooth import (
CBUUID,
CBATTErrorDomain,
CBCharacteristic,
CBCharacteristicWriteType,
CBCharacteristicWriteWithResponse,
CBDescriptor,
CBPeripheral,
CBService,
)
from Foundation import NSUUID, NSArray, NSData, NSError, NSNumber, NSObject
from bleak._compat import Self
from bleak._compat import timeout as async_timeout
from bleak.args.corebluetooth import NotificationDiscriminator
from bleak.backends._utils import external_thread_callback, try_call_soon_threadsafe
from bleak.backends.client import NotifyCallback
from bleak.exc import BleakError, BleakGATTProtocolError
logger = logging.getLogger(__name__)
CBPeripheralDelegate = objc.protocolNamed("CBPeripheralDelegate")
class ObjcPeripheralDelegate(NSObject, protocols=[CBPeripheralDelegate]):
"""
CoreBluetooth peripheral manager delegate for bridging callbacks to asyncio.
"""
def initWithPyDelegate_(
self, py_delegate: weakref.ReferenceType["PeripheralDelegate"]
) -> Optional[Self]:
"""macOS init function for NSObject"""
self = objc.super(ObjcPeripheralDelegate, self).init() # type: ignore[assignment]
if self is None:
return None # pragma: no cover
self.py_delegate = py_delegate
return self
# Protocol Functions
@external_thread_callback
def peripheral_didDiscoverServices_(
self, peripheral: CBPeripheral, error: Optional[NSError]
) -> None:
logger.debug("peripheral_didDiscoverServices_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_discover_services,
peripheral,
peripheral.services(),
error,
)
@external_thread_callback
def peripheral_didDiscoverIncludedServicesForService_error_( # pragma: no cover
self, peripheral: CBPeripheral, service: CBService, error: Optional[NSError]
) -> None:
logger.debug("peripheral_didDiscoverIncludedServicesForService_error_")
# Currently not used in Bleak
@external_thread_callback
def peripheral_didDiscoverCharacteristicsForService_error_(
self, peripheral: CBPeripheral, service: CBService, error: Optional[NSError]
) -> None:
logger.debug("peripheral_didDiscoverCharacteristicsForService_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_discover_characteristics_for_service,
peripheral,
service,
service.characteristics(),
error,
)
@external_thread_callback
def peripheral_didDiscoverDescriptorsForCharacteristic_error_(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didDiscoverDescriptorsForCharacteristic_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_discover_descriptors_for_characteristic,
peripheral,
characteristic,
error,
)
@external_thread_callback
def peripheral_didUpdateValueForCharacteristic_error_(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didUpdateValueForCharacteristic_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_update_value_for_characteristic,
peripheral,
characteristic,
characteristic.value(),
error,
)
@external_thread_callback
def peripheral_didUpdateValueForDescriptor_error_(
self,
peripheral: CBPeripheral,
descriptor: CBDescriptor,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didUpdateValueForDescriptor_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_update_value_for_descriptor,
peripheral,
descriptor,
descriptor.value(),
error,
)
@external_thread_callback
def peripheral_didWriteValueForCharacteristic_error_(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didWriteValueForCharacteristic_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_write_value_for_characteristic,
peripheral,
characteristic,
error,
)
@external_thread_callback
def peripheral_didWriteValueForDescriptor_error_(
self,
peripheral: CBPeripheral,
descriptor: CBDescriptor,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didWriteValueForDescriptor_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_write_value_for_descriptor,
peripheral,
descriptor,
error,
)
@external_thread_callback
def peripheralIsReadyToSendWriteWithoutResponse_( # pragma: no cover
self, peripheral: CBPeripheral
) -> None:
logger.debug("peripheralIsReadyToSendWriteWithoutResponse_")
# Currently not used in Bleak
@external_thread_callback
def peripheral_didUpdateNotificationStateForCharacteristic_error_(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didUpdateNotificationStateForCharacteristic_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_update_notification_for_characteristic,
peripheral,
characteristic,
error,
)
@external_thread_callback
def peripheral_didReadRSSI_error_(
self,
peripheral: CBPeripheral,
rssi: NSNumber,
error: Optional[NSError],
) -> None:
logger.debug("peripheral_didReadRSSI_error_")
if (py_delegate := self.py_delegate()) is None:
return
try_call_soon_threadsafe(
py_delegate.event_loop,
py_delegate.did_read_rssi,
peripheral,
int(rssi),
error,
)
@external_thread_callback
def peripheralDidUpdateName_( # pragma: no cover
self, peripheral: CBPeripheral
) -> None:
logger.debug("peripheralDidUpdateName_")
logger.debug(
f"name of {peripheral.identifier()} changed to {peripheral.name()}"
)
# Currently not used in Bleak
@external_thread_callback
def peripheral_didModifyServices_( # pragma: no cover
self, peripheral: CBPeripheral, invalidatedServices: NSArray[CBService]
) -> None:
logger.debug("peripheral_didModifyServices_")
logger.debug(
f"{peripheral.identifier()} invalidated services: {invalidatedServices}"
)
# Currently not used in Bleak
class PeripheralDelegate:
"""macOS conforming python class for managing the PeripheralDelegate for BLE"""
def __init__(self, peripheral: CBPeripheral) -> None:
delegate = ObjcPeripheralDelegate.alloc().initWithPyDelegate_(weakref.ref(self))
assert delegate is not None
self.objc_delegate = delegate
self.peripheral = peripheral
self.peripheral.setDelegate_(self.objc_delegate)
self.event_loop = asyncio.get_running_loop()
self._services_discovered_future = self.event_loop.create_future()
self._service_characteristic_discovered_futures: dict[
int, asyncio.Future[NSArray[CBCharacteristic]]
] = {}
self._characteristic_descriptor_discover_futures: dict[
int, asyncio.Future[None]
] = {}
self._characteristic_read_futures: dict[int, asyncio.Future[NSData]] = {}
self._characteristic_write_futures: dict[int, asyncio.Future[None]] = {}
self._descriptor_read_futures: dict[int, asyncio.Future[NSObject]] = {}
self._descriptor_write_futures: dict[int, asyncio.Future[None]] = {}
self._characteristic_notify_change_futures: dict[int, asyncio.Future[None]] = {}
self._characteristic_notify_callbacks: dict[int, NotifyCallback] = {}
self._characteristic_notification_discriminators: dict[
int, Optional[NotificationDiscriminator]
] = {}
self._read_rssi_futures: dict[NSUUID, asyncio.Future[int]] = {}
def futures(self) -> Iterable[asyncio.Future[Any]]:
"""
Gets all futures for this delegate.
These can be used to handle any pending futures when a peripheral is disconnected.
"""
services_discovered_future = (
(self._services_discovered_future,)
if hasattr(self, "_services_discovered_future")
else ()
)
return itertools.chain(
services_discovered_future,
self._service_characteristic_discovered_futures.values(),
self._characteristic_descriptor_discover_futures.values(),
self._characteristic_read_futures.values(),
self._characteristic_write_futures.values(),
self._descriptor_read_futures.values(),
self._descriptor_write_futures.values(),
self._characteristic_notify_change_futures.values(),
self._read_rssi_futures.values(),
)
async def discover_services(
self, services: Optional[NSArray[CBUUID]] = None
) -> NSArray[CBService]:
future = self.event_loop.create_future()
self._services_discovered_future = future
try:
self.peripheral.discoverServices_(services)
return await future
finally:
del self._services_discovered_future
async def discover_characteristics(
self, service: CBService
) -> NSArray[CBCharacteristic]:
future = self.event_loop.create_future()
self._service_characteristic_discovered_futures[service.startHandle()] = future
try:
self.peripheral.discoverCharacteristics_forService_(None, service)
return await future
finally:
del self._service_characteristic_discovered_futures[service.startHandle()]
async def discover_descriptors(
self, characteristic: CBCharacteristic
) -> NSArray[CBDescriptor]:
future = self.event_loop.create_future()
self._characteristic_descriptor_discover_futures[characteristic.handle()] = (
future
)
try:
self.peripheral.discoverDescriptorsForCharacteristic_(characteristic)
await future
finally:
del self._characteristic_descriptor_discover_futures[
characteristic.handle()
]
return characteristic.descriptors()
async def read_characteristic(
self,
characteristic: CBCharacteristic,
use_cached: bool,
timeout: int = 20,
) -> NSData:
value = characteristic.value()
if value is not None and use_cached:
return value
future = self.event_loop.create_future()
self._characteristic_read_futures[characteristic.handle()] = future
try:
self.peripheral.readValueForCharacteristic_(characteristic)
async with async_timeout(timeout):
return await future
finally:
del self._characteristic_read_futures[characteristic.handle()]
async def read_descriptor(
self, descriptor: CBDescriptor, use_cached: bool = True
) -> Any:
value = descriptor.value()
if value is not None and use_cached:
return value
future = self.event_loop.create_future()
self._descriptor_read_futures[descriptor.handle()] = future
try:
self.peripheral.readValueForDescriptor_(descriptor)
return await future
finally:
del self._descriptor_read_futures[descriptor.handle()]
async def write_characteristic(
self,
characteristic: CBCharacteristic,
value: NSData,
response: CBCharacteristicWriteType,
) -> None:
# in CoreBluetooth there is no indication of success or failure of
# CBCharacteristicWriteWithoutResponse
if response == CBCharacteristicWriteWithResponse:
future = self.event_loop.create_future()
self._characteristic_write_futures[characteristic.handle()] = future
try:
self.peripheral.writeValue_forCharacteristic_type_(
value, characteristic, response
)
await future
finally:
del self._characteristic_write_futures[characteristic.handle()]
else:
self.peripheral.writeValue_forCharacteristic_type_(
value, characteristic, response
)
async def write_descriptor(self, descriptor: CBDescriptor, value: NSData) -> None:
future = self.event_loop.create_future()
self._descriptor_write_futures[descriptor.handle()] = future
try:
self.peripheral.writeValue_forDescriptor_(value, descriptor)
await future
finally:
del self._descriptor_write_futures[descriptor.handle()]
async def start_notifications(
self,
characteristic: CBCharacteristic,
callback: NotifyCallback,
notification_discriminator: Optional[NotificationDiscriminator] = None,
) -> None:
c_handle = characteristic.handle()
if c_handle in self._characteristic_notify_callbacks:
raise ValueError("Characteristic notifications already started")
self._characteristic_notify_callbacks[c_handle] = callback
self._characteristic_notification_discriminators[c_handle] = (
notification_discriminator
)
future = self.event_loop.create_future()
self._characteristic_notify_change_futures[c_handle] = future
try:
self.peripheral.setNotifyValue_forCharacteristic_(True, characteristic)
await future
finally:
del self._characteristic_notify_change_futures[c_handle]
async def stop_notifications(self, characteristic: CBCharacteristic) -> None:
c_handle = characteristic.handle()
if c_handle not in self._characteristic_notify_callbacks:
raise ValueError("Characteristic notification never started")
future = self.event_loop.create_future()
self._characteristic_notify_change_futures[c_handle] = future
try:
self.peripheral.setNotifyValue_forCharacteristic_(False, characteristic)
await future
finally:
del self._characteristic_notify_change_futures[c_handle]
self._characteristic_notify_callbacks.pop(c_handle)
self._characteristic_notification_discriminators.pop(c_handle)
async def read_rssi(self) -> int:
future = self.event_loop.create_future()
self._read_rssi_futures[self.peripheral.identifier()] = future
try:
self.peripheral.readRSSI()
return await future
finally:
del self._read_rssi_futures[self.peripheral.identifier()]
# Protocol Functions
def did_discover_services(
self,
peripheral: CBPeripheral,
services: NSArray[CBService],
error: Optional[NSError],
) -> None:
future = self._services_discovered_future
if error is not None:
exception = BleakError(f"Failed to discover services {error}")
future.set_exception(exception)
else:
logger.debug("Services discovered")
future.set_result(services)
def did_discover_characteristics_for_service(
self,
peripheral: CBPeripheral,
service: CBService,
characteristics: NSArray[CBCharacteristic],
error: Optional[NSError],
) -> None:
future = self._service_characteristic_discovered_futures.get(
service.startHandle()
)
if not future:
logger.debug(
f"Unexpected event didDiscoverCharacteristicsForService for {service.startHandle()}"
)
return
if error is not None:
exception = BleakError(
f"Failed to discover characteristics for service {service.startHandle()}: {error}"
)
future.set_exception(exception)
else:
logger.debug("Characteristics discovered")
future.set_result(characteristics)
def did_discover_descriptors_for_characteristic(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
future = self._characteristic_descriptor_discover_futures.get(
characteristic.handle()
)
if not future:
logger.warning(
f"Unexpected event didDiscoverDescriptorsForCharacteristic for {characteristic.handle()}"
)
return
if error is not None:
exception = BleakError(
f"Failed to discover descriptors for characteristic {characteristic.handle()}: {error}"
)
future.set_exception(exception)
else:
logger.debug(f"Descriptor discovered {characteristic.handle()}")
future.set_result(None)
def did_update_value_for_characteristic(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
value: Optional[NSData],
error: Optional[NSError],
) -> None:
c_handle = characteristic.handle()
future = self._characteristic_read_futures.get(c_handle)
# If error is set, then we know this was a read response.
# Otherwise, if there is a pending read request, we can't tell if this is a read response or notification.
# If the user provided a notification discriminator, we can use that to
# identify if this callback is due to a notification by analyzing the value.
# If not, and there is a future (pending read request), we assume it is a read response but can't know for sure.
if not error:
assert value is not None
notification_discriminator = (
self._characteristic_notification_discriminators.get(c_handle)
)
if not future or (
notification_discriminator and notification_discriminator(bytes(value))
):
notify_callback = self._characteristic_notify_callbacks.get(c_handle)
if notify_callback:
notify_callback(bytearray(value))
return
if not future:
logger.warning(
"Unexpected event didUpdateValueForCharacteristic for 0x%04x with value: %r and error: %r",
c_handle,
value,
error,
)
return
if error is not None:
exception = (
BleakGATTProtocolError(error.code())
if error.domain() == CBATTErrorDomain
else BleakError(f"Failed to read characteristic {c_handle}: {error}")
)
future.set_exception(exception)
else:
logger.debug("Read characteristic value")
assert value is not None
future.set_result(value)
def did_update_value_for_descriptor(
self,
peripheral: CBPeripheral,
descriptor: CBDescriptor,
value: Optional[Any],
error: Optional[NSError],
) -> None:
future = self._descriptor_read_futures.get(descriptor.handle())
if not future:
logger.warning("Unexpected event didUpdateValueForDescriptor")
return
if error is not None:
exception = (
BleakGATTProtocolError(error.code())
if error.domain() == CBATTErrorDomain
else BleakError(
f"Failed to read descriptor {descriptor.handle()}: {error}"
)
)
future.set_exception(exception)
else:
logger.debug("Read descriptor value")
assert value is not None
future.set_result(value)
def did_write_value_for_characteristic(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
future = self._characteristic_write_futures.get(characteristic.handle(), None)
if not future:
return # event only expected on write with response
if error is not None:
exception = (
BleakGATTProtocolError(error.code())
if error.domain() == CBATTErrorDomain
else BleakError(
f"Failed to write characteristic {characteristic.handle()}: {error}"
)
)
future.set_exception(exception)
else:
logger.debug("Write Characteristic Value")
future.set_result(None)
def did_write_value_for_descriptor(
self,
peripheral: CBPeripheral,
descriptor: CBDescriptor,
error: Optional[NSError],
) -> None:
future = self._descriptor_write_futures.get(descriptor.handle())
if not future:
logger.warning("Unexpected event didWriteValueForDescriptor")
return
if error is not None:
exception = (
BleakGATTProtocolError(error.code())
if error.domain() == CBATTErrorDomain
else BleakError(
f"Failed to write descriptor {descriptor.handle()}: {error}"
)
)
future.set_exception(exception)
else:
logger.debug("Write Descriptor Value")
future.set_result(None)
def did_update_notification_for_characteristic(
self,
peripheral: CBPeripheral,
characteristic: CBCharacteristic,
error: Optional[NSError],
) -> None:
c_handle = characteristic.handle()
future = self._characteristic_notify_change_futures.get(c_handle)
if not future:
logger.warning(
"Unexpected event didUpdateNotificationStateForCharacteristic"
)
return
if error is not None:
exception = BleakError(
f"Failed to update the notification status for characteristic {c_handle}: {error}"
)
future.set_exception(exception)
else:
logger.debug("Character Notify Update")
future.set_result(None)
def did_read_rssi(
self, peripheral: CBPeripheral, rssi: int, error: Optional[NSError]
) -> None:
future = self._read_rssi_futures.get(peripheral.identifier(), None)
if not future:
logger.warning("Unexpected event did_read_rssi")
return
if error is not None:
exception = BleakError(f"Failed to read RSSI: {error}")
future.set_exception(exception)
else:
future.set_result(rssi)

View File

@ -0,0 +1,14 @@
# Created on 2017-11-19 by hbldh <henrik.blidh@nedomkull.com>
"""
__init__.py
"""
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
import objc
objc.options.verbose = True

View File

@ -0,0 +1,402 @@
# Created on 2019-06-26 by kevincar <kevincarrolldavis@gmail.com>
"""
BLE Client for CoreBluetooth on macOS
"""
import functools
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
import asyncio
import logging
from typing import Any, Optional, Union
from CoreBluetooth import (
CBUUID,
CBCharacteristicWriteWithoutResponse,
CBCharacteristicWriteWithResponse,
CBPeripheral,
CBPeripheralStateConnected,
)
from Foundation import NSArray, NSData
from bleak import BleakScanner
from bleak._compat import override
from bleak.args import SizedBuffer
from bleak.args.corebluetooth import CBStartNotifyArgs
from bleak.assigned_numbers import gatt_char_props_to_strs
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.client import BaseBleakClient, NotifyCallback
from bleak.backends.corebluetooth.CentralManagerDelegate import CentralManagerDelegate
from bleak.backends.corebluetooth.PeripheralDelegate import PeripheralDelegate
from bleak.backends.corebluetooth.scanner import BleakScannerCoreBluetooth
from bleak.backends.corebluetooth.utils import (
cb_uuid_to_str,
is_descriptor_nsnumber,
is_descriptor_nsstring,
)
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.backends.device import BLEDevice
from bleak.backends.service import BleakGATTService, BleakGATTServiceCollection
from bleak.exc import BleakDeviceNotFoundError, BleakError
logger = logging.getLogger(__name__)
class BleakClientCoreBluetooth(BaseBleakClient):
"""CoreBluetooth class interface for BleakClient
Args:
address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it.
services: Optional set of service UUIDs that will be used.
"""
def __init__(
self,
address_or_ble_device: Union[BLEDevice, str],
services: Optional[set[str]] = None,
**kwargs: Any,
):
super().__init__(address_or_ble_device, **kwargs)
self._peripheral: Optional[CBPeripheral] = None
self._delegate: Optional[PeripheralDelegate] = None
self._central_manager_delegate: Optional[CentralManagerDelegate] = None
if isinstance(address_or_ble_device, BLEDevice):
(
self._peripheral,
self._central_manager_delegate,
) = address_or_ble_device.details
self._requested_services = (
NSArray[CBUUID]
.alloc()
.initWithArray_(list(map(CBUUID.UUIDWithString_, services)))
if services
else None
)
def __str__(self) -> str:
return f"BleakClientCoreBluetooth ({self.address})"
@override
async def connect(self, pair: bool, **kwargs: Any) -> None:
"""Connect to a specified Peripheral
Keyword Args:
timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call.
"""
if pair:
logger.debug("Explicit pairing is not available in CoreBluetooth.")
timeout = kwargs.get("timeout", self._timeout)
if self._peripheral is None:
device = await BleakScanner.find_device_by_address(
self.address, timeout=timeout, backend=BleakScannerCoreBluetooth
)
if device:
self._peripheral, self._central_manager_delegate = device.details
else:
raise BleakDeviceNotFoundError(
self.address, f"Device with address {self.address} was not found"
)
if self._delegate is None:
assert self._peripheral is not None
self._delegate = PeripheralDelegate(self._peripheral)
def disconnect_callback() -> None:
# Ensure that `get_services` retrieves services again, rather
# than using the cached object
self.services = None
assert self._delegate is not None
# If there are any pending futures waiting for delegate callbacks, we
# need to raise an exception since the callback will no longer be
# called because the device is disconnected.
for future in self._delegate.futures():
try:
future.set_exception(BleakError("disconnected"))
except asyncio.InvalidStateError:
# the future was already done
pass
if self._disconnected_callback:
self._disconnected_callback()
manager = self._central_manager_delegate
assert manager is not None
logger.debug("CentralManagerDelegate at %r", manager)
logger.debug("Connecting to BLE device @ %s", self.address)
assert self._peripheral is not None
await manager.connect(self._peripheral, disconnect_callback, timeout=timeout)
# Now get services
await self._get_services()
@override
async def disconnect(self) -> None:
"""Disconnect from the peripheral device"""
if (
self._peripheral is None
or self._peripheral.state() != CBPeripheralStateConnected
):
return
assert self._central_manager_delegate
await self._central_manager_delegate.disconnect(self._peripheral)
@property
@override
def is_connected(self) -> bool:
"""Checks for current active connection"""
return (
False
if self._peripheral is None
else self._peripheral.state() == CBPeripheralStateConnected
)
@property
@override
def name(self) -> str:
"""Get the name of the connected peripheral"""
if self._peripheral is None:
raise BleakError("Not connected")
return self._peripheral.name()
@property
@override
def mtu_size(self) -> int:
"""Get ATT MTU size for active connection"""
# Use type CBCharacteristicWriteWithoutResponse to get maximum write
# value length based on the negotiated ATT MTU size. Add the ATT header
# length (+3) to get the actual ATT MTU size.
assert self._peripheral
return (
self._peripheral.maximumWriteValueLengthForType_(
CBCharacteristicWriteWithoutResponse
)
+ 3
)
@override
async def pair(self, *args: Any, **kwargs: Any) -> None:
"""Attempt to pair with a peripheral.
Raises:
NotImplementedError:
This is not available on macOS since there is not explicit API
to do a pairing. Instead, the docs state that it "auto-pairs",
when trying to read a characteristic that requires encryption.
Reference:
- `Apple Docs <https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral/BestPracticesForSettingUpYourIOSDeviceAsAPeripheral.html#//apple_ref/doc/uid/TP40013257-CH5-SW1>`_
- `Stack Overflow post #1 <https://stackoverflow.com/questions/25254932/can-you-pair-a-bluetooth-le-device-in-an-ios-app>`_
- `Stack Overflow post #2 <https://stackoverflow.com/questions/47546690/ios-bluetooth-pairing-request-dialog-can-i-know-the-users-choice>`_
"""
raise NotImplementedError("Pairing is not available in Core Bluetooth.")
@override
async def unpair(self) -> None:
"""
Remove pairing information for a peripheral.
Raises:
NotImplementedError:
This is not available on macOS since there is not explicit API
to do a pairing.
"""
raise NotImplementedError("Pairing is not available in Core Bluetooth.")
async def _get_services(self) -> BleakGATTServiceCollection:
"""Get all services registered for this GATT server.
Returns:
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
"""
if self.services is not None:
return self.services
services = BleakGATTServiceCollection()
logger.debug("Retrieving services...")
assert self._delegate
assert self._peripheral
cb_services = await self._delegate.discover_services(self._requested_services)
for service in cb_services:
serv = BleakGATTService(
service, service.startHandle(), cb_uuid_to_str(service.UUID())
)
services.add_service(serv)
serviceUUID = service.UUID().UUIDString()
logger.debug("Retrieving characteristics for service %s", serviceUUID)
characteristics = await self._delegate.discover_characteristics(service)
for characteristic in characteristics:
cUUID = characteristic.UUID().UUIDString()
logger.debug("Retrieving descriptors for characteristic %s", cUUID)
char = BleakGATTCharacteristic(
characteristic,
characteristic.handle(),
cb_uuid_to_str(characteristic.UUID()),
list(gatt_char_props_to_strs(characteristic.properties())),
functools.partial(
self._peripheral.maximumWriteValueLengthForType_,
CBCharacteristicWriteWithoutResponse,
),
serv,
)
services.add_characteristic(char)
descriptors = await self._delegate.discover_descriptors(characteristic)
for descriptor in descriptors:
desc = BleakGATTDescriptor(
descriptor,
int(descriptor.handle()),
cb_uuid_to_str(descriptor.UUID()),
char,
)
services.add_descriptor(desc)
logger.debug("Services resolved for %s", str(self))
self.services = services
return self.services
@override
async def read_gatt_char(
self,
characteristic: BleakGATTCharacteristic,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT characteristic.
Args:
characteristic (BleakGATTCharacteristic): The characteristic to read from.
Returns:
(bytearray) The read data.
"""
assert self._delegate
output = await self._delegate.read_characteristic(
characteristic.obj, use_cached=use_cached
)
value = bytearray(output)
logger.debug("Read Characteristic %s: %r", characteristic.uuid, value)
return value
@override
async def read_gatt_descriptor(
self,
descriptor: BleakGATTDescriptor,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT descriptor.
Args:
handle (int): The handle of the descriptor to read from.
use_cached (bool): `False` forces Windows to read the value from the
device again and not use its own cached value. Defaults to `False`.
Returns:
(bytearray) The read data.
"""
assert self._delegate
output = await self._delegate.read_descriptor(
descriptor.obj, use_cached=use_cached
)
if is_descriptor_nsnumber(output, descriptor.uuid):
value = bytearray(int(output).to_bytes(2, byteorder="little"))
elif is_descriptor_nsstring(output, descriptor.uuid):
value = bytearray(output.encode())
else:
value = bytearray(output)
logger.debug("Read Descriptor %d : %r", descriptor.handle, value)
return value
@override
async def write_gatt_char(
self, characteristic: BleakGATTCharacteristic, data: SizedBuffer, response: bool
) -> None:
value = NSData.alloc().initWithBytes_length_(data, len(data))
assert self._delegate
await self._delegate.write_characteristic(
characteristic.obj,
value,
(
CBCharacteristicWriteWithResponse
if response
else CBCharacteristicWriteWithoutResponse
),
)
logger.debug(f"Write Characteristic {characteristic.uuid} : {data}")
@override
async def write_gatt_descriptor(
self, descriptor: BleakGATTDescriptor, data: SizedBuffer
) -> None:
"""Perform a write operation on the specified GATT descriptor.
Args:
descriptor: The descriptor to read from.
data: The data to send (any bytes-like object).
"""
assert self._delegate
value = NSData.alloc().initWithBytes_length_(data, len(data))
await self._delegate.write_descriptor(descriptor.obj, value)
logger.debug("Write Descriptor %d : %r", descriptor.handle, data)
@override
async def start_notify(
self,
characteristic: BleakGATTCharacteristic,
callback: NotifyCallback,
**kwargs: Any,
) -> None:
"""
Activate notifications/indications on a characteristic.
"""
assert self._delegate is not None
cb: CBStartNotifyArgs = kwargs["cb"]
await self._delegate.start_notifications(
characteristic.obj,
callback,
cb.get("notification_discriminator"),
)
@override
async def stop_notify(self, characteristic: BleakGATTCharacteristic) -> None:
"""Deactivate notification/indication on a specified characteristic.
Args:
characteristic (BleakGATTCharacteristic: The characteristic to deactivate
notification/indication on.
"""
assert self._delegate
await self._delegate.stop_notifications(characteristic.obj)
async def get_rssi(self) -> int:
"""To get RSSI value in dBm of the connected Peripheral"""
assert self._delegate
return int(await self._delegate.read_rssi())

View File

@ -0,0 +1,180 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
import logging
from typing import Any, Literal, Optional, cast
from warnings import warn
import objc
from CoreBluetooth import CBPeripheral
from Foundation import NSBundle, NSNumber
from bleak._compat import override
from bleak.args.corebluetooth import CBScannerArgs as _CBScannerArgs
from bleak.backends.corebluetooth.CentralManagerDelegate import (
CBAdvertisementData,
CentralManagerDelegate,
)
from bleak.backends.corebluetooth.utils import (
cb_uuid_to_str,
to_optional_int,
to_optional_str,
)
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak.exc import BleakError
logger = logging.getLogger(__name__)
def __getattr__(name: str):
if name == "CBScannerArgs":
warn(
"importing CBScannerArgs from bleak.backends.corebluetooth.scanner is deprecated, use bleak.args.corebluetooth instead",
DeprecationWarning,
stacklevel=2,
)
return _CBScannerArgs
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class BleakScannerCoreBluetooth(BaseBleakScanner):
"""The native macOS Bleak BLE Scanner.
Documentation:
https://developer.apple.com/documentation/corebluetooth/cbcentralmanager
CoreBluetooth doesn't explicitly use Bluetooth addresses to identify peripheral
devices because private devices may obscure their Bluetooth addresses. To cope
with this, CoreBluetooth utilizes UUIDs for each peripheral. Bleak uses
this for the BLEDevice address on macOS.
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received. Required on
macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``).
scanning_mode:
Set to ``"passive"`` to avoid the ``"active"`` scanning mode. Not
supported on macOS! Will raise :class:`BleakError` if set to
``"passive"``
**timeout (float):
The scanning timeout to be used, in case of missing
``stopScan_`` method.
"""
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[list[str]],
scanning_mode: Literal["active", "passive"],
*,
cb: _CBScannerArgs,
**kwargs: Any,
):
super().__init__(detection_callback, service_uuids)
self._use_bdaddr = cb.get("use_bdaddr", False)
if scanning_mode == "passive":
raise BleakError("macOS does not support passive scanning")
self._manager = CentralManagerDelegate()
self._timeout: float = kwargs.get("timeout", 5.0)
if (
objc.macos_available(12, 0)
and not objc.macos_available(12, 3)
and not self._service_uuids
):
# See https://github.com/hbldh/bleak/issues/720
if NSBundle.mainBundle().bundleIdentifier() == "org.python.python":
logger.error(
"macOS 12.0, 12.1 and 12.2 require non-empty service_uuids kwarg, otherwise no advertisement data will be received"
)
@override
async def start(self) -> None:
await self._manager.wait_until_ready()
self.seen_devices = {}
def callback(
peripheral: CBPeripheral, adv_data: CBAdvertisementData, rssi: NSNumber
) -> None:
service_uuids = [
cb_uuid_to_str(u) for u in adv_data.get("kCBAdvDataServiceUUIDs", [])
]
if not self.is_allowed_uuid(service_uuids):
return
# Process service data
service_data = {
cb_uuid_to_str(k): bytes(v)
for k, v in adv_data.get("kCBAdvDataServiceData", {}).items()
}
# Process manufacturer data into a more friendly format
manufacturer_binary_data = adv_data.get("kCBAdvDataManufacturerData")
manufacturer_data: dict[int, bytes] = {}
if manufacturer_binary_data:
manufacturer_id = int.from_bytes(
manufacturer_binary_data[0:2], byteorder="little"
)
manufacturer_value = bytes(manufacturer_binary_data[2:])
manufacturer_data[manufacturer_id] = manufacturer_value
advertisement_data = AdvertisementData(
local_name=to_optional_str(adv_data.get("kCBAdvDataLocalName")),
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
tx_power=to_optional_int(adv_data.get("kCBAdvDataTxPowerLevel")),
rssi=int(rssi),
platform_data=(peripheral, adv_data, rssi),
)
if self._use_bdaddr:
# HACK: retrieveAddressForPeripheral_ is undocumented but seems to do the trick
address_bytes = cast(
Optional[bytes],
self._manager.central_manager.retrieveAddressForPeripheral_(peripheral), # type: ignore
)
if address_bytes is None:
logger.debug(
"Could not get Bluetooth address for %s. Ignoring this device.",
peripheral.identifier().UUIDString(),
)
return
address = address_bytes.hex(":").upper()
else:
address = peripheral.identifier().UUIDString()
device = self.create_or_update_device(
peripheral.identifier().UUIDString(),
address,
peripheral.name(),
(peripheral, self._manager),
advertisement_data,
)
self.call_detection_callbacks(device, advertisement_data)
self._manager.callbacks[id(self)] = callback
await self._manager.start_scan(self._service_uuids)
@override
async def stop(self) -> None:
await self._manager.stop_scan()
self._manager.callbacks.pop(id(self), None)

View File

@ -0,0 +1,123 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "darwin":
assert False, "This backend is only available on macOS"
from typing import Any, Optional, TypeGuard, overload
from CoreBluetooth import (
CBUUID,
CBManagerState,
CBManagerStatePoweredOff,
CBManagerStatePoweredOn,
CBManagerStateResetting,
CBManagerStateUnauthorized,
CBManagerStateUnknown,
CBManagerStateUnsupported,
CBUUIDCharacteristicExtendedPropertiesString,
CBUUIDCharacteristicUserDescriptionString,
CBUUIDClientCharacteristicConfigurationString,
CBUUIDServerCharacteristicConfigurationString,
)
from Foundation import NSNumber, NSString
from bleak.uuids import normalize_uuid_str
def cb_uuid_to_str(uuid: CBUUID) -> str:
"""Converts a CoreBluetooth UUID to a Python string.
If ``uuid`` is a 16-bit UUID, it is assumed to be a Bluetooth GATT UUID
(``0000xxxx-0000-1000-8000-00805f9b34fb``).
Args
uuid: The UUID.
Returns:
The UUID as a lower case Python string (``xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx``)
"""
return normalize_uuid_str(uuid.UUIDString())
@overload
def to_optional_str(value: NSString) -> str: ...
@overload
def to_optional_str(value: None) -> None: ...
def to_optional_str(value: Optional[NSString]) -> Optional[str]:
"""Converts an NSString to a Python string or None.
Args:
value: The NSString or None.
Returns:
The Python string or None.
"""
if value is None:
return None
return str(value)
@overload
def to_optional_int(value: NSNumber) -> int: ...
@overload
def to_optional_int(value: None) -> None: ...
def to_optional_int(value: Optional[NSNumber]) -> Optional[int]:
"""Converts an NSNumber to a Python int or None.
Args:
value: The NSNumber or None.
Returns:
The Python int or None.
"""
if value is None:
return None
return int(value)
# Most descriptors are returned as NSData (raw bytes), but some of them
# are returned as NSNumber or NSString.
# See: https://developer.apple.com/documentation/corebluetooth/characteristic-descriptors
_DESCRIPTOR_TYPE_NSNUMBER = (
normalize_uuid_str(CBUUIDCharacteristicExtendedPropertiesString), # 0x2900
normalize_uuid_str(CBUUIDClientCharacteristicConfigurationString), # 0x2902
normalize_uuid_str(CBUUIDServerCharacteristicConfigurationString), # 0x2903
)
_DESCRIPTOR_TYPE_NSSTRING = (
normalize_uuid_str(CBUUIDCharacteristicUserDescriptionString), # 0x2901
)
def is_descriptor_nsnumber(value: Any, descriptor_uuid: str) -> TypeGuard[NSNumber]:
"""Check if descriptor value is returned as NSNumber by CoreBluetooth."""
return descriptor_uuid in _DESCRIPTOR_TYPE_NSNUMBER
def is_descriptor_nsstring(value: Any, descriptor_uuid: str) -> TypeGuard[NSString]:
"""Check if descriptor value is returned as NSString by CoreBluetooth."""
return descriptor_uuid in _DESCRIPTOR_TYPE_NSSTRING
_CB_MANAGER_STATE_MESSAGE = {
CBManagerStateUnknown: "Bluetooth in unknown state",
CBManagerStateResetting: "Bluetooth is resetting",
CBManagerStateUnsupported: "Bluetooth is unsupported",
CBManagerStateUnauthorized: "Bluetooth is unauthorized",
CBManagerStatePoweredOff: "Bluetooth powered off",
CBManagerStatePoweredOn: "Bluetooth powered on",
}
def cb_manager_state_message(state: CBManagerState) -> str:
"""Get log message for CoreBluetooth manager state."""
return _CB_MANAGER_STATE_MESSAGE.get(
state, f"Unknown CBManagerState value: {state}"
)

View File

@ -0,0 +1,154 @@
# Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
"""
Interface class for the Bleak representation of a GATT Descriptor
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from bleak.uuids import normalize_uuid_16
# avoid circular import
if TYPE_CHECKING:
from bleak.backends.characteristic import BleakGATTCharacteristic
_descriptor_descriptions = {
normalize_uuid_16(0x2905): [
"Characteristic Aggregate Format",
"org.bluetooth.descriptor.gatt.characteristic_aggregate_format",
"0x2905",
"GSS",
],
normalize_uuid_16(0x2900): [
"Characteristic Extended Properties",
"org.bluetooth.descriptor.gatt.characteristic_extended_properties",
"0x2900",
"GSS",
],
normalize_uuid_16(0x2904): [
"Characteristic Presentation Format",
"org.bluetooth.descriptor.gatt.characteristic_presentation_format",
"0x2904",
"GSS",
],
normalize_uuid_16(0x2901): [
"Characteristic User Description",
"org.bluetooth.descriptor.gatt.characteristic_user_description",
"0x2901",
"GSS",
],
normalize_uuid_16(0x2902): [
"Client Characteristic Configuration",
"org.bluetooth.descriptor.gatt.client_characteristic_configuration",
"0x2902",
"GSS",
],
normalize_uuid_16(0x290B): [
"Environmental Sensing Configuration",
"org.bluetooth.descriptor.es_configuration",
"0x290B",
"GSS",
],
normalize_uuid_16(0x290C): [
"Environmental Sensing Measurement",
"org.bluetooth.descriptor.es_measurement",
"0x290C",
"GSS",
],
normalize_uuid_16(0x290D): [
"Environmental Sensing Trigger Setting",
"org.bluetooth.descriptor.es_trigger_setting",
"0x290D",
"GSS",
],
normalize_uuid_16(0x2907): [
"External Report Reference",
"org.bluetooth.descriptor.external_report_reference",
"0x2907",
"GSS",
],
normalize_uuid_16(0x2909): [
"Number of Digitals",
"org.bluetooth.descriptor.number_of_digitals",
"0x2909",
"GSS",
],
normalize_uuid_16(0x2908): [
"Report Reference",
"org.bluetooth.descriptor.report_reference",
"0x2908",
"GSS",
],
normalize_uuid_16(0x2903): [
"Server Characteristic Configuration",
"org.bluetooth.descriptor.gatt.server_characteristic_configuration",
"0x2903",
"GSS",
],
normalize_uuid_16(0x290E): [
"Time Trigger Setting",
"org.bluetooth.descriptor.time_trigger_setting",
"0x290E",
"GSS",
],
normalize_uuid_16(0x2906): [
"Valid Range",
"org.bluetooth.descriptor.valid_range",
"0x2906",
"GSS",
],
normalize_uuid_16(0x290A): [
"Value Trigger Setting",
"org.bluetooth.descriptor.value_trigger_setting",
"0x290A",
"GSS",
],
}
class BleakGATTDescriptor:
"""The Bleak representation of a GATT Descriptor"""
def __init__(
self, obj: Any, handle: int, uuid: str, characteristic: BleakGATTCharacteristic
):
"""
Args:
obj: The backend-specific object for the descriptor.
handle: The handle of the descriptor.
uuid: The UUID of the descriptor.
characteristic: The characteristic that this descriptor belongs to.
"""
self.obj = obj
self._handle = handle
self._uuid = uuid
self._characteristic = characteristic
def __str__(self):
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
@property
def characteristic_uuid(self) -> str:
"""UUID for the characteristic that this descriptor belongs to"""
return self._characteristic.uuid
@property
def characteristic_handle(self) -> int:
"""handle for the characteristic that this descriptor belongs to"""
return self._characteristic.handle
@property
def uuid(self) -> str:
"""UUID for this descriptor"""
return self._uuid
@property
def handle(self) -> int:
"""Integer handle for this descriptor"""
return self._handle
@property
def description(self) -> str:
"""A text description of what this descriptor represents"""
return _descriptor_descriptions.get(self.uuid, ["Unknown"])[0]

View File

@ -0,0 +1,39 @@
# Created on 2018-04-23 by hbldh <henrik.blidh@nedomkull.com>
"""
Wrapper class for Bluetooth LE servers returned from calling
:py:meth:`bleak.discover`.
"""
from typing import Any, Optional
from warnings import warn
class BLEDevice:
"""
A simple wrapper class representing a BLE server detected during scanning.
"""
__slots__ = ("address", "name", "details")
def __init__(self, address: str, name: Optional[str], details: Any, **kwargs: Any):
#: The Bluetooth address of the device on this machine (UUID on macOS).
self.address = address
#: The operating system name of the device (not necessarily the local name
#: from the advertising data), suitable for display to the user.
self.name = name
#: The OS native details required for connecting to the device.
self.details = details
if kwargs:
warn(
"Passing additional arguments for BLEDevice is deprecated and has no effect.",
DeprecationWarning,
stacklevel=2,
)
def __str__(self):
return f"{self.address}: {self.name}"
def __repr__(self):
return f"BLEDevice({self.address}, {self.name})"

View File

@ -0,0 +1,528 @@
"""
BLE Client for python-for-android
"""
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "android":
assert False, "This backend is only available on Android"
import asyncio
import logging
import uuid
import warnings
from typing import Any, Optional, Union
from android.broadcast import BroadcastReceiver
from jnius import java_method
from bleak._compat import override
from bleak.assigned_numbers import gatt_char_props_to_strs
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.client import BaseBleakClient, NotifyCallback
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.backends.device import BLEDevice
from bleak.backends.p4android import defs, utils
from bleak.backends.service import BleakGATTService, BleakGATTServiceCollection
from bleak.exc import BleakError
logger = logging.getLogger(__name__)
class BleakClientP4Android(BaseBleakClient):
"""A python-for-android Bleak Client
Args:
address_or_ble_device:
The Bluetooth address of the BLE peripheral to connect to or the
:class:`BLEDevice` object representing it.
services:
Optional set of services UUIDs to filter.
"""
def __init__(
self,
address_or_ble_device: Union[BLEDevice, str],
services: Optional[set[uuid.UUID]],
**kwargs,
):
super().__init__(address_or_ble_device, **kwargs)
self._requested_services = (
set(map(defs.UUID.fromString, services)) if services else None
)
self.__gatt = None
self.__mtu = 23
self.__callbacks = None
# Connectivity methods
@override
async def connect(self, pair: bool, **kwargs) -> None:
"""Connect to the specified GATT server."""
if pair:
logger.warning("Pairing during connect is not implemented on Android")
loop = asyncio.get_running_loop()
adapter = defs.BluetoothAdapter.getDefaultAdapter()
if adapter is None:
raise BleakError("Bluetooth is not supported on this hardware platform")
if adapter.getState() != defs.BluetoothAdapter.STATE_ON:
raise BleakError("Bluetooth is not turned on")
self.__device = adapter.getRemoteDevice(self.address)
self.__callbacks = _PythonBluetoothGattCallback(self, loop)
self._subscriptions = {}
logger.debug(f"Connecting to BLE device @ {self.address}")
(self.__gatt,) = await self.__callbacks.perform_and_wait(
dispatchApi=self.__device.connectGatt,
dispatchParams=(
defs.context,
False,
self.__callbacks.java,
defs.BluetoothDevice.TRANSPORT_LE,
),
resultApi="onConnectionStateChange",
resultExpected=(defs.BluetoothProfile.STATE_CONNECTED,),
return_indicates_status=False,
)
try:
logger.debug("Connection successful.")
# unlike other backends, Android doesn't automatically negotiate
# the MTU, so we request the largest size possible like BlueZ
logger.debug("requesting mtu...")
(self.__mtu,) = await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.requestMtu,
dispatchParams=(517,),
resultApi="onMtuChanged",
)
logger.debug("discovering services...")
await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.discoverServices,
dispatchParams=(),
resultApi="onServicesDiscovered",
)
await self._get_services()
except BaseException:
# if connecting is canceled or one of the above fails, we need to
# disconnect
try:
await self.disconnect()
except Exception:
pass
raise
@override
async def disconnect(self) -> None:
"""Disconnect from the specified GATT server."""
logger.debug("Disconnecting from BLE device...")
if self.__gatt is None:
# No connection exists. Either one hasn't been created or
# we have already called disconnect and closed the gatt
# connection.
logger.debug("already disconnected")
return
# Try to disconnect the actual device/peripheral
try:
await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.disconnect,
dispatchParams=(),
resultApi="onConnectionStateChange",
resultExpected=(defs.BluetoothProfile.STATE_DISCONNECTED,),
unless_already=True,
return_indicates_status=False,
)
self.__gatt.close()
except Exception as e:
logger.error(f"Attempt to disconnect device failed: {e}")
self.__gatt = None
self.__callbacks = None
# Reset all stored services.
self.services = None
@override
async def pair(self, *args, **kwargs) -> None:
"""Pair with the peripheral.
You can use ConnectDevice method if you already know the MAC address of the device.
Else you need to StartDiscovery, Trust, Pair and Connect in sequence.
"""
loop = asyncio.get_running_loop()
bondedFuture = loop.create_future()
def handleBondStateChanged(context, intent):
bond_state = intent.getIntExtra(defs.BluetoothDevice.EXTRA_BOND_STATE, -1)
if bond_state == -1:
loop.call_soon_threadsafe(
bondedFuture.set_exception,
BleakError(f"Unexpected bond state {bond_state}"),
)
elif bond_state == defs.BluetoothDevice.BOND_NONE:
loop.call_soon_threadsafe(
bondedFuture.set_exception,
BleakError(
f"Device with address {self.address} could not be paired with."
),
)
elif bond_state == defs.BluetoothDevice.BOND_BONDED:
loop.call_soon_threadsafe(bondedFuture.set_result, True)
receiver = BroadcastReceiver(
handleBondStateChanged,
actions=[defs.BluetoothDevice.ACTION_BOND_STATE_CHANGED],
)
receiver.start()
try:
# See if it is already paired.
bond_state = self.__device.getBondState()
if bond_state == defs.BluetoothDevice.BOND_BONDED:
return
elif bond_state == defs.BluetoothDevice.BOND_NONE:
logger.debug(f"Pairing to BLE device @ {self.address}")
if not self.__device.createBond():
raise BleakError(
f"Could not initiate bonding with device @ {self.address}"
)
await bondedFuture
finally:
await receiver.stop()
@override
async def unpair(self) -> None:
"""Unpair with the peripheral."""
warnings.warn(
"Unpairing is seemingly unavailable in the Android API at the moment."
)
@property
@override
def is_connected(self) -> bool:
"""Check connection status between this client and the server.
Returns:
Boolean representing connection status.
"""
return (
self.__callbacks is not None
and self.__callbacks.states["onConnectionStateChange"][1]
== defs.BluetoothProfile.STATE_CONNECTED
)
@property
@override
def mtu_size(self) -> int:
return self.__mtu
# GATT services methods
async def _get_services(self) -> BleakGATTServiceCollection:
"""Get all services registered for this GATT server.
Returns:
A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree.
"""
if self.services is not None:
return self.services
services = BleakGATTServiceCollection()
logger.debug("Get Services...")
for java_service in self.__gatt.getServices():
if (
self._requested_services is not None
and java_service.getUuid() not in self._requested_services
):
continue
service = BleakGATTService(
java_service,
java_service.getInstanceId(),
java_service.getUuid().toString(),
)
services.add_service(service)
for java_characteristic in java_service.getCharacteristics():
characteristic = BleakGATTCharacteristic(
java_characteristic,
java_characteristic.getInstanceId(),
java_characteristic.getUuid().toString(),
gatt_char_props_to_strs(java_characteristic.getProperties()),
lambda: self.__mtu - 3,
service,
)
services.add_characteristic(characteristic)
for descriptor_index, java_descriptor in enumerate(
java_characteristic.getDescriptors()
):
descriptor = BleakGATTDescriptor(
java_descriptor,
characteristic.handle + 1 + descriptor_index,
java_descriptor.getUuid().toString(),
characteristic,
)
services.add_descriptor(descriptor)
self.services = services
return self.services
# IO methods
@override
async def read_gatt_char(
self,
characteristic: BleakGATTCharacteristic,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT characteristic.
Args:
characteristic (BleakGATTCharacteristic): The characteristic to read from.
Returns:
(bytearray) The read data.
"""
if use_cached:
logger.debug(
"Reading cached characteristic values is not implemented on Android"
)
(value,) = await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.readCharacteristic,
dispatchParams=(characteristic.obj,),
resultApi=("onCharacteristicRead", characteristic.handle),
)
value = bytearray(value)
logger.debug(
f"Read Characteristic {characteristic.uuid} | {characteristic.handle}: {value}"
)
return value
@override
async def read_gatt_descriptor(
self,
descriptor: BleakGATTDescriptor,
*,
use_cached: bool = False,
**kwargs: Any,
) -> bytearray:
"""Perform read operation on the specified GATT descriptor.
Args:
descriptor: The descriptor to read from.
use_cached: Whether to use cached value.
Returns:
The read data.
"""
if use_cached:
logger.debug(
"Reading cached descriptor values is not implemented on Android"
)
(value,) = await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.readDescriptor,
dispatchParams=(descriptor.obj,),
resultApi=("onDescriptorRead", descriptor.uuid),
)
value = bytearray(value)
logger.debug(
f"Read Descriptor {descriptor.uuid} | {descriptor.handle}: {value}"
)
return value
@override
async def write_gatt_char(
self, characteristic: BleakGATTCharacteristic, data: bytearray, response: bool
) -> None:
if response:
characteristic.obj.setWriteType(
defs.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
else:
characteristic.obj.setWriteType(
defs.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
)
characteristic.obj.setValue(data)
await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.writeCharacteristic,
dispatchParams=(characteristic.obj,),
resultApi=("onCharacteristicWrite", characteristic.handle),
)
logger.debug(
f"Write Characteristic {characteristic.uuid} | {characteristic.handle}: {data}"
)
@override
async def write_gatt_descriptor(
self,
desc_specifier: Union[BleakGATTDescriptor, str, uuid.UUID],
data: bytearray,
) -> None:
"""Perform a write operation on the specified GATT descriptor.
Args:
desc_specifier (BleakGATTDescriptor, str or UUID): The descriptor to write
to, specified by either UUID or directly by the
BleakGATTDescriptor object representing it.
data (bytes or bytearray): The data to send.
"""
if not isinstance(desc_specifier, BleakGATTDescriptor):
descriptor = self.services.get_descriptor(desc_specifier)
else:
descriptor = desc_specifier
if not descriptor:
raise BleakError(f"Descriptor {desc_specifier} was not found!")
descriptor.obj.setValue(data)
await self.__callbacks.perform_and_wait(
dispatchApi=self.__gatt.writeDescriptor,
dispatchParams=(descriptor.obj,),
resultApi=("onDescriptorWrite", descriptor.uuid),
)
logger.debug(
f"Write Descriptor {descriptor.uuid} | {descriptor.handle}: {data}"
)
@override
async def start_notify(
self,
characteristic: BleakGATTCharacteristic,
callback: NotifyCallback,
**kwargs,
) -> None:
"""
Activate notifications/indications on a characteristic.
"""
self._subscriptions[characteristic.handle] = callback
assert self.__gatt is not None
if not self.__gatt.setCharacteristicNotification(characteristic.obj, True):
raise BleakError(
f"Failed to enable notification for characteristic {characteristic.uuid}"
)
await self.write_gatt_descriptor(
characteristic.get_descriptor(
defs.CLIENT_CHARACTERISTIC_CONFIGURATION_UUID
),
defs.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
)
@override
async def stop_notify(self, characteristic: BleakGATTCharacteristic) -> None:
"""Deactivate notification/indication on a specified characteristic.
Args:
characteristic (BleakGATTCharacteristic): The characteristic to deactivate
notification/indication on,.
"""
await self.write_gatt_descriptor(
characteristic.get_descriptor(
defs.CLIENT_CHARACTERISTIC_CONFIGURATION_UUID
),
defs.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE,
)
if not self.__gatt.setCharacteristicNotification(characteristic.obj, False):
raise BleakError(
f"Failed to disable notification for characteristic {characteristic.uuid}"
)
del self._subscriptions[characteristic.handle]
class _PythonBluetoothGattCallback(utils.AsyncJavaCallbacks):
__javainterfaces__ = [
"com.github.hbldh.bleak.PythonBluetoothGattCallback$Interface"
]
def __init__(self, client, loop):
super().__init__(loop)
self._client = client
self.java = defs.PythonBluetoothGattCallback(self)
def result_state(self, status, resultApi, *data):
if status == defs.BluetoothGatt.GATT_SUCCESS:
failure_str = None
else:
failure_str = defs.GATT_STATUS_STRINGS.get(status, status)
self._loop.call_soon_threadsafe(
self._result_state_unthreadsafe, failure_str, resultApi, data
)
@java_method("(II)V")
def onConnectionStateChange(self, status, new_state):
try:
self.result_state(status, "onConnectionStateChange", new_state)
except BleakError:
pass
if (
new_state == defs.BluetoothProfile.STATE_DISCONNECTED
and self._client._disconnected_callback is not None
):
self._client._disconnected_callback()
@java_method("(II)V")
def onMtuChanged(self, mtu, status):
self.result_state(status, "onMtuChanged", mtu)
@java_method("(I)V")
def onServicesDiscovered(self, status):
self.result_state(status, "onServicesDiscovered")
@java_method("(I[B)V")
def onCharacteristicChanged(self, handle, value):
self._loop.call_soon_threadsafe(
self._client._subscriptions[handle], bytearray(value.tolist())
)
@java_method("(II[B)V")
def onCharacteristicRead(self, handle, status, value):
self.result_state(
status, ("onCharacteristicRead", handle), bytes(value.tolist())
)
@java_method("(II)V")
def onCharacteristicWrite(self, handle, status):
self.result_state(status, ("onCharacteristicWrite", handle))
@java_method("(Ljava/lang/String;I[B)V")
def onDescriptorRead(self, uuid, status, value):
self.result_state(status, ("onDescriptorRead", uuid), bytes(value.tolist()))
@java_method("(Ljava/lang/String;I)V")
def onDescriptorWrite(self, uuid, status):
self.result_state(status, ("onDescriptorWrite", uuid))

View File

@ -0,0 +1,85 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "android":
assert False, "This backend is only available on Android"
import enum
from jnius import autoclass, cast
import bleak.exc
from bleak.uuids import normalize_uuid_16
# caching constants avoids unnecessary extra use of the jni-python interface, which can be slow
List = autoclass("java.util.ArrayList")
UUID = autoclass("java.util.UUID")
BluetoothAdapter = autoclass("android.bluetooth.BluetoothAdapter")
ScanCallback = autoclass("android.bluetooth.le.ScanCallback")
ScanFilter = autoclass("android.bluetooth.le.ScanFilter")
ScanFilterBuilder = autoclass("android.bluetooth.le.ScanFilter$Builder")
ScanSettings = autoclass("android.bluetooth.le.ScanSettings")
ScanSettingsBuilder = autoclass("android.bluetooth.le.ScanSettings$Builder")
BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice")
BluetoothGatt = autoclass("android.bluetooth.BluetoothGatt")
BluetoothGattCharacteristic = autoclass("android.bluetooth.BluetoothGattCharacteristic")
BluetoothGattDescriptor = autoclass("android.bluetooth.BluetoothGattDescriptor")
BluetoothProfile = autoclass("android.bluetooth.BluetoothProfile")
PythonActivity = autoclass("org.kivy.android.PythonActivity")
ParcelUuid = autoclass("android.os.ParcelUuid")
activity = cast("android.app.Activity", PythonActivity.mActivity)
context = cast("android.content.Context", activity.getApplicationContext())
ScanResult = autoclass("android.bluetooth.le.ScanResult")
BLEAK_JNI_NAMESPACE = "com.github.hbldh.bleak"
PythonScanCallback = autoclass(BLEAK_JNI_NAMESPACE + ".PythonScanCallback")
PythonBluetoothGattCallback = autoclass(
BLEAK_JNI_NAMESPACE + ".PythonBluetoothGattCallback"
)
class ScanFailed(enum.IntEnum):
ALREADY_STARTED = ScanCallback.SCAN_FAILED_ALREADY_STARTED
APPLICATION_REGISTRATION_FAILED = (
ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED
)
FEATURE_UNSUPPORTED = ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED
INTERNAL_ERROR = ScanCallback.SCAN_FAILED_INTERNAL_ERROR
GATT_SUCCESS = 0x0000
# TODO: we may need different lookups, e.g. one for bleak.exc.CONTROLLER_ERROR_CODES
GATT_STATUS_STRINGS = {
# https://developer.android.com/reference/android/bluetooth/BluetoothGatt
# https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/5738f83aeb59361a0a2eda2460113f6dc9194271/stack/include/gatt_api.h
# https://android.googlesource.com/platform/system/bt/+/master/stack/include/gatt_api.h
# https://www.bluetooth.com/specifications/bluetooth-core-specification/
**bleak.exc.PROTOCOL_ERROR_CODES,
0x007F: "Too Short",
0x0080: "No Resources",
0x0081: "Internal Error",
0x0082: "Wrong State",
0x0083: "DB Full",
0x0084: "Busy",
0x0085: "Error",
0x0086: "Command Started",
0x0087: "Illegal Parameter",
0x0088: "Pending",
0x0089: "Auth Failure",
0x008A: "More",
0x008B: "Invalid Configuration",
0x008C: "Service Started",
0x008D: "Encrypted No MITM",
0x008E: "Not Encrypted",
0x008F: "Congested",
0x0090: "Duplicate Reg",
0x0091: "Already Open",
0x0092: "Cancel",
0x0101: "Failure",
}
CLIENT_CHARACTERISTIC_CONFIGURATION_UUID = normalize_uuid_16(0x2902)

View File

@ -0,0 +1,84 @@
package com.github.hbldh.bleak;
import java.net.ConnectException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.HashMap;
import java.util.UUID;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothProfile;
public final class PythonBluetoothGattCallback extends BluetoothGattCallback
{
public interface Interface
{
public void onConnectionStateChange(int status, int newState);
public void onMtuChanged(int mtu, int status);
public void onServicesDiscovered(int status);
public void onCharacteristicChanged(int handle, byte[] value);
public void onCharacteristicRead(int handle, int status, byte[] value);
public void onCharacteristicWrite(int handle, int status);
public void onDescriptorRead(String uuid, int status, byte[] value);
public void onDescriptorWrite(String uuid, int status);
}
private Interface callback;
public PythonBluetoothGattCallback(Interface pythonCallback)
{
callback = pythonCallback;
}
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState)
{
callback.onConnectionStateChange(status, newState);
}
@Override
public void onMtuChanged(BluetoothGatt gatt, int mtu, int status)
{
callback.onMtuChanged(mtu, status);
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status)
{
callback.onServicesDiscovered(status);
}
@Override
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)
{
callback.onCharacteristicRead(characteristic.getInstanceId(), status, characteristic.getValue());
}
@Override
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)
{
callback.onCharacteristicWrite(characteristic.getInstanceId(), status);
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic)
{
callback.onCharacteristicChanged(characteristic.getInstanceId(), characteristic.getValue());
}
@Override
public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status)
{
callback.onDescriptorRead(descriptor.getUuid().toString(), status, descriptor.getValue());
}
@Override
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status)
{
callback.onDescriptorWrite(descriptor.getUuid().toString(), status);
}
}

View File

@ -0,0 +1,41 @@
package com.github.hbldh.bleak;
import java.util.List;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
public final class PythonScanCallback extends ScanCallback
{
public interface Interface
{
public void onScanFailed(int code);
public void onScanResult(ScanResult result);
}
private Interface callback;
public PythonScanCallback(Interface pythonCallback)
{
callback = pythonCallback;
}
@Override
public void onBatchScanResults(List<ScanResult> results)
{
for (ScanResult result : results) {
callback.onScanResult(result);
}
}
@Override
public void onScanFailed(int errorCode)
{
callback.onScanFailed(errorCode);
}
@Override
public void onScanResult(int callbackType, ScanResult result)
{
callback.onScanResult(result);
}
}

View File

@ -0,0 +1,58 @@
import os
from os.path import join
import sh
from pythonforandroid.recipe import PythonRecipe
from pythonforandroid.toolchain import info, shprint
class BleakRecipe(PythonRecipe):
version = None # Must be none for p4a to correctly clone repo
fix_setup_py_version = "bleak develop branch"
url = "git+https://github.com/hbldh/bleak.git"
name = "bleak"
depends = ["pyjnius"]
call_hostpython_via_targetpython = False
fix_setup_filename = "fix_setup.py"
def prepare_build_dir(self, arch):
super().prepare_build_dir(arch) # Unpack the url file to the get_build_dir
build_dir = self.get_build_dir(arch)
setup_py_path = join(build_dir, "setup.py")
if not os.path.exists(setup_py_path):
# Perform the p4a temporary fix
# At the moment, p4a recipe installing requires setup.py to be present
# So, we create a setup.py file only for android
fix_setup_py_path = join(self.get_recipe_dir(), self.fix_setup_filename)
with open(fix_setup_py_path, "r") as f:
contents = f.read()
# Write to the correct location and fill in the version number
with open(setup_py_path, "w") as f:
f.write(contents.replace("[VERSION]", self.fix_setup_py_version))
else:
info("setup.py found in bleak directory, are you installing older version?")
def get_recipe_env(self, arch=None, with_flags_in_cc=True):
env = super().get_recipe_env(arch, with_flags_in_cc)
# to find jnius and identify p4a
env["PYJNIUS_PACKAGES"] = self.ctx.get_site_packages_dir(arch)
return env
def postbuild_arch(self, arch):
super().postbuild_arch(arch)
info("Copying java files")
dest_dir = self.ctx.javaclass_dir
path = join(
self.get_build_dir(arch.arch), "bleak", "backends", "p4android", "java", "."
)
shprint(sh.cp, "-a", path, dest_dir)
recipe = BleakRecipe()

View File

@ -0,0 +1,10 @@
from setuptools import find_packages, setup
VERSION = "[VERSION]" # Version will be filled in by the bleak recipe
NAME = "bleak"
setup(
name=NAME,
version=VERSION,
packages=find_packages(exclude=("tests", "examples", "docs")),
)

View File

@ -0,0 +1,293 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "android":
assert False, "This backend is only available on Android"
import asyncio
import logging
import warnings
from typing import Literal, Optional
from android.broadcast import BroadcastReceiver
from android.permissions import Permission, request_permissions
from jnius import cast, java_method
from bleak._compat import override
from bleak._compat import timeout as async_timeout
from bleak.backends.p4android import defs, utils
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak.exc import BleakError
logger = logging.getLogger(__name__)
class BleakScannerP4Android(BaseBleakScanner):
"""
The python-for-android Bleak BLE Scanner.
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received. Specifying this
also enables scanning while the screen is off on Android.
scanning_mode:
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
"""
__scanner = None
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[list[str]],
scanning_mode: Literal["active", "passive"],
**kwargs,
):
super().__init__(detection_callback, service_uuids)
if scanning_mode == "passive":
self.__scan_mode = defs.ScanSettings.SCAN_MODE_OPPORTUNISTIC
else:
self.__scan_mode = defs.ScanSettings.SCAN_MODE_LOW_LATENCY
self.__javascanner = None
self.__callback = None
@override
async def start(self) -> None:
if BleakScannerP4Android.__scanner is not None:
raise BleakError("A BleakScanner is already scanning on this adapter.")
logger.debug("Starting BTLE scan")
loop = asyncio.get_running_loop()
if self.__javascanner is None:
if self.__callback is None:
self.__callback = _PythonScanCallback(self, loop)
permission_acknowledged = loop.create_future()
def handle_permissions(permissions, grantResults):
if any(grantResults):
loop.call_soon_threadsafe(
permission_acknowledged.set_result, grantResults
)
else:
loop.call_soon_threadsafe(
permission_acknowledged.set_exception(
BleakError("User denied access to " + str(permissions))
)
)
request_permissions(
[
Permission.ACCESS_FINE_LOCATION,
Permission.ACCESS_COARSE_LOCATION,
"android.permission.ACCESS_BACKGROUND_LOCATION",
],
handle_permissions,
)
await permission_acknowledged
adapter = defs.BluetoothAdapter.getDefaultAdapter()
if adapter is None:
raise BleakError("Bluetooth is not supported on this hardware platform")
if adapter.getState() != defs.BluetoothAdapter.STATE_ON:
raise BleakError("Bluetooth is not turned on")
self.__javascanner = adapter.getBluetoothLeScanner()
BleakScannerP4Android.__scanner = self
filters = cast("java.util.List", defs.List())
if self._service_uuids:
for uuid in self._service_uuids:
filters.add(
defs.ScanFilterBuilder()
.setServiceUuid(defs.ParcelUuid.fromString(uuid))
.build()
)
scanfuture = self.__callback.perform_and_wait(
dispatchApi=self.__javascanner.startScan,
dispatchParams=(
filters,
defs.ScanSettingsBuilder()
.setScanMode(self.__scan_mode)
.setReportDelay(0)
.setPhy(defs.ScanSettings.PHY_LE_ALL_SUPPORTED)
.setNumOfMatches(defs.ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
.setMatchMode(defs.ScanSettings.MATCH_MODE_AGGRESSIVE)
.setCallbackType(defs.ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build(),
self.__callback.java,
),
resultApi="onScan",
return_indicates_status=False,
)
self.__javascanner.flushPendingScanResults(self.__callback.java)
try:
async with async_timeout(0.2):
await scanfuture
except asyncio.exceptions.TimeoutError:
pass
except BleakError as bleakerror:
await self.stop()
if bleakerror.args != (
"onScan",
"SCAN_FAILED_APPLICATION_REGISTRATION_FAILED",
):
raise bleakerror
else:
# there might be a clearer solution to this if android source and vendor
# documentation are reviewed for the meaning of the error
# https://stackoverflow.com/questions/27516399/solution-for-ble-scans-scan-failed-application-registration-failed
warnings.warn(
"BT API gave SCAN_FAILED_APPLICATION_REGISTRATION_FAILED. Resetting adapter."
)
def handlerWaitingForState(state, stateFuture):
def handleAdapterStateChanged(context, intent):
adapter_state = intent.getIntExtra(
defs.BluetoothAdapter.EXTRA_STATE,
defs.BluetoothAdapter.STATE_ERROR,
)
if adapter_state == defs.BluetoothAdapter.STATE_ERROR:
loop.call_soon_threadsafe(
stateOffFuture.set_exception,
BleakError(f"Unexpected adapter state {adapter_state}"),
)
elif adapter_state == state:
loop.call_soon_threadsafe(
stateFuture.set_result, adapter_state
)
return handleAdapterStateChanged
logger.info(
"disabling bluetooth adapter to handle SCAN_FAILED_APPLICATION_REGSTRATION_FAILED ..."
)
stateOffFuture = loop.create_future()
receiver = BroadcastReceiver(
handlerWaitingForState(
defs.BluetoothAdapter.STATE_OFF, stateOffFuture
),
actions=[defs.BluetoothAdapter.ACTION_STATE_CHANGED],
)
receiver.start()
try:
adapter.disable()
await stateOffFuture
finally:
receiver.stop()
logger.info("re-enabling bluetooth adapter ...")
stateOnFuture = loop.create_future()
receiver = BroadcastReceiver(
handlerWaitingForState(
defs.BluetoothAdapter.STATE_ON, stateOnFuture
),
actions=[defs.BluetoothAdapter.ACTION_STATE_CHANGED],
)
receiver.start()
try:
adapter.enable()
await stateOnFuture
finally:
receiver.stop()
logger.debug("restarting scan ...")
return await self.start()
@override
async def stop(self) -> None:
if self.__javascanner is not None:
logger.debug("Stopping BTLE scan")
self.__javascanner.stopScan(self.__callback.java)
BleakScannerP4Android.__scanner = None
self.__javascanner = None
else:
logger.debug("BTLE scan already stopped")
def _handle_scan_result(self, result) -> None:
native_device = result.getDevice()
record = result.getScanRecord()
service_uuids = record.getServiceUuids()
if service_uuids is not None:
service_uuids = [service_uuid.toString() for service_uuid in service_uuids]
if not self.is_allowed_uuid(service_uuids):
return
manufacturer_data = record.getManufacturerSpecificData()
manufacturer_data = {
manufacturer_data.keyAt(index): bytes(manufacturer_data.valueAt(index))
for index in range(manufacturer_data.size())
}
service_data = {
entry.getKey().toString(): bytes(entry.getValue())
for entry in record.getServiceData().entrySet()
}
tx_power = record.getTxPowerLevel()
# change "not present" value to None to match other backends
if tx_power == -2147483648: # Integer#MIN_VALUE
tx_power = None
advertisement = AdvertisementData(
local_name=record.getDeviceName(),
manufacturer_data=manufacturer_data,
service_data=service_data,
service_uuids=service_uuids,
tx_power=tx_power,
rssi=result.getRssi(),
platform_data=(result,),
)
device = self.create_or_update_device(
native_device.getAddress(),
native_device.getAddress(),
native_device.getName(),
native_device,
advertisement,
)
self.call_detection_callbacks(device, advertisement)
class _PythonScanCallback(utils.AsyncJavaCallbacks):
__javainterfaces__ = ["com.github.hbldh.bleak.PythonScanCallback$Interface"]
def __init__(self, scanner: BleakScannerP4Android, loop: asyncio.AbstractEventLoop):
super().__init__(loop)
self._scanner = scanner
self.java = defs.PythonScanCallback(self)
def result_state(self, status_str, name, *data):
self._loop.call_soon_threadsafe(
self._result_state_unthreadsafe, status_str, name, data
)
@java_method("(I)V")
def onScanFailed(self, errorCode):
self.result_state(defs.ScanFailed(errorCode).name, "onScan")
@java_method("(Landroid/bluetooth/le/ScanResult;)V")
def onScanResult(self, result):
self._loop.call_soon_threadsafe(self._scanner._handle_scan_result, result)
if "onScan" not in self.states:
self.result_state(None, "onScan", result)

View File

@ -0,0 +1,99 @@
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if sys.platform != "android":
assert False, "This backend is only available on Android"
import asyncio
import logging
import warnings
from jnius import PythonJavaClass
from bleak.exc import BleakError
logger = logging.getLogger(__name__)
class AsyncJavaCallbacks(PythonJavaClass):
__javacontext__ = "app"
def __init__(self, loop: asyncio.AbstractEventLoop):
self._loop = loop
self.states = {}
self.futures = {}
@staticmethod
def _if_expected(result, expected):
if result[: len(expected)] == expected[:]:
return result[len(expected) :]
else:
return None
async def perform_and_wait(
self,
dispatchApi,
dispatchParams,
resultApi,
resultExpected=(),
unless_already=False,
return_indicates_status=True,
):
result2 = None
if unless_already:
if resultApi in self.states:
result2 = self._if_expected(self.states[resultApi][1:], resultExpected)
result1 = True
if result2 is not None:
logger.debug(
f"Not waiting for android api {resultApi} because found {resultExpected}"
)
else:
logger.debug(f"Waiting for android api {resultApi}")
state = self._loop.create_future()
self.futures[resultApi] = state
result1 = dispatchApi(*dispatchParams)
if return_indicates_status and not result1:
del self.futures[resultApi]
raise BleakError(f"api call failed, not waiting for {resultApi}")
data = await state
result2 = self._if_expected(data, resultExpected)
if result2 is None:
raise BleakError("Expected", resultExpected, "got", data)
logger.debug(f"{resultApi} succeeded {result2}")
if return_indicates_status:
return result2
else:
return (result1, *result2)
def _result_state_unthreadsafe(self, failure_str, source, data):
logger.debug(f"Java state transfer {source} error={failure_str} data={data}")
self.states[source] = (failure_str, *data)
future = self.futures.get(source, None)
if future is not None and not future.done():
if failure_str is None:
future.set_result(data)
else:
future.set_exception(BleakError(source, failure_str, *data))
else:
if failure_str is not None:
# an error happened with nothing waiting for it
exception = BleakError(source, failure_str, *data)
namedfutures = [
namedfuture
for namedfuture in self.futures.items()
if not namedfuture[1].done()
]
if len(namedfutures):
# send it on existing requests
for name, future in namedfutures:
warnings.warn(f"Redirecting error without home to {name}")
future.set_exception(exception)
else:
# send it on the event thread
raise exception

View File

@ -0,0 +1,324 @@
import abc
import asyncio
import inspect
from collections.abc import Callable, Coroutine, Hashable
from typing import Any, NamedTuple, Optional
from bleak.backends import BleakBackend, get_default_backend
from bleak.backends.device import BLEDevice
from bleak.exc import BleakError
# prevent tasks from being garbage collected
_background_tasks: set[asyncio.Task[None]] = set()
class AdvertisementData(NamedTuple):
"""
Wrapper around the advertisement data that each platform returns upon discovery
"""
local_name: Optional[str]
"""
The local name of the device or ``None`` if not included in advertising data.
"""
manufacturer_data: dict[int, bytes]
"""
Dictionary of manufacturer data in bytes from the received advertisement data or empty dict if not present.
The keys are Bluetooth SIG assigned Company Identifiers and the values are bytes.
https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/
"""
service_data: dict[str, bytes]
"""
Dictionary of service data from the received advertisement data or empty dict if not present.
"""
service_uuids: list[str]
"""
List of service UUIDs from the received advertisement data or empty list if not present.
"""
tx_power: Optional[int]
"""
TX Power Level of the remote device from the received advertising data or ``None`` if not present.
.. versionadded:: 0.17
"""
rssi: int
"""
The Radio Receive Signal Strength (RSSI) in dBm.
.. versionadded:: 0.19
"""
platform_data: tuple[Any, ...]
"""
Tuple of platform specific data.
This is not a stable API. The actual values may change between releases.
"""
def __repr__(self) -> str:
kwargs: list[str] = []
if self.local_name:
kwargs.append(f"local_name={repr(self.local_name)}")
if self.manufacturer_data:
kwargs.append(f"manufacturer_data={repr(self.manufacturer_data)}")
if self.service_data:
kwargs.append(f"service_data={repr(self.service_data)}")
if self.service_uuids:
kwargs.append(f"service_uuids={repr(self.service_uuids)}")
if self.tx_power is not None:
kwargs.append(f"tx_power={repr(self.tx_power)}")
kwargs.append(f"rssi={repr(self.rssi)}")
return f"AdvertisementData({', '.join(kwargs)})"
AdvertisementDataCallback = Callable[
[BLEDevice, AdvertisementData],
Optional[Coroutine[Any, Any, None]],
]
"""
Type alias for callback called when advertisement data is received.
"""
AdvertisementDataFilter = Callable[
[BLEDevice, AdvertisementData],
bool,
]
"""
Type alias for an advertisement data filter function.
Implementations should return ``True`` for matches, otherwise ``False``.
"""
class BaseBleakScanner(abc.ABC):
"""
Interface for Bleak Bluetooth LE Scanners
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received.
"""
seen_devices: dict[str, tuple[BLEDevice, AdvertisementData]]
"""
Map of device identifier to BLEDevice and most recent advertisement data.
The key is a backend-specific identifier for the device.
This map must be cleared when scanning starts.
"""
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[list[str]],
):
self._ad_callbacks: dict[
Hashable, Callable[[BLEDevice, AdvertisementData], None]
] = {}
"""
List of callbacks to call when an advertisement is received.
"""
if detection_callback is not None:
self.register_detection_callback(detection_callback)
self._service_uuids: Optional[list[str]] = (
[u.lower() for u in service_uuids] if service_uuids is not None else None
)
self.seen_devices = {}
def register_detection_callback(
self, callback: Optional[AdvertisementDataCallback]
) -> Callable[[], None]:
"""
Register a callback that is called when an advertisement event from the
OS is received.
The ``callback`` is a function or coroutine that takes two arguments: :class:`BLEDevice`
and :class:`AdvertisementData`.
Args:
callback: A function, coroutine or ``None``.
Returns:
A method that can be called to unregister the callback.
"""
error_text = "callback must be callable with 2 parameters"
if not callable(callback):
raise TypeError(error_text)
handler_signature = inspect.signature(callback)
if len(handler_signature.parameters) != 2:
raise TypeError(error_text)
if inspect.iscoroutinefunction(callback):
def detection_callback(s: BLEDevice, d: AdvertisementData) -> None:
task = asyncio.create_task(callback(s, d))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
else:
detection_callback = callback # type: ignore
token = object()
self._ad_callbacks[token] = detection_callback
def remove() -> None:
self._ad_callbacks.pop(token, None)
return remove
def is_allowed_uuid(self, service_uuids: Optional[list[str]]) -> bool:
"""
Check if the advertisement data contains any of the service UUIDs
matching the filter. If no filter is set, this will always return
``True``.
Args:
service_uuids: The service UUIDs from the advertisement data.
Returns:
``True`` if the advertisement data should be allowed or ``False``
if the advertisement data should be filtered out.
"""
# Backends will make best effort to filter out advertisements that
# don't match the service UUIDs, but if other apps are scanning at the
# same time or something like that, we may still receive advertisements
# that don't match. So we need to do more filtering here to get the
# expected behavior.
if not self._service_uuids:
# if there is no filter, everything is allowed
return True
if not service_uuids:
# if there is a filter the advertisement data doesn't contain any
# service UUIDs, filter it out
return False
for uuid in service_uuids:
if uuid in self._service_uuids:
# match was found, keep this advertisement
return True
# there were no matching service uuids, filter this one out
return False
def call_detection_callbacks(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""
Calls all registered detection callbacks.
Backend implementations should call this method when an advertisement
event is received from the OS.
"""
for callback in self._ad_callbacks.values():
callback(device, advertisement_data)
def create_or_update_device(
self,
key: str,
address: str,
name: Optional[str],
details: Any,
adv: AdvertisementData,
) -> BLEDevice:
"""
Creates or updates a device in :attr:`seen_devices`.
Args:
key: A backend-specific identifier for the device.
address: The Bluetooth address of the device (UUID on macOS).
name: The OS display name for the device.
details: The platform-specific handle for the device.
adv: The most recent advertisement data received.
Returns:
The updated device.
"""
try:
device, _ = self.seen_devices[key]
device.name = name
except KeyError:
device = BLEDevice(address, name, details)
self.seen_devices[key] = (device, adv)
return device
@abc.abstractmethod
async def start(self) -> None:
"""Start scanning for devices"""
raise NotImplementedError()
@abc.abstractmethod
async def stop(self) -> None:
"""Stop scanning for devices"""
raise NotImplementedError()
def get_platform_scanner_backend_type() -> tuple[type[BaseBleakScanner], BleakBackend]:
"""
Gets the platform-specific :class:`BaseBleakScanner` type.
"""
backend = get_default_backend()
match backend:
case BleakBackend.P4ANDROID:
from bleak.backends.p4android.scanner import (
BleakScannerP4Android, # type: ignore
)
return (BleakScannerP4Android, backend) # type: ignore
case BleakBackend.BLUEZ_DBUS:
from bleak.backends.bluezdbus.scanner import (
BleakScannerBlueZDBus, # type: ignore
)
return (BleakScannerBlueZDBus, backend) # type: ignore
case BleakBackend.PYTHONISTA_CB:
try:
from bleak_pythonista import BleakScannerPythonistaCB # type: ignore
return (BleakScannerPythonistaCB, backend) # type: ignore
except ImportError as e:
raise ImportError(
"Ensure you have `bleak-pythonista` package installed."
) from e
case BleakBackend.CORE_BLUETOOTH:
from bleak.backends.corebluetooth.scanner import (
BleakScannerCoreBluetooth, # type: ignore
)
return (BleakScannerCoreBluetooth, backend) # type: ignore
case BleakBackend.WIN_RT:
from bleak.backends.winrt.scanner import BleakScannerWinRT # type: ignore
return (BleakScannerWinRT, backend) # type: ignore
case _:
raise BleakError(f"Unsupported backend: {backend}")

View File

@ -0,0 +1,218 @@
# Created on 2019-03-19 by hbldh <henrik.blidh@nedomkull.com>
"""
Gatt Service Collection class and interface class for the Bleak representation of a GATT Service.
"""
import logging
from collections.abc import Iterator
from typing import Any, Optional, Union, cast
from uuid import UUID
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.exc import BleakError
from bleak.uuids import normalize_uuid_str, uuidstr_to_str
logger = logging.getLogger(__name__)
class BleakGATTService:
"""The Bleak representation of a GATT Service."""
def __init__(self, obj: Any, handle: int, uuid: str) -> None:
self.obj = obj
self._handle = handle
self._uuid = uuid
self._characteristics: dict[int, BleakGATTCharacteristic] = {}
def __str__(self) -> str:
return f"{self.uuid} (Handle: {self.handle}): {self.description}"
@property
def handle(self) -> int:
"""The handle of this service"""
return self._handle
@property
def uuid(self) -> str:
"""The UUID to this service"""
return self._uuid
@property
def description(self) -> str:
"""String description for this service"""
return uuidstr_to_str(self.uuid)
@property
def characteristics(self) -> list[BleakGATTCharacteristic]:
"""List of characteristics for this service"""
return list(self._characteristics.values())
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
"""Add a :py:class:`~BleakGATTCharacteristic` to the service.
Should not be used by end user, but rather by `bleak` itself.
"""
if characteristic.handle in self._characteristics:
raise BleakError(
"The characteristic '%s' is already present in this BleakGATTService!",
characteristic.handle,
)
self._characteristics[characteristic.handle] = characteristic
def get_characteristic(
self, uuid: Union[str, UUID]
) -> Union[BleakGATTCharacteristic, None]:
"""Get a characteristic by UUID.
Args:
uuid: The UUID to match.
Returns:
The first characteristic matching ``uuid`` or ``None`` if no
matching characteristic was found.
"""
uuid = normalize_uuid_str(str(uuid))
try:
return next(
filter(lambda x: x.uuid == uuid, self._characteristics.values())
)
except StopIteration:
return None
class BleakGATTServiceCollection:
"""Simple data container for storing the peripheral's service complement."""
def __init__(self) -> None:
self.__services: dict[int, BleakGATTService] = {}
self.__characteristics: dict[int, BleakGATTCharacteristic] = {}
self.__descriptors: dict[int, BleakGATTDescriptor] = {}
def __getitem__(
self, item: Union[str, int, UUID]
) -> Optional[
Union[BleakGATTService, BleakGATTCharacteristic, BleakGATTDescriptor]
]:
"""Get a service, characteristic or descriptor from uuid or handle"""
return (
self.get_service(item)
or self.get_characteristic(item)
or self.get_descriptor(cast(int, item))
)
def __iter__(self) -> Iterator[BleakGATTService]:
"""Returns an iterator over all BleakGATTService objects"""
return iter(self.services.values())
@property
def services(self) -> dict[int, BleakGATTService]:
"""Returns dictionary of handles mapping to BleakGATTService"""
return self.__services
@property
def characteristics(self) -> dict[int, BleakGATTCharacteristic]:
"""Returns dictionary of handles mapping to BleakGATTCharacteristic"""
return self.__characteristics
@property
def descriptors(self) -> dict[int, BleakGATTDescriptor]:
"""Returns a dictionary of integer handles mapping to BleakGATTDescriptor"""
return self.__descriptors
def add_service(self, service: BleakGATTService) -> None:
"""Add a :py:class:`~BleakGATTService` to the service collection.
Should not be used by end user, but rather by `bleak` itself.
"""
if service.handle not in self.__services:
self.__services[service.handle] = service
else:
logger.error(
"The service '%s' is already present in this BleakGATTServiceCollection!",
service.handle,
)
def get_service(
self, specifier: Union[int, str, UUID]
) -> Optional[BleakGATTService]:
"""Get a service by handle (int) or UUID (str or uuid.UUID)"""
if isinstance(specifier, int):
return self.services.get(specifier)
uuid = normalize_uuid_str(str(specifier))
x = list(
filter(
lambda x: x.uuid == uuid,
self.services.values(),
)
)
if len(x) > 1:
raise BleakError(
"Multiple Services with this UUID, refer to your desired service by the `handle` attribute instead."
)
return x[0] if x else None
def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None:
"""Add a :py:class:`~BleakGATTCharacteristic` to the service collection.
Should not be used by end user, but rather by `bleak` itself.
"""
if characteristic.handle not in self.__characteristics:
self.__characteristics[characteristic.handle] = characteristic
self.__services[characteristic.service_handle].add_characteristic(
characteristic
)
else:
logger.error(
"The characteristic '%s' is already present in this BleakGATTServiceCollection!",
characteristic.handle,
)
def get_characteristic(
self, specifier: Union[int, str, UUID]
) -> Optional[BleakGATTCharacteristic]:
"""Get a characteristic by handle (int) or UUID (str or uuid.UUID)"""
if isinstance(specifier, int):
return self.characteristics.get(specifier)
uuid = normalize_uuid_str(str(specifier))
# Assume uuid usage.
x = list(
filter(
lambda x: x.uuid == uuid,
self.characteristics.values(),
)
)
if len(x) > 1:
raise BleakError(
"Multiple Characteristics with this UUID, refer to your desired characteristic by the `handle` attribute instead."
)
return x[0] if x else None
def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None:
"""Add a :py:class:`~BleakGATTDescriptor` to the service collection.
Should not be used by end user, but rather by `bleak` itself.
"""
if descriptor.handle not in self.__descriptors:
self.__descriptors[descriptor.handle] = descriptor
self.__characteristics[descriptor.characteristic_handle].add_descriptor(
descriptor
)
else:
logger.error(
"The descriptor '%s' is already present in this BleakGATTServiceCollection!",
descriptor.handle,
)
def get_descriptor(self, handle: int) -> Optional[BleakGATTDescriptor]:
"""Get a descriptor by integer handle"""
return self.descriptors.get(handle)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,341 @@
import sys
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
if sys.platform != "win32":
assert False, "This backend is only available on Windows"
import asyncio
import logging
from typing import Literal, NamedTuple, Optional
from uuid import UUID
from winrt.windows.devices.bluetooth import BluetoothAdapter
from winrt.windows.devices.bluetooth.advertisement import (
BluetoothLEAdvertisementReceivedEventArgs,
BluetoothLEAdvertisementType,
BluetoothLEAdvertisementWatcher,
BluetoothLEAdvertisementWatcherStatus,
BluetoothLEAdvertisementWatcherStoppedEventArgs,
BluetoothLEScanningMode,
)
from winrt.windows.devices.radios import RadioState
from winrt.windows.foundation import EventRegistrationToken
from bleak._compat import override
from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak.backends.winrt.util import assert_mta
from bleak.exc import (
BleakBluetoothNotAvailableError,
BleakBluetoothNotAvailableReason,
BleakError,
)
from bleak.uuids import normalize_uuid_str
logger = logging.getLogger(__name__)
def _format_bdaddr(a: int) -> str:
return ":".join(f"{x:02X}" for x in a.to_bytes(6, byteorder="big"))
def _format_event_args(e: BluetoothLEAdvertisementReceivedEventArgs) -> str:
try:
return f"{_format_bdaddr(e.bluetooth_address)}: {e.advertisement.local_name}"
except Exception:
return _format_bdaddr(e.bluetooth_address)
class RawAdvData(NamedTuple):
"""
Platform-specific advertisement data.
Windows does not combine advertising data with type SCAN_RSP with other
advertising data like other platforms, so we have to do it ourselves.
"""
adv: Optional[BluetoothLEAdvertisementReceivedEventArgs]
"""
The advertisement data received from the BluetoothLEAdvertisementWatcher.Received event.
"""
scan: Optional[BluetoothLEAdvertisementReceivedEventArgs]
"""
The scan response for the same device as *adv*.
"""
class BleakScannerWinRT(BaseBleakScanner):
"""The native Windows Bleak BLE Scanner.
Implemented using `Python/WinRT <https://github.com/Microsoft/xlang/tree/master/src/package/pywinrt/projection/>`_.
Args:
detection_callback:
Optional function that will be called each time a device is
discovered or advertising data has changed.
service_uuids:
Optional list of service UUIDs to filter on. Only advertisements
containing this advertising data will be received.
scanning_mode:
Set to ``"passive"`` to avoid the ``"active"`` scanning mode.
"""
def __init__(
self,
detection_callback: Optional[AdvertisementDataCallback],
service_uuids: Optional[list[str]],
scanning_mode: Literal["active", "passive"],
**kwargs: Any,
):
super().__init__(detection_callback, service_uuids)
self.watcher: Optional[BluetoothLEAdvertisementWatcher] = None
self._advertisement_pairs: dict[str, RawAdvData] = {}
self._stopped_event: Optional[asyncio.Event] = None
# case insensitivity is for backwards compatibility on Windows only
if scanning_mode.lower() == "passive":
self._scanning_mode = BluetoothLEScanningMode.PASSIVE
else:
self._scanning_mode = BluetoothLEScanningMode.ACTIVE
# Unfortunately, due to the way Windows handles filtering, we can't
# make use of the service_uuids filter here. If we did we would only
# get the advertisement data or the scan data, but not both, so would
# miss out on other essential data. Advanced users can pass their own
# filters though if they want to.
self._signal_strength_filter = kwargs.get("SignalStrengthFilter", None)
self._advertisement_filter = kwargs.get("AdvertisementFilter", None)
self._received_token: Optional[EventRegistrationToken] = None
self._stopped_token: Optional[EventRegistrationToken] = None
def _received_handler(
self,
sender: BluetoothLEAdvertisementWatcher,
event_args: BluetoothLEAdvertisementReceivedEventArgs,
):
"""Callback for AdvertisementWatcher.Received"""
# TODO: Cannot check for if sender == self.watcher in winrt?
logger.debug("Received %s.", _format_event_args(event_args))
# REVISIT: if scanning filters with BluetoothSignalStrengthFilter.OutOfRangeTimeout
# are in place, an RSSI of -127 means that the device has gone out of range and should
# be removed from the list of seen devices instead of processing the advertisement data.
# https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.bluetoothsignalstrengthfilter.outofrangetimeout
bdaddr = _format_bdaddr(event_args.bluetooth_address)
# Unlike other platforms, Windows does not combine advertising data for
# us (regular advertisement + scan response) so we have to do it manually.
# get the previous advertising data/scan response pair or start a new one
raw_data = self._advertisement_pairs.get(bdaddr, RawAdvData(None, None))
# update the advertising data depending on the advertising data type
if event_args.advertisement_type == BluetoothLEAdvertisementType.SCAN_RESPONSE:
raw_data = RawAdvData(raw_data.adv, event_args)
else:
raw_data = RawAdvData(event_args, raw_data.scan)
self._advertisement_pairs[bdaddr] = raw_data
uuids: list[str] = []
mfg_data = {}
service_data = {}
local_name = None
tx_power = None
for args in filter(lambda d: d is not None, raw_data):
assert args
for u in args.advertisement.service_uuids:
uuids.append(str(u))
for m in args.advertisement.manufacturer_data:
mfg_data[m.company_id] = bytes(m.data)
# local name is empty string rather than None if not present
if args.advertisement.local_name:
local_name = args.advertisement.local_name
try:
if args.transmit_power_level_in_dbm is not None:
tx_power = args.transmit_power_level_in_dbm
except AttributeError:
# the transmit_power_level_in_d_bm property was introduce in
# Windows build 19041 so we have a fallback for older versions
for section in args.advertisement.get_sections_by_type(
AdvertisementDataType.TX_POWER_LEVEL
):
tx_power = bytes(section.data)[0]
# Decode service data
for section in args.advertisement.get_sections_by_type(
AdvertisementDataType.SERVICE_DATA_UUID16
):
data = bytes(section.data)
service_data[normalize_uuid_str(f"{data[1]:02x}{data[0]:02x}")] = data[
2:
]
for section in args.advertisement.get_sections_by_type(
AdvertisementDataType.SERVICE_DATA_UUID32
):
data = bytes(section.data)
service_data[
normalize_uuid_str(
f"{data[3]:02x}{data[2]:02x}{data[1]:02x}{data[0]:02x}"
)
] = data[4:]
for section in args.advertisement.get_sections_by_type(
AdvertisementDataType.SERVICE_DATA_UUID128
):
data = bytes(section.data)
service_data[str(UUID(bytes=bytes(data[15::-1])))] = data[16:]
if not self.is_allowed_uuid(uuids):
return
# Use the BLEDevice to populate all the fields for the advertisement data to return
advertisement_data = AdvertisementData(
local_name=local_name,
manufacturer_data=mfg_data,
service_data=service_data,
service_uuids=uuids,
tx_power=tx_power,
rssi=event_args.raw_signal_strength_in_dbm,
platform_data=(sender, raw_data),
)
device = self.create_or_update_device(
bdaddr, bdaddr, local_name, raw_data, advertisement_data
)
self.call_detection_callbacks(device, advertisement_data)
def _stopped_handler(
self,
sender: BluetoothLEAdvertisementWatcher,
e: BluetoothLEAdvertisementWatcherStoppedEventArgs,
) -> None:
logger.debug(
"%s devices found. Watcher status: %r.",
len(self.seen_devices),
sender.status,
)
assert self._stopped_event
self._stopped_event.set()
@override
async def start(self) -> None:
if self.watcher:
raise BleakError("Scanner already started")
# Callbacks for WinRT async methods will never happen in STA mode if
# there is nothing pumping a Windows message loop.
await assert_mta()
# TODO: need to fix return type of get_default_async() in PyWinRT
adapter = await BluetoothAdapter.get_default_async()
if adapter is None: # pyright: ignore[reportUnnecessaryComparison]
raise BleakBluetoothNotAvailableError(
"No Bluetooth adapter found",
BleakBluetoothNotAvailableReason.NO_BLUETOOTH,
)
if not adapter.is_central_role_supported:
raise BleakBluetoothNotAvailableError(
"BLE 'central' role not supported on this adapter",
BleakBluetoothNotAvailableReason.NO_BLE_CENTRAL_ROLE,
)
radio = await adapter.get_radio_async()
if radio.state != RadioState.ON:
raise BleakBluetoothNotAvailableError(
"Bluetooth radio is not powered on. Turn on Bluetooth and try again.",
BleakBluetoothNotAvailableReason.POWERED_OFF,
)
# start with fresh list of discovered devices
self.seen_devices = {}
self._advertisement_pairs.clear()
self.watcher = BluetoothLEAdvertisementWatcher()
self.watcher.scanning_mode = self._scanning_mode
# BlueZ and CoreBluetooth don't allow controlling this and always enabled it, so do the same here
try:
self.watcher.allow_extended_advertisements = True
except AttributeError:
logger.warning(
"Extended advertisements are not available in this OS Version."
)
event_loop = asyncio.get_running_loop()
self._stopped_event = asyncio.Event()
def on_received(
sender: BluetoothLEAdvertisementWatcher,
args: BluetoothLEAdvertisementReceivedEventArgs,
) -> None:
event_loop.call_soon_threadsafe(self._received_handler, sender, args)
self._received_token = self.watcher.add_received(on_received)
def on_stopped(
sender: BluetoothLEAdvertisementWatcher,
args: BluetoothLEAdvertisementWatcherStoppedEventArgs,
) -> None:
event_loop.call_soon_threadsafe(self._stopped_handler, sender, args)
self._stopped_token = self.watcher.add_stopped(on_stopped)
if self._signal_strength_filter is not None:
self.watcher.signal_strength_filter = self._signal_strength_filter
if self._advertisement_filter is not None:
self.watcher.advertisement_filter = self._advertisement_filter
self.watcher.start()
# no events for status changes, so we have to poll :-(
while self.watcher.status == BluetoothLEAdvertisementWatcherStatus.CREATED:
await asyncio.sleep(0.01)
if self.watcher.status == BluetoothLEAdvertisementWatcherStatus.ABORTED:
raise BleakError("Failed to start scanner. Is Bluetooth turned on?")
if self.watcher.status != BluetoothLEAdvertisementWatcherStatus.STARTED:
raise BleakError(f"Unexpected watcher status: {self.watcher.status.name}")
@override
async def stop(self) -> None:
assert self.watcher
assert self._stopped_event
assert self._received_token
assert self._stopped_token
self.watcher.stop()
if self.watcher.status == BluetoothLEAdvertisementWatcherStatus.STOPPING:
await self._stopped_event.wait()
else:
logger.debug(
"skipping waiting for stop because status is %r",
self.watcher.status,
)
try:
self.watcher.remove_received(self._received_token)
self.watcher.remove_stopped(self._stopped_token)
except Exception as e:
logger.debug("Could not remove event handlers: %s", e)
self._stopped_token = None
self._received_token = None
self.watcher = None

View File

@ -0,0 +1,224 @@
import sys
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
if sys.platform != "win32":
assert False, "This backend is only available on Windows"
import asyncio
import ctypes
from ctypes import wintypes
from enum import IntEnum
from bleak._compat import timeout as async_timeout
from bleak.exc import BleakError
def _check_result(result: int, func: Any, args: Any) -> Any:
if not result:
raise ctypes.WinError()
return args
def check_hresult(result: int, func: Any, args: Any) -> Any:
if result:
raise ctypes.WinError(result)
return args
# not defined in wintypes
_UINT_PTR = wintypes.WPARAM
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc
_TIMERPROC = ctypes.WINFUNCTYPE(
None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD
)
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer
_SET_TIMER_PROTOTYPE = ctypes.WINFUNCTYPE(
_UINT_PTR, wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC
)
_SET_TIMER_PARAM_FLAGS = (
(1, "hwnd", None),
(1, "nidevent"),
(1, "uelapse"),
(1, "lptimerfunc", None),
)
_SetTimer = _SET_TIMER_PROTOTYPE(
("SetTimer", ctypes.windll.user32), _SET_TIMER_PARAM_FLAGS
)
_SetTimer.errcheck = _check_result # type: ignore[assignment]
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer
_KILL_TIMER_PROTOTYPE = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, _UINT_PTR)
_KILL_TIMER_PARAM_FLAGS = (
(1, "hwnd", None),
(1, "uidevent"),
)
_KillTimer = _KILL_TIMER_PROTOTYPE(
("KillTimer", ctypes.windll.user32), _KILL_TIMER_PARAM_FLAGS
)
# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype
_CO_GET_APARTMENT_TYPE_PROTOTYPE = ctypes.WINFUNCTYPE(
ctypes.c_int,
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
)
_CO_GET_APARTMENT_TYPE_PARAM_FLAGS = (
(1, "papttype", None),
(1, "paptqualifier", None),
)
_CoGetApartmentType = _CO_GET_APARTMENT_TYPE_PROTOTYPE(
("CoGetApartmentType", ctypes.windll.ole32), _CO_GET_APARTMENT_TYPE_PARAM_FLAGS
)
_CoGetApartmentType.errcheck = check_hresult # type: ignore[assignment]
_CO_E_NOTINITIALIZED = -2147221008
# https://learn.microsoft.com/en-us/windows/win32/api/objidl/ne-objidl-apttype
class _AptType(IntEnum):
CURRENT = -1
STA = 0
MTA = 1
NA = 2
MAIN_STA = 3
# https://learn.microsoft.com/en-us/windows/win32/api/objidl/ne-objidl-apttypequalifier
class _AptQualifierType(IntEnum):
NONE = 0
IMPLICIT_MTA = 1
NA_ON_MTA = 2
NA_ON_STA = 3
NA_ON_IMPLICIT_STA = 4
NA_ON_MAIN_STA = 5
APPLICATION_STA = 6
RESERVED_1 = 7
def _get_apartment_type() -> tuple[_AptType, _AptQualifierType]:
"""
Calls CoGetApartmentType to get the current apartment type and qualifier.
Returns:
The current apartment type and qualifier.
Raises:
OSError: If the call to CoGetApartmentType fails.
"""
api_type = ctypes.c_int()
api_type_qualifier = ctypes.c_int()
_CoGetApartmentType(ctypes.byref(api_type), ctypes.byref(api_type_qualifier))
return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value)
async def assert_mta() -> None:
"""
Asserts that the current apartment type is MTA.
Raises:
BleakError:
If the current apartment type is not MTA and there is no Windows
message loop running.
.. versionadded:: 0.22
.. versionchanged:: 0.22.2
Function is now async and will not raise if the current apartment type
is STA and the Windows message loop is running.
"""
if hasattr(allow_sta, "_allowed"):
return
try:
apt_type, _ = _get_apartment_type()
except OSError as e:
# All is OK if not initialized yet. WinRT will initialize it.
if e.winerror == _CO_E_NOTINITIALIZED:
return
raise
if apt_type == _AptType.MTA:
# if we get here, WinRT probably set the apartment type to MTA and all
# is well, we don't need to check again
setattr(allow_sta, "_allowed", True)
return
event = asyncio.Event()
def wait_event(hwnd: int, uidevent: int, uelapse: int, dwtime: int) -> None:
event.set()
# have to keep a reference to the callback or it will be garbage collected
# before it is called
callback = _TIMERPROC(wait_event)
# set a timer to see if we get a callback to ensure the windows event loop
# is running
timer = _SetTimer(None, 1, 0, callback)
try:
async with async_timeout(0.5):
await event.wait()
except asyncio.TimeoutError:
raise BleakError(
"Thread is configured for Windows GUI but callbacks are not working."
+ (
" Suspect unwanted side effects from importing 'pythoncom'."
if "pythoncom" in sys.modules
else ""
)
)
else:
# if the windows event loop is running, we assume it is going to keep
# running and we don't need to check again
setattr(allow_sta, "_allowed", True)
finally:
_KillTimer(None, timer)
def allow_sta() -> None:
"""
Suppress check for MTA thread type and allow STA.
Bleak will hang forever if the current thread is not MTA - unless there is
a Windows event loop running that is properly integrated with asyncio in
Python.
If your program meets that condition, you must call this function do disable
the check for MTA. If your program doesn't have a graphical user interface
you probably shouldn't call this function. and use ``uninitialize_sta()``
instead.
.. versionadded:: 0.22.1
"""
setattr(allow_sta, "_allowed", True)
def uninitialize_sta():
"""
Uninitialize the COM library on the current thread if it was not initialized
as MTA.
This is intended to undo the implicit initialization of the COM library as STA
by packages like pywin32.
It should be called as early as possible in your application after the
offending package has been imported.
.. versionadded:: 0.22
"""
try:
_get_apartment_type()
except OSError as e:
# All is OK if not initialized yet. WinRT will initialize it.
if e.winerror == _CO_E_NOTINITIALIZED:
return
else:
ctypes.windll.ole32.CoUninitialize()

View File

@ -0,0 +1,344 @@
from __future__ import annotations
import enum
import uuid
from typing import Any, Optional, Union
class BleakError(Exception):
"""Base Exception for bleak."""
pass
class BleakBluetoothNotAvailableReason(enum.Enum):
"""
Reasons for Bluetooth not being available.
.. versionadded:: 2.0
"""
NO_BLUETOOTH = enum.auto()
"""
The system does not support Bluetooth. I.e. there is no Bluetooth radio.
"""
NO_BLE_CENTRAL_ROLE = enum.auto()
"""
The Bluetooth radio does not support the Central role. (E.g. classic-only adapters.)
"""
POWERED_OFF = enum.auto()
"""
Bluetooth is not currently available because the radio is turned off.
"""
DENIED_BY_USER = enum.auto()
"""
The user denied permission for the app to use Bluetooth when prompted.
"""
DENIED_BY_SYSTEM = enum.auto()
"""
Using Bluetooth was denied by the system. E.g. because of a system administrator policy.
"""
DENIED_BY_UNKNOWN = enum.auto()
"""
Permission to use Bluetooth was denied for an unknown reason.
"""
UNKNOWN = enum.auto()
"""
Bluetooth is not available for an unknown reason.
"""
class BleakBluetoothNotAvailableError(BleakError):
"""
Exception which is raised if the Bluetooth access is not available for some reason.
.. versionadded:: 2.0
"""
def __init__(self, msg: str, reason: BleakBluetoothNotAvailableReason) -> None:
super().__init__(msg, reason)
@property
def reason(self) -> BleakBluetoothNotAvailableReason:
"""
Gets the reason why Bluetooth is not available.
"""
return self.args[1]
class BleakCharacteristicNotFoundError(BleakError):
"""
Exception which is raised if a device does not support a characteristic.
.. versionadded:: 0.22
"""
char_specifier: Union[int, str, uuid.UUID]
def __init__(self, char_specifier: Union[int, str, uuid.UUID]) -> None:
"""
Args:
characteristic (str): handle or UUID of the characteristic which was not found
"""
super().__init__(f"Characteristic {char_specifier} was not found!")
self.char_specifier = char_specifier
class BleakDeviceNotFoundError(BleakError):
"""
Exception which is raised if a device can not be found by ``connect``, ``pair`` and ``unpair``.
This is the case if the OS Bluetooth stack has never seen this device or it was removed and forgotten.
.. versionadded:: 0.19
"""
identifier: str
def __init__(self, identifier: str, *args: object) -> None:
"""
Args:
identifier (str): device identifier (Bluetooth address or UUID) of the device which was not found
"""
super().__init__(*args)
self.identifier = identifier
class BleakDBusError(BleakError):
"""Specialized exception type for D-Bus errors."""
def __init__(self, dbus_error: str, error_body: list[Any]):
"""
Args:
dbus_error (str): The D-Bus error, e.g. ``org.freedesktop.DBus.Error.UnknownObject``.
error_body (list): Body of the D-Bus error, sometimes containing error description or details.
"""
super().__init__(dbus_error, *error_body)
@property
def dbus_error(self) -> str:
"""Gets the D-Bus error name, e.g. ``org.freedesktop.DBus.Error.UnknownObject``."""
return self.args[0]
@property
def dbus_error_details(self) -> Optional[str]:
"""Gets the optional D-Bus error details, e.g. 'Invalid UUID'."""
if len(self.args) > 1:
details = self.args[1]
# Some error descriptions can be further parsed to be even more helpful
if "ATT error: 0x" in details:
more_detail = PROTOCOL_ERROR_CODES.get(
int(details.rsplit("x")[1], 16), "Unknown code"
)
details += f" ({more_detail})"
return details
return None
def __str__(self) -> str:
name = f"[{self.dbus_error}]"
details = self.dbus_error_details
return (name + " " + details) if details else name
CONTROLLER_ERROR_CODES = {
0x00: "Success",
0x01: "Unknown HCI Command",
0x02: "Unknown Connection Identifier",
0x03: "Hardware Failure",
0x04: "Page Timeout",
0x05: "Authentication Failure",
0x06: "PIN or Key Missing",
0x07: "Memory Capacity Exceeded",
0x08: "Connection Timeout",
0x09: "Connection Limit Exceeded",
0x0A: "Synchronous Connection Limit To A Device Exceeded",
0x0B: "Connection Already Exists",
0x0C: "Command Disallowed",
0x0D: "Connection Rejected due to Limited Resources",
0x0E: "Connection Rejected Due To Security Reasons",
0x0F: "Connection Rejected due to Unacceptable BD_ADDR",
0x10: "Connection Accept Timeout Exceeded",
0x11: "Unsupported Feature or Parameter Value",
0x12: "Invalid HCI Command Parameters",
0x13: "Remote User Terminated Connection",
0x14: "Remote Device Terminated Connection due to Low Resources",
0x15: "Remote Device Terminated Connection due to Power Off",
0x16: "Connection Terminated By Local Host",
0x17: "Repeated Attempts",
0x18: "Pairing Not Allowed",
0x19: "Unknown LMP PDU",
0x1A: "Unsupported Remote Feature / Unsupported LMP Feature",
0x1B: "SCO Offset Rejected",
0x1C: "SCO Interval Rejected",
0x1D: "SCO Air Mode Rejected",
0x1E: "Invalid LMP Parameters / Invalid LL Parameters",
0x1F: "Unspecified Error",
0x20: "Unsupported LMP Parameter Value / Unsupported LL Parameter Value",
0x21: "Role Change Not Allowed",
0x22: "LMP Response Timeout / LL Response Timeout",
0x23: "LMP Error Transaction Collision / LL Procedure Collision",
0x24: "LMP PDU Not Allowed",
0x25: "Encryption Mode Not Acceptable",
0x26: "Link Key cannot be Changed",
0x27: "Requested QoS Not Supported",
0x28: "Instant Passed",
0x29: "Pairing With Unit Key Not Supported",
0x2A: "Different Transaction Collision",
0x2B: "Reserved for future use",
0x2C: "QoS Unacceptable Parameter",
0x2D: "QoS Rejected",
0x2E: "Channel Classification Not Supported",
0x2F: "Insufficient Security",
0x30: "Parameter Out Of Mandatory Range",
0x31: "Reserved for future use",
0x32: "Role Switch Pending",
0x33: "Reserved for future use",
0x34: "Reserved Slot Violation",
0x35: "Role Switch Failed",
0x36: "Extended Inquiry Response Too Large",
0x37: "Secure Simple Pairing Not Supported By Host",
0x38: "Host Busy - Pairing",
0x39: "Connection Rejected due to No Suitable Channel Found",
0x3A: "Controller Busy",
0x3B: "Unacceptable Connection Parameters",
0x3C: "Advertising Timeout",
0x3D: "Connection Terminated due to MIC Failure",
0x3E: "Connection Failed to be Established / Synchronization Timeout",
0x3F: "MAC Connection Failed",
0x40: "Coarse Clock Adjustment Rejected but Will Try to Adjust Using Clock",
0x41: "Type0 Submap Not Defined",
0x42: "Unknown Advertising Identifier",
0x43: "Limit Reached",
0x44: "Operation Cancelled by Host",
0x45: "Packet Too Long",
}
# as defined in Bluetooth Core Specification v5.2, volume 3, part F, section 3.4.1.1, table 3.4.
PROTOCOL_ERROR_CODES = {
0x01: "Invalid Handle",
0x02: "Read Not Permitted",
0x03: "Write Not Permitted",
0x04: "Invalid PDU",
0x05: "Insufficient Authentication",
0x06: "Request Not Supported",
0x07: "Invalid Offset",
0x08: "Insufficient Authorization",
0x09: "Prepare Queue Full",
0x0A: "Attribute Not Found",
0x0B: "Attribute Not Long",
0x0C: "Insufficient Encryption Key Size",
0x0D: "Invalid Attribute Value Length",
0x0E: "Unlikely Error",
0x0F: "Insufficient Encryption",
0x10: "Unsupported Group Type",
0x11: "Insufficient Resource",
0x12: "Database Out Of Sync",
0x13: "Value Not Allowed",
0x80: "Application-specific Error 0x80",
0x81: "Application-specific Error 0x81",
0x82: "Application-specific Error 0x82",
0x83: "Application-specific Error 0x83",
0x84: "Application-specific Error 0x84",
0x85: "Application-specific Error 0x85",
0x86: "Application-specific Error 0x86",
0x87: "Application-specific Error 0x87",
0x88: "Application-specific Error 0x88",
0x89: "Application-specific Error 0x89",
0x8A: "Application-specific Error 0x8A",
0x8B: "Application-specific Error 0x8B",
0x8C: "Application-specific Error 0x8C",
0x8D: "Application-specific Error 0x8D",
0x8E: "Application-specific Error 0x8E",
0x8F: "Application-specific Error 0x8F",
0x90: "Application-specific Error 0x90",
0x91: "Application-specific Error 0x91",
0x92: "Application-specific Error 0x92",
0x93: "Application-specific Error 0x93",
0x94: "Application-specific Error 0x94",
0x95: "Application-specific Error 0x95",
0x96: "Application-specific Error 0x96",
0x97: "Application-specific Error 0x97",
0x98: "Application-specific Error 0x98",
0x99: "Application-specific Error 0x99",
0x9A: "Application-specific Error 0x9A",
0x9B: "Application-specific Error 0x9B",
0x9C: "Application-specific Error 0x9C",
0x9D: "Application-specific Error 0x9D",
0x9E: "Application-specific Error 0x9E",
0x9F: "Application-specific Error 0x9F",
0xFC: "Write Request Rejected",
0xFD: "Client Characteristic Configuration Descriptor Improperly Configured",
0xFE: "Procedure Already in Progress",
0xFF: "Out of Range",
}
class BleakGATTProtocolErrorCode(enum.IntEnum):
"""
Enumeration of GATT protocol error codes.
.. versionadded:: 3.0
"""
INVALID_HANDLE = 0x01
READ_NOT_PERMITTED = 0x02
WRITE_NOT_PERMITTED = 0x03
INVALID_PDU = 0x04
INSUFFICIENT_AUTHENTICATION = 0x05
REQUEST_NOT_SUPPORTED = 0x06
INVALID_OFFSET = 0x07
INSUFFICIENT_AUTHORIZATION = 0x08
PREPARE_QUEUE_FULL = 0x09
ATTRIBUTE_NOT_FOUND = 0x0A
ATTRIBUTE_NOT_LONG = 0x0B
INSUFFICIENT_ENCRYPTION_KEY_SIZE = 0x0C
INVALID_ATTRIBUTE_VALUE_LENGTH = 0x0D
UNLIKELY_ERROR = 0x0E
INSUFFICIENT_ENCRYPTION = 0x0F
UNSUPPORTED_GROUP_TYPE = 0x10
INSUFFICIENT_RESOURCE = 0x11
DATABASE_OUT_OF_SYNC = 0x12
VALUE_NOT_ALLOWED = 0x13
WRITE_REQUEST_REJECTED = 0xFC
CCCD_IMPROPERLY_CONFIGURED = 0xFD
PROCEDURE_ALREADY_IN_PROGRESS = 0xFE
OUT_OF_RANGE = 0xFF
@classmethod
def _missing_(cls, value: Any) -> BleakGATTProtocolErrorCode | None:
try:
obj = int.__new__(cls, value)
except TypeError: # pragma: no cover
return None
obj._value_ = value
obj._name_ = f"{cls.__name__}[{value}]"
return obj
class BleakGATTProtocolError(BleakError):
"""
Exception which is raised if a GATT protocol error occurs.
.. versionadded:: 3.0
"""
def __init__(self, error_code: int) -> None:
"""
Args:
error_code (int): The GATT protocol error code.
"""
error_message = PROTOCOL_ERROR_CODES.get(error_code, "Unknown code")
super().__init__(error_code, f"GATT Protocol Error: {error_message}")
@property
def code(self) -> BleakGATTProtocolErrorCode:
"""
Gets the GATT protocol error code.
"""
return BleakGATTProtocolErrorCode(self.args[0])

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More