Phomemo で 感熱紙に Hello World ! 


:::note info
この記事は [LAPRAS Advent Calendar 2023](https://qiita.com/advent-calendar/2023/lapras) 12月19日の記事です！
LAPRASメンバーのいろんなアウトプットが見られるので、ぜひアドベントカレンダーもご覧ください。
:::

## はじめに

Phomemo M02 Pro というサーマルプリンターを手に入れました。
はじめての技術に触れるときのお約束として Hello World をしたいと思いますので、その道のりを書き残して置きます。

### Phomemo M02 Pro について
写真のように感熱ロール紙に印刷ができるモバイルプリンターです。
Bluetooth で通信をします。
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113212/24a7f8a4-64d0-03c4-a480-411d6cdfa982.jpeg">

https://www.amazon.co.jp/dp/B086JRPT4W


なおこちらのプリンターは便利なアプリがありますので、**通常はこの記事のように頑張らなくても Hello World が印字できます**。

### 環境について
執筆時の環境は macOS, Python

## 事前の情報収集
GitHub で Phomemo を検索するといくつかのリポジトリがみつかりました。
おもに参考にしたリポジトリを2つほど。

https://github.com/vivier/phomemo-tools

https://github.com/hkeward/phomemo_printer

以下のことがわかった

- ESC/POS というコマンド規格で通信していること
- フォントは内蔵されていないので文字もビットマップで送る必要があること

ESC/POS というのはエプソン社が開発したPOS端末向けのコマンド規格。なるほどね。

https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=2

## Hello World までのロードマップ
以下のステップで Hello Wolrd をやっていきます。
Hello World とは思えない道のりだけど、あらかじめ道のりを明確にしておくの大事。

1. Bluetooth でmacと実機を接続する
1. iPhoneアプリでの通信内容を確認
1. 初期化
1. 紙送り
1. 線を印刷する
1. 文字を印刷する(Hello World!)

## Bluetooth でmacと実機を接続する
macOS で動作する Python の Bluetooth ライブラリを比較検討したが、最もメンテナンス・リリースも活発そうな Bleak を利用することにする。

https://github.com/hbldh/bleak

Phomemo 本体と接続するコードは以下。

- [Bleak のドキュメントに記載があるが、macOS の場合は機器のmacアドレスで接続ができない](https://bleak.readthedocs.io/en/latest/backends/macos.html)ようだ。代わりに OS で割り当てられる UUID を指定しろとのことだがこれは固定値ではないようなので、諦めてデバイス名(M02 Pro)で接続するようにしました。
- 結構な確率で接続できないことがあるので、3回リトライするようにしています。

```python
import asyncio
from typing import Optional

from bleak import BleakScanner, BLEDevice

DEVICE_NAME = 'M02 Pro'
CONNECTION_RETRY_MAX_COUNT = 3


async def main():
    # scan and connect
    device = await connect()
    if device:
        print('connected.')
    else:
        print('device not found.')
        return


async def connect() -> Optional[BLEDevice]:
    retry_count = 0
    device = None
    while not device and retry_count < CONNECTION_RETRY_MAX_COUNT:
        device = await BleakScanner.find_device_by_name(
            name=DEVICE_NAME
        )
        retry_count += 1

    return device


if __name__ == "__main__":
    asyncio.run(main())

```

## iPhoneアプリでの通信内容を確認
接続ができたので次はコマンドを送りたいがその前に調査。

ESC/POS のようだが、Phomemo には 公式のiPhoneアプリがあるので、実際の通信内容を確認してそれを再現するようにする。

iPhone の Bluetooth 通信の確認には、PacketLogger というアプリを使う。
iPhone と mac をUSB 接続すれば通信の中身を確認できる。

https://zenn.dev/moutend/articles/36b1c43f4a97b8

iPhone アプリから印刷したときの実際の通信内容の抜粋は以下。
ざっくり 初期化→印字→紙送り という流れになっていることがわかる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113212/a6c06dda-7625-0e65-6645-4320d773ce12.png)

- `１B４０` : [ESC @ 初期化コマンド](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=192)
- `1F11 0204` : 不明。ESC/POS には無いが他のリポジトリでは初期化コマンドとして扱っている。
- `1D76 30` : [GS v 0 ラスタービットイメージの印字コマンドとそのパラメータ](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=94)
- それ以降 : ビットマップデータ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113212/ac9022fe-aaa1-f394-5e4d-cddbced81b17.png)
- `１B64` : [ESC d 印字およびn行の紙送り](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=17)

以降この記事では、この通信内容を再現するほようにコマンドを送信していく。

## 初期化
接続後にまずは [ESC @ コマンド](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=192) で初期化する。

BLEで書き込むためには、書き込み用の Characteristic の UUID が必要。
このあたりは詳しくないので以下のサイトなどで必要な情報をキャッチアップした。

https://monomonotech.jp/kurage/webbluetooth/ble_guide.html

以下のコードで Phomemo の Characteristic 一覧を出力する。

```python
CHARACTERISTIC_UUID_WRITE = '0000ff02-0000-1000-8000-00805f9b34fb'

async def main():
    # 省略
    async with BleakClient(device) as client:
        for c in client.services.characteristics.values():
            print(f'Property: {c.properties}, UUID: {c.uuid}')
```
```
Property: ['read'], UUID: 0000ff01-0000-1000-8000-00805f9b34fb
Property: ['write-without-response', 'write'], UUID: 0000ff02-0000-1000-8000-00805f9b34fb
Property: ['notify'], UUID: 0000ff03-0000-1000-8000-00805f9b34fb
```

2つめの UUID が書き込み用のようだ。
該当する UUID に、 `ESC @` コマンドとその後のPhomemo独自の初期化コマンドを書き込む。

```python
ESC = b'\x1b'
CHARACTERISTIC_UUID_WRITE = '0000ff02-0000-1000-8000-00805f9b34fb'

async def main():
    # 省略
    await init_printer(client=client)

async def init_printer(client: BleakClient):
    print(f'init printer')
    await send_command(client=client, data=ESC + b'@' + b'\x1f\x11\x02\x04')

async def send_command(client: BleakClient, data: bytes):
    await client.write_gatt_char(char_specifier=CHARACTERISTIC_UUID_WRITE, data=data, response=True)

```

初期化なので Phomemo からは何も反応がないのでよくわからない。
変なコマンド送ると Phomemo の電源が落ちるので、多分うまく言っているはず。

## 紙送り
[`ESC d` コマンド](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=17) で紙送りをする。
コマンドのパラメータは 1byte(0 ～ 255) で送る行数を指定する。

```python
async def main():
    # 省略
    async with BleakClient(device) as client:
        await init_printer(client=client)
        await feed(client=client, line=3)

async def feed(client: BleakClient, line: int = 1):
    print(f'feed paper: {line} lines')
    await send_command(client=client, data=ESC + b'd' + line.to_bytes(1, 'little'))
```

動作確認すると、指定した行数だけ紙送りされることを確認できた。
プログラムから物理デバイスが動くのはおもしろい。

## 線を印刷する
次は線を印刷する。
線といっても Phomemo では印字データをビットマップとして受け取るので、線のビットマップデータを作成して [`GS v 0` コマンド](https://reference.epson-biz.com/modules/ref_escpos_ja/index.php?content_id=94) で送信する。

以下のコードでは、0xFFで埋め尽くした１行分のビットマップデータを書き込んで、直線を印刷している。

また印字コマンドを送った直後に切断すると印字が行われなかったため、Sleep を入れることで回避する。(これに気づかなくて数時間かかってしまった)

```python
GS = b'\x1d'

# 1 line = 576 dots = 72 bytes x 8 bit
DOT_PER_LINE = 576
BYTE_PER_LINE = DOT_PER_LINE // 8


async def main():
    # 省略
    async with BleakClient(device) as client:
        await init_printer(client=client)
        await print_line(client=client)
        # 印字データ書き込みのあとにすぐDisconnectしてしまうとうまく動かないので少し待つ
        await asyncio.sleep(2)


async def print_line(client: BleakClient):
    # GS v 0 コマンド
    # パラメータの詳細はESC/POSのコマンドリファレンスを参照
    # ビットマップのx,yサイズはリトルエンディアンで送信する必要があるので注意
    command = GS + b'v0' \
              + int(0).to_bytes(1, byteorder="little") \
              + int(BYTE_PER_LINE).to_bytes(2, byteorder="little") \
              + int(1).to_bytes(2, byteorder="little")
    await send_command(client=client, data=command)

    # 上記コマンドで指定したバイト数分のビットマップデータを送信する
    line_data = bytearray([0xff] * BYTE_PER_LINE)
    await send_command(client=client, data=line_data)
```

## 文字を印刷する(Hello World!)
いよいよ Hello World の印字。
前述の通り Phomemo にはフォントが含まれていないため、文字を画像(ビットマップデータ)として送信する必要がある。

Python には [Pillow(PIL) という便利な画像処理ライブラリ](https://pypi.org/project/Pillow/)があるので利用する。
具体的には、ビットマップ領域を用意し、そこに文字を描画して、必要なビットマップデータを作る。

```python
from dataclasses import dataclass
from PIL import ImageFont, Image, ImageDraw

@dataclass
class BitmapData:
    bitmap: bytes
    width: int
    height: int

async def main():
    # 省略
    async with BleakClient(device) as client:
        await init_printer(client=client)
        await print_text(client=client, text='Hello World!', fontsize=32)
        # 印字データ書き込みのあとにすぐDisconnectしてしまうとうまく動かないので少し待つ
        await asyncio.sleep(2)

async def print_text(client: BleakClient, text: str, fontsize: int = 24):
    # 指定した文字が描かれたビットマップを生成して取得
    bitmap_data = text_to_bitmap(text=text, fontsize=fontsize)

    # GS v 0 コマンド
    command = GS + b'v0' \
              + int(0).to_bytes(1, byteorder="little") \
              + int(BYTE_PER_LINE).to_bytes(2, byteorder="little") \
              + int(bitmap_data.height).to_bytes(2, byteorder="little")
    await send_command(client=client, data=command)

    # 上記コマンドで指定したバイト数分のビットマップデータを送信する
    await send_command(client=client, data=bitmap_data.bitmap)


def text_to_bitmap(text: str, fontsize: int) -> BitmapData:
    # 必要なビットマップサイズ
    font = ImageFont.load_default(size=fontsize)
    image_width = DOT_PER_LINE
    image_height = int(fontsize * 1.5)

    # ビットマップを作成
    img = Image.new('1', (image_width, image_height), 0)
    draw = ImageDraw.Draw(img)

    # 文字を描画する
    draw.text((0, 0), text, font=font, fill=1)

    # ビットマップのバイト列を返却する
    return BitmapData(img.tobytes(), image_width, image_height)
```

　Hello World できた！

<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113212/6e7cd392-0c91-0da5-b8a8-56cb3ffd4566.png">

## おわりに

以上のように~~とても簡単に~~ Phomemo で Hello World ができました。

物理紙に印字されるのは、コンソールに文字が出力されるのとはまた違う嬉しさがありますね！
最終的にビットマップを渡すところまでできたので、あとは何でも印刷できそうです。

:::note info
画像プリントにも対応したサンプルコードをGitHubで公開しています。
https://github.com/ryo-endo/phomemo-printer
:::

安価でいろいろ応用できそうなデバイスですので、年末年始にぜひ遊んでみてください。