Skip to content

Commit b4192e2

Browse files
committed
python-esp-bridge v0.0.2: faster OLED frame pushes
- i2c.write(wait=False): fire-and-forget writes (seq=0, no ACK round-trip); OLED.show() pipelines all page writes and syncs only on the last one, cutting 16 USB round-trips per frame down to 1 - OLED.show(): pack pages via PIL transpose + bit-reversal translate instead of a per-pixel Python loop (~8k iterations -> 3 C calls) - i2c.max_write: firmware-aware write limit (2046 on fw >= 0.0.2, 128 on older firmware whose Wire TX buffer silently truncates longer writes); OLED and LumaI2C chunk to fit automatically
1 parent 490f4c1 commit b4192e2

7 files changed

Lines changed: 56 additions & 29 deletions

File tree

examples/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "espbridge-examples"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
description = "Runnable examples for python-esp-bridge"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/espbridge/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
)
2525
from .transport import find_ports
2626

27-
__version__ = "0.0.1"
27+
__version__ = "0.0.2"
2828

2929
__all__ = [
3030
"Bridge",

src/espbridge/i2c.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ class I2c:
1010
def __init__(self, bridge):
1111
self._b = bridge
1212

13+
@property
14+
def max_write(self) -> int:
15+
"""Largest data block write() accepts, firmware-dependent: older
16+
firmware leaves Wire's TX buffer at its 128-byte default and
17+
silently truncates anything longer."""
18+
info = self._b.info
19+
if info is not None and info.fw_version < (0, 0, 2):
20+
return 128
21+
return C.MAX_PAYLOAD - 2 # frame carries bus + addr first
22+
1323
def init(self, *, sda: int = 21, scl: int = 22, freq: int = 400_000, bus: int = 0) -> None:
1424
self._b.request(C.I2C_INIT, struct.pack(">BBBI", bus, sda, scl, freq))
1525

@@ -18,10 +28,19 @@ def scan(self, bus: int = 0) -> list[int]:
1828
r = self._b.request(C.I2C_SCAN, bytes([bus]), timeout=5.0)
1929
return list(r[1 : 1 + r[0]])
2030

21-
def write(self, addr: int, data: bytes, bus: int = 0) -> None:
22-
if len(data) > C.MAX_PAYLOAD - 2: # frame carries bus + addr first
23-
raise ValueError(f"max {C.MAX_PAYLOAD - 2} bytes per I2C write")
24-
self._b.request(C.I2C_WRITE, bytes([bus, addr]) + bytes(data))
31+
def write(self, addr: int, data: bytes, bus: int = 0, *, wait: bool = True) -> None:
32+
"""Write bytes to a device. ``wait=False`` sends fire-and-forget —
33+
no ACK round-trip, errors are not reported; pair a burst of unwaited
34+
writes with a final waited one to sync (the firmware executes
35+
requests in arrival order)."""
36+
if len(data) > self.max_write:
37+
raise ValueError(f"max {self.max_write} bytes per I2C write "
38+
"(update the firmware for 2046)")
39+
payload = bytes([bus, addr]) + bytes(data)
40+
if wait:
41+
self._b.request(C.I2C_WRITE, payload)
42+
else:
43+
self._b.send(C.I2C_WRITE, payload)
2544

2645
def read(self, addr: int, n: int, bus: int = 0) -> bytes:
2746
if not 1 <= n <= 255:

src/espbridge/oled.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@
4141

4242
_CMD = 0x00 # control byte: command stream follows
4343
_DATA = 0x40 # control byte: display data follows
44-
_CHUNK = 2045 # max I2C_WRITE data (2046) minus the control byte; the
45-
# firmware sizes Wire's TX buffer to match (fw >= 0.0.2)
4644

4745
_INSTALL_HINT = (
4846
'OLED drawing needs Pillow — install it with:\n'
4947
' pip install "python-esp-bridge[oled]" (or: pip install pillow)'
5048
)
5149

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+
5253

5354
class OLED:
5455
"""A 128x64/128x32 I2C OLED driven directly over the bridge."""
@@ -65,6 +66,9 @@ def __init__(self, esp, *, addr: int | None = None, sda: int = 21,
6566

6667
self._i2c = esp.i2c
6768
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
6872
self.width, self.height = width, height
6973
self.colstart = colstart
7074

@@ -99,34 +103,38 @@ def __init__(self, esp, *, addr: int | None = None, sda: int = 21,
99103

100104
# ---- low level ------------------------------------------------------------
101105

102-
def command(self, *cmds: int) -> None:
103-
self._i2c.write(self.addr, bytes([_CMD, *cmds]), self._bus)
106+
def command(self, *cmds: int, wait: bool = True) -> None:
107+
self._i2c.write(self.addr, bytes([_CMD, *cmds]), self._bus, wait=wait)
104108

105-
def _write_data(self, data: bytes) -> None:
106-
for off in range(0, len(data), _CHUNK):
107-
self._i2c.write(self.addr, bytes([_DATA]) + data[off : off + _CHUNK],
108-
self._bus)
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))
109115

110116
# ---- drawing ---------------------------------------------------------------
111117

112118
def show(self, image=None) -> None:
113119
"""Push a PIL image (mode '1' or anything convertible) to the panel."""
114120
if image is None:
115121
image = self._Image.new("1", (self.width, self.height))
116-
pix = image.convert("L").tobytes() # 1 byte/pixel, row-major
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
117129
low = 0x00 | (self.colstart & 0x0F)
118130
high = 0x10 | (self.colstart >> 4)
119-
for page in range(self.height // 8):
120-
self.command(0xB0 + page, low, high) # page + column start
121-
base = page * self.width * 8
122-
buf = bytearray(self.width)
123-
for x in range(self.width):
124-
byte = 0
125-
for bit in range(8):
126-
if pix[base + bit * self.width + x]:
127-
byte |= 1 << bit
128-
buf[x] = byte
129-
self._write_data(bytes(buf))
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)
130138

131139
@contextlib.contextmanager
132140
def draw(self):

src/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-esp-bridge"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
description = "Control every ESP32 peripheral from Python over USB serial — GPIO, ADC, DAC, PWM, touch, I2C, SPI, UART, Wi-Fi sockets, BLE"
55
readme = "README.md"
66
requires-python = ">=3.10"

tests/fake_firmware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def emit(self, cmd: int, payload: bytes = b"") -> None:
6363
def _info(self) -> bytes:
6464
nbytes = self.name.encode()
6565
return (
66-
bytes([self.proto_version, 0, 0, 1, C.ChipModel.ESP32, 3])
66+
bytes([self.proto_version, 0, 0, 2, C.ChipModel.ESP32, 3])
6767
+ bytes.fromhex(self.mac)
6868
+ struct.pack(">I", int(CAPS))
6969
+ bytes([40, 4])

tests/test_bridge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_handshake_parses_ready_banner(bridge):
1414
info = bridge.info
1515
assert info.chip is ChipModel.ESP32
1616
assert info.protocol == C.PROTOCOL_VERSION
17-
assert info.fw_version == (0, 0, 1)
17+
assert info.fw_version == (0, 0, 2)
1818
assert info.mac == "24:a1:60:12:34:56"
1919
assert Cap.DAC in info.caps and Cap.WIFI in info.caps
2020
assert info.gpio_count == 40

0 commit comments

Comments
 (0)