TauriからESC/POSでレシートプリンタを制御して搭乗券を作る
Rust 未経験のフロントエンド中心に書いている私が,ESC/POS 対応プリンターで搭乗券を発券できるシステムを作った話です。
経緯(業務機器オタク話)
今夏は福岡・バンコク・ホーチミン・クアラルンプール・シンガポール旅行を楽しんでいました。
ところで,空港のチェックインカウンターに設置されているコンピュータ端末は 「CUTE(Common Use Terminal Equipment)」という共有機器で,大抵の空港ではCollins (旧 ARINC) [1] や SITA [2] などの企業が展開する CUTE 用のシステム一式を導入しています。
この CUTE という仕組みは,CRS/GDS(いわゆる予約システム)に Amadeus[3]を使っていようが,Travelport[4] を使っていようが,基本的にはこのコンピュータを共用することになっているそうです。
閑話休題,福岡空港でチェックインが少し揉めたので[5],その間この CUTE 機器を観察していたところ,設置されている搭乗機の印刷装置に目が止まりました。
それはどこからどう見ても NEC の MultiCoder 300S2DC あるいはその OEM だったのです。この機器は富士通が OEM を受けていると思しき FP-510 の元品番であり,富士通は航空業界向けに当該機種をFujitsu Printer 9860という名前で発売していることまでわかってしまいました。(なお SITA 配備品についてはメーカー名のところに SITA ステッカーがあり,流石に空港機器をひっくり返したりはしていないので,実際にどの品番であるかは不明)
このレシートプリンターの品番がわかった理由としては,大学 1 年生の頃に当該機種(NEC 版)を実際に保有しているためです。帰国後改めて本体の裏を見ると,搭乗券が 1 枚くらいすり抜けられそうな隙間が開いていて, Bingo だな,と確信しました。
更に,クアラルンプール(KLIA T1)に設置されていた搭乗券発行用のサーマルプリンタも同じ型番と見られる外見で,おそらく SITA 系は富士通製プリンターを全面的に導入している可能性が高い,と考察しました。
ここまでの機器導入に関する考察はどうでもいいのですが,実機が手元にあるのであれば自分で搭乗券を発券できるのではないか,という予想に基づき,勝手に GDS もどきを作って搭乗券を発行しようと思った次第です。
さらに,ちょうど私の友人が旅行に行っており,ガチっぽい搭乗券を到着地で渡せばドッキリにいいかな,とも思いました。彼の帰京日である 2 週間後までになんとか作り上げることにしました。
機器構成
その開発当時,手元で壊しても問題なさそうな PC として中古 Windows デスクトップがありましたから,当該機をホストにすることにしました。
本来 CUTE 端末は Windows で動作するのですが,個人的経験から Windows と UNIX 系 OS では挙動が変わったり,Windows 側の権限の都合で使えない機能が一定数存在することを把握していたので,元 Windows 機に Ubuntu を入れ,その上で動作させることにしました。
プリンターは普通に USB のインターフェイスがついている一方で,通常空港ターミナルでは 2 台のプリンターを接続していることから,2 台以上のプリンターがつながっている想定で実装することにしました。[6]
また,気分を高めるため,Alibaba で搭乗券用紙を購入しました。経営破綻後のタイ航空の書式と大体同じです。
実装について
方針
とりあえず書式を覚えていることもあり,ANA 国際線の搭乗券と同等のものを発券できることを目標としました。ANA の搭乗券は凝視すると MS ゴシックや Arial[7]・メイリオ[8]で印字されている箇所があることから,大半の部分は画像ファイルとして展開していることが予想されます。
それ以外の部分については,空港によって書体が違うことから各プリンター内蔵の書体である可能性が高いです。しかし,私がプリンターのマニュアルを見る限りでは同一書体のファイルを見つけることができませんでした。そのため,PC 内蔵の書体で代用することとしました。
ソフトウェアとして GUI を用意したいので,任意のネイティブソフト用ライブラリを使用することとしました。Rust が人気の言語となってきていることを踏まえ,Tauri を採用しました。
そして,やはり空港のチェックイン端末は複数台。サーバーと通信を行った方が気分が上がるので,GDS もどきを自作しました。(といっても,最低限の PNR のデータを保存しているだけなんですが...)
バックエンド
無料だし手慣れているので,Hono + Cloudflare Workers + D1 で実装しました。
搭乗券のバーコードの実装に必要なデータ+券面に表示する remark(備考) だけ保存することにしました。IATA の BCBP という形式があり,これをバーコード(PDF417)に起こすと実装できます。
型の実装はnode-red-contrib-iata-bcbpを参考に,これに Remark というプロパティ(String)を追加しました。
発券時にはフロントエンドでは情報登録はこれを並べた文字列を,それ以外の部分はいい感じに JSON API を実装しました。
フロントエンド&ネイティブ
Tauri はフロントエンド部分を一般的な WEB 関係のスタック(React, Vue など)で,ネイティブの部分を Rust で実装します。基本的には大半をフロントエンドで実装します。
フロントエンドとネイティブの棲み分け
フロントエンドは React + Vite で開発し,ヘッドレス UI として Radix UI を採用しました。状態管理等は全てフロントエンドで実施し,印刷の処理や USB デバイス一覧の取得などのみを Rust で実装しました。
Rust ではescposとrusbの crate を使用しました。escpos では印刷の処理を,rusb ではデバイス情報の管理に使用しました。
以下のような関数を用意し,デバイス名と Vendor ID, Device ID を取得し,これをフロントエンド側で管理することにしました。
struct DeviceInfo {
vendor_id: u16,
device_id: u16,
name: String,
}
#[tauri::command]
fn get_usb_devices() -> Vec<DeviceInfo> {
let mut devices = vec![];
for device in rusb::devices().unwrap().iter() {
let device_desc = device.device_descriptor().unwrap();
devices.push(DeviceInfo {
vendor_id: device_desc.vendor_id(),
device_id: device_desc.product_id(),
name: device_desc.product_string_index().map_or_else(|| "".to_string(), |i| {
let handle = device.open().unwrap();
handle.read_string_descriptor_ascii(i).unwrap_or_else(|_| "".to_string())
}),
});
}
devices
}
印刷側で vendor_id, device_id を毎度指定する実装になっているので(UsbDriver::open
以降),以下のような関数を用意することでフロントエンド側で管理することができます。
#[tauri::command]
fn text_print(vendor_id: u16, device_id: u16, text: String) -> Result<(), String> {
let driver = UsbDriver::open(vendor_id, device_id, None)
.map_err(|e| e.to_string())?;
let protocol = Protocol::default();
let mut printer = Printer::new(driver, protocol, None);
let printer = printer.init().map_err(|e| e.to_string())?;
printer
.writeln(&text)
.and_then(|p| p.feeds(10))
.and_then(|p| p.print_cut())
.map_err(|e| e.to_string())?;
Ok(())
}
搭乗券画像の作成
ESCPOS で印刷される画像は 2 値の画像であり,だいたい 6~7 割くらいのところで黒に変わります。なので,PowerPoint で以下のような画像を作成します。
これをフロントエンドから参照できる領域(public
ディレクトリ)に保存します。個別の事項をフロントエンド上で画像として作成するため,Canvas で上に書き込む形で実装しました。Canvas で書き込むときの好きポイントとして,WEB サイトで使える大半のフォント(WEB フォント含む)が使用できるので好きなフォントを使用できます。Windows 上で動かせば,MS ゴシックなども使えるはずです。
結果的には,Roboto Mono を使って書き込みました。日本語を書き込むなら Source Code JP をフォントとして参照するといいと思います。
この画像を最終的に ArrayBuffer に起こし,引数として渡すことで,印刷が可能です。
#[tauri::command]
fn image_print(vendor_id: u16, device_id: u16, image: Vec<u8>) -> Result<(), String> {
let driver = UsbDriver::open(vendor_id, device_id, None)
.map_err(|e| e.to_string())?;
let protocol = Protocol::default();
let mut printer = Printer::new(driver, protocol, None);
let printer = printer.init().map_err(|e| e.to_string())?;
printer
.bit_image_from_bytes_option(&image, BitImageOption::new(Some(800), None, BitImageSize::Normal).unwrap(),)
.and_then(|p| p.print())
.map_err(|e| e.to_string())?;
Ok(())
}
おまけ(escpos で日本語の印刷をしたい人へ)
なお,上記の text_print()
関数は文字を印刷するものですが,文字をプリンタ側で印刷することを諦めた理由の一つに漢字モードの実装があります。カタカナまでは標準の ESCPOS ライブラリでも印刷できるのですが,漢字モードを使用するには漢字モードに切り替える実装を自分で書く必要があります。python-escpos-jpなどの実装に見られる通り,JIS(SHIFT-JIS ではない)と互換性のある EUC-JP に変換すれば概ね印刷することができます。ただ,今回策定した仕様では日本語を印刷できる必要性が皆無だったためこのような実装をすることはありませんでした。
ビルドと実機テスト
ビルドは GitHub Actions で,各 OS ごとにpnpm tauri build
を動かすことで実行できます。今回は Ubuntu-latest(amd64)を使用しましたが,arm64 であればおそらくラズパイでも動くのでしょう。
いい感じに ci で release を作成し,release にインストーラーの zip ファイルをアップロードする処理を書くことで,ダウンロードも簡単になりました。
なお,Linux は USB へのアクセスに適切な権限を振る必要がありました。そこを実装するのが面倒だったため,sudo で起動するという"パワーで解決"なソリューションを採用しました。
実機運用にあたり,画像のサイズの指定がうまくいかないなどの試行錯誤を経ながらなんとか動作するコード(と画像)を作成できました。
本番運用
このアプリは,友達へのドッキリ用に用意しました。メールとかを実装するのが厳しすぎたので事前チェックインメール等は送付できなかったのですが,とりあえず搭乗券だけ渡しました。
そこそこウケました。
終わりに
地味に rust で escpos を使って制御する関係の,日本語での記事が少ないと思うので参考になれば幸いです。互換プリンターが多い規格なので中華メーカーから NEC の一部機種まで使えます。ご参考になれば幸いです。
業務用機器を手に入れるには,官公庁オークションとヤフオクをウォッチするのがおすすめです。今回のプリンターの他,サーバーやバーコードリーダーが手に入っています。
業務用ハードウェアは信頼性が高くなるように作られているものも多く,中古で買っても十分楽しめます。ぜひ皆さんも各自のご家庭で業務用システムを作ってみてください。
-
日本では成田空港などが導入 https://flyteam.jp/news/article/58687 ↩︎
-
日本では関西空港などが導入 https://www.travelvision.jp/news/detail/news-31472 ↩︎
-
ANA,JAL など多くの FSC が導入 ↩︎
-
Delta が導入 ↩︎
-
前日の台風の影響で,羽田福岡便(第 1 区間)に搭乗せず福岡バンコク便(第 2 区間)に搭乗しようとした関係で,TG の職員さんが本国のチームか何かに確認する羽目になってチェックインに 10 分くらいかかった ↩︎
-
なぜか自宅に複数台のサーマルプリンターが転がっているので,ちょうどテストできました ↩︎
-
便名/FLIGHT など人ごとに異なる場所以外のほぼ全て ↩︎
-
搭乗口へは出発時刻の 30 分前までに...の部分 ↩︎
Discussion