python-esp-bridge 0.1.1__tar.gz → 0.3.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.
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/PKG-INFO +3 -3
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/__init__.py +10 -2
- python_esp_bridge-0.3.1/espbridge/_log.py +68 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/bridge.py +115 -72
- python_esp_bridge-0.3.1/espbridge/camera.py +81 -0
- python_esp_bridge-0.3.1/espbridge/can.py +105 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/cli.py +2 -2
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/constants.py +121 -0
- python_esp_bridge-0.3.1/espbridge/dht.py +74 -0
- python_esp_bridge-0.3.1/espbridge/ds18b20.py +68 -0
- python_esp_bridge-0.3.1/espbridge/espnow.py +157 -0
- python_esp_bridge-0.3.1/espbridge/eth.py +92 -0
- python_esp_bridge-0.3.1/espbridge/fs.py +163 -0
- python_esp_bridge-0.3.1/espbridge/hcsr04.py +37 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/i2c.py +9 -2
- python_esp_bridge-0.3.1/espbridge/i2s.py +81 -0
- python_esp_bridge-0.3.1/espbridge/ir.py +90 -0
- python_esp_bridge-0.3.1/espbridge/mcpwm.py +36 -0
- python_esp_bridge-0.3.1/espbridge/neopixel.py +83 -0
- python_esp_bridge-0.3.1/espbridge/nvs.py +76 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/oled.py +4 -3
- python_esp_bridge-0.3.1/espbridge/onewire.py +84 -0
- python_esp_bridge-0.3.1/espbridge/ota.py +57 -0
- python_esp_bridge-0.3.1/espbridge/rmt.py +109 -0
- python_esp_bridge-0.3.1/espbridge/stepper.py +93 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/transports/ble.py +7 -8
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/pyproject.toml +3 -3
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/uv.lock +1 -1
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/.gitignore +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/.python-version +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/README.md +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/analog.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/ble.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/__init__.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/blinka.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/gpiozero.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/luma.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/rpi_gpio.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/compat/smbus.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/errors.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/gpio.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/net.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/protocol.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/pwm.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/spi.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/transport.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/transports/__init__.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/transports/mock.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/transports/serial.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/uart.py +0 -0
- {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.1}/espbridge/wifi.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-esp-bridge
|
|
3
|
-
Version: 0.
|
|
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
|
|
3
|
+
Version: 0.3.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, ESP-NOW, RMT, 1-Wire, CAN, I2S, files, NVS, OTA
|
|
5
5
|
Project-URL: Homepage, https://github.com/HamzaYslmn/python-esp-bridge
|
|
6
6
|
Author: HamzaYslmn
|
|
7
|
-
Keywords: ble,bridge,esp32,firmata,gpio,i2c,raspberry-pi,serial,spi
|
|
7
|
+
Keywords: ble,bridge,esp32,espnow,firmata,gpio,i2c,raspberry-pi,serial,spi
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Requires-Dist: pyserial>=3.5
|
|
10
10
|
Provides-Extra: ble
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""espbridge — control every ESP32 peripheral from Python over USB
|
|
1
|
+
"""espbridge — control every ESP32 peripheral from Python over USB or Bluetooth.
|
|
2
2
|
|
|
3
3
|
Flash firmware/firmware.ino once, then:
|
|
4
4
|
|
|
@@ -11,6 +11,14 @@ Flash firmware/firmware.ino once, then:
|
|
|
11
11
|
print(esp.i2c.scan())
|
|
12
12
|
esp.wifi.connect("ssid", "password")
|
|
13
13
|
sock = esp.net.tcp_connect("example.com", 80) # TCP through the ESP32 radio
|
|
14
|
+
|
|
15
|
+
esp.nvs.set("runs", 1) # on-board key/value storage
|
|
16
|
+
esp.fs.mount("littlefs") # on-board files
|
|
17
|
+
esp.can.begin(tx=21, rx=22) # CAN bus
|
|
18
|
+
esp.ota.flash("new.bin") # update the firmware over the link
|
|
19
|
+
|
|
20
|
+
# device drivers in pure Python (RMT / 1-Wire primitives):
|
|
21
|
+
# espbridge.neopixel, .dht, .ds18b20, .hcsr04, .ir, .stepper
|
|
14
22
|
"""
|
|
15
23
|
from .bridge import Bridge, BridgeSet, Info, connect_all
|
|
16
24
|
from .constants import Cap, ChipModel, Status
|
|
@@ -32,7 +40,7 @@ def find_ble_devices(timeout: float = 5.0):
|
|
|
32
40
|
|
|
33
41
|
return _scan(timeout)
|
|
34
42
|
|
|
35
|
-
__version__ = "0.
|
|
43
|
+
__version__ = "0.3.1"
|
|
36
44
|
|
|
37
45
|
__all__ = [
|
|
38
46
|
"Bridge",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Library logger: "[espbridge]" prefix + uvicorn-style colored level prefix.
|
|
2
|
+
|
|
3
|
+
Mirrors uvicorn.logging.DefaultFormatter (same colors, same ``LEVEL:``
|
|
4
|
+
padding) without depending on uvicorn: colors auto-enable only when stderr
|
|
5
|
+
is a terminal that supports ANSI, and NO_COLOR is honored.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
# uvicorn's palette: DEBUG cyan, INFO green, WARNING yellow, ERROR red,
|
|
14
|
+
# CRITICAL bright red.
|
|
15
|
+
_LEVEL_COLORS = {
|
|
16
|
+
logging.DEBUG: 36,
|
|
17
|
+
logging.INFO: 32,
|
|
18
|
+
logging.WARNING: 33,
|
|
19
|
+
logging.ERROR: 31,
|
|
20
|
+
logging.CRITICAL: 91,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _supports_color(stream) -> bool:
|
|
25
|
+
if os.environ.get("NO_COLOR"):
|
|
26
|
+
return False
|
|
27
|
+
if not getattr(stream, "isatty", lambda: False)():
|
|
28
|
+
return False
|
|
29
|
+
if sys.platform == "win32":
|
|
30
|
+
# Windows Terminal/VS Code have VT processing on already; flip it on
|
|
31
|
+
# for the classic console. Failure means no ANSI support.
|
|
32
|
+
import ctypes
|
|
33
|
+
|
|
34
|
+
kernel32 = ctypes.windll.kernel32
|
|
35
|
+
handle = kernel32.GetStdHandle(-12) # STD_ERROR_HANDLE
|
|
36
|
+
mode = ctypes.c_uint32()
|
|
37
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
38
|
+
return False
|
|
39
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
40
|
+
return bool(kernel32.SetConsoleMode(
|
|
41
|
+
handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Formatter(logging.Formatter):
|
|
46
|
+
def __init__(self, use_colors: bool):
|
|
47
|
+
super().__init__("[espbridge] %(levelprefix)s %(message)s")
|
|
48
|
+
self.use_colors = use_colors
|
|
49
|
+
|
|
50
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
51
|
+
pad = " " * max(0, 8 - len(record.levelname)) # align like uvicorn
|
|
52
|
+
if self.use_colors:
|
|
53
|
+
color = _LEVEL_COLORS.get(record.levelno, 0)
|
|
54
|
+
record.levelprefix = (
|
|
55
|
+
f"\x1b[{color}m{record.levelname}\x1b[0m:{pad}")
|
|
56
|
+
else:
|
|
57
|
+
record.levelprefix = f"{record.levelname}:{pad}"
|
|
58
|
+
return super().format(record)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
log = logging.getLogger("espbridge")
|
|
62
|
+
if not log.handlers:
|
|
63
|
+
if log.level == logging.NOTSET: # respect a level the app set first
|
|
64
|
+
log.setLevel(logging.INFO)
|
|
65
|
+
_handler = logging.StreamHandler(sys.stderr)
|
|
66
|
+
_handler.setFormatter(_Formatter(_supports_color(sys.stderr)))
|
|
67
|
+
log.addHandler(_handler)
|
|
68
|
+
log.propagate = False
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import dataclasses
|
|
5
|
-
import functools
|
|
6
5
|
import struct
|
|
7
6
|
import threading
|
|
8
7
|
import time
|
|
9
8
|
from dataclasses import dataclass
|
|
10
9
|
|
|
11
10
|
from . import constants as C
|
|
11
|
+
from ._log import log
|
|
12
12
|
from .errors import (
|
|
13
13
|
AuthError,
|
|
14
14
|
BridgeError,
|
|
@@ -124,12 +124,9 @@ class Bridge:
|
|
|
124
124
|
"no ESP32 serial port found (CP210x/CH340/CH9102/native USB); "
|
|
125
125
|
"pass port='COM5' / '/dev/ttyUSB0' explicitly — or ble=True"
|
|
126
126
|
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
f"multiple ESP32-like ports found ({names}); pass port=, "
|
|
131
|
-
f"name= or mac= — or use espbridge.connect_all()"
|
|
132
|
-
)
|
|
127
|
+
# Several ESP32-like ports: probe each in turn and keep the first
|
|
128
|
+
# that answers the bridge handshake (and matches name=/mac= if
|
|
129
|
+
# given) — non-bridge boards just time out and are skipped.
|
|
133
130
|
candidates = [(lambda p=p: SerialTransport(p.device, baud, usb_chip=p.usb_chip),
|
|
134
131
|
p.device, p.usb_chip) for p in ports]
|
|
135
132
|
|
|
@@ -137,9 +134,12 @@ class Bridge:
|
|
|
137
134
|
errors: list[str] = []
|
|
138
135
|
for factory, label, chip in candidates:
|
|
139
136
|
self._reset_state()
|
|
137
|
+
if probing:
|
|
138
|
+
log.debug(f"probing {label} ...")
|
|
140
139
|
try:
|
|
141
140
|
self._t = factory()
|
|
142
141
|
except Exception as e:
|
|
142
|
+
log.debug(f"{label}: open failed: {e}")
|
|
143
143
|
errors.append(f"{label}: {e}")
|
|
144
144
|
continue
|
|
145
145
|
self._reader = threading.Thread(target=self._read_loop, daemon=True,
|
|
@@ -156,6 +156,16 @@ class Bridge:
|
|
|
156
156
|
if upgrade_baud and getattr(self._t, "has_baud", True):
|
|
157
157
|
self._upgrade_baud(baud, target_baud)
|
|
158
158
|
self.reset_on_exit = reset_on_exit
|
|
159
|
+
if probing and name is None and mac is None:
|
|
160
|
+
others = ", ".join(l for _, l, _ in candidates
|
|
161
|
+
if l != label) or "none"
|
|
162
|
+
log.info(
|
|
163
|
+
f"auto-selected {label}: "
|
|
164
|
+
f"name={self.info.name or '(unnamed)'} "
|
|
165
|
+
f"mac={self.info.mac} "
|
|
166
|
+
f"chip={self.info.chip.name} "
|
|
167
|
+
f"(other candidates: {others}; pin one with "
|
|
168
|
+
f"port=, name=, mac= or ble='name-or-mac')")
|
|
159
169
|
return
|
|
160
170
|
errors.append(f"{label}: name={self.info.name!r} "
|
|
161
171
|
f"mac={self.info.mac} (no match)")
|
|
@@ -164,6 +174,7 @@ class Bridge:
|
|
|
164
174
|
self.close()
|
|
165
175
|
if not probing:
|
|
166
176
|
raise
|
|
177
|
+
log.debug(f"{label}: {e}")
|
|
167
178
|
errors.append(f"{label}: {e}")
|
|
168
179
|
except BaseException:
|
|
169
180
|
self.close()
|
|
@@ -195,11 +206,8 @@ class Bridge:
|
|
|
195
206
|
f"no bridge{what} found over Bluetooth — is the board powered, "
|
|
196
207
|
f"in range, and flashed with BRIDGE_BLE_LINK enabled?"
|
|
197
208
|
)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
raise NoDeviceError(
|
|
201
|
-
f"multiple bridges advertising ({names}); pass ble='name-or-mac'"
|
|
202
|
-
)
|
|
209
|
+
# Several bridges advertising: probe in adv order, first auth +
|
|
210
|
+
# handshake wins (the caller prints which one was auto-selected).
|
|
203
211
|
return [(lambda d=d: BleTransport(d.address),
|
|
204
212
|
f"BLE {d.name or d.address}", None) for d in devs]
|
|
205
213
|
|
|
@@ -228,6 +236,7 @@ class Bridge:
|
|
|
228
236
|
self._closing = False
|
|
229
237
|
self.info = None
|
|
230
238
|
self.on_event(C.SYS_READY, self._on_ready)
|
|
239
|
+
self.on_event(C.SYS_LOG, self._on_sys_log)
|
|
231
240
|
|
|
232
241
|
def _matches(self, name: str | None, mac: str | None) -> bool:
|
|
233
242
|
assert self.info is not None
|
|
@@ -291,10 +300,12 @@ class Bridge:
|
|
|
291
300
|
for _ in range(3):
|
|
292
301
|
try:
|
|
293
302
|
self.ping(b"baud")
|
|
303
|
+
log.debug(f"baud upgraded {current} -> {target}")
|
|
294
304
|
return
|
|
295
305
|
except BridgeTimeoutError:
|
|
296
306
|
continue
|
|
297
307
|
# Could not talk at the new baud: fall back.
|
|
308
|
+
log.warning(f"baud upgrade to {target} failed; falling back to {current}")
|
|
298
309
|
self._t.set_baudrate(current)
|
|
299
310
|
try:
|
|
300
311
|
self.ping(b"fallback")
|
|
@@ -310,6 +321,7 @@ class Bridge:
|
|
|
310
321
|
chip = getattr(self._t, "usb_chip", None)
|
|
311
322
|
if port is None:
|
|
312
323
|
raise BridgeTimeoutError("link lost and transport cannot be reopened")
|
|
324
|
+
log.warning(f"link lost; reopening {port} at {baud} baud")
|
|
313
325
|
self._t.close()
|
|
314
326
|
self._reader.join(timeout=1.0)
|
|
315
327
|
time.sleep(0.3) # let the device reboot from the close-time reset
|
|
@@ -345,15 +357,18 @@ class Bridge:
|
|
|
345
357
|
while not self._closing:
|
|
346
358
|
try:
|
|
347
359
|
data = self._t.read()
|
|
348
|
-
except Exception:
|
|
360
|
+
except Exception as e:
|
|
361
|
+
if not self._closing:
|
|
362
|
+
log.warning(f"transport read failed ({e}); link is down")
|
|
349
363
|
break # port closed / unplugged
|
|
350
364
|
if not data:
|
|
351
365
|
continue
|
|
352
366
|
for chunk in self._splitter.feed(data):
|
|
353
367
|
try:
|
|
354
368
|
frame = decode_frame(chunk)
|
|
355
|
-
except ProtocolError:
|
|
356
|
-
|
|
369
|
+
except ProtocolError as e:
|
|
370
|
+
log.debug(f"dropping corrupted frame: {e}")
|
|
371
|
+
continue # requester times out & retries
|
|
357
372
|
self._handle_frame(frame)
|
|
358
373
|
# Wake up anyone still waiting.
|
|
359
374
|
with self._pending_lock:
|
|
@@ -370,6 +385,13 @@ class Bridge:
|
|
|
370
385
|
p.frame = frame
|
|
371
386
|
p.event.set()
|
|
372
387
|
|
|
388
|
+
def _on_sys_log(self, payload: bytes) -> None:
|
|
389
|
+
# Firmware log line (incl. redirected ESP-IDF Wi-Fi/BT logs):
|
|
390
|
+
# level u8 | message. Surface via the espbridge logger.
|
|
391
|
+
if payload:
|
|
392
|
+
msg = payload[1:].decode("utf-8", "replace")
|
|
393
|
+
(log.warning if payload[0] >= 2 else log.info)(f"[fw] {msg}")
|
|
394
|
+
|
|
373
395
|
def _dispatch_event(self, frame: Frame) -> None:
|
|
374
396
|
with self._handlers_lock:
|
|
375
397
|
specific = list(self._handlers.get(frame.cmd, ()))
|
|
@@ -377,13 +399,13 @@ class Bridge:
|
|
|
377
399
|
for cb in specific:
|
|
378
400
|
try:
|
|
379
401
|
cb(frame.payload)
|
|
380
|
-
except Exception:
|
|
381
|
-
|
|
402
|
+
except Exception: # user callbacks must not kill the reader
|
|
403
|
+
log.exception(f"event callback {cb!r} raised")
|
|
382
404
|
for cb in wildcard:
|
|
383
405
|
try:
|
|
384
406
|
cb(frame)
|
|
385
407
|
except Exception:
|
|
386
|
-
|
|
408
|
+
log.exception(f"event callback {cb!r} raised")
|
|
387
409
|
|
|
388
410
|
# ---- events API ----------------------------------------------------------------
|
|
389
411
|
|
|
@@ -459,6 +481,42 @@ class Bridge:
|
|
|
459
481
|
return {"free": free, "min_free": min_free, "largest_block": largest,
|
|
460
482
|
"dropped_events": dropped}
|
|
461
483
|
|
|
484
|
+
def deep_sleep(self, seconds: float = 0, *, wake_pin: int | None = None,
|
|
485
|
+
wake_level: int = 1) -> None:
|
|
486
|
+
"""Put the ESP32 into deep sleep; it reboots on wake-up.
|
|
487
|
+
|
|
488
|
+
The link drops while asleep. Wake on a timer (`seconds`), a GPIO
|
|
489
|
+
level (`wake_pin`/`wake_level`), or both. After a timer wake the
|
|
490
|
+
board boots fresh — reconnect with a new Bridge() or `reset()` flow.
|
|
491
|
+
|
|
492
|
+
Not available on classic ESP32 when the BLE link is compiled in
|
|
493
|
+
(IRAM limit) — build with BRIDGE_ENABLE_BLE 0 to enable it there.
|
|
494
|
+
"""
|
|
495
|
+
self.require(C.Cap.SLEEP, "sleep")
|
|
496
|
+
self.request(C.SYS_SLEEP, self._sleep_args(0, seconds, wake_pin, wake_level))
|
|
497
|
+
|
|
498
|
+
def light_sleep(self, seconds: float = 0, *, wake_pin: int | None = None,
|
|
499
|
+
wake_level: int = 1) -> int:
|
|
500
|
+
"""Pause the ESP32 in light sleep; returns the wake cause (RAM and
|
|
501
|
+
the link survive; the reply arrives after wake-up)."""
|
|
502
|
+
self.require(C.Cap.SLEEP, "sleep")
|
|
503
|
+
r = self.request(C.SYS_SLEEP, self._sleep_args(1, seconds, wake_pin, wake_level),
|
|
504
|
+
timeout=seconds + self.timeout)
|
|
505
|
+
return r[0]
|
|
506
|
+
|
|
507
|
+
def wake_cause(self) -> int:
|
|
508
|
+
"""esp_sleep_wakeup_cause_t of the last boot (0 = normal reset,
|
|
509
|
+
2 ext0, 3 ext1, 4 timer, 7 gpio)."""
|
|
510
|
+
return self.request(C.SYS_WAKE_CAUSE)[0]
|
|
511
|
+
|
|
512
|
+
@staticmethod
|
|
513
|
+
def _sleep_args(mode: int, seconds: float, wake_pin: int | None,
|
|
514
|
+
wake_level: int) -> bytes:
|
|
515
|
+
if seconds <= 0 and wake_pin is None:
|
|
516
|
+
raise ValueError("give a timer (seconds) and/or a wake_pin")
|
|
517
|
+
return struct.pack(">BQbB", mode, round(seconds * 1_000_000),
|
|
518
|
+
-1 if wake_pin is None else wake_pin, wake_level)
|
|
519
|
+
|
|
462
520
|
def reset(self) -> None:
|
|
463
521
|
"""Soft-reset the ESP32 and wait for it to come back."""
|
|
464
522
|
self._ready.clear()
|
|
@@ -477,60 +535,44 @@ class Bridge:
|
|
|
477
535
|
|
|
478
536
|
# ---- sub-APIs (lazy, created on first access) ----------------------------------------------
|
|
479
537
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def
|
|
517
|
-
|
|
518
|
-
return Uart(self)
|
|
519
|
-
|
|
520
|
-
@functools.cached_property
|
|
521
|
-
def wifi(self):
|
|
522
|
-
from .wifi import Wifi
|
|
523
|
-
return Wifi(self)
|
|
524
|
-
|
|
525
|
-
@functools.cached_property
|
|
526
|
-
def net(self):
|
|
527
|
-
from .net import Net
|
|
528
|
-
return Net(self)
|
|
529
|
-
|
|
530
|
-
@functools.cached_property
|
|
531
|
-
def ble(self):
|
|
532
|
-
from .ble import Ble
|
|
533
|
-
return Ble(self)
|
|
538
|
+
_SUBAPIS = {
|
|
539
|
+
"gpio": ("gpio", "Gpio"),
|
|
540
|
+
"adc": ("analog", "Adc"),
|
|
541
|
+
"dac": ("analog", "Dac"),
|
|
542
|
+
"touch": ("analog", "Touch"),
|
|
543
|
+
"pwm": ("pwm", "Pwm"),
|
|
544
|
+
"i2c": ("i2c", "I2c"),
|
|
545
|
+
"spi": ("spi", "Spi"),
|
|
546
|
+
"uart": ("uart", "Uart"),
|
|
547
|
+
"wifi": ("wifi", "Wifi"),
|
|
548
|
+
"net": ("net", "Net"),
|
|
549
|
+
"ble": ("ble", "Ble"),
|
|
550
|
+
"espnow": ("espnow", "EspNow"),
|
|
551
|
+
"rmt": ("rmt", "Rmt"),
|
|
552
|
+
"onewire": ("onewire", "OneWire"),
|
|
553
|
+
"fs": ("fs", "Fs"),
|
|
554
|
+
"nvs": ("nvs", "Nvs"),
|
|
555
|
+
"ota": ("ota", "Ota"),
|
|
556
|
+
"can": ("can", "Can"),
|
|
557
|
+
"i2s": ("i2s", "I2s"),
|
|
558
|
+
"eth": ("eth", "Eth"),
|
|
559
|
+
"camera": ("camera", "Camera"),
|
|
560
|
+
"mcpwm": ("mcpwm", "Mcpwm"),
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
def __getattr__(self, name):
|
|
564
|
+
try:
|
|
565
|
+
mod_name, cls_name = self._SUBAPIS[name]
|
|
566
|
+
except KeyError:
|
|
567
|
+
raise AttributeError(name) from None
|
|
568
|
+
import importlib
|
|
569
|
+
|
|
570
|
+
obj = getattr(importlib.import_module(f".{mod_name}", __package__), cls_name)(self)
|
|
571
|
+
setattr(self, name, obj) # cache: next access skips __getattr__
|
|
572
|
+
return obj
|
|
573
|
+
|
|
574
|
+
def __dir__(self):
|
|
575
|
+
return [*super().__dir__(), *self._SUBAPIS]
|
|
534
576
|
|
|
535
577
|
|
|
536
578
|
class BridgeSet(list):
|
|
@@ -574,6 +616,7 @@ def connect_all(**kwargs) -> BridgeSet:
|
|
|
574
616
|
try:
|
|
575
617
|
out.append(Bridge(p.device, **kwargs))
|
|
576
618
|
except BridgeError as e:
|
|
619
|
+
log.warning(f"connect_all: skipping {p.device}: {e}")
|
|
577
620
|
errors.append(f"{p.device}: {e}")
|
|
578
621
|
if not out:
|
|
579
622
|
raise NoDeviceError("no bridges connected"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Camera — JPEG snapshots from OV-series sensors (firmware opt-in).
|
|
2
|
+
|
|
3
|
+
Build the firmware with ``#define BRIDGE_ENABLE_CAM 1`` (esp32/s2/s3 with
|
|
4
|
+
PSRAM), then:
|
|
5
|
+
|
|
6
|
+
esp.camera.begin("ai-thinker") # ESP32-CAM board
|
|
7
|
+
jpeg = esp.camera.capture() # bytes, ready to save
|
|
8
|
+
open("shot.jpg", "wb").write(jpeg)
|
|
9
|
+
esp.camera.set("framesize", FRAMESIZE_VGA)
|
|
10
|
+
|
|
11
|
+
Frames cross the link at ~92 KB/s over USB — snapshots, not video.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
|
|
17
|
+
from . import constants as C
|
|
18
|
+
|
|
19
|
+
# framesize_t values (esp32-camera)
|
|
20
|
+
FRAMESIZE_QQVGA, FRAMESIZE_QCIF, FRAMESIZE_HQVGA, FRAMESIZE_240X240, \
|
|
21
|
+
FRAMESIZE_QVGA, FRAMESIZE_CIF, FRAMESIZE_HVGA, FRAMESIZE_VGA, \
|
|
22
|
+
FRAMESIZE_SVGA, FRAMESIZE_XGA, FRAMESIZE_HD, FRAMESIZE_SXGA, \
|
|
23
|
+
FRAMESIZE_UXGA = range(13)
|
|
24
|
+
|
|
25
|
+
# CAM_SET property ids (mirrors firmware mod_cam.cpp)
|
|
26
|
+
_PROPS = {"framesize": 0, "quality": 1, "brightness": 2, "contrast": 3,
|
|
27
|
+
"saturation": 4, "vflip": 5, "hmirror": 6, "special_effect": 7,
|
|
28
|
+
"whitebal": 8, "exposure_ctrl": 9, "aec_value": 10,
|
|
29
|
+
"gain_ctrl": 11, "agc_gain": 12}
|
|
30
|
+
|
|
31
|
+
# pin maps: pwdn, reset, xclk, siod, sioc, d7..d0, vsync, href, pclk
|
|
32
|
+
PRESETS: dict[str, list[int]] = {
|
|
33
|
+
"ai-thinker": [32, -1, 0, 26, 27, 35, 34, 39, 36, 21, 19, 18, 5, 25, 23, 22],
|
|
34
|
+
"esp-eye": [-1, -1, 4, 18, 23, 36, 37, 38, 39, 35, 14, 13, 34, 5, 27, 25],
|
|
35
|
+
"m5stack-wide": [-1, 15, 27, 22, 23, 19, 36, 18, 39, 5, 34, 35, 32, 25, 26, 21],
|
|
36
|
+
"xiao-s3-sense": [-1, -1, 10, 40, 39, 48, 11, 12, 14, 16, 18, 17, 15, 38, 47, 13],
|
|
37
|
+
"freenove-s3": [-1, -1, 15, 4, 5, 16, 17, 18, 12, 10, 8, 9, 11, 6, 7, 13],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_CHUNK = C.MAX_PAYLOAD - 8
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Camera:
|
|
44
|
+
def __init__(self, bridge):
|
|
45
|
+
self._b = bridge
|
|
46
|
+
bridge.require(C.Cap.CAM, "camera (BRIDGE_ENABLE_CAM=1 firmware + PSRAM)")
|
|
47
|
+
|
|
48
|
+
def begin(self, preset: str | list[int] = "ai-thinker", *,
|
|
49
|
+
framesize: int = FRAMESIZE_VGA, quality: int = 12,
|
|
50
|
+
xclk_mhz: int = 20, fb_count: int = 1) -> None:
|
|
51
|
+
"""Init the sensor. preset = board name or a 16-entry pin list.
|
|
52
|
+
quality: JPEG 0 (best) .. 63; fb_count 2 double-buffers in PSRAM."""
|
|
53
|
+
pins = PRESETS[preset] if isinstance(preset, str) else list(preset)
|
|
54
|
+
if len(pins) != 16:
|
|
55
|
+
raise ValueError("pin map must have 16 entries")
|
|
56
|
+
payload = struct.pack(">16b", *pins) + bytes([xclk_mhz, framesize,
|
|
57
|
+
quality, fb_count])
|
|
58
|
+
self._b.request(C.CAM_INIT, payload, timeout=15.0)
|
|
59
|
+
|
|
60
|
+
def capture(self) -> bytes:
|
|
61
|
+
"""Grab one frame and pull it across the link (JPEG bytes)."""
|
|
62
|
+
r = self._b.request(C.CAM_CAPTURE, timeout=10.0)
|
|
63
|
+
total = struct.unpack(">I", r[:4])[0]
|
|
64
|
+
out = bytearray()
|
|
65
|
+
while len(out) < total:
|
|
66
|
+
chunk = self._b.request(
|
|
67
|
+
C.CAM_READ, struct.pack(">IH", len(out),
|
|
68
|
+
min(_CHUNK, total - len(out))))
|
|
69
|
+
if not chunk:
|
|
70
|
+
break
|
|
71
|
+
out += chunk
|
|
72
|
+
self._b.request(C.CAM_RELEASE)
|
|
73
|
+
return bytes(out)
|
|
74
|
+
|
|
75
|
+
def set(self, prop: str, value: int) -> None:
|
|
76
|
+
"""Tune the sensor: framesize, quality, brightness (-2..2),
|
|
77
|
+
contrast, saturation, vflip, hmirror, ... (see _PROPS)."""
|
|
78
|
+
self._b.request(C.CAM_SET, struct.pack(">Bi", _PROPS[prop], value))
|
|
79
|
+
|
|
80
|
+
def end(self) -> None:
|
|
81
|
+
self._b.request(C.CAM_DEINIT)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""CAN bus (the ESP32's TWAI controller, python-can-flavored API).
|
|
2
|
+
|
|
3
|
+
Wire a CAN transceiver (SN65HVD230, TJA1050, ...) between the chosen pins
|
|
4
|
+
and the bus — the ESP32 pins speak TTL, not CAN levels.
|
|
5
|
+
|
|
6
|
+
esp.can.begin(tx=21, rx=22, bitrate=500_000)
|
|
7
|
+
esp.can.send(0x123, b"\\x01\\x02")
|
|
8
|
+
msg = esp.can.recv(timeout=1.0) # polled
|
|
9
|
+
esp.can.on_message(lambda m: print(m)) # or callbacks
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import queue
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from . import constants as C
|
|
18
|
+
|
|
19
|
+
_BITRATES = {25_000: 0, 50_000: 1, 100_000: 2, 125_000: 3,
|
|
20
|
+
250_000: 4, 500_000: 5, 800_000: 6, 1_000_000: 7}
|
|
21
|
+
_MODES = {"normal": 0, "listen": 1, "no_ack": 2}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Message:
|
|
26
|
+
id: int
|
|
27
|
+
data: bytes = b""
|
|
28
|
+
extended: bool = False
|
|
29
|
+
rtr: bool = field(default=False, repr=False)
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"CAN {self.id:0{8 if self.extended else 3}X} [{len(self.data)}] {self.data.hex(' ')}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Can:
|
|
36
|
+
def __init__(self, bridge):
|
|
37
|
+
self._b = bridge
|
|
38
|
+
bridge.require(C.Cap.TWAI, "CAN/TWAI")
|
|
39
|
+
self._rx: queue.Queue[Message] = queue.Queue(maxsize=1024)
|
|
40
|
+
self._callbacks: list = []
|
|
41
|
+
bridge.on_event(C.TWAI_RX_EVT, self._on_rx)
|
|
42
|
+
|
|
43
|
+
def _on_rx(self, payload: bytes) -> None:
|
|
44
|
+
msg = Message(id=struct.unpack_from(">I", payload, 1)[0],
|
|
45
|
+
data=payload[5:],
|
|
46
|
+
extended=bool(payload[0] & 1),
|
|
47
|
+
rtr=bool(payload[0] & 2))
|
|
48
|
+
if self._callbacks:
|
|
49
|
+
for cb in self._callbacks:
|
|
50
|
+
cb(msg)
|
|
51
|
+
else:
|
|
52
|
+
try:
|
|
53
|
+
self._rx.put_nowait(msg)
|
|
54
|
+
except queue.Full:
|
|
55
|
+
pass # oldest-first backpressure: drop newest
|
|
56
|
+
|
|
57
|
+
def begin(self, tx: int, rx: int, bitrate: int = 500_000, *,
|
|
58
|
+
mode: str = "normal",
|
|
59
|
+
accept: tuple[int, int] | None = None) -> None:
|
|
60
|
+
"""Start the controller. mode: normal | listen | no_ack.
|
|
61
|
+
|
|
62
|
+
accept=(code, mask) programs the single acceptance filter
|
|
63
|
+
(hardware-level; see the ESP32 TRM for the bit layout).
|
|
64
|
+
"""
|
|
65
|
+
payload = bytes([tx, rx, _MODES[mode], _BITRATES[bitrate]])
|
|
66
|
+
if accept is not None:
|
|
67
|
+
payload += struct.pack(">IIB", accept[0], accept[1], 1)
|
|
68
|
+
self._b.request(C.TWAI_INIT, payload)
|
|
69
|
+
|
|
70
|
+
def send(self, msg_or_id: Message | int, data: bytes = b"", *,
|
|
71
|
+
extended: bool = False, rtr: bool = False) -> None:
|
|
72
|
+
"""Queue one frame (blocks briefly if the TX queue is full)."""
|
|
73
|
+
m = msg_or_id if isinstance(msg_or_id, Message) else Message(
|
|
74
|
+
msg_or_id, data, extended, rtr)
|
|
75
|
+
if len(m.data) > 8:
|
|
76
|
+
raise ValueError("classic CAN frames carry at most 8 bytes")
|
|
77
|
+
flags = (1 if m.extended else 0) | (2 if m.rtr else 0)
|
|
78
|
+
self._b.request(C.TWAI_SEND,
|
|
79
|
+
struct.pack(">BI", flags, m.id) + m.data)
|
|
80
|
+
|
|
81
|
+
def recv(self, timeout: float | None = None) -> Message | None:
|
|
82
|
+
"""Next received frame (None on timeout). Unused when callbacks are set."""
|
|
83
|
+
try:
|
|
84
|
+
return self._rx.get(timeout=timeout)
|
|
85
|
+
except queue.Empty:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def on_message(self, callback) -> None:
|
|
89
|
+
"""callback(Message) for every received frame (reader thread —
|
|
90
|
+
don't call blocking bridge requests from inside it)."""
|
|
91
|
+
self._callbacks.append(callback)
|
|
92
|
+
|
|
93
|
+
def status(self) -> dict:
|
|
94
|
+
r = self._b.request(C.TWAI_STATUS)
|
|
95
|
+
state, tx_err, rx_err = r[0], r[1], r[2]
|
|
96
|
+
return {"state": ("stopped", "running", "recovering", "bus_off")[state],
|
|
97
|
+
"tx_errors": tx_err, "rx_errors": rx_err,
|
|
98
|
+
"rx_missed": struct.unpack_from(">I", r, 3)[0]}
|
|
99
|
+
|
|
100
|
+
def recover(self) -> None:
|
|
101
|
+
"""Start bus-off recovery."""
|
|
102
|
+
self._b.request(C.TWAI_RECOVER)
|
|
103
|
+
|
|
104
|
+
def end(self) -> None:
|
|
105
|
+
self._b.request(C.TWAI_DEINIT)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
-
import sys
|
|
6
5
|
|
|
7
6
|
from . import __version__
|
|
7
|
+
from ._log import log
|
|
8
8
|
from .bridge import Bridge, connect_all
|
|
9
9
|
from .errors import BridgeError
|
|
10
10
|
from .transports import find_ports
|
|
@@ -119,7 +119,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
119
119
|
_print_info(esp)
|
|
120
120
|
return 0
|
|
121
121
|
except BridgeError as e:
|
|
122
|
-
|
|
122
|
+
log.error(str(e))
|
|
123
123
|
return 1
|
|
124
124
|
|
|
125
125
|
|