2024/12/31(Tue)ディスプレイの入力切替イベントを拾う方法メモ

2024/12/31 22:23 Software::LinuxSoftware::Windows
Fire stick tvなどHDMI端子に直刺しして操作するデバイスは、電源供給していればバックグラウンドでネットワークにアクセスしている様子が観測できるものの、映像出力は実際に表示されてから(入力切替で画面に映るようになってから)というのが、PCでどう実現するか調べてみたメモ。

どのイベントを拾うか

初めに愚直に考えると、HDMIは映像ストリーム以外にもEDIDとかHDCPとか制御信号をやりとりしているはずなので、このイベントをどうにかして拾ってあげれば良さそう。
しかし表示デバイスにも依るのかもしれないが、HDMIケーブルを都度抜き差しするならともかく、表示デバイスの電源の入切や、入力切替が選ばれている/いないを考慮して「本当に映っているか」を調べるのはなかなか大変そうだ。

DPMS

まず初めにLinuxで情報を探したところ(テストマシンはThinkPad X250でFedora、miniDP端子経由でHDMI接続したテレビを想定)、DPMS*1というディスプレイ省電力関連の規格を拾う方法が見つかった。
とりあえず、libX11-devel, libXext-develあたりをインストールし、X11/Xlib.h, X11/Xproto.h, X11/extensions/dpms.hが参照できるようにしておく。LDFLAGSには-lX11 -lXextを指定すると、XOpenDisplayやDPMSInfoといった外部関数がリンクされる。
Display *disp = XOpenDisplay(NULL);
CARD16 power_level;
BOOL state;
DPMSInfo(disp, &power_level, &state);
XCloseDisplay(disp);
stateにはTRUE/FALSEでDPMSの有効状態、power_levelはDPMSModeOn, DPMSModeStandby, DPMSModeSuspend, DPMSModeOffの4択(0~3)で状態が返るとあったが、そもそもDPMSが利用できないというエラーで呼び出し自体が成功しない。

まず今回試したFedora 39*2では、既にXorgの替わりにWaylandが動いているということで、そもそもDPMSがサポート外と表示されているためかも。
$ xset q
Keyboard Control:
  auto repeat:  on    key click percent:  0    LED mask:  00000000
  XKB indicators:
    00: Caps Lock:   off    01: Num Lock:    off    02: Scroll Lock: off
    03: Compose:     off    04: Kana:        off    05: Sleep:       off
    06: Suspend:     off    07: Mute:        off    08: Misc:        off
    09: Mail:        off    10: Charging:    off    11: Shift Lock:  off
    12: Group 2:     off    13: Mouse Keys:  off
  auto repeat delay:  500    repeat rate:  33
  auto repeating keys:  00ffffffdffffbbf
                        fadfffefffedffff
                        9fffffffffffffff
                        fff7ffffffffffff
  bell percent:  50    bell pitch:  400    bell duration:  100
Pointer Control:
  acceleration:  2/1    threshold:  4
Screen Saver:
  prefer blanking:  yes    allow exposures:  yes
  timeout:  0    cycle:  0
Colors:
  default colormap:  0x3f    BlackPixel:  0x0    WhitePixel:  0xffffff
Font Path:
  catalogue:/etc/X11/fontpath.d,built-ins
DPMS (Display Power Management Signaling):
  Server does not have the DPMS Extension
Waylandを無効にしてXorgに戻せばDPMSは自動的に有効になると言うことなので、ログイン時の歯車をクリックしてXorgベースのデスクトップを選択した状態でログインするとDPMSは有効になった。
(一度ログインすれば次回も同様の設定を維持するようだが、/etc/gdm/custom.confの[daemon]セクションにWaylandEnable=falseを書いて再起動しても良い)
$ xset q
Keyboard Control:
  auto repeat:  on    key click percent:  0    LED mask:  00000000
  XKB indicators:
    00: Caps Lock:   off    01: Num Lock:    off    02: Scroll Lock: off
    03: Compose:     off    04: Kana:        off    05: Sleep:       off
    06: Suspend:     off    07: Mute:        off    08: Misc:        off
    09: Mail:        off    10: Charging:    off    11: Shift Lock:  off
    12: Group 2:     off    13: Mouse Keys:  off
  auto repeat delay:  500    repeat rate:  33
  auto repeating keys:  00ffffffdffffbbf
                        fadfffefffedffff
                        9fffffffffffffff
                        fff7ffffffffffff
  bell percent:  50    bell pitch:  400    bell duration:  100
Pointer Control:
  acceleration:  2/1    threshold:  4
Screen Saver:
  prefer blanking:  yes    allow exposures:  yes
  timeout:  0    cycle:  0
Colors:
  default colormap:  0x20    BlackPixel:  0x0    WhitePixel:  0xffffff
Font Path:
  catalogue:/etc/X11/fontpath.d,built-ins
DPMS (Display Power Management Signaling):
  Standby: 0    Suspend: 0    Off: 0
  DPMS is Enabled
  Monitor is On

しかし、これで万事解決かというと、どうも電源や入力切替を考慮した状態が返ってこないようだ……。常にMonitorはOnだというステータスが返ってくる。
今回試したのがノートPCで、内部的にLCDパネルと結線状態が保たれているせいかもしれないが、とりあえず「このポートのディスプレイに出力しているか」という判定には使えなさそう。

*1 : Display Power Management Signaling

*2 : アップデートを怠っていたので既にサポート切れなのはとにかく……汗

xrandr

Linuxでディスプレイ制御というと大御所はXRandRかなということで、実機またはDISPLAY環境変数に:0とか:1を指定した状態でxrandrを実行してみた。

まずは、ディスプレイ側がOFFの状態。(HDMIケーブルは接続してある)
$ DISPLAY=:1 xrandr
Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 16384 x 16384
eDP-1 connected (normal left inverted right x axis y axis)
   1920x1080     60.04 +  60.01    59.97    59.96    59.93
   1680x1050     59.95    59.88
   1400x1050     59.98
   1600x900      59.99    59.94    59.95    59.82
   1280x1024     60.02
   1400x900      59.96    59.88
   1280x960      60.00
   1440x810      60.00    59.97
   1368x768      59.88    59.85
   1280x800      59.99    59.97    59.81    59.91
   1280x720      60.00    59.99    59.86    59.74
   1024x768      60.04    60.00
   960x720       60.00
   928x696       60.05
   896x672       60.01
   1024x576      59.95    59.96    59.90    59.82
   960x600       59.93    60.00
   960x540       59.96    59.99    59.63    59.82
   800x600       60.00    60.32    56.25
   840x525       60.01    59.88
   864x486       59.92    59.57
   700x525       59.98
   800x450       59.95    59.82
   640x512       60.02
   700x450       59.96    59.88
   640x480       60.00    59.94
   720x405       59.51    58.99
   684x384       59.88    59.85
   640x400       59.88    59.98
   640x360       59.86    59.83    59.84    59.32
   512x384       60.00
   512x288       60.00    59.92
   480x270       59.63    59.82
   400x300       60.32    56.34
   432x243       59.92    59.57
   320x240       60.05
   360x202       59.51    59.13
   320x180       59.84    59.32
DP-1 disconnected (normal left inverted right x axis y axis)
HDMI-1 disconnected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 0mm x 0mm
DP-2 disconnected (normal left inverted right x axis y axis)
HDMI-2 disconnected (normal left inverted right x axis y axis)
  1920x1080 (0x9a) 148.500MHz +HSync +VSync
        h: width  1920 start 2008 end 2052 total 2200 skew    0 clock  67.50KHz
        v: height 1080 start 1084 end 1089 total 1125           clock  60.00Hz
eDP-1は内蔵ディスプレイに繋がっているので良いとして、この時点ではHDMI-1は"disconnected"だと認識している。

この状態はディスプレイ側をオンにするだけでは変化せず、入力切替をして該当のHDMIポートを選ぶことで、以下のように変化した。
(略)
HDMI-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 819mm x 461mm
   1920x1080     60.00*+  59.94    24.00    23.98    23.97
   1920x1080i    60.00    59.94
   1280x720      60.00    59.94
   1440x480i     59.94
   720x480       60.00    59.94
   720x480i      60.00    59.94
   640x480       60.00    59.94
よしよし。
ということは、これを知るためのAPIを叩くと良いわけですね。

libXrandr

libXrandr-develを入れると、X11/extensions/Xrandr.hがincludeできるようになり、ここにXRRから始まる関数がいくつかある。
XRRGetOutputInfoで上記のxrandコマンドの各ディスプレイの解像度・周波数情報などが取れるようになるので、これに与えるリソース情報をXRRGetScreenResourcesで取得しておき、さらにそれに渡すウィンドウ情報をDefaultRootScreenで取得する。
順番に書くとこんな感じ。LDFLAGSに-lX11 -lXext -lXrandrをお忘れ無く。
Display *disp = XOpenDisplay(NULL);
Window root = DefaultRootWindow(disp);
XRRScreenResources *res = XRRGetScreenResources(disp, root);
for(int i=0; i<res->noutput; i++) {
    XRROutputInfo *info = XRRGetOutputInfo(disp, res, res->output[i]);
    if(!strcmp("HDMI-1", info->name) {
        printf("connection: %d\n", (info->connection & 0x1));
    }
    XRRFreeOutputInfo(info);
}
XRRFreeScreenResources(res);
XCloseDisplay(disp);
上記部分のうち、info->connection & 0x1trueになったときに接続中、falseなら未接続、ということになる。

筆者の環境では、電源オン→入力切替で画面表示が始まるとtrueになり、その後は電源をオフにするまでこのフラグはfalseにならなかった……ので、厳密に表示しているかというよりは、表示が始まったかどうか程度を判別する物として使っている。

なお、この方法は予めイベントハンドラを登録しておいて、変化があったときに呼んでもらう、という風には出来ないようなので、愚直に while(true){...} sleep(1);でポーリングすることにした。

しばしテスト

とりあえずinfo->connectionの変化を見て制御してみるコードを書いたものの、問題点が2つ。

XRRGetScreenResourcesを呼ぶと、ドロップフレームする

今回HDMIのイベントを拾って、VLC等のプレーヤーを起動させるようにしたのだが、XRRGetScreenResourcesを呼んだタイミングで、コンマ何秒画面が固まる。音は問題無い。
VLCの統計情報を見たところ、徐々にドロップフレームが増えていくのと、XRRGetScreenResourcesCurrentでは問題が起こらないが、接続情報が更新されないことから、この2つの関数差をざっと確認すると、Currentが無い方はハードウェア問合せ、Currentの方は何らかのキャッシュを使うような挙動ということが分かったので、本来あんまり連発して叩く物では無いのかもしれない。

なお、Waylandに戻すとこの挙動は起こらないのだが、DPMSが使えなくなってinfo->connectionの値が変化しないので、残念ながらこれは許容することにした。(一度オン判定したら、オフ判定するまでのインターバルを1秒などではなく、10分とか長めにとる)

単発でinfo->connectionが変化するときがある

1秒ごとにループして状態を確認していたところ、数分~数十分に1度くらいのペースで、電源/選局状態が変化していないのにtrueになることがあった。
おそらくディスプレイ側がなんらかの挙動でバックグラウンドで起きたり、処理しているタイミングを拾ってしまったのだろう……詳しいことは分からないが、3回連続で変化が認められたときにオン判定する、といったロジックに置き換えて対処した。

結局は…

上記2つの点をなんとかリカバーして、この方法を採用することにした。
他にも、サウンドデバイスが増えたり減ったりするのが使えるかと思って確認したりしたが、プレーヤーで再生が続いていると、ディスプレイ側の電源が切れてもサウンドデバイスがinactiveにならないようだったので、この方法は没かも。
Linuxは長く使っていますが、Xは殆ど使って居らず、X11/以下のheaderを参照するプログラムも今回初めて作ったので、色々間違ってたらすいません。

Windowsでは?

ここまでやっておいて、じゃあWindowsではどうなるのか、とC#で.NETアプリを作って試してみたところ*3
  • デバイスマネージャ上は、接続したときにデバイスが見え、その後はディスプレイ側の電源状態に関係無く接続済として見える
  • SystemEvents.DisplaySettingsChangedにイベントハンドラーを登録しても、物理的な挿抜を伴わない状況では意味ない模様。
  • GetDevicePowerStateでは、ディスプレイの電源状態は取れないとわざわざ書いてある。*4
  • EnumDisplayMonitorsQueryDisplayConfigを叩いてみた結果も同様。ディスプレイは消灯していても、デバイスとしては接続されているので、使用可というスタンスのようだ。
  • WM_POWERBROADCAST - PBT_POWERSETTINGCHANGEのウィンドウメッセージハンドラをフックして云々については、Windows側の電源変化イベントをフックするのが目的であって、ディスプレイ側のイベント変化は取れない。
ということで時間切れで諦め。
多分、もっとローレイヤーな所のイベントをフックできないとダメだと思うし、ディスプレイ側が電源オフかつ主電源オンの時にEDIDを保持するなどの仕組みがある場合は、Windows側は切断と見なしていないようなので(スリープ復帰後にウィンドウ位置が崩れないようにするなどの目的では、これが望ましいわけだし…)、ディスプレイの挙動によって出来たり出来なかったりするのだろう。

*3 : Windows 11にはCPU制限で出来ないので、Windows 10で実施

*4 : じゃあどうすれば取れるのかへのリンクも張って欲しい