仙石浩明の日記

2019年11月28日

IFTTT に登録できないのでお蔵入りになってた Eco Plugs RC-028W & CT-065W が、UDP パケットを送るだけでコントロールできた!

IoT 機器の多くが、 専用のスマホアプリだけでなく Googleアシスタントや Amazonアレクサからコントロールできる。 しかし、 いちいち音声でコントロールするのはメンドクサイ (なぜ音声以外の方法でもコントロールできるようにしないのか?)。 出かけるときに毎回 「行ってきま〜す」 などと Googleアシスタントに呼び掛けるのは、 いかがなものかと思う。 外出を勝手に検知して家電を適切にコントロール (例えば電気ポットの電源を切る) してくれるほうがずっといい。

IoT 機器を IFTTT に登録すると、 自前のプログラムからコントロールできるようになる。 IoT 機器は Googleアシスタントでコントロールするより、 自前のプログラムでコントロールするに限る。 例えば自宅の Wi-Fi LAN にスマホが繋がっているかプログラムで監視し、 繋がってるスマホがいなくなったら留守になったと判断して、 自動的に電気ポットの電源を切れば、 電気ポットのコンセントを抜いたかどうか出先で心配せずに済む。 あるいはコンセントを抜くのを忘れて寝てしまい、 翌朝電気ポットのお湯が熱いままなのを見て愕然とするより (先月の電気使用量が 600kWh だったので驚いた)、 部屋が暗いときは自動的に電源が切れている方がいい (これはプログラムを書かなくても IFTTT だけで実現できる)。

というわけで持ってる IoT 機器を片っ端から IFTTT に登録したのだけど、 IFTTT に登録できない IoT 機器も残念ながら若干ある。 いまどき IFTTT に登録できない IoT 機器に何の意味があるのだろう? (今なら絶対に買わない) と思うのだけど、 IFTTT の便利さを知る前に買ってしまったのだから後悔先に立たず。 IFTTT の便利さを知ってからは、 お蔵入りになっていた。

Eco Plugs もそんな「使えない」IoT 機器の一つ。 当時としては安価だった (今ならもっと安い) ので Walmart で購入してしまった。 Googleアシスタントや Amazonアレクサには登録できるのに、 肝心の IFTTT に登録できない。

といって通信プロトコルを解析しようにも、 いまどきの IoT 機器はクラウド (ベンダが運用するサーバ) と https で通信するので調べる取っ掛かりがない。 最後の手段、 分解するしかないのか?

ところがググっていると、 Eco Plugs は平文で通信しているという投稿を見つけた。 Eco Plugs はクラウドに登録しなくても、 同一 LAN セグメントならスマホアプリでコントロールできるが、 同一 LAN 内では平文の UDP パケットを飛ばしているらしい。

ありがたいことに Eco Plugs をコントロールする JavaScript プログラムが GitHub に公開されていた。 JavaScript は文法もロクに知らない (^^; のだけど、 見よう見まねで perl で書き直してみる:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Std;
use IO::Socket::INET;
our ($opt_v);
(getopts('v') && @ARGV == 3) || &help;
my ($ip, $id, $on) = @ARGV;

my $state = 0x0100;
$state = 0x0101 if $on eq "on";
my $buf = pack("H260", 0);
# Byte 0:3 - Command 0x16000500 = Write, 0x17000500 = Read
substr($buf, 0, 4) = pack("N", 0x16000500);
# Byte 4:7 - Command sequence num - looks random
substr($buf, 4, 4) = pack("N", rand(0xffffffff));
# Byte 8:9 - Not sure what this field is - 0x0200 = Write, 0x0000 = Read
substr($buf, 8, 2) = pack("n", 0x0200);
# Byte 16:31 - ECO Plugs ID ASCII Encoded - <ECO-xxxxxxxx>
substr($buf, 16, 16) = $id;
# Byte 116:119 - The current epoch time in Little Endian
substr($buf, 116, 4) = pack("L", time());
# Byte 124:127 - Not sure what this field is - this value works, but i've seen others 0xCDB8422A
substr($buf, 124, 4) = pack("N", 0xCDB8422A);
# Byte 128:129 - Power state (only for writes)
substr($buf, 128, 2) = pack("n", $state);

my $sock = IO::Socket::INET->new(PeerAddr => $ip, PeerPort => 80,
    Proto => 'udp', Timeout => 1) || die;
my $flags;
print unpack("H*", $buf) . "\n" if $opt_v;
print $sock $buf;
$sock->recv($buf, 1024, $flags);
print unpack("H*", $buf) . "\n" if $opt_v;

# Byte 10:14 - ASCII encoded FW Version - Set in readback only?
my $fwver = substr($buf, 10, 5);
# Byte 48:79 - ECO Plugs name as set in app
my $name = substr($buf, 48, 32);
$name =~ s/\0*$//;
printf("%s (ver %s)\n", $name, $fwver);

sub help {
    print <<EOF;
Usage: ecoplugs <opt> <IP> <ID> <on/off>
opt:   -v           ; verbose
EOF
    exit 1;
} 

長さ 130 バイトの UDP パケット (変数 $buf) を作って Eco Plugs へ送信している (print $sock $buf;) だけなので、 いたってシンプル。 ユーザ認証もないので LAN 内なら誰でもコントロールできる。

Eco Plugs の IP アドレス (第1引数) と、 Eco Plugs の ID 「ECO-XXXXXXXX」(第2引数, XXXXXXXX は MACアドレスの第3〜6オクテット, ただし 16進数の A〜F は大文字限定)、 および「on」あるいは「off」の 3引数を付けて、 この perl プログラムを実行すると、 該当 Eco Plugs をオン/オフし、 Eco Plugs の名前 (スマホアプリで設定できる。以下の例では 「potplug」) と、 ファームウェアのバージョン (以下の例では 「1.6.3」) を表示する。

senri:~ $ ecoplugs -v 192.168.15.123 ECO-01234567 on
16000500940c163b020000000000000045434f2d303132333435363700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a625df5d00000000cdb8422a0101
160005000000163b0000312e362e330045434f2d30313233343536370000000000000000000000000000000000000000706f74706c7567000000000000000000000000000000000000000000000000003031323334353637000000000000000000000000000000000000000000000000a8e23b7ea625df5d00000000cdb8422a
potplug (ver 1.6.3) 

「-v」オプションを付けた場合、 最初に表示される行が Eco Plugs へ送った長さ 130バイトの UDP パケット (260桁の 16進数)、 2行目が Eco Plugs から返ってきた長さ 128バイトの UDP パケット (256桁の 16進数)。 第1引数で指定した IP アドレスが Eco Plugs のものでなかった場合、 あるいは第2引数で指定した ID が間違っている場合など、 応答が返ってこない時は待ち続ける。 ID の 16進数において A〜F が小文字だと応答しないので注意。

Eco PlugsRC-028W (屋外用) および CT-065W (屋内用) で動作を確認したが、 おそらく同シリーズの他の機器でも使えるだろう。 Woods の WiON (スマホアプリが Eco Plugs そっくり) でも使えるらしい。

12月4日 追記:

Eco Plugs からの応答 UDP パケットは、 確実を期すためか同じものが 2度送られてくる。 複数の Eco Plugs デバイスを続けてコントロールするときなど、 2度目のパケットを次のデバイスからの応答と誤認する恐れがあるので、 応答パケットのシーケンス番号を確認すべき。

ここでシーケンス番号とは、 UDPパケットの 6, 7バイト目にランダムな値を設定してデバイスへ送信すると、 その応答パケットには同じ値が設定されて返ってくる。 参考にした eco.js では 「Byte 4:7 - Command sequence num」 と書かれているが、 実際の応答パケットでは 4, 5バイト目は常に 0が返ってきた。

また、 こちらから送った UDP パケットが届いていない可能性、 あるいは UDP パケットが正しく届いていても、 何らかの原因でオン/オフが正しく切り替わっていない可能性もある。 Eco Plugs をオン/オフするパケットを送った後は、 現在のオン/オフ状態を問合わせるパケットを送って、 正しい状態になっているか確認すべき。

さらに、 前掲した perl スクリプトでは、 Eco Plugs から応答が帰ってこない場合は待ち続けるが、 実地に使う場合は待ち続けて止まってしまっては困るので、 当然タイムアウト処理も必要になる。

以上 3点を修正した perl スクリプトは以下の通り:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Std;
use IO::Socket::INET;
our ($opt_v);
(getopts('v') && @ARGV == 3) || help();
my ($ip, $id, $on) = @ARGV;

my $com;
if ($on eq "on") {
    $com = 1;
} elsif ($on eq "off") {
    $com = 0;
} elsif ($on eq "get") {
    $com = -1;
} else {
    help();
}
my $buf = ecoplug_send($ip, $id, $com);
if (! $buf) {
    print "ecoplug_send timeout\n";
    exit 1;
}
# Byte 10:14 - ASCII encoded FW Version - Set in readback only?
my $fwver = substr($buf, 10, 5);
# Byte 48:79 - ECO Plugs name as set in app
my $name = substr($buf, 48, 32);
$name =~ s/\0*$//;
my $ret = ord(substr($buf, 129, 1));
printf("%s (ver %s) %s\n", $name, $fwver, $ret);
exit 0;

sub ecoplug_send {
    my ($ip, $id, $com) = @_;
    my $sock = IO::Socket::INET->new(
	PeerAddr => $ip, PeerPort => 80,
	Proto => 'udp', Timeout => 1) || return undef;
    my $seq;
    my $buf;
    for (;;) {
	$seq = rand(0x10000);
	$buf = ecoplug_packet($id, $com, $seq);
	printf("SEND %04x %s\n", $seq, unpack("H*", $buf)) if $opt_v;
	print $sock $buf;
	my $retry = 3;
	$SIG{'ALRM'} = sub {};
	do {
	    my $flags;
	    alarm 1;
	    my $ret = $sock->recv($buf, 1024, $flags);
	    alarm 0;
	    return undef unless $ret;
	    printf("RECV %04x %s\n", unpack("n", substr($buf, 6, 2)),
		   unpack("H*", $buf)) if $opt_v;
	} until substr($buf, 6, 2) eq pack("n", $seq) || $retry-- <= 0;
	last if $com < 0;
	$com = -1;
    }
    $sock->close;
    return $buf;
}

sub ecoplug_packet {
    my ($id, $com, $seq) = @_;
    my $buf;
    if ($com == 1) {		# on
	$buf = pack("H260", 0);
	# Byte 0:3 - Command 0x16000500 = Write
	substr($buf, 0, 4) = pack("N", 0x16000500);
	# Byte 8:9 - Not sure what this field is - 0x0200 = Write
	substr($buf, 8, 2) = pack("n", 0x0200);
    } elsif ($com == 0) {	# off
	$buf = pack("H260", 0);
	substr($buf, 0, 4) = pack("N", 0x16000500);
	substr($buf, 8, 2) = pack("n", 0x0200);
    } elsif ($com == -1) {	# get
	$buf = pack("H256", 0);
	# Byte 0:3 - Command 0x17000500 = Read
	substr($buf, 0, 4) = pack("N", 0x17000500);
	# Byte 8:9 - Not sure what this field is - 0x0000 = Read
    } else {
	return undef;
    }
    # Byte 6:7 - Command sequence num - looks random
    substr($buf, 6, 2) = pack("n", $seq);
    # Byte 16:31 - ECO Plugs ID ASCII Encoded - <ECO-xxxxxxxx>
    substr($buf, 16, 16) = $id;
    # Byte 116:119 - The current epoch time in Little Endian
    substr($buf, 116, 4) = pack("L", time());
    # Byte 124:127 - Not sure what this field is
    substr($buf, 124, 4) = pack("N", 0xCDB8422A);
    if ($com == 1) {		# on
	# Byte 128:129 - Power state (only for writes)
	substr($buf, 128, 2) = pack("n", 0x0101);
    } elsif ($com == 0) {	# off
	substr($buf, 128, 2) = pack("n", 0x0100);
    }
    return $buf;
}

sub help {
    print <<EOF;
Usage: ecoplugs <opt> <IP> <ID> <on/off/get>
opt:   -v           ; verbose
EOF
    exit 1;
}

私の自宅では、 実際にこの perl スクリプトを使って、 留守中および就寝中は自動的に電気ポットの電源が切れるようにしている。

No Comments »

No comments yet.

RSS feed for comments on this post. TrackBack URL

Leave a comment