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
5354class 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 ):
0 commit comments