python-esp-bridge 0.1.1__tar.gz → 0.3.0__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 (51) hide show
  1. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/PKG-INFO +3 -3
  2. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/__init__.py +10 -2
  3. python_esp_bridge-0.3.0/espbridge/_log.py +68 -0
  4. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/bridge.py +115 -72
  5. python_esp_bridge-0.3.0/espbridge/camera.py +81 -0
  6. python_esp_bridge-0.3.0/espbridge/can.py +105 -0
  7. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/cli.py +2 -2
  8. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/constants.py +121 -0
  9. python_esp_bridge-0.3.0/espbridge/dht.py +74 -0
  10. python_esp_bridge-0.3.0/espbridge/ds18b20.py +68 -0
  11. python_esp_bridge-0.3.0/espbridge/espnow.py +157 -0
  12. python_esp_bridge-0.3.0/espbridge/eth.py +94 -0
  13. python_esp_bridge-0.3.0/espbridge/fs.py +163 -0
  14. python_esp_bridge-0.3.0/espbridge/hcsr04.py +37 -0
  15. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/i2c.py +9 -2
  16. python_esp_bridge-0.3.0/espbridge/i2s.py +81 -0
  17. python_esp_bridge-0.3.0/espbridge/ir.py +90 -0
  18. python_esp_bridge-0.3.0/espbridge/mcpwm.py +36 -0
  19. python_esp_bridge-0.3.0/espbridge/neopixel.py +75 -0
  20. python_esp_bridge-0.3.0/espbridge/nvs.py +76 -0
  21. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/oled.py +4 -3
  22. python_esp_bridge-0.3.0/espbridge/onewire.py +84 -0
  23. python_esp_bridge-0.3.0/espbridge/ota.py +57 -0
  24. python_esp_bridge-0.3.0/espbridge/rmt.py +109 -0
  25. python_esp_bridge-0.3.0/espbridge/stepper.py +93 -0
  26. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/transports/ble.py +7 -8
  27. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/pyproject.toml +3 -3
  28. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/uv.lock +1 -1
  29. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/.gitignore +0 -0
  30. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/.python-version +0 -0
  31. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/README.md +0 -0
  32. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/analog.py +0 -0
  33. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/ble.py +0 -0
  34. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/__init__.py +0 -0
  35. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/blinka.py +0 -0
  36. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/gpiozero.py +0 -0
  37. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/luma.py +0 -0
  38. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/rpi_gpio.py +0 -0
  39. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/compat/smbus.py +0 -0
  40. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/errors.py +0 -0
  41. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/gpio.py +0 -0
  42. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/net.py +0 -0
  43. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/protocol.py +0 -0
  44. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/pwm.py +0 -0
  45. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/spi.py +0 -0
  46. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/transport.py +0 -0
  47. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/transports/__init__.py +0 -0
  48. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/transports/mock.py +0 -0
  49. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/transports/serial.py +0 -0
  50. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/uart.py +0 -0
  51. {python_esp_bridge-0.1.1 → python_esp_bridge-0.3.0}/espbridge/wifi.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-esp-bridge
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
3
+ Version: 0.3.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, 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 serial.
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.1.1"
43
+ __version__ = "0.3.0"
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
- if name is None and mac is None and len(ports) > 1:
128
- names = ", ".join(p.device for p in ports)
129
- raise NoDeviceError(
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
- 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
- )
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
- continue # corrupted frame: drop; requester times out & retries
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
- pass # user callbacks must not kill the reader
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
- pass
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
- @functools.cached_property
481
- def gpio(self):
482
- from .gpio import Gpio
483
- return Gpio(self)
484
-
485
- @functools.cached_property
486
- def adc(self):
487
- from .analog import Adc
488
- return Adc(self)
489
-
490
- @functools.cached_property
491
- def dac(self):
492
- from .analog import Dac
493
- return Dac(self)
494
-
495
- @functools.cached_property
496
- def touch(self):
497
- from .analog import Touch
498
- return Touch(self)
499
-
500
- @functools.cached_property
501
- def pwm(self):
502
- from .pwm import Pwm
503
- return Pwm(self)
504
-
505
- @functools.cached_property
506
- def i2c(self):
507
- from .i2c import I2c
508
- return I2c(self)
509
-
510
- @functools.cached_property
511
- def spi(self):
512
- from .spi import Spi
513
- return Spi(self)
514
-
515
- @functools.cached_property
516
- def uart(self):
517
- from .uart import Uart
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
- print(f"error: {e}", file=sys.stderr)
122
+ log.error(str(e))
123
123
  return 1
124
124
 
125
125