BRAVIA X9500Gを最近買ってHDMI経由でPCと接続しているのですが、このテレビにはPCからの入力がOFFになったときに、自動的にTV電源がOFFになる機能がありませんでした1。
そこで、2年前くらいに買って転がっていたRaspberry Pi Zero WHを使ってRaspberry Pi経由で電源OFFを試してみました。
方針
TV操作について
HDMI-CECを使ってTVを操作します。
残念ながらPCについているHDMIからではHDMI-CEC信号を送れないことが多いらしく、対象のPC(NVIDIAグラボ)でも送れませんでした。
Raspberry PiはHDMI-CECを使えるようなので、HDMIでTVに接続してcec-clientを使ってTVを操作します。
cec-clientをインストールしておけば、echo "standby 0" | cec-client -s -d 1
コマンドでTV電源をOFFにできます。
モニタOFF検知について
PCはWindows 10なのですが、簡単に検知する方法はなさそうでした。
Win32 APIのRegisterPowerSettingNotificationを使うと通知されるようになるWM_POWERBROADCASTメッセージから情報を取得します。
モニタOFFを検知したら、HTTPでサーバ(Raspberry Pi)にTV電源OFFを指示します。
なお、クライアント側・サーバ側ともにNode.jsを使っていきます。
環境
Raspberry Pi | バージョン |
---|---|
OS | Raspbian GNU/Linux 9.4 (stretch) |
Node.js | v8.11.1 |
cec-client (libCEC) | 4.0.2 |
Raspberry PiはIPアドレスを192.168.1.100で固定してHTTP通信できるところまで設定済
PC | バージョン |
---|---|
OS | Windows 10 Pro 1909 |
Node.js | v10.17.0 |
PC側で使用するライブラリは以下のとおり
"console-stamp": "^0.2.9",
"ref-array": "^1.2.0",
"ref-struct-di": "^1.1.0",
"unirest": "^0.6.0",
"win32-api": "^6.2.0"
Raspberry Pi側のソース
サーバ側は、リクエストBodyの内容をcec-clientに入力するだけです。
consthttp=require('http');const{execSync}=require('child_process');http.createServer((req,res)=>{letbody=[];req.on('error',(err)=>{console.error(err);}).on('data',(chunk)=>{body.push(chunk);}).on('end',()=>{body=Buffer.concat(body).toString();console.log(`body: ${body}`);constcommand=`echo "${body}" | cec-client -s -d 1`;constresult=execSync(command).toString();console.log(`result: ${result}`);res.writeHead(200,{'Content-Type':'text/plain'});res.end(result);});}).listen(8080);console.log('server started');
#!/bin/sh
/opt/nodejs/bin/node /opt/cec_server/server.js &
起動時に動いてほしいので、cronで@reboot
を指定します。
パイプでlogger
につないで、ログが/var/log/messages
に出るようにしています。
@reboot /opt/cec_server/start.sh | logger -t cec_server
PC側のソース
クライアント側は少し大変で、メッセージ受信用のWindowとコールバック関数を作っていく必要があります。
また、受信したメッセージ内のPointerのアドレスをStructに変換していく仕組みも必要です。
まずは、struct.jsでGUID
とPOWERBROADCAST_SETTING
のStructの定義と、ffiを使ってRegisterPowerSettingNotification
関数を呼び出すための仕組みを作っていきます。
GUIDの値はGUID_CONSOLE_DISPLAY_STATEとGUID_MONITOR_POWER_ONのどちらでも使えますが、"New applications should use GUID_CONSOLE_DISPLAY_STATE"とのことなので、こっちを使っています。
constffi=require('ffi');constref=require('ref');constStruct=require('ref-struct-di')(ref);constArrayType=require('ref-array');constGUID=Struct({Data1:ref.types.long,Data2:ref.types.short,Data3:ref.types.short,Data4:ArrayType(ref.types.uchar,8)});constLPCGUID=ref.refType(GUID);functiontranslateGUID(strGUID){constpattern=/(\w+)-(\w+)-(\w+)-(\w{2})(\w{2})-(\w{2})(\w{2})(\w{2})(\w{2})(\w{2})(\w{2})/;const[d1,d2,d3,...d4]=pattern.exec(strGUID).slice(1).map(v=>parseInt(v,16));returnGUID({Data1:d1,Data2:d2,Data3:d3,Data4:d4});}functionequalsGUID(guid1,guid2){returnJSON.stringify(guid1)==JSON.stringify(guid2);}constGUID_CONSOLE_DISPLAY_STATE=translateGUID('6FE69556-704A-47A0-8F24-C28D936FDA47');// export const GUID_MONITOR_POWER_ON = translateGUID('02731015-4510-4526-99E6-E5A17EBD1AEA');constPOWERBROADCAST_SETTING=Struct({PowerSetting:GUID,DataLength:ref.types.ulong,Data:ArrayType(ref.types.uchar,1)});constRegisterPowerSettingNotification=ffi.Library('user32.dll',{RegisterPowerSettingNotification:['long',['long',LPCGUID,'long']]}).RegisterPowerSettingNotification;module.exports={GUID_CONSOLE_DISPLAY_STATE:GUID_CONSOLE_DISPLAY_STATE,POWERBROADCAST_SETTING:POWERBROADCAST_SETTING,RegisterPowerSettingNotification:RegisterPowerSettingNotification,equalsGUID:equalsGUID};
detect_poweroff.jsでは、createWindow
でメッセージ受信用の非表示のウィンドウを作って、registerNotification
で通知の登録をしていきます。
Windowを作る際にも指定したWndProc
でコールバック(メッセージ)を待ち受けます。
WndProc
の中では、対象のメッセージのlParam
にPOWERBROADCAST_SETTING
へのポインタのアドレスが入っているので、bufferAtAddress
を使ってアドレス数値からBufferを構築しています。lParam
がNumber型になっていて、整数を53ビットまでしか扱えないのに問題ないか気になりましたが、Windows 10ではサポートする仮想アドレス空間は48ビットのため大丈夫なようです2。
外から呼び出されるstartMessageLoop
では、メッセージをWndProc
に転送するメッセージループを開始します。
const{U,K,DTypes,DStruct,Config}=require('win32-api');const[W,DS]=[DTypes,DStruct];constffi=require('ffi');constref=require('ref');constStruct=require('ref-struct-di')(ref);const{GUID_CONSOLE_DISPLAY_STATE,POWERBROADCAST_SETTING,RegisterPowerSettingNotification,equalsGUID}=require('./struct.js');constWM_POWERBROADCAST=536;constPBT_POWERSETTINGCHANGE=32787;constuser32=U.load();constkernel32=K.load();varcallback_f=()=>{};functionregisterCallback(f){callback_f=f;}functionregisterNotification(hWnd,guid){consthWndAddr=ref.address(hWnd);consthPowerNotify=RegisterPowerSettingNotification(hWndAddr,guid.ref(),0);console.log('register result:',hPowerNotify);consterror=kernel32.GetLastError();console.log('register error:',error);}functionbufferAtAddress(address,bufferType){if(address>Number.MAX_SAFE_INTEGER){thrownewError('Address too high!');}constbuff=Buffer.alloc(8);buff.writeUInt32LE(address%0x100000000,0);buff.writeUInt32LE(Math.trunc(address/0x100000000),4);buff.type=bufferType;returnref.deref(buff);}constWndProc=ffi.Callback('uint32',[W.HWND,W.UINT,W.WPARAM,W.LPARAM],(hwnd,uMsg,wParam,lParam)=>{console.log('WndProc callback:',uMsg,wParam,lParam);if(uMsg==WM_POWERBROADCAST&&wParam==PBT_POWERSETTINGCHANGE){constbuf=bufferAtAddress(lParam,ref.refType(POWERBROADCAST_SETTING));constsetting=buf.deref();conststate=setting.Data[0];console.info('power state:',state);if(equalsGUID(setting.PowerSetting,GUID_CONSOLE_DISPLAY_STATE)){console.log('GUID_CONSOLE_DISPLAY_STATE');callback_f(state);}}return0;});functioncreateWindow(clazzName){constclassName=Buffer.from(clazzName+'\0','ucs-2');constwindowName=Buffer.from(clazzName+'_Window\0','ucs-2');consthInstance=ref.alloc(W.HINSTANCE);kernel32.GetModuleHandleExW(0,null,hInstance);constwClass=newStruct(DS.WNDCLASSEX)();wClass.cbSize=Config._WIN64?80:48;// x86 = 48, x64=80wClass.style=0;wClass.lpfnWndProc=WndProc;wClass.cbClsExtra=0;wClass.cbWndExtra=0;wClass.hInstance=hInstance;wClass.hIcon=ref.NULL;wClass.hCursor=ref.NULL;wClass.hbrBackground=ref.NULL;wClass.lpszMenuName=ref.NULL;wClass.lpszClassName=className;wClass.hIconSm=ref.NULL;if(!user32.RegisterClassExW(wClass.ref())){thrownewError('Error registering class');}consthWnd=user32.CreateWindowExW(0,className,windowName,0,0,0,0,0,null,null,hInstance,null);registerNotification(hWnd,GUID_CONSOLE_DISPLAY_STATE);}functionsleep(ms){returnnewPromise(resolve=>setTimeout(resolve,ms));}asyncfunctionstartMessageLoop(){constmsg=newStruct(DS.MSG)();while(true){if(user32.PeekMessageW(msg.ref(),null,0,0,1)){user32.TranslateMessageEx(msg.ref());user32.DispatchMessageW(msg.ref());}awaitsleep(100);}}createWindow('Node.js_idle-poweroff');module.exports={registerCallback:registerCallback,startMessageLoop:startMessageLoop}
send_poweroff.jsでは、モニタON/OFFを検知した際に呼ばれるコールバックを登録して、メッセージループを開始します。
コールバックが呼ばれたら、HTTPでHDMI-CECコマンドを送信します。
require('console-stamp')(console,'yyyy-mm-dd HH:MM:ss');constunirest=require('unirest');const{registerCallback,startMessageLoop}=require('./detect_poweroff.js');constDISPLAY_OFF=0;constDISPLAY_ON=1;consturl='http://192.168.1.100:8080';functionsendCommand(command){console.info('send command:',command);unirest.post(url).send(command).end(res=>{console.log('response body:',res.body);});}registerCallback(state=>{if(state==DISPLAY_OFF){sendCommand('standby 0');}elseif(state==DISPLAY_ON){sendCommand('on 0');}});startMessageLoop();
cd %~dp0
node send_poweroff.js
実行結果は以下のとおり。
PC側のON/OFFに合わせてTV電源が連動するようになりました。
[2019-12-22 10:23:04] [LOG] WndProc callback: 36 0 267690495392
[2019-12-22 10:23:04] [LOG] WndProc callback: 129 0 267690495296
[2019-12-22 10:23:04] [LOG] WndProc callback: 131 0 267690495424
[2019-12-22 10:23:04] [LOG] WndProc callback: 1 0 267690495296
[2019-12-22 10:23:04] [LOG] register result: -1532784720
[2019-12-22 10:23:04] [LOG] register error: 0
[2019-12-22 10:23:04] [LOG] WndProc callback: 536 32787 2382176198256
[2019-12-22 10:23:04] [INFO] power state: 1
[2019-12-22 10:23:04] [LOG] GUID_CONSOLE_DISPLAY_STATE
[2019-12-22 10:23:04] [INFO] send command: on 0
[2019-12-22 10:23:04] [LOG] WndProc callback: 799 1 0
[2019-12-22 10:23:08] [LOG] response body: opening a connection to the CEC adapter...
[2019-12-22 10:29:48] [LOG] WndProc callback: 536 32787 2382174664240
[2019-12-22 10:29:48] [INFO] power state: 0
[2019-12-22 10:29:48] [LOG] GUID_CONSOLE_DISPLAY_STATE
[2019-12-22 10:29:48] [INFO] send command: standby 0
[2019-12-22 10:29:52] [LOG] WndProc callback: 537 7 0
[2019-12-22 10:29:52] [LOG] response body: opening a connection to the CEC adapter...
[2019-12-22 10:31:49] [LOG] WndProc callback: 536 32787 2382147455920
[2019-12-22 10:31:49] [INFO] power state: 1
[2019-12-22 10:31:49] [LOG] GUID_CONSOLE_DISPLAY_STATE
[2019-12-22 10:31:49] [INFO] send command: on 0
[2019-12-22 10:31:52] [LOG] response body: opening a connection to the CEC adapter...
感想
Node.jsでRegisterPowerSettingNotificationを使うサンプルが見当たらなかったので作ってみましたが、予想以上に大変でした。
特にrefの使い方は試行錯誤が必要で、hWndをログ出力しようとするとログが出ずにプログラムが落ちるなど、解析が難しい場面が多くありました。
また、lParamの型は W.LPARAMのところをW.PINT64などに変えるとBuffer型になるのですが、そこからうまくStructに変換ができませんでした。
はじめはPCのアイドル時間を(これもWin32 API経由で)取得して電源OFFをするようにしていました。その場合はPC側のコードは30行程度で済みます。
しかし、それだと動画を見ている最中に電源がOFFになってしまうという課題がありました。
参考にしたページ
https://stackoverflow.com/questions/48720924/python-3-detect-monitor-power-state-in-windows
https://github.com/waitingsong/node-win32-api/blob/master/demo/create_window.ts
https://github.com/TooTallNate/ref/issues/96