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/net.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""TCP/UDP sockets proxied through the ESP32 radio.
|
|
2
|
+
|
|
3
|
+
Socket-like objects backed by the NET module's credit-window flow control:
|
|
4
|
+
the firmware never sends more than the window un-acked; we replenish credit
|
|
5
|
+
as the application consumes data, so fast peers get normal TCP backpressure.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import queue
|
|
10
|
+
import struct
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from . import constants as C
|
|
15
|
+
from .errors import BridgeError, BridgeTimeoutError
|
|
16
|
+
from .protocol import ip_str, lp
|
|
17
|
+
|
|
18
|
+
# Per-request send chunk: bounds single-request size (and how long the
|
|
19
|
+
# firmware's synchronous socket write can run). Well under MAX_PAYLOAD.
|
|
20
|
+
_SEND_CHUNK = 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_UNSET = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TcpSocket:
|
|
27
|
+
def __init__(self, net: "Net", handle: int, peer: tuple[str, int] | None = None):
|
|
28
|
+
self._net = net
|
|
29
|
+
self._b = net._b
|
|
30
|
+
self.handle = handle
|
|
31
|
+
self.peer = peer
|
|
32
|
+
self._buf = bytearray()
|
|
33
|
+
self._cond = threading.Condition()
|
|
34
|
+
self._open = True
|
|
35
|
+
self._timeout: float | None = None
|
|
36
|
+
|
|
37
|
+
def settimeout(self, timeout: float | None) -> None:
|
|
38
|
+
"""Default timeout for recv() — stdlib socket semantics."""
|
|
39
|
+
self._timeout = timeout
|
|
40
|
+
|
|
41
|
+
def gettimeout(self) -> float | None:
|
|
42
|
+
return self._timeout
|
|
43
|
+
|
|
44
|
+
# internal: called from the reader thread
|
|
45
|
+
def _feed(self, data: bytes) -> None:
|
|
46
|
+
with self._cond:
|
|
47
|
+
self._buf += data
|
|
48
|
+
self._cond.notify_all()
|
|
49
|
+
|
|
50
|
+
def _closed_remote(self) -> None:
|
|
51
|
+
with self._cond:
|
|
52
|
+
self._open = False
|
|
53
|
+
self._cond.notify_all()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def connected(self) -> bool:
|
|
57
|
+
return self._open
|
|
58
|
+
|
|
59
|
+
def recv(self, maxbytes: int = 65536, timeout: float | None = _UNSET) -> bytes:
|
|
60
|
+
"""Like socket.recv: b'' means the peer closed. Blocks up to `timeout`
|
|
61
|
+
(default: the settimeout() value; None blocks until data/close)."""
|
|
62
|
+
if timeout is _UNSET:
|
|
63
|
+
timeout = self._timeout
|
|
64
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
65
|
+
with self._cond:
|
|
66
|
+
while not self._buf:
|
|
67
|
+
if not self._open:
|
|
68
|
+
return b""
|
|
69
|
+
wait = None if deadline is None else deadline - time.monotonic()
|
|
70
|
+
if wait is not None and wait <= 0:
|
|
71
|
+
raise BridgeTimeoutError("recv timed out")
|
|
72
|
+
self._cond.wait(wait if wait is not None else 0.5)
|
|
73
|
+
n = min(maxbytes, len(self._buf))
|
|
74
|
+
data = bytes(self._buf[:n])
|
|
75
|
+
del self._buf[:n]
|
|
76
|
+
# Replenish the firmware's send window (fire-and-forget).
|
|
77
|
+
self._b.send(C.NET_WINDOW_ACK, struct.pack(">BH", self.handle, n))
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
def recv_exactly(self, n: int, timeout: float | None = None) -> bytes:
|
|
81
|
+
out = bytearray()
|
|
82
|
+
while len(out) < n:
|
|
83
|
+
chunk = self.recv(n - len(out), timeout)
|
|
84
|
+
if not chunk:
|
|
85
|
+
raise BridgeError("connection closed mid-read")
|
|
86
|
+
out += chunk
|
|
87
|
+
return bytes(out)
|
|
88
|
+
|
|
89
|
+
def send(self, data: bytes) -> int:
|
|
90
|
+
"""Send all of `data`; returns len(data)."""
|
|
91
|
+
data = bytes(data)
|
|
92
|
+
off = 0
|
|
93
|
+
while off < len(data):
|
|
94
|
+
chunk = data[off : off + _SEND_CHUNK]
|
|
95
|
+
r = self._b.request(C.NET_SEND, bytes([self.handle]) + chunk, timeout=10.0)
|
|
96
|
+
(sent,) = struct.unpack(">H", r)
|
|
97
|
+
if sent == 0:
|
|
98
|
+
raise BridgeError("send failed (socket closed?)")
|
|
99
|
+
off += sent
|
|
100
|
+
return len(data)
|
|
101
|
+
|
|
102
|
+
sendall = send
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
if self._open:
|
|
106
|
+
self._open = False
|
|
107
|
+
try:
|
|
108
|
+
self._b.request(C.NET_CLOSE, bytes([self.handle]))
|
|
109
|
+
except BridgeError:
|
|
110
|
+
pass
|
|
111
|
+
self._net._forget(self.handle)
|
|
112
|
+
|
|
113
|
+
def __enter__(self):
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *exc):
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TcpServer:
|
|
121
|
+
def __init__(self, net: "Net", handle: int, port: int):
|
|
122
|
+
self._net = net
|
|
123
|
+
self.handle = handle
|
|
124
|
+
self.port = port
|
|
125
|
+
self._accepted: queue.Queue[TcpSocket] = queue.Queue()
|
|
126
|
+
|
|
127
|
+
def accept(self, timeout: float | None = None) -> TcpSocket:
|
|
128
|
+
try:
|
|
129
|
+
return self._accepted.get(timeout=timeout)
|
|
130
|
+
except queue.Empty:
|
|
131
|
+
raise BridgeTimeoutError("no incoming connection") from None
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
try:
|
|
135
|
+
self._net._b.request(C.NET_CLOSE, bytes([self.handle]))
|
|
136
|
+
except BridgeError:
|
|
137
|
+
pass
|
|
138
|
+
self._net._forget(self.handle)
|
|
139
|
+
|
|
140
|
+
def __enter__(self):
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(self, *exc):
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class UdpSocket:
|
|
148
|
+
def __init__(self, net: "Net", handle: int, local_port: int):
|
|
149
|
+
self._net = net
|
|
150
|
+
self._b = net._b
|
|
151
|
+
self.handle = handle
|
|
152
|
+
self.local_port = local_port
|
|
153
|
+
self._timeout: float | None = None
|
|
154
|
+
self._packets: queue.Queue[tuple[bytes, tuple[str, int]]] = queue.Queue(maxsize=256)
|
|
155
|
+
|
|
156
|
+
def settimeout(self, timeout: float | None) -> None:
|
|
157
|
+
self._timeout = timeout
|
|
158
|
+
|
|
159
|
+
def gettimeout(self) -> float | None:
|
|
160
|
+
return self._timeout
|
|
161
|
+
|
|
162
|
+
def _feed(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
163
|
+
try:
|
|
164
|
+
self._packets.put_nowait((data, addr))
|
|
165
|
+
except queue.Full:
|
|
166
|
+
pass # UDP: drop on overflow
|
|
167
|
+
|
|
168
|
+
def sendto(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
169
|
+
ip = bytes(int(x) for x in addr[0].split("."))
|
|
170
|
+
if len(data) > C.MAX_PAYLOAD - 7:
|
|
171
|
+
raise ValueError(f"datagram too large (> {C.MAX_PAYLOAD - 7})")
|
|
172
|
+
self._b.request(C.NET_SEND_TO,
|
|
173
|
+
bytes([self.handle]) + ip + struct.pack(">H", addr[1]) + bytes(data))
|
|
174
|
+
|
|
175
|
+
def recvfrom(self, timeout: float | None = _UNSET) -> tuple[bytes, tuple[str, int]]:
|
|
176
|
+
if timeout is _UNSET:
|
|
177
|
+
timeout = self._timeout
|
|
178
|
+
try:
|
|
179
|
+
return self._packets.get(timeout=timeout)
|
|
180
|
+
except queue.Empty:
|
|
181
|
+
raise BridgeTimeoutError("no datagram received") from None
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
try:
|
|
185
|
+
self._b.request(C.NET_CLOSE, bytes([self.handle]))
|
|
186
|
+
except BridgeError:
|
|
187
|
+
pass
|
|
188
|
+
self._net._forget(self.handle)
|
|
189
|
+
|
|
190
|
+
def __enter__(self):
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc):
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Net:
|
|
198
|
+
def __init__(self, bridge):
|
|
199
|
+
self._b = bridge
|
|
200
|
+
self._sockets: dict[int, object] = {}
|
|
201
|
+
bridge.on_event(C.NET_DATA_EVT, self._on_data)
|
|
202
|
+
bridge.on_event(C.NET_UDP_EVT, self._on_udp)
|
|
203
|
+
bridge.on_event(C.NET_ACCEPT_EVT, self._on_accept)
|
|
204
|
+
bridge.on_event(C.NET_CLOSED_EVT, self._on_closed)
|
|
205
|
+
|
|
206
|
+
# ---- events (reader thread) ------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _on_data(self, p: bytes) -> None:
|
|
209
|
+
s = self._sockets.get(p[0]) if p else None
|
|
210
|
+
if isinstance(s, TcpSocket):
|
|
211
|
+
s._feed(p[1:])
|
|
212
|
+
|
|
213
|
+
def _on_udp(self, p: bytes) -> None:
|
|
214
|
+
if len(p) < 7:
|
|
215
|
+
return
|
|
216
|
+
s = self._sockets.get(p[0])
|
|
217
|
+
if isinstance(s, UdpSocket):
|
|
218
|
+
(port,) = struct.unpack_from(">H", p, 5)
|
|
219
|
+
s._feed(p[7:], (ip_str(p[1:5]), port))
|
|
220
|
+
|
|
221
|
+
def _on_accept(self, p: bytes) -> None:
|
|
222
|
+
if len(p) < 8:
|
|
223
|
+
return
|
|
224
|
+
srv = self._sockets.get(p[0])
|
|
225
|
+
if isinstance(srv, TcpServer):
|
|
226
|
+
(port,) = struct.unpack_from(">H", p, 6)
|
|
227
|
+
sock = TcpSocket(self, p[1], (ip_str(p[2:6]), port))
|
|
228
|
+
self._sockets[p[1]] = sock
|
|
229
|
+
srv._accepted.put(sock)
|
|
230
|
+
|
|
231
|
+
def _on_closed(self, p: bytes) -> None:
|
|
232
|
+
s = self._sockets.get(p[0]) if p else None
|
|
233
|
+
if isinstance(s, TcpSocket):
|
|
234
|
+
s._closed_remote()
|
|
235
|
+
|
|
236
|
+
def _forget(self, handle: int) -> None:
|
|
237
|
+
self._sockets.pop(handle, None)
|
|
238
|
+
|
|
239
|
+
# ---- API -----------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def tcp_connect(self, host: str, port: int, timeout: float = 10.0) -> TcpSocket:
|
|
242
|
+
"""Open a TCP connection *through the ESP32's Wi-Fi*."""
|
|
243
|
+
r = self._b.request(C.NET_TCP_CONNECT,
|
|
244
|
+
struct.pack(">H", port) + lp(host),
|
|
245
|
+
timeout=timeout)
|
|
246
|
+
sock = TcpSocket(self, r[0], (host, port))
|
|
247
|
+
self._sockets[r[0]] = sock
|
|
248
|
+
return sock
|
|
249
|
+
|
|
250
|
+
def tcp_listen(self, port: int) -> TcpServer:
|
|
251
|
+
r = self._b.request(C.NET_TCP_LISTEN, struct.pack(">H", port))
|
|
252
|
+
srv = TcpServer(self, r[0], port)
|
|
253
|
+
self._sockets[r[0]] = srv
|
|
254
|
+
return srv
|
|
255
|
+
|
|
256
|
+
def udp(self, local_port: int = 0) -> UdpSocket:
|
|
257
|
+
r = self._b.request(C.NET_UDP_OPEN, struct.pack(">H", local_port))
|
|
258
|
+
sock = UdpSocket(self, r[0], local_port)
|
|
259
|
+
self._sockets[r[0]] = sock
|
|
260
|
+
return sock
|
|
261
|
+
|
|
262
|
+
def http_get(self, url: str, timeout: float = 15.0) -> tuple[int, bytes]:
|
|
263
|
+
"""Tiny HTTP/1.0 GET through the bridge (no TLS). Returns (status, body)."""
|
|
264
|
+
if not url.startswith("http://"):
|
|
265
|
+
raise ValueError("only http:// supported (no TLS on the proxy path)")
|
|
266
|
+
rest = url[7:]
|
|
267
|
+
host, _, path = rest.partition("/")
|
|
268
|
+
host, _, port_s = host.partition(":")
|
|
269
|
+
with self.tcp_connect(host, int(port_s) if port_s else 80, timeout) as s:
|
|
270
|
+
s.send(f"GET /{path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode())
|
|
271
|
+
raw = bytearray()
|
|
272
|
+
while True:
|
|
273
|
+
chunk = s.recv(timeout=timeout)
|
|
274
|
+
if not chunk:
|
|
275
|
+
break
|
|
276
|
+
raw += chunk
|
|
277
|
+
head, _, body = bytes(raw).partition(b"\r\n\r\n")
|
|
278
|
+
status = int(head.split(b" ", 2)[1]) if b" " in head else 0
|
|
279
|
+
return status, body
|
espbridge/oled.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Direct OLED support: SSD1306 / SH1106 / clones over the bridge's I2C.
|
|
2
|
+
|
|
3
|
+
pip install "python-esp-bridge[oled]" (just adds Pillow for drawing)
|
|
4
|
+
|
|
5
|
+
from espbridge import Bridge
|
|
6
|
+
from espbridge.oled import OLED
|
|
7
|
+
|
|
8
|
+
with Bridge() as esp:
|
|
9
|
+
oled = OLED(esp) # bus init + auto-detect + power up
|
|
10
|
+
with oled.draw() as d: # d is a PIL ImageDraw
|
|
11
|
+
d.text((0, 10), "Hello!", fill="white")
|
|
12
|
+
d.rectangle((0, 56, 127, 62), fill="white")
|
|
13
|
+
|
|
14
|
+
Drawing is plain PIL: text with any TTF font, shapes, bitmaps — build your own
|
|
15
|
+
Image and call ``oled.show(image)`` if you prefer.
|
|
16
|
+
|
|
17
|
+
Compatibility (learned the hard way): cheap "SSD1306" modules are very often
|
|
18
|
+
SH1106-family clones. SH1106 lacks the SSD1306's horizontal addressing mode
|
|
19
|
+
(bulk framebuffer dumps come out interleaved/wrapped), and clones disagree on
|
|
20
|
+
where the visible window sits in controller RAM (column offset 0 vs 2).
|
|
21
|
+
This driver therefore:
|
|
22
|
+
|
|
23
|
+
- writes in *page mode*, which every chip in the family implements,
|
|
24
|
+
- powers up with both families' commands (each chip ignores the other's),
|
|
25
|
+
- defaults to column offset 0 (true SSD1306s and most 0.96" clones).
|
|
26
|
+
|
|
27
|
+
Only genuine 1.3" SH1106 modules (window centered in 132-column RAM) need
|
|
28
|
+
``OLED(esp, colstart=2)`` — symptom: image shifted sideways with a stripe of
|
|
29
|
+
stale pixels at one edge; try colstart 0..4.
|
|
30
|
+
|
|
31
|
+
Every common panel adjustment has a named method — no datasheet bytes needed:
|
|
32
|
+
``contrast(0..255)``, ``invert()``, ``power(False)``, ``vertical_offset(rows)``,
|
|
33
|
+
``start_line(row)``, ``flip(horizontal=..., vertical=...)`` and the
|
|
34
|
+
``colstart`` attribute.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import contextlib
|
|
39
|
+
|
|
40
|
+
from .errors import NoDeviceError
|
|
41
|
+
|
|
42
|
+
_CMD = 0x00 # control byte: command stream follows
|
|
43
|
+
_DATA = 0x40 # control byte: display data follows
|
|
44
|
+
|
|
45
|
+
_INSTALL_HINT = (
|
|
46
|
+
'OLED drawing needs Pillow — install it with:\n'
|
|
47
|
+
' pip install "python-esp-bridge[oled]" (or: pip install pillow)'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# PIL packs mode-"1" rows MSB-first; panel pages want the top pixel in bit 0.
|
|
51
|
+
_BITREV = bytes(int(f"{i:08b}"[::-1], 2) for i in range(256))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OLED:
|
|
55
|
+
"""A 128x64/128x32 I2C OLED driven directly over the bridge."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, esp, *, addr: int | None = None, sda: int = 21,
|
|
58
|
+
scl: int = 22, freq: int = 400_000, bus: int = 0,
|
|
59
|
+
width: int = 128, height: int = 64, colstart: int = 0,
|
|
60
|
+
contrast: int = 0xCF, init_bus: bool = True):
|
|
61
|
+
try:
|
|
62
|
+
from PIL import Image, ImageDraw
|
|
63
|
+
except ImportError as e:
|
|
64
|
+
raise ImportError(_INSTALL_HINT) from e
|
|
65
|
+
self._Image, self._ImageDraw = Image, ImageDraw
|
|
66
|
+
|
|
67
|
+
self._i2c = esp.i2c
|
|
68
|
+
self._bus = bus
|
|
69
|
+
# largest data write minus the control byte (128 on old firmware,
|
|
70
|
+
# whose Wire TX buffer silently truncates longer transmissions)
|
|
71
|
+
self._chunk = getattr(esp.i2c, "max_write", 2046) - 1
|
|
72
|
+
self.width, self.height = width, height
|
|
73
|
+
self.colstart = colstart
|
|
74
|
+
|
|
75
|
+
if init_bus:
|
|
76
|
+
self._i2c.init(sda=sda, scl=scl, freq=freq, bus=bus)
|
|
77
|
+
if addr is None:
|
|
78
|
+
found = self._i2c.scan(bus)
|
|
79
|
+
addr = next((a for a in (0x3C, 0x3D) if a in found), None)
|
|
80
|
+
if addr is None:
|
|
81
|
+
raise NoDeviceError(
|
|
82
|
+
f"no OLED at 0x3C/0x3D (i2c scan found: {[hex(a) for a in found]})"
|
|
83
|
+
)
|
|
84
|
+
self.addr = addr
|
|
85
|
+
|
|
86
|
+
self.command(
|
|
87
|
+
0xAE, # display off
|
|
88
|
+
0xD5, 0x80, # clock divide
|
|
89
|
+
0xA8, height - 1, # multiplex
|
|
90
|
+
0xD3, 0x00, # display offset
|
|
91
|
+
0x40, # start line 0
|
|
92
|
+
0xAD, 0x8B, # SH1106: DC-DC on (NOP on SSD1306)
|
|
93
|
+
0x8D, 0x14, # SSD1306: charge pump on (NOP on SH1106)
|
|
94
|
+
0xA1, 0xC8, # flip horizontally + vertically (rotation 0)
|
|
95
|
+
0xDA, 0x12 if height == 64 else 0x02, # COM pins
|
|
96
|
+
0x81, contrast,
|
|
97
|
+
0xD9, 0xF1, # precharge
|
|
98
|
+
0xDB, 0x40, # VCOM detect
|
|
99
|
+
0xA4, 0xA6, # from RAM, not inverted
|
|
100
|
+
)
|
|
101
|
+
self.clear() # wipe power-on RAM noise before lighting up
|
|
102
|
+
self.command(0xAF) # display on
|
|
103
|
+
|
|
104
|
+
# ---- low level ------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def command(self, *cmds: int, wait: bool = True) -> None:
|
|
107
|
+
self._i2c.write(self.addr, bytes([_CMD, *cmds]), self._bus, wait=wait)
|
|
108
|
+
|
|
109
|
+
def _write_data(self, data: bytes, *, wait: bool = True) -> None:
|
|
110
|
+
chunk = self._chunk
|
|
111
|
+
for off in range(0, len(data), chunk):
|
|
112
|
+
self._i2c.write(self.addr, bytes([_DATA]) + data[off : off + chunk],
|
|
113
|
+
self._bus,
|
|
114
|
+
wait=wait and off + chunk >= len(data))
|
|
115
|
+
|
|
116
|
+
# ---- drawing ---------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def show(self, image=None) -> None:
|
|
119
|
+
"""Push a PIL image (mode '1' or anything convertible) to the panel."""
|
|
120
|
+
if image is None:
|
|
121
|
+
image = self._Image.new("1", (self.width, self.height))
|
|
122
|
+
if image.mode != "1": # any nonzero pixel lights up (no dithering)
|
|
123
|
+
image = image.convert("L").point(lambda v: 255 if v else 0, mode="1")
|
|
124
|
+
# Transposing makes each image row a display column, so tobytes()
|
|
125
|
+
# yields the 8-pixel vertical slices pages are made of (MSB-first;
|
|
126
|
+
# the panel wants the top pixel in bit 0, hence the bit reversal).
|
|
127
|
+
raw = image.transpose(self._Image.Transpose.TRANSPOSE).tobytes()
|
|
128
|
+
bpr = self.height // 8 # transposed row = bpr bytes, one per page
|
|
129
|
+
low = 0x00 | (self.colstart & 0x0F)
|
|
130
|
+
high = 0x10 | (self.colstart >> 4)
|
|
131
|
+
pages = self.height // 8
|
|
132
|
+
for page in range(pages):
|
|
133
|
+
# Pipelined: everything fire-and-forget except the final data
|
|
134
|
+
# write, which acts as the frame sync (firmware runs in order).
|
|
135
|
+
self.command(0xB0 + page, low, high, wait=False)
|
|
136
|
+
self._write_data(raw[page::bpr].translate(_BITREV),
|
|
137
|
+
wait=page == pages - 1)
|
|
138
|
+
|
|
139
|
+
@contextlib.contextmanager
|
|
140
|
+
def draw(self):
|
|
141
|
+
"""Context manager: yields a PIL ImageDraw; pushes the frame on exit."""
|
|
142
|
+
image = self._Image.new("1", (self.width, self.height))
|
|
143
|
+
yield self._ImageDraw.Draw(image)
|
|
144
|
+
self.show(image)
|
|
145
|
+
|
|
146
|
+
def clear(self) -> None:
|
|
147
|
+
self.show(None)
|
|
148
|
+
|
|
149
|
+
# ---- panel controls -----------------------------------------------------------
|
|
150
|
+
# Named knobs for every common panel adjustment — no datasheet bytes needed.
|
|
151
|
+
# (`colstart` is a plain attribute: `oled.colstart = 2` applies on next show().)
|
|
152
|
+
|
|
153
|
+
def contrast(self, value: int) -> None:
|
|
154
|
+
"""Brightness, 0..255."""
|
|
155
|
+
self.command(0x81, value & 0xFF)
|
|
156
|
+
|
|
157
|
+
def invert(self, enabled: bool = True) -> None:
|
|
158
|
+
"""White-on-black <-> black-on-white, instantly (no redraw needed)."""
|
|
159
|
+
self.command(0xA7 if enabled else 0xA6)
|
|
160
|
+
|
|
161
|
+
def power(self, on: bool = True) -> None:
|
|
162
|
+
"""Panel on/off (RAM contents are kept while off)."""
|
|
163
|
+
self.command(0xAF if on else 0xAE)
|
|
164
|
+
|
|
165
|
+
def vertical_offset(self, rows: int = 0) -> None:
|
|
166
|
+
"""Shift the image down by 0..63 rows — fixes panels whose glass is
|
|
167
|
+
wired with a vertical offset (image starts a couple rows low/high)."""
|
|
168
|
+
self.command(0xD3, rows & 0x3F)
|
|
169
|
+
|
|
170
|
+
def start_line(self, line: int = 0) -> None:
|
|
171
|
+
"""Hardware vertical scroll: which RAM row appears at the top."""
|
|
172
|
+
self.command(0x40 | (line & 0x3F))
|
|
173
|
+
|
|
174
|
+
def flip(self, *, horizontal: bool = False, vertical: bool = False) -> None:
|
|
175
|
+
"""Mirror the panel in hardware (for displays mounted rotated).
|
|
176
|
+
|
|
177
|
+
flip(horizontal=True, vertical=True) is a 180° rotation. The vertical
|
|
178
|
+
mirror applies instantly; the horizontal one applies from the next
|
|
179
|
+
show() (it remaps how RAM is written).
|
|
180
|
+
"""
|
|
181
|
+
self.command(0xA0 if horizontal else 0xA1,
|
|
182
|
+
0xC0 if vertical else 0xC8)
|
espbridge/protocol.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Frame codec: COBS framing + CRC-16/CCITT-FALSE.
|
|
2
|
+
|
|
3
|
+
Wire format (see docs/PROTOCOL.md):
|
|
4
|
+
logical frame = flags u8 | seq u8 | cmd u16 BE | payload | crc16 BE
|
|
5
|
+
on the wire = COBS(logical) + b"\\x00"
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .constants import FLAG_ERROR, FLAG_EVENT, MAX_PAYLOAD
|
|
13
|
+
from .errors import ProtocolError
|
|
14
|
+
|
|
15
|
+
_HDR = struct.Struct(">BBH")
|
|
16
|
+
|
|
17
|
+
# ---- CRC-16/CCITT-FALSE (table-driven) ---------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_table() -> list[int]:
|
|
21
|
+
table = []
|
|
22
|
+
for byte in range(256):
|
|
23
|
+
crc = byte << 8
|
|
24
|
+
for _ in range(8):
|
|
25
|
+
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
|
26
|
+
table.append(crc)
|
|
27
|
+
return table
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_CRC_TABLE = _make_table()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def crc16_ccitt(data: bytes) -> int:
|
|
34
|
+
crc = 0xFFFF
|
|
35
|
+
for b in data:
|
|
36
|
+
crc = ((crc << 8) & 0xFFFF) ^ _CRC_TABLE[((crc >> 8) ^ b) & 0xFF]
|
|
37
|
+
return crc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- COBS ----------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cobs_encode(data: bytes) -> bytes:
|
|
44
|
+
out = bytearray([0]) # placeholder for first code byte
|
|
45
|
+
code_idx = 0
|
|
46
|
+
code = 1
|
|
47
|
+
for b in data:
|
|
48
|
+
if b == 0:
|
|
49
|
+
out[code_idx] = code
|
|
50
|
+
code_idx = len(out)
|
|
51
|
+
out.append(0)
|
|
52
|
+
code = 1
|
|
53
|
+
else:
|
|
54
|
+
out.append(b)
|
|
55
|
+
code += 1
|
|
56
|
+
if code == 0xFF:
|
|
57
|
+
out[code_idx] = code
|
|
58
|
+
code_idx = len(out)
|
|
59
|
+
out.append(0)
|
|
60
|
+
code = 1
|
|
61
|
+
out[code_idx] = code
|
|
62
|
+
return bytes(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cobs_decode(data: bytes) -> bytes:
|
|
66
|
+
out = bytearray()
|
|
67
|
+
i = 0
|
|
68
|
+
n = len(data)
|
|
69
|
+
while i < n:
|
|
70
|
+
code = data[i]
|
|
71
|
+
if code == 0:
|
|
72
|
+
raise ProtocolError("zero byte inside COBS frame")
|
|
73
|
+
i += 1
|
|
74
|
+
end = i + code - 1
|
|
75
|
+
if end > n:
|
|
76
|
+
raise ProtocolError("truncated COBS block")
|
|
77
|
+
out += data[i:end]
|
|
78
|
+
i = end
|
|
79
|
+
if code != 0xFF and i < n:
|
|
80
|
+
out.append(0)
|
|
81
|
+
return bytes(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- frames ---------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Frame:
|
|
89
|
+
flags: int
|
|
90
|
+
seq: int
|
|
91
|
+
cmd: int
|
|
92
|
+
payload: bytes
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_event(self) -> bool:
|
|
96
|
+
return bool(self.flags & FLAG_EVENT)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_error(self) -> bool:
|
|
100
|
+
return bool(self.flags & FLAG_ERROR)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def encode_frame(flags: int, seq: int, cmd: int, payload: bytes = b"") -> bytes:
|
|
104
|
+
"""Logical frame -> wire bytes (COBS + 0x00 delimiter)."""
|
|
105
|
+
if len(payload) > MAX_PAYLOAD:
|
|
106
|
+
raise ProtocolError(f"payload too large: {len(payload)} > {MAX_PAYLOAD}")
|
|
107
|
+
logical = _HDR.pack(flags, seq, cmd) + payload
|
|
108
|
+
logical += struct.pack(">H", crc16_ccitt(logical))
|
|
109
|
+
return cobs_encode(logical) + b"\x00"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_frame(encoded: bytes) -> Frame:
|
|
113
|
+
"""Wire bytes between delimiters -> logical Frame. Raises ProtocolError."""
|
|
114
|
+
logical = cobs_decode(encoded)
|
|
115
|
+
if len(logical) < 6:
|
|
116
|
+
raise ProtocolError(f"frame too short: {len(logical)} bytes")
|
|
117
|
+
(crc,) = struct.unpack_from(">H", logical, len(logical) - 2)
|
|
118
|
+
if crc16_ccitt(logical[:-2]) != crc:
|
|
119
|
+
raise ProtocolError("CRC mismatch")
|
|
120
|
+
flags, seq, cmd = _HDR.unpack_from(logical)
|
|
121
|
+
return Frame(flags, seq, cmd, logical[4:-2])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- wire-format helpers shared by the sub-API modules ---------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def lp(s: str | bytes) -> bytes:
|
|
128
|
+
"""Length-prefixed string (len u8 | bytes) — the protocol's string format."""
|
|
129
|
+
b = s.encode() if isinstance(s, str) else bytes(s)
|
|
130
|
+
if len(b) > 255:
|
|
131
|
+
raise ProtocolError(f"string too long for length prefix: {len(b)} bytes")
|
|
132
|
+
return bytes([len(b)]) + b
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ip_str(b: bytes) -> str:
|
|
136
|
+
"""4 raw bytes -> dotted-quad string."""
|
|
137
|
+
return ".".join(str(x) for x in b)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FrameSplitter:
|
|
141
|
+
"""Accumulates raw serial bytes and yields COBS chunks between 0x00 delimiters."""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self._buf = bytearray()
|
|
145
|
+
|
|
146
|
+
def feed(self, data: bytes) -> list[bytes]:
|
|
147
|
+
chunks: list[bytes] = []
|
|
148
|
+
self._buf += data
|
|
149
|
+
while True:
|
|
150
|
+
i = self._buf.find(0)
|
|
151
|
+
if i < 0:
|
|
152
|
+
break
|
|
153
|
+
if i > 0:
|
|
154
|
+
chunks.append(bytes(self._buf[:i]))
|
|
155
|
+
del self._buf[: i + 1]
|
|
156
|
+
return chunks
|
|
157
|
+
|
|
158
|
+
def reset(self) -> None:
|
|
159
|
+
self._buf.clear()
|
espbridge/pwm.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""PWM via the ESP32 LEDC peripheral, plus servo/tone conveniences."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
from . import constants as C
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Pwm:
|
|
10
|
+
def __init__(self, bridge):
|
|
11
|
+
self._b = bridge
|
|
12
|
+
self._attached: dict[int, tuple[int, int]] = {} # pin -> (freq, res_bits)
|
|
13
|
+
|
|
14
|
+
def attach(self, pin: int, freq: int = 1000, resolution_bits: int = 10) -> None:
|
|
15
|
+
self._b.request(C.PWM_ATTACH, struct.pack(">BIB", pin, freq, resolution_bits))
|
|
16
|
+
self._attached[pin] = (freq, resolution_bits)
|
|
17
|
+
|
|
18
|
+
def write(self, pin: int, duty: int) -> None:
|
|
19
|
+
"""Raw duty (0 .. 2**resolution_bits - 1)."""
|
|
20
|
+
self._b.request(C.PWM_WRITE, struct.pack(">BI", pin, duty))
|
|
21
|
+
|
|
22
|
+
def duty_pct(self, pin: int, percent: float) -> None:
|
|
23
|
+
if pin not in self._attached:
|
|
24
|
+
self.attach(pin)
|
|
25
|
+
_, res = self._attached[pin]
|
|
26
|
+
self.write(pin, round((2**res - 1) * max(0.0, min(100.0, percent)) / 100.0))
|
|
27
|
+
|
|
28
|
+
def detach(self, pin: int) -> None:
|
|
29
|
+
self._b.request(C.PWM_DETACH, bytes([pin]))
|
|
30
|
+
self._attached.pop(pin, None)
|
|
31
|
+
|
|
32
|
+
def tone(self, pin: int, freq: int) -> None:
|
|
33
|
+
"""Square wave at `freq` Hz (0 = off) — buzzer-friendly."""
|
|
34
|
+
self._b.request(C.PWM_TONE, struct.pack(">BI", pin, freq))
|
|
35
|
+
|
|
36
|
+
def servo(self, pin: int, angle: float, *, min_us: int = 500, max_us: int = 2500) -> None:
|
|
37
|
+
"""Drive a hobby servo: 50 Hz, angle 0..180 mapped to min_us..max_us."""
|
|
38
|
+
if self._attached.get(pin) != (50, 14):
|
|
39
|
+
self.attach(pin, 50, 14)
|
|
40
|
+
us = min_us + (max_us - min_us) * max(0.0, min(180.0, angle)) / 180.0
|
|
41
|
+
self.write(pin, round(us / 20000.0 * (2**14 - 1)))
|