Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8829

ディススレイがOFFになったらRaspberryPi経由でTV電源をOFFにしたい

$
0
0

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バージョン
OSRaspbian GNU/Linux 9.4 (stretch)
Node.jsv8.11.1
cec-client (libCEC)4.0.2

Raspberry PiはIPアドレスを192.168.1.100で固定してHTTP通信できるところまで設定済

PCバージョン
OSWindows 10 Pro 1909
Node.jsv10.17.0

PC側で使用するライブラリは以下のとおり

dependencies
    "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に入力するだけです。

server.js
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');
start.sh
#!/bin/sh

/opt/nodejs/bin/node /opt/cec_server/server.js &

起動時に動いてほしいので、cronで@rebootを指定します。
パイプでloggerにつないで、ログが/var/log/messagesに出るようにしています。

cron設定
@reboot /opt/cec_server/start.sh | logger -t cec_server

PC側のソース

クライアント側は少し大変で、メッセージ受信用のWindowとコールバック関数を作っていく必要があります。
また、受信したメッセージ内のPointerのアドレスをStructに変換していく仕組みも必要です。

まずは、struct.jsでGUIDPOWERBROADCAST_SETTINGのStructの定義と、ffiを使ってRegisterPowerSettingNotification関数を呼び出すための仕組みを作っていきます。

GUIDの値はGUID_CONSOLE_DISPLAY_STATEとGUID_MONITOR_POWER_ONのどちらでも使えますが、"New applications should use GUID_CONSOLE_DISPLAY_STATE"とのことなので、こっちを使っています。

struct.js
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の中では、対象のメッセージのlParamPOWERBROADCAST_SETTINGへのポインタのアドレスが入っているので、bufferAtAddressを使ってアドレス数値からBufferを構築しています。
lParamがNumber型になっていて、整数を53ビットまでしか扱えないのに問題ないか気になりましたが、Windows 10ではサポートする仮想アドレス空間は48ビットのため大丈夫なようです2

外から呼び出されるstartMessageLoopでは、メッセージをWndProcに転送するメッセージループを開始します。

detect_poweroff.js
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コマンドを送信します。

send_poweroff.js
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();
start_idle_poweroff.bat
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


  1. 「無操作電源オフ」の設定はあるものの、これはテレビのリモコンを操作しなかった場合にOFFになる設定のため、使えませんでした。サポートにも問い合わせましたが機能はないとのこと 

  2. アドレス数値をBufferに変換する方法は他に見つかりませんでした 


Viewing all articles
Browse latest Browse all 8829

Trending Articles