跳至内容
liuzhen932 的小窝
返回

逆向某错题打印机,实现自定义内容流式打印

啵哩口袋打印机(Poooli)是那种巴掌大、用热敏纸、靠蓝牙连接的口袋打印机,它官方只有手机 App,而我们都知道它的公司已经挂注销状态了,很明显印不了我自己的东西。所以我想把它接管过来:让电脑里的图片、文字,甚至一个 SSH 会话,都直接变成纸上的墨点

不过,这套协议没有公开文档,格式是一点点抓包猜出来的;下面先讲那段经历——怎么抓包、怎么撞协议、怎么验证猜想;挖出来的格式再单独整理一遍;最后看它在代码里长成什么样

0x00 逆向:协议是怎么抓出来的

遇事不决先逆向,看看这小东西是怎么实现的。

它到底是什么设备

协议长什么样,其实是硬件定死的:我的啵哩 L2 是一台 203dpi 的热敏打印机,不用墨水,靠加热头把纸烧黑,打印区固定 640 像素宽;这两个数字很关键——热敏意味着只有黑白两态,640 意味着每行的字节数是写死的

蓝牙那边走的是经典蓝牙,不是 BLE,上面跑 RFCOMM —— 这是一种串口仿真协议,默认 channel 1,这一点决定了协议的整体形态:没有 GATT 特征值可以寻址,「发一条指令」在物理层就是「往串口里写一串字节」,成帧的活儿得协议自己干

合起来看,它就是一台挂在蓝牙串口上、只会按行吃点阵的黑盒

从抓包开始

啵哩没有公开协议,官方 App 是唯一「会说话」的客户端;想让它说给我听,就得在它和打印机之间架一个中转站,整个过程分五步:录下来 → 剥到裸字节 → 认出握手 → 解开长度字段 → 认出压缩算法,下面按这个顺序来

第一步:想办法录下来

蓝牙 RFCOMM 是串口风格的字节流,Linux 上让 BlueZ 把 HCI 流量吐出来:

# 方式一:btmon 直接写 btsnoop,Wireshark 原生支持
sudo btmon --write ~/poooli.btsnoop

# 方式二:hcidump 打原始 hex,适合不喜欢 GUI 的 Geek
sudo hcidump --raw -i hci0 > poooli.raw

让蓝牙适配器进入监听模式的前提是它本身在工作(hciconfig 里能看到 UP RUNNING);如果手上只有 USB 蓝牙棒,也可以直接插一台能跑 BlueZ 的机器当中间人,让手机连它

录的时候要做的动作很固定,一次录一件事,方便后面按动作对齐:

  1. 打开官方 App,点「连接设备」
  2. 新建一张纯白图片,打印
  3. 新建一张左半黑、右半白的图片,打印
  4. 输入一行短文字,打印

四次动作录进四个文件,为什么要四份:纯白的用来找「压缩后极短」的行,左半黑的用来确定位序,文字的用来确认 ASCII 指令——对照组是逆向里最省时间的东西

第二步:剥到裸字节

.btsnoop 用 Wireshark 打开,从 HCI ACL → L2CAP → RFCOMM 一层层点进去;手动看太累,用 tshark 把 RFCOMM 的载荷直接导出来:

# 把 RFCOMM payload 导出成一行一串 hex
tshark -r ~/poooli.btsnoop \
       -Y 'btbrfcomm' \
       -T fields -e btbrfcomm.data 2>/dev/null \
  | tr -d ':' > frames.hex

(不同版本的 Wireshark 字段名可能略有差别,-G fields | grep rfcomm 查一下就有了)

导出来是一坨连续的 hex,写个小脚本把它还原成分帧的样子:

"""把 tshark 导出的 RFCOMM 载荷画成一棵「长度树」,先找固定前缀。"""

from collections import Counter
from pathlib import Path

raw = bytes.fromhex(Path("frames.hex").read_text().strip())

# 统计所有 4 字节窗口,出现次数远高于平均的那些就是候选帧头。
counts = Counter(raw[i:i + 4] for i in range(len(raw) - 4))
for window, count in counts.most_common(12):
    print(count, window.hex(" "))

第一次跑的输出(节选)大概是这样的:

41 10 7b 3d 3d
2  1b 1c 73 65
2  10 7e 68 79
...

10 7b 3d 3d 出现了 41 次,远超其它窗口——而纯白图正好 41 行,第一个结论立刻成立:10 7b 3d 3d 是每一行图像数据的开头

第三步:认出握手

另外两个高频窗口 1b 1c 73 6510 7e 68 79 只出现两次(四次动作各一次连接),说明它们是每次连接发一次的握手帧,把完整的几组抓出来对齐:

1b 1c 73 65 74 20 6d 6d 05 08      10 7e 68 79 7a ed 09
└────────────── 10 字节 ───────┘    └───── 7 字节 ─────┘

1b 1c 73 65 74 20 6d 6d 一眼可读:ESC FS + ASCII "set mm"PAPER_WIDTH 那条则完全是魔数,但它是定长、每次都在第二位、和「纸」有关——按位置和出现规律命名就够了

逆向时不用急着弄懂每条指令的含义,按「出现次数 + 位置」先起个工作名就行,名字写在注释里,等后面用代码验证过再定稿

第四步:解开长度字段

接下来是硬骨头,把每一行的信封切出来:

10 7b 3d 3d   a1 a2   b1 b2   c1 c2 c3 c4   <数据...>

一行数据的边界靠 10 7b 3d 3d 就能切,切完之后,观察后面那三个「长度」:

先量一下 c 后面实际的字节数,再和 c 的值放在一起看:

"""把每行的三个长度字段和真实数据长度列出来,找异或关系。"""

import struct
from pathlib import Path

raw = bytes.fromhex(Path("frames.hex").read_text().strip())
HEAD = bytes.fromhex("107b3d3d")

offset = 0
rows = []
while True:
    start = raw.find(HEAD, offset)
    if start < 0:
        break
    a, b, c = struct.unpack_from("<HHI", raw, start + 4)
    rows.append((a, b, c))
    offset = start + 12          # 先按 12 字节头跳,数据部分靠人工对齐

for a, b, c in rows[:8]:
    print("a=%#06x b=%#06x c=%#010x" % (a, b, c))

输出(节选,纯白图的几行):

a=0x0d5d b=0x0d0c c=0x0d0d0d1f
a=0x0d5d b=0x0d0c c=0x0d0d0d14
a=0x0d5d b=0x0d0c c=0x0d0d0d1a

ab 是定值,c 的高两位 0d 0d 也几乎不动——这就是掩码的味道,拿几个已知值试异或:

for mask in (0x0d, 0x0d0d, 0x0d0d0d0d):
    print(hex(mask), [hex(v ^ mask) for v in (0x0d5d, 0x0d0c, 0x0d0d0d1f)])
0xd      [0xd50, 0xd01, 0xd0d0d12]
0xd0d    [0x50, 0x1, 0x0d0d0012]
0xd0d0d0 [0xd50, 0xd01, 0x12]

第二列是最漂亮的:0x0d5d ^ 0x0d0d = 0x50 = 800x0d0c ^ 0x0d0d = 1,一个正好是 640 / 8,一个正好是「一行」——掩码就是 0x0d0d,长度字段用它,四字节长度字段用 0x0d0d0d0d

结论立刻闭环:三个字段 = 宽度 ^ 0x0d0d、行数 ^ 0x0d0d、压缩长度 ^ 0x0d0d0d0d

顺便,c 解开之后就能精确切出每帧的数据段了——把循环里的 offset 从「+12 硬跳」改成「按长度跳」,整段流可以完整解完:

offset = start + 12
length = c ^ 0x0D0D0D0D
payload = raw[offset:offset + length]
offset += length

第五步:认出压缩算法

payload 现在是一段二进制,它有几个很明显的特征:

这是压缩的典型特征:冗余越多,输出越短——加密不会这样

拿压缩库一个个试,第一个想到的是 zlib,因为 Python 自带:

import zlib
for data in payloads:
    try:
        print(zlib.decompress(data ^ 0x0d))
    except zlib.error as error:
        print("不是 zlib:", error)

全军覆没,第二个试 LZO——小票机、打印机、嵌入式设备上用 LZO 的非常多:

import lzo

row = bytes(b ^ 0x0D for b in payload)     # 先把那层异或剥掉
print(lzo.decompress(row, False))          # False = 不带头部

一行 80 字节的原始数据原样还原,和「左半黑右半白」的测试图对得上:

b'\xff' * 40 + b'\x00' * 40

0xff = 8 个黑点,40 字节 = 左半 320 像素黑;右半全白 0x00);位序、黑点、压缩算法、异或掩码,一次全部验证通过

把结论固化成一张对照表

逆向做完,把每一步的猜测和验证结果整理成表,这张表后来直接变成了代码里的常量:

观察猜测验证方式结论
10 7b 3d 3d 出现 41 次行头行数 == 图片高度BMP
1b 1c 73 65 74 20 6d 6d初始化每次连接首条INITIALIZE
10 7e 68 79 7a ed 09纸宽每次连接第二条PAPER_WIDTH
a=0x0d5d宽度 ^ 掩码^ 0x0d0d = 80640/8
b=0x0d0c行数 ^ 掩码^ 0x0d0d = 1✅ 逐行发
c 与数据长度差固定长度 ^ 掩码^ 0x0d0d0d0d 后相符
payload 冗余越多越短压缩LZO1X-1 解压成功
16 16 0c 57 0d走纸每页末尾出现一次LINE_FEED

这张表就是协议文档的初稿——代码里的四行常量、一个信封格式,全部来自它

抓包阶段的三个坑

当时踩过的坑比结论更值得记一笔:

  1. 不要相信「长度字段看起来像长度」:第一版我直接按 c 的值去切数据,结果每切一行错位一格,整段流越解越乱;后来意识到 c 被异或过,用掩码解出来才对齐
  2. 不要跳过握手:握手两条指令和图像帧的编码风格不一样(握手是字面量,图像帧是长度 + 压缩),如果只盯着图像帧,会以为协议全是二进制的
  3. 对照组比猜测快十倍:纯白图、半黑图、文字,三张图一对比,压缩率、位序、行数三个问题一次性排掉;这比反复对着 App 点「打印」猜要高效得多

到这里,格式已经齐了,下面把它整理成一份干净的说明

0x01 协议

抓包的结论整理出来,就是下面这套格式;它算不上复杂:四种帧、一个信封格式、一层异或,再加一次 LZO 压缩

四种帧

把整段流量按功能拆开,一共就四种帧:

字节作用
INITIALIZE1b 1c 73 65 74 20 6d 6d 05 08建立连接后第一条,设置打印模式
PAPER_WIDTH10 7e 68 79 7a ed 09第二条,告诉它纸宽
BMP10 7b 3d 3d每一行图像数据的开头
LINE_FEED16 16 0c 57 0d全部发完后走纸

INITIALIZE 一眼能认出一半:1b 1cESC FS(小票外设常用的转义前缀),后面干脆是字面量 set mm——73 65 74 20 6d 6d 就是 ASCII 的 "set mm",这类「半明文 + 半魔数」的指令在这个协议里到处都是

在代码里它们就是四个常量,没有任何解释层:

class Poooli:
    INITIALIZE = b"\x1b\x1cset mm\x05\x08"
    PAPER_WIDTH = b"\x10\x7e\x68\x79\x7a" + b"\xed\x09"
    BMP = b"\x10\x7b\x3d\x3d"
    LINE_FEED = b"\x16\x16\x0c\x57\x0d"
    WIDTH = 640

连接建立后的握手就是「把前两条按顺序发出去」,仅此而已:

def connect(self, mac, channel=1):
    LOG.debug("socket.connect(%s, %d)", mac, channel)
    started = time.monotonic()
    self.socket.connect((mac, channel))
    LOG.info("已连接,用时 %.2f", time.monotonic() - started)
    self.socket.send(Poooli.INITIALIZE)
    self.socket.send(Poooli.PAPER_WIDTH)
    LOG.debug("握手已发送:%s", (Poooli.INITIALIZE + Poooli.PAPER_WIDTH).hex(" "))

注意 connect 里没有任何「等应答」的逻辑,这也是抓包看出来的:打印机对握手不回复,它是单向的,发完就认

每一行数据的信封

图像是按行发的,一行一个信封,格式固定:

10 7b 3d 3d   5d 0d   0c 0d   ZZ ZZ ZZ ZZ   <压缩数据>
└─ BMP 头 ──┘  └宽度┘  └行数┘  └─ 压缩长度 ─┘

四段的含义:

三个长度字段全部用 0x0d 重复填满做异或,这是协议里唯一的「混淆层」;它不是加密,更像随手加的偏置——但没猜到它,抓包看到的长度字段永远是乱的

一行到底多少字节,这个数必须算明白

640 像素、每字节 8 个像素,所以 640 / 8 = 80,但协议文档(包括这个仓库早期的 docs/poooli.md)里写着「每行 48 字节」——这是错的;48 是抓包时常见的压缩后长度,被误当成了宽度字段;真正的宽度字段是 80,异或 0x0d0d 之后是 5d 0d

80        = 0x0050
0x0050 ^ 0x0d0d = 0x0d5d   → 小端两根字节:5d 0d
1         = 0x0001
0x0001 ^ 0x0d0d = 0x0d0c   → 0c 0d

所以每一行的信封实际是:

10 7b 3d 3d   5d 0d   0c 0d   ZZ ZZ ZZ ZZ   <压缩数据>
└─ BMP 头 ──┘  └宽度┘  └行数┘  └─ 压缩长度 ─┘
宽度 = 80 ^ 0x0d0d = 0x0d5d
行数 = 1  ^ 0x0d0d = 0x0d0c

有个地方容易看走眼:协议里的「宽度」说的是这一行的字节数,它恰好等于 纸宽 / 8;两者混起来看很容易把常量写错,我在最初那版里就照着那段文档把 48 写死了,结果打印机接收帧后错位、整张图扭曲;后来改成从 Poooli.WIDTH // 8 算,这个 bug 才彻底消失

位序与黑点

位图打包的规则有两条,都必须和打印机一致:

代码里就是这三行:

for bit in range(8):
    if image.getpixel((ix * 8 + bit, iy)) == 0:
        byte |= 1 << (7 - bit)

如果位序反了,印出来的就是一张镜像的乱码——能看出一点结构,但完全认不出内容;如果黑白反了,照片会变成底片;这两个错误在调试期各出现过一次,都是靠打印一张左边半黑、右边半白的测试图定位的

LZO:为什么是它,怎么用

点阵本身非常冗余:一整行 80 字节里,白纸部分全是 0x00;不做压缩的话,一张 640×800 的图就是 64KB 原始数据,走蓝牙要磨很久,所以协议用了 LZO1X-1

选择理由也很朴素:LZO 解压极快、内存占用小,非常适合单片机;打印机端解压,发送端压缩

关键是用哪种模式:LZO 有很多入口,协议要的是「裸块、无头部」的那一种:lzo1x_1_compress,python-lzo 里对应:

PYTHON_LZO.compress(image_bytes, 1, False)
#                                │  └─ False = 不带头部(裸块)
#                                └──── 1 = lzo1x_1

第三参数如果填 True,会在前面加一段 LZO 自己的头,打印机直接不认;这个参数也是抓包对比出来的:带头的输出比实际发出去的多几个字节,且首字节对不上

压缩完还要做两次异或:

lzno_xor = (len(lzno) ^ LENGTH_XOR).to_bytes(4, "little")   # 长度,小端 4 字节
image_lzo_xor = b"".join((b ^ BYTE_XOR).to_bytes(1, "little") for b in lzno)

LENGTH_XOR = 0x0D0D0D0DBYTE_XOR = 13(也就是 0x0d),同一套掩码,发射顺序是「长度在前、数据在后」,长度本身也异或过

收尾

所有行发完后单独补一条 LINE_FEED16 16 0c 57 0d)走纸:

self.socket.send(Poooli.LINE_FEED)
LOG.debug("送纸指令:%s", Poooli.LINE_FEED.hex(" "))

没有它,纸就停在打印头下面没切出来;多页打印时,每页末尾都会来一次,所以「页面数量」是可以直接数 LINE_FEED 的——测试就是这么数页数的

协议全景(一张图看完)

PC                                    啵哩打印机
│                                          │
├─ connect((mac, 1)) ──── RFCOMM ─────────►│
│                                          │
├─ INITIALIZE (1b 1c "set mm" 05 08) ─────►│   握手:不回复
├─ PAPER_WIDTH (10 7e 68 79 7a ed 09) ────►│
│                                          │
│   ┌─ 第 0 行 ────────────────────────┐   │
├───┤ 10 7b 3d 3d 5d 0d 0c 0d L L L L  ├──►│   80 字节点阵
│   │ <LZO(80B) ^ 0x0d,长度 ^ 0x0d0d0d0d>│   │
│   └──────────────────────────────────┘   │
│              ... 第 1..N-1 行 ...        │
│                                          │
├─ LINE_FEED (16 16 0c 57 0d) ────────────►│   走纸
│                                          │
├─ 关闭 socket ───────────────────────────►│

协议本身就这么点东西,四种帧、一个信封格式,抄在一张便签上都写得下;后面所有的复杂度,都在回答同一个问题:怎么把想印的内容变成这 80 字节一行

0x02 代码展示

下面把每一层真正落地的代码贴出来,顺序顺着数据流走:图片 → 位图 → LZO → 蓝牙帧 → 文本排版 → ssh 镜像 → CLI → 自检 → 打包 → CI

代码来自仓库,只做了必要的裁剪(省略与主题无关的分支)

0x03 协议层:组帧

poooli.py 是协议的唯一出口,核心是 _send_image:把一张二值图逐行打包、压缩、异或、发出去;这个函数是整个项目的「心脏」,也几乎是逆向结论的逐字翻译

def _send_image(self, image):
    width_bytes = Poooli.WIDTH // 8
    started = time.monotonic()
    raw_total = 0
    compressed_total = 0
    progress_step = max(1, image.height // 10)
    LOG.info("开始发送:%d 行 × %d 字节/行", image.height, width_bytes)
    for iy in range(image.height):
        image_bytes = b""
        for ix in range(width_bytes):
            byte = 0
            for bit in range(8):
                if image.getpixel((ix * 8 + bit, iy)) == 0:
                    byte |= 1 << (7 - bit)
            image_bytes += byte.to_bytes(1, "little")
        lzo_image, lzono = Poooli.compress_to_lzo(image_bytes)
        self.socket.send(Poooli.BMP)
        self.socket.send(
            (width_bytes ^ (LENGTH_XOR & 0xFFFF)).to_bytes(2, "little")
        )
        self.socket.send((1 ^ (LENGTH_XOR & 0xFFFF)).to_bytes(2, "little"))
        self.socket.send(lzono)
        self.socket.send(lzo_image)
        raw_total += len(image_bytes)
        compressed_total += len(lzo_image)
        if iy == 0:
            LOG.debug("第 1 行原始数据:%s", image_bytes.hex(" "))
        LOG.debug(
            "%d/%d 行:%d 字节 -> LZO %d 字节,发送 %d",
            iy + 1,
            image.height,
            len(image_bytes),
            len(lzo_image),
            5,
        )
        if (iy + 1) % progress_step == 0 and iy + 1 < image.height:
            LOG.info(
                "进度 %d%%%d/%d 行,已发 %d 字节),用时 %.1f",
                100 * (iy + 1) // image.height,
                iy + 1,
                image.height,
                compressed_total,
                time.monotonic() - started,
            )
    self.socket.send(Poooli.LINE_FEED)
    LOG.debug("送纸指令:%s", Poooli.LINE_FEED.hex(" "))
    LOG.info(
        "发送完成:%d 行,原始 %d 字节,LZO 后 %d 字节(%.0f%%),用时 %.1f",
        image.height,
        raw_total,
        compressed_total,
        100.0 * compressed_total / raw_total if raw_total else 0.0,
        time.monotonic() - started,
    )

有三处值得琢磨:

压缩 + 异或是最小的一个函数:

@staticmethod
def compress_to_lzo(image_bytes):
    """LZO1X-1 compress one row and obfuscate it the way the printer wants.

    Returns the compressed bytes XORed with 0x0d and the compressed length
    XORed with 0x0d0d0d0d.
    """
    if PYTHON_LZO is not None:
        lzno = PYTHON_LZO.compress(image_bytes, 1, False)
    else:
        lzno = _compress_with_ctypes(image_bytes)
    lzno_xor = (len(lzno) ^ LENGTH_XOR).to_bytes(4, "little")
    image_lzo_xor = b""  # compressed data
    for ib in range(len(lzno)):
        image_lzo_xor += (lzno[ib] ^ BYTE_XOR).to_bytes(1, "little")
    return image_lzo_xor, lzno_xor

两个常量放在模块顶部,含义用注释锁死:

BYTE_XOR = 13                      # 0x0d,数据段逐字节异或
LENGTH_XOR = 0x0D0D0D0D            # 长度字段异或(低 2 字节给行宽/行数用)
WORK_MEMORY_SIZE = 16384 * 8       # ctypes 那条路要一块工作内存

0x04 图像层:把任何东西变成 640 宽二值图

协议只要一种输入:640 宽的单色位图,prepare_image 负责把任意 PIL 图片变成它

def prepare_image(
    self, image, mode="bnw_dither", contrast=1.0, brightness=1.0, rotate=True
):
    """把内存里的 PIL 图片处理成打印机要的 1 位位图。

    rotate=True 时横图会自动转 90°(照片的习惯);文本排版渲染出来的位图
    传 rotate=False,否则整页文字会被竖过来。
    """
    LOG.debug("处理图片:%s %s", image.size, image.mode)
    image = image.convert("RGBA")
    if brightness != 1.0:
        image = ImageEnhance.Brightness(image).enhance(brightness)
        LOG.debug("亮度 ×%.2f", brightness)
    if contrast != 1.0:
        image = ImageEnhance.Contrast(image).enhance(contrast)
        LOG.debug("对比度 ×%.2f", contrast)
    if rotate and image.width > image.height:
        image = image.rotate(90, expand=True)
        LOG.debug("横图转 90°:%s", image.size)
    height = int(image.height * Poooli.WIDTH / image.width)
    if image.size != (Poooli.WIDTH, height):
        # 已经是对应宽度的位图(文本页就是)就直接用:Pillow 的 resize()
        # 即使尺寸不变也会重采样,会把已经二值化的点阵弄脏。
        image = image.resize(size=(Poooli.WIDTH, height))
        LOG.debug("等比缩放到 %s", image.size)
    else:
        LOG.debug("宽度已经是 %dpx,不缩放", Poooli.WIDTH)
    if mode == "bnw_dither":
        image = image.convert(mode="1")
    elif mode == "bnw":
        image = image.convert(mode="1", dither=Image.Dither.NONE)
    else:
        raise ValueError("Unknown mode: " + mode)
    LOG.info(
        "图像处理完成:%dx%d 单色位图(%s),黑色像素 %.1f%%",
        image.width,
        image.height,
        mode,
        Poooli.black_ratio(image),
    )
    return image

这里藏着四个「后来才补上」的细节:

  1. 横图转 90°:照片是横的,纸是竖的;不转的话,一张 4:3 的照片会被压成一条细长的东西,人脸都认不出来;转过去之后再等比缩放到 640 宽,高度自动算
  2. 已经是 640 宽就不 resize:Pillow 的 resize() 就算目标尺寸和当前一样,也会跑一遍重采样滤波器;对已经二值化的文本页来说,这一跑会把干净的边缘弄出灰边,再二值化就变成毛刺——所以加了个 image.size != (WIDTH, height) 的判断,文本页直接从这条路跳过
  3. 两种二值化模式bnw_dither 抖动,照片过渡自然;bnw 纯阈值,文字锐利
  4. rotate 参数:文本页传 False,否则整页文字会被竖过来

黑点占比是个很实用的小工具,用来一眼判断「是不是整片糊黑 / 全白」:

@staticmethod
def black_ratio(image):
    """黑色像素占比,用来判断是不是整片糊黑 / 全白。"""
    total = image.width * image.height
    if not total:
        return 0.0
    histogram = image.histogram()
    return 100.0 * histogram[0] / total

对外暴露三个入口,覆盖「文件 / 内存图片 / 字节流」三种来源:

def send_image(self, file, mode="bnw_dither", contrast=1.0, brightness=1.0):
    self._send_image(self.process_image(file, mode, contrast, brightness))

def send_image_object(self, image, mode="bnw_dither", contrast=1.0, brightness=1.0):
    """发送内存里的 PIL 图片:文本排版渲染出来的位图走这条路。"""
    self._send_image(
        self.prepare_image(image, mode, contrast, brightness, rotate=False)
    )

def send_image_bytes(self, image_bytes, mode="bnw_dither", contrast=1.0, brightness=1.0):
    image = Image.open(io.BytesIO(image_bytes))
    self._send_image(self.prepare_image(image, mode, contrast, brightness))

0x05 LZO 双后端:让用户机器上「什么都没装」也能跑

这是我在打包阶段花时间最多的一块,目标很明确:打出来的程序,用户双击就能用,不需要装 python-lzo,也不需要装 liblzo2

策略是两条路:

  1. python-lzo 存在就用它(Windows 轮子自带静态 LZO,Linux 上编译时链系统 liblzo2);
  2. 没有就退回 ctypes 动态加载系统库
try:
    # python-lzo ships its own copy of LZO, which keeps the packaged program
    # self contained (on Windows it is a single statically linked extension).
    import lzo as PYTHON_LZO
except ImportError:
    PYTHON_LZO = None

ctypes 那条路要按优先级找库文件,从最具体的地方开始:

def lzo_max_output_size(size):
    """Worst case size of a lzo1x_1 compressed block."""
    return size + size // 16 + 64 + 3

def _lzo_library_candidates():
    """Places to look for a liblzo compatible library, most specific first."""
    names = ("liblzo2.so.2", "liblzo2.so", "lzo2.dll", "minilzo.dll")
    directories = []
    bundle_dir = getattr(sys, "_MEIPASS", None)
    if bundle_dir:  # PyInstaller onefile unpack dir
        directories.append(bundle_dir)
    directories.append(os.path.dirname(os.path.abspath(__file__)))
    candidates = [os.getenv("MINILZO_PATH")]
    for directory in directories:
        candidates += [os.path.join(directory, name) for name in names]
    candidates += [
        ctypes.util.find_library("lzo2"),
        ctypes.util.find_library("minilzo"),
    ]
    return candidates

lzo_max_output_size 那个公式来自 LZO 文档的最坏情况上界,缓冲区按它开就不会溢出;sys._MEIPASS 是 PyInstaller 单文件运行时的解包目录——打包后的 DLL 就躺在那里

加载失败要静默跳过,候选列表里必然有一些不存在,抛错就没法接着往下找了:

def _load_lzo_library():
    for candidate in _lzo_library_candidates():
        if not candidate:
            continue
        try:
            return ctypes.cdll.LoadLibrary(candidate)
        except OSError:
            continue
    return None

真正的调用最容易写错,下面这个坑要到 64 位上才暴露:

def _compress_into(image_bytes, source, work_memory):
    # create_string_buffer() appends a NUL terminator, so the row length has to
    # be passed explicitly: the printer expects exactly one line.
    source_length = ctypes.c_size_t(len(image_bytes))
    destination = ctypes.create_string_buffer(lzo_max_output_size(len(image_bytes)))
    # lzo_uint is a size_t; a c_int would be written past on 64-bit platforms.
    destination_length = ctypes.c_size_t(len(destination))
    LZO.lzo1x_1_compress(
        source,
        source_length,
        destination,
        ctypes.byref(destination_length),
        work_memory,
    )
    return destination.raw[: destination_length.value]

这三个细节全都是踩出来的:

工作内存的管理:

LZO = None if PYTHON_LZO else _load_lzo_library()
_work_memory = None

def _compress_with_ctypes(image_bytes):
    global _work_memory
    if LZO is None:
        raise RuntimeError(
            "no LZO backend available: install python-lzo or point MINILZO_PATH "
            "at a liblzo2/minilzo shared library"
        )
    if _work_memory is None:
        _work_memory = ctypes.create_string_buffer(WORK_MEMORY_SIZE)
    source = ctypes.create_string_buffer(image_bytes)
    return _compress_into(image_bytes, source, _work_memory)

「两个后端都没有」时的报错信息里带了 MINILZO_PATH,这是给打包后的现场排查留的:用户可以把一个 minilzo.dll 放在旁边,用环境变量指过去

0x06 传输层:连不上时到底发生了什么

协议对了不代表连得上;蓝牙连接失败的花样很多:没配对、没开机、被手机占着、channel 不对;poooli_cli/transport.py 负责建 socket、重试

第一件事就是绕开 pybluez2 的一个坑:

def rfcomm_socket(bluetooth):
    """新建一个 RFCOMM 套接字。

    pybluez2 0.46 的 native_socket 拿 btcommon.Protocols 枚举成员当协议表的键,
    而 bluetooth.RFCOMM 只是整数常量 3,于是
    BluetoothSocket(bluetooth.RFCOMM) 会抛 KeyError: 3。优先传枚举成员,
    拿不到就退回构造函数默认值(同样是 RFCOMM)。
    """
    protocols = getattr(getattr(bluetooth, "btcommon", None), "Protocols", None)
    rfcomm = getattr(protocols, "RFCOMM", None)
    if rfcomm is not None:
        try:
            return bluetooth.BluetoothSocket(rfcomm)
        except KeyError:
            pass
    return bluetooth.BluetoothSocket()

这行代码让我卡了半天:文档里的写法 bluetooth.BluetoothSocket(bluetooth.RFCOMM) 在 0.46 上直接 KeyError: 3;原因是它的协议表用枚举成员当键,而 bluetooth.RFCOMM 是个整数;所以这里「优先枚举、连不上退回默认」

超时设置也是必须的:

def open_bluetooth_socket(bluetooth, args, interrupt):
    sock = rfcomm_socket(bluetooth)
    interrupt.watch(sock)
    if args.timeout > 0:
        sock.settimeout(args.timeout)
        LOG.debug(
            "socket 超时 %.1f 秒(阻塞调用会定期返回,Ctrl+C 才能及时生效)",
            args.timeout,
        )
    else:
        LOG.debug("socket 不设超时;真被系统调用卡住只能靠退出兜底定时器")
    return sock

为什么强调超时:不设超时的阻塞调用会把 Ctrl+C 一起吞掉connect() 卡在内核里的时候,Python 要等它返回才能跑信号处理器;设了超时,调用定期返回,中断才有机会生效

重试循环里有一条很容易忽略的优先级:中断不算连接失败

def connect_printer(bluetooth, args, interrupt):
    """连上打印机(按 args.retries 重试),返回 (socket, Poooli)。"""
    attempts = max(1, args.retries)
    for attempt in range(1, attempts + 1):
        sock = open_bluetooth_socket(bluetooth, args, interrupt)
        printer = Poooli(sock)
        try:
            LOG.info("连接 %s (channel %d) ...", args.mac, args.channel)
            printer.connect(args.mac, args.channel)
            return sock, printer
        except OSError as error:
            close_quietly(sock)
            interrupt.watch(None)
            if interrupt.requested:
                raise KeyboardInterrupt  # 是 Ctrl+C / EOF 打断的,不算连接失败
            if attempt == attempts:
                raise  # 最后一次交给 commands 统一汇报
            LOG.error(
                "连接失败(第 %d/%d 次):%s",
                attempt,
                attempts,
                describe_bluetooth_error(error),
            )
            if args.diagnose and attempt == 1:
                diagnose_connect(bluetooth, args.mac)  # 诊断一次就够,别拖慢重试
            LOG.info("等待 %.1f 秒后重试 ...", args.retry_delay)
            time.sleep(args.retry_delay)
    raise AssertionError("unreachable")  # pragma: no cover

if interrupt.requested: raise KeyboardInterrupt 这行看着多余,其实很关键:用户按了 Ctrl+C,connect 抛出来的也是一个 OSError(超时或中断);如果当成「连接失败」去重试,用户按了三次 Ctrl+C 都退不出来

诊断模块把 Winsock 错误码翻译成人话:

WINERROR_HINTS = {
    10038: "socket 已经关了(多半是刚收到 Ctrl+C / EOF,正在退出)",
    10048: "地址已被占用:上一次连接可能没断干净,等几秒或重启打印机",
    10049: "地址不可用:检查 -m 里的 MAC 地址有没有写错",
    10050: "蓝牙适配器不可用:先确认 Windows 的蓝牙是打开的",
    10051: "Windows 到不了这个设备。常见原因:打印机没开机/不在范围、还没在系统里配对、"
    "或者正被手机 App 连着(啵哩一次只接受一个连接)",
    10060: "连接超时:设备在范围内但没应答,多半是没配对,或者 channel 不对",
    10061: "连接被拒绝:打印机可能正被别的设备连着,或者 channel 不对(换 -c 2 / -c 3 试试)",
    10064: "目标设备已关闭或暂时不可达",
    10065: "主机不可达:打印机没开机、不在范围或已休眠",
}

CONNECT_CHECKLIST = (
    "打印机开机、亮灯;手机 App / 其他电脑先断开(啵哩一次只接一个连接)",
    "在 Windows「设置 → 蓝牙和其他设备」里把打印机配对好,未配对时 RFCOMM 连不上",
    "poooli.exe --list 里要能看到这个 MAC;扫不到就是没开机 / 不在范围 / 地址不对",
    "换 channel 试试(-c 2 / -c 3);加 -v 看完整日志",
)

def describe_bluetooth_error(error):
    """把 Winsock 错误码翻成能照着做的提示。"""
    message = "%s" % error
    hint = WINERROR_HINTS.get(getattr(error, "winerror", None))
    return "%s%s" % (message, hint) if hint else message

连接失败一次之后,还会做一次现场排查,用来区分「扫不到设备」(没开机 / 没配对)、「扫得到但没有 SPP 服务」(多半是 BLE 设备)、「有服务却连不上」(被占用 / channel 不对):

def diagnose_connect(bluetooth, mac):
    """连接失败后的现场诊断;这里的异常都自己吞掉,免得盖住原始错误。"""
    LOG.info("开始诊断 %s ...", mac)
    try:
        name = bluetooth.lookup_name(mac, timeout=5)
    except Exception as error:  # noqa: BLE001 - 后端异常五花八门
        LOG.warning("名称查询失败:%s", error)
    else:
        LOG.info(
            "名称查询:%s", name or "没应答(没开机 / 不在范围 / 已被别的设备占用)"
        )

    try:
        devices = bluetooth.discover_devices(duration=4, lookup_names=True)
    except Exception as error:  # noqa: BLE001
        LOG.warning("设备扫描失败:%s", error)
    else:
        found = [device for device in devices if device[0].upper() == mac.upper()]
        LOG.info(
            "扫描到 %d 个设备;%s",
            len(devices),
            "其中包含目标 %s" % (found[0],)
            if found
            else "没有目标 %s(已配对的设备不一定会出现在扫描结果里)" % mac,
        )

    try:
        services = bluetooth.find_service(address=mac)
    except Exception as error:  # noqa: BLE001
        LOG.warning("SDP 服务查询失败:%s", error)
    else:
        if not services:
            LOG.warning(
                "SDP 没查到服务:设备没配对 / 不在范围,或者根本不是经典蓝牙"
                "(BLE 设备不能用 RFCOMM)"
            )
        for service in services:
            LOG.info(
                "SDP 服务:name=%s protocol=%s port=%s classes=%s",
                service.get("name"),
                service.get("protocol"),
                service.get("port"),
                service.get("service-classes"),
            )
    LOG.info("诊断结束")

0x07 文本:把一串字排成纸

文本是这个项目第二个大功能;它要解决的是排版,画字只是其中一步:中文没有空格怎么断行、标点不能掉行首、找不到中文字体怎么办、内容太长怎么分页

拆成四个模块:fonts 找字体、layout 折行分页、render 画位图、dump 存图调试

找字体

字体不能写死;Windows 上有微软雅黑,Linux 上多半是 Noto CJK,macOS 上是 PingFang;所以每个平台排一份候选表:

CANDIDATE_FONTS = {
    "win32": (
        "msyh.ttc",  # 微软雅黑,带中文
        "msyhbd.ttc",
        "simhei.ttf",  # 黑体
        "simsun.ttc",  # 宋体
        "Deng.ttf",  # 等线
        "consola.ttf",  # 等宽
        "segoeui.ttf",
        "arial.ttf",
        "cour.ttf",
    ),
    "darwin": (
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc",
        "/System/Library/Fonts/Supplemental/Songti.ttc",
        "/Library/Fonts/Arial.ttf",
        "/System/Library/Fonts/Menlo.ttc",
    ),
    "linux": (
        "NotoSansCJK-Regular.ttc",
        "NotoSansCJKsc-Regular.otf",
        "wqy-zenhei.ttc",
        "wqy-microhei.ttc",
        "SourceHanSansSC-Regular.otf",
        "DejaVuSansMono.ttf",
        "DejaVuSans.ttf",
    ),
}

搜索是按优先级来的,没有用「找到哪个算哪个」:_search 拿文件名当下标排名,找到更靠前的就替换,rank 0 直接提前返回

def _search(directory, names):
    """在目录里递归找候选字体,按 names 的顺序优先,找到第一个就返回。"""
    wanted = {name.lower(): rank for rank, name in enumerate(names)}
    best = None
    for root, _dirs, files in os.walk(directory):
        for name in files:
            rank = wanted.get(name.lower())
            if rank is None:
                continue
            if best is None or rank < best[0]:
                best = (rank, Path(root, name))
                if rank == 0:
                    return best[1]
    return best[1] if best is not None else None

加载失败绝不静默:

def load_font(path=None, size=32):
    """加载字体。找不到系统字体时退回 Pillow 内置字体并告警。"""
    if path:
        if not Path(path).is_file():
            raise FileNotFoundError("找不到字体文件:%s" % path)
        font = ImageFont.truetype(str(path), size)
        LOG.debug("使用指定字体:%s", describe_font(font))
        return font

    found = find_font_file()
    if found is not None:
        font = ImageFont.truetype(str(found), size)
        LOG.debug("自动选中字体:%s", describe_font(font))
        return font

    LOG.warning(
        "没找到系统字体,退回 Pillow 内置字体:中文会渲染成方块,"
        "请用 --font 指定一个 .ttf/.ttc(Windows 上例如 C:\\Windows\\Fonts\\msyh.ttc)"
    )
    try:
        return ImageFont.load_default(size=size)
    except TypeError:  # 老 Pillow 的 load_default() 不收 size
        return ImageFont.load_default()

这条 WARNING 很重要;Pillow 内置字体只有拉丁字符,中文会渲染成方块,但程序不会报错——用户只会看到一张印满方块的纸,然后来问为什么;所以宁可吵一点,也要在日志里说清楚

折行:中文禁则

折行的核心矛盾:中文没有空格,不能按词断行,只能按字断;但按字断会出问题:标点跑到行首( 单独占一行)非常难看;所以要做「禁则」

先把文本切成断行单元;规则是:中文单字一个单元,拉丁词一个单元,空格跟着前一个:

def is_wide(char):
    """东亚全角字符(可以逐字断行)。"""
    return unicodedata.east_asian_width(char) in ("W", "F")

def tokens(paragraph):
    """把一段话切成断行单元:中文单字一个单元,拉丁词一个单元,空格跟着前一个。"""
    result = []
    for char in paragraph:
        if char == " ":
            if result and not result[-1].endswith(" "):
                result[-1] += " "
            else:
                result.append(" ")
        elif (
            result
            and result[-1][-1] != " "
            and not is_wide(char)
            and not is_wide(result[-1][-1])
        ):
            result[-1] += char
        else:
            result.append(char)
    return result

判定用的是 east_asian_width,不是「是不是中文」——日文、韩文、全角标点都会落在 W/F 里,一并处理掉了

然后是折行主体,禁则就体现在两个 continue 上:

# 不能出现在行首的标点(简单版禁则)。
NO_LINE_START = ",。、;:?!)】》」』%,.;:?!)]}>"
NO_LINE_END = "(【《「『([{<"

def wrap_paragraph(paragraph, font, max_width):
    """把一段(不含换行的)文本折成若干行。"""
    lines = []
    current = ""
    for token in tokens(paragraph):
        if token and font.getlength(token) > max_width:
            if current:
                lines.append(current.rstrip())
            pieces = _hard_split(token, font, max_width)
            lines.extend(pieces[:-1])
            current = pieces[-1]
            continue
        if token in NO_LINE_START and current:
            current += token  # 标点不给放行首
            continue
        candidate = current + token
        if current and font.getlength(candidate.rstrip()) > max_width:
            if current[-1] in NO_LINE_END:  # 左括号不给放行尾
                current += token
                continue
            lines.append(current.rstrip())
            current = token.lstrip(" ") if token.startswith(" ") else token
        else:
            current = candidate
    lines.append(current.rstrip())
    return [line for line in lines if line != ""] or [""]

注意测量用的是 font.getlength(),不是「字数 × 字号」;等宽拉丁字符和全角汉字宽度差一倍,按字数算必然错位

超长单元(比如一条没有空格的 URL)要硬切:

def _hard_split(token, font, max_width):
    """把超过一整行的单元按字符切成能放下的碎片。"""
    pieces = []
    current = ""
    for char in token:
        if current and font.getlength(current + char) > max_width:
            pieces.append(current)
            current = ""
        current += char
    if current:
        pieces.append(current)
    return pieces

再往上一层是「整段折行」和「分页」:

def wrap(text, font, max_width):
    """整段文本折行:显式换行保留,空行也保留。"""
    lines = []
    for paragraph in expand_tabs(unescape(text)).split("\n"):
        if not paragraph.strip():
            lines.append("")
            continue
        lines.extend(wrap_paragraph(paragraph, font, max_width))
    return lines

@dataclass
class Page:
    """一页:渲染用的行 + 这一页在原文里的行号范围。"""
    lines: list
    first_line: int

    @property
    def count(self):
        return len(self.lines)

def paginate(lines, lines_per_page):
    """按每页行数切页;lines_per_page <= 0 表示不分页。"""
    if lines_per_page is None or lines_per_page <= 0:
        return [Page(lines=list(lines), first_line=0)]
    pages = []
    for index in range(0, len(lines), lines_per_page):
        pages.append(
            Page(lines=list(lines[index : index + lines_per_page]), first_line=index)
        )
    return pages or [Page(lines=[], first_line=0)]

def lines_per_page_for(page_height, line_height):
    """把像素页高换算成行数;<=0 表示不分页。"""
    if page_height is None or page_height <= 0:
        return 0
    return max(1, page_height // line_height)

Pagefirst_line 是为了日志能说「第 3 页从原文第 41 行开始」,分页出了问题能直接定位

命令行里的 \n 转义也在这里处理:

def unescape(text):
    r"""把命令行里写的字面 \n \t \\ 变成真实字符(只处理这三个)。"""
    out = []
    index = 0
    mapping = {"n": "\n", "t": "\t", "\\": "\\", "r": "\r"}
    while index < len(text):
        char = text[index]
        if char == "\\" and index + 1 < len(text) and text[index + 1] in mapping:
            out.append(mapping[text[index + 1]])
            index += 2
            continue
        out.append(char)
        index += 1
    return "".join(out)

只处理这四个转义,不做通用解析——--text 里的反斜杠大多是路径(C:\Users\...),过度解析反而会吃掉用户的字符

渲染:行高、对齐、不留白

DEFAULT_WIDTH = poooli.Poooli.WIDTH  # 640px,和协议里写死的纸宽一致
DEFAULT_MARGIN = 16
DEFAULT_LINE_SPACING = 1.35
ALIGNMENTS = ("left", "center", "right")

def line_height(font, line_spacing=DEFAULT_LINE_SPACING):
    """一行占多少像素(含行距)。"""
    ascent, descent = font.getmetrics()
    return max(1, int(round((ascent + descent) * line_spacing)))

行高用 ascent + descent,不用 font.size;有些字体的实际高度会超过字号,按字号算会让上下行贴在一起

render_page 有一个细节:末尾的空行不参与高度计算,免得浪费纸

def render_page(
    lines,
    font,
    width=DEFAULT_WIDTH,
    margin=DEFAULT_MARGIN,
    align="left",
    line_spacing=DEFAULT_LINE_SPACING,
):
    """把一页的行渲染成位图;末尾的空行不会浪费纸。"""
    if align not in ALIGNMENTS:
        raise ValueError("Unknown align: " + align)
    step = line_height(font, line_spacing)
    used = 0
    for index, line in enumerate(lines):
        if line.strip():
            used = index + 1
    height = max(step, 2 * margin + used * step)
    image = Image.new("RGB", (width, height), "white")
    draw = ImageDraw.Draw(image)
    usable = max(1, width - 2 * margin)
    for index, line in enumerate(lines):
        if not line:
            continue
        offset = draw.textlength(line, font=font)
        if align == "center":
            x = margin + (usable - offset) / 2
        elif align == "right":
            x = margin + usable - offset
        else:
            x = margin
        draw.text((x, margin + index * step), line, font=font, fill="black")
    LOG.debug(
        "渲染一页:%dx%d%d 行(%s 对齐)", image.width, image.height, used, align
    )
    return image

这一步画出来的是白底黑字的 RGB 图,不是二值图;为什么?因为 PIL 画字的抗锯齿只有灰度才有意义;真正的二值化留到 prepare_image,和图片走同一条路;这样「文本」和「图片」在协议层完全没有区别——都是 640 宽的二值位图

最外层的 render_pages 把四步串起来:

def render_pages(
    text,
    font_path=None,
    font_size=32,
    width=DEFAULT_WIDTH,
    margin=DEFAULT_MARGIN,
    align="left",
    line_spacing=DEFAULT_LINE_SPACING,
    page_height=0,
    lines_per_page=0,
):
    """排版并渲染整段文本,返回每页一张 PIL 位图(640px 宽)。"""
    font = load_font(font_path, font_size)
    max_width = max(1, width - 2 * margin)
    lines = wrap(text, font, max_width)
    step = line_height(font, line_spacing)
    per_page = lines_per_page or lines_per_page_for(page_height, step)
    pages = paginate(lines, per_page)
    LOG.info(
        "文本排版:%d 行 → %d 页(%s,每页最多 %s 行,行高 %dpx)",
        len(lines),
        len(pages),
        describe_font(font),
        per_page or "不限",
        step,
    )
    for page in pages:
        LOG.debug(
            "%d 页首行(原文第 %d 行):%s",
            pages.index(page) + 1,
            page.first_line + 1,
            (page.lines[0] if page.lines else "")[:40],
        )
    return [
        render_page(page.lines, font, width, margin, align, line_spacing)
        for page in pages
    ]

存图调试:--verbose 时把「打印机收到什么」留下来

文字打印最烦的问题是「印出来不对」——字体错了、折行错了、二值化阈值不合适;光看纸很难判断,所以加了「把渲染出的位图存成 PNG」:

def save_pages(pages, directory=DEFAULT_DIR, prefix="page", start=1):
    """把每页存成 ``<prefix>-0001.png``,返回保存的路径列表。"""
    target = Path(directory)
    target.mkdir(parents=True, exist_ok=True)
    saved = []
    for index, page in enumerate(pages, start):
        path = target / ("%s-%04d.png" % (prefix, index))
        page.save(path)
        saved.append(path)
    return saved

def dump_directory(selected, verbose=False, default=DEFAULT_DIR):
    """算出要不要存图、存到哪。

    `selected` 是选项给的值:``None`` 表示没给(`--verbose` 时用默认目录),
    ``False`` 表示 `--no-dump-images`(死活不存),其余当目录用。
    """
    if selected is None:
        return default if verbose else None
    return selected or None

--no-dump-imagesstore_const 存成 False,和「没给」(None)区分开——这样 --no-dump-images 的优先级高于 -v,用户表达「我不要这些文件」时不会被覆盖

0x08 ssh:把终端会话镜像到纸上

这是项目里最大胆的一块功能:poooli ssh --mac AA:BB:... root@host,你在电脑上看到什么,打印机上就有什么

它要同时解决四件事,每一件单独看都不难,叠在一起就很烦:

  1. 跑一个真正的 ssh 子进程(不是自己实现 ssh),参数原样转发;
  2. POSIX 上给它 pty,本地终端切 raw,按键原样转发;
  3. 子进程的 stdout/stderr 合成一路:一边显示到终端(保留颜色),一边剥掉 ANSI 转义送打印机;
  4. 节流:打印机的速度远低于终端,不能让每个回车都变成一张纸

剥 ANSI:打印机不认颜色

终端输出里什么都有:颜色、光标移动、进度条的回车、清屏;先剥掉转义序列,只留看得见的字符;但 \n\r\b 要留给下一层,因为它们有语义:

# CSI(含颜色)/ OSC(窗口标题等)/ 其它两字符转义
ESCAPE = re.compile(
    r"""
    \x1b
    (?:
        \[ [0-?]* [ -/]* [@-~]          # CSI ...
      | \] [^\x07\x1b]* (?:\x07|\x1b\\)  # OSC ... BEL 或 ST
      | [@-Z\\-_]                        # Fe 两字符序列
    )
""",
    re.VERBOSE,
)

# 其它 C0 控制符(保留 \t \n \r \b,交给 LineBuffer)
CONTROL = re.compile(r"[\x00-\x07\x0b\x0c\x0e-\x1f\x7f]")

def strip(text):
    """去掉转义序列与不可见控制符;\t \n \r \b 原样保留。"""
    return CONTROL.sub("", ESCAPE.sub("", text))

LineBuffer:\r 回到行首覆盖

这是整个 ssh 功能里最容易写错的地方;终端的换行是 \r\n:回车 + 换行,进度条又靠 \r 原地刷新;如果看到 \r 就把当前行清空,那 abc\r\n 里的 abc 会被一起丢掉

正确做法是维护一个光标位置\r 把光标移回 0,后面的字符覆盖上去:

class LineBuffer:
    """按行切分终端文本:``\n`` 收一行,``\r`` 回行首覆盖,``\b`` 退格删一个字。

    关键在 ``\r``:pty 上每行以 ``\r\n`` 结尾,进度条又靠 ``\r`` 原地刷新,所以不能
    简单地把当前行清空(那会把 ``abc\r\n`` 里的 ``abc`` 一起丢掉),要按光标位置
    覆盖。``carriage_return_newline=True`` 换一种语义:``\r`` 也算换行(raw 终端里
    回车键送出的是 ``\r``),用来处理键盘输入。
    """

    def __init__(self, carriage_return_newline=False):
        self.carriage_return_newline = carriage_return_newline
        self.chars = []
        self.cursor = 0
        self._just_newline = False

    @property
    def text(self):
        return "".join(self.chars)

    def reset(self):
        self.chars = []
        self.cursor = 0

    def feed(self, text):
        """吃一段文本,返回其中已经完整的行。"""
        if self._clean(text):
            if "\n" not in text:
                self.chars.extend(text)
                self.cursor = len(self.chars)
                return []
            *lines, tail = (self.text + text).split("\n")
            self.reset()
            self.chars.extend(tail)
            self.cursor = len(self.chars)
            return lines
        lines = []
        for char in text:
            if char == "\n":
                if not self._just_newline:
                    lines.append(self.text)
                self.reset()
                self._just_newline = False
            elif char == "\r":
                if self.carriage_return_newline:
                    lines.append(self.text)  # raw 终端的回车键就是换行
                    self.reset()
                    self._just_newline = True
                else:
                    self.cursor = 0  # 回到行首,后面的字覆盖上来
            elif char == "\b":
                if self.cursor:
                    self.cursor -= 1
                    del self.chars[self.cursor]
            elif self.cursor < len(self.chars):
                self.chars[self.cursor] = char
                self.cursor += 1
            else:
                self.chars.append(char)
                self.cursor += 1
        return lines

    def _clean(self, text):
        """这段文本能不能走快路径(没有光标控制,且当前光标就在行尾)。"""
        return (
            self.cursor == len(self.chars)
            and not self._just_newline
            and "\r" not in text
            and "\b" not in text
        )

    def take(self):
        """取走还没发出去的半行(提示符、密码提示之类)。"""
        pending = self.text
        self.reset()
        self._just_newline = False
        return pending

_clean 是个优化:绝大多数输出块里没有 \r / \b,光标也在行尾,这时候可以直接 split("\n") 批量处理,不用逐字符跑状态机;终端流量很大,这个快路径是必要的

take() 处理的是「半行」:提示符 $ 后面没有换行,如果只按行发,提示符永远印不出来;所以空闲一段时间后要把残留的半行也发出去

节流:FLUSH_INTERVAL

打印机很慢;逐行发的话,跑一个 apt update 能印掉半卷纸;所以攒一段再发:

# 攒这么久再渲染一次(秒);这是唯一的出处,其他地方只提常量名,改值只改这里。
# 这段时间里的行会合成同一张位图(0 表示来一行发一行)。
FLUSH_INTERVAL = 0.8

节流循环由 printer.tick() 驱动,主循环每 200ms 叫它一次:

def tick(self):
    """开一次缓冲区(pump 循环每 200ms 叫一次)。

    攒够 `flush_interval` 并且有新行才渲染;空闲够久再把提示符那样的半行
    也发出去。没有新内容的时候什么都不做——不渲染,也不发送。
    """
    interval = self.options.flush_interval
    if not interval or time.monotonic() - self.last_flush >= interval:
        self.flush_lines()
    idle = self.options.idle_flush
    if idle and time.monotonic() - self.last_activity >= idle:
        self.flush_pending()

def flush_lines(self):
    """把攒下的整行一次渲染出去;没有新行就返回 False。"""
    lines = self.take_lines()
    if not lines:
        return False
    self.last_flush = time.monotonic()
    self.printer.send_block(lines)
    return True

def flush_pending(self):
    """收尾:整行和没有换行的半行都立刻发出去(会话结束 / banner 前后)。"""
    lines = self.take_lines()
    for buffer in (self.output, self.stdin):
        pending = buffer.take()
        if pending and not is_console_noise(pending):
            lines.append(pending)
    self.last_flush = time.monotonic()
    self.printer.send_block(lines)

send_block 是省纸的关键:把这一批行合成一张位图发一次,逐行发的老路就省掉了;渲染省了,蓝牙往返也省了

def send(self, line):
    """发一行(等价于只有一行的块)。"""
    return self.send_block([line])

def send_block(self, lines):
    """把一批行一次渲染、一次发出:省渲染,也省一遍蓝牙往返。

    纯空行(包括只有空白的行)直接丢掉;整批都是空行就什么都不做。
    """
    texts = []
    for line in lines:
        text = line.rstrip()
        if not text:
            continue
        if self.timestamp:
            text = "%s %s" % (time.strftime("%H:%M:%S"), text)
        texts.append(text)
    if not texts:
        return False
    if self.printer is None and not self.dump_dir:
        LOG.debug("(只在本机显示)%s", "\n".join(texts))
        return False
    text = "\n".join(texts)
    try:
        pages = poooli_text.render_pages(
            text,
            font_path=self.font_path,
            font_size=self.font_size,
            margin=0,
            line_spacing=1.0,
        )
        self.dump(pages, text)
    except (OSError, ValueError) as error:
        LOG.error("这批行渲染失败,跳过:%s%r", error, text[:40])
        return False
    if self.printer is None:  # --no-print:只存图
        return False
    try:
        for page in pages:
            self.printer.send_image_object(page, self.mode)
    except (OSError, ValueError) as error:
        LOG.error("打印机出错,接下来只在本机显示:%s", error)
        self.printer = None
        self.failed = True
        return False
    self.lines_sent += len(texts)
    return True

注意 except 那段:打印机中途出错(没纸、走远、断电)时,会话本身要继续——用户还在 ssh 里干活,不能因为打印机出错就把 ssh 也断了;所以出错后把 self.printer = None,之后只在本机显示

滤掉 Windows ssh 的噪声

Windows 版 ssh(Win32-OpenSSH)在管道模式下会把「句柄不是控制台」的告警打到 stderr;句柄本来就不是控制台,这行纯属噪声,不该上纸:

# Windows 版 ssh(Win32-OpenSSH)在管道模式下会把「句柄不是控制台」的告警打到 stderr,
# 句柄本来就不是控制台,这行纯属噪声。首行匹配(不匹配行中间的相似文本);整行的那种
# 连换行一起删掉,半行/跨块的残渣由 `is_console_noise` 在成行后再拦一道。
CONSOLE_NOISE = re.compile(
    r"(?m)^(?:Get|Set)ConsoleMode on [^\r\n]*failed with \d+[ \t]*\r?\n"
)
NOISE_LINE = re.compile(r"^(?:Get|Set)ConsoleMode on .*failed with \d+$")

def drop_console_noise(text):
    """删掉完整的 ssh 控制台告警行(半行留到拼成整行时再判)。"""
    return CONSOLE_NOISE.sub("", text)

def is_console_noise(line):
    """这一行是不是 ssh 的控制台告警。"""
    return bool(NOISE_LINE.match(line.strip()))

两道过滤是因为噪声可能跨块:告警的前半截在这个 4096 字节的读块里,后半截在下一个块里;所以块级删一次,成行后再判一次

本地显示与输入镜像

终端输出要同时满足两个消费者;本地这份要补 \r\n,因为 raw 模式下终端不会自己把 \n 变成回车换行(否则输出会「爬楼梯」):

def write_output(self, text):
    text = drop_console_noise(text)  # 跨块被切断的那种在下面成行后再拦
    self.out.write(self._for_local_terminal(text))
    self.out.flush()
    self.last_activity = time.monotonic()
    self.enqueue(self.output.feed(strip(text)))

def _for_local_terminal(self, text):
    """本机终端切了 raw 就不再自己把 \\n 变成 \\r\\n,这里补上,否则输出会爬楼梯。"""
    if not self.raw_local:
        return text
    return text.replace("\r\n", "\n").replace("\n", "\r\n")

键盘输入要不要打到纸上是个判断题;远端有 pty 时会自己回显输入,再镜像一遍就重复了;管道模式下远端不回显,不打就看不到输入行;所以默认 auto,按「远端会不会回显」推断:

@property
def mirror_stdin(self):
    """远端有 pty 时它会自己回显,再镜像一遍就会重复。"""
    mode = self.options.stdin_mirror
    if mode == "always":
        return True
    if mode == "never":
        return False
    return not self.remote_echo

def write_input(self, text):
    if not self.mirror_stdin:
        return
    self.enqueue(
        ["> " + line for line in self.stdin.feed(strip(text)) if line.strip()]
    )

输入行加 > 前缀,纸上就能和命令输出区分开

跑真正的 ssh:pty、双向转发

会话层给子进程一个真正的 pty(和 openssh 一样),把 stdin 接过去,把 stdout/stderr 合成一路:

try:  # POSIX only
    import fcntl
    import pty
    import struct
    import termios
    import tty
except ImportError:  # pragma: no cover - Windows
    fcntl = pty = struct = termios = tty = None

def default_pty():
    """有 pty 模块就用;Windows 上退回管道。"""
    return pty is not None and sys.stdin is not None and hasattr(sys.stdin, "fileno")

Windows 上没有 pty 模块,退回管道,并自动给 ssh 补 -tt 让远端分配伪终端:

def remote_pty_wanted(ssh_args):
    """ssh 会不会给远端分配伪终端(有伪终端远端就会自己回显输入)。

    本地有 pty 时 ssh 默认分配;没有本地 pty 时 `command.build_argv` 会补 `-tt`。
    """

主循环要处理一个容易被忽略的收尾问题:子进程退出后,管道里可能还有输出没读完;如果立刻返回,最后几行就丢了:

def pump(self, mirror, on_tick=None):
    """转发到子进程退出;返回子进程退出码。

    子进程退出前可能还有一大段输出没读完(管道是边写边读的),所以退出后要
    等 reader 把剩下的读完、打完再收工,否则最后几行会丢。
    """
    reader = threading.Thread(
        target=self._reader, args=(mirror,), name="ssh-out", daemon=True
    )
    reader.start()
    threading.Thread(
        target=self._writer,
        args=(mirror, not self.use_pty),
        name="ssh-in",
        daemon=True,
    ).start()
    while True:
        try:
            code = self.child.wait(timeout=POLL_INTERVAL)
            break
        except subprocess.TimeoutExpired:
            if self._stopped.is_set():
                self.stop()
                try:
                    code = self.child.wait(timeout=5)
                except subprocess.TimeoutExpired:  # pragma: no cover
                    self.child.kill()
                    code = self.child.wait()
                break
            if on_tick is not None:
                on_tick()
    reader.join(DRAIN_TIMEOUT)
    if reader.is_alive():  # pragma: no cover
        LOG.debug("还有输出没读完(打印机太慢或管道没关),先收工")
    flush = getattr(mirror, "flush_pending", None)
    if flush is not None:
        flush()  # 缓冲区里攒的整行、还有没换行的半行,收工前一起发出去
    return code

终端窗口大小也要同步给远端,否则 vimtop 这类全屏程序会画错:

def set_window_size(fd, rows, columns):
    if termios is None:  # pragma: no cover - Windows
        return
    try:
        fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0))
    except OSError as error:  # pragma: no cover
        LOG.debug("设置终端大小失败:%s", error)

def install_resize_handler(session):
    """终端窗口变化时同步给远端;返回原来的处理器,会话结束后还原。"""
    import signal

    def handler(signum, frame):  # pragma: no cover
        rows, columns = terminal_size()
        session.resize(rows, columns)

    if not hasattr(signal, "SIGWINCH"):
        return None
    try:
        previous = signal.getsignal(signal.SIGWINCH)
        signal.signal(signal.SIGWINCH, handler)
    except (OSError, ValueError) as error:  # pragma: no cover
        LOG.debug("接管 SIGWINCH 失败:%s", error)
        return None
    return previous

会话的总装放在最后;finally 里做了三件收尾:还原 SIGWINCH、关闭子进程、把缓冲区里的内容刷出去(包括 footer):

def run_session(ssh_argv, printer=None, options=None, interrupt=None):
    """跑一次 ssh 会话,顺带把内容打到打印机;返回 SessionResult。"""
    options = options or SessionOptions()
    use_pty = default_pty() if options.use_pty is None else options.use_pty
    if use_pty != default_pty():  # 用户显式指定
        LOG.debug("pty 模式:%s", use_pty)
    if options.remote_echo is None:
        options.remote_echo = remote_pty_wanted(ssh_argv[1:])
    if options.stdin_mirror == "auto" and not options.remote_echo:
        LOG.debug("远端不会回显输入,所以输入行也打到纸上")

    mirror = SessionMirror(printer, options=options)
    if options.banner:
        mirror.note(options.banner)

    session = SshSession(ssh_argv, use_pty=use_pty)
    started = time.monotonic()
    interrupted = False
    code = 0
    previous_handler = None
    try:
        session.start()
        previous_handler = install_resize_handler(session)
        if interrupt is not None:
            interrupt.watch(_InterruptSocket(session))
        code = session.pump(mirror, on_tick=mirror.tick)
    except KeyboardInterrupt:
        LOG.warning(
            "已中断(%s", getattr(interrupt, "reason", None) or "KeyboardInterrupt"
        )
        interrupted = True
        session.stop()
    finally:
        restore_resize_handler(previous_handler)
        session.close()
        mirror.flush_pending()
        if options.footer:
            mirror.note(options.footer)

    result = SessionResult(
        code=code or 0, interrupted=interrupted, lines_sent=mirror.printer.lines_sent
    )
    LOG.info(
        "ssh 会话结束:退出码 %s,用时 %.1f 秒,打印机收到 %d",
        result.code,
        time.monotonic() - started,
        result.lines_sent,
    )
    return result

自己的选项怎么和 ssh 的选项共存

poooli ssh 的目标是「用法和 openssh-client 几乎一致」;难点在于参数归属:-p-i-L 是 ssh 的,--mac--font-size 是我的

规则定得很死:自己只认长选项,短选项全部留给 ssh;所以 -v 是 ssh 的 verbose,--verbose 才是本程序的日志开关;-m 是 ssh 的 MAC 算法,打印机地址只能用 --mac

class OptionError(ValueError):
    """我们自己的长选项写错了(缺值、类型不对、取值不在候选里)。"""

@dataclass(frozen=True)
class Option:
    """一个只属于我们的长选项。"""
    name: str
    dest: str
    kind: str  # "value"(要跟一个值)或 "flag"(开关)
    default: object
    help: str
    metavar: str = "VALUE"
    choices: tuple = ()
    const: object = True  # flag 的取值,--no-xxx 那种用 False
    convert: object = None  # value 的转换函数(int / float)

def _value(name, dest, default, help, **kwargs):
    return Option(name, dest, "value", default, help, **kwargs)

def _flag(name, dest, default, help, const=True):
    return Option(name, dest, "flag", default, help, const=const)

ssh 自己的「带值选项」要列出来,拆分时才不会把 -o StrictHostKeyChecking=no 里的值当成目标主机:

# 这些 ssh 选项后面跟一个值,找目标主机 / 拆自己的选项时都要跳过。
SSH_OPTIONS_WITH_VALUE = {
    "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l",
    "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
}

还有一个 openssh 的规则被完整继承:目标主机之后的东西一律当远端命令,不再解析;所以下面这样的命令是对的:

poooli ssh --mac AA:BB:... root@host -p 2222   # 错!-p 会被当成远端命令
poooli ssh --mac AA:BB:... -p 2222 root@host   # 对

poooli ssh --help 里专门写明了这一点;这么写是有意的:和 ssh 保持一致比自作聪明重要——用户按 openssh 的直觉敲命令,行为必须符合预期

0x09 命令行:把库变成能用的命令

驱动好用不等于工具好用;命令行这一层要做的是:参数、日志、退出码、中断、错误提示——全是「用户能感知到」的部分

一个命令,两条发送路径

图片和文本的参数不同,但连接、重试、收尾、错误码翻译完全一样;所以抽了一个 _run,两条路径共用:

def _run(args, interrupt, job):
    """连接打印机并执行 job(printer),统一处理中断 / 超时 / 报错。"""
    import bluetooth

    LOG.debug("蓝牙模块:%s", getattr(bluetooth, "__file__", bluetooth))
    started = time.monotonic()
    phase = "连接"
    sock = None
    try:
        sock, printer = transport.connect_printer(bluetooth, args, interrupt)
        phase = "发送"
        job(printer)
    except KeyboardInterrupt:
        LOG.warning("已中断(%s", interrupt.reason or "KeyboardInterrupt")
        return EXIT_INTERRUPT
    except OSError as error:
        if interrupt.requested:
            LOG.warning("已中断(%s):%s", interrupt.reason, error)
            return EXIT_INTERRUPT
        if phase == "连接":
            LOG.error(
                "连接失败:%s(已尝试 %d 次)",
                describe_bluetooth_error(error),
                max(1, args.retries),
            )
            for line in CONNECT_CHECKLIST:
                LOG.error("怎么办:%s", line)
        else:
            LOG.error("%s失败:%s", phase, describe_bluetooth_error(error))
        if isinstance(error, TimeoutError):
            LOG.error(
                "超过 %.1f 秒没响应;可以调大 --timeout,或检查蓝牙链路", args.timeout
            )
        return EXIT_FAILURE
    finally:
        interrupt.watch(None)
        transport.close_quietly(sock)
    if interrupt.requested:
        LOG.warning("已取消:%s", interrupt.reason)
        return EXIT_INTERRUPT
    LOG.info("全部完成,用时 %.1f", time.monotonic() - started)
    return EXIT_OK

phase 变量是为了让报错更准确:连接阶段失败和发送阶段失败,用户要做的事完全不同;连接失败时把 CONNECT_CHECKLIST 整段打到日志里——因为「连不上」是最高频的问题,用户不需要去翻文档

文本文件编码:先 UTF-8,失败退 GBK

Windows 记事本存的中文 txt 经常是 GBK;用户不该被迫先转换编码:

TEXT_ENCODINGS = ("utf-8-sig", "gbk")

def decode_text(data, encoding=None):
    """字节 → 文本;默认先试 UTF-8,再退回 GBK(Windows 记事本的中文 txt 常是 GBK)。"""
    if encoding:
        return data.decode(encoding), encoding
    for candidate in TEXT_ENCODINGS:
        try:
            return data.decode(candidate), candidate
        except UnicodeDecodeError:
            continue
    return data.decode("utf-8", errors="replace"), "utf-8(errors=replace)"

utf-8-sig 放在第一位是为了吃掉 Windows 记事本可能加的 BOM,否则 BOM 会被当成正文印出来(纸上多一个不可见字符,表现为行首多一个空格)

退出码

退出码是命令行工具的契约,必须稳定:

"""进程退出码。

0    成功
1    失败(连接、发送、自检不过)
2    argparse 参数错误
130  被 Ctrl+C / Ctrl+D 中断(惯例:128 + SIGINT)
"""

EXIT_OK = 0
EXIT_FAILURE = 1
EXIT_INTERRUPT = 130

ssh 那条路更进一步,沿用 ssh 自己的退出码:远端 exit 7 就返回 7,这样 poooli ssh root@host 'exit 7'; echo $? 和直接用 ssh 是一样的

中断处理:Ctrl+C 要能及时生效

Python 的信号处理器只在主线程、且解释器能跑的时候执行;蓝牙的阻塞调用会把 Ctrl+C 卡住,所以这里做了两层:

触发的信号不止 SIGINT,还有 SIGTERM / SIGBREAK / SIGQUIT:

def install_interrupt_handlers(interrupt):
    """接管 Ctrl+C(SIGINT)/ Ctrl+Break(SIGBREAK)/ SIGTERM / SIGQUIT。"""

    def handler(signum, frame):
        interrupt.request("收到 %s" % signal_name(signum))
        # 阻塞调用返回后立刻跳出发送循环,不再把后面的行发完。
        raise KeyboardInterrupt

    for name in ("SIGINT", "SIGTERM", "SIGBREAK", "SIGQUIT"):
        signum = getattr(signal, name, None)
        if signum is None:
            continue
        try:
            signal.signal(signum, handler)
            LOG.debug("已接管 %s", name)
        except (OSError, ValueError) as error:  # 平台不支持 / 不在主线程
            LOG.debug("接管 %s 失败:%s", name, error)

Ctrl+D 则是 stdin 的 EOF,没有信号可接管,只能盯住 stdin:

def watch_stdin(interrupt):
    """Ctrl+D / Ctrl+Z 在控制台里只是 stdin EOF,不会发信号,只能盯住 stdin。"""

主循环每 200ms 返回一次,也让 mirror.tick() 有机会刷缓冲区——中断响应和打印节流是同一套机制在支撑

入口组装

app.pymain() 是整个程序的主干,短得可以一眼看完:

def main(argv=None):
    configure_output()
    raw = list(sys.argv[1:] if argv is None else argv)
    if raw and raw[0] == "ssh":
        from poooli_cli.ssh_command import ssh_main  # 延迟导入,普通命令不碰 ssh

        return ssh_main(raw[1:])

    parser = build_parser()
    args = parser.parse_args(raw)
    configure_logging(args)

    interrupt = InterruptHandler()
    install_interrupt_handlers(interrupt)

    LOG.debug("Python %s%s", sys.version.replace("\n", " "), sys.platform)
    LOG.debug("LZO 后端:%s", lzo_backend())
    LOG.debug("参数:%s", args)

    try:
        if args.selftest:
            return selftest()
        if args.list:
            return list_devices(interrupt)
        check_args(parser, args)
        watch_stdin(interrupt)
        if args.text is not None or args.text_file:
            return print_text(args, interrupt)
        return print_image(args, interrupt)
    except KeyboardInterrupt:
        LOG.warning("键盘中断,退出")
        return EXIT_INTERRUPT
    finally:
        if args.log_file:
            logging.shutdown()

LOG.debug("LZO 后端:%s", lzo_backend()) 这行是给售后用的:用户贴日志时,第一眼就能看到他用的是 python-lzo 还是 ctypes 加载的系统库,省掉一轮「你装没装 lzo」的对话

ssh 子命令用延迟导入:普通打印命令根本不需要 import pty、termios 那一堆东西,也不需要 import ssh 的代码;启动速度有实实在在的差别

0x0a 自检:不连打印机也能验证整条链路

协议是逆向来的,最怕改坏了还不知道;所以做了一个 --selftest:用一个「只收集字节的假 socket」,把整条链路跑一遍,逐帧校验

class CollectingSocket:
    """假的 socket:不发真数据,只把字节收起来,用来做离线自检。"""

先验证 LZO 本身:

row = bytes([0b10101010] * 24 + [0] * 24)
compressed, length_xor = Poooli.compress_to_lzo(row)
length = int.from_bytes(length_xor, "little") ^ poooli.LENGTH_XOR
payload = bytes(byte ^ poooli.BYTE_XOR for byte in compressed)
check(
    "LZO round trip",
    length == len(payload) and decompress(payload, len(row)) == row,
    "%d -> %d bytes" % (len(row), length),
)

再逐帧走一遍字节流,把每一行的宽度、行数、LZO 解压后的位图内容和原图对比:

def _check_frames(stream, expected):
    """逐帧校验:每行的宽度、LZO 解压后的位图内容,以及末尾的走纸指令。"""
    width_bytes = Poooli.WIDTH // 8
    offset = len(Poooli.INITIALIZE) + len(Poooli.PAPER_WIDTH)
    rows = 0
    compressed_total = 0
    framing_error = None
    for y in range(expected.height):
        try:
            assert stream[offset : offset + 4] == Poooli.BMP
            offset += 4
            width = int.from_bytes(stream[offset : offset + 2], "little") ^ 0x0D0D
            lines = int.from_bytes(stream[offset + 2 : offset + 4], "little") ^ 0x0D0D
            offset += 4
            assert (width, lines) == (width_bytes, 1), "width/rows %s" % (
                (width, lines),
            )
            length = (
                int.from_bytes(stream[offset : offset + 4], "little")
                ^ poooli.LENGTH_XOR
            )
            offset += 4
            payload = stream[offset : offset + length]
            offset += length

            decoded = decompress(
                bytes(byte ^ poooli.BYTE_XOR for byte in payload), width_bytes
            )
            assert len(decoded) == width_bytes, "row is %d bytes" % len(decoded)
            for ix in range(width_bytes):
                byte = 0
                for bit in range(8):
                    if expected.getpixel((ix * 8 + bit, y)) == 0:
                        byte |= 1 << (7 - bit)
                assert decoded[ix] == byte, "row %d byte %d differs" % (y, ix)
        except AssertionError as error:
            framing_error = str(error)
            break
        rows += 1
        compressed_total += length

    detail = framing_error or "%d rows, %d/%d bytes" % (
        rows,
        compressed_total,
        rows * width_bytes,
    )
    return framing_error is None and offset + len(Poooli.LINE_FEED) == len(
        stream
    ), detail

这个函数是整个自检最有价值的部分:它用协议规则反向解析自己产出的字节流,把「原图 → 位图 → LZO → 帧」的每一步都验了一遍,最后还检查结尾恰好是走纸指令(offset + len(LINE_FEED) == len(stream),一个字节不多不少)

自检最后还会把文本这条路径也跑一遍(渲染 + 发送 + 逐帧校验),并尝试 import 蓝牙模块:

pages = poooli_text.render_pages(
    "啵哩自检 selftest\n第二行 second line", font_size=24
)
check(
    "text layout",
    len(pages) >= 1 and all(page.width == Poooli.WIDTH for page in pages),
    "%d%s" % (len(pages), pages[0].size),
)

输出是给人看的一行一条:

[ok] LZO backend python-lzo (...)
[ok] LZO round trip 80 -> 18 bytes
[ok] handshake
[ok] image processing 1 (640, 320)
[ok] frame layout 320 rows, 5760/25600 bytes
[ok] line feed
[ok] text layout 1 页 (640, 108)
[ok] text pipeline
[ok] bluetooth import ...
selftest passed

CI 里跑的就是它;没有真打印机,也能保证协议没坏

0x0b 打包:两个平台,用户双击就能用

源码能用和「别人也能用」之间隔着打包;目标是:下载 → 解压 → 运行,不需要装 Python、不需要装任何库

Linux:PyInstaller 单文件

# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 配置:单文件可执行程序。"""

import os

from PyInstaller.utils.hooks import collect_submodules

project = os.path.dirname(SPECPATH)

hiddenimports = collect_submodules("bluetooth") + collect_submodules("lzo")

a = Analysis(
    [os.path.join(project, "main.py")],
    pathex=[project],
    binaries=[],
    datas=[],
    hiddenimports=hiddenimports,
    hookspath=[],
    runtime_hooks=[],
    excludes=["tkinter", "test", "unittest", "pydoc_data"],
    noarchive=False,
)
pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.datas,
    [],
    name="poooli",
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=False,
    console=True,
    ...
)

collect_submodules("bluetooth")collect_submodules("lzo") 是必须的:这两个是二进制扩展,PyInstaller 的静态分析看不到它们内部 import 了什么,不收全就会在运行时报 ModuleNotFoundError

Windows:在 Linux 上纯交叉编译

Windows 包走的是另一条路:手工组装一个可移植 Python 环境

  1. 下载官方的 embeddable CPython(固定 URL + SHA256,可校验);
  2. 下载 win_amd64 + cp310 的 wheel(pillow / python-lzo / pybluez2);
  3. 用 mingw-w64 交叉编译一个 poooli.exe 启动器;
  4. 全部塞进一个 zip

好处是全程不启动任何 Windows 环境——不需要 Wine、虚拟机或 Windows 机器

SOURCES = ("poooli.py", "main.py", "README.md")
PACKAGES = ("poooli_cli", "poooli_ssh", "poooli_text")
DOCS = ("docs",)  # 根 README 里的链接要能在发行包里点开

MINGW = "x86_64-w64-mingw32-gcc"
EMBEDDABLE = {
    "url": "https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip",
    "sha256": "608619f8619075629c9c69f361352a0da6ed7e62f83a0e19c63e0ea32eb7629d",
}
WINDOWS_PACKAGES = ("pillow", "python-lzo", "pybluez2")
WHEEL_TAGS = ("cp310", "win_amd64")
BUNDLE_NAME = "poooli-printer-windows-x86_64"

下载固件和 wheel 都要校验哈希:

def download(url, destination, sha256=None):
    destination.parent.mkdir(parents=True, exist_ok=True)
    if destination.exists() and (sha256 is None or _sha256(destination) == sha256):
        return destination
    log("下载 %s" % url)
    with urllib.request.urlopen(url) as response, destination.open("wb") as handle:
        shutil.copyfileobj(response, handle)
    if sha256 is not None:
        actual = _sha256(destination)
        if actual != sha256:
            ...

启动器是一小段 C;它做的事很简单:找到自己旁边的 python310.dllmain.py,加载 DLL、调 Py_Main;启动器本身很短,但有两个地方必须处理:

/* 控制台默认代码页不是 UTF-8,中文输出会乱码。 */
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);

python = LoadLibraryW(dll_path);
if (python == NULL) { ... }

第一,控制台代码页;Windows 控制台默认是 GBK 或别的区域代码页,直接输出 UTF-8 的中文日志会变成乱码,所以启动时先把输入输出代码页设成 UTF-8

第二,路径;启动器必须相对于自己的位置找文件,不能依赖当前工作目录——用户从任何目录双击 poooli.exe 都要能用:

length = GetModuleFileNameW(NULL, directory, MAX_PATH);
...
while (length > 0 && directory[length - 1] != L'\\') {
    length--;
}
directory[length] = L'\0';

交叉编译产物的依赖检查

交叉编译最容易出的问题是「在 Linux 上打出个 Windows exe,但缺 DLL」;所以 build.py 里维护了一份白名单:只允许依赖 Windows 自带的系统 DLL 和我们自己打包的文件,其余一律报错

# Windows 自带的 DLL / API set,交叉编译产物只允许依赖这些,以及我们自己打包的文件。
SYSTEM_DLLS = {
    "advapi32.dll",
    "api-ms-win-core-*",
    "api-ms-win-crt-*",
    "bthprops.cpl",
    "gdi32.dll",
    "kernel32.dll",
    "msvcrt.dll",
    "ole32.dll",
    "shell32.dll",
    "user32.dll",
    "vcruntime140.dll",
    "ws2_32.dll",
    ...
}

这样「在 Windows 上一运行就报缺 DLL」的问题,在 Linux 的构建阶段就被拦住了

真机验证

交叉编译再干净,也只是「看起来对」;所以 GitHub Actions 里有一个 windows-latest 的作业,把产物解压后真的跑一次:

verify-windows:
  needs: build
  runs-on: windows-latest
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: poooli-windows
    - name: 解压
      run: Expand-Archive -Path poooli-printer-windows-x86_64.zip -DestinationPath app
    - name: 运行交叉编译产物
      run: |
        .\app\poooli-printer-windows-x86_64\poooli.exe --selftest
        .\app\poooli-printer-windows-x86_64\poooli.exe --help

--selftest 会跑完整个协议链路(不含真实蓝牙连接),--help 顺便验证启动器和代码页没问题;这两步一过,Windows 包基本就稳了

0x0c 测试与 CI:假后端让协议可以放心改

所有测试都用假蓝牙模块,不需要真打印机:

"""测试公用的假 bluetooth 模块等。

打包后真正跑的是 pybluez2,这里用假的替身把「连接失败 / 中断 / 正常发送」几种
情况都能在离线环境下复现出来。
"""

class Protocols(enum.Enum):
    L2CAP = 10
    RFCOMM = 11

class FakeSocket:
    def __init__(self, connect=None, send=None):
        self.connect_hook = connect
        self.send_hook = send
        self.timeout = None
        self.closed = False
        self.sent = bytearray()
        self.address = None

    def settimeout(self, value):
        self.timeout = value

    def connect(self, address):
        self.address = address
        if self.connect_hook is not None:
            self.connect_hook(self, address)

    def send(self, data):
        if self.send_hook is not None:
            self.send_hook(self, data)
        self.sent += data
        return len(data)

    def close(self):
        self.closed = True

def fake_bluetooth(connect=None, send=None):
    """假 bluetooth 模块:BluetoothSocket 造 FakeSocket,不发真数据。"""
    module = types.SimpleNamespace(RFCOMM=3, __file__="fake/bluetooth.py")
    module.btcommon = types.SimpleNamespace(Protocols=Protocols)
    module.sockets = []

    def BluetoothSocket(proto=Protocols.RFCOMM):
        sock = FakeSocket(connect=connect, send=send)
        sock.proto = proto
        module.sockets.append(sock)
        return sock

    module.BluetoothSocket = BluetoothSocket
    return module

注意 FakeSocketconnect / send 都是可注入的钩子,这样「连接失败」「发送到一半出错」都能离线复现

还有一个只走帧结构、不解 LZO 的轻量工具,用来数「发了多少行、分了几页」:

def walk_frames(stream, offset=0):
    """按帧结构走一遍蓝牙数据流,返回 (行数, 送纸次数)。

    不解码 LZO,只按 BMP 头 + 长度字段跳;用来确认「发了多少行、分了几页」。
    """
    rows = 0
    pages = 0
    while offset < len(stream):
        if stream[offset : offset + 4] == poooli.Poooli.BMP:
            length = (
                int.from_bytes(stream[offset + 8 : offset + 12], "little")
                ^ poooli.LENGTH_XOR
            )
            offset += 12 + length
            rows += 1
            continue
        assert stream[offset : offset + 5] == poooli.Poooli.LINE_FEED, stream[
            offset : offset + 8
        ]
        offset += 5
        pages += 1
    return rows, pages

CI 用 CNB 流水线,只跑测试、不构建产物(构建在开发机上做,省 CI 时间):

.test-pipeline: &test-pipeline
  name: test
  docker:
    build:
      dockerfile: .cnb/Dockerfile
      versionBy:
        - pyproject.toml
        - uv.lock
  stages:
    - name: 同步依赖
      script: uv sync --frozen
    - name: 单元测试
      script: uv run pytest -q
    - name: 自检
      script: uv run python main.py --selftest

main:
  push:
    - *test-pipeline
  pull_request:
    - *test-pipeline

uv sync --frozen 保证 CI 用的依赖和 uv.lock 完全一致——「本地能跑 CI 挂」有一大半是依赖漂移造成的;versionBy 让 Docker 镜像跟着 pyproject.toml / uv.lock 变化重建

打包、测试、自检这些动作全部封装成 mise 任务,本地和 CI 跑的是同一套:

[tasks.test]
description = "Run the unit tests; extra arguments go to pytest (mise run test -k lzo)"
depends = ["deps:python"]
run = "uv run pytest -q"

[tasks.selftest]
description = "Offline end-to-end self check, no printer needed"
depends = ["deps:python"]
run = "uv run python main.py --selftest"

[tasks.ci]
description = "CI checks: frozen dependencies, unit tests and self check, no build"
depends = ["setup"]
run = "uv sync --frozen && uv run pytest -q && uv run python main.py --selftest"

mise run ci 和 CI 里跑的命令逐字相同;本地过了,CI 就过

0x0d 从一个文件到一个项目

前面几节讲的是「是什么」和「怎么做」,这一段讲「怎么长成现在这样的」——按能力阶段划分,没有按提交顺序;每个阶段都是被一个具体的痛点推着往前走的

阶段零:一个文件,一张图

最开始只有一个类、一个方法:连上蓝牙,把图片打成点阵,发出去,功能上等同于:

class Poooli:
    def connect(self, mac, channel=1): ...
    def send_image(self, file, mode="bnw_dither", contrast=1.0, brightness=1.0): ...

这个阶段的代码只有一个优点:能跑;但它已经包含了整个项目最硬的部分——逆向出来的协议;四行常量、一个信封格式、一套 LZO 掩码,这些从头到尾没变过

第一个真实痛点很快出现:图片印出来是歪的

这四条每一条都逼出一个参数:rotate、位序修正、modeprepare_image 的现在这个版本,就是把四次「印坏了」的经验一条条加回来的结果

这一阶段的教训:协议对了只是起点;从「字节正确」到「图是对的」,中间全是图像处理的细节

阶段一:先能连上

功能能跑之后,下一个问题就砸过来了:根本连不上

连不上的原因五花八门:打印机没开机、没配对、被手机 App 占着、channel 不对、MAC 写错;第一版代码只会抛一个 OSError: [WinError 10051] ...,用户拿到这个信息完全不知道该干什么

于是长出了两样东西:

def connect_printer(bluetooth, args, interrupt):
    """连上打印机(按 args.retries 重试),返回 (socket, Poooli)。"""
    attempts = max(1, args.retries)
    for attempt in range(1, attempts + 1):
        ...

现在回看,错误信息本身就是产品的一部分;「失败」两个字对用户没有价值,「打印机没开机,或正被手机 App 连着」才有

阶段二:先能用

能稳定印图之后,需求立刻变成了「我不想每次都用 Python 脚本」;于是有了命令行、有了文本打印

文本这一块是推倒重来最多的:

  1. 第一版:直接把文字画上去,宽度不限制 → 超出纸宽的部分被裁掉;
  2. 第二版:按字数折行 → 拉丁文和中文混排全乱;
  3. 第三版:按像素宽度折行,但中文标点乱跑 → 加了禁则;
  4. 第四版:字体找不到时静默退回内置字体 → 用户印出一堆方块却不知道原因

第四版的教训最深刻:看不见的降级比报错更糟;所以现在找不到中文字体时一定有 WARNING,还会在提示里给出 --font 的具体例子

这一阶段还把「图片」和「文本」统一到了同一条发送路径上:

for page in render_pages("你好,世界"):
    printer.send_image_object(page)

文本渲染出 640 宽的二值位图,走 send_image_object,和照片没有任何区别;统一数据流比统一参数重要——这让后来加 ssh 镜像变得容易得多

阶段三:先能装

「我自己的电脑能跑」和「别人也能用」之间,隔着打包

打包踩的坑比代码本身还多:

回头看,打包远在「最后一步」之前就反过来改了设计;ctypes 那个 LZO 后端、MINILZO_PATH 环境变量,都是被打包逼出来的

阶段四:先能维护

到这一步,功能已经不少了,但所有东西还挤在一起;改一行字体代码可能把协议测试搞挂,非常难受;于是做了三件事:

第一,按职责拆分

poooli.py        协议:位图 → LZO → 蓝牙帧
poooli_text/     排版:字体、折行、渲染、存图
poooli_ssh/      会话镜像:pty 子进程、ANSI 清理、节流
poooli_cli/      命令行:参数、日志、退出码、诊断、自检
packaging/       打包
tests/           测试

每个目录有自己的 README,讲清楚这一层负责什么、不负责什么;poooli.py 不知道有「文本」这回事,poooli_text 也不知道蓝牙怎么连——它只产出位图,依赖是单向的

第二,加测试:协议是逆向来的,没有测试根本不敢动;所有测试用假蓝牙后端,把「连接失败 / 中断 / 正常发送」三种情况都离线复现

第三,加自检--selftest 把整条链路跑一遍,逐帧反向校验自己产出的字节流;它是 CI 的守门员,也是用户报 bug 时收集现场的第一件工具

拆分的时机是「改 A 会碰坏 B」的那一刻;过早拆分会猜错边界,过晚拆分则积重难返

阶段五:把 ssh 打到纸上

最后一个大功能,也是最不像「打印」的一个:把 ssh 会话同步到纸上

它的实现难点全在两边节奏不匹配:

解决办法就是 FLUSH_INTERVAL:把这一小段时间里的行攒起来,合成一张位图发一次;配套的还有:

这个功能把前面所有层的设计都检验了一遍:协议层没变、位图层复用了文本渲染、CLI 层复用了连接和中断处理;如果一个功能加进来不需要改底层,说明前面的边界划对了

今天回头看

按能力排一下,整个项目是这么长起来的:

阶段新增能力触发的痛点
图片打印想把 App 里的东西拿回来
重试 / 诊断 / 中断连不上时不知道怎么办
命令行 / 文本 / 字体不想每次写脚本;中文排版
打包别人也要用
拆分 / 测试 / 自检 / CI改一处坏一片
ssh 镜像想让远处的终端也落到纸上

还有一点:协议层从阶段零到阶段五几乎没改过;四行常量、一个信封格式、一套 LZO 掩码,从逆向出来的第一天就定死了;后面所有的复杂度,都是「怎么把用户想要的东西变成那 80 字节一行」;这大概也是逆向最迷人的地方——把黑盒拆开的那一刻,问题就从「未知」变成了「已知」,剩下的全是工程

0x0e 结语

如果有人也想干一遍同样的事,我的建议只有一条:

把它当成一个会说字节的黑盒;先录下来,再猜格式,最后用代码去验证

录的时候记得做对照组(全白、半黑、文字),猜的时候先按「出现次数 + 位置」起工作名,验证的时候一定要落到代码上——猜对了但没写成代码,过两天就忘了

从这个角度说,那个周末的抓包本身就是项目最重要的一次设计,算不上什么「准备工作」;后面的每一层,都只是在回答同一个问题:怎么把人的意图,翻译成那 80 字节

好了以上就是本文的全部内容,如您有任何疑问,欢迎留言讨论。


分享这篇文章:

下一篇
为 PCem 配置网络并使用 Netscape 上网

人机验证:请刷新页面以加载评论区