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/__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)
|