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