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に入力するだけです。
const http = require('http');
const { execSync } = require('child_process');
http.createServer((req, res) => {
let body = [];
req.on('error', (err) => {
console.error(err);
}).on('data', (chunk) => {
body.push(chunk);
}).on('end', () => {
body = Buffer.concat(body).toString();
console.log(`body: ${body}`);
const command = `echo "${body}" | cec-client -s -d 1`;
const result = 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"とのことなので、こっちを使っています。
const ffi = require('ffi');
const ref = require('ref');
const Struct = require('ref-struct-di')(ref);
const ArrayType = require('ref-array');
const GUID = Struct({
Data1: ref.types.long,
Data2: ref.types.short,
Data3: ref.types.short,
Data4: ArrayType(ref.types.uchar, 8)
});
const LPCGUID = ref.refType(GUID);
function translateGUID(strGUID) {
const pattern = /(\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));
return GUID({ Data1: d1, Data2: d2, Data3: d3, Data4: d4 });
}
function equalsGUID(guid1, guid2) {
return JSON.stringify(guid1) == JSON.stringify(guid2);
}
const GUID_CONSOLE_DISPLAY_STATE = translateGUID('6FE69556-704A-47A0-8F24-C28D936FDA47');
// export const GUID_MONITOR_POWER_ON = translateGUID('02731015-4510-4526-99E6-E5A17EBD1AEA');
const POWERBROADCAST_SETTING = Struct({
PowerSetting: GUID,
DataLength: ref.types.ulong,
Data: ArrayType(ref.types.uchar, 1)
});
const RegisterPowerSettingNotification = 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];
const ffi = require('ffi');
const ref = require('ref');
const Struct = require('ref-struct-di')(ref);
const {
GUID_CONSOLE_DISPLAY_STATE,
POWERBROADCAST_SETTING,
RegisterPowerSettingNotification,
equalsGUID
} = require('./struct.js');
const WM_POWERBROADCAST = 536;
const PBT_POWERSETTINGCHANGE = 32787;
const user32 = U.load();
const kernel32 = K.load();
var callback_f = () => {};
function registerCallback(f) {
callback_f = f;
}
function registerNotification(hWnd, guid) {
const hWndAddr = ref.address(hWnd);
const hPowerNotify = RegisterPowerSettingNotification(hWndAddr, guid.ref(), 0);
console.log('register result:', hPowerNotify);
const error = kernel32.GetLastError();
console.log('register error:', error);
}
function bufferAtAddress(address, bufferType) {
if (address > Number.MAX_SAFE_INTEGER) {
throw new Error('Address too high!');
}
const buff = Buffer.alloc(8);
buff.writeUInt32LE(address % 0x100000000, 0);
buff.writeUInt32LE(Math.trunc(address / 0x100000000), 4);
buff.type = bufferType;
return ref.deref(buff);
}
const WndProc = 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) {
const buf = bufferAtAddress(lParam, ref.refType(POWERBROADCAST_SETTING));
const setting = buf.deref();
const state = 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);
}
}
return 0;
});
function createWindow(clazzName) {
const className = Buffer.from(clazzName + '\0', 'ucs-2');
const windowName = Buffer.from(clazzName + '_Window\0', 'ucs-2');
const hInstance = ref.alloc(W.HINSTANCE);
kernel32.GetModuleHandleExW(0, null, hInstance);
const wClass = new Struct(DS.WNDCLASSEX)();
wClass.cbSize = Config._WIN64 ? 80 : 48; // x86 = 48, x64=80
wClass.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())) {
throw new Error('Error registering class');
}
const hWnd = user32.CreateWindowExW(0, className, windowName, 0, 0, 0, 0, 0, null, null, hInstance, null);
registerNotification(hWnd, GUID_CONSOLE_DISPLAY_STATE);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function startMessageLoop() {
const msg = new Struct(DS.MSG)();
while (true) {
if (user32.PeekMessageW(msg.ref(), null, 0, 0, 1)) {
user32.TranslateMessageEx(msg.ref());
user32.DispatchMessageW(msg.ref());
}
await sleep(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');
const unirest = require('unirest');
const { registerCallback, startMessageLoop } = require('./detect_poweroff.js');
const DISPLAY_OFF = 0;
const DISPLAY_ON = 1;
const url = 'http://192.168.1.100:8080';
function sendCommand(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');
} else if (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