203 lines
7.0 KiB
Python
203 lines
7.0 KiB
Python
"""Standalone QR emitter for testing the Android reader on a real camera.
|
|
|
|
Replicates exactly what the plugin's QR export page puts on screen
|
|
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
|
|
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
|
|
|
|
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
|
|
PyQt6):
|
|
|
|
source "$BAL_HOME/electrum/env/bin/activate"
|
|
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
|
|
|
|
Controls:
|
|
Left/Right previous / next frame
|
|
Space toggle autoplay
|
|
L toggle loop (default off)
|
|
Q / Esc quit
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
|
|
import qrcode # noqa: E402
|
|
from PyQt6.QtCore import Qt, QTimer # noqa: E402
|
|
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
|
|
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
|
|
|
|
from bal.core import animated_qr as aq # noqa: E402
|
|
from bal.core import qrtransfer as qtf # noqa: E402
|
|
|
|
|
|
def build_frames(tx_strings, fmt, budget):
|
|
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
|
|
transfer = qtf.encode_transfer(tx_strings, compress=False)
|
|
if fmt == "balqr":
|
|
return qtf.split_frames(transfer, budget, compressed=False)
|
|
payload = transfer.encode("utf-8")
|
|
if fmt == "ur1":
|
|
return aq.ur1_frames(payload, budget)
|
|
if fmt == "ur2":
|
|
return aq.ur2_frames(payload, budget)
|
|
if fmt == "bbqr":
|
|
return aq.bbqr_frames(payload, budget, encoding="Z")
|
|
raise SystemExit("unknown format: {}".format(fmt))
|
|
|
|
|
|
def load_payload(args):
|
|
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
|
|
if args.will is not None:
|
|
will = json.loads(Path(args.will).read_text())
|
|
return (
|
|
[json.dumps(will, ensure_ascii=False)],
|
|
"whole-will JSON ({})".format(Path(args.will).name),
|
|
)
|
|
if args.txs is not None:
|
|
raw = Path(args.txs).read_text()
|
|
return [line.strip() for line in raw.split() if line.strip()], "txs"
|
|
raise SystemExit("give --will FILE or --txs FILE")
|
|
|
|
|
|
def qr_pixmap(text, size):
|
|
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
|
|
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
|
qr.add_data(text)
|
|
qr.make(fit=True)
|
|
matrix = qr.modules
|
|
n = len(matrix)
|
|
border = qr.border
|
|
scale = max(1, size // (n + 2 * border))
|
|
dim = (n + 2 * border) * scale
|
|
image = QImage(dim, dim, QImage.Format.Format_RGB32)
|
|
image.fill(Qt.GlobalColor.white)
|
|
painter = QPainter(image)
|
|
painter.fillRect(0, 0, dim, dim, QColor("white"))
|
|
painter.setBrush(QColor("black"))
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
for y, row in enumerate(matrix):
|
|
for x, on in enumerate(row):
|
|
if on:
|
|
painter.fillRect(
|
|
(x + border) * scale, (y + border) * scale, scale, scale,
|
|
QColor("black"),
|
|
)
|
|
painter.end()
|
|
return QPixmap.fromImage(image).scaled(
|
|
size, size, Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation,
|
|
)
|
|
|
|
|
|
class EmitterWindow(QMainWindow):
|
|
def __init__(self, frames, description, fmt, fps, loop):
|
|
super().__init__()
|
|
self.frames = frames
|
|
self.fps = fps
|
|
self.loop = loop
|
|
self.index = 0
|
|
self.autoplay = True
|
|
|
|
central = QWidget(self)
|
|
self.setCentralWidget(central)
|
|
self.pix = QLabel(central)
|
|
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.caption = QLabel(central)
|
|
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
|
|
|
|
v = qt.QVBoxLayout(central)
|
|
v.addWidget(self.pix, 1)
|
|
v.addWidget(self.caption)
|
|
|
|
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
|
|
self.resize(900, 1000)
|
|
|
|
self.timer = QTimer(self)
|
|
self.timer.timeout.connect(self._step)
|
|
self.timer.start(int(1000 / self.fps))
|
|
|
|
self._render()
|
|
self.caption.setText(
|
|
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
|
|
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
|
|
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
|
|
)
|
|
)
|
|
self.show()
|
|
|
|
def _render(self):
|
|
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
|
|
|
|
def _update_caption(self):
|
|
auto = "on" if self.autoplay else "off"
|
|
loop = "on" if self.loop else "off"
|
|
self.caption.setText(
|
|
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
|
|
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
|
|
)
|
|
|
|
def _step(self):
|
|
if self.index + 1 < len(self.frames):
|
|
self.index += 1
|
|
elif self.loop:
|
|
self.index = 0
|
|
else:
|
|
self.autoplay = False
|
|
self.timer.stop()
|
|
self._render()
|
|
self._update_caption()
|
|
|
|
def keyPressEvent(self, event): # noqa: N802 - Qt override name
|
|
key = event.key()
|
|
if key == Qt.Key.Key_Right:
|
|
self.autoplay = False
|
|
self.index = min(self.index + 1, len(self.frames) - 1)
|
|
self._render()
|
|
elif key == Qt.Key.Key_Left:
|
|
self.autoplay = False
|
|
self.index = max(self.index - 1, 0)
|
|
self._render()
|
|
elif key == Qt.Key.Key_Space:
|
|
self.autoplay = not self.autoplay
|
|
if self.autoplay:
|
|
self.timer.start(int(1000 / self.fps))
|
|
else:
|
|
self.timer.stop()
|
|
elif key == Qt.Key.Key_L:
|
|
self.loop = not self.loop
|
|
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
|
|
self.close()
|
|
self._update_caption()
|
|
|
|
|
|
def main(argv):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
|
|
default="balqr")
|
|
parser.add_argument("--budget", type=int, default=400)
|
|
parser.add_argument("--fps", type=float, default=1.0)
|
|
parser.add_argument("--loop", action="store_true")
|
|
parser.add_argument("--will", help="whole-will JSON file")
|
|
parser.add_argument("--txs", help="file with serialized tx strings")
|
|
args = parser.parse_args(argv)
|
|
|
|
tx_strings, description = load_payload(args)
|
|
frames = build_frames(tx_strings, args.format, args.budget)
|
|
if len(frames) == 1:
|
|
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
|
|
else:
|
|
print("{} frames ready".format(len(frames)), file=sys.stderr)
|
|
|
|
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
|
|
EmitterWindow(frames, description, args.format, args.fps, args.loop)
|
|
app.exec()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|