python-esp-bridge 0.0.2__tar.gz → 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.
Files changed (33) hide show
  1. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/PKG-INFO +4 -2
  2. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/__init__.py +13 -3
  3. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/bridge.py +102 -16
  4. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/cli.py +49 -2
  5. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/constants.py +11 -1
  6. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/errors.py +5 -0
  7. python_esp_bridge-0.1.1/espbridge/transport.py +5 -0
  8. python_esp_bridge-0.1.1/espbridge/transports/__init__.py +22 -0
  9. python_esp_bridge-0.1.1/espbridge/transports/ble.py +172 -0
  10. python_esp_bridge-0.1.1/espbridge/transports/mock.py +46 -0
  11. python_esp_bridge-0.0.2/espbridge/transport.py → python_esp_bridge-0.1.1/espbridge/transports/serial.py +6 -44
  12. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/pyproject.toml +3 -2
  13. python_esp_bridge-0.1.1/uv.lock +712 -0
  14. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/.gitignore +0 -0
  15. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/.python-version +0 -0
  16. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/README.md +0 -0
  17. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/analog.py +0 -0
  18. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/ble.py +0 -0
  19. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/__init__.py +0 -0
  20. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/blinka.py +0 -0
  21. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/gpiozero.py +0 -0
  22. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/luma.py +0 -0
  23. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/rpi_gpio.py +0 -0
  24. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/compat/smbus.py +0 -0
  25. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/gpio.py +0 -0
  26. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/i2c.py +0 -0
  27. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/net.py +0 -0
  28. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/oled.py +0 -0
  29. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/protocol.py +0 -0
  30. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/pwm.py +0 -0
  31. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/spi.py +0 -0
  32. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/uart.py +0 -0
  33. {python_esp_bridge-0.0.2 → python_esp_bridge-0.1.1}/espbridge/wifi.py +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
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
3
+ Version: 0.1.1
4
+ Summary: Control every ESP32 peripheral from Python over USB serial or Bluetooth — GPIO, ADC, DAC, PWM, touch, I2C, SPI, UART, Wi-Fi sockets, BLE
5
5
  Project-URL: Homepage, https://github.com/HamzaYslmn/python-esp-bridge
6
6
  Author: HamzaYslmn
7
7
  Keywords: ble,bridge,esp32,firmata,gpio,i2c,raspberry-pi,serial,spi
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: pyserial>=3.5
10
+ Provides-Extra: ble
11
+ Requires-Dist: bleak>=0.22; extra == 'ble'
10
12
  Provides-Extra: oled
11
13
  Requires-Dist: pillow>=10; extra == 'oled'
12
14
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  """espbridge — control every ESP32 peripheral from Python over USB serial.
2
2
 
3
- Flash esp/esp.ino once, then:
3
+ Flash firmware/firmware.ino once, then:
4
4
 
5
5
  from espbridge import Bridge
6
6
 
@@ -15,6 +15,7 @@ Flash esp/esp.ino once, then:
15
15
  from .bridge import Bridge, BridgeSet, Info, connect_all
16
16
  from .constants import Cap, ChipModel, Status
17
17
  from .errors import (
18
+ AuthError,
18
19
  BridgeError,
19
20
  BridgeTimeoutError,
20
21
  NoDeviceError,
@@ -22,18 +23,27 @@ from .errors import (
22
23
  RemoteError,
23
24
  UnsupportedError,
24
25
  )
25
- from .transport import find_ports
26
+ from .transports import find_ports
26
27
 
27
- __version__ = "0.0.2"
28
+
29
+ def find_ble_devices(timeout: float = 5.0):
30
+ """Scan for bridges advertising over Bluetooth (needs the [ble] extra)."""
31
+ from .transports.ble import find_ble_devices as _scan
32
+
33
+ return _scan(timeout)
34
+
35
+ __version__ = "0.1.1"
28
36
 
29
37
  __all__ = [
30
38
  "Bridge",
31
39
  "BridgeSet",
32
40
  "connect_all",
41
+ "find_ble_devices",
33
42
  "Info",
34
43
  "Cap",
35
44
  "ChipModel",
36
45
  "Status",
46
+ "AuthError",
37
47
  "BridgeError",
38
48
  "BridgeTimeoutError",
39
49
  "NoDeviceError",
@@ -10,6 +10,7 @@ from dataclasses import dataclass
10
10
 
11
11
  from . import constants as C
12
12
  from .errors import (
13
+ AuthError,
13
14
  BridgeError,
14
15
  BridgeTimeoutError,
15
16
  NoDeviceError,
@@ -18,7 +19,7 @@ from .errors import (
18
19
  UnsupportedError,
19
20
  )
20
21
  from .protocol import Frame, FrameSplitter, decode_frame, encode_frame
21
- from .transport import SerialTransport, find_ports
22
+ from .transports import SerialTransport, find_ports
22
23
 
23
24
 
24
25
  @dataclass(frozen=True)
@@ -78,6 +79,12 @@ class Bridge:
78
79
 
79
80
  >>> esp = Bridge(name="relays")
80
81
  >>> esp = Bridge(mac="24:a1:60:12:34:56")
82
+
83
+ Over Bluetooth instead of USB (firmware default password "espbridge",
84
+ change it at the top of firmware/firmware.ino):
85
+
86
+ >>> esp = Bridge(ble=True) # the only advertising bridge
87
+ >>> esp = Bridge(ble="relays", password="espbridge")
81
88
  """
82
89
 
83
90
  def __init__(
@@ -86,6 +93,8 @@ class Bridge:
86
93
  *,
87
94
  name: str | None = None,
88
95
  mac: str | None = None,
96
+ ble: bool | str = False,
97
+ password: str | None = None,
89
98
  baud: int = 115200,
90
99
  upgrade_baud: bool = True,
91
100
  target_baud: int | None = None,
@@ -98,18 +107,22 @@ class Bridge:
98
107
  self.reset_on_exit = False # set for real only once connected (see below)
99
108
  self.info: Info | None = None
100
109
 
101
- # Candidate ports: explicit transport/port, or every ESP32-like port.
110
+ # Candidates: (transport factory, label, usb_chip).
102
111
  if transport is not None:
103
- candidates = [(transport, None, getattr(transport, "usb_chip", None))]
112
+ candidates = [(lambda t=transport: t, "transport",
113
+ getattr(transport, "usb_chip", None))]
114
+ elif ble:
115
+ candidates = self._ble_candidates(ble, name, mac)
104
116
  elif port is not None:
105
117
  chip = next((p.usb_chip for p in find_ports() if p.device == port), None)
106
- candidates = [(None, port, chip)]
118
+ candidates = [(lambda p=port, c=chip: SerialTransport(p, baud, usb_chip=c),
119
+ port, chip)]
107
120
  else:
108
121
  ports = find_ports()
109
122
  if not ports:
110
123
  raise NoDeviceError(
111
124
  "no ESP32 serial port found (CP210x/CH340/CH9102/native USB); "
112
- "pass port='COM5' / '/dev/ttyUSB0' explicitly"
125
+ "pass port='COM5' / '/dev/ttyUSB0' explicitly — or ble=True"
113
126
  )
114
127
  if name is None and mac is None and len(ports) > 1:
115
128
  names = ", ".join(p.device for p in ports)
@@ -117,41 +130,92 @@ class Bridge:
117
130
  f"multiple ESP32-like ports found ({names}); pass port=, "
118
131
  f"name= or mac= — or use espbridge.connect_all()"
119
132
  )
120
- candidates = [(None, p.device, p.usb_chip) for p in ports]
133
+ candidates = [(lambda p=p: SerialTransport(p.device, baud, usb_chip=p.usb_chip),
134
+ p.device, p.usb_chip) for p in ports]
121
135
 
122
136
  probing = len(candidates) > 1
123
137
  errors: list[str] = []
124
- for t, prt, chip in candidates:
138
+ for factory, label, chip in candidates:
125
139
  self._reset_state()
126
140
  try:
127
- self._t = t if t is not None else SerialTransport(prt, baud, usb_chip=chip)
141
+ self._t = factory()
128
142
  except Exception as e:
129
- errors.append(f"{prt}: {e}")
143
+ errors.append(f"{label}: {e}")
130
144
  continue
131
145
  self._reader = threading.Thread(target=self._read_loop, daemon=True,
132
146
  name="espbridge-reader")
133
147
  self._reader.start()
134
148
  try:
135
- self._handshake(reset_on_open)
149
+ if getattr(self._t, "needs_auth", False):
150
+ self._auth(password)
151
+ self._handshake(reset_on_open=False)
152
+ else:
153
+ self._handshake(reset_on_open)
136
154
  assert self.info is not None
137
155
  if self._matches(name, mac):
138
- if upgrade_baud:
156
+ if upgrade_baud and getattr(self._t, "has_baud", True):
139
157
  self._upgrade_baud(baud, target_baud)
140
158
  self.reset_on_exit = reset_on_exit
141
159
  return
142
- errors.append(f"{prt or 'transport'}: name={self.info.name!r} "
160
+ errors.append(f"{label}: name={self.info.name!r} "
143
161
  f"mac={self.info.mac} (no match)")
144
162
  self.close()
145
163
  except (BridgeTimeoutError, ProtocolError) as e:
146
164
  self.close()
147
165
  if not probing:
148
166
  raise
149
- errors.append(f"{prt or 'transport'}: {e}")
167
+ errors.append(f"{label}: {e}")
150
168
  except BaseException:
151
169
  self.close()
152
170
  raise
153
171
  raise NoDeviceError("no matching bridge found — " + "; ".join(errors))
154
172
 
173
+ @staticmethod
174
+ def _ble_candidates(ble: bool | str, name: str | None, mac: str | None):
175
+ from .transports.ble import BleTransport, find_ble_devices
176
+
177
+ target = ble if isinstance(ble, str) else None
178
+ devs = find_ble_devices()
179
+ if target is not None:
180
+ tmac = _norm_mac(target)
181
+ devs = [d for d in devs
182
+ if d.name == target or d.device_name == target
183
+ or _norm_mac(d.address) == tmac or _norm_mac(d.mac) == tmac]
184
+ # The advertised name carries the bridge MAC and custom name: narrow
185
+ # the candidates before connecting when the caller passed name=/mac=.
186
+ # On no match keep the full list (adv data can be stale/truncated) —
187
+ # the post-connect _matches() check stays authoritative either way.
188
+ if mac is not None:
189
+ devs = [d for d in devs if _norm_mac(d.mac) == _norm_mac(mac)] or devs
190
+ if name is not None:
191
+ devs = [d for d in devs if d.device_name == name] or devs
192
+ if not devs:
193
+ what = f" named/at {target!r}" if target else ""
194
+ raise NoDeviceError(
195
+ f"no bridge{what} found over Bluetooth — is the board powered, "
196
+ f"in range, and flashed with BRIDGE_BLE_LINK enabled?"
197
+ )
198
+ if target is None and name is None and mac is None and len(devs) > 1:
199
+ names = ", ".join(d.name or d.address for d in devs)
200
+ raise NoDeviceError(
201
+ f"multiple bridges advertising ({names}); pass ble='name-or-mac'"
202
+ )
203
+ return [(lambda d=d: BleTransport(d.address),
204
+ f"BLE {d.name or d.address}", None) for d in devs]
205
+
206
+ def _auth(self, password: str | None) -> None:
207
+ """Authenticate a wireless link (SYS_AUTH) before the handshake."""
208
+ pw = (C.DEFAULT_PASSWORD if password is None else password).encode()
209
+ try:
210
+ self.request(C.SYS_AUTH, pw, timeout=5.0)
211
+ except RemoteError as e:
212
+ if e.status == C.Status.DENIED:
213
+ raise AuthError(
214
+ "bridge rejected the password — check BRIDGE_PASSWORD at "
215
+ "the top of firmware/firmware.ino"
216
+ ) from None
217
+ raise
218
+
155
219
  def _reset_state(self) -> None:
156
220
  self._splitter = FrameSplitter()
157
221
  self._pending: dict[int, _Pending] = {}
@@ -203,13 +267,13 @@ class Bridge:
203
267
  continue
204
268
  if not self._ready.is_set():
205
269
  raise BridgeTimeoutError(
206
- "no response from bridge firmware — is it flashed? (esp/README.md)"
270
+ "no response from bridge firmware — is it flashed? (firmware/README.md)"
207
271
  )
208
272
  assert self.info is not None
209
273
  if self.info.protocol != C.PROTOCOL_VERSION:
210
274
  raise ProtocolError(
211
275
  f"protocol mismatch: firmware speaks v{self.info.protocol}, "
212
- f"this library v{C.PROTOCOL_VERSION} — reflash esp/esp.ino or "
276
+ f"this library v{C.PROTOCOL_VERSION} — reflash firmware.ino or "
213
277
  f"update python-esp-bridge"
214
278
  )
215
279
 
@@ -232,7 +296,29 @@ class Bridge:
232
296
  continue
233
297
  # Could not talk at the new baud: fall back.
234
298
  self._t.set_baudrate(current)
235
- self.ping(b"fallback")
299
+ try:
300
+ self.ping(b"fallback")
301
+ except BridgeTimeoutError:
302
+ # The firmware already switched to `target` and can't hear us.
303
+ # Reopen the port: the open-time DTR/RTS toggle resets the board
304
+ # (reliable even where a manual reset pulse is not).
305
+ self._reconnect(current)
306
+
307
+ def _reconnect(self, baud: int) -> None:
308
+ """Reopen the serial port and redo the handshake (recovers a dead link)."""
309
+ port = getattr(getattr(self._t, "ser", None), "port", None)
310
+ chip = getattr(self._t, "usb_chip", None)
311
+ if port is None:
312
+ raise BridgeTimeoutError("link lost and transport cannot be reopened")
313
+ self._t.close()
314
+ self._reader.join(timeout=1.0)
315
+ time.sleep(0.3) # let the device reboot from the close-time reset
316
+ self._reset_state()
317
+ self._t = SerialTransport(port, baud, usb_chip=chip)
318
+ self._reader = threading.Thread(target=self._read_loop, daemon=True,
319
+ name="espbridge-reader")
320
+ self._reader.start()
321
+ self._handshake(reset_on_open=True)
236
322
 
237
323
  def close(self) -> None:
238
324
  if self._closing:
@@ -7,7 +7,7 @@ import sys
7
7
  from . import __version__
8
8
  from .bridge import Bridge, connect_all
9
9
  from .errors import BridgeError
10
- from .transport import find_ports
10
+ from .transports import find_ports
11
11
 
12
12
 
13
13
  def _print_info(esp: Bridge) -> None:
@@ -32,10 +32,18 @@ def main(argv: list[str] | None = None) -> int:
32
32
  ap.add_argument("--version", action="version", version=f"espbridge {__version__}")
33
33
  ap.add_argument("-p", "--port", help="serial port (default: auto-detect)")
34
34
  ap.add_argument("-n", "--name", help="select device by stored name")
35
+ ap.add_argument("-b", "--ble", nargs="?", const=True, metavar="NAME_OR_MAC",
36
+ help="connect over Bluetooth instead of USB")
37
+ ap.add_argument("--password", help="Bluetooth link password "
38
+ "(default: 'espbridge')")
35
39
  ap.add_argument("--no-baud-upgrade", action="store_true",
36
40
  help="stay at 115200 instead of upgrading the link speed")
37
41
  sub = ap.add_subparsers(dest="cmd")
38
42
  sub.add_parser("ports", help="list ESP32-like serial ports")
43
+ p_scan = sub.add_parser("scan", help="connect to every attached device and "
44
+ "list port/name/chip/mac")
45
+ p_scan.add_argument("--ble", action="store_true", dest="scan_ble",
46
+ help="scan for bridges advertising over Bluetooth")
39
47
  sub.add_parser("info", help="connect and print firmware/chip info (default; "
40
48
  "shows every device when several are attached)")
41
49
  p_name = sub.add_parser("set-name", help="store a device name on the ESP32 (NVS)")
@@ -43,8 +51,25 @@ def main(argv: list[str] | None = None) -> int:
43
51
  args = ap.parse_args(argv)
44
52
 
45
53
  kwargs = dict(upgrade_baud=not args.no_baud_upgrade)
54
+ if args.ble:
55
+ kwargs["ble"] = args.ble
56
+ if args.password is not None:
57
+ kwargs["password"] = args.password
46
58
 
47
59
  try:
60
+ if args.cmd == "scan" and args.scan_ble:
61
+ from .transports.ble import find_ble_devices
62
+
63
+ devs = find_ble_devices()
64
+ if not devs:
65
+ print("no bridges advertising over Bluetooth")
66
+ return 1
67
+ print(f"{'NAME':<16s} {'MAC':<18s} {'ADVERTISED':<32s} RSSI")
68
+ for d in devs:
69
+ print(f"{d.device_name or '-':<16s} {d.mac or '-':<18s} "
70
+ f"{d.name:<32s} {d.rssi} dBm")
71
+ return 0
72
+
48
73
  if args.cmd == "ports":
49
74
  ports = find_ports()
50
75
  if not ports:
@@ -54,14 +79,36 @@ def main(argv: list[str] | None = None) -> int:
54
79
  print(f"{p.device}\t{p.usb_chip}\t{p.description}")
55
80
  return 0
56
81
 
82
+ if args.cmd == "scan":
83
+ ports = find_ports()
84
+ if not ports:
85
+ print("no ESP32-like serial ports found")
86
+ return 1
87
+ print(f"{'PORT':<12s} {'NAME':<16s} {'CHIP':<10s} {'MAC':<17s} FW")
88
+ rc = 0
89
+ for p in ports:
90
+ try:
91
+ # info only — skip the baud upgrade for a quicker probe
92
+ with Bridge(p.device, upgrade_baud=False) as esp:
93
+ info = esp.info
94
+ fw = ".".join(map(str, info.fw_version))
95
+ print(f"{p.device:<12s} {info.name or '-':<16s} "
96
+ f"{info.chip.name:<10s} {info.mac:<17s} v{fw}")
97
+ except BridgeError as e:
98
+ print(f"{p.device:<12s} error: {e}")
99
+ rc = 1
100
+ return rc
101
+
57
102
  if args.cmd == "set-name":
58
103
  with Bridge(args.port, name=args.name, **kwargs) as esp:
59
104
  esp.set_name(args.new_name)
60
105
  print(f"{esp.info.mac} is now named {args.new_name!r}")
106
+ print("(the Bluetooth advertised name updates on next reset)")
61
107
  return 0
62
108
 
63
109
  # default: info
64
- if args.port is None and args.name is None and len(find_ports()) > 1:
110
+ if args.port is None and args.name is None and not args.ble \
111
+ and len(find_ports()) > 1:
65
112
  with connect_all(**kwargs) as boards:
66
113
  for i, esp in enumerate(boards):
67
114
  if i:
@@ -1,6 +1,6 @@
1
1
  """python-esp-bridge — shared protocol contract.
2
2
 
3
- MUST stay in sync with esp/src/espbridge/commands.h.
3
+ MUST stay in sync with firmware/src/espbridge/commands.h.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
@@ -34,6 +34,7 @@ class Status(enum.IntEnum):
34
34
  WIFI = 0x0A
35
35
  SOCKET = 0x0B
36
36
  CRC = 0x0C
37
+ DENIED = 0x0D # wireless link not authenticated (see SYS_AUTH)
37
38
 
38
39
 
39
40
  class Cap(enum.IntFlag):
@@ -46,6 +47,7 @@ class Cap(enum.IntFlag):
46
47
  PSRAM = 1 << 6
47
48
  NATIVE_USB = 1 << 7
48
49
  BLE_FW = 1 << 8
50
+ BLE_LINK = 1 << 9 # bridge reachable over the BLE transport
49
51
 
50
52
 
51
53
  class ChipModel(enum.IntEnum):
@@ -82,6 +84,7 @@ SYS_SET_BAUD = _cmd(MOD_SYS, 0x03)
82
84
  SYS_RESET = _cmd(MOD_SYS, 0x04)
83
85
  SYS_FREE_HEAP = _cmd(MOD_SYS, 0x05)
84
86
  SYS_SET_NAME = _cmd(MOD_SYS, 0x06)
87
+ SYS_AUTH = _cmd(MOD_SYS, 0x07)
85
88
  SYS_READY = _cmd(MOD_SYS, 0x80)
86
89
  SYS_LOG = _cmd(MOD_SYS, 0x81)
87
90
 
@@ -189,6 +192,13 @@ KNOWN_USB_IDS = {
189
192
  (0x303A, None): "native", # Espressif native USB (S2/S3/C3/...)
190
193
  }
191
194
 
195
+ # BLE link: Nordic-UART-style GATT service the firmware exposes as a transport.
196
+ # MUST stay in sync with firmware link_ble.cpp.
197
+ BLE_LINK_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
198
+ BLE_LINK_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # host -> board (write)
199
+ BLE_LINK_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # board -> host (notify)
200
+ DEFAULT_PASSWORD = "espbridge" # firmware default; change in firmware.ino
201
+
192
202
  # Safe upgraded baud per bridge chip (conservative defaults; CH340 can do 2M).
193
203
  UPGRADE_BAUD = {
194
204
  "cp210x": 921600,
@@ -24,6 +24,11 @@ class UnsupportedError(BridgeError):
24
24
  """The connected chip lacks this capability (e.g. DAC on ESP32-S3)."""
25
25
 
26
26
 
27
+ class AuthError(BridgeError):
28
+ """The bridge rejected the wireless-link password (see BRIDGE_PASSWORD
29
+ at the top of firmware/firmware.ino)."""
30
+
31
+
27
32
  class RemoteError(BridgeError):
28
33
  """The firmware returned an error status for a request."""
29
34
 
@@ -0,0 +1,5 @@
1
+ """Back-compat shim — transports now live in espbridge.transports.*"""
2
+ from .transports.serial import PortInfo, SerialTransport, autodetect_port, find_ports
3
+ from .transports.mock import MockTransport
4
+
5
+ __all__ = ["PortInfo", "SerialTransport", "MockTransport", "autodetect_port", "find_ports"]
@@ -0,0 +1,22 @@
1
+ """Transports: byte links the Bridge protocol runs over (serial, BLE, mock).
2
+
3
+ A transport provides:
4
+ read() -> bytes # whatever is available (may block briefly, b"" ok)
5
+ write(data: bytes) # send raw bytes
6
+ close()
7
+ set_baudrate(baud) # serial links only; no-op elsewhere
8
+ pulse_reset() # serial links only; no-op elsewhere
9
+ usb_chip: str | None # adapter hint for baud upgrade ("cp210x", ...)
10
+ has_baud: bool # False to skip baud negotiation entirely
11
+ needs_auth: bool # True = Bridge sends SYS_AUTH before the handshake
12
+ """
13
+ from .serial import PortInfo, SerialTransport, autodetect_port, find_ports
14
+ from .mock import MockTransport
15
+
16
+ __all__ = [
17
+ "PortInfo",
18
+ "SerialTransport",
19
+ "MockTransport",
20
+ "autodetect_port",
21
+ "find_ports",
22
+ ]
@@ -0,0 +1,172 @@
1
+ """Bluetooth (BLE) transport — talk to the bridge with no USB cable.
2
+
3
+ The firmware advertises a Nordic-UART-style GATT service (see firmware
4
+ link_ble.cpp); this transport carries the exact same COBS frame stream over
5
+ it. Requires the `bleak` package: ``pip install python-esp-bridge[ble]``.
6
+
7
+ bleak is asyncio-based; a private event loop runs in a daemon thread so the
8
+ transport exposes the same blocking read()/write() interface as the serial
9
+ one. Notifications land in a queue that read() drains.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import queue
14
+ import threading
15
+ from dataclasses import dataclass
16
+
17
+ from ..constants import BLE_LINK_RX_UUID, BLE_LINK_SERVICE_UUID, BLE_LINK_TX_UUID
18
+ from ..errors import BridgeError, NoDeviceError
19
+
20
+
21
+ ADV_NAME_PREFIX = "espbridge_" # firmware advertises espbridge_<mac>[_<name>]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BleDeviceInfo:
26
+ name: str # advertised name: "espbridge_c049efd03fe0" or "espbridge_c049efd03fe0_relays"
27
+ address: str # MAC on Windows/Linux, CoreBluetooth UUID on macOS
28
+ rssi: int
29
+
30
+ def _parts(self) -> tuple[str, str]:
31
+ rest = self.name.removeprefix(ADV_NAME_PREFIX)
32
+ hexpart, _, custom = rest.partition("_")
33
+ if len(hexpart) == 12 and all(c in "0123456789abcdef" for c in hexpart):
34
+ return hexpart, custom
35
+ return "", ""
36
+
37
+ @property
38
+ def mac(self) -> str:
39
+ """Bridge MAC (matches Info.mac) parsed from the advertised name."""
40
+ hexpart, _ = self._parts()
41
+ return ":".join(hexpart[i : i + 2] for i in range(0, 12, 2)) if hexpart else ""
42
+
43
+ @property
44
+ def device_name(self) -> str:
45
+ """User-assigned name (espbridge set-name), "" when unset."""
46
+ return self._parts()[1]
47
+
48
+
49
+ def _require_bleak():
50
+ try:
51
+ import bleak # noqa: F401
52
+ except ImportError:
53
+ raise BridgeError(
54
+ "the Bluetooth transport needs the 'bleak' package — "
55
+ "install with: pip install python-esp-bridge[ble]"
56
+ ) from None
57
+
58
+
59
+ def find_ble_devices(timeout: float = 5.0) -> list[BleDeviceInfo]:
60
+ """Scan for bridges advertising the BLE link service."""
61
+ _require_bleak()
62
+ import asyncio
63
+
64
+ from bleak import BleakScanner
65
+
66
+ async def _scan():
67
+ found = await BleakScanner.discover(
68
+ timeout=timeout, return_adv=True,
69
+ service_uuids=[BLE_LINK_SERVICE_UUID],
70
+ )
71
+ return [
72
+ BleDeviceInfo(dev.name or "", dev.address, adv.rssi)
73
+ for dev, adv in found.values()
74
+ ]
75
+
76
+ return asyncio.run(_scan())
77
+
78
+
79
+ class BleTransport:
80
+ """BLE GATT transport with the same blocking interface as SerialTransport."""
81
+
82
+ has_baud = False # wireless: no baud negotiation
83
+ needs_auth = True # firmware requires SYS_AUTH before other commands
84
+ usb_chip = None
85
+
86
+ def __init__(self, address: str, *, connect_timeout: float = 10.0):
87
+ _require_bleak()
88
+ import asyncio
89
+
90
+ self.address = address
91
+ self._rx: queue.Queue[bytes] = queue.Queue()
92
+ self._loop = asyncio.new_event_loop()
93
+ self._thread = threading.Thread(target=self._loop.run_forever,
94
+ daemon=True, name="espbridge-ble")
95
+ self._thread.start()
96
+ self._client = None
97
+ try:
98
+ self._run(self._connect(address, connect_timeout), connect_timeout + 5)
99
+ except BaseException:
100
+ self._stop_loop()
101
+ raise
102
+
103
+ def _run(self, coro, timeout: float):
104
+ import asyncio
105
+
106
+ fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
107
+ return fut.result(timeout)
108
+
109
+ async def _connect(self, address: str, timeout: float) -> None:
110
+ import asyncio
111
+
112
+ from bleak import BleakClient
113
+ from bleak.exc import BleakError
114
+
115
+ client = BleakClient(address, timeout=timeout)
116
+ try:
117
+ await client.connect()
118
+ await client.start_notify(BLE_LINK_TX_UUID, self._on_notify)
119
+ except BleakError as e:
120
+ raise NoDeviceError(f"BLE connect to {address} failed: {e}") from None
121
+ self._client = client
122
+ self._rx_char = client.services.get_characteristic(BLE_LINK_RX_UUID)
123
+ # The write-without-response limit starts at 20 and jumps after the
124
+ # MTU exchange (bleak docs); wait briefly so frames are not shredded
125
+ # into 20-byte writes during the handshake.
126
+ for _ in range(20):
127
+ if self._rx_char.max_write_without_response_size > 20:
128
+ break
129
+ await asyncio.sleep(0.1)
130
+
131
+ def _on_notify(self, _char, data: bytearray) -> None:
132
+ self._rx.put(bytes(data))
133
+
134
+ async def _write(self, data: bytes) -> None:
135
+ # Re-read per write: the limit can grow once the MTU exchange lands.
136
+ chunk = max(20, self._rx_char.max_write_without_response_size)
137
+ for i in range(0, len(data), chunk):
138
+ await self._client.write_gatt_char(
139
+ self._rx_char, data[i : i + chunk], response=False)
140
+
141
+ async def _disconnect(self) -> None:
142
+ if self._client is not None:
143
+ try:
144
+ await self._client.disconnect()
145
+ except Exception:
146
+ pass
147
+
148
+ def read(self) -> bytes:
149
+ try:
150
+ return self._rx.get(timeout=0.05)
151
+ except queue.Empty:
152
+ return b""
153
+
154
+ def write(self, data: bytes) -> None:
155
+ self._run(self._write(data), timeout=10.0)
156
+
157
+ def set_baudrate(self, baud: int) -> None:
158
+ pass # wireless
159
+
160
+ def pulse_reset(self) -> None:
161
+ pass # no DTR/RTS lines over the air
162
+
163
+ def _stop_loop(self) -> None:
164
+ self._loop.call_soon_threadsafe(self._loop.stop)
165
+ self._thread.join(timeout=2.0)
166
+
167
+ def close(self) -> None:
168
+ try:
169
+ self._run(self._disconnect(), timeout=5.0)
170
+ except Exception:
171
+ pass
172
+ self._stop_loop()
@@ -0,0 +1,46 @@
1
+ """In-memory transport for hardware-free tests."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class MockTransport:
6
+ """In-memory transport for hardware-free tests.
7
+
8
+ `responder(data: bytes)` is called with everything the host writes; it can
9
+ push firmware->host bytes back via `inject()`.
10
+ """
11
+
12
+ has_baud = True # exercises the baud-upgrade code path in tests
13
+ needs_auth = False # tests flip this to emulate the wireless link
14
+
15
+ def __init__(self, responder=None):
16
+ import queue
17
+
18
+ self.usb_chip = None
19
+ self._rx: queue.Queue[bytes] = queue.Queue()
20
+ self.responder = responder
21
+ self.closed = False
22
+ self.baud = 115200
23
+
24
+ def inject(self, data: bytes) -> None:
25
+ self._rx.put(data)
26
+
27
+ def read(self) -> bytes:
28
+ import queue
29
+
30
+ try:
31
+ return self._rx.get(timeout=0.05)
32
+ except queue.Empty:
33
+ return b""
34
+
35
+ def write(self, data: bytes) -> None:
36
+ if self.responder is not None:
37
+ self.responder(data)
38
+
39
+ def set_baudrate(self, baud: int) -> None:
40
+ self.baud = baud
41
+
42
+ def pulse_reset(self) -> None:
43
+ pass
44
+
45
+ def close(self) -> None:
46
+ self.closed = True