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 ADDED
@@ -0,0 +1,45 @@
1
+ """espbridge — control every ESP32 peripheral from Python over USB serial.
2
+
3
+ Flash esp/esp.ino once, then:
4
+
5
+ from espbridge import Bridge
6
+
7
+ with Bridge() as esp: # auto-detects the serial port
8
+ esp.gpio.mode(2, "output")
9
+ esp.gpio.write(2, 1)
10
+ print(esp.adc.read(34))
11
+ print(esp.i2c.scan())
12
+ esp.wifi.connect("ssid", "password")
13
+ sock = esp.net.tcp_connect("example.com", 80) # TCP through the ESP32 radio
14
+ """
15
+ from .bridge import Bridge, BridgeSet, Info, connect_all
16
+ from .constants import Cap, ChipModel, Status
17
+ from .errors import (
18
+ BridgeError,
19
+ BridgeTimeoutError,
20
+ NoDeviceError,
21
+ ProtocolError,
22
+ RemoteError,
23
+ UnsupportedError,
24
+ )
25
+ from .transport import find_ports
26
+
27
+ __version__ = "0.0.2"
28
+
29
+ __all__ = [
30
+ "Bridge",
31
+ "BridgeSet",
32
+ "connect_all",
33
+ "Info",
34
+ "Cap",
35
+ "ChipModel",
36
+ "Status",
37
+ "BridgeError",
38
+ "BridgeTimeoutError",
39
+ "NoDeviceError",
40
+ "ProtocolError",
41
+ "RemoteError",
42
+ "UnsupportedError",
43
+ "find_ports",
44
+ "__version__",
45
+ ]
espbridge/analog.py ADDED
@@ -0,0 +1,64 @@
1
+ """ADC (oneshot reads), DAC (write + cosine generator), capacitive touch."""
2
+ from __future__ import annotations
3
+
4
+ import struct
5
+
6
+ from . import constants as C
7
+
8
+ # Attenuation -> roughly full-scale input voltage on classic ESP32:
9
+ # 0 dB ≈ 1.1 V, 2.5 dB ≈ 1.5 V, 6 dB ≈ 2.2 V, 11 dB ≈ 3.3 V (default)
10
+ ATTEN = {0: 0, 2.5: 1, 6: 2, 11: 3, "0db": 0, "2.5db": 1, "6db": 2, "11db": 3}
11
+
12
+
13
+ class Adc:
14
+ def __init__(self, bridge):
15
+ self._b = bridge
16
+
17
+ def config(self, pin: int, atten=11) -> None:
18
+ self._b.request(C.ADC_CONFIG, bytes([pin, ATTEN.get(atten, int(atten))]))
19
+
20
+ def read(self, pin: int) -> int:
21
+ """Raw 12-bit reading (0..4095)."""
22
+ return struct.unpack(">H", self._b.request(C.ADC_READ, bytes([pin])))[0]
23
+
24
+ def read_mv(self, pin: int) -> int:
25
+ """Calibrated millivolts."""
26
+ return struct.unpack(">H", self._b.request(C.ADC_READ_MV, bytes([pin])))[0]
27
+
28
+
29
+ class Dac:
30
+ """True 8-bit DAC — classic ESP32 (GPIO 25/26) and S2 (GPIO 17/18) only."""
31
+
32
+ def __init__(self, bridge):
33
+ bridge.require(C.Cap.DAC, "DAC")
34
+ self._b = bridge
35
+
36
+ def write(self, pin: int, value: int) -> None:
37
+ """Output value 0..255 (0..3.3 V)."""
38
+ self._b.request(C.DAC_WRITE, bytes([pin, value & 0xFF]))
39
+
40
+ def cosine(self, pin: int, freq_hz: int, *, scale: int = 0, offset: int = 0,
41
+ phase_180: bool = False) -> None:
42
+ """Start the hardware cosine-wave generator (~130 Hz .. ~100 kHz).
43
+
44
+ scale: 0..3 = full/half/quarter/eighth amplitude.
45
+ """
46
+ self._b.request(C.DAC_COSINE, struct.pack(">BIBbB", pin, freq_hz, scale & 3,
47
+ offset, 1 if phase_180 else 0))
48
+
49
+ def cosine_stop(self, pin: int) -> None:
50
+ self._b.request(C.DAC_COS_STOP, bytes([pin]))
51
+
52
+ def disable(self, pin: int) -> None:
53
+ self._b.request(C.DAC_DISABLE, bytes([pin]))
54
+
55
+
56
+ class Touch:
57
+ """Capacitive touch pads (classic ESP32: lower = touched; S2/S3: higher = touched)."""
58
+
59
+ def __init__(self, bridge):
60
+ bridge.require(C.Cap.TOUCH, "touch sensing")
61
+ self._b = bridge
62
+
63
+ def read(self, pin: int) -> int:
64
+ return struct.unpack(">I", self._b.request(C.TOUCH_READ, bytes([pin])))[0]
espbridge/ble.py ADDED
@@ -0,0 +1,223 @@
1
+ """BLE: scanning, advertising, GATT server and basic GATT client."""
2
+ from __future__ import annotations
3
+
4
+ import queue
5
+ import struct
6
+ import threading
7
+ import time
8
+ import uuid as _uuid
9
+ from dataclasses import dataclass
10
+
11
+ from . import constants as C
12
+ from .errors import BridgeTimeoutError
13
+ from .protocol import lp
14
+
15
+
16
+ def to_uuid128(value: int | str | _uuid.UUID) -> bytes:
17
+ """16/32-bit ints expand into the Bluetooth base UUID; strs/UUIDs pass through."""
18
+ if isinstance(value, int):
19
+ return _uuid.UUID(f"{value:08x}-0000-1000-8000-00805f9b34fb").bytes
20
+ if isinstance(value, str):
21
+ return _uuid.UUID(value).bytes
22
+ return value.bytes
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Advertisement:
27
+ addr: str
28
+ addr_type: int
29
+ rssi: int
30
+ data: bytes # raw advertisement payload (AD structures)
31
+
32
+ @property
33
+ def name(self) -> str | None:
34
+ """Local name parsed from the AD structures, if present."""
35
+ i, d = 0, self.data
36
+ while i + 1 < len(d):
37
+ length = d[i]
38
+ if length == 0 or i + 1 + length > len(d) + 1:
39
+ break
40
+ ad_type = d[i + 1]
41
+ if ad_type in (0x08, 0x09): # shortened / complete local name
42
+ return d[i + 2 : i + 1 + length].decode("utf-8", "replace")
43
+ i += 1 + length
44
+ return None
45
+
46
+
47
+ class Characteristic:
48
+ """A characteristic of the local GATT server."""
49
+
50
+ def __init__(self, ble: "Ble", char_id: int, uuid_: bytes):
51
+ self._ble = ble
52
+ self.char_id = char_id
53
+ self.uuid = uuid_
54
+ self.on_write = None # callback(bytes)
55
+
56
+ def set(self, data: bytes) -> None:
57
+ self._ble._b.request(C.BLE_GATTS_SET, bytes([self.char_id]) + bytes(data))
58
+
59
+ def notify(self, data: bytes) -> None:
60
+ self._ble._b.request(C.BLE_GATTS_NTFY, bytes([self.char_id]) + bytes(data))
61
+
62
+
63
+ class GattClient:
64
+ """Connection to a remote BLE peripheral (one at a time)."""
65
+
66
+ def __init__(self, ble: "Ble"):
67
+ self._ble = ble
68
+ self.connected = True
69
+
70
+ def read(self, service, char) -> bytes:
71
+ return self._ble._b.request(
72
+ C.BLE_GATTC_READ, to_uuid128(service) + to_uuid128(char), timeout=10.0)
73
+
74
+ def write(self, service, char, data: bytes) -> None:
75
+ self._ble._b.request(
76
+ C.BLE_GATTC_WRITE, to_uuid128(service) + to_uuid128(char) + bytes(data),
77
+ timeout=10.0)
78
+
79
+ def subscribe(self, service, char, callback) -> None:
80
+ """callback(bytes) for notifications from this characteristic."""
81
+ self._ble._notify_callbacks[to_uuid128(char)] = callback
82
+ self._ble._b.request(
83
+ C.BLE_GATTC_SUB, to_uuid128(service) + to_uuid128(char) + b"\x01",
84
+ timeout=10.0)
85
+
86
+ def unsubscribe(self, service, char) -> None:
87
+ self._ble._notify_callbacks.pop(to_uuid128(char), None)
88
+ self._ble._b.request(
89
+ C.BLE_GATTC_SUB, to_uuid128(service) + to_uuid128(char) + b"\x00",
90
+ timeout=10.0)
91
+
92
+ def disconnect(self) -> None:
93
+ self.connected = False
94
+ self._ble._b.request(C.BLE_GATTC_DISC, timeout=10.0)
95
+
96
+
97
+ class Ble:
98
+ # characteristic property flags
99
+ READ = C.GATT_PROP_READ
100
+ WRITE = C.GATT_PROP_WRITE
101
+ NOTIFY = C.GATT_PROP_NOTIFY
102
+ WRITE_NR = C.GATT_PROP_WRITE_NR
103
+
104
+ def __init__(self, bridge):
105
+ self._b = bridge
106
+ bridge.require(C.Cap.BLE_FW, "BLE (firmware built without it?)")
107
+ self._adv_queue: queue.Queue[Advertisement] = queue.Queue(maxsize=512)
108
+ self._adv_callbacks: list = []
109
+ self._chars: dict[int, Characteristic] = {}
110
+ self._notify_callbacks: dict[bytes, object] = {}
111
+ self._server_connected = threading.Event()
112
+ bridge.on_event(C.BLE_ADV_EVT, self._on_adv)
113
+ bridge.on_event(C.BLE_GATTS_WR_EVT, self._on_gatts_write)
114
+ bridge.on_event(C.BLE_GATTS_CONN_EVT, self._on_gatts_conn)
115
+ bridge.on_event(C.BLE_GATTC_NTFY_EVT, self._on_notify)
116
+
117
+ # ---- events (reader thread) ---------------------------------------------------
118
+
119
+ def _on_adv(self, p: bytes) -> None:
120
+ if len(p) < 8:
121
+ return
122
+ adv = Advertisement(
123
+ addr=":".join(f"{x:02x}" for x in p[0:6]),
124
+ addr_type=p[6],
125
+ rssi=int.from_bytes(p[7:8], "big", signed=True),
126
+ data=p[8:],
127
+ )
128
+ for cb in list(self._adv_callbacks):
129
+ cb(adv)
130
+ try:
131
+ self._adv_queue.put_nowait(adv)
132
+ except queue.Full:
133
+ pass
134
+
135
+ def _on_gatts_write(self, p: bytes) -> None:
136
+ if not p:
137
+ return
138
+ ch = self._chars.get(p[0])
139
+ if ch is not None and ch.on_write is not None:
140
+ ch.on_write(p[1:])
141
+
142
+ def _on_gatts_conn(self, p: bytes) -> None:
143
+ if p and p[0]:
144
+ self._server_connected.set()
145
+ else:
146
+ self._server_connected.clear()
147
+
148
+ def _on_notify(self, p: bytes) -> None:
149
+ if len(p) < 16:
150
+ return
151
+ cb = self._notify_callbacks.get(bytes(p[:16]))
152
+ if cb is not None:
153
+ cb(p[16:])
154
+
155
+ # ---- scanning --------------------------------------------------------------------
156
+
157
+ def scan(self, duration: float = 5.0, *, active: bool = True,
158
+ callback=None) -> list[Advertisement]:
159
+ """Collect advertisements for `duration` seconds.
160
+
161
+ With `callback`, advertisements also stream to it live. Returns devices
162
+ deduplicated by address (strongest RSSI kept).
163
+ """
164
+ if callback is not None:
165
+ self._adv_callbacks.append(callback)
166
+ while not self._adv_queue.empty(): # drop stale results
167
+ self._adv_queue.get_nowait()
168
+ self._b.request(C.BLE_SCAN_START, bytes([min(255, int(duration) + 1), 1 if active else 0]),
169
+ timeout=10.0)
170
+ seen: dict[str, Advertisement] = {}
171
+ deadline = time.monotonic() + duration
172
+ while time.monotonic() < deadline:
173
+ try:
174
+ adv = self._adv_queue.get(timeout=0.2)
175
+ except queue.Empty:
176
+ continue
177
+ cur = seen.get(adv.addr)
178
+ if cur is None or adv.rssi > cur.rssi or (adv.name and not cur.name):
179
+ seen[adv.addr] = adv
180
+ self._b.request(C.BLE_SCAN_STOP, timeout=10.0)
181
+ if callback is not None:
182
+ self._adv_callbacks.remove(callback)
183
+ return sorted(seen.values(), key=lambda a: -a.rssi)
184
+
185
+ # ---- advertising ----------------------------------------------------------------
186
+
187
+ def advertise(self, name: str = "", *, manufacturer_data: bytes = b"",
188
+ service_uuid16: int = 0) -> None:
189
+ payload = lp(name) + lp(manufacturer_data) + struct.pack(">H", service_uuid16)
190
+ self._b.request(C.BLE_ADV_START, payload, timeout=10.0)
191
+
192
+ def advertise_stop(self) -> None:
193
+ self._b.request(C.BLE_ADV_STOP, timeout=10.0)
194
+
195
+ # ---- GATT server -----------------------------------------------------------------
196
+
197
+ def serve(self, service, chars: list[tuple[object, int]]) -> list[Characteristic]:
198
+ """Define a GATT service. `chars` is [(uuid, props), ...] with props a
199
+ bitmask of Ble.READ / Ble.WRITE / Ble.NOTIFY / Ble.WRITE_NR.
200
+ Call advertise() afterwards to become discoverable."""
201
+ payload = to_uuid128(service) + bytes([len(chars)])
202
+ for u, props in chars:
203
+ payload += to_uuid128(u) + bytes([props])
204
+ ids = self._b.request(C.BLE_GATTS_DEF, payload, timeout=10.0)
205
+ out = []
206
+ for (u, _), cid in zip(chars, ids):
207
+ ch = Characteristic(self, cid, to_uuid128(u))
208
+ self._chars[cid] = ch
209
+ out.append(ch)
210
+ return out
211
+
212
+ def wait_connect(self, timeout: float | None = None) -> bool:
213
+ return self._server_connected.wait(timeout)
214
+
215
+ # ---- GATT client ------------------------------------------------------------------
216
+
217
+ def connect(self, addr: str, addr_type: int = 0, timeout: float = 15.0) -> GattClient:
218
+ """Connect to a peripheral by address ("aa:bb:cc:dd:ee:ff")."""
219
+ mac = bytes(int(x, 16) for x in addr.split(":"))
220
+ if len(mac) != 6:
221
+ raise ValueError("address must be 6 bytes, e.g. 'aa:bb:cc:dd:ee:ff'")
222
+ self._b.request(C.BLE_GATTC_CONN, mac + bytes([addr_type]), timeout=timeout)
223
+ return GattClient(self)