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/__init__.py +45 -0
- espbridge/analog.py +64 -0
- espbridge/ble.py +223 -0
- espbridge/bridge.py +495 -0
- espbridge/cli.py +80 -0
- espbridge/compat/__init__.py +0 -0
- espbridge/compat/blinka.py +209 -0
- espbridge/compat/gpiozero.py +233 -0
- espbridge/compat/luma.py +90 -0
- espbridge/compat/rpi_gpio.py +121 -0
- espbridge/compat/smbus.py +63 -0
- espbridge/constants.py +199 -0
- espbridge/errors.py +38 -0
- espbridge/gpio.py +72 -0
- espbridge/i2c.py +65 -0
- espbridge/net.py +279 -0
- espbridge/oled.py +182 -0
- espbridge/protocol.py +159 -0
- espbridge/pwm.py +41 -0
- espbridge/spi.py +43 -0
- espbridge/transport.py +121 -0
- espbridge/uart.py +111 -0
- espbridge/wifi.py +128 -0
- python_esp_bridge-0.0.2.dist-info/METADATA +36 -0
- python_esp_bridge-0.0.2.dist-info/RECORD +27 -0
- python_esp_bridge-0.0.2.dist-info/WHEEL +4 -0
- python_esp_bridge-0.0.2.dist-info/entry_points.txt +2 -0
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
|