gatt-link-ble 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gatt_link_ble-0.1.1/.github/agents/phase-builder.agent.md +11 -0
- gatt_link_ble-0.1.1/.github/copilot-instructions.md +8 -0
- gatt_link_ble-0.1.1/.github/workflows/ci.yml +35 -0
- gatt_link_ble-0.1.1/.github/workflows/publish.yml +49 -0
- gatt_link_ble-0.1.1/.github/workflows/release.yml +61 -0
- gatt_link_ble-0.1.1/.gitignore +8 -0
- gatt_link_ble-0.1.1/CHANGELOG.md +4 -0
- gatt_link_ble-0.1.1/LICENSE +21 -0
- gatt_link_ble-0.1.1/PKG-INFO +133 -0
- gatt_link_ble-0.1.1/README.md +121 -0
- gatt_link_ble-0.1.1/pyproject.toml +72 -0
- gatt_link_ble-0.1.1/scripts/live_scan.py +99 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/__init__.py +21 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/client.py +406 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/const.py +23 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/exceptions.py +13 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/models.py +45 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/__init__.py +13 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/base.py +42 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/battery.py +29 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/device_info.py +52 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/environmental.py +42 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/parsers/thingy_env.py +49 -0
- gatt_link_ble-0.1.1/src/gatt_link_ble/registry.py +37 -0
- gatt_link_ble-0.1.1/tests/conftest.py +114 -0
- gatt_link_ble-0.1.1/tests/parsers/test_battery.py +34 -0
- gatt_link_ble-0.1.1/tests/parsers/test_device_info.py +44 -0
- gatt_link_ble-0.1.1/tests/parsers/test_environmental.py +49 -0
- gatt_link_ble-0.1.1/tests/parsers/test_thingy_env.py +67 -0
- gatt_link_ble-0.1.1/tests/test_client.py +411 -0
- gatt_link_ble-0.1.1/tests/test_const.py +22 -0
- gatt_link_ble-0.1.1/tests/test_exceptions.py +6 -0
- gatt_link_ble-0.1.1/tests/test_models.py +45 -0
- gatt_link_ble-0.1.1/tests/test_registry.py +60 -0
- gatt_link_ble-0.1.1/tests/test_scaffold.py +5 -0
- gatt_link_ble-0.1.1/uv.lock +849 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Executes a single implementation phase from ARCHITECTURE.md with automated lint, typecheck, and pytest verification.
|
|
3
|
+
tools: ['edit', 'runNotebooks', 'search', 'new', 'runCommands', 'problems', 'usages', 'findTestFiles']
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an expert Python and Open Home Foundation developer.
|
|
7
|
+
1. Read `ARCHITECTURE.md` and inspect the current directory tree to see which phases are already complete.
|
|
8
|
+
2. Implement ONLY the requested phase (or the next incomplete phase) cleanly and completely.
|
|
9
|
+
3. Write unit/functional tests covering edge cases (negative numbers, malformed payloads, disconnects) alongside the implementation.
|
|
10
|
+
4. Run the verification suite in terminal (`uv run ruff check --fix . && uv run ruff format . && uv run mypy . && uv run pytest`) and resolve any failures before responding.
|
|
11
|
+
5. Keep your final summary concise (under 10 bullet points) to conserve tokens.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Copilot Instructions — `gatt-link-ble`
|
|
2
|
+
|
|
3
|
+
- **Architecture:** Always follow `ARCHITECTURE.md` in the repo root. Execute one phase at a time.
|
|
4
|
+
- **Strict Boundary:** NEVER import `homeassistant` or `habluetooth`. This is a pure Python PyPI library.
|
|
5
|
+
- **Tooling:** Always use `uv run` for commands (`uv run pytest`, `uv run mypy src tests`, `uv run ruff check .`). Never use raw `pip`.
|
|
6
|
+
- **BLE Rules:** Use `bleak-retry-connector` (`establish_connection` + `BleakClientWithServiceCache`). Unpack binary payloads strictly with Python's `struct` module—never slice hex strings.
|
|
7
|
+
- **Code Quality:** Enforce Python 3.12+ type annotations (`mypy --strict`) and immutable `@dataclass(frozen=True)` state models.
|
|
8
|
+
- **Verification:** Before finishing any task, run `uv run ruff check . && uv run mypy src tests && uv run pytest` and fix all errors in the same turn.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: ${{ matrix.os }} / Python ${{ matrix.python-version }}
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-24.04, windows-2025]
|
|
20
|
+
python-version: ["3.12", "3.13"]
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
+
|
|
24
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
|
|
32
|
+
- run: uv sync --locked --all-groups
|
|
33
|
+
- run: uv run --locked ruff check .
|
|
34
|
+
- run: uv run --locked mypy src tests
|
|
35
|
+
- run: uv run --locked pytest
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
tag:
|
|
10
|
+
description: Existing version tag to publish
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
publish:
|
|
19
|
+
name: Build, verify, and publish
|
|
20
|
+
runs-on: ubuntu-24.04
|
|
21
|
+
environment:
|
|
22
|
+
name: pypi
|
|
23
|
+
url: https://pypi.org/project/gatt-link-ble/
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
id-token: write
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
29
|
+
with:
|
|
30
|
+
ref: ${{ inputs.tag || github.ref }}
|
|
31
|
+
|
|
32
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
33
|
+
with:
|
|
34
|
+
python-version: "3.12"
|
|
35
|
+
|
|
36
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
37
|
+
with:
|
|
38
|
+
enable-cache: true
|
|
39
|
+
|
|
40
|
+
- name: Install locked dependencies
|
|
41
|
+
run: uv sync --locked --all-groups
|
|
42
|
+
|
|
43
|
+
- run: uv run --locked ruff check .
|
|
44
|
+
- run: uv run --locked mypy src tests
|
|
45
|
+
- run: uv run --locked pytest
|
|
46
|
+
- run: uv build
|
|
47
|
+
|
|
48
|
+
- name: Publish to PyPI using OIDC
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_run:
|
|
5
|
+
workflows: ["CI"]
|
|
6
|
+
types: [completed]
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: release-main
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
release:
|
|
18
|
+
if: >-
|
|
19
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
20
|
+
github.event.workflow_run.head_branch == 'main' &&
|
|
21
|
+
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
|
|
22
|
+
name: Create semantic release
|
|
23
|
+
runs-on: ubuntu-24.04
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
26
|
+
with:
|
|
27
|
+
ref: main
|
|
28
|
+
fetch-depth: 0
|
|
29
|
+
token: ${{ secrets.RELEASE_PAT }}
|
|
30
|
+
|
|
31
|
+
- name: Confirm CI checked the current main commit
|
|
32
|
+
id: current-main
|
|
33
|
+
env:
|
|
34
|
+
CI_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
35
|
+
run: |
|
|
36
|
+
if [[ "$(git rev-parse HEAD)" == "$CI_SHA" ]]; then
|
|
37
|
+
echo "current=true" >> "$GITHUB_OUTPUT"
|
|
38
|
+
else
|
|
39
|
+
echo "CI result is stale; main has advanced"
|
|
40
|
+
echo "current=false" >> "$GITHUB_OUTPUT"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
44
|
+
if: steps.current-main.outputs.current == 'true'
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.12"
|
|
47
|
+
|
|
48
|
+
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
49
|
+
if: steps.current-main.outputs.current == 'true'
|
|
50
|
+
with:
|
|
51
|
+
enable-cache: true
|
|
52
|
+
|
|
53
|
+
- name: Install uv.lock
|
|
54
|
+
if: steps.current-main.outputs.current == 'true'
|
|
55
|
+
run: uv sync --locked --all-groups
|
|
56
|
+
|
|
57
|
+
- name: Release with Python Semantic Release
|
|
58
|
+
if: steps.current-main.outputs.current == 'true'
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
|
|
61
|
+
run: uv run --with python-semantic-release==10.7.0 --no-project semantic-release version
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 infoyouth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gatt-link-ble
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Async BLE GATT discovery, parsing, and state updates.
|
|
5
|
+
Author: infoyouth
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: bleak-retry-connector>=3.6.0
|
|
10
|
+
Requires-Dist: bleak>=0.22.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# gatt-link-ble
|
|
14
|
+
|
|
15
|
+
An asynchronous, framework-independent Python library for discovering BLE GATT
|
|
16
|
+
services, decoding sensor values, and receiving device-state updates.
|
|
17
|
+
|
|
18
|
+
The package owns BLE transport and parsing. It has no Home Assistant, MQTT, or
|
|
19
|
+
UI-framework dependencies.
|
|
20
|
+
|
|
21
|
+
## Requirements and installation
|
|
22
|
+
|
|
23
|
+
- Python 3.12 or newer
|
|
24
|
+
- BLE adapter and a device advertising supported GATT services
|
|
25
|
+
|
|
26
|
+
Install from PyPI:
|
|
27
|
+
|
|
28
|
+
```shell
|
|
29
|
+
python -m pip install gatt-link-ble
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Poll once
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
|
|
37
|
+
from bleak import BleakScanner
|
|
38
|
+
|
|
39
|
+
from gatt_link_ble import ConnectionMode, GattLinkClient
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def main() -> None:
|
|
43
|
+
address = "AA:BB:CC:DD:EE:FF"
|
|
44
|
+
device = await BleakScanner.find_device_by_address(address, timeout=10)
|
|
45
|
+
if device is None:
|
|
46
|
+
raise RuntimeError(f"BLE device not found: {address}")
|
|
47
|
+
|
|
48
|
+
client = GattLinkClient(device, mode=ConnectionMode.POLL_AND_DISCONNECT)
|
|
49
|
+
state = await client.poll()
|
|
50
|
+
print(state)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Receive push updates
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from bleak import BleakScanner
|
|
62
|
+
|
|
63
|
+
from gatt_link_ble import GattLinkClient
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def main() -> None:
|
|
67
|
+
address = "AA:BB:CC:DD:EE:FF"
|
|
68
|
+
device = await BleakScanner.find_device_by_address(address, timeout=10)
|
|
69
|
+
if device is None:
|
|
70
|
+
raise RuntimeError(f"BLE device not found: {address}")
|
|
71
|
+
|
|
72
|
+
client = GattLinkClient(device, state_callback=print)
|
|
73
|
+
await client.start()
|
|
74
|
+
try:
|
|
75
|
+
await asyncio.Event().wait()
|
|
76
|
+
finally:
|
|
77
|
+
await client.stop()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`PUSH` keeps the connection open, sends an initial state after connecting, and
|
|
84
|
+
reconnects after unexpected disconnects. `POLL_AND_DISCONNECT` reads available
|
|
85
|
+
characteristics once and disconnects before returning.
|
|
86
|
+
|
|
87
|
+
## Supported profiles
|
|
88
|
+
|
|
89
|
+
| Profile | Service UUID | Characteristics |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| Battery Service | `0x180F` | Battery Level `0x2A19` |
|
|
92
|
+
| Device Information | `0x180A` | Manufacturer `0x2A29`, Model `0x2A24`, Firmware `0x2A26`, Hardware `0x2A27` |
|
|
93
|
+
| Environmental Sensing | `0x181A` | Temperature `0x2A6E`, Humidity `0x2A6F`, Pressure `0x2A6D` |
|
|
94
|
+
| Nordic Thingy Environment | `ef680200-9b35-4933-9b10-52ffa9740042` | Temperature, pressure, humidity, eCO2, TVOC |
|
|
95
|
+
|
|
96
|
+
Parsed values are available through the immutable `GattDeviceState`,
|
|
97
|
+
`DeviceMetadata`, and `GattSensorData` models.
|
|
98
|
+
|
|
99
|
+
## Inspect a device
|
|
100
|
+
|
|
101
|
+
The hardware diagnostic script lists nearby devices and, when passed an
|
|
102
|
+
address, dumps raw bytes from readable characteristics:
|
|
103
|
+
|
|
104
|
+
```shell
|
|
105
|
+
uv run python scripts/live_scan.py
|
|
106
|
+
uv run python scripts/live_scan.py AA:BB:CC:DD:EE:FF --timeout 10
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```shell
|
|
112
|
+
uv sync --locked --all-groups
|
|
113
|
+
uv run ruff check .
|
|
114
|
+
uv run mypy src tests
|
|
115
|
+
uv run pytest
|
|
116
|
+
uv build
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Tests use mocked BLE clients and GATT services; no hardware is needed.
|
|
120
|
+
`ARCHITECTURE.md` is a local implementation guide and is intentionally not
|
|
121
|
+
included in the public repository.
|
|
122
|
+
|
|
123
|
+
## Releases
|
|
124
|
+
|
|
125
|
+
After CI succeeds on `main`, Python Semantic Release uses Conventional Commits
|
|
126
|
+
(`feat:` for minor, `fix:` or `perf:` for patch, and `!` for breaking changes)
|
|
127
|
+
to update the version, create a GitHub release, and push a tag. Configure the
|
|
128
|
+
repository Actions secret `RELEASE_PAT` with a GitHub token that can write to
|
|
129
|
+
this repository and whose account is allowed to bypass the `main` branch rules.
|
|
130
|
+
|
|
131
|
+
The PAT is only for GitHub release operations; it is not a PyPI token. PyPI
|
|
132
|
+
publishing uses OIDC Trusted Publishing. Configure the PyPI publisher for this
|
|
133
|
+
repository's `publish.yml` workflow before the first upload.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# gatt-link-ble
|
|
2
|
+
|
|
3
|
+
An asynchronous, framework-independent Python library for discovering BLE GATT
|
|
4
|
+
services, decoding sensor values, and receiving device-state updates.
|
|
5
|
+
|
|
6
|
+
The package owns BLE transport and parsing. It has no Home Assistant, MQTT, or
|
|
7
|
+
UI-framework dependencies.
|
|
8
|
+
|
|
9
|
+
## Requirements and installation
|
|
10
|
+
|
|
11
|
+
- Python 3.12 or newer
|
|
12
|
+
- BLE adapter and a device advertising supported GATT services
|
|
13
|
+
|
|
14
|
+
Install from PyPI:
|
|
15
|
+
|
|
16
|
+
```shell
|
|
17
|
+
python -m pip install gatt-link-ble
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Poll once
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
|
|
25
|
+
from bleak import BleakScanner
|
|
26
|
+
|
|
27
|
+
from gatt_link_ble import ConnectionMode, GattLinkClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def main() -> None:
|
|
31
|
+
address = "AA:BB:CC:DD:EE:FF"
|
|
32
|
+
device = await BleakScanner.find_device_by_address(address, timeout=10)
|
|
33
|
+
if device is None:
|
|
34
|
+
raise RuntimeError(f"BLE device not found: {address}")
|
|
35
|
+
|
|
36
|
+
client = GattLinkClient(device, mode=ConnectionMode.POLL_AND_DISCONNECT)
|
|
37
|
+
state = await client.poll()
|
|
38
|
+
print(state)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Receive push updates
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from bleak import BleakScanner
|
|
50
|
+
|
|
51
|
+
from gatt_link_ble import GattLinkClient
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
address = "AA:BB:CC:DD:EE:FF"
|
|
56
|
+
device = await BleakScanner.find_device_by_address(address, timeout=10)
|
|
57
|
+
if device is None:
|
|
58
|
+
raise RuntimeError(f"BLE device not found: {address}")
|
|
59
|
+
|
|
60
|
+
client = GattLinkClient(device, state_callback=print)
|
|
61
|
+
await client.start()
|
|
62
|
+
try:
|
|
63
|
+
await asyncio.Event().wait()
|
|
64
|
+
finally:
|
|
65
|
+
await client.stop()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`PUSH` keeps the connection open, sends an initial state after connecting, and
|
|
72
|
+
reconnects after unexpected disconnects. `POLL_AND_DISCONNECT` reads available
|
|
73
|
+
characteristics once and disconnects before returning.
|
|
74
|
+
|
|
75
|
+
## Supported profiles
|
|
76
|
+
|
|
77
|
+
| Profile | Service UUID | Characteristics |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| Battery Service | `0x180F` | Battery Level `0x2A19` |
|
|
80
|
+
| Device Information | `0x180A` | Manufacturer `0x2A29`, Model `0x2A24`, Firmware `0x2A26`, Hardware `0x2A27` |
|
|
81
|
+
| Environmental Sensing | `0x181A` | Temperature `0x2A6E`, Humidity `0x2A6F`, Pressure `0x2A6D` |
|
|
82
|
+
| Nordic Thingy Environment | `ef680200-9b35-4933-9b10-52ffa9740042` | Temperature, pressure, humidity, eCO2, TVOC |
|
|
83
|
+
|
|
84
|
+
Parsed values are available through the immutable `GattDeviceState`,
|
|
85
|
+
`DeviceMetadata`, and `GattSensorData` models.
|
|
86
|
+
|
|
87
|
+
## Inspect a device
|
|
88
|
+
|
|
89
|
+
The hardware diagnostic script lists nearby devices and, when passed an
|
|
90
|
+
address, dumps raw bytes from readable characteristics:
|
|
91
|
+
|
|
92
|
+
```shell
|
|
93
|
+
uv run python scripts/live_scan.py
|
|
94
|
+
uv run python scripts/live_scan.py AA:BB:CC:DD:EE:FF --timeout 10
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```shell
|
|
100
|
+
uv sync --locked --all-groups
|
|
101
|
+
uv run ruff check .
|
|
102
|
+
uv run mypy src tests
|
|
103
|
+
uv run pytest
|
|
104
|
+
uv build
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tests use mocked BLE clients and GATT services; no hardware is needed.
|
|
108
|
+
`ARCHITECTURE.md` is a local implementation guide and is intentionally not
|
|
109
|
+
included in the public repository.
|
|
110
|
+
|
|
111
|
+
## Releases
|
|
112
|
+
|
|
113
|
+
After CI succeeds on `main`, Python Semantic Release uses Conventional Commits
|
|
114
|
+
(`feat:` for minor, `fix:` or `perf:` for patch, and `!` for breaking changes)
|
|
115
|
+
to update the version, create a GitHub release, and push a tag. Configure the
|
|
116
|
+
repository Actions secret `RELEASE_PAT` with a GitHub token that can write to
|
|
117
|
+
this repository and whose account is allowed to bypass the `main` branch rules.
|
|
118
|
+
|
|
119
|
+
The PAT is only for GitHub release operations; it is not a PyPI token. PyPI
|
|
120
|
+
publishing uses OIDC Trusted Publishing. Configure the PyPI publisher for this
|
|
121
|
+
repository's `publish.yml` workflow before the first upload.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gatt-link-ble"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Async BLE GATT discovery, parsing, and state updates."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "infoyouth" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"bleak>=0.22.0",
|
|
15
|
+
"bleak-retry-connector>=3.6.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"mypy>=1.11.0",
|
|
21
|
+
"pytest>=8.3.0",
|
|
22
|
+
"pytest-asyncio>=0.24.0",
|
|
23
|
+
"pytest-cov>=5.0.0",
|
|
24
|
+
"ruff>=0.6.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/gatt_link_ble"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 88
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.12"
|
|
39
|
+
strict = true
|
|
40
|
+
warn_return_any = true
|
|
41
|
+
mypy_path = "src"
|
|
42
|
+
packages = ["gatt_link_ble"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = [
|
|
49
|
+
"--strict-config",
|
|
50
|
+
"--strict-markers",
|
|
51
|
+
"--cov=gatt_link_ble",
|
|
52
|
+
"--cov-report=term-missing",
|
|
53
|
+
"--cov-fail-under=95",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.semantic_release]
|
|
57
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
58
|
+
commit_parser = "conventional"
|
|
59
|
+
tag_format = "v{version}"
|
|
60
|
+
commit_message = "chore(release): {version}"
|
|
61
|
+
build_command = "uv lock --no-upgrade && uv build"
|
|
62
|
+
allow_zero_version = true
|
|
63
|
+
|
|
64
|
+
[tool.semantic_release.commit_parser_options]
|
|
65
|
+
parse_squash_commits = true
|
|
66
|
+
ignore_merge_commits = true
|
|
67
|
+
|
|
68
|
+
[tool.semantic_release.branches.main]
|
|
69
|
+
match = "^main$"
|
|
70
|
+
|
|
71
|
+
[tool.semantic_release.changelog]
|
|
72
|
+
default_templates = { changelog_file = "CHANGELOG.md" }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Discover nearby BLE devices and dump readable GATT characteristic values."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bleak import BleakScanner
|
|
8
|
+
from bleak.backends.device import BLEDevice
|
|
9
|
+
from bleak.exc import BleakError
|
|
10
|
+
from bleak_retry_connector import (
|
|
11
|
+
BleakClientWithServiceCache,
|
|
12
|
+
establish_connection,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_args() -> argparse.Namespace:
|
|
19
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"address",
|
|
22
|
+
nargs="?",
|
|
23
|
+
help="BLE address to inspect; omit to list discovered devices",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--timeout",
|
|
27
|
+
type=float,
|
|
28
|
+
default=5.0,
|
|
29
|
+
help="scan duration in seconds (default: 5)",
|
|
30
|
+
)
|
|
31
|
+
return parser.parse_args()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _inspect_device(device: BLEDevice) -> None:
|
|
35
|
+
client = await establish_connection(
|
|
36
|
+
BleakClientWithServiceCache,
|
|
37
|
+
device,
|
|
38
|
+
device.name or device.address,
|
|
39
|
+
max_attempts=3,
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
for service in client.services:
|
|
43
|
+
print(f"\nService {service.uuid}")
|
|
44
|
+
for characteristic in service.characteristics:
|
|
45
|
+
properties = ", ".join(characteristic.properties)
|
|
46
|
+
print(f" Characteristic {characteristic.uuid} [{properties}]")
|
|
47
|
+
if "read" not in characteristic.properties:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
value = await client.read_gatt_char(characteristic)
|
|
51
|
+
except BleakError as err:
|
|
52
|
+
_LOGGER.warning(
|
|
53
|
+
"Could not read characteristic %s: %s",
|
|
54
|
+
characteristic.uuid,
|
|
55
|
+
err,
|
|
56
|
+
)
|
|
57
|
+
continue
|
|
58
|
+
print(f" Raw: {value.hex(' ')}")
|
|
59
|
+
finally:
|
|
60
|
+
await client.disconnect()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _run(args: argparse.Namespace) -> None:
|
|
64
|
+
try:
|
|
65
|
+
devices = await BleakScanner.discover(timeout=args.timeout)
|
|
66
|
+
except BleakError as err:
|
|
67
|
+
raise SystemExit(f"BLE scan failed: {err}") from err
|
|
68
|
+
|
|
69
|
+
for device in devices:
|
|
70
|
+
print(f"{device.address} {device.name or '(unnamed)'}")
|
|
71
|
+
|
|
72
|
+
if args.address is None:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
match = next(
|
|
76
|
+
(
|
|
77
|
+
candidate
|
|
78
|
+
for candidate in devices
|
|
79
|
+
if candidate.address.casefold() == args.address.casefold()
|
|
80
|
+
),
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
if match is None:
|
|
84
|
+
raise SystemExit(f"No discovered BLE device matches {args.address}")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
await _inspect_device(match)
|
|
88
|
+
except BleakError as err:
|
|
89
|
+
raise SystemExit(f"Unable to inspect {match.address}: {err}") from err
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
"""Run the BLE scanner CLI."""
|
|
94
|
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
|
95
|
+
asyncio.run(_run(_parse_args()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Asynchronous BLE GATT discovery and parsing."""
|
|
2
|
+
|
|
3
|
+
from .client import GattLinkClient
|
|
4
|
+
from .exceptions import GattConnectionError, GattLinkError, GattParseError
|
|
5
|
+
from .models import (
|
|
6
|
+
ConnectionMode,
|
|
7
|
+
DeviceMetadata,
|
|
8
|
+
GattDeviceState,
|
|
9
|
+
GattSensorData,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ConnectionMode",
|
|
14
|
+
"DeviceMetadata",
|
|
15
|
+
"GattConnectionError",
|
|
16
|
+
"GattDeviceState",
|
|
17
|
+
"GattLinkClient",
|
|
18
|
+
"GattLinkError",
|
|
19
|
+
"GattParseError",
|
|
20
|
+
"GattSensorData",
|
|
21
|
+
]
|