C#で複数HIDバーコードリーダーの入力元を確実に識別する方法|Raw Input/WM_INPUT実装ガイド

生産ラインや検査装置でUSB接続のバーコードリーダーを複数台(HID=キーボードエミュレーション)使うと、「どのリーダーから来た入力か」を区別できずに困りがちです。本稿ではC#(Windows Forms)でWindowsのRaw Input APIを用い、WM_INPUTからデバイスハンドルを拾ってリーダーを識別する、現場投入レベルの実装手順・サンプル・運用ノウハウを網羅的に解説します。

目次

課題:HIDキーボードでは送信元を区別できない

バーコードリーダーをHID(USBキーボード)モードで接続すると、通常のKeyDown / KeyPressイベントには「どの物理デバイスからの入力か」という情報が含まれません。結果として、A工程用・B工程用・C工程用に3台のリーダーを挿しても、アプリ側では区別できず処理を振り分けられません。

これを解決するのがRaw Input APIです。Raw InputはOSが受け取った生の入力(キーボード・マウス・HID)をアプリに届け、デバイスハンドル(hDevice)やデバイスパスまで取得できます。これを使えば「どのHIDキーボードが押したか」を正確に判定できます。

結論:Raw InputでWM_INPUTを処理し、hDevice→リーダーをひも付ける

  • デバイス登録:RegisterRawInputDevicesで「キーボード(Usage=0x06)」を購読。RIDEV_INPUTSINKを指定して非アクティブでも受信。
  • メッセージ受信:フォーム(またはメッセージ専用ウィンドウ)のWndProcでWM_INPUTを捕捉。
  • hDevice取得:GetRawInputDataでRAWINPUTHEADER.hDeviceを取り出す。
  • hDevice→VID/PID/シリアル:GetRawInputDeviceInfo(RIDI_DEVICENAME)でデバイスパスを得て、VID_xxxx/PID_yyyyやシリアルを解析。
  • 辞書化:初回だけ、装置上の工程(A/B/C)と該当デバイスを手順に従いひも付け。以後はhDeviceまたはデバイスパスで自動識別。
  • スキャン文字列の復元:キーDownのみから文字を復元し、Enterなどの終端で確定。工程別ハンドラに振り分け。

実装全体像(アーキテクチャ)

┌─────────────────────┐
│ RawInputWindow(NativeWindow) │ ← RegisterRawInputDevices(RIDEV_INPUTSINK | DEVNOTIFY)
└──────────┬──────────┘
           │ WM_INPUT / WM_INPUT_DEVICE_CHANGE
           ▼
┌─────────────────────┐
│ RawInputKeyboardService    │  hDevice→DevicePath→VID/PID/SN を解決
│  ・デバイス情報キャッシュ     │  hDevice毎に入力バッファ(StringBuilder)保持
│  ・ホットプラグ対応           │  Enterで確定し ScanCompleted を発火
└──────────┬──────────┘
           ▼
┌─────────────────────┐
│ Routing(工程A/B/Cの判定) │  DeviceIdentity→工程ロールを解決
└──────────┬──────────┘
           ▼
┌─────────────────────┐
│ アプリ本体(DB/画面更新)   │  工程ごとに別ロジック
└─────────────────────┘

まずは最小構成で動かす

P/Invoke定義

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;

[Flags]
enum RawInputDeviceFlags : uint
{
RIDEV_REMOVE       = 0x00000001,
RIDEV_EXCLUDE      = 0x00000010,
RIDEV_PAGEONLY     = 0x00000020,
RIDEV_NOLEGACY     = 0x00000030, // legacy WM_KEY* を抑制(慎重に)
RIDEV_INPUTSINK    = 0x00000100, // 非アクティブでも受信
RIDEV_NOHOTKEYS    = 0x00000200,
RIDEV_APPKEYS      = 0x00000400,
RIDEV_DEVNOTIFY    = 0x00002000  // ホットプラグ通知
}

[StructLayout(LayoutKind.Sequential)]
struct RAWINPUTDEVICE
{
public ushort usUsagePage; // 0x01(Generic Desktop Controls)
public ushort usUsage;     // 0x06(Keyboard)
public RawInputDeviceFlags dwFlags;
public IntPtr hwndTarget;
}

[StructLayout(LayoutKind.Sequential)]
struct RAWINPUTHEADER
{
public uint  dwType;   // 1 = Keyboard
public uint  dwSize;
public IntPtr hDevice;
public IntPtr wParam;
}

[StructLayout(LayoutKind.Sequential)]
struct RAWKEYBOARD
{
public ushort MakeCode;
public ushort Flags;
public ushort Reserved;
public ushort VKey;
public uint   Message;
public uint   ExtraInformation;
}

static class NativeMethods
{
public const int WM_INPUT                 = 0x00FF;
public const int WM_INPUT_DEVICE_CHANGE   = 0x00FE;


public const uint RIM_TYPEKEYBOARD = 1;
public const uint RID_INPUT  = 0x10000003;
public const uint RID_HEADER = 0x10000005;

public const uint RIDI_DEVICENAME  = 0x20000007;

[DllImport("user32.dll", SetLastError = true)]
public static extern bool RegisterRawInputDevices(
    [In] RAWINPUTDEVICE[] pRawInputDevices,
    uint uiNumDevices,
    uint cbSize);

[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetRawInputData(
    IntPtr hRawInput, uint uiCommand,
    IntPtr pData, ref uint pcbSize, uint cbSizeHeader);

[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern uint GetRawInputDeviceInfo(
    IntPtr hDevice, uint uiCommand,
    StringBuilder pData, ref uint pcbSize);

[DllImport("user32.dll")]
public static extern bool GetKeyboardState(byte[] lpKeyState);

[DllImport("user32.dll")]
public static extern IntPtr GetKeyboardLayout(uint idThread);

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int ToUnicodeEx(uint wVirtKey, uint wScanCode,
    byte[] lpKeyState, StringBuilder pwszBuff, int cchBuff, uint wFlags, IntPtr dwhkl);


} 

デバイス登録(フォームやNativeWindowのハンドルへ)

void RegisterKeyboardRawInput(IntPtr hwnd)
{
    var rid = new RAWINPUTDEVICE[]
    {
        new RAWINPUTDEVICE {
            usUsagePage = 0x01,    // Generic Desktop Controls
            usUsage     = 0x06,    // Keyboard
            dwFlags     = RawInputDeviceFlags.RIDEV_INPUTSINK
                        | RawInputDeviceFlags.RIDEV_DEVNOTIFY,
            hwndTarget  = hwnd
        }
    };


if (!NativeMethods.RegisterRawInputDevices(
    rid, (uint)rid.Length, (uint)Marshal.SizeOf(typeof(RAWINPUTDEVICE))))
{
    throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
}


} 

ポイント:RIDEV_INPUTSINKでアプリが非アクティブでも受信します。オプションとしてテスト端末に物理キーボードが無い、またはスキャン文字がUIに流入して困る場合はRIDEV_NOLEGACYを併用し、レガシーWM_KEY*を止める手もあります(ただし全キーボードに影響するため慎重に)。

WM_INPUTを受け、hDeviceとキー情報を取り出す

protected override void WndProc(ref Message m)
{
    if (m.Msg == NativeMethods.WM_INPUT)
    {
        uint dwSize = 0;
        // サイズ問い合わせ
        NativeMethods.GetRawInputData(m.LParam, NativeMethods.RID_INPUT,
            IntPtr.Zero, ref dwSize, (uint)Marshal.SizeOf(typeof(RAWINPUTHEADER)));
        if (dwSize == 0) { base.WndProc(ref m); return; }


    IntPtr buffer = Marshal.AllocHGlobal((int)dwSize);
    try
    {
        if (NativeMethods.GetRawInputData(m.LParam, NativeMethods.RID_INPUT,
            buffer, ref dwSize, (uint)Marshal.SizeOf(typeof(RAWINPUTHEADER))) != dwSize)
        {
            return;
        }

        // 先頭はRAWINPUTHEADER
        var header = Marshal.PtrToStructure<RAWINPUTHEADER>(buffer);
        if (header.dwType != NativeMethods.RIM_TYPEKEYBOARD)
        {
            return; // キーボード以外は無視
        }

        // ヘッダ直後にRAWKEYBOARD構造体が続く
        IntPtr pKb = IntPtr.Add(buffer, Marshal.SizeOf(typeof(RAWINPUTHEADER)));
        var kb = Marshal.PtrToStructure<RAWKEYBOARD>(pKb);

        // hDeviceが「どのリーダーか」を示す鍵
        IntPtr hDevice = header.hDevice;

        // KeyDownだけで処理(BreakはKeyUp)
        bool isBreak = (kb.Flags & 0x0001) != 0; // RI_KEY_BREAK
        if (!isBreak)
        {
            OnRawKeyDown(hDevice, kb.VKey, kb.MakeCode);
        }
    }
    finally
    {
        Marshal.FreeHGlobal(buffer);
    }
}
else if (m.Msg == NativeMethods.WM_INPUT_DEVICE_CHANGE)
{
    // 到着/離脱の検知(詳細は後述)
    OnDeviceChanged();
}

base.WndProc(ref m);


} 

hDevice → デバイスパス(VID/PID/シリアル)の解決

GetRawInputDeviceInfoでデバイスインターフェースパス(例:\\?\HID#VID_1234&PID_5678...#...)を取得できます。パス文字列から正規表現でVID_xxxx/PID_yyyy、製造時に埋め込まれたシリアル相当の部分を抜き出し、工程とのひも付けキーにします。

string GetDevicePath(IntPtr hDevice)
{
    uint size = 0;
    NativeMethods.GetRawInputDeviceInfo(hDevice, NativeMethods.RIDI_DEVICENAME, null, ref size);
    if (size == 0) return string.Empty;


var sb = new StringBuilder((int)size);
NativeMethods.GetRawInputDeviceInfo(hDevice, NativeMethods.RIDI_DEVICENAME, sb, ref size);
return sb.ToString();


}

(string Vid, string Pid, string Serial) ParseVidPidSerial(string devicePath)
{
// 例: \?\HID#VID_05F9&PID_2206&MI_00#7&123456&0&0000#{GUID}
string vid = "", pid = "", serial = "";
var m1 = System.Text.RegularExpressions.Regex.Match(devicePath, "VID_([0-9A-Fa-f]{4})");
var m2 = System.Text.RegularExpressions.Regex.Match(devicePath, "PID_([0-9A-Fa-f]{4})");
if (m1.Success) vid = m1.Groups[1].Value.ToUpperInvariant();
if (m2.Success) pid = m2.Groups[1].Value.ToUpperInvariant();


// シリアルは機種により末尾のデバイスインスタンス部に出ることがある
// ここでは「#」で分割し3要素目を参照する例(要環境検証)
var parts = devicePath.Split('#');
if (parts.Length >= 3) serial = parts[2];

return (vid, pid, serial);


} 

スキャン文字列の復元(ToUnicodeEx)

Raw Inputは仮想キー(VKey)とスキャンコードを渡してきます。一般的なバーコードは英数字・記号・Enterで終端されるので、ToUnicodeExで文字に変換し、hDeviceごとに文字列バッファへ積み上げ、Enter(0x0D)で確定します。

readonly Dictionary<IntPtr, StringBuilder> _buffers = new();
public event Action<DeviceIdentity, string> ScanCompleted;

void OnRawKeyDown(IntPtr hDevice, ushort vkey, ushort scan)
{
// Enterで確定
if (vkey == 0x0D) // VK_RETURN
{
if (_buffers.TryGetValue(hDevice, out var sb))
{
var text = sb.ToString();
sb.Clear();
var id = ResolveDeviceIdentity(hDevice);
ScanCompleted?.Invoke(id, text);
}
return;
}


// 文字へ変換
var ch = VkToChar(vkey, scan);
if (string.IsNullOrEmpty(ch)) return;

if (!_buffers.TryGetValue(hDevice, out var buf))
{
    buf = new StringBuilder(64);
    _buffers[hDevice] = buf;
}
buf.Append(ch);


}

string VkToChar(ushort vkey, ushort scan)
{
// 現在のキーボード状態(Shiftなど)を取得
var ks = new byte[256];
NativeMethods.GetKeyboardState(ks);
var sb = new StringBuilder(8);
var hkl = NativeMethods.GetKeyboardLayout(0);
int rc = NativeMethods.ToUnicodeEx(vkey, scan, ks, sb, sb.Capacity, 0, hkl);
return rc > 0 ? sb.ToString() : string.Empty;
} 

工程ロールへのマッピングと永続化

装置側では「工程A」「工程B」「工程C」のように役割(ロール)で扱うのが実運用で堅牢です。最初の起動時のみキャリブレーションを行い、オペレーターに工程ごとのリーダーで指定のバーコードをスキャンしてもらい、「DevicePath/VID/PID/Serial → ロール」をJSONに保存します。以後はホットプラグやハンドル変更が起きても、デバイスパスで一致すれば自動でロール解決できます。

public record DeviceIdentity(string DevicePath, string Vid, string Pid, string Serial);

Dictionary _roleByDevicePath = new(); // DevicePath → Role

DeviceIdentity ResolveDeviceIdentity(IntPtr hDevice)
{
string path = GetDevicePath(hDevice);
var (vid, pid, sn) = ParseVidPidSerial(path);
return new DeviceIdentity(path, vid, pid, sn);
}

string ResolveRole(DeviceIdentity id)
{
if (_roleByDevicePath.TryGetValue(id.DevicePath, out var role)) return role;
// 予備:シリアルやVID/PID一致でも検索可能
foreach (var kv in _roleByDevicePath)
{
if (kv.Key.Contains(id.Serial) && !string.IsNullOrEmpty(id.Serial)) return kv.Value;
}
return "Unknown";
} 

ホットプラグ対応(WM_INPUT_DEVICE_CHANGE)

USBの抜き差しやスリープ復帰でhDeviceは変わることがあります。WM_INPUT_DEVICE_CHANGEを受けたら、全Rawデバイスを列挙し直し→キャッシュ更新→未登録ならUnknown扱い、という流れにしておくと安定します。

実運用のコツ(設計・構成・パフォーマンス)

テーマ推奨理由・補足
フォーカス不要RIDEV_INPUTSINKを必ず付けるバックグラウンドでもWM_INPUTを受信でき、ラインのUI操作と干渉しない
UIへの流入防止必要ならRIDEV_NOLEGACY+メッセージ専用ウィンドウレガシーWM_KEY*を抑止し、文字がフォーカス中コントロールに入るのを防ぐ(ただし物理キーボードも影響)
文字変換ToUnicodeEx+GetKeyboardState国際キーボード/記号でも正しく復元。必要に応じてレイアウト固定も検討
終端判定Enterやスキャナ設定のサフィックス多くのスキャナは「CR/LF」等のサフィックスを設定可能。確定の明確化で誤読を減らす
処理スレッド確定後の重処理は別スレッドWM_INPUTはUIスレッドに来る。DB書き込み等はキュー/チャネルに渡して非同期処理
識別キーDevicePathを主キー、シリアル/VID/PIDを副キーhDeviceは都度変化し得る。パスやSNで永続化しておくと再起動・抜き差しに強い
テスト工程ラベル貼付+試験バーコード台紙「工程A/B/C」名のラベルを物理的に貼る。運用教育と紛れ防止が効く

運用例:工程A/B/Cへのルーティング

public partial class MainForm : Form
{
    public MainForm()
    {
        InitializeComponent();
        RegisterKeyboardRawInput(this.Handle);
    }


protected override void OnHandleCreated(EventArgs e)
{
    base.OnHandleCreated(e);
    // サービス購読
    _service = new RawInputKeyboardService(this.Handle);
    _service.ScanCompleted += (id, text) =>
    {
        var role = ResolveRole(id);
        if (role == "ProcessA") HandleProcessA(text);
        else if (role == "ProcessB") HandleProcessB(text);
        else if (role == "ProcessC") HandleProcessC(text);
        else Log($"Unknown device: {id.DevicePath} text={text}");
    };
}

RawInputKeyboardService _service;
void HandleProcessA(string code) { /* DB書き込み・UI更新など */ }
void HandleProcessB(string code) { /* ... */ }
void HandleProcessC(string code) { /* ... */ }
void Log(string s) { /* ログビューに追記 */ }


} 

キャリブレーション手順(初回のみ)

  1. アプリに「デバイス登録」画面を用意(工程A/B/Cのボタン)。
  2. 「工程A」ボタンを押し、工程Aのリーダーでテストバーコードを1回スキャン。
  3. WM_INPUTから得たDevicePathを工程Aに保存(JSON/設定ファイル)。
  4. 工程B/Cも同様に登録。
  5. 本稼働時は辞書で即時ルーティング。Unknownなら登録画面に誘導。
record RoleBinding(string DevicePath, string Role);

void SaveBindings(IEnumerable bindings, string path)
{
var json = System.Text.Json.JsonSerializer.Serialize(
bindings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(path, json, Encoding.UTF8);
} 

代替アプローチ(採用判断の材料)

  • COM(仮想シリアル)モード:スキャナをシリアルエミュレーションに切替え、SerialPortでCOMポート毎に受信。既存資産を活かせ、UIへの流入も無い。ただしデバイス設定が必要で導入統制が要る。
  • 専用SDK/ドライバ(HID POS / OPOS / POS for .NET等):ベンダSDKがデバイス選択APIを提供。HIDキーボードではなくPOS用クラスドライバで扱うため、識別は容易。機種・SDK依存度が上がる点に注意。

よくある落とし穴と回避策

症状原因対策
UIのテキストボックスにバーコードが流れ込むレガシーWM_KEY*が通常経路で配信RIDEV_NOLEGACYを併用しメッセージ専用ウィンドウに登録/フォーカスをダミーに置く
かな入力・記号が化けるレイアウト差異・修飾キー状態ToUnicodeEx+GetKeyboardStateで都度変換。スキャナ側でASCII固定にするのも有効
再接続で別デバイス扱いになるhDeviceは都度変化DevicePath/シリアルを主キーに永続化。WM_INPUT_DEVICE_CHANGEでキャッシュ更新
スキャン欠落や取りこぼしUIスレッドで重い処理確定後の処理はキューに投げ、別スレッドでDB・I/O実行
セキュリティ懸念ドライバ不要だが入力取得はアプリ権限で動く署名済みアプリの配布・書き込み先の権限最小化・ログ監査

「そのまま使える」簡易サービス実装(抜粋)

フォームのハンドルに紐づくRaw Inputサービスの最小実装(要:上記P/Invoke)。工程別ハンドラにイベントで通知します。

public sealed class RawInputKeyboardService : NativeWindow, IDisposable
{
    public event Action<DeviceIdentity, string> ScanCompleted;


readonly Dictionary<IntPtr, StringBuilder> _buf = new();
public RawInputKeyboardService(IntPtr ownerHandle)
{
    // メッセージ専用ウィンドウを作る方法:
    CreateHandle(new CreateParams { Parent = ownerHandle });
    RegisterKeyboardRawInput(this.Handle);
}

protected override void WndProc(ref Message m)
{
    if (m.Msg == NativeMethods.WM_INPUT)
    {
        uint size = 0;
        NativeMethods.GetRawInputData(m.LParam, NativeMethods.RID_INPUT, IntPtr.Zero,
            ref size, (uint)Marshal.SizeOf<RAWINPUTHEADER>());
        if (size == 0) return;
        var ptr = Marshal.AllocHGlobal((int)size);
        try
        {
            NativeMethods.GetRawInputData(m.LParam, NativeMethods.RID_INPUT, ptr,
                ref size, (uint)Marshal.SizeOf<RAWINPUTHEADER>());
            var header = Marshal.PtrToStructure<RAWINPUTHEADER>(ptr);
            if (header.dwType != NativeMethods.RIM_TYPEKEYBOARD) return;

            IntPtr pKb = IntPtr.Add(ptr, Marshal.SizeOf<RAWINPUTHEADER>());
            var kb = Marshal.PtrToStructure<RAWKEYBOARD>(pKb);

            bool isBreak = (kb.Flags & 0x0001) != 0;
            if (!isBreak)
            {
                if (kb.VKey == 0x0D) // Enter
                {
                    if (_buf.TryGetValue(header.hDevice, out var sb))
                    {
                        var id = ResolveDeviceIdentity(header.hDevice);
                        ScanCompleted?.Invoke(id, sb.ToString());
                        sb.Clear();
                    }
                }
                else
                {
                    var ch = VkToChar(kb.VKey, kb.MakeCode);
                    if (!string.IsNullOrEmpty(ch))
                    {
                        if (!_buf.TryGetValue(header.hDevice, out var sb))
                        {
                            sb = new StringBuilder(64);
                            _buf[header.hDevice] = sb;
                        }
                        sb.Append(ch);
                    }
                }
            }
        }
        finally { Marshal.FreeHGlobal(ptr); }
    }
    else if (m.Msg == NativeMethods.WM_INPUT_DEVICE_CHANGE)
    {
        // 必要に応じてデバイス一覧を再構築する
    }

    base.WndProc(ref m);
}

public void Dispose() => DestroyHandle();

// ここに前述のResolveDeviceIdentity/VkToCharなどを実装


} 

品質を上げる追加Tips

  • 専用のバーコード前置/後置(Prefix/Suffix)をスキャナ設定で付与(例:<STX>や<ETX>)。アプリ側でパターン検出し、ヒューマンキーボードの誤操作を見分けやすくします。
  • 最小遅延:UIスレッドでは「受信→バッファ→イベント発火」だけに限定。ログ書込みやDBはBlockingCollectionやChannelでワーカーに委譲。
  • リーダー毎の照明/ブザー:工程別に音や色を変えると現場フィードバックが向上。SDK機種ならAPIで制御可能。
  • 監査ログ:DevicePath・VID/PID・Serial・工程ロール・バーコード・時刻を1行で残す。トレーサビリティが強化されます。

セキュリティ・権限・配布

  • 管理者権限は不要(一般ユーザーでRaw Inputは扱えます)。
  • 配布はコード署名し、起動フォルダ・ログフォルダの書込権限を最小限に。
  • スキャナの設定変更(モード切替など)は現場の標準作業手順書に明記。勝手な変更を防ぎます。

検証チェックリスト

  • 3台同時接続で、各リーダーのスキャンが正しく別ハンドラに届くか。
  • アプリ非アクティブ(他アプリ前面)でも受信できるか。
  • USB抜き差し/再起動/スリープ復帰後もロールが維持されるか。
  • 日本語配列・US配列の混在で文字化けが起きないか。
  • 大量スキャン(1000回)で取りこぼしやUIフリーズが無いか。

トラブルシューティングの実例

症例1:一部スキャナで「/」「-」が逆になる → PC側キーボードレイアウトがUS/JPと不一致。OSのキーボードレイアウトを統一またはスキャナ側でASCII固定に変更。

症例2:特定の台だけUnknownになる → USBハブ経由でデバイスパスが変動。ハブ固定・ポート固定、可能ならシリアル番号でひも付ける。

症例3:スキャン確定が早すぎる/遅すぎる → スキャナのサフィックスを「CR」単独にし、アプリ側はEnterのみで確定。タイマ確定(一定無入力で確定)は誤確定の温床。

Raw Inputラッパーの活用

ゼロからP/Invokeでも十分ですが、RawInput.NetやSharpDX.RawInputなどのNuGetパッケージを活用すると、デバイス列挙やイベント購読が簡潔になります。WPFでも概ね同じ考え方で実装可能です。

まとめ

要点:Raw Input APIでWM_INPUTを受け、hDeviceからDevicePath(VID/PID/Serial)を解決して工程ロールへルーティングするのが王道。RIDEV_INPUTSINKでフォーカスに依存せず、ToUnicodeExで文字復元、WM_INPUT_DEVICE_CHANGEでホットプラグに耐性を持たせれば、生産ラインでも安心して使える「複数リーダー識別」が実現します。代替としてCOMモードやベンダSDKも検討しつつ、要件・運用体制・機種選定に合わせてベストな戦略を選びましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次