$30 off During Our Annual Pro Sale. View Details »

iOS デバイスから始める Bluetooth 制御の業務用サーマルプリンター対応アプリの作り方

iOS デバイスから始める Bluetooth 制御の業務用サーマルプリンター対応アプリの作り方

iOS デバイスから始める Bluetooth 制御の業務用サーマルプリンター対応アプリの作り方
https://fortee.jp/iosdc-japan-2024/proposal/77eccb66-ea35-4c9f-aa33-a36ce98569df

Mitsuharu Emoto

July 06, 2024
Tweet

More Decks by Mitsuharu Emoto

Other Decks in Programming

Transcript

  1. iOS デバイスから始める Bluetooth 制御の 業務用サーマルプリンター対応アプリの作り方 江本光晴(株式会社ゆめみ) 𝕏: @mitsuharu_e Bluesky: @mitsuharu.bsky.social

    昨今のペーパーレス化が進む社会でも、サーマル(感熱紙)プリントを見る機会は依然として多いです。 iPhone や iPad を利用した POS レジからレシートを受け取った経験があるでしょう。その iPhone からど のようにレシートを印刷しているのか気になったことはありませんか? 印刷といえば iOS には Wi-Fi に接続されたプリンターを制御する AirPrint があります。しかしながら、業 務用サーマルプリンターは Wi-Fi 非対応の機種も多く、AirPrint をサポートしていません。サーマルプリン ター向けの印刷命令を実行する必要があります。この記事は、iOS デバイスを利用してサーマルプリンターを 制御する方法を取り扱います。Bluetooth が利用可能なサーマルプリンターを対象として、接続と印刷の方法 を説明します。また JavaScript で作られたレシート印刷に便利な OSS を iOS アプリで実行する方法も紹 介します。この記事を読むことで、業務用サーマルプリンター対応アプリの実装方法を理解し、レシートを印 刷できるようになります。今すぐにサーマルプリンターを買い求めたくなるでしょう。 免責事項および商標について 本記事は、製造メーカーが提供するドキュメントや私が所有する数台の実機から調査・検証した内容をまと めました。製造時期により機種のモデルやソフトウェアは異なる場合があるため、記載どおりにならない場 合があります。また、製品名称は各社の商標または登録商標です。™ や ® などの表記は省略します。 開発環境 開発環境は MacBook Pro 14 インチ 2021 、Apple M1 Pro 、macOS Sonoma 14.5 を用いて、Xcode 15.4 (15F31d) で開発しました。検証機として iPhone SE (第3世代) 、iOS 17.5.1 を利用しました。 対象のサーマルプリンターについて 本記事で対象するとサーマルプリンターは SUNMI が製造する「SUNMI 58mm Cloud Printer 」です。 58mm 幅の感熱紙を印刷できるサーマルプリンターで、無線のインタフェースとして Wi-Fi 4 (2.4GHz )と Bluetooth 4.2 BLE を備えています(国内版・技適あり) 。この Bluetooth を利用して、サーマルプリン ターを制御します。なお、私が所持する機種のモデルおよびソフトウェアのバージョンは次のとおりです。 Model Firmware version SUNMI APP version Partner APP version MiniApp version NT212_S 2.1.0 2.2.0 1.0.10 0.0.1 その他に、80mm 幅に対応した兄弟機「SUNMI 80mm Kitchen Cloud Printer 」 、セイコーエプソン(以 降、エプソン)のモバイル機「TM-P20II 」を所有しています。これらでも動作確認を行なっています。
  2. サーマルプリンターのページ記述言語 ページ記述言語はプリンターに対して印刷を指示するためのプログラミング言語です。アドビが開発した PostScript が有名です。その他に、エプソンが開発した ESC/P (Epson Standard Code for Printers

    )が あります。ドットインパクトプリンタが主流だった時代には、多くのメーカーがサポートしていました。その ESC/P のバリエーションの1つとして、POS 端末に採用されたサーマルプリンターを制御する ESC/POS が あります。この言語は現在も多くのサーマルプリンターでサポートされています。私が所有している3台の サーマルプリンターも ESC/POS をサポートしています。つまり、この ESC/POS の命令(コマンド)を利 用すれば、サーマルプリンターで印刷ができます。 ESC/POS コマンド ESC/POS は、プリンターに送信されるコマンド(16 進数のバイトコード列)です。そのコマンドを組み合 わせて、さまざまな印刷パターンを制御します。たとえば、次のようなコマンドがあります。 コマンド 説明 コード ESC @ プリンターを初期化する 1b 40 LF 改行する 0a ESC E n n=1 のとき太字にする 太字オン 1b 45 01 太字オフ 1b 45 00 例として、太字の「Hello World 」を印刷するコマンドを考えましょう。 「Hello World 」を ASCII コード で表すと「48 65 6c 6c 6f 20 57 6f 72 6c 64 」になるので、次のようなコマンドになります。 1b 40 // 初期化 1b 45 01 // 太字オン 48 65 6c 6c 6f 20 57 6f 72 6c 64 // Hello World 0a // 改行 このコマンドをサーマルプリンターに渡せば「Hello World 」が印刷されます。これを実現するため、 iPhone でサーマルプリンターを制御する方法を紹介します。 Bluetooth による制御方法 CoreBluetooth を用いて、サーマルプリンターを制御します。私はライブラリ AsyncBluetooth を採用し ました。CoreBluetooth が提供する API は Delegate を多用するため、コードは複雑になります。一方、 AsyncBluetooth は Swift Concurrency でシンプルに書けます。それを利用して、サーマルプリンターを接 続および制御する方法を紹介します。ソースコードは紙面の都合上、簡略表示します。実際にソースコードを 書く際は付録する GitHub リポジトリを参照してください。なお、Bluetooth を利用するので、Info.plist に許可設定と利用理由を忘れずに追加しましょう。 <key>NSBluetoothAlwaysUsageDescription</key> <string>Use to connect with thermal printers</string> 1
  3. まず最初に Bluetooth デバイスのスキャンを行い、サーマルプリンターとなる Peripheral を探します。対象 のサーマルプリンターは「CloudPrint_{ 数字} 」という名前が設定されているので、その名前が付けられた機 種を選択して接続します。例ではスキャンされた機種の名前を逐次確認して選択しましたが、一般には検出さ れた機種を一覧表示して、目視確認してから選択するとよいでしょう。

    import AsyncBluetooth let manager = CentralManager() try await manager.waitUntilReady() let stream = try await manager.scanForPeripherals(withServices: nil) for await scanData in stream { if let name = scanData.peripheral.name, name.contains("CloudPrint") { try await manager.connect(scanData.peripheral, options: nil) await manager.stopScan() } } 接続した Peripheral から、印刷に関するサービス(serviceUUID )および、そのサービスのデータを制御 するキャラクタリスティック(characteristicUUID )を取得します。今回はデータ送信するため、書き込み 可能なキャラクタリスティックを選択します。例では、単純に条件に合う最初の組み合わせを選択してます が、実際は機種のドキュメントを確認して、適切なサービスとキャラクタリスティックを選択してください。 try await peripheral.discoverServices(nil) for service in peripheral.discoveredServices ?? [] { try await peripheral.discoverCharacteristics(nil, for: service) guard let serviceUUID = UUID(uuidString: service.uuid.uuidString), let char = service.discoveredCharacteristics?.first(where: { $0.properties.contains(.write) }), let characteristicUUID = UUID(uuidString: char.uuid.uuidString) else { continue } return (serviceUUID, characteristicUUID) } これで準備が揃いました。サーマルプリンターにデータを送信する関数を作成しましょう。印刷するので関数 名を print としたいところですが、すでに標準出力に出力する有名な関数があるので、我慢しました。 func send(data: Data) async throws { try await peripheral.writeValue( data, forCharacteristicWithUUID: characteristicUUID, ofServiceWithUUID: serviceUUID ) }
  4. iOS アプリで ESC/POS コマンドを実装する 先の例で挙げた「Hello World 」を印刷するコマンドを、前節の Bluetooth の制御関数で実行します。 var

    command = Data() command.append(contentsOf: [0x1b, 0x40]) // 初期化 command.append(contentsOf: [0x1b, 0x45, 0x01]) // 太字オン command.append(contentsOf: [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64]) // Hello World command.append(contentsOf: [0x0a]) // 改行 try send(data: command) 「プリンターにコマンドを送る」は難しい印象がありますが、簡単なコードで実現できます。なお、一度に書 き込めるデータのサイズに上限があるので、その上限サイズを調べて、データを分割して書き込みます。 // これで上限値が取得できるが、値は適切ではない(値が大きく、印刷失敗する) // 実際のアプリでは、適当な固定値を設定しました let mtuSize = peripheral.maximumWriteValueLength(for: .withResponse) 上記の例は直接コマンドを書いているため、可読性は悪いです。一例ですが、実際にコーディングする際は enum で印刷命令を定義して、それに対応するコマンドを返すと可読性は保たれるでしょう。 enum PrintOrder { case bold(isBold: Bool) func command() -> Data { switch self { case .bold(let isBold): return Data([0x1b, 0x45, isBold ? 0x01 : 0x00]) } } } 先に挙げたコマンド以外で、レシートを印刷する際に便利な ESC/POS コマンドのいくつかを Swift のコー ド実装と共に紹介していきます。なお、今回はコマンドを簡単に説明します。詳細はメーカーが提供するド キュメント を確認してください。 日本語 例では英字を印刷しましたが、日本語も印刷できます。私が保持しているサーマルプリンターは ShiftJIS で エンコードしたものを利用します。 var command = Data() if let textData = "こんにちは世界".data(using: .shiftJIS) { command.append(textData) } return command 2
  5. フィード (紙送り) 印刷した直後の用紙位置はサーマルヘッドの位置のままなので、適度に紙送りをします。改行コードの 0a で代用できますが、複数行分の紙送りをするので専用のコマンドを利用するとよいです。 コマンド 説明 コード ESC d

    n n 行の紙送りをする 1b 64 n return Data([0x1b, 0x64, UInt8(n)]) // n は自然数 文字サイズ 文字のサイズを指定します。絶対値ではなく倍率(1 ~ 8 倍)を指定します。縦横それぞれの倍率はビットマ クスを利用して、1つの値でそれぞれの倍率を指定します。分かりにくい指定方法が出てきましたね… 。 コマンド 説明 コード GS ! n 縦横最大 8 倍 n のビットの 0-3 が横、4-7 が縦の倍率(縦横 1 倍なら 00 ) 1d 21 n let widthScale = UInt8(16 * (width - 1)) // width は 1 ~ 8 の範囲 let heightScale = UInt8(height - 1) // height は 1 ~ 8 の範囲 return Data([0x1d, 0x21, widthScale + heightScale]) 画像 二値画像を印刷します。コマンドで、画像印刷用の命令、横と縦のサイズ、そして画像情報のバイトコード列 を指定していきます。 コマンド 説明 コード GS v 0 m xL xH yL yH d1....dk 二値画像を印刷する 1d 76 30 m xL xH yL yH d1....dk m は印刷モードを指定します。xL 、xH 、yL 、yH は画像サイズで、次の式を満たす値です。横サイズは 1byte で 8 つ分の画像情報を表現するので、実際の横サイズとは異なります。 width = (xL + xH * 256) * 8 height = (yL + yH * 256) d は画像データです。1bit ごとに画像情報(0 or 1 )を表現する 1bit Bitmap です。たとえば、n 番目の画 像情報を c(n) とすると、i 番目の d は次のように設定します。i は 0 から (width * height)/8 の間の自然 数です。なお、横サイズが 8 の倍数でなければ 0 を埋めておきます。
  6. d[i] = c(i*8+0) << 7 | c(i*8+1) << 6 |

    c(i*8+2) << 5 | c(i*8+3) << 4 | c(i*8+4)<< 3 | c(i*8+5) << 2 | c(i*8+6) << 1 | c(i*8+7) 以上からコマンドを作成します。紙面の都合上、画像変換の関数の実装紹介は省略します(付録を参照してく ださい) 。なお、私はビットマクスを日常的には使わない、画像変換も久々にやったので、なかなか上手くい かず試行錯誤しました。難しいです… 。誤って画像じゃなくて画像データを延々と印刷しちゃった。わァ… let m = UInt8(0) // 標準モード let xL = UInt8((width / 8) % 256) let xH = UInt8((width / 8) / 256) let yL = UInt8(height % 256) let yH = UInt8(height / 256) let imageData: [UInt8] = convert1BitBitmap(image) return Data([0x1d, 0x76, 0x30, m, xL, xH, yL, yH] + imageData) 他プラットフォームにおける ESC/POS コマンドの実行 ESC/POS はページ記述言語なので、iOS には依存していません。もちろん他プラットフォームでも利用でき ます。同様な Bluetooth の制御関数を用意したら Kotlin や JavaScript でも利用できます。好きな言語や環 境で試してみてください。 // Kotlin val bold = byteArrayOf(0x1b, 0x45, if (isBold) 0x01 else 0x00) send(bold) // JavaScript const bold = new Uint8Array([0x1B, 0x45, isBold ? 0x01 : 0x00]); send(bold); ESC/POS コマンドの問題と対応 前節で ESC/POS コマンドを利用した印刷方法を紹介しました。一部で難しいコマンド設定がありましたが、 一度作ってしまえば、他プラットフォームでも利用できるということで、移植も容易です。とてもよいです ね… といいたいところですが、重大な問題があります。ESC/POS コマンドはメーカーごとに一部のコマンド が異なっています。いわゆる方言がメーカーそれぞれにあります。たとえば、先ほど紹介した画像印刷です が、エプソンのサーマルプリンターでは利用できません。エプソン版では、まず画像データをプリンターに キャッシュするコマンドを実行してから、そのキャッシュを印刷するという二段階で画像を印刷します。 我々 iOS アプリエンジニアは(バージョンで差異はありますが)1つの Swift で開発しているので、環境そ れぞれでコマンドが異なるのは衝撃的です。それを知ると、バイトコードは複雑だし書きづらい、ESC/POS コマンドは書きたくない!と手のひらを返します。しかし、捨てる神あれば拾う神あり、この問題を解決す るレシート印刷に便利な OSS が存在します。
  7. ReceiptLine ReceiptLine は、小型ロール紙の出力イメージを表現するレシート記述言語の OSS です 。マークダウン でレシートを書いて、そのマークダウンを ESC/POS コマンドに変換します(なお、印刷までサポートして ますが、今回は取り上げません)

    。コマンドの記述は複雑なのでマークダウンで書けるのは便利ですね。ま た、先ほどコマンドはメーカーごとに異なると書きましたが、この ReceiptLine は ESC/POS コマンドの他 に、SVG でも出力できます。SVG はアプリ内で画像に変換できるので、画像印刷さえコマンドで準備した ら印刷できます。よかったね!といいたいところですが、今回も問題があります。この ReceiptLine は JavaScript で作られており、Swift への移植はありません。 JavaScript のライブラリを iOS で動かす Swift 移植版を作りたいが難しいと詰んだところに、一筋の光明が差す。iOS は JavaScriptCore を持って いるので、JavaScript のライブラリを実行できます。準備として、その ReceiptLine を手元に用意します。 mkdir js-packages cd js-packages yarn init yarn add receiptline この用意した Receiptline をすぐに読み込みたいところですが、JavaScript のファイル構成や他ライブラリ 依存性の問題で簡単には読み込めません。そこで、webpack を利用して、読み込みやすい形に作成しま す。まず、JavaScript のブリッヂとなるクラスで ReceiptLine の関数を定義します。 import { transform } from "receiptline" export class Bridge { static transformSvg(doc) { const display = { cpl: 42, encoding: 'multilingual' } const svg = transform(doc, display) return svg } } このブリッヂファイルから webpack の設定ファイルに基づいて、バンドルファイルを生成します。設定ファ イルの記述に関しては省略します。サンプルリポジトリ を参照してください。生成されたバンドルファイル を bundle.js とします。 yarn add -D webpack webpack-cli yarn webpack バンドルファイルを iOS アプリのプロジェクトに追加します。フレームワーク JavaScriptCore を import して、JSContext でそのファイルを読み込みます。 3 4 5
  8. import JavaScriptCore guard let path = Bundle.main.path(forResource: "bundle.js", ofType: nil),

    let contents = try? String(contentsOfFile: path) else { throw Error() } let context: JSContext = JSContext(virtualMachine: JSVirtualMachine()) context.evaluateScript(contents) この context に対して webpack で設定したモジュール名や関数名を頼りに関数を取得して、実行します。 これらの詳細は先日の勉強会で発表したので、そのスライド を参照してください。 let module = context.objectForKeyedSubscript("Module") let bridge = module?.objectForKeyedSubscript("Bridge") let transformSvg = bridge?.objectForKeyedSubscript("transformSvg") let svg = transformSvg?.call(withArguments: [markdownText]) (おまけ) SVG を画像化する WKWebView の takeSnapshot(with:completionHandler:) を利用すれば SVG を簡単に画像化できま す。それで生成した画像を ESC/POS コマンドで印刷しましょう。 まとめ Bluetooth と ESC/POS コマンドを利用して、iPhone でサーマルプリンターを制御する方法を紹介しまし た。正直なところ、もしメーカーが SDK を公開していたら、その SDK を利用する方がよいです。私が所有 している SUNMI のサーマルプリンターには SDK がありますが、ソフトウェアのバージョンが動作要件を満 たしてないため非対応でした。ESC/POS コマンドを利用するしかありませんでした。なお、エプソンのサー マルプリンターには SDK があります。SDK の有無で開発を比べると、エプソンの方が開発体験は圧倒的に よかったです。では「どうして今回 ESC/POS コマンドを取り上げたの?」ですが、単純に面白いからです。 今回紹介した内容をもとに開発している印刷アプリの GitHub リポジトリを付録します。現在も開発中のた め、ソースコードは変更される場合があります。ご了承ください。よいサーマルプリンターライフを! https://github.com/mitsuharu/Calliope 1. https://github.com/manolofdez/AsyncBluetooth 2. https://developer.sunmi.com/docs/en-US/xeghjk491/ciqeghjk513 3. https://www.ofsc.or.jp/receiptline_/ 4. https://webpack.js.org/ 5. https://github.com/mitsuharu/UseJavaScriptPackages 6. https://speakerdeck.com/mitsuharu/2024-05-17-javascript-multiplatform 7. この記事は https://github.com/mitsuharu/iosdc-2024-pamphlet でも公開しています 6 7