C#+POS for .NETでサーマルプリンター等のPOS機器を制御する

**これは「C#によるPOSレジ・サーマルプリンター開発入門」と称して連載している記事の1つです。他は[こちら](https://qiita.com/Automatic9045/items/0c8d05e1aa6255f7bd1c)**

やっとプログラミングに辿り着きました。ここまで長かった……

#1. はじめに
この記事では、Microsoft Point of Service for .NET（POS for .NET）を用いてPOS機器を制御する方法を説明します。
なお、私に教えてくれるような人など周りにいなかったため、ここに書いてあるのはサンプルやマニュアルの見ながらの完全独学な知識です。「ここ良くないよー」という所があれば教えて下さると泣いて喜びます。

機器を購入する際のポイントやPOS for .NET以外の機器制御方法については、記事の一番上にあるリンクから見ることが出来ます。良ければ参考にして下さい。

#2. この記事の対象読者
- POS開発に興味があるが、やったことは無くやり方も知らない人
- 面白そうだったので感熱プリンターを買ってみたものの、動かし方が分からず途方に暮れている人

#3. POS for .NETって？
Microsoftが定めた、POS機器を扱うための共通規格です。機器メーカーにPOS for .NETを通じて動作させるドライバーを作らせることで、使用する機器を入れ替えても今までのコードをそのまま使えるのがポイントです。

#4. 基礎
※POS for .NETで機器を制御するには、事前にPOS for .NETとその機器のOPOS（POS for .NET）対応ドライバーをPCにインストールする必要があります。

## 機器を動作させ、終了するまでの流れ
1. `Open()`する
2. `Claim()`する
3. `DeviceEnabled`を`true`に
4. 印刷とか表示とか
5. `DeviceEnabled`を`false`に
6. `Release()`する
7. `Close()`する

このあたりの流れは、POS for .NETのSDKに入っているサンプルアプリケーション（Microsoft Point Of Service\SDK\Samples\Sample Application\TestApp.exe）が非常に分かりやすいです。
![初期状態](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/e2f90fc1-6db6-9ce9-c428-0b82fc6a67fd.png)


左のメニューから制御したい機器を選択します。ここではEPSON OPOS ADK同梱のSetupPOSで事前に登録しておいたEPSON DM-D30を使用します。なお、このサンプルアプリケーション内で使える機器のシミュレーターも準備されているので、機器を持っていなくても使い勝手を試してみることが出来ます。
![LineDisplayを選択](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/7bb25bc8-51be-4bf0-4a60-9097674851d1.png)

Openボタン→Claimボタン→DeviceEnabledチェックボックスの順に押します。これで機器が使用可能が状態になりました。
![機器が使用可能な状態になりました](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/e2697eeb-dd75-a2f8-a0bc-046d0a5db612.png)

ここでは無難に「Hello, POS for .NET!」とでも表示することにします。「Hello, POS for .NET!」と入力し、Display Textボタンを押します。
問題が無ければ、これで無事表示されたはずです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/b0ae17fd-7e43-d617-c3bb-e5fabdb75e3f.jpeg" alt="無事表示されました" title="無事表示されました" width="600">

終了するときは、DeviceEnabledチェックボックス→Releaseボタン→Closeボタンの順に押します。
![c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/09e8e610-02c4-e3b1-5c09-e9091371063d.png)

OPOSの正式名称は「OLE for Retail POS」、要するにPOS for .NETのラップ元はOLEです。なので、<font color="red">**この終了処理を怠ると、オブジェクトが解放されずプロセスに残り続けます。また、プロセスが残るせいで、他のアプリケーションがOPOSのサービスを利用できなくなります。**</font>終了処理はちゃんとやりましょう。

#5. 実際にコードを書いてみる
前項でボタンやチェックボックスを操作していたところをコードに落とし込むのみで、基本形が完成します。

以下のサンプルコードを使う場合は`Microsoft.PointOfService`名前空間をusingして下さい。

## カスタマーディスプレイの場合
### 前準備
```C#
PosExplorer posExplorer = new PosExplorer();
DeviceInfo deviceInfo = null;
deviceInfo = PosExplorer.GetDevice(DeviceType.LineDisplay, "MyLineDisplay"); // 論理デバイス名が「MyLineDisplay」のカスタマーディスプレイを見つける
LineDisplay lineDisplay = (LineDisplay)PosExplorer.CreateInstance(deviceInfo); // DeviceInfoからカスタマーディスプレイのインスタンスを作成
lineDisplay.Open();
lineDisplay.Claim(1000); // 1000ミリ秒でタイムアウトし、Claimに失敗する
lineDisplay.DeviceEnabled = true;

lineDisplay.CharacterSet = 932; // カスタマーディスプレイで表示させる言語に日本語を設定
```

### 実際に動作させる部分のコード
```C#
lineDisplay.DisplayText("Hello, POS for .NET!", DisplayTextMode.Normal); // カスタマーディスプレイに「Hello, POS for .NET!」と表示
```

### 後始末
```C#
lineDisplay.DeviceEnabled = true;
lineDisplay.Release();
lineDisplay.Close();
```

どう書くのか知ってさえいれば簡単ですね。

## カスタマーディスプレイで一部のみスクロール表示させる
ちなみにカスタマーディスプレイの場合は、ウィンドウを作成して1行目に「いらっしゃいませ」を固定表示し、2行目にお知らせをスクロール表示する、といったことも出来ます。最適化できているかは怪しいですが、以下のように書きます。

```C#
// LineDisplay.CreateWindow(viewportRow, viewportColumn, viewportHeight, viewportWidth, windowHeight, windowWidth);

// ウィンドウ（ID=0）
// ウィンドウのIDは作った順につけられていきます
lineDisplay.CreateWindow(0, 0, 1, 20, 1, 21); // ウィンドウを作成
lineDisplay.MarqueeFormat = DisplayMarqueeFormat.Place; // 固定表示に設定
lineDisplay.MarqueeType = DisplayMarqueeType.Init; // 初期化処理中フラグを立てる
lineDisplay.DisplayText("いらっしゃいませ", DisplayTextMode.Normal);
lineDisplay.MarqueeType = DisplayMarqueeType.None; // スクロール無しで表示

// ウィンドウ（ID=1）
string scrollingMessage = "ご利用ありがとうございます。只今の時間帯、お惣菜がなんと９割引！是非お買い求め下さい！！";
lineDisplay.CreateWindow(1, 0, 1, 20, 1, 20 + 2 * scrollingMessage.Length); // ウィンドウを作成。全角は2文字としてカウント
lineDisplay.MarqueeFormat = DisplayMarqueeFormat.Walk; // スクロール表示に設定
lineDisplay.MarqueeType = DisplayMarqueeType.Init; // 初期化処理中フラグを立てる
lineDisplay.MarqueeRepeatWait = 1000; // スクロールし切ってからまた現れるまでのミリ秒数
lineDisplay.MarqueeUnitWait = 100; // 半角1文字分スクロールするのにかかるミリ秒数
lineDisplay.DisplayText(scrollingMessage, DisplayTextMode.Normal);
lineDisplay.MarqueeType = DisplayMarqueeType.Left; // 左方向にスクロール
```

## サーマルプリンターの場合
カスタマディスプレイ以外でも、前準備と後始末の書き方はほぼ同じです。

### 前準備
```C#
PosExplorer posExplorer = new PosExplorer();
DeviceInfo deviceInfo = null;
deviceInfo = PosExplorer.GetDevice(DeviceType.PosPrinter, "MyPosPrinter"); // 論理デバイス名が「MyPosPrinter」のサーマルプリンターを見つける
PosPrinter posPrinter = (PosPrinter)PosExplorer.CreateInstance(deviceInfo); // DeviceInfoからサーマルプリンターのインスタンスを作成
posPrinter.Open();
posPrinter.Claim(1000); // 1000ミリ秒でタイムアウトし、Claimに失敗する
posPrinter.DeviceEnabled = true;
```

### 後始末
```C#
posPrinter.DeviceEnabled = true;
posPrinter.Release();
posPrinter.Close();
```

## サーマルプリンターで印刷する基礎
カスタマーディスプレイと異なり、印刷に関しては難解です。
まずは基本的なコードです。「Hello, POS for .NET!」と印刷し、紙をカットします。

```C#
posPrinter.PrintNormal(PrinterStation.Receipt, "Hello, POS for .NET!"); // サーマルプリンターで「Hello, POS for .NET!」と印刷
posPrinter.PrintNormal(PrinterStation.Receipt, "\u001b|100fP"); // ESC|100fP：カッター位置までフィードし、フルカット。100を1～99に変えるとパーシャルカット（1点残しカット）、fPをPに変えるとフィードせずカットのみ行う
```

印字以外の特殊な操作を行うには、エスケープシーケンスを`PrintNormal`メソッドから送信します。このエスケープシーケンスはESC/POSコマンドとは異なったものです。リファレンスは[こちら](https://jpn.nec.com/printer/label/download/user/printer/pdf/MC320S2DC_OPOS_APG.pdf#%5B%7B%22num%22%3A387%2C%22gen%22%3A0%7D%2C%7B%22name%22%3A%22XYZ%22%7D%2C84%2C728%2C0%5D)からどうぞ。
紙をカットするための`CutPaper`メソッドもあるのですが、EPSON OPOS ADK同梱のサンプルではコマンドを送信する方法で記述されていたので、恐らくこちらの方が良いのでしょう。

## サーマルプリンターで画像を印刷する
画像を印刷する方法は3通りあります。

- プリンターの記憶領域に事前に画像を保存しておき、それを印刷する（`SetBitmap`メソッド+`ESC|B`）
- 画像ファイルのパスを指定して印刷する（`PrintBitmap`メソッド）
- 画像をコードで生成して印刷する（`PrintMemoryBitmap`メソッド）

<font color="red">**画像の印刷には罠が2つ仕掛けられています。**</font>これについては[別の記事](https://qiita.com/Automatic9045/items/6ff588da95e94aa708c4)でまとめたので、良ければそちらも参照して下さい。

### プリンターの記憶領域に事前に画像を保存しておき、それを印刷する
店のロゴを毎回レシートの先頭に印刷する場合、決まった割引の案内を末尾に印刷する場合などは、毎回PCから画像のデータを送信すると時間がかかってしまうため、あらかじめプリンターの記憶領域に画像を保存しておくという手法をとります。

※文字列を組み合わせてロゴを作成する`SetLogo`メソッドもありますが、ここでは割愛します。

```C#
// 前準備で以下のコードを実行
posPrinter.RecLetterQuality = true; // 画像を劣化させずに印刷
posPrinter.MapMode = MapMode.Dots; // 単位をドットに設定（省略可）
posPrinter.SetBitmap(1, PrinterStation.Receipt, "discount.bmp", PosPrinter.PrinterBitmapAsIs, PosPrinter.PrinterBitmapCenter); // discount.bmpを拡大縮小せず、センタリングして印刷するよう、ID1として保存
// ここまで

posPrinter.PrintNormal(PrinterStation.Receipt, "\u001b|1B"); // ID1で保存されていた画像を印刷
posPrinter.PrintNormal(PrinterStation.Receipt, "\u001b|99fP"); // カッター位置までフィードし、パーシャルカット
```

### 画像ファイルのパスを指定して印刷する
画像をプリンターの記憶領域に事前に保存せず、その都度読み込んでプリンターに送信する方法です。

```C#
// 前準備で以下のコードを実行
posPrinter.RecLetterQuality = true; // 画像を劣化させずに印刷
posPrinter.MapMode = MapMode.Dots; // 単位をドットに設定（省略可）
// ここまで

posPrinter.PrintBitmap(PrinterStation.Receipt, "bitmap.bmp", PosPrinter.PrinterBitmapAsIs, PosPrinter.PrinterBitmapLeft); // bitmap.bmpを拡大縮小せず、左寄せして印刷
posPrinter.PrintNormal(PrinterStation.Receipt, "\u001b|99fP"); // カッター位置までフィードし、パーシャルカット
```

### 画像をコードで生成して印刷する
`System.Drawing.Bitmap`のインスタンスを渡して印刷する方法です。
この方法で使用する`PrintMemoryBitmap`メソッドなのですが、<font color="red">**渡すBitmapでGraphicsのインスタンスが作成されている場合、エラーが起こるという謎仕様となっています。**</font>

```C#
/*
using System.Drawing;
using System.Drawing.Imaging;
using Microsoft.PointOfService;
*/

Bitmap baseBmp = new Bitmap(200, 150, PixelFormat.Format24bppRgb);
using (Graphics g = Graphics.FromImage(baseBmp)) // 透明なBitmapの全体に赤いバツ印を描く
{
  g.DrawLine(Pens.Red, 0, 0, bitmap.Width, bitmap.Height);
  g.DrawLine(Pens.Red, bitmap.Width, 0, 0, bitmap.Height);
}

// これはダメ
posPrinter.PrintMemoryBitmap(PrinterStation.Receipt, baseBmp, PosPrinter.PrinterBitmapAsIs, PosPrinter.PrinterBitmapRight); // Graphicsに汚染されたBitmapを渡すとエラーになる

// これもダメ
Bitmap newBmp = new Bitmap(baseBmp);
posPrinter.PrintMemoryBitmap(PrinterStation.Receipt, newBmp, PosPrinter.PrinterBitmapAsIs, PosPrinter.PrinterBitmapRight); // Bitmapを複製しても汚染は除去できない模様
```

Graphicsを使えないとなるとこのメソッドの魅力がほぼ0になってしまうんですが……
私はゴリ押しでなんとかやりました。詳しくは[こちら](https://qiita.com/Automatic9045/items/6ff588da95e94aa708c4)へ。
ここにはコードのみ載せておきます。

<details><summary>コードを見たい方は展開して下さい</summary><div>

```C#
// 32bitアルファチャンネル付bmpを24bitに変換
// EPSON製のプリンターではアルファチャンネルが無視されて真っ黒になるため、アルファチャンネル無しに変換する必要がある
BitmapData bmpData = baseBmp.LockBits(new Rectangle(0, 0, baseBmp.Width, baseBmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);

byte[] basePixels = new byte[bmpData.Stride * bmpData.Height];
Marshal.Copy(bmpData.Scan0, basePixels, 0, basePixels.Length);

int stride = (int)Math.Ceiling(3.0 * bmpData.Width / 4) * 4;
byte[] pixels = new byte[14 + 40 + stride * bmpData.Height];
for (int y = 0; y < bmpData.Height; y++)
{
  for (int x = 0; x < bmpData.Width; x++)
  {
    int basePos = 4 * x + bmpData.Stride * y;
    int pos = 14 + 40 + 3 * x + stride * (bmpData.Height - y - 1);

    pixels[pos] = (byte)(255 + (basePixels[basePos] - 255) * basePixels[basePos + 3] / 255);
    pixels[pos + 1] = (byte)(255 + (basePixels[basePos + 1] - 255) * basePixels[basePos + 3] / 255);
    pixels[pos + 2] = (byte)(255 + (basePixels[basePos + 2] - 255) * basePixels[basePos + 3] / 255);
  }
}

// bmpファイルのヘッダーバイナリを自作
byte[] bfSize = BitConverter.GetBytes(pixels.GetLength(0));
byte[] biWidth = BitConverter.GetBytes(bmpData.Width);
byte[] biHeight = BitConverter.GetBytes(bmpData.Height);
byte[] biSizeImage = BitConverter.GetBytes(pixels.GetLength(0) - 14 - 40);

byte[] header = new byte[14 + 40]
{
  0x42, 0x4d, // bfType
  bfSize[0], bfSize[1], bfSize[2], bfSize[3], // bfSize
  0x00, 0x00, // bfReserved1
  0x00, 0x00, // bfReserved2
  0x01, 0x00, 0x00, 0x00, // bfOffBits

  0x28, 0x00, 0x00, 0x00, // biSize
  biWidth[0], biWidth[1], biWidth[2], biWidth[3], // biWidth
  biHeight[0], biHeight[1], biHeight[2], biHeight[3], // biHeight
  0x01, 0x00, // biPlanes
  0x18, 0x00, // biBitCount
  0x00, 0x00, 0x00, 0x00, // biCompression
  biSizeImage[0], biSizeImage[1], biSizeImage[2], biSizeImage[3], // biSizeImage
  0xc4, 0x0e, 0x00, 0x00, // biXPixPerMeter
  0xc4, 0x0e, 0x00, 0x00, // biYPixPerMeter
  0x00, 0x00, 0x00, 0x00, // biCirUsed
  0x00, 0x00, 0x00, 0x00, // biCirImportant
};


Array.Copy(header, 0, pixels, 0, 14 + 40); // ヘッダーとビットマップデータをくっつける
Bitmap newBmp = new Bitmap(new MemoryStream(pixels)); // Graphicsに汚染されていないBitmapの出来上がり
posPrinter.PrintMemoryBitmap(PrinterStation.Receipt, newBmp, PosPrinter.PrinterBitmapAsIs, PosPrinter.PrinterBitmapRight); // これでOK
```
</div></details>

もっと良い方法をご存知の方がいらっしゃれば教えて下さいm(_ _)m

## キャッシュドロアの場合
サーマルプリンターの場合とは打って変わってとても簡単なので、コードのみ載せておきます。

### 前準備
```C#
PosExplorer posExplorer = new PosExplorer();
DeviceInfo deviceInfo = null;
deviceInfo = PosExplorer.GetDevice(DeviceType.CashDrawer, "MyCashDrawer");
CashDrawer cashDrawer = (CashDrawer)PosExplorer.CreateInstance(deviceInfo);
cashDrawer.Open();
cashDrawer.Claim(1000);
cashDrawer.DeviceEnabled = true;
```

### 実際に動作させる部分のコード
```C#
cashDrawer.OpenDrawer(); // キャッシュドロアを開く
```

### 後始末
```C#
cashDrawer.DeviceEnabled = true;
cashDrawer.Release();
cashDrawer.Close();
```

## 注意
- PosExplorerのインスタンスを複数個作成しようとすると例外を吐きます。
- POS for .NETを2個以上のアプリケーションから同時に呼び出すことは出来ません。

#6. 終わりに
この記事以外にもPOS開発関連の記事を投稿しています。もし良ければ[こちら](https://qiita.com/Automatic9045/items/0c8d05e1aa6255f7bd1c)からどうぞ。