小型のサーマルプリンタ「Phomemo M02S」を JavaScript で扱うための下調べ

## はじめに

以下のポストに登場している小型のサーマルプリンタ「Phomemo M02S」（※ 水色のデバイス）を、プログラムで扱うための下調べに関する記事です。

https://x.com/youtoy/status/1859595063661367457

https://x.com/youtoy/status/1903673527070552318

### 購入した背景

このサーマルプリンタは Amazon で買ったものですが、それを購入した理由が「プログラムで扱えるという情報を得たため」でした。

●Amazon.co.jp: Phomemo M02S ミニプリンター スマホ対応 サーマルプリンター ラベルライター 300DPI 白黒 ポータブル型 フォト感熱プリンター ...
 https://www.amazon.co.jp/dp/B086PF62LG

それについてもう少し詳しく書くと、自分も過去にちょこちょこ使っているブラウザの API である「[Web Seriarl API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API)」で扱えるという話を聞いたという状況でした。

### アプリを使った利用について

このサーマルプリンタをプログラムから使うのではなく、スマホアプリから利用する場合は以下を用いる形になります。

https://apps.apple.com/jp/app/phomemo/id1456102145

https://play.google.com/store/apps/details?id=com.quyin.phomemo&hl=ja

#### アプリを使った印刷のテスト

こちらのアプリを使って、簡単な印刷のテストは既に行っています。

![486299204_1859781994854928_1767700707061771959_n.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/8da6a059-5b5b-45f9-80bd-2c0d661bd852.jpeg)

テスト印刷したデータは、[以下のポスト](https://x.com/youtoy/status/1899868351788441621)に添付していた写真です。

![2025-03-23_18-52-06.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/08ec770f-5900-4936-ac73-5956ec098504.jpeg)


## プログラムで扱うという話の関連情報

それでは、「Web Seriarl API」や他の仕組みを使って、このサーマルプリンタをプログラムで扱うという話の情報をメモしていきます。

### Web Serial API を用いた事例

Web Serial API を用いた事例は、Qiita の記事を見つけることができました。

具体的には、以下などです。

●Phomemo M02SにWeb Serial APIで画像を印刷する - Qiita
 https://qiita.com/tana_p/items/33b8a8e9f247e406afa2
●ブラウザからPhomemo M02Sをレシートプリンターにする  - Qiita
 https://qiita.com/tana_p/items/55119df7a7d55cf99de7

#### 利用の流れ

上記の記事を見ると、利用する際の流れは以下となるようです。

1. デバイスを PC と Bluetooth で通信ができるようにペアリング
1. ブラウザで Web Serial API を使って、デバイスとブラウザを通信可能な状態にする（※ Bluetooth でのシリアル通信になるようです）
1. 白黒画像にあたるデータを送り込む

また、上記の記事の 2つ目のほうは、デモ用のページがあったり、以下でコード一式を公開されているようです。

●tanapi/m02s_receipt_print: Print Recipt for Phomemo M02S(Web Serial API)
 https://github.com/tanapi/m02s_receipt_print

### Node.js を用いた事例

また、Node.js ベースのツールもありました。

●vrk/cli-phomemo-printer: a script that prints images to the phomemo m02s
 https://github.com/vrk/cli-phomemo-printer

[index.js](https://github.com/vrk/cli-phomemo-printer/blob/main/index.js) の冒頭部分を見てみると、Bluetooth通信関連の noble やその他のいくつかのパッケージを使っているのが分かります（他は、コマンドライン処理・画像処理系のものがあったりするようです）。

![2025-03-26_01-04-09.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/6486a0b0-6332-471a-a43a-672011ba8aa0.jpeg)

## おわりに

とりあえず軽く調査してみた感じでは、Chrome系ブラウザを使う形でも Node.js を使う形でも、JavaScript での「Phomemo M02S」の利用は問題なく行えそうです。

あとは、実際に JavaScript のプログラムから扱うのを試せればと思います。


### その他

以下は、「Phomemo M02S」のスペックや感熱紙関連の情報が記載された記事です。
JavaScript で扱う話には直接関係ないですが、プリンタを扱っていくのに役立ちそうだったのでメモしてみました。

●特徴・使い方｜PhomemoM02、M02S、M02PRO、M03の違い | らてco.blog
 https://latecoblog.com/phomemo-thermal-printer/
 
●感熱紙プリンターPhomemoのランニングコストを抑えるとプリントが楽しくなる、というお話｜Nero_hiro
 https://note.com/nero_hiro/n/n5d9a8e9ebda4

## 【追記】

この記事を X でポストしたら、以下のコメントをもらったので追記してみます。

https://x.com/come25136/status/1904581857527947267

↓[こちらのコード](https://gist.github.com/come25136/44c0f6efbfc25839c808ffcea5c2347d)を共有いただきました。

<details>
<summary>
【折りたたみ】サーマルプリンタで遊ぶ用のクラス</summary>

```typescript
// 画像をグレイスケール化
function toGrayscale(array: Uint8ClampedArray, width: number, height: number) {
  let outputArray = new Uint8Array(width * height);
  for (let y = 0; y < height; y += 4) {
    for (let x = 0; x < width; x += 4) {
      for (let dy = 0; dy < 4; ++dy) {
        for (let dx = 0; dx < 4; ++dx) {
          const r = array[((y + dy) * width + (x + dx)) * 4 + 0];
          const g = array[((y + dy) * width + (x + dx)) * 4 + 1];
          const b = array[((y + dy) * width + (x + dx)) * 4 + 2];
          const gray = (r + g + b) / 3 | 0;
          outputArray[(y + dy) * width + (x + dx)] = gray;
        }
      }
    }
  }
  return outputArray;
}

// 画像を誤差拡散で2値化
function errorDiffusion1CH(u8array: Uint8Array, width: number, height: number) {
  let errorDiffusionBuffer = new Int16Array(width * height); // 誤差拡散法で元画像+処理誤差を一旦保持するバッファ Uint8だとオーバーフローする
  let outputData = new Uint8Array(width * height);
  for (let i = 0; i < width * height; ++i) errorDiffusionBuffer[i] = u8array[i];

  for (let y = 0; y < height; y += 1) {
    for (let x = 0; x < width; x += 1) {
      let outputValue;
      let errorValue;
      const currentPositionValue = errorDiffusionBuffer[y * width + x];
      if (currentPositionValue >= 128) {
        outputValue = 255;
        errorValue = currentPositionValue - 255;
      } else {
        outputValue = 0;
        errorValue = currentPositionValue;
      }

      if (x < width - 1) {
        errorDiffusionBuffer[y * width + x + 1] += 5 * errorValue / 16 | 0;
      }
      if (0 < x && y < height - 1) {
        errorDiffusionBuffer[(y + 1) * width + x - 1] += 3 * errorValue / 16 | 0;
      }
      if (y < height - 1) {
        errorDiffusionBuffer[(y + 1) * width + x] += 5 * errorValue / 16 | 0;
      }
      if (x < width - 1 && y < height - 1) {
        errorDiffusionBuffer[(y + 1) * width + x + 1] += 3 * errorValue / 16 | 0;
      }
      outputData[y * width + x] = outputValue;
    }
  }
  return outputData;
}

const ESC = 0x1B;
const GS = 0x1D;
const US = 0x1F;

// canvas画像をグレイスケール→誤差拡散で2値化
function getErrorDiffusionImage(cvs: HTMLCanvasElement) {
  const ctx = cvs.getContext('2d');
  if (ctx === null) throw new Error("ctx is null");

  const inputData = ctx.getImageData(0, 0, cvs.width, cvs.height).data;

  const output = ctx.createImageData(cvs.width, cvs.height);
  let outputData = output.data;

  const grayArray = toGrayscale(inputData, cvs.width, cvs.height);
  const funcOutput = errorDiffusion1CH(grayArray, cvs.width, cvs.height)
  for (let y = 0; y < cvs.height; y += 1) {
    for (let x = 0; x < cvs.width; x += 1) {
      const value = funcOutput[y * cvs.width + x];

      outputData[(y * cvs.width + x) * 4 + 0] = value;
      outputData[(y * cvs.width + x) * 4 + 1] = value;
      outputData[(y * cvs.width + x) * 4 + 2] = value;
      outputData[(y * cvs.width + x) * 4 + 3] = 0xff;
    }
  }
  return outputData;
}

// canvasの画像データからラスターイメージデータ取得
function getPrintImage(cvs: HTMLCanvasElement, start_y: number) {
  const inputData = getErrorDiffusionImage(cvs);

  if (start_y > cvs.height) return null;

  let height = (start_y + 255 < cvs.height) ? start_y + 255 : cvs.height;
  let outputArray = new Uint8Array(cvs.width * (height - start_y) / 8);
  let bytes = 0;
  for (let y = start_y; y < height; y++) {
    for (let x = 0; x < cvs.width; x += 8) {
      let bit8 = 0;
      for (let i = 0; i < 8; i++) {
        let r = inputData[((x + i) + y * cvs.width) * 4];
        bit8 |= (r & 0x01) << (7 - i);
      }
      outputArray[bytes] = ~bit8;
      bytes++;
    }
  }

  return outputArray;
}

// 印刷処理
export async function print(cvs: HTMLCanvasElement) {
  let port: SerialPort | null = null;
  let writer = null;
  let reader = null;

  try {
    port = await navigator.serial.requestPort();
    await port.open({ baudRate: 115200 });

    writer = port.writable.getWriter();

    await writer.write(new Uint8Array([ESC, 0x40, 0x02])); // reset
    await writer.write(new Uint8Array([ESC, 0x40]).buffer); // initialize
    await writer.write(new Uint8Array([ESC, 0x61, 0x01]).buffer); // align center
    await writer.write(new Uint8Array([US, 0x11, 0x37, 0x96]).buffer); // concentration coefficiennt
    await writer.write(new Uint8Array([US, 0x11, 0x02, 0x01]).buffer); // concentration

    // 画像出力
    let start_y = 0;
    while (true) {
      let bit_image = getPrintImage(cvs, start_y); // 255ラインのラスターデータを取得
      if (!bit_image) break;

      let width = cvs.width / 8;
      await writer.write(new Uint8Array([GS, 0x76, 0x30, 0x00])); // image
      await writer.write(new Uint8Array([width & 0x00FF, (width >> 8) & 0x00FF])); // width
      let height = bit_image.length / width;
      await writer.write(new Uint8Array([height & 0x00FF, (height >> 8) & 0x00FF])); // height
      await writer.write(bit_image); // raster bit image

      start_y += (height + 1);
    }

    await writer.write(new Uint8Array([ESC, 0x64, 0x03]).buffer); // 行送り(feed line)

    // 印字完了まで待つ
    // await writer.write(new Uint8Array([US, 0x11, 0x0E]).buffer); // 電源が切れるまでの秒数を要求
    // await writer.write(new Uint8Array([US, 0x11, 0x08]).buffer); // 電池残量を要求
    reader = port.readable.getReader();
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        console.log("reader done");
        break;
      }
      console.log("device timer:" + value[2]);
      if (value[2] == 0) break;
    }
    reader.releaseLock();
    reader = null;

    await writer.write(new Uint8Array([ESC, 0x40, 0x02])); // reset

    // writer.releaseLock();
    // writer = null;
    // await port.close();
    // port = null;

    alert("印刷が完了しました！")
  } catch (error) {
    alert("Error:" + error);
    if (writer) {
      writer.releaseLock();
    }
    if (reader) {
      reader.releaseLock();
    }
    if (port) {
      await port.close();
    }
  }
}


export class M02S {
  protected connected = false;
  protected serialPort: SerialPort;

  protected reader: ReadableStreamDefaultReader<Uint8Array> | null = null;

  protected writer: WritableStreamDefaultWriter | null = null;

  /**
   * ブラウザにシリアルポートのリクエストを行い、利用可能なポートリストを表示してもらう
   */
  static async requestSerialPort() {
    const serialPort = await navigator.serial.requestPort();

    return new M02S(serialPort);
  }

  private static commands = {
    /**
     * Initialize
     */
    initialize: () => new Uint8Array([ESC, 0x40]),

    /**
     * Alignment (not functioning, always centered)
     */
    alignment: (alignment: 'left' | 'center' | 'right') => {
      const map = {
        left: 0x00,
        center: 0x01,
        right: 0x02,
      } as const

      return new Uint8Array([ESC, 0x61, map[alignment]])
    },

    /**
     * Concentration coefficient (0x96 = M02S dedicated)
     */
    concentrationCoefficient: (coefficient: number = 0x96) => new Uint8Array([US, 0x11, 0x37, coefficient]),

    /**
     * Concentration
     */
    concentration: (concentration: 'weak' | 'normal' | 'thick') => {
      const map = {
        weak: 0x01,
        normal: 0x03,
        thick: 0x04,
      } as const

      return new Uint8Array([US, 0x11, 0x02, map[concentration]])
    },

    /**
     * Raster bit image
     */
    rasterBitImage: (mode: 'normal' | 'doubleWidth' | 'doubleHeight' | 'quadruple', width: number, height: number, data: Uint8Array) => {
      const modeMap = {
        normal: 0x00,
        doubleWidth: 0x01,
        doubleHeight: 0x02,
        quadruple: 0x03,
      } as const

      return new Uint8Array([
        GS, 0x76, 0x30, // command
        modeMap[mode], // mode
        width & 0x00FF, (width >> 8) & 0x00FF, // width
        height & 0x00FF, (height >> 8) & 0x00FF, // height
        ...data
      ])
    },

    /**
     * Feed lines
     */
    feedLines: (lines: number) => new Uint8Array([ESC, 0x64, lines]),

    /**
     * Number of seconds until the power is turned off
     */
    powerIsTurnedOffSeconds: () => new Uint8Array([US, 0x11, 0x0E]),

    /**
     * Battery capacity
     */
    getBatteryCapacity: () => new Uint8Array([US, 0x11, 0x08]),

    /**
     * Status
     */
    status: () => new Uint8Array([US, 0x11, 0x11]),

    /**
     * Reset
     */
    reset: () => new Uint8Array([ESC, 0x40, 0x02]),

    /**
     * Feed paper cut
     */
    feedPaperCut: () => new Uint8Array([GS, 0x56, 0x01]),

    /**
     * Get firmware version
     */
    getFirmwareVersion: () => new Uint8Array([US, 0x11, 0x07]),
  }

  constructor(port: SerialPort) {
    this.serialPort = port;
  }

  async connect() {
    await this.disconnect()

    await this.serialPort.open({ baudRate: 115200 });
    this.reader = this.serialPort.readable.getReader();
    this.writer = this.serialPort.writable.getWriter();

    this.connected = true;
  }

  async disconnect() {
    if (this.reader) {
      console.log('reader disconnect')
      await this.reader.cancel()
      this.reader.releaseLock()
      this.reader = null
      console.log('reader disconnected')
    }
    if (this.writer) {
      console.log('writer disconnect')
      this.writer.releaseLock()
      this.writer = null
      console.log('writer disconnected')
    }

    if (this.connected) {
      await this.serialPort.close()

      this.connected = false
    }
  }

  async dispose() {
    await this.disconnect()
    await this.serialPort.forget()
  }

  async sendSerial(data: Uint8Array) {
    if (this.writer === null) throw new Error("writer is null")

    await this.writer.write(data.buffer)
  }

  recvSerial(): Promise<Uint8Array> {
    return new Promise<Uint8Array>(async (resolve, reject) => {
      if (this.reader === null) throw new Error("reader is null")

      const timeout = setTimeout(() => reject(new Error("timeout")), 5000)
      const { value, done } = await this.reader.read();
      clearTimeout(timeout)

      if (done) {
        return reject(new Error("reader done"))
      }

      return resolve(value)
    })
  }

  async print(cvs: HTMLCanvasElement) {
    try {
      if (this.reader === null) throw new Error("reader is null")
      if (this.writer === null) throw new Error("writer is null")

      // await this.serialWrite(M02S.commands.reset())
      // const initializeResult = await this.waitRecv()
      // if ((initializeResult[0] === 0x1A &&
      //   initializeResult[1] === 0x0F &&
      //   initializeResult[2] === 0x0C
      // ) === false) throw new Error("initialize failed")
      // console.log("initialize success")

      await this.sendSerial(M02S.commands.alignment('center'))
      await this.sendSerial(M02S.commands.concentrationCoefficient())
      await this.sendSerial(M02S.commands.concentration('weak'))

      // 画像出力
      let start_y = 0;
      while (true) {
        const imageBits = getPrintImage(cvs, start_y); // 255ラインのラスターデータを取得
        if (imageBits === null) break;

        const imageWidth = cvs.width / 8;
        const imageHeight = imageBits.length / imageWidth;
        await this.sendSerial(M02S.commands.rasterBitImage('normal', imageWidth, imageHeight, imageBits))

        start_y += (imageHeight + 1);
      }

      await this.sendSerial(M02S.commands.feedLines(3))

      // 印字完了まで待つ
      await this.sendSerial(M02S.commands.getBatteryCapacity()); // 電源が切れるまでの秒数を要求（何か返ってくる命令投げとけばいい）
      // await writer.write(new Uint8Array([US, 0x11, 0x08]).buffer); // 電池残量を要求
      await this.recvSerial()

      // writer.releaseLock();
      // writer = null;
      // await port.close();
      // port = null;

      alert("印刷が完了しました！")
    } catch (error) {
      alert("Error:" + error);

      await this.disconnect()
    }
  }

  async getFirmwareVersion() {
    await this.sendSerial(M02S.commands.getFirmwareVersion())
    const result = await this.recvSerial()

    return `${result[2]}.${result[3]}.${result[4]}`
  }

  /**
   * 
   * @returns 電池残量（0-100）
   */
  async getBatteryCapacity() {
    await this.sendSerial(M02S.commands.getBatteryCapacity())
    const result = await this.recvSerial()

    return result[2]
  }
}
```

</details>