凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

遠隔コンセントタップ WeMo を SOAP で動かすハックやってみた

はじめに

WeMo は belkin 社が提供しているホームオートメーション製品で、iPhone からコントロールできる電源タップ (WeMo Switch) やモーションセンサー (WeMo Motion) といったものがあります。

WeMo Switch

WeMo Motion

WeMo アプリ


iPhone 上でどういうルールで電源 ON/OFF するかといったことを設定できることに加え、IFTTT 連携も可能で、例えば「"電気を消して" というツイートが来たら電気を消す」とか「もし動きを検知したらメールする」みたいなことが可能です。
ということは、きっとハックして自分のプログラムからスイッチ ON/OFF したり動きを検知したり出来るだろう、ということで調べてみましたら、以下のエントリを見つけました。

ここでは PythonUPnP クライアント miranda を改造して WeMo 操作したよ!ということだったので、私は自分が慣れ親しんだ Node.js から操作を試みてみました。別の言語からでも操作手順は同じだと思うので、WeMo をお持ちの方は是非チャレンジしてみて下さい。

概要

WeMo を JS から操作するハック手順は以下の様な感じです。

  1. WeMo の IP / LOCATION を SSDP して調べる
  2. LOCATION へアクセスして WeMo のコマンドを調べる
  3. 対象の IP へ SOAP でコマンドを送る

WeMo の IP / LOCATION を SSDP して調べる

Node.js には SSDP という便利なライブラリがあるのでコレを使います。基本的には M-SEARCH した結果をよしなにオブジェクトに格納してくれているものです。

$ npm install ssdp

で、次のようなプログラムを書きます。

var SSDP   = require('ssdp').SSDP
  , client = new SSDP()
;
setInterval(function() {
	client.on('response', function (msg, rinfo) {
		console.log(msg.toString());
	});
	client.search('urn:Belkin:service:basicevent:1');
}, 2000);

サービスを指定して SSDP でネットワーク内の機器を検索しています。これを実行すると次のような結果が返ってきます。

HTTP/1.1 200 OK
CACHE-CONTROL: max-age=86400
DATE: Mon, 14 Jan 2013 01:03:45 GMT
EXT:
LOCATION: http://192.168.0.12:49153/setup.xml
SERVER: Linux/2.6.21, UPnP/1.0, Portable SDK for UPnP devices/1.6.6
X-User-Agent: redsonic
ST: urn:Belkin:service:basicevent:1
USN: uuid:Socket-1_0-221227K01007B1::urn:Belkin:service:basicevent:1

これで WeMo の IP と LOCATION が分かります。WeMo Switch でも WeMo Motion でも同じ結果が返ってきます。
ちなみに search で ssdp:all を指定すると UPnP 対応の機器を全サーチできます。

LOCATION へアクセスして WeMo のコマンドを調べる

得られた LOCATION にアクセスしてみると以下の様な XML が置いてあります。

<root xmlns="urn:Belkin:device-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <device>
    <deviceType>urn:Belkin:device:sensor:1</deviceType>
    <friendlyName>WeMo Motion</friendlyName>
    <manufacturer>Belkin International Inc.</manufacturer>
    <manufacturerURL>http://www.belkin.com</manufacturerURL>
    <modelDescription>Belkin Plugin Socket 1.0</modelDescription>
    <modelName>Socket</modelName>
    <modelNumber>1.0</modelNumber>
    <modelURL>http://www.belkin.com/plugin/</modelURL>
    <serialNumber>221214L0102249</serialNumber>
    <UDN>uuid:Sensor-1_0-221214L0102249</UDN>
    <UPC>123456789</UPC>
    <iconList>
      <icon>
        <mimetype>jpg</mimetype>
        <width>100</width>
        <height>100</height>
        <depth>100</depth>
        <url>icon.jpg</url>
      </icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:Belkin:service:WiFiSetup:1</serviceType>
        <serviceId>urn:Belkin:serviceId:WiFiSetup1</serviceId>
        <controlURL>/upnp/control/WiFiSetup1</controlURL>
        <eventSubURL>/upnp/event/WiFiSetup1</eventSubURL>
        <SCPDURL>/setupservice.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:timesync:1</serviceType>
        <serviceId>urn:Belkin:serviceId:timesync1</serviceId>
        <controlURL>/upnp/control/timesync1</controlURL>
        <eventSubURL>/upnp/event/timesync1</eventSubURL>
        <SCPDURL>/timesyncservice.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:basicevent:1</serviceType>
        <serviceId>urn:Belkin:serviceId:basicevent1</serviceId>
        <controlURL>/upnp/control/basicevent1</controlURL>
        <eventSubURL>/upnp/event/basicevent1</eventSubURL>
        <SCPDURL>/eventservice.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:firmwareupdate:1</serviceType>
        <serviceId>urn:Belkin:serviceId:firmwareupdate1</serviceId>
        <controlURL>/upnp/control/firmwareupdate1</controlURL>
        <eventSubURL>/upnp/event/firmwareupdate1</eventSubURL>
        <SCPDURL>/firmwareupdate.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:rules:1</serviceType>
        <serviceId>urn:Belkin:serviceId:rules1</serviceId>
        <controlURL>/upnp/control/rules1</controlURL>
        <eventSubURL>/upnp/event/rules1</eventSubURL>
        <SCPDURL>/rulesservice.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:metainfo:1</serviceType>
        <serviceId>urn:Belkin:serviceId:metainfo1</serviceId>
        <controlURL>/upnp/control/metainfo1</controlURL>
        <eventSubURL>/upnp/event/metainfo1</eventSubURL>
        <SCPDURL>/metainfoservice.xml</SCPDURL>
      </service>
      <service>
        <serviceType>urn:Belkin:service:remoteaccess:1</serviceType>
        <serviceId>urn:Belkin:serviceId:remoteaccess1</serviceId>
        <controlURL>/upnp/control/remoteaccess1</controlURL>
        <eventSubURL>/upnp/event/remoteaccess1</eventSubURL>
        <SCPDURL>/remoteaccess.xml</SCPDURL>
      </service>
    </serviceList>
    <presentationURL>/pluginpres.html</presentationURL>
  </device>
</root>

ここではデバイスの情報やどういったサービスを持っているかが定義されています。注目するのは「urn:Belkin:service:basicevent:1」のところで、このサービスを利用して WeMo の操作を行います。ではこのイベントが定義されている xml を見るために、「eventservice.xml」にアクセスしてみます。

<scpd xmlns="urn:Belkin:service-1-0">
  <specVersion>
    <major>1</major>
    <minor>0</minor>
  </specVersion>
  <actionList>
    <action>
      <name>SetBinaryState</name>
      <argumentList>
        <argument>
          <retval/>
          <name>BinaryState</name>
          <relatedStateVariable>BinaryState</relatedStateVariable>
          <direction>in</direction>
        </argument>
      </argumentList>
    </action>
    <action>
      <name>SetLogLevelOption</name>
      <argumentList>
        <argument>
          <retval/>
          <name>Level</name>
          <relatedStateVariable>Level</relatedStateVariable>
          <direction>in</direction>
        </argument>
        <argument>
          <retval/>
          <name>Option</name>
          <relatedStateVariable>Option</relatedStateVariable>
          <direction>in</direction>
        </argument>
      </argumentList>
    </action>
    ...(中略)
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="yes">
      <name>BinaryState</name>
      <dataType>Boolean</dataType>
      <defaultValue>0</defaultValue>
    </stateVariable>
    <stateVariable sendEvents="yes">
      <name>level</name>
      <dataType>string</dataType>
      <defaultValue>0</defaultValue>
    </stateVariable>
    ...(中略)
  </serviceStateTable>
</scpd>

ここではデバイスがどんな Action を持っているかが書いてあります。GetBinaryState / SetBinaryState あたりで ON/OFF 状態の取得とセットが出来そうです。

対象の IP へ SOAP でコマンドを送る

では先ほど調べた Action を SOAP で送ってみます。

var http = require('http');

var data =
	'<?xml version="1.0" encoding="utf-8"?>\n' +
	'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\n' +
	' <s:Body>\n' +
	'  <u:GetBinaryState xmlns:u="urn:Belkin:service:basicevent:1">\n' + // 対象の Action を指定
	'  </u:GetBinaryState>\n' +
	' </s:Body>\n' +
	'</s:Envelope>\n';

var options = {
	host   : '192.168.0.8', // 先ほど調べた WeMo の IP と 
	port   : 49153,         // ポートを指定
	path   : '/upnp/control/basicevent1', // controlURL を指定
	method : 'POST',
	headers: {
		'SOAPACTION'     : '"urn:Belkin:service:basicevent:1#GetBinaryState"', // ユニークサービス名 + #Action
		'Content-Length' : data.length,
		'Content-Type'   : 'text/xml; charset="utf-8"',
		'User-Agent'     : 'CyberGarage-HTTP/1.0' // iPhone App と同じ User-Agent
	}
};

var req = http.request(options, function(res) {
	console.log('STATUS: ' + res.statusCode);
	console.log('HEADERS: ' + JSON.stringify(res.headers));
	res.setEncoding('utf8');
	res.on('data', function (chunk) {
		console.log('BODY: ' + chunk);
	});
});

req.on('error', function(e) {
	console.log('problem with request: ' + e.message);
});

req.write(data);
req.end();

SOAP は基本的には HTTP Post で指定のフォーマットを送るだけです。これで指定のフォーマットで結果が返ってきます。ちなみにHTTP ヘッダに付与する SOAPAction をダブルクォートしていなくてコレで正しい結果が返ってこずうんうん悩んでたので皆さんお間違いなきよう…*1
送ると以下の様な結果が返ってきます。

STATUS: 200
HEADERS: {"content-length":"285","content-type":"text/xml; charset=\"utf-8\"","date":"Mon, 14 Jan 2013 01:27:03 GMT","ext":"","server":"Linux/2.6.21, UPnP/1.0, Portable SDK for UPnP devices/1.6.6","x-user-agent":"redsonic"}
BODY: <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>
<u:GetBinaryStateResponse xmlns:u="urn:Belkin:service:basicevent:1">
<BinaryState>0</BinaryState>
</u:GetBinaryStateResponse>
</s:Body> </s:Envelope>

これを利用したかったらパースすれば OK です。ちなみに上記例は ON/OFF 状態を BinaryState 内の 1/0 で返してくれているものです。ON/OFF をセットするには以下のようにします。

var http = require('http');

var data =
	'<?xml version="1.0" encoding="utf-8"?>\n' +
	'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\n' +
	' <s:Body>\n' +
	'  <u:SetBinaryState xmlns:u="urn:Belkin:service:basicevent:1">\n' + // 対象の Action を指定
	'   <BinaryState>1</BinaryState>\n' + // ON/OFF を 1/0 で指定
	'  </u:SetBinaryState>\n' +
	' </s:Body>\n' +
	'</s:Envelope>\n';

var options = {
	host   : '192.168.0.12', // 先ほど調べた WeMo の IP と
	port   : 49153,         // ポートを指定
	path   : '/upnp/control/basicevent1', // controlURL を指定
	method : 'POST',
	headers: {
		'SOAPACTION'     : '"urn:Belkin:service:basicevent:1#SetBinaryState"', // ユニークサービス名 + #Action
		'Content-Length' : data.length,
		'Content-Type'   : 'text/xml; charset="utf-8"',
		'User-Agent'     : 'CyberGarage-HTTP/1.0' // iPhone App と同じ User-Agent
	}
};

var req = http.request(options, function(res) {
	console.log('STATUS: ' + res.statusCode);
	console.log('HEADERS: ' + JSON.stringify(res.headers));
	res.setEncoding('utf8');
	res.on('data', function (chunk) {
		console.log('BODY: ' + chunk);
	});
});

req.on('error', function(e) {
	console.log('problem with request: ' + e.message);
});

req.write(data);
req.end();

上手く動きました!

WeMo Switch の挙動

SetBinaryState で ON/OFF セット、GetBinaryState で ON/OFF 状態取得、両方共良い感じに動きます。

WeMo Motion の挙動

SetBinaryState は 501 エラーが返ってきます。GetBinaryState でモーションがあったかどうかを 0/1 で取得出来ます。1秒置きとかでポーリングしてあげれば良い感じに動きがあったかどうかを検知できると思います。本当は動きがあったら特定のサーバへメッセージを送るみたいな挙動して欲しかったんですが分からず。。

需要がアレばモジュール化します。

おわりに

スマートホーム関連製品は今後もいろいろ出てくると思うので、似たようなハックで色々遊べたら楽しいですね。

*1:miranda で送っていたパケットを wireshark で比較してようやく分かるまで2時間くらいかかった…