【Delphi】利用可能な COM ポートを列挙する【WMI】

# はじめに

[Delphi Users (Japan)](https://gist.github.com/freeonterminate/928ccd3ad64c5762315c069afd72e51e) で質問のあった「シリアルポートの監視と列挙」の件をやってみたいと思います。

:::note
コードは『Delphi 12 Athens』で書かれています。
:::

 - [シリアルポート (Wikipedia)](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%AA%E3%82%A2%E3%83%AB%E3%83%9D%E3%83%BC%E3%83%88)

# シリアル (COM) ポートの監視と列挙

シリアルポートの監視は `WM_DEVICECHANGE` メッセージで `DBT_DEVICEARRIVAL` と `DBT_DEVICEREMOVECOMPLETE` を監視すれば良さそうです。

 - [メディア挿入または削除の検出 (learn.microsoft.com)](https://learn.microsoft.com/ja-jp/windows/win32/devio/detecting-media-insertion-or-removal)

列挙にはレジストリを読む方法と **WMI** (Windows Management Instrumentation) で取得する方法がありますが、レジストリで読む方法はうまくいかなかったという事で、WMI で取得してみます。

**See also:**

 - [Windows Management Instrumentation (learn.microsoft.com)](https://learn.microsoft.com/ja-jp/windows/win32/wmisdk/wmi-start-page)

## WMI の使い方

Delphi から WMI を使うには、まず `[コンポーネント | コンポーネントのインポート]` から、**Delphi インターフェイスファイル** (`*_TLB.pas`) を生成します。

### Delphi インターフェイス ファイルの生成

まず `[コンポーネント | コンポーネントのインポート]` で、タイプライブラリの取り込みを行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/32157ab2-2ec8-89f4-8b1d-53dd3f91c60b.png)

`Microsoft WMI Scriptiong V1.2 Library` を選びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/39dc3bda-49d0-6ecd-81e4-7f8b6ce7c40a.png)

一応コンポーネントラッパーも作っておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/add8da4e-73b1-0fc6-4741-de97ab27f006.png)

パッケージに登録せず、ユニットだけ作る事にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/6d860784-ee40-b1f3-bbfb-3677a3e4247c.png)

`$(BDSUSERDIR)\Imports` に `WbemScripting_TLB.pas` が生成されています。この場所は通常ライブラリパスが通っているので、あらためてライブラリパスを通さなくても OK です。

### コマンドラインツールでインターフェイスファイルを生成する

コマンドラインツール **TLIBIMP.EXE** を使ってインターフェイスファイルを生成する事もできます。

32bit Windows の場合には次のコマンドで `WbemScripting_TLB.pas` が生成されます。

```
TLIBIMP -P C:\Windows\System32\wbem\wbemdisp.TLB
```

64bit Windows の場合には次のコマンドで `WbemScripting_TLB.pas` が生成されます。

```
TLIBIMP -P C:\Windows\SysWOW64\wbem\wbemdisp.TLB
```

:::note
XE2 以降の Delphi には `$(BDS)\bin` と `$(BDS)\bin64` の両方に `TLIBIMP.EXE` がありますが、今回の場合はどちらを使っても構いません。
:::

**See also:**

 - [TLIBIMP.EXE (DocWiki)](https://docwiki.embarcadero.com/RADStudio/Athens/ja/TLIBIMP.EXE)

### WMI の基本的な使い方

定型なので難しくはありません。Delphi 10.3 Rio 以降だとインライン変数宣言と型推論が使えるのでスッキリ書けます。**uses** に先程作った `WbemScripting_TLB` (と `ActiveX`) を追加しておいてください。

```pascal
uses
  ..., WinApi.ActiveX, WbemScripting_TLB;

...

  var SWbemLocator := TSWbemLocator.Create(nil);
  try
    var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '', '', '', 0, nil);
    var WQL := ''; // WQL を記述
    var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagReturnImmediately, nil);
    var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
    var TempObj: OleVariant;
    var Value: LongWord;
    while Enum.Next(1, TempObj, Value) = S_OK do
    begin
      var PropSet := (IUnknown(TempObj) as ISWBemObject).Properties_;
      var FieldName := ''; // ここにフィールド名
      var &Property := PropSet.Item(FieldName, 0);
      if not VarIsNull(&Property.Get_Value) then
      begin
        // &Property.Get_Value が値
        // Null を調べる必要がないなら
        // PropSet.Item(FieldName, 0).Get_Value; で値を取れる
      end;
    end;
  finally
    SWbemLocator.Free;
  end;
```

上記サンプルでは **WQL** (WMI 用の SQL) を実行し、グルグル回して値を取得するという事をやっています。

 - [WQL (WMI 用の SQL) (learn.microsoft.com)](https://learn.microsoft.com/ja-jp/windows/win32/wmisdk/wql-sql-for-wmi) 

#### CreateOleObject()

全部 OLEVariant なので入力補完とか利きませんけど、それでもよければタイプライブラリを取り込まずに `CreateOleObject()` でやる方法もあります。

```pascal
uses
  ..., WinApi.ActiveX, System.Win.ComObj;
  
...  

const
  wbemFlagForwardOnly = $20;
begin
  var SWbemLocator: OLEVariant := CreateOleObject('WbemScripting.SWbemLocator');
  var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '');
  var WQL := ''; // WQL を記述
  var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagForwardOnly);
  var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
  var TempObj: OleVariant;
  var Value: LongWord;
  while Enum.Next(1, TempObj, Value) = S_OK do
  begin
    var PropSet := TempObj.Properties_;
    var FieldName := ''; // ここにフィールド名
    var &Property := PropSet.Item(FieldName);
    if not VarIsNull(&Property.Value) then
    begin
      // &Property.Value が値
      // Null を調べる必要がないなら
      // TempObj.Properties_.Item(FieldName).Value; で値を取れる
    end;
  end;
```


#### Winapi.Wbem

Delphi XE3 以降では `Winapi.Wbem` ユニットも使えます。

...が、あんまり便利じゃないんだよなぁ。ドキュメントもないし。

**See also:**

 - [Winapi (DocWiki)](https://docwiki.embarcadero.com/Libraries/Athens/ja/Winapi)



### プラグアンドプレイデバイスの列挙

「COM ポートが抜き差しされる」という事は RS-232C の物理コネクタが PC にあるのではなく、USB シリアル変換みたいなものなのだろうと思われます。プラグアンドプレイデバイスは **Win32_PnPEntity クラス** で列挙できます。

 - [Win32_PnPEntity クラス (learn.microsoft.com)](https://learn.microsoft.com/ja-jp/windows/win32/cimwin32prov/win32-pnpentity)

WQL はこんな感じですね。

```SQL
SELECT * FROM Win32_PnPEntity
```

ここで COM ポートのみを探すには、`Win32_PnPEntity.PNPClass` フィールドが `Ports` になっていて、名前に `(COM` が含まれるものを列挙すればよさそうです。WQL はこんな感じになります。

```SQL
SELECT * FROM Win32_PnPEntity WHERE (PNPClass = 'Ports') and (Name LIKE '%(COM%')
```

次のようなアプリケーションを作ってテストしてみましょう。ボタンを押すとメモに COM ポートが列挙されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/dca5d22c-ff8c-5047-aac8-e97243a9fc71.png)

```pascal
procedure TForm1.EnumComPort;
begin
  Memo1.Clear;
  var SWbemLocator := TSWbemLocator.Create(nil);
  try
    var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '', '', '', 0, nil);
    var WQL := 'SELECT * FROM Win32_PnPEntity WHERE (PNPClass = ''Ports'') and (Name LIKE ''%(COM%'')';
    var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagReturnImmediately, nil);
    var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
    var TempObj: OleVariant;
    var Value: LongWord;
    while Enum.Next(1, TempObj, Value) = S_OK do
    begin
      var PropSet := (IUnknown(TempObj) as ISWBemObject).Properties_;
      Memo1.Lines.Add(PropSet.Item('Name', 0).Get_Value);
    end;
  finally
    SWbemLocator.Free;
  end;
end;

procedure TForm1.EnumClick(Sender: TObject);
begin
  EnumComPort;
end;
```

正しく列挙できたようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/b02f1c2c-8fca-3012-55d5-9fd065f7cdc5.png)

:::note
物理コネクタが本体から出ている COM ポート (COM1 とか) も PnP デバイスとして取得できるようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/3b8e635b-85d3-99e5-25d5-3d54ff514f54.png)
:::

## プラグアンドプレイの検出

今度はプラグアンドプレイに対応させてみます。ボタンを削除して、フォームの `OnShow` イベントで列挙するようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/3f26a695-233d-fdb1-c85b-533147e50c28.png)

```pascal
procedure TForm1.FormShow(Sender: TObject);
begin
  EnumComPort;
end;
```

次に `WM_DEVICECHANGE` のメッセージハンドラを記述します。

```pascal
  private
    { Private 宣言 }
    procedure DeviceChange(var Msg: TMessage); message WM_DEVICECHANGE;

...

procedure TForm1.DeviceChange(var Msg: TMessage);
const
  DBT_DEVICEARRIVAL        = $8000;
  DBT_DEVICEREMOVECOMPLETE = $8004;
begin
  case Msg.WParam of
    DBT_DEVICEARRIVAL,
    DBT_DEVICEREMOVECOMPLETE:
      EnumComPort;
  end;
end;
```

実行してみましょう。起動時に COM ポートが列挙されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/3bfbbe61-52dc-8480-5a63-4bd69e62418d.png)

デバイスを挿抜すると...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/50d05dd7-d4b8-19a8-231f-c13a9ee528ba.png)

```
---------------------------
Project1
---------------------------
アプリケーションが入力同期呼び出しをディスパッチしているため、呼び出せません。.
---------------------------
OK   
---------------------------
```

Windows メッセージの中にはブロックしてはいけないものがあり、さっさと処理を渡さないといけないものがあります。今回もその類だと思います。このような時に私が採る解決方法は 3 つあります。

 1. タイマーを起動して `OnTimer` イベントハンドラの中で処理する
 2. `PostMessage()` で別のメッセージ (`WM_APP + 1` とか) を自身に投げて、そのメッセージハンドラの中で処理する
 3. 処理をスレッドで実行する

今回はスレッドで解決してみましょう。`CreateAnonymousThread()` でスレッドを作成します。


```pascal
procedure TForm1.EnumComPort;
begin
  TThread.CreateAnonymousThread(
    procedure
    begin
      TThread.Synchronize(nil,
        procedure
        begin
          Memo1.Clear;
        end);
      var SWbemLocator := TSWbemLocator.Create(nil);
      try
        var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '', '', '', 0, nil);
        var WQL := 'SELECT * FROM Win32_PnPEntity WHERE (PNPClass = ''Ports'') and (Name LIKE ''%(COM%'')';
        var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagReturnImmediately, nil);
        var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
        var TempObj: OleVariant;
        var Value: LongWord;
        while Enum.Next(1, TempObj, Value) = S_OK do
        begin
          var PropSet := (IUnknown(TempObj) as ISWBemObject).Properties_;
          TThread.Synchronize(nil,
            procedure
            begin
              Memo1.Lines.Add(PropSet.Item('Name', 0).Get_Value);
            end);
        end;
      finally
        SWbemLocator.Free;
      end;
    end
  ).Start;
end;
```

実行してみると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/eee1898f-5672-787b-02fc-dcf460cfc43f.png)

あ、スレッド内で **COM** (Component Object Model) を使っているのでした。`CoInitialize()` と `CoUninitialize()` で括らなきゃですね。

```pascal
procedure TForm1.EnumComPort;
begin
  TThread.CreateAnonymousThread(
    procedure
    begin
      CoInitialize(nil); // 追加
      TThread.Synchronize(nil,
        procedure
        begin
          Memo1.Clear;
        end);
      var SWbemLocator := TSWbemLocator.Create(nil);
      try
        var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '', '', '', 0, nil);
        var WQL := 'SELECT * FROM Win32_PnPEntity WHERE (PNPClass = ''Ports'') and (Name LIKE ''%(COM%'')';
        var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagReturnImmediately, nil);
        var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
        var TempObj: OleVariant;
        var Value: LongWord;
        while Enum.Next(1, TempObj, Value) = S_OK do
        begin
          var PropSet := (IUnknown(TempObj) as ISWBemObject).Properties_;
          TThread.Synchronize(nil,
            procedure
            begin
              Memo1.Lines.Add(PropSet.Item('Name', 0).Get_Value);
            end);
        end;
      finally
        SWbemLocator.Free;
        CoUninitialize; // 追加
      end;
    end
  ).Start;
end;
```

今度はどうでしょう？起動時は OK です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/44c5f761-1ecc-8088-b455-a5ef6b90687c.png)

一つデバイスを抜くと...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21785/074eca63-7992-6a60-8a5d-ddf5ba4e91bd.png)

大丈夫のようです。全体のソースは次のようになりました。

```pascal:Unit1.pas 
unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, WinApi.ActiveX, WbemScripting_TLB;

type
  TForm1 = class(TForm)
    Memo1: TMemo;
    procedure FormShow(Sender: TObject);
  private
    { Private 宣言 }
    procedure DeviceChange(var Msg: TMessage); message WM_DEVICECHANGE;
    procedure EnumComPort;
  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.DeviceChange(var Msg: TMessage);
const
  DBT_DEVICEARRIVAL        = $8000;
  DBT_DEVICEREMOVECOMPLETE = $8004;
begin
  case Msg.WParam of
    DBT_DEVICEARRIVAL,
    DBT_DEVICEREMOVECOMPLETE:
      EnumComPort;
  end;
end;

procedure TForm1.EnumComPort;
begin
  TThread.CreateAnonymousThread(
    procedure
    begin
      CoInitialize(nil);
      TThread.Synchronize(nil,
        procedure
        begin
          Memo1.Clear;
        end);
      var SWbemLocator := TSWbemLocator.Create(nil);
      try
        var Service := SWbemLocator.ConnectServer('.', 'root\CIMV2', '', '', '', '', 0, nil);
        var WQL := 'SELECT * FROM Win32_PnPEntity WHERE (PNPClass = ''Ports'') and (Name LIKE ''%(COM%'')';
        var ObjectSet := Service.ExecQuery(WQL, 'WQL', wbemFlagReturnImmediately, nil);
        var Enum := IUnknown(ObjectSet._NewEnum) as IEnumVariant;
        var TempObj: OleVariant;
        var Value: LongWord;
        while Enum.Next(1, TempObj, Value) = S_OK do
        begin
          var PropSet := (IUnknown(TempObj) as ISWBemObject).Properties_;
          TThread.Synchronize(nil,
            procedure
            begin
              Memo1.Lines.Add(PropSet.Item('Name', 0).Get_Value);
            end);
        end;
      finally
        SWbemLocator.Free;
        CoUninitialize;
      end;
    end
  ).Start;
end;

procedure TForm1.FormShow(Sender: TObject);
begin
  EnumComPort;
end;

end.
```

# おわりに

今回のコードを実用的なものにするには、

 - 文字列から `COMn` の部分だけを抜き出すコードを書く
 - コンボボックスに列挙するようにする
 - 挿抜されてもコンボボックスのリストが破綻しないようにする (選択されていないデバイスが抜かれても、アイテムは選択されたままにする)
 - 前回選択されていた COM ポートがあればそれを選択状態にする

等々、やるべき事は多いのですが、今回はここまでにしておきます。

**See also:**

 - [<1> スレッドオブジェクト (Delphi コンカレントプログラミング) (Qiita)](https://qiita.com/ht_deko/items/39da99a10202589e002c)
 - [898_WMI イントロダクション (Mr.XRAY)](http://mrxray.on.coocan.jp/Delphi/plSamples/898_WMI_Introduction.htm#16)
 - [List of SerialPorts queried using WMI differs from devicemanager? (StackOverflow)](https://stackoverflow.com/questions/45840978/receiving-acces-denied-when-get-status-of-uwf-filter-using-wmi-in-c-sharp)
 - [Delphi で Arduino につなぐ (シリアル通信) (Qiita)](https://qiita.com/ht_deko/items/9d2ed3fb125b0d06e599)