python-esp-bridge 0.0.2__py3-none-any.whl

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.
espbridge/bridge.py ADDED
@@ -0,0 +1,495 @@
1
+ """Bridge: connection, reader thread, request/response correlation, events."""
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import functools
6
+ import struct
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+
11
+ from . import constants as C
12
+ from .errors import (
13
+ BridgeError,
14
+ BridgeTimeoutError,
15
+ NoDeviceError,
16
+ ProtocolError,
17
+ RemoteError,
18
+ UnsupportedError,
19
+ )
20
+ from .protocol import Frame, FrameSplitter, decode_frame, encode_frame
21
+ from .transport import SerialTransport, find_ports
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Info:
26
+ protocol: int
27
+ fw_version: tuple[int, int, int]
28
+ chip: C.ChipModel
29
+ chip_rev: int
30
+ mac: str
31
+ caps: C.Cap
32
+ gpio_count: int
33
+ flash_mb: int
34
+ name: str = "" # user-assigned device name (see Bridge.set_name)
35
+
36
+ @classmethod
37
+ def parse(cls, payload: bytes) -> "Info":
38
+ if len(payload) < 18:
39
+ raise ProtocolError(f"short SYS_INFO payload: {len(payload)} bytes")
40
+ proto, maj, mnr, pat, model, rev = struct.unpack_from(">6B", payload)
41
+ mac = ":".join(f"{b:02x}" for b in payload[6:12])
42
+ (caps,) = struct.unpack_from(">I", payload, 12)
43
+ gpio_count, flash_mb = payload[16], payload[17]
44
+ name = ""
45
+ if len(payload) > 18: # optional name tail: len u8 | bytes
46
+ nlen = payload[18]
47
+ name = payload[19 : 19 + nlen].decode("utf-8", "replace")
48
+ try:
49
+ chip = C.ChipModel(model)
50
+ except ValueError:
51
+ chip = C.ChipModel.UNKNOWN
52
+ return cls(proto, (maj, mnr, pat), chip, rev, mac, C.Cap(caps),
53
+ gpio_count, flash_mb, name)
54
+
55
+
56
+ def _norm_mac(mac: str) -> str:
57
+ return mac.replace(":", "").replace("-", "").lower()
58
+
59
+
60
+ class _Pending:
61
+ __slots__ = ("event", "frame")
62
+
63
+ def __init__(self) -> None:
64
+ self.event = threading.Event()
65
+ self.frame: Frame | None = None
66
+
67
+
68
+ class Bridge:
69
+ """Connection to a python-esp-bridge ESP32.
70
+
71
+ >>> from espbridge import Bridge
72
+ >>> with Bridge() as esp: # auto-detects the serial port
73
+ ... esp.gpio.mode(2, "output")
74
+ ... esp.gpio.write(2, 1)
75
+
76
+ With several boards attached, select one by persistent name or MAC
77
+ (assign names once with ``Bridge(port=...).set_name("relays")``):
78
+
79
+ >>> esp = Bridge(name="relays")
80
+ >>> esp = Bridge(mac="24:a1:60:12:34:56")
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ port: str | None = None,
86
+ *,
87
+ name: str | None = None,
88
+ mac: str | None = None,
89
+ baud: int = 115200,
90
+ upgrade_baud: bool = True,
91
+ target_baud: int | None = None,
92
+ reset_on_open: bool = True,
93
+ timeout: float = 2.0,
94
+ reset_on_exit: bool = False,
95
+ transport=None,
96
+ ):
97
+ self.timeout = timeout
98
+ self.reset_on_exit = False # set for real only once connected (see below)
99
+ self.info: Info | None = None
100
+
101
+ # Candidate ports: explicit transport/port, or every ESP32-like port.
102
+ if transport is not None:
103
+ candidates = [(transport, None, getattr(transport, "usb_chip", None))]
104
+ elif port is not None:
105
+ chip = next((p.usb_chip for p in find_ports() if p.device == port), None)
106
+ candidates = [(None, port, chip)]
107
+ else:
108
+ ports = find_ports()
109
+ if not ports:
110
+ raise NoDeviceError(
111
+ "no ESP32 serial port found (CP210x/CH340/CH9102/native USB); "
112
+ "pass port='COM5' / '/dev/ttyUSB0' explicitly"
113
+ )
114
+ if name is None and mac is None and len(ports) > 1:
115
+ names = ", ".join(p.device for p in ports)
116
+ raise NoDeviceError(
117
+ f"multiple ESP32-like ports found ({names}); pass port=, "
118
+ f"name= or mac= — or use espbridge.connect_all()"
119
+ )
120
+ candidates = [(None, p.device, p.usb_chip) for p in ports]
121
+
122
+ probing = len(candidates) > 1
123
+ errors: list[str] = []
124
+ for t, prt, chip in candidates:
125
+ self._reset_state()
126
+ try:
127
+ self._t = t if t is not None else SerialTransport(prt, baud, usb_chip=chip)
128
+ except Exception as e:
129
+ errors.append(f"{prt}: {e}")
130
+ continue
131
+ self._reader = threading.Thread(target=self._read_loop, daemon=True,
132
+ name="espbridge-reader")
133
+ self._reader.start()
134
+ try:
135
+ self._handshake(reset_on_open)
136
+ assert self.info is not None
137
+ if self._matches(name, mac):
138
+ if upgrade_baud:
139
+ self._upgrade_baud(baud, target_baud)
140
+ self.reset_on_exit = reset_on_exit
141
+ return
142
+ errors.append(f"{prt or 'transport'}: name={self.info.name!r} "
143
+ f"mac={self.info.mac} (no match)")
144
+ self.close()
145
+ except (BridgeTimeoutError, ProtocolError) as e:
146
+ self.close()
147
+ if not probing:
148
+ raise
149
+ errors.append(f"{prt or 'transport'}: {e}")
150
+ except BaseException:
151
+ self.close()
152
+ raise
153
+ raise NoDeviceError("no matching bridge found — " + "; ".join(errors))
154
+
155
+ def _reset_state(self) -> None:
156
+ self._splitter = FrameSplitter()
157
+ self._pending: dict[int, _Pending] = {}
158
+ self._pending_lock = threading.Lock()
159
+ self._seq = 0
160
+ self._write_lock = threading.Lock()
161
+ self._handlers: dict[int | None, list] = {}
162
+ self._handlers_lock = threading.Lock()
163
+ self._ready = threading.Event()
164
+ self._closing = False
165
+ self.info = None
166
+ self.on_event(C.SYS_READY, self._on_ready)
167
+
168
+ def _matches(self, name: str | None, mac: str | None) -> bool:
169
+ assert self.info is not None
170
+ if name is not None and self.info.name != name:
171
+ return False
172
+ if mac is not None and _norm_mac(self.info.mac) != _norm_mac(mac):
173
+ return False
174
+ return True
175
+
176
+ # ---- lifecycle -----------------------------------------------------------
177
+
178
+ def _on_ready(self, payload: bytes) -> None:
179
+ try:
180
+ self.info = Info.parse(payload)
181
+ except ProtocolError:
182
+ return
183
+ self._ready.set()
184
+
185
+ def _handshake(self, reset_on_open: bool) -> None:
186
+ # Opening the port usually auto-resets the board (DTR/RTS): wait for the
187
+ # SYS_READY banner, force a reset if it doesn't come, then fall back to
188
+ # polling SYS_INFO (covers boards with auto-reset disabled).
189
+ if self._ready.wait(3.0 if not reset_on_open else 1.5):
190
+ pass
191
+ elif reset_on_open:
192
+ self._t.pulse_reset()
193
+ self._ready.wait(3.0)
194
+
195
+ if not self._ready.is_set():
196
+ for _ in range(3):
197
+ try:
198
+ payload = self.request(C.SYS_INFO, timeout=1.0)
199
+ self.info = Info.parse(payload)
200
+ self._ready.set()
201
+ break
202
+ except BridgeTimeoutError:
203
+ continue
204
+ if not self._ready.is_set():
205
+ raise BridgeTimeoutError(
206
+ "no response from bridge firmware — is it flashed? (esp/README.md)"
207
+ )
208
+ assert self.info is not None
209
+ if self.info.protocol != C.PROTOCOL_VERSION:
210
+ raise ProtocolError(
211
+ f"protocol mismatch: firmware speaks v{self.info.protocol}, "
212
+ f"this library v{C.PROTOCOL_VERSION} — reflash esp/esp.ino or "
213
+ f"update python-esp-bridge"
214
+ )
215
+
216
+ def _upgrade_baud(self, current: int, target: int | None) -> None:
217
+ assert self.info is not None
218
+ if C.Cap.NATIVE_USB in self.info.caps:
219
+ return # USB CDC: baud is meaningless
220
+ if target is None:
221
+ target = C.UPGRADE_BAUD.get(getattr(self._t, "usb_chip", None), 921600)
222
+ if not target or target == current:
223
+ return
224
+ self.request(C.SYS_SET_BAUD, struct.pack(">I", target))
225
+ time.sleep(0.05) # firmware flushes, then switches
226
+ self._t.set_baudrate(target)
227
+ for _ in range(3):
228
+ try:
229
+ self.ping(b"baud")
230
+ return
231
+ except BridgeTimeoutError:
232
+ continue
233
+ # Could not talk at the new baud: fall back.
234
+ self._t.set_baudrate(current)
235
+ self.ping(b"fallback")
236
+
237
+ def close(self) -> None:
238
+ if self._closing:
239
+ return
240
+ self._closing = True
241
+ if self.reset_on_exit and self._ready.is_set():
242
+ try:
243
+ self.request(C.SYS_RESET, timeout=1.0)
244
+ except Exception:
245
+ pass
246
+ self._t.close()
247
+ if self._reader is not threading.current_thread():
248
+ self._reader.join(timeout=1.0)
249
+
250
+ def __enter__(self) -> "Bridge":
251
+ return self
252
+
253
+ def __exit__(self, *exc) -> None:
254
+ self.close()
255
+
256
+ # ---- reader thread ----------------------------------------------------------
257
+
258
+ def _read_loop(self) -> None:
259
+ while not self._closing:
260
+ try:
261
+ data = self._t.read()
262
+ except Exception:
263
+ break # port closed / unplugged
264
+ if not data:
265
+ continue
266
+ for chunk in self._splitter.feed(data):
267
+ try:
268
+ frame = decode_frame(chunk)
269
+ except ProtocolError:
270
+ continue # corrupted frame: drop; requester times out & retries
271
+ self._handle_frame(frame)
272
+ # Wake up anyone still waiting.
273
+ with self._pending_lock:
274
+ for p in self._pending.values():
275
+ p.event.set()
276
+
277
+ def _handle_frame(self, frame: Frame) -> None:
278
+ if frame.is_event:
279
+ self._dispatch_event(frame)
280
+ return
281
+ with self._pending_lock:
282
+ p = self._pending.get(frame.seq)
283
+ if p is not None:
284
+ p.frame = frame
285
+ p.event.set()
286
+
287
+ def _dispatch_event(self, frame: Frame) -> None:
288
+ with self._handlers_lock:
289
+ specific = list(self._handlers.get(frame.cmd, ()))
290
+ wildcard = list(self._handlers.get(None, ()))
291
+ for cb in specific:
292
+ try:
293
+ cb(frame.payload)
294
+ except Exception:
295
+ pass # user callbacks must not kill the reader
296
+ for cb in wildcard:
297
+ try:
298
+ cb(frame)
299
+ except Exception:
300
+ pass
301
+
302
+ # ---- events API ----------------------------------------------------------------
303
+
304
+ def on_event(self, cmd: int | None, callback) -> None:
305
+ """Register `callback(payload)` for event `cmd` (None = all events, gets the Frame)."""
306
+ with self._handlers_lock:
307
+ self._handlers.setdefault(cmd, []).append(callback)
308
+
309
+ def off_event(self, cmd: int | None, callback) -> None:
310
+ with self._handlers_lock:
311
+ try:
312
+ self._handlers.get(cmd, []).remove(callback)
313
+ except ValueError:
314
+ pass
315
+
316
+ # ---- request/response -------------------------------------------------------------
317
+
318
+ def _alloc_seq(self) -> int:
319
+ with self._pending_lock:
320
+ for _ in range(255):
321
+ self._seq = self._seq % 255 + 1 # cycles 1..255, 0 is reserved
322
+ if self._seq not in self._pending:
323
+ self._pending[self._seq] = _Pending()
324
+ return self._seq
325
+ raise BridgeTimeoutError("255 requests in flight — firmware not answering")
326
+
327
+ def request(self, cmd: int, payload: bytes = b"", timeout: float | None = None) -> bytes:
328
+ """Send a request and return the response payload (raises RemoteError on error status)."""
329
+ seq = self._alloc_seq()
330
+ p = self._pending[seq]
331
+ try:
332
+ with self._write_lock:
333
+ self._t.write(encode_frame(0, seq, cmd, payload))
334
+ if not p.event.wait(timeout if timeout is not None else self.timeout):
335
+ raise BridgeTimeoutError(f"no response for command 0x{cmd:04X}")
336
+ if p.frame is None:
337
+ raise BridgeTimeoutError("connection closed while waiting for response")
338
+ if p.frame.is_error:
339
+ status = p.frame.payload[0] if p.frame.payload else 0xFF
340
+ raise RemoteError(status, cmd)
341
+ return p.frame.payload
342
+ finally:
343
+ with self._pending_lock:
344
+ self._pending.pop(seq, None)
345
+
346
+ def send(self, cmd: int, payload: bytes = b"") -> None:
347
+ """Fire-and-forget (seq=0): the firmware will not reply."""
348
+ with self._write_lock:
349
+ self._t.write(encode_frame(0, 0, cmd, payload))
350
+
351
+ # ---- conveniences ---------------------------------------------------------------------
352
+
353
+ def ping(self, payload: bytes = b"ping") -> float:
354
+ """Round-trip a payload; returns latency in seconds."""
355
+ t0 = time.perf_counter()
356
+ echoed = self.request(C.SYS_PING, payload)
357
+ if echoed != payload:
358
+ raise ProtocolError("ping payload mismatch")
359
+ return time.perf_counter() - t0
360
+
361
+ @property
362
+ def caps(self) -> C.Cap:
363
+ assert self.info is not None
364
+ return self.info.caps
365
+
366
+ def require(self, cap: C.Cap, what: str) -> None:
367
+ if self.info is not None and cap not in self.info.caps:
368
+ raise UnsupportedError(f"{what} is not available on {self.info.chip.name}")
369
+
370
+ def free_heap(self) -> dict:
371
+ v = self.request(C.SYS_FREE_HEAP)
372
+ free, min_free, largest, dropped = struct.unpack(">4I", v)
373
+ return {"free": free, "min_free": min_free, "largest_block": largest,
374
+ "dropped_events": dropped}
375
+
376
+ def reset(self) -> None:
377
+ """Soft-reset the ESP32 and wait for it to come back."""
378
+ self._ready.clear()
379
+ self.request(C.SYS_RESET)
380
+ if not self._ready.wait(5.0):
381
+ raise BridgeTimeoutError("bridge did not come back after reset")
382
+
383
+ def set_name(self, name: str) -> None:
384
+ """Persist a device name on the ESP32 (NVS) for `Bridge(name=...)` lookup."""
385
+ data = name.encode()
386
+ if len(data) > C.BRIDGE_NAME_MAX:
387
+ raise ValueError(f"name must be at most {C.BRIDGE_NAME_MAX} bytes")
388
+ self.request(C.SYS_SET_NAME, data)
389
+ if self.info is not None:
390
+ self.info = dataclasses.replace(self.info, name=name)
391
+
392
+ # ---- sub-APIs (lazy, created on first access) ----------------------------------------------
393
+
394
+ @functools.cached_property
395
+ def gpio(self):
396
+ from .gpio import Gpio
397
+ return Gpio(self)
398
+
399
+ @functools.cached_property
400
+ def adc(self):
401
+ from .analog import Adc
402
+ return Adc(self)
403
+
404
+ @functools.cached_property
405
+ def dac(self):
406
+ from .analog import Dac
407
+ return Dac(self)
408
+
409
+ @functools.cached_property
410
+ def touch(self):
411
+ from .analog import Touch
412
+ return Touch(self)
413
+
414
+ @functools.cached_property
415
+ def pwm(self):
416
+ from .pwm import Pwm
417
+ return Pwm(self)
418
+
419
+ @functools.cached_property
420
+ def i2c(self):
421
+ from .i2c import I2c
422
+ return I2c(self)
423
+
424
+ @functools.cached_property
425
+ def spi(self):
426
+ from .spi import Spi
427
+ return Spi(self)
428
+
429
+ @functools.cached_property
430
+ def uart(self):
431
+ from .uart import Uart
432
+ return Uart(self)
433
+
434
+ @functools.cached_property
435
+ def wifi(self):
436
+ from .wifi import Wifi
437
+ return Wifi(self)
438
+
439
+ @functools.cached_property
440
+ def net(self):
441
+ from .net import Net
442
+ return Net(self)
443
+
444
+ @functools.cached_property
445
+ def ble(self):
446
+ from .ble import Ble
447
+ return Ble(self)
448
+
449
+
450
+ class BridgeSet(list):
451
+ """A list of Bridges with convenience helpers (returned by connect_all)."""
452
+
453
+ def by_name(self, name: str) -> "Bridge":
454
+ for b in self:
455
+ if b.info is not None and b.info.name == name:
456
+ return b
457
+ raise NoDeviceError(f"no connected bridge named {name!r}")
458
+
459
+ def by_mac(self, mac: str) -> "Bridge":
460
+ for b in self:
461
+ if b.info is not None and _norm_mac(b.info.mac) == _norm_mac(mac):
462
+ return b
463
+ raise NoDeviceError(f"no connected bridge with MAC {mac}")
464
+
465
+ def close_all(self) -> None:
466
+ for b in self:
467
+ b.close()
468
+
469
+ def __enter__(self) -> "BridgeSet":
470
+ return self
471
+
472
+ def __exit__(self, *exc) -> None:
473
+ self.close_all()
474
+
475
+
476
+ def connect_all(**kwargs) -> BridgeSet:
477
+ """Connect to every attached bridge.
478
+
479
+ >>> import espbridge
480
+ >>> with espbridge.connect_all() as boards:
481
+ ... for esp in boards:
482
+ ... print(esp.info.name or esp.info.mac, esp.info.chip.name)
483
+ ... boards.by_name("relays").gpio.write(2, 1)
484
+ """
485
+ out = BridgeSet()
486
+ errors: list[str] = []
487
+ for p in find_ports():
488
+ try:
489
+ out.append(Bridge(p.device, **kwargs))
490
+ except BridgeError as e:
491
+ errors.append(f"{p.device}: {e}")
492
+ if not out:
493
+ raise NoDeviceError("no bridges connected"
494
+ + (" — " + "; ".join(errors) if errors else ""))
495
+ return out
espbridge/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line entry point: `espbridge`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .bridge import Bridge, connect_all
9
+ from .errors import BridgeError
10
+ from .transport import find_ports
11
+
12
+
13
+ def _print_info(esp: Bridge) -> None:
14
+ info = esp.info
15
+ assert info is not None
16
+ fw = ".".join(map(str, info.fw_version))
17
+ print(f"name : {info.name or '(unnamed — set with `espbridge set-name`)'}")
18
+ print(f"chip : {info.chip.name} rev {info.chip_rev}")
19
+ print(f"mac : {info.mac}")
20
+ print(f"firmware : v{fw} (protocol v{info.protocol})")
21
+ print(f"flash : {info.flash_mb} MB")
22
+ print(f"gpio count: {info.gpio_count}")
23
+ print(f"caps : {info.caps!r}")
24
+ print(f"ping : {esp.ping() * 1000:.2f} ms")
25
+ heap = esp.free_heap()
26
+ print(f"free heap : {heap['free']} (min {heap['min_free']})")
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ ap = argparse.ArgumentParser(prog="espbridge",
31
+ description="python-esp-bridge host tool")
32
+ ap.add_argument("--version", action="version", version=f"espbridge {__version__}")
33
+ ap.add_argument("-p", "--port", help="serial port (default: auto-detect)")
34
+ ap.add_argument("-n", "--name", help="select device by stored name")
35
+ ap.add_argument("--no-baud-upgrade", action="store_true",
36
+ help="stay at 115200 instead of upgrading the link speed")
37
+ sub = ap.add_subparsers(dest="cmd")
38
+ sub.add_parser("ports", help="list ESP32-like serial ports")
39
+ sub.add_parser("info", help="connect and print firmware/chip info (default; "
40
+ "shows every device when several are attached)")
41
+ p_name = sub.add_parser("set-name", help="store a device name on the ESP32 (NVS)")
42
+ p_name.add_argument("new_name", help="name to assign (max 32 bytes)")
43
+ args = ap.parse_args(argv)
44
+
45
+ kwargs = dict(upgrade_baud=not args.no_baud_upgrade)
46
+
47
+ try:
48
+ if args.cmd == "ports":
49
+ ports = find_ports()
50
+ if not ports:
51
+ print("no ESP32-like serial ports found")
52
+ return 1
53
+ for p in ports:
54
+ print(f"{p.device}\t{p.usb_chip}\t{p.description}")
55
+ return 0
56
+
57
+ if args.cmd == "set-name":
58
+ with Bridge(args.port, name=args.name, **kwargs) as esp:
59
+ esp.set_name(args.new_name)
60
+ print(f"{esp.info.mac} is now named {args.new_name!r}")
61
+ return 0
62
+
63
+ # default: info
64
+ if args.port is None and args.name is None and len(find_ports()) > 1:
65
+ with connect_all(**kwargs) as boards:
66
+ for i, esp in enumerate(boards):
67
+ if i:
68
+ print("-" * 40)
69
+ _print_info(esp)
70
+ return 0
71
+ with Bridge(args.port, name=args.name, **kwargs) as esp:
72
+ _print_info(esp)
73
+ return 0
74
+ except BridgeError as e:
75
+ print(f"error: {e}", file=sys.stderr)
76
+ return 1
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
File without changes