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/spi.py ADDED
@@ -0,0 +1,43 @@
1
+ """SPI master, full-duplex transfers."""
2
+ from __future__ import annotations
3
+
4
+ import struct
5
+
6
+ from . import constants as C
7
+
8
+ # Max data per TRANSFER frame: payload cap minus host(1) + cs(1) header bytes.
9
+ _CHUNK = C.MAX_PAYLOAD - 2
10
+
11
+
12
+ class Spi:
13
+ def __init__(self, bridge):
14
+ self._b = bridge
15
+
16
+ def init(self, *, sck: int = 18, miso: int = 19, mosi: int = 23,
17
+ freq: int = 1_000_000, mode: int = 0, msb_first: bool = True,
18
+ host: int = 0) -> None:
19
+ self._b.request(C.SPI_INIT, struct.pack(">BbbbIBB", host, sck, miso, mosi,
20
+ freq, mode, 1 if msb_first else 0))
21
+
22
+ def transfer(self, tx: bytes, *, cs: int | None = None, host: int = 0) -> bytes:
23
+ """Full-duplex transfer; returns the bytes clocked in (len == len(tx)).
24
+
25
+ With `cs` given, CS is held low for the whole transfer — limited to
26
+ one frame (≤ 2046 bytes). Without `cs`, any length (chunked).
27
+ """
28
+ tx = bytes(tx)
29
+ cs_b = 255 if cs is None else cs # i8 -1 == 255 unsigned
30
+ if cs is not None and len(tx) > _CHUNK:
31
+ raise ValueError(
32
+ f"transfers with CS are limited to {_CHUNK} bytes per call "
33
+ "(CS would deassert between chunks); split the transfer or manage "
34
+ "CS manually via gpio"
35
+ )
36
+ rx = bytearray()
37
+ for off in range(0, len(tx), _CHUNK):
38
+ chunk = tx[off : off + _CHUNK]
39
+ rx += self._b.request(C.SPI_TRANSFER, bytes([host, cs_b]) + chunk, timeout=10.0)
40
+ return bytes(rx)
41
+
42
+ def deinit(self, host: int = 0) -> None:
43
+ self._b.request(C.SPI_DEINIT, bytes([host]))
espbridge/transport.py ADDED
@@ -0,0 +1,121 @@
1
+ """Serial transport with ESP32 auto-detection, plus an in-memory mock for tests."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+ from .constants import KNOWN_USB_IDS
8
+ from .errors import NoDeviceError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class PortInfo:
13
+ device: str # e.g. "COM5" or "/dev/ttyUSB0"
14
+ usb_chip: str | None # "cp210x" | "ch340" | "ch9102" | "native" | None
15
+ description: str = ""
16
+
17
+
18
+ def find_ports() -> list[PortInfo]:
19
+ """List serial ports that look like an ESP32 (by USB VID/PID)."""
20
+ from serial.tools import list_ports
21
+
22
+ found: list[PortInfo] = []
23
+ for p in list_ports.comports():
24
+ if p.vid is None:
25
+ continue
26
+ for (vid, pid), chip in KNOWN_USB_IDS.items():
27
+ if p.vid == vid and (pid is None or p.pid == pid):
28
+ found.append(PortInfo(p.device, chip, p.description or ""))
29
+ break
30
+ return found
31
+
32
+
33
+ def autodetect_port() -> PortInfo:
34
+ ports = find_ports()
35
+ if not ports:
36
+ raise NoDeviceError(
37
+ "no ESP32 serial port found (CP210x/CH340/CH9102/native USB); "
38
+ "pass port='COM5' / '/dev/ttyUSB0' explicitly"
39
+ )
40
+ if len(ports) > 1:
41
+ names = ", ".join(p.device for p in ports)
42
+ raise NoDeviceError(f"multiple ESP32-like ports found ({names}); pass port= explicitly")
43
+ return ports[0]
44
+
45
+
46
+ class SerialTransport:
47
+ """Thin pyserial wrapper. read() returns whatever bytes are available."""
48
+
49
+ def __init__(self, port: str, baud: int = 115200, usb_chip: str | None = None):
50
+ import serial
51
+
52
+ self.usb_chip = usb_chip
53
+ self.ser = serial.Serial(port, baudrate=baud, timeout=0.05, write_timeout=2.0)
54
+
55
+ def read(self) -> bytes:
56
+ data = self.ser.read(1) # blocks up to `timeout`
57
+ waiting = self.ser.in_waiting
58
+ if data and waiting:
59
+ data += self.ser.read(waiting)
60
+ return data
61
+
62
+ def write(self, data: bytes) -> None:
63
+ self.ser.write(data)
64
+
65
+ def set_baudrate(self, baud: int) -> None:
66
+ self.ser.baudrate = baud
67
+ self.ser.reset_input_buffer()
68
+
69
+ def pulse_reset(self) -> None:
70
+ """Toggle RTS/DTR the way esptool does to hard-reset the ESP32."""
71
+ self.ser.dtr = False
72
+ self.ser.rts = True
73
+ time.sleep(0.1)
74
+ self.ser.rts = False
75
+
76
+ def close(self) -> None:
77
+ try:
78
+ self.ser.close()
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ class MockTransport:
84
+ """In-memory transport for hardware-free tests.
85
+
86
+ `responder(data: bytes)` is called with everything the host writes; it can
87
+ push firmware->host bytes back via `inject()`.
88
+ """
89
+
90
+ def __init__(self, responder=None):
91
+ import queue
92
+
93
+ self.usb_chip = None
94
+ self._rx: queue.Queue[bytes] = queue.Queue()
95
+ self.responder = responder
96
+ self.closed = False
97
+ self.baud = 115200
98
+
99
+ def inject(self, data: bytes) -> None:
100
+ self._rx.put(data)
101
+
102
+ def read(self) -> bytes:
103
+ import queue
104
+
105
+ try:
106
+ return self._rx.get(timeout=0.05)
107
+ except queue.Empty:
108
+ return b""
109
+
110
+ def write(self, data: bytes) -> None:
111
+ if self.responder is not None:
112
+ self.responder(data)
113
+
114
+ def set_baudrate(self, baud: int) -> None:
115
+ self.baud = baud
116
+
117
+ def pulse_reset(self) -> None:
118
+ pass
119
+
120
+ def close(self) -> None:
121
+ self.closed = True
espbridge/uart.py ADDED
@@ -0,0 +1,111 @@
1
+ """Secondary UARTs (1, 2): write from host, RX streamed back as events."""
2
+ from __future__ import annotations
3
+
4
+ import struct
5
+ import threading
6
+
7
+ from . import constants as C
8
+
9
+ # Per-request write chunk: bounds single-request size (and how long the
10
+ # firmware's synchronous UART write can stall on slow baud rates).
11
+ _WRITE_CHUNK = 1024
12
+
13
+
14
+ class UartPort:
15
+ """Bridged UART with a pyserial-like surface (read/write/in_waiting/...)."""
16
+
17
+ def __init__(self, bridge, port: int):
18
+ self._b = bridge
19
+ self.port = port
20
+ self.timeout: float | None = None # default for read()/read_until()
21
+ self._buf = bytearray()
22
+ self._cond = threading.Condition()
23
+ self._callbacks: list = []
24
+
25
+ def _feed(self, data: bytes) -> None:
26
+ with self._cond:
27
+ self._buf += data
28
+ self._cond.notify_all()
29
+ for cb in list(self._callbacks):
30
+ cb(data)
31
+
32
+ def write(self, data: bytes) -> None:
33
+ data = bytes(data)
34
+ for off in range(0, len(data), _WRITE_CHUNK):
35
+ self._b.request(C.UART_WRITE, bytes([self.port]) + data[off : off + _WRITE_CHUNK])
36
+
37
+ def read(self, n: int | None = None, timeout: float | None = None) -> bytes:
38
+ """Read up to n buffered bytes (all if n is None); waits up to `timeout`
39
+ (default: self.timeout, pyserial-style; None blocks until data)."""
40
+ timeout = self.timeout if timeout is None else timeout
41
+ with self._cond:
42
+ if not self._buf and timeout != 0:
43
+ self._cond.wait(timeout)
44
+ n = len(self._buf) if n is None else min(n, len(self._buf))
45
+ data = bytes(self._buf[:n])
46
+ del self._buf[:n]
47
+ return data
48
+
49
+ @property
50
+ def in_waiting(self) -> int:
51
+ """Bytes already received and buffered (pyserial-compatible)."""
52
+ with self._cond:
53
+ return len(self._buf)
54
+
55
+ def reset_input_buffer(self) -> None:
56
+ with self._cond:
57
+ self._buf.clear()
58
+
59
+ def flush(self) -> None:
60
+ pass # writes are synchronous requests; nothing left to flush
61
+
62
+ def readline(self, timeout: float | None = None) -> bytes:
63
+ t = timeout if timeout is not None else (self.timeout or 2.0)
64
+ return self.read_until(b"\n", t)
65
+
66
+ def read_until(self, sep: bytes = b"\n", timeout: float = 2.0) -> bytes:
67
+ import time
68
+ deadline = time.monotonic() + timeout
69
+ with self._cond:
70
+ while True:
71
+ i = self._buf.find(sep)
72
+ if i >= 0:
73
+ data = bytes(self._buf[: i + len(sep)])
74
+ del self._buf[: i + len(sep)]
75
+ return data
76
+ remaining = deadline - time.monotonic()
77
+ if remaining <= 0:
78
+ data = bytes(self._buf)
79
+ self._buf.clear()
80
+ return data
81
+ self._cond.wait(remaining)
82
+
83
+ def on_rx(self, callback) -> None:
84
+ """callback(bytes) on every RX chunk (runs on the reader thread)."""
85
+ self._callbacks.append(callback)
86
+
87
+ def close(self) -> None:
88
+ self._b.request(C.UART_DEINIT, bytes([self.port]))
89
+
90
+
91
+ class Uart:
92
+ def __init__(self, bridge):
93
+ self._b = bridge
94
+ self._ports: dict[int, UartPort] = {}
95
+ bridge.on_event(C.UART_RX_EVT, self._on_rx)
96
+
97
+ def _on_rx(self, payload: bytes) -> None:
98
+ if len(payload) < 2:
99
+ return
100
+ port = self._ports.get(payload[0])
101
+ if port is not None:
102
+ port._feed(payload[1:])
103
+
104
+ def init(self, *, port: int = 1, tx: int = 17, rx: int = 16,
105
+ baud: int = 115_200) -> UartPort:
106
+ self._b.request(C.UART_INIT, struct.pack(">BbbI", port, tx, rx, baud))
107
+ p = self._ports.get(port)
108
+ if p is None:
109
+ p = UartPort(self._b, port)
110
+ self._ports[port] = p
111
+ return p
espbridge/wifi.py ADDED
@@ -0,0 +1,128 @@
1
+ """Wi-Fi management: scan, STA connect, AP mode, status."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from . import constants as C
9
+ from .errors import BridgeTimeoutError
10
+ from .protocol import ip_str as _ip
11
+ from .protocol import lp
12
+
13
+ WL_CONNECTED = 3 # wl_status_t
14
+
15
+ AUTH_MODES = {0: "open", 1: "wep", 2: "wpa_psk", 3: "wpa2_psk", 4: "wpa_wpa2_psk",
16
+ 5: "wpa2_enterprise", 6: "wpa3_psk", 7: "wpa2_wpa3_psk", 8: "wapi_psk"}
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Network:
21
+ ssid: str
22
+ rssi: int
23
+ bssid: str
24
+ channel: int
25
+ auth: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WifiStatus:
30
+ status: int
31
+ ip: str
32
+ gateway: str
33
+ netmask: str
34
+ rssi: int
35
+ channel: int
36
+ mac: str
37
+
38
+ @property
39
+ def connected(self) -> bool:
40
+ return self.status == WL_CONNECTED
41
+
42
+
43
+ class Wifi:
44
+ def __init__(self, bridge):
45
+ self._b = bridge
46
+ self._scan_results: list[Network] = []
47
+ self._scan_done = threading.Event()
48
+ self._state_callbacks: list = []
49
+ bridge.on_event(C.WIFI_SCAN_RES, self._on_scan_res)
50
+ bridge.on_event(C.WIFI_SCAN_DONE, self._on_scan_done)
51
+ bridge.on_event(C.WIFI_STATE_EVT, self._on_state)
52
+
53
+ # ---- events ---------------------------------------------------------------
54
+
55
+ def _on_scan_res(self, p: bytes) -> None:
56
+ if len(p) < 12:
57
+ return
58
+ ssid = p[12 : 12 + p[11]].decode("utf-8", "replace")
59
+ rssi = int.from_bytes(p[2:3], "big", signed=True)
60
+ bssid = ":".join(f"{x:02x}" for x in p[5:11])
61
+ self._scan_results.append(
62
+ Network(ssid, rssi, bssid, p[4], AUTH_MODES.get(p[3], str(p[3])))
63
+ )
64
+
65
+ def _on_scan_done(self, p: bytes) -> None:
66
+ self._scan_done.set()
67
+
68
+ def _on_state(self, p: bytes) -> None:
69
+ if not p:
70
+ return
71
+ event = {1: "connected", 2: "got_ip", 3: "disconnected"}.get(p[0], str(p[0]))
72
+ ip = _ip(p[1:5]) if len(p) >= 5 else "0.0.0.0"
73
+ for cb in list(self._state_callbacks):
74
+ cb(event, ip)
75
+
76
+ def on_state(self, callback) -> None:
77
+ """callback(event: str, ip: str) on connect/got_ip/disconnect."""
78
+ self._state_callbacks.append(callback)
79
+
80
+ # ---- commands ----------------------------------------------------------------
81
+
82
+ def scan(self, timeout: float = 15.0) -> list[Network]:
83
+ self._scan_results = []
84
+ self._scan_done.clear()
85
+ self._b.request(C.WIFI_SCAN)
86
+ if not self._scan_done.wait(timeout):
87
+ raise BridgeTimeoutError("Wi-Fi scan did not finish")
88
+ return sorted(self._scan_results, key=lambda n: -n.rssi)
89
+
90
+ def connect(self, ssid: str, password: str = "", *, wait: bool = True,
91
+ timeout: float = 20.0) -> WifiStatus:
92
+ self._b.request(C.WIFI_CONNECT, lp(ssid) + lp(password))
93
+ if not wait:
94
+ return self.status()
95
+ deadline = time.monotonic() + timeout
96
+ while time.monotonic() < deadline:
97
+ st = self.status()
98
+ if st.connected and st.ip != "0.0.0.0":
99
+ return st
100
+ time.sleep(0.25)
101
+ raise BridgeTimeoutError(f"could not join {ssid!r} within {timeout}s")
102
+
103
+ def disconnect(self) -> None:
104
+ self._b.request(C.WIFI_DISCONNECT)
105
+
106
+ def status(self) -> WifiStatus:
107
+ p = self._b.request(C.WIFI_STATUS)
108
+ return WifiStatus(
109
+ status=p[0],
110
+ ip=_ip(p[1:5]),
111
+ gateway=_ip(p[5:9]),
112
+ netmask=_ip(p[9:13]),
113
+ rssi=int.from_bytes(p[13:14], "big", signed=True),
114
+ channel=p[14],
115
+ mac=":".join(f"{x:02x}" for x in p[15:21]),
116
+ )
117
+
118
+ def ap_start(self, ssid: str, password: str = "", *, channel: int = 1,
119
+ max_conn: int = 4) -> str:
120
+ """Start an access point; returns its IP (clients usually get 192.168.4.x)."""
121
+ payload = lp(ssid) + lp(password) + bytes([channel, max_conn])
122
+ return _ip(self._b.request(C.WIFI_AP_START, payload, timeout=5.0))
123
+
124
+ def ap_stop(self) -> None:
125
+ self._b.request(C.WIFI_AP_STOP)
126
+
127
+ def hostname(self, name: str) -> None:
128
+ self._b.request(C.WIFI_HOSTNAME, lp(name))
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-esp-bridge
3
+ Version: 0.0.2
4
+ Summary: Control every ESP32 peripheral from Python over USB serial — GPIO, ADC, DAC, PWM, touch, I2C, SPI, UART, Wi-Fi sockets, BLE
5
+ Project-URL: Homepage, https://github.com/HamzaYslmn/python-esp-bridge
6
+ Author: HamzaYslmn
7
+ Keywords: ble,bridge,esp32,firmata,gpio,i2c,raspberry-pi,serial,spi
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: pyserial>=3.5
10
+ Provides-Extra: oled
11
+ Requires-Dist: pillow>=10; extra == 'oled'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # python-esp-bridge
15
+
16
+ Control every ESP32 peripheral from Python over USB serial — GPIO, PWM, ADC,
17
+ DAC, touch, I2C, SPI, UART, Wi-Fi (with TCP/UDP sockets through the ESP32
18
+ radio) and BLE. Flash the bridge firmware once, then it's all Python.
19
+
20
+ ```python
21
+ from espbridge import Bridge
22
+
23
+ with Bridge() as esp: # auto-detects the USB port
24
+ esp.gpio.mode(2, "output")
25
+ esp.gpio.write(2, 1)
26
+ print(esp.adc.read_mv(34), "mV")
27
+ esp.i2c.init(sda=21, scl=22)
28
+ print(esp.i2c.scan())
29
+ esp.wifi.connect("ssid", "password")
30
+ status, body = esp.net.http_get("http://example.com/")
31
+ ```
32
+
33
+ - Firmware (flash once with Arduino IDE) and full docs:
34
+ **<https://github.com/HamzaYslmn/python-esp-bridge>**
35
+ - Works on Raspberry Pi OS, Linux, Windows, macOS (Python ≥ 3.10, pyserial).
36
+ - `espbridge` CLI: connection info; `espbridge ports`: list candidate ports.
@@ -0,0 +1,27 @@
1
+ espbridge/__init__.py,sha256=J6wt84grWASJUf09iWopqoXdbONxV17rRlYBswsDIkU,1063
2
+ espbridge/analog.py,sha256=zv1EFVZC2CSNBr-J0wvQw2QZIdKQzhSrknfYmaOkSig,2247
3
+ espbridge/ble.py,sha256=fuTWlVoqCiDlu0e0-6r4Trq1VR_7skZwSFtpIgzZNyc,8321
4
+ espbridge/bridge.py,sha256=E6kHPq6NYIkROn-e4pFqcZtp3l8DCXGlO5FDOIPYhT8,17358
5
+ espbridge/cli.py,sha256=6gVeDrb7AbR-vGDnJlbBhhQ0J9HBxBZmbAzjcLh8rZM,3101
6
+ espbridge/constants.py,sha256=btS0wKrp6MfsT28kqcqYL7I9dNHVWlYdXPw1P3Dgxzc,4816
7
+ espbridge/errors.py,sha256=gYKhAohajGVGXiuzkC-i2KzvygvbgMZoqnJ8x-F7HyI,1051
8
+ espbridge/gpio.py,sha256=daaE7GgjYxGBnGpFbkKMTsqLmyao8e5Hs_1dM3uFWFc,2384
9
+ espbridge/i2c.py,sha256=S7uISLcMOMFTGIfOeL8li4TIG9slI6E3G380u7Q9ACM,2772
10
+ espbridge/net.py,sha256=PHVg2OQYh5Ap-Zdi1C9vW-qEohExzwgBT-03ImCElYI,9634
11
+ espbridge/oled.py,sha256=UYEDZmfl8UwCw4B36Awk_uGdROR2W5jRpUeOfD1sCJ0,8072
12
+ espbridge/protocol.py,sha256=kIns3gXxZ7wZABY5En5YeK1MgtT86-GVr4_JBMqIhTI,4405
13
+ espbridge/pwm.py,sha256=S2VzG6lh0GRM-WUaXp6uHbKZ8dvlRVs4T7WW_nCxj0o,1669
14
+ espbridge/spi.py,sha256=eNdrhPlpdgPPIzI4OHIC0vJ3dQnSX2W1m6lw6_n5DXk,1688
15
+ espbridge/transport.py,sha256=rJmUyalr3-2ilVUGnhseV9alewmnD3v7QT_75ho31i4,3432
16
+ espbridge/uart.py,sha256=-PgCjr5iMkhp9b_PYr_qPNYBwPfjYv4Ancjq0bxgWtc,3870
17
+ espbridge/wifi.py,sha256=q7FH-9DoClSyR7ibGCb2KHkNTP4NqrqNo6gtn9SAzpw,4198
18
+ espbridge/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ espbridge/compat/blinka.py,sha256=Pke1QWnXx6yoXR8T62XQBofSYUciyEHBTpw5fLIRo9c,6924
20
+ espbridge/compat/gpiozero.py,sha256=PMD7_yUPH0CF9mycf4XZeollH-G54MeSy3X72vXNZJY,7841
21
+ espbridge/compat/luma.py,sha256=iErusKC4dccFzN5qK1HfZ_q0rt50j8L9SK0ZBtc9nlY,3382
22
+ espbridge/compat/rpi_gpio.py,sha256=YwOXbXdNLVVHv1CbODV_tCNg59iXWEuhpBCkAaSZnF8,3346
23
+ espbridge/compat/smbus.py,sha256=k8h1VQe6SzqRKvtfsuVzw8YFDjceedDdod5JtG0QX0s,2519
24
+ python_esp_bridge-0.0.2.dist-info/METADATA,sha256=Cl44L_9aK_dV7oVYofm3KsPKACM4P5hJQ_lbdY0FoTQ,1393
25
+ python_esp_bridge-0.0.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
26
+ python_esp_bridge-0.0.2.dist-info/entry_points.txt,sha256=xXm9lM8iMGXRhFiDBZrncBlA_RuWOSCxUO2_v7jGuGE,49
27
+ python_esp_bridge-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ espbridge = espbridge.cli:main