python-esp-bridge 0.0.2__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.
- python_esp_bridge-0.0.2/.gitignore +218 -0
- python_esp_bridge-0.0.2/.python-version +1 -0
- python_esp_bridge-0.0.2/PKG-INFO +36 -0
- python_esp_bridge-0.0.2/README.md +23 -0
- python_esp_bridge-0.0.2/espbridge/__init__.py +45 -0
- python_esp_bridge-0.0.2/espbridge/analog.py +64 -0
- python_esp_bridge-0.0.2/espbridge/ble.py +223 -0
- python_esp_bridge-0.0.2/espbridge/bridge.py +495 -0
- python_esp_bridge-0.0.2/espbridge/cli.py +80 -0
- python_esp_bridge-0.0.2/espbridge/compat/__init__.py +0 -0
- python_esp_bridge-0.0.2/espbridge/compat/blinka.py +209 -0
- python_esp_bridge-0.0.2/espbridge/compat/gpiozero.py +233 -0
- python_esp_bridge-0.0.2/espbridge/compat/luma.py +90 -0
- python_esp_bridge-0.0.2/espbridge/compat/rpi_gpio.py +121 -0
- python_esp_bridge-0.0.2/espbridge/compat/smbus.py +63 -0
- python_esp_bridge-0.0.2/espbridge/constants.py +199 -0
- python_esp_bridge-0.0.2/espbridge/errors.py +38 -0
- python_esp_bridge-0.0.2/espbridge/gpio.py +72 -0
- python_esp_bridge-0.0.2/espbridge/i2c.py +65 -0
- python_esp_bridge-0.0.2/espbridge/net.py +279 -0
- python_esp_bridge-0.0.2/espbridge/oled.py +182 -0
- python_esp_bridge-0.0.2/espbridge/protocol.py +159 -0
- python_esp_bridge-0.0.2/espbridge/pwm.py +41 -0
- python_esp_bridge-0.0.2/espbridge/spi.py +43 -0
- python_esp_bridge-0.0.2/espbridge/transport.py +121 -0
- python_esp_bridge-0.0.2/espbridge/uart.py +111 -0
- python_esp_bridge-0.0.2/espbridge/wifi.py +128 -0
- python_esp_bridge-0.0.2/pyproject.toml +28 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-esp-bridge
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Control every ESP32 peripheral from Python over USB serial — GPIO, ADC, DAC, PWM, touch, I2C, SPI, UART, Wi-Fi sockets, BLE
|
|
5
|
+
Project-URL: Homepage, https://github.com/HamzaYslmn/python-esp-bridge
|
|
6
|
+
Author: HamzaYslmn
|
|
7
|
+
Keywords: ble,bridge,esp32,firmata,gpio,i2c,raspberry-pi,serial,spi
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: pyserial>=3.5
|
|
10
|
+
Provides-Extra: oled
|
|
11
|
+
Requires-Dist: pillow>=10; extra == 'oled'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# python-esp-bridge
|
|
15
|
+
|
|
16
|
+
Control every ESP32 peripheral from Python over USB serial — GPIO, PWM, ADC,
|
|
17
|
+
DAC, touch, I2C, SPI, UART, Wi-Fi (with TCP/UDP sockets through the ESP32
|
|
18
|
+
radio) and BLE. Flash the bridge firmware once, then it's all Python.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from espbridge import Bridge
|
|
22
|
+
|
|
23
|
+
with Bridge() as esp: # auto-detects the USB port
|
|
24
|
+
esp.gpio.mode(2, "output")
|
|
25
|
+
esp.gpio.write(2, 1)
|
|
26
|
+
print(esp.adc.read_mv(34), "mV")
|
|
27
|
+
esp.i2c.init(sda=21, scl=22)
|
|
28
|
+
print(esp.i2c.scan())
|
|
29
|
+
esp.wifi.connect("ssid", "password")
|
|
30
|
+
status, body = esp.net.http_get("http://example.com/")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- Firmware (flash once with Arduino IDE) and full docs:
|
|
34
|
+
**<https://github.com/HamzaYslmn/python-esp-bridge>**
|
|
35
|
+
- Works on Raspberry Pi OS, Linux, Windows, macOS (Python ≥ 3.10, pyserial).
|
|
36
|
+
- `espbridge` CLI: connection info; `espbridge ports`: list candidate ports.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# python-esp-bridge
|
|
2
|
+
|
|
3
|
+
Control every ESP32 peripheral from Python over USB serial — GPIO, PWM, ADC,
|
|
4
|
+
DAC, touch, I2C, SPI, UART, Wi-Fi (with TCP/UDP sockets through the ESP32
|
|
5
|
+
radio) and BLE. Flash the bridge firmware once, then it's all Python.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from espbridge import Bridge
|
|
9
|
+
|
|
10
|
+
with Bridge() as esp: # auto-detects the USB port
|
|
11
|
+
esp.gpio.mode(2, "output")
|
|
12
|
+
esp.gpio.write(2, 1)
|
|
13
|
+
print(esp.adc.read_mv(34), "mV")
|
|
14
|
+
esp.i2c.init(sda=21, scl=22)
|
|
15
|
+
print(esp.i2c.scan())
|
|
16
|
+
esp.wifi.connect("ssid", "password")
|
|
17
|
+
status, body = esp.net.http_get("http://example.com/")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- Firmware (flash once with Arduino IDE) and full docs:
|
|
21
|
+
**<https://github.com/HamzaYslmn/python-esp-bridge>**
|
|
22
|
+
- Works on Raspberry Pi OS, Linux, Windows, macOS (Python ≥ 3.10, pyserial).
|
|
23
|
+
- `espbridge` CLI: connection info; `espbridge ports`: list candidate ports.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""espbridge — control every ESP32 peripheral from Python over USB serial.
|
|
2
|
+
|
|
3
|
+
Flash esp/esp.ino once, then:
|
|
4
|
+
|
|
5
|
+
from espbridge import Bridge
|
|
6
|
+
|
|
7
|
+
with Bridge() as esp: # auto-detects the serial port
|
|
8
|
+
esp.gpio.mode(2, "output")
|
|
9
|
+
esp.gpio.write(2, 1)
|
|
10
|
+
print(esp.adc.read(34))
|
|
11
|
+
print(esp.i2c.scan())
|
|
12
|
+
esp.wifi.connect("ssid", "password")
|
|
13
|
+
sock = esp.net.tcp_connect("example.com", 80) # TCP through the ESP32 radio
|
|
14
|
+
"""
|
|
15
|
+
from .bridge import Bridge, BridgeSet, Info, connect_all
|
|
16
|
+
from .constants import Cap, ChipModel, Status
|
|
17
|
+
from .errors import (
|
|
18
|
+
BridgeError,
|
|
19
|
+
BridgeTimeoutError,
|
|
20
|
+
NoDeviceError,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
RemoteError,
|
|
23
|
+
UnsupportedError,
|
|
24
|
+
)
|
|
25
|
+
from .transport import find_ports
|
|
26
|
+
|
|
27
|
+
__version__ = "0.0.2"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Bridge",
|
|
31
|
+
"BridgeSet",
|
|
32
|
+
"connect_all",
|
|
33
|
+
"Info",
|
|
34
|
+
"Cap",
|
|
35
|
+
"ChipModel",
|
|
36
|
+
"Status",
|
|
37
|
+
"BridgeError",
|
|
38
|
+
"BridgeTimeoutError",
|
|
39
|
+
"NoDeviceError",
|
|
40
|
+
"ProtocolError",
|
|
41
|
+
"RemoteError",
|
|
42
|
+
"UnsupportedError",
|
|
43
|
+
"find_ports",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""ADC (oneshot reads), DAC (write + cosine generator), capacitive touch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
from . import constants as C
|
|
7
|
+
|
|
8
|
+
# Attenuation -> roughly full-scale input voltage on classic ESP32:
|
|
9
|
+
# 0 dB ≈ 1.1 V, 2.5 dB ≈ 1.5 V, 6 dB ≈ 2.2 V, 11 dB ≈ 3.3 V (default)
|
|
10
|
+
ATTEN = {0: 0, 2.5: 1, 6: 2, 11: 3, "0db": 0, "2.5db": 1, "6db": 2, "11db": 3}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Adc:
|
|
14
|
+
def __init__(self, bridge):
|
|
15
|
+
self._b = bridge
|
|
16
|
+
|
|
17
|
+
def config(self, pin: int, atten=11) -> None:
|
|
18
|
+
self._b.request(C.ADC_CONFIG, bytes([pin, ATTEN.get(atten, int(atten))]))
|
|
19
|
+
|
|
20
|
+
def read(self, pin: int) -> int:
|
|
21
|
+
"""Raw 12-bit reading (0..4095)."""
|
|
22
|
+
return struct.unpack(">H", self._b.request(C.ADC_READ, bytes([pin])))[0]
|
|
23
|
+
|
|
24
|
+
def read_mv(self, pin: int) -> int:
|
|
25
|
+
"""Calibrated millivolts."""
|
|
26
|
+
return struct.unpack(">H", self._b.request(C.ADC_READ_MV, bytes([pin])))[0]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Dac:
|
|
30
|
+
"""True 8-bit DAC — classic ESP32 (GPIO 25/26) and S2 (GPIO 17/18) only."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, bridge):
|
|
33
|
+
bridge.require(C.Cap.DAC, "DAC")
|
|
34
|
+
self._b = bridge
|
|
35
|
+
|
|
36
|
+
def write(self, pin: int, value: int) -> None:
|
|
37
|
+
"""Output value 0..255 (0..3.3 V)."""
|
|
38
|
+
self._b.request(C.DAC_WRITE, bytes([pin, value & 0xFF]))
|
|
39
|
+
|
|
40
|
+
def cosine(self, pin: int, freq_hz: int, *, scale: int = 0, offset: int = 0,
|
|
41
|
+
phase_180: bool = False) -> None:
|
|
42
|
+
"""Start the hardware cosine-wave generator (~130 Hz .. ~100 kHz).
|
|
43
|
+
|
|
44
|
+
scale: 0..3 = full/half/quarter/eighth amplitude.
|
|
45
|
+
"""
|
|
46
|
+
self._b.request(C.DAC_COSINE, struct.pack(">BIBbB", pin, freq_hz, scale & 3,
|
|
47
|
+
offset, 1 if phase_180 else 0))
|
|
48
|
+
|
|
49
|
+
def cosine_stop(self, pin: int) -> None:
|
|
50
|
+
self._b.request(C.DAC_COS_STOP, bytes([pin]))
|
|
51
|
+
|
|
52
|
+
def disable(self, pin: int) -> None:
|
|
53
|
+
self._b.request(C.DAC_DISABLE, bytes([pin]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Touch:
|
|
57
|
+
"""Capacitive touch pads (classic ESP32: lower = touched; S2/S3: higher = touched)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, bridge):
|
|
60
|
+
bridge.require(C.Cap.TOUCH, "touch sensing")
|
|
61
|
+
self._b = bridge
|
|
62
|
+
|
|
63
|
+
def read(self, pin: int) -> int:
|
|
64
|
+
return struct.unpack(">I", self._b.request(C.TOUCH_READ, bytes([pin])))[0]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""BLE: scanning, advertising, GATT server and basic GATT client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import queue
|
|
5
|
+
import struct
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid as _uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from . import constants as C
|
|
12
|
+
from .errors import BridgeTimeoutError
|
|
13
|
+
from .protocol import lp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_uuid128(value: int | str | _uuid.UUID) -> bytes:
|
|
17
|
+
"""16/32-bit ints expand into the Bluetooth base UUID; strs/UUIDs pass through."""
|
|
18
|
+
if isinstance(value, int):
|
|
19
|
+
return _uuid.UUID(f"{value:08x}-0000-1000-8000-00805f9b34fb").bytes
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return _uuid.UUID(value).bytes
|
|
22
|
+
return value.bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Advertisement:
|
|
27
|
+
addr: str
|
|
28
|
+
addr_type: int
|
|
29
|
+
rssi: int
|
|
30
|
+
data: bytes # raw advertisement payload (AD structures)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self) -> str | None:
|
|
34
|
+
"""Local name parsed from the AD structures, if present."""
|
|
35
|
+
i, d = 0, self.data
|
|
36
|
+
while i + 1 < len(d):
|
|
37
|
+
length = d[i]
|
|
38
|
+
if length == 0 or i + 1 + length > len(d) + 1:
|
|
39
|
+
break
|
|
40
|
+
ad_type = d[i + 1]
|
|
41
|
+
if ad_type in (0x08, 0x09): # shortened / complete local name
|
|
42
|
+
return d[i + 2 : i + 1 + length].decode("utf-8", "replace")
|
|
43
|
+
i += 1 + length
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Characteristic:
|
|
48
|
+
"""A characteristic of the local GATT server."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, ble: "Ble", char_id: int, uuid_: bytes):
|
|
51
|
+
self._ble = ble
|
|
52
|
+
self.char_id = char_id
|
|
53
|
+
self.uuid = uuid_
|
|
54
|
+
self.on_write = None # callback(bytes)
|
|
55
|
+
|
|
56
|
+
def set(self, data: bytes) -> None:
|
|
57
|
+
self._ble._b.request(C.BLE_GATTS_SET, bytes([self.char_id]) + bytes(data))
|
|
58
|
+
|
|
59
|
+
def notify(self, data: bytes) -> None:
|
|
60
|
+
self._ble._b.request(C.BLE_GATTS_NTFY, bytes([self.char_id]) + bytes(data))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GattClient:
|
|
64
|
+
"""Connection to a remote BLE peripheral (one at a time)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, ble: "Ble"):
|
|
67
|
+
self._ble = ble
|
|
68
|
+
self.connected = True
|
|
69
|
+
|
|
70
|
+
def read(self, service, char) -> bytes:
|
|
71
|
+
return self._ble._b.request(
|
|
72
|
+
C.BLE_GATTC_READ, to_uuid128(service) + to_uuid128(char), timeout=10.0)
|
|
73
|
+
|
|
74
|
+
def write(self, service, char, data: bytes) -> None:
|
|
75
|
+
self._ble._b.request(
|
|
76
|
+
C.BLE_GATTC_WRITE, to_uuid128(service) + to_uuid128(char) + bytes(data),
|
|
77
|
+
timeout=10.0)
|
|
78
|
+
|
|
79
|
+
def subscribe(self, service, char, callback) -> None:
|
|
80
|
+
"""callback(bytes) for notifications from this characteristic."""
|
|
81
|
+
self._ble._notify_callbacks[to_uuid128(char)] = callback
|
|
82
|
+
self._ble._b.request(
|
|
83
|
+
C.BLE_GATTC_SUB, to_uuid128(service) + to_uuid128(char) + b"\x01",
|
|
84
|
+
timeout=10.0)
|
|
85
|
+
|
|
86
|
+
def unsubscribe(self, service, char) -> None:
|
|
87
|
+
self._ble._notify_callbacks.pop(to_uuid128(char), None)
|
|
88
|
+
self._ble._b.request(
|
|
89
|
+
C.BLE_GATTC_SUB, to_uuid128(service) + to_uuid128(char) + b"\x00",
|
|
90
|
+
timeout=10.0)
|
|
91
|
+
|
|
92
|
+
def disconnect(self) -> None:
|
|
93
|
+
self.connected = False
|
|
94
|
+
self._ble._b.request(C.BLE_GATTC_DISC, timeout=10.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Ble:
|
|
98
|
+
# characteristic property flags
|
|
99
|
+
READ = C.GATT_PROP_READ
|
|
100
|
+
WRITE = C.GATT_PROP_WRITE
|
|
101
|
+
NOTIFY = C.GATT_PROP_NOTIFY
|
|
102
|
+
WRITE_NR = C.GATT_PROP_WRITE_NR
|
|
103
|
+
|
|
104
|
+
def __init__(self, bridge):
|
|
105
|
+
self._b = bridge
|
|
106
|
+
bridge.require(C.Cap.BLE_FW, "BLE (firmware built without it?)")
|
|
107
|
+
self._adv_queue: queue.Queue[Advertisement] = queue.Queue(maxsize=512)
|
|
108
|
+
self._adv_callbacks: list = []
|
|
109
|
+
self._chars: dict[int, Characteristic] = {}
|
|
110
|
+
self._notify_callbacks: dict[bytes, object] = {}
|
|
111
|
+
self._server_connected = threading.Event()
|
|
112
|
+
bridge.on_event(C.BLE_ADV_EVT, self._on_adv)
|
|
113
|
+
bridge.on_event(C.BLE_GATTS_WR_EVT, self._on_gatts_write)
|
|
114
|
+
bridge.on_event(C.BLE_GATTS_CONN_EVT, self._on_gatts_conn)
|
|
115
|
+
bridge.on_event(C.BLE_GATTC_NTFY_EVT, self._on_notify)
|
|
116
|
+
|
|
117
|
+
# ---- events (reader thread) ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _on_adv(self, p: bytes) -> None:
|
|
120
|
+
if len(p) < 8:
|
|
121
|
+
return
|
|
122
|
+
adv = Advertisement(
|
|
123
|
+
addr=":".join(f"{x:02x}" for x in p[0:6]),
|
|
124
|
+
addr_type=p[6],
|
|
125
|
+
rssi=int.from_bytes(p[7:8], "big", signed=True),
|
|
126
|
+
data=p[8:],
|
|
127
|
+
)
|
|
128
|
+
for cb in list(self._adv_callbacks):
|
|
129
|
+
cb(adv)
|
|
130
|
+
try:
|
|
131
|
+
self._adv_queue.put_nowait(adv)
|
|
132
|
+
except queue.Full:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def _on_gatts_write(self, p: bytes) -> None:
|
|
136
|
+
if not p:
|
|
137
|
+
return
|
|
138
|
+
ch = self._chars.get(p[0])
|
|
139
|
+
if ch is not None and ch.on_write is not None:
|
|
140
|
+
ch.on_write(p[1:])
|
|
141
|
+
|
|
142
|
+
def _on_gatts_conn(self, p: bytes) -> None:
|
|
143
|
+
if p and p[0]:
|
|
144
|
+
self._server_connected.set()
|
|
145
|
+
else:
|
|
146
|
+
self._server_connected.clear()
|
|
147
|
+
|
|
148
|
+
def _on_notify(self, p: bytes) -> None:
|
|
149
|
+
if len(p) < 16:
|
|
150
|
+
return
|
|
151
|
+
cb = self._notify_callbacks.get(bytes(p[:16]))
|
|
152
|
+
if cb is not None:
|
|
153
|
+
cb(p[16:])
|
|
154
|
+
|
|
155
|
+
# ---- scanning --------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def scan(self, duration: float = 5.0, *, active: bool = True,
|
|
158
|
+
callback=None) -> list[Advertisement]:
|
|
159
|
+
"""Collect advertisements for `duration` seconds.
|
|
160
|
+
|
|
161
|
+
With `callback`, advertisements also stream to it live. Returns devices
|
|
162
|
+
deduplicated by address (strongest RSSI kept).
|
|
163
|
+
"""
|
|
164
|
+
if callback is not None:
|
|
165
|
+
self._adv_callbacks.append(callback)
|
|
166
|
+
while not self._adv_queue.empty(): # drop stale results
|
|
167
|
+
self._adv_queue.get_nowait()
|
|
168
|
+
self._b.request(C.BLE_SCAN_START, bytes([min(255, int(duration) + 1), 1 if active else 0]),
|
|
169
|
+
timeout=10.0)
|
|
170
|
+
seen: dict[str, Advertisement] = {}
|
|
171
|
+
deadline = time.monotonic() + duration
|
|
172
|
+
while time.monotonic() < deadline:
|
|
173
|
+
try:
|
|
174
|
+
adv = self._adv_queue.get(timeout=0.2)
|
|
175
|
+
except queue.Empty:
|
|
176
|
+
continue
|
|
177
|
+
cur = seen.get(adv.addr)
|
|
178
|
+
if cur is None or adv.rssi > cur.rssi or (adv.name and not cur.name):
|
|
179
|
+
seen[adv.addr] = adv
|
|
180
|
+
self._b.request(C.BLE_SCAN_STOP, timeout=10.0)
|
|
181
|
+
if callback is not None:
|
|
182
|
+
self._adv_callbacks.remove(callback)
|
|
183
|
+
return sorted(seen.values(), key=lambda a: -a.rssi)
|
|
184
|
+
|
|
185
|
+
# ---- advertising ----------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def advertise(self, name: str = "", *, manufacturer_data: bytes = b"",
|
|
188
|
+
service_uuid16: int = 0) -> None:
|
|
189
|
+
payload = lp(name) + lp(manufacturer_data) + struct.pack(">H", service_uuid16)
|
|
190
|
+
self._b.request(C.BLE_ADV_START, payload, timeout=10.0)
|
|
191
|
+
|
|
192
|
+
def advertise_stop(self) -> None:
|
|
193
|
+
self._b.request(C.BLE_ADV_STOP, timeout=10.0)
|
|
194
|
+
|
|
195
|
+
# ---- GATT server -----------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def serve(self, service, chars: list[tuple[object, int]]) -> list[Characteristic]:
|
|
198
|
+
"""Define a GATT service. `chars` is [(uuid, props), ...] with props a
|
|
199
|
+
bitmask of Ble.READ / Ble.WRITE / Ble.NOTIFY / Ble.WRITE_NR.
|
|
200
|
+
Call advertise() afterwards to become discoverable."""
|
|
201
|
+
payload = to_uuid128(service) + bytes([len(chars)])
|
|
202
|
+
for u, props in chars:
|
|
203
|
+
payload += to_uuid128(u) + bytes([props])
|
|
204
|
+
ids = self._b.request(C.BLE_GATTS_DEF, payload, timeout=10.0)
|
|
205
|
+
out = []
|
|
206
|
+
for (u, _), cid in zip(chars, ids):
|
|
207
|
+
ch = Characteristic(self, cid, to_uuid128(u))
|
|
208
|
+
self._chars[cid] = ch
|
|
209
|
+
out.append(ch)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
def wait_connect(self, timeout: float | None = None) -> bool:
|
|
213
|
+
return self._server_connected.wait(timeout)
|
|
214
|
+
|
|
215
|
+
# ---- GATT client ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def connect(self, addr: str, addr_type: int = 0, timeout: float = 15.0) -> GattClient:
|
|
218
|
+
"""Connect to a peripheral by address ("aa:bb:cc:dd:ee:ff")."""
|
|
219
|
+
mac = bytes(int(x, 16) for x in addr.split(":"))
|
|
220
|
+
if len(mac) != 6:
|
|
221
|
+
raise ValueError("address must be 6 bytes, e.g. 'aa:bb:cc:dd:ee:ff'")
|
|
222
|
+
self._b.request(C.BLE_GATTC_CONN, mac + bytes([addr_type]), timeout=timeout)
|
|
223
|
+
return GattClient(self)
|