技術 約7分で読めます

Bluetoothミニプリンター「Mini Printer Sugar」をPCから操作する

Mini Printer Sugar パッケージ

Bluetooth感熱ミニプリンター「Mini Printer Sugar」(株式会社エール)。ビックカメラドットコムだと5000円ちょいするが、あきばおーで1000円ちょいで売ってたので遊び用に買ってきた。専用のスマホアプリからしか印刷できない想定の製品だけど、PCから直接操作できるようにしたい。

スペック

項目仕様
印刷方式感熱式(インク不要)
解像度200dpi
接続Bluetooth(BLE)
電源USB充電式
サイズW81 x D85 x H45mm / 146g

BLEスキャンとサービス確認

Python + bleakでBLEスキャンする。

pip install bleak
import asyncio
from bleak import BleakScanner

async def scan():
    devices = await BleakScanner.discover(timeout=5.0)
    for d in sorted(devices, key=lambda x: x.rssi, reverse=True):
        print(f"{d.rssi:4d} dBm | {d.name or '(unknown)':20s} | {d.address}")

asyncio.run(scan())

デバイス名 YMP-01 で検出された。

接続してサービスを確認すると、以下の構成だった。

項目UUID
Service49535343-fe7d-4ae5-8fa9-9fafd205e455
TX(Write)49535343-8841-43f4-a8d4-ecbe34729bb3
RX(Notify)49535343-1e4d-4bd9-ba61-23c647249616

これはISSC(Microchip)のTransparent UARTサービスで、BLE経由でシリアル通信するための汎用プロファイル。安価なBLE感熱プリンターでよく見る「Cat Printerプロトコル」(Service UUID 0000AE30)とは異なるため、Cat Printer系のライブラリはそのまま使えない。

プロトコルの特定

Cat Printer式のコマンド(マジックバイト 0x51 0x78)を送ってみたが反応なし。ESC/POSコマンドを試したところ、紙送りに成功した。

import asyncio
from bleak import BleakScanner, BleakClient

TX_UUID = "49535343-8841-43f4-a8d4-ecbe34729bb3"

async def feed():
    devices = await BleakScanner.discover(timeout=5.0)
    target = next((d for d in devices if d.name and "YMP" in d.name), None)
    if not target:
        print("YMP not found")
        return

    async with BleakClient(target.address) as client:
        # ESC @ = プリンター初期化
        await client.write_gatt_char(TX_UUID, bytes([0x1B, 0x40]))
        await asyncio.sleep(1)

        # ESC J n = n dot分の紙送り
        await client.write_gatt_char(TX_UUID, bytes([0x1B, 0x4A, 0x30]))
        await asyncio.sleep(1)

        # LF x3 = 改行3回
        await client.write_gatt_char(TX_UUID, bytes([0x0A, 0x0A, 0x0A]))

asyncio.run(feed())

つまりこのプリンターはBLE上でESC/POSコマンドを受け付ける。ESC/POSは感熱レシートプリンターの業界標準プロトコルで、コマンド体系が広く文書化されている。

画像を印刷する

ESC/POSのビットマップ印刷コマンド GS v 00x1D 0x76 0x30)で画像を送信する。画像をグレースケール化 → 閾値128で白黒2値化 → 1ビット/ピクセルのビットマップに変換して送る。

import asyncio
from bleak import BleakScanner, BleakClient
from PIL import Image

TX_UUID = "49535343-8841-43f4-a8d4-ecbe34729bb3"

def image_to_escpos(img_path, max_width=384):
    img = Image.open(img_path).convert("RGBA")
    # 透過部分を白背景に合成
    bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
    bg.paste(img, mask=img.split()[3])
    img = bg.convert("L")

    # プリンター幅に合わせてリサイズ
    w, h = img.size
    if w > max_width:
        ratio = max_width / w
        img = img.resize((max_width, int(h * ratio)))
    w, h = img.size

    # 幅を8の倍数にパディング
    pad_w = (w + 7) // 8 * 8
    if pad_w != w:
        padded = Image.new("L", (pad_w, h), 255)
        padded.paste(img, (0, 0))
        img = padded
        w = pad_w

    # 白黒2値化
    img = img.point(lambda x: 0 if x < 128 else 255, "1")
    bytes_per_line = w // 8

    # GS v 0: 1D 76 30 m xL xH yL yH [data]
    header = bytes([
        0x1D, 0x76, 0x30, 0x00,
        bytes_per_line & 0xFF, (bytes_per_line >> 8) & 0xFF,
        h & 0xFF, (h >> 8) & 0xFF,
    ])

    # PILの1-modeは0=黒、ESC/POSは1=黒なので反転
    raw = img.tobytes()
    inverted = bytes([~b & 0xFF for b in raw])
    return header + inverted

async def print_image():
    devices = await BleakScanner.discover(timeout=5.0)
    target = next((d for d in devices if d.name and "YMP" in d.name), None)
    if not target:
        print("YMP not found")
        return

    async with BleakClient(target.address) as client:
        await client.write_gatt_char(TX_UUID, bytes([0x1B, 0x40]))
        await asyncio.sleep(0.5)

        data = image_to_escpos("image.png")

        # BLEのMTU制限があるのでチャンクに分けて送信
        chunk_size = 100
        for i in range(0, len(data), chunk_size):
            await client.write_gatt_char(TX_UUID, data[i:i+chunk_size])
            await asyncio.sleep(0.05)

        await asyncio.sleep(1)
        await client.write_gatt_char(TX_UUID, bytes([0x0A, 0x0A, 0x0A]))

asyncio.run(print_image())

いくつかのパターンで印刷を試した。なお、このプリンターは紙が下から排出されるためビットマップを上から送ると上下逆に印刷される。180度回転してから送る必要がある。

1. 256x256そのまま + 閾値2値化

元画像の256pxをリサイズせず送信。回転補正なし。

  • 送信サイズ: 256x256
  • 2値化: 閾値128
  • 回転: なし(逆さで出力)

元画像: 顔アイコン

パターン1の印刷結果

サイズは小さいがドット絵のくっきり感は一番出ている。

2. 384px拡大 + Floyd-Steinbergディザリング

プリンター幅いっぱいに拡大し、PILのFloyd-Steinbergディザリングで2値化。

  • 送信サイズ: 384x384(NEAREST拡大)
  • 2値化: Floyd-Steinbergディザリング
  • 回転: なし(逆さで出力)

パターン2の印刷結果

ディザリングのドットパターンがピクセルアートのドットと干渉して、逆にぼやけた。ピクセルアートにはディザリングは不向き。

3. 384px拡大 + 閾値2値化

ディザリングなしで384pxに拡大。

  • 送信サイズ: 384x384(NEAREST拡大)
  • 2値化: 閾値128
  • 回転: なし(逆さで出力)

パターン3の印刷結果

256→384は1.5倍で割り切れないため、ピクセルの拡大率にムラが出てジャギーが目立つ。

4. 全身イラスト + 180度回転

別のピクセルアート(全身)で180度回転を適用。

  • 送信サイズ: 384x384(NEAREST拡大)
  • 2値化: 閾値128
  • 回転: 180度(正位置で出力)

元画像: 全身イラスト

パターン4の印刷結果

向きは正しく出た。髪の中間色が飛んでしまうのは閾値2値化の限界で、200dpiの感熱プリンターだとこのあたりが実力。

5. トーン処理済み線画 + 閾値128

漫画用のスクリーントーンが貼ってある線画で試した。

  • 送信サイズ: 384x530
  • 2値化: 閾値128
  • 回転: 180度

元画像: トーン処理済み線画

パターン5の印刷結果(閾値128)

線は出ているが、髪や肌のトーン(薄いグレーのドットパターン)が閾値128では白に飛んでしまう。

6. トーン処理済み線画 + 閾値200

同じ線画で閾値を200に上げて再印刷。

  • 送信サイズ: 384x530
  • 2値化: 閾値200
  • 回転: 180度

パターン6の印刷結果(閾値200)

制服や目の周りのトーンは拾えるようになった。ただし髪のトーンは薄すぎて200でも飛ぶ。1000円の200dpi感熱プリンターだとこのあたりが限界。

テキスト(レシート)を印刷する

ESC/POSのテキスト印刷コマンド(ESC aでセンタリング、文字列 + LF)を直接送ってみたが、何も印刷されなかった。テキストコマンドには対応していないようだ。

代わりにPILでレシート画像をレンダリングし、ビットマップとして送信する方式で対応できた。

from PIL import Image, ImageDraw, ImageFont

def render_receipt(print_width=384):
    font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 20)
    font_large = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 24)

    lines = [
        ("center", font_large, "Kanachan Shop"),
        ("center", font, "========================"),
        ("left", font, "2026/03/15 00:30"),
        ("left", font, "------------------------"),
        ("left", font, "Chibi Sticker    x1  500"),
        ("left", font, "Acrylic Key      x2 1200"),
        ("left", font, "Bunny Headband   x1  800"),
        ("left", font, "------------------------"),
        ("left", font, "Subtotal            2500"),
        ("left", font, "Tax(10%)             250"),
        ("center", font, "========================"),
        ("left", font_large, "Total               2750"),
        ("left", font, "Cash                3000"),
        ("left", font, "Change               250"),
        ("left", font, ""),
        ("center", font, "Thank you!"),
    ]

    # 各行を描画して画像化
    img = Image.new("L", (print_width, 600), 255)
    draw = ImageDraw.Draw(img)
    y = 20
    for align, f, text in lines:
        if align == "center":
            bbox = draw.textbbox((0, 0), text, font=f)
            x = (print_width - (bbox[2] - bbox[0])) // 2
        else:
            x = 10
        draw.text((x, y), text, fill=0, font=f)
        y += 30

    # 180度回転してESC/POSビットマップに変換
    img = img.rotate(180)
    # ... 以降はimage_to_escposと同じ手順

レシート印刷結果

テキストコマンドは通らないが、画像レンダリング経由ならレシート風の出力もできる。日本語フォントを指定すれば日本語のレシートも印刷可能。


1000円ちょいのおもちゃでもBLE接続 → ESC/POSで画像もレシートも出せることが分かった。まだ全然元が取れてないので、次はこれを使ってなんちゃってレジスターを作れるか試してみたい。