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

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側で使用するライブラリは以下のとおり

```: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
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');
```

```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で`GUID`と`POWERBROADCAST_SETTING`のStructの定義と、ffiを使って`RegisterPowerSettingNotification`関数を呼び出すための仕組みを作っていきます。

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

```struct.js
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`に転送するメッセージループを開始します。

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

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

```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に変換する方法は他に見つかりませんでした