Skip to content

Commit b1c92d4

Browse files
BitHighlanderclaude
andcommitted
feat: OLED screenshot capture + test report generator
- Replace Pillow with pure Python PNG writer (stdlib struct+zlib, zero deps) - Move screenshot capture from call_raw to callback_ButtonRequest (captures actual confirmation screens, not idle state) - Per-test screenshot directories via KEEPKEY_SCREENSHOT=1 env var - Add scripts/generate-test-report.py: version-aware PDF report with pass/fail checkmarks, embedded OLED screenshots, human-readable context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 450ff17 commit b1c92d4

3 files changed

Lines changed: 1092 additions & 25 deletions

File tree

keepkeylib/client.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,20 @@
5555
from .debuglink import DebugLink
5656

5757

58-
try:
59-
from PIL import Image
60-
SCREENSHOT = os.environ.get('KEEPKEY_SCREENSHOT', '') == '1'
61-
except ImportError:
62-
SCREENSHOT = False
58+
import struct as _struct
59+
import zlib as _zlib
60+
61+
SCREENSHOT = os.environ.get('KEEPKEY_SCREENSHOT', '') == '1'
62+
63+
64+
def _write_png(path, width, height, pixels):
65+
"""Write a minimal grayscale PNG. No Pillow needed."""
66+
def _chunk(tag, data):
67+
raw = tag + data
68+
return _struct.pack('>I', len(data)) + raw + _struct.pack('>I', _zlib.crc32(raw) & 0xffffffff)
69+
ihdr = _struct.pack('>IIBBBBB', width, height, 8, 0, 0, 0, 0)
70+
raw_data = b''.join(b'\x00' + row for row in pixels)
71+
return b'\x89PNG\r\n\x1a\n' + _chunk(b'IHDR', ihdr) + _chunk(b'IDAT', _zlib.compress(raw_data)) + _chunk(b'IEND', b'')
6372

6473
DEFAULT_CURVE = 'secp256k1'
6574

@@ -423,25 +432,8 @@ def set_mnemonic(self, mnemonic):
423432

424433
def call_raw(self, msg):
425434

426-
if SCREENSHOT and self.debug:
427-
try:
428-
layout = self.debug.read_layout()
429-
if layout and len(layout) >= 2048:
430-
# KeepKey OLED: 256x64, packed as 1bpp (2048 bytes)
431-
im = Image.new("RGB", (256, 64))
432-
pix = im.load()
433-
for x in range(256):
434-
for y in range(64):
435-
byte_idx = x + (y // 8) * 256
436-
b = layout[byte_idx] if isinstance(layout[byte_idx], int) else ord(layout[byte_idx])
437-
if (b >> (y % 8)) & 1:
438-
pix[x, y] = (255, 255, 255)
439-
screenshot_dir = getattr(self, 'screenshot_dir', os.environ.get('SCREENSHOT_DIR', '.'))
440-
os.makedirs(screenshot_dir, exist_ok=True)
441-
im.save(os.path.join(screenshot_dir, 'scr%05d.png' % self.screenshot_id))
442-
self.screenshot_id += 1
443-
except Exception:
444-
pass # Don't let screenshot failures break tests
435+
# Screenshot capture disabled in call_raw (captures idle screens, adds latency).
436+
# Real confirmation screenshots are captured in callback_ButtonRequest instead.
445437

446438
resp = super(DebugLinkMixin, self).call_raw(msg)
447439
self._check_request(resp)
@@ -469,6 +461,29 @@ def callback_ButtonRequest(self, msg):
469461
if self.verbose:
470462
log("ButtonRequest code: " + get_buttonrequest_value(msg.code))
471463

464+
# Capture OLED screenshot BEFORE pressing button (confirmation screen)
465+
if SCREENSHOT and self.debug:
466+
try:
467+
layout = self.debug.read_layout()
468+
if layout and len(layout) >= 2048:
469+
rows = []
470+
for y in range(64):
471+
row = bytearray(256)
472+
for x in range(256):
473+
byte_idx = x + (y // 8) * 256
474+
b = layout[byte_idx] if isinstance(layout[byte_idx], int) else ord(layout[byte_idx])
475+
if (b >> (y % 8)) & 1:
476+
row[x] = 255
477+
rows.append(bytes(row))
478+
screenshot_dir = getattr(self, 'screenshot_dir', os.environ.get('SCREENSHOT_DIR', '.'))
479+
os.makedirs(screenshot_dir, exist_ok=True)
480+
png_path = os.path.join(screenshot_dir, 'btn%05d.png' % self.screenshot_id)
481+
with open(png_path, 'wb') as f:
482+
f.write(_write_png(png_path, 256, 64, rows))
483+
self.screenshot_id += 1
484+
except Exception:
485+
pass
486+
472487
if self.auto_button:
473488
if self.verbose:
474489
log("Pressing button " + str(self.button))

0 commit comments

Comments
 (0)