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.
@@ -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)