Phomemo M834 A4 thermal printer への Python での bluetooth 出力

# 要旨
Phomemo 社の A4 サイズの bluetooth サーマル・プリンタの解析を行い、プリンタ制御コマンドは従来の小型サイズプリンタと共通ながら Bitmap データ部分は、miniLZO によって圧縮されていることを見出しました。この知見に基づき画像ファイルを出力するプログラムを Python で作製しました。

![IMG_7413.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44522/1cc3c40e-9b9c-4ed5-9ee8-6cb37fd25ae3.jpeg)


# Phomemo 社 A4 サイズ サーマル・プリンタ

 Phomemo 社は、複数の A4 サーマル・プリンタを出していて、基本的には数字が大きいほど新しいモデルになっています。A4 用紙か、米レターサイズかなど、微妙に細かな仕様が変わっていますが、基本的には同じものだと推測されます。

A４の紙は後ろから差し込む形で、Fax 用のロール紙は内蔵させて使えますが、かなり細くないと入りきれません。
 
どの機種を買っても良かったのですが、たまたま M834 が違う会社名で Phomemo 社より 2000 円ばかり安く売られていたので、それを買ってみました。箱に書いてある製造メーカー名は Zunhai Quin Technology になっています。Bluetooth の Mac Address の会社名を表す上位桁も、以前買った Phomemo 社製品とは異なっています。 しかし、説明書などでは Phomemo のアプリをダウンロードしろと言っており、中身は同じだと思います。
 
M08F https://www.amazon.co.jp/dp/B09ZXB6H7X
M832 https://www.amazon.co.jp/dp/B0CJ91GWM7
M834 https://www.amazon.co.jp/dp/B0CTGRNJM5
M836 https://www.amazon.co.jp/dp/B0DM8JZSFM

なぜか安かった M834 同一商品
itari M834 https://www.amazon.co.jp/dp/B0CMTFQZ82

# Bluetooth 解析

解析手法は、以前と同じです。
https://qiita.com/cure_honey/items/72124ff8effddc075e9b

Phomemo 社のアプリを古いアンドロイド端末にインストールし、そこから小さな純白や純黒画像を出力して描かせ、その時に発せられた Bluetooth パケットを読みます。パケット情報は、アンドロイド端末を開発者モードにすることによって、デバッグ情報の log として出力できます。この log を Google Drive 経由で Windows に送って wireshark というソフトで読み取ります。

アプリの逆コンパイルも行いましたが、ざっとキーワード検索を掛けても、プリントに関する情報は得られませんでしたので、こちらの情報は用いませんでした。

# 解析結果

Phomemo 社のアプリから M834 サーマル・プリンタに送られている Bluetooth Byte 列を眺めてみると、プリンタ制御用のコマンドは、おおむね従来通りの ESC/POS プリンタ制御命令を独自拡張したもので、さらに新しい独自命令がいくつか加えられておりました。また、白黒 2 値の Bitmap 情報の部分は、従来のものとは異なっていて、何らかの圧縮が掛けられてデータ転送がなされているようでした。純白画像を描かせてみたところ Poooli 社の L3 サーマル・プリンタ解析の時と同様のデータが転送されていることが分かったので、圧縮アルゴリズムは miniLZO だろうと推測されました。

多少の試行錯誤ののち、Bitmap 情報は従来のものと同じ並びで作った生データを、4096 byte 毎の固まりに区切って、miniLZO で圧縮し 3byte little endian の圧縮後のデータ byte 長のあとに miniLZO data をべた書きに並べていることが分かりました。この時、一般には最後の固まりは 4096byte より短くなりますが、それはその短いままで送ればいいようです。逆に、途中で 4096 byte より短く送ると、画像が途中で途切れておかしくなります。4096 byte より長くても認識されなくなります。圧縮データ長の長さに 3 byte 取っているのでもっと長くても良さそうなのですが･･･

また、注意点として ESC/POS の Bitmap 命令にあたる、GS+"v"+00h+30h 命令は、その引数である画像サイズ(x 方向 byte length, y 方向 bit length；各々 2byte little endian）と一緒に一括して送らなければいけないことがありいます。これは従来機では別々でも続けて送ればよかった事と異なります。

プリンタのリセット命令らしきものが、新設されております。これは、今のところ詳細不明です。以下のプログラムではアプリからの出力を忠実になぞることにします。

# python プログラム

### 実行例

miniLZO の実行ライブラリが必要になりますが、それに関しては以前の記事を参考にして下さい。基本的には、コンパイルしてできたバイナリを同じディレクトリに放り込んでおけばいいです。

https://qiita.com/cure_honey/items/e82a85ef8ca8ed26210a
https://qiita.com/cure_honey/items/dff6ac380de15aee31bf


```
(bluez) D:\bluez>python -m M834 C:\859590865797022714.png
Connecting...
b'\x1a\x17\x03'
1 0 9
b'346J43Q1210048'
100
6
152
137
Data sending...
Energy  100 %
```

### source program 

```python
import os, sys
import socket
import ctypes
import time
from PIL import Image

M834 = ("24:12:D8:63:AD:34", 1) #  (MAC address, channel 1)

ESC = b"\x1B"
GS  = b"\x1D"
US  = b"\x1F"

INIT  = ESC + b"@"
RESET = ESC + b"@"
BMP   = GS  + b"v" + b"0" + b"\x00"
ENRGY = US  + b"\x11" + b"\x08" 

args = sys.argv
width = 2472 # default  A4 2472 118dots/cm;  1280/912/576 dots 
print("Connecting...")
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
if len(args) == 2:
    fn = args[1]
    s.connect(M834)
elif len(args) == 3:
    width = args[1]
    fn = args[2]
    s.connect(M834)
else:
    s.connect((args[1], 1))
    width = args[2]
    fn = args[3]

# info 
s.send(US + b"\x11\x38") # ?
tmp38 = s.recv(3)
print(tmp38)
s.send(US + b"\x11\x07") # firmware version
version = s.recv(5)
print(version[2], version[3], version[4])
s.send(US + b"\x11\x63")
s.send(US + b"\x11\x5e")
s.send(US + b"\x11\x09")
s.send(US + b"\x11\x56") # serial no.
serial_number = s.recv(17)
print(serial_number[3:])
s.send(US + b"\x11\x51") 
s.send(US + b"\x11\x08") # energy
energy = s.recv(3)
print(energy[2])
s.send(US + b"\x11\x0e") # timer
timer = s.recv(3)
print(timer[2]) # 256*i+9 (sec) ?
s.send(US + b"\x11\x12") # paperstate for A4 ?
tmp12 = s.recv(3)
print(tmp12[2])
s.send(US + b"\x11\x11") # paperstate
tmp11 = s.recv(3)
print(tmp11[2])
s.send(US + b"\x11\x71\x01") # ?

###############################################################
# printer reset
s.send(RESET)
# set concentration
s.send(US + b"\x11\x02" + b"\x03" ) # concenttration 01, 03, 04
# set concentration coefficiennt
s.send(US + b"\x11\x37" + b"\x64" ) # standard 64, M04S 96

# ?? reset
s.send(US + b"\x11\x0b")     # ?
s.send(US + b"\x11\x35\x01") # phomemo A4 reset ?
s.send(US + b"\x11\x3c\x00") # ?

# PIL
image = Image.open(fn)

if image.width < image.height:
    image = image.transpose(Image.ROTATE_90)

# width M02S 576 dots, M04S 1280/912/576 dots 
IMAGE_WIDTH_BITS = int(width) # must be multiple of 8
IMAGE_WIDTH_BYTES = IMAGE_WIDTH_BITS // 8 
image = image.resize( size=(IMAGE_WIDTH_BITS, int(image.height * IMAGE_WIDTH_BITS / image.width)) )

# black&white printer: dithering
image = image.convert(mode="1")

# Header
s.send(BMP  # these 3 parameters ought to be sent simultaneously  
    + IMAGE_WIDTH_BYTES.to_bytes(2, byteorder="little")
    + image.height.to_bytes(2, byteorder="little"))

## print ##
lzo = ctypes.cdll.LoadLibrary('./minilzo.so')
wk = ctypes.create_string_buffer(16384 * 8)  # 64bit pointer
#
print("Data sending...")
nsize = 4096 # data must be sent in 4096byte chunk except the last chunk   
image_bytes = b""
for iy in range(image.height):
    for ix in range(int(image.width / 8)):
        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")
        #
        if (len(image_bytes) == nsize):
        
        # miniLZO compress
            buff = ctypes.create_string_buffer(image_bytes[:-1])
            ni = ctypes.c_int(len(buff))
            no_max = len(buff) + len(buff) // 16 + 64 + 3
            out = ctypes.create_string_buffer(no_max)
            no = ctypes.c_int(len(out))
            iret = lzo.lzo1x_1_compress(ctypes.byref(buff), ni, ctypes.byref(
                out), ctypes.byref(no), ctypes.byref(wk))
            lzno = no.value
            image_lzo = out[:lzno]
            #
            # send to printer
            # compressed data length
            s.send(lzno.to_bytes(3, byteorder="little") + image_lzo)
            image_bytes = b"" 
# aux
#print("-last chunk------>", iy, len(image_bytes))
#
buff = ctypes.create_string_buffer(image_bytes[:-1])
ni = ctypes.c_int(len(buff))
no_max = len(buff) + len(buff) // 16 + 64 + 3
out = ctypes.create_string_buffer(no_max)
no = ctypes.c_int(len(out))
iret = lzo.lzo1x_1_compress(ctypes.byref(buff), ni, ctypes.byref(
    out), ctypes.byref(no), ctypes.byref(wk))
lzno = no.value
image_lzo = out[:lzno]
#
# send to printer
# compressed data length
s.send(lzno.to_bytes(3, byteorder="little") + image_lzo)
# feed line
#s.send(ESC + b"\x64\x02")
#s.send(ESC + b"\x64\x02")
#
# wait till print ends 
s.send(ENRGY) # get battery energy
a = s.recv(3)
print("Energy ", int(a[2]), "%")

# close bluetooth connection 
s.close()
```

# おわりに

米中関税問題により、近いうち自由主義陣営は、米関税率に同調してブロック経済圏を形成することが求められるという噂を信じて、今のうちに中国産品を買ってみることにしました。

解析そのものは、以前の手順を繰り返すだけで、それほど難しいものではなかったのですが、Bitmap 命令を縦横のサイズ引数込みで一括 send しないと認識してくれないという罠にはまって時間を空費してしまいました。

ロール紙で出力できるので、書き初めとか演題の垂れ幕づくりに向いているかもしれません。

ChatGPT に調べてもらった限りでは、Phomemo A4 thermal printer の解析はなされていない様なので、多少は世の中に裨益する所があるのではないかと思います。