仙石浩明の日記

2020年9月16日

IFTTT のアプレットが 3個に制限されてしまったので、IFTTT を使わずに スマート家電リモコン RS-WFIREX4 をコントロールしてみた 〜 RS-WFIREX4 の通信プロトコルの解析 hatena_b

IoT機器を IFTTT (IF This Then That) に登録すると、 自前のプログラムからコントロールできるようになる。 つまり IFTTT の webhooks を使うことで、 IFTTT の特定の URL を自前のプログラムからアクセスするだけで IoT機器がコントロールできる。 例えばこんな感じ:

senri:~ $ curl https://maker.ifttt.com/trigger/light_on/with/key/dD-v7GCx46LnWaF1AD9nwSUeA_N1ALvDHKS57cP1_Md
Congratulations! You've fired the light_on event

「light_on」の部分は任意に定めることができる。 この例では照明を点灯させている。

Nature Remo のように API を公開している IoT機器なら、 自前のプログラムから API を直接たたけばよいが、 残念ながら IoT機器の多くが API 非公開なので、 IFTTT が唯一のコントロール手段となっていた。 IoT機器の操作一つ一つ (例えば light_on) に、 IFTTT アプレットを作ることになるので、 私の場合は 50個以上のアプレットを作っていた。

Get IFTTT Pro

ところが!

有料版の IFTTT Pro が新たに発表され、 従来の無料版は登録できるアプレットが 3個に制限されてしまった。 有料版なら無制限にアプレットを作ることができるが、 無料版だと最大 3個しかアプレットを作ることができない。

かくなる上は、 IoT機器の API (通信プロトコル) を解析して IFTTT 抜きで IoT機器をコントロールするしかない。 いままでも IFTTT に登録できない IoT機器については API を解析していたので、 なんとかなるだろう。

IoT機器のほとんど (全て?) がスマホからコントロールできるので、 root 権限を取得できる Android 端末があれば、 スマホのアプリと IoT機器との間の通信を tcpdump 等で観察することができる。 通信が暗号化されていなければ API を解析するのは (比較的) 容易。

RS-WFIREX4

というわけで、 ラトックシステム社 スマート家電リモコン RS-WFIREX4 の API を解析してみた。 幸い、 RS-WFIREX4 の Android 用アプリ スマート家電コントローラ は、 家中モード (スマホと RS-WFIREX4 が同一セグメントにある場合) では通信が暗号化されていない。 スマホ上で tcpdump を実行することで通信 (TCP/IP) 内容を見ることができた。

RS-WFIREX4 は温度、湿度、明るさを計測することができる。 アプリが RS-WFIREX4 の TCP ポート 60001番に対して 5バイトのデータ 「AA 00 01 18 50」 を送信すると、 RS-WFIREX4 から 13バイトのデータ 「AA 00 09 18 00 01 5E 00 E5 00 0A B2 08」 が返ってきた。 最初の 5バイト 「AA 00 09 18 00」 は部屋の温度等に関係なく常に同一だったので、 ヘッダ (つまり計測データは含まれない) と考えられる。

この手の通信プロトコルでは、 可変長のデータを扱うためにヘッダにペイロード (ヘッダ以外のデータ本体) の長さが含まれる (ことが多い)。 この例ではヘッダ以外のデータの長さは 13 - 5 = 8 バイトなので、 おそらく 「00 09」 が (ビッグエンディアンの) ペイロード長だろうとあたりをつける。 つまりヘッダは 「AA 00 09 18」 の 4バイトで、 続く 9バイト 「00 01 5E 00 E5 00 0A B2 08」 がペイロードということになる。

┌─┬─┬─┬─┬─┐
│頭│ペイ長│測│検│
└─┴─┴─┴─┴─┘
│←─ヘッダ─→│ペ│

アプリが送信したデータ 「AA 00 01 18 50」 も、 先頭が 「AA」 (図中では 「頭」 と略記) であることから同じフォーマットである可能性が高い。 つまり 「00 01」 がペイロード長 (図中では 「ペイ長」 と略記) で、 「50」の 1バイトがペイロードなのだろう。 「50」は 「計測データを送信せよ」 という命令の可能性も無くはないが、 おそらく後述するチェックサムだろう (図中では 「検」 と略記)。

そして、 ヘッダ末尾の 「18」 のほうが、 「計測データを送信せよ」 という命令である可能性が高い (図中では 「測」 と略記)。 RS-WFIREX4 の応答のヘッダの末尾も 「18」 だが、 これは命令 「18」 に対する応答であることを示しているのだろう。

┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│頭│ペイ長│測│0│湿度%│温度℃│明るさ│安│検│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
│←─ヘッダ─→│←─────ペイロード─────→│

部屋を明るくしたり暗くしたり、 温度を上げたり下げたりしたときに、 RS-WFIREX4 からの応答がどのように変化するか調べることで、 ペイロードの 2, 3バイト目 「01 5E」 (10進数で 350) は、 「湿度 35.0 %」 を示していると推測できた。 以下同様に、 4, 5バイト目 「00 E5」 (10進数で 229) は、 「温度 22.9 ℃」 を、 6, 7バイト目 「00 0A」 (10進数で 10) は、 「明るさ」 を示している。

ペイロードの 8バイト目 「B2」 は、 RS-WFIREX4 の電源を入れた瞬間は 0 で、 時間の経過と共に増大し、 充分時間が経つと 255 になる。 RS-WFIREX4 は電源投入後 30分間はセンサーが使えないので、 おそらくセンサーの安定度合い (0〜255) を示していて、 この数値が一定以上 (255?) でないとセンサーの値が正確でないことを示しているのだろう。

末尾の 1バイトは後述するチェックサムと思われる。 なぜなら、 末尾の 1バイトを除くペイロードの内容が同じ (つまり湿度・温度・明るさ・安定度の組合わせが同一) 応答データであれば、 末尾の 1バイトも (少なくとも私が観察した範囲では) 同じ値になっているから。

AA 00 AE 11
00 00 AA
22 11 04 04 05 04 04 0D 04 0D 04 04 05 0C 05 04
04 04 05 04 04 0D 04 04 05 04 04 0D 04 04 05 0C
05 04 04 0D 04 04 05 04 04 0D 04 04 05 04 04 04
05 04 04 04 05 04 04 0D 04 0D 04 04 05 0C 05 04
04 04 05 0C 05 04 04 0D 04 04 05 04 04 0D 04 04
05 04 04 FF FF FF 07 23 10 05 04 04 04 05 0C 05
0C 05 04 04 0D 04 04 05 04 04 04 05 0C 05 04 04
04 05 0C 05 04 04 0D 04 04 05 0C 05 04 04 04 05
0C 05 04 04 04 05 04 04 04 05 04 04 04 05 0C 05
0C 05 04 04 0D 04 04 05 04 04 0D 04 04 05 0C 05
04 04 04 05 0C 05 04 04 04 05
69

次に赤外線を発射させて家電をコントロールしてみる。

← 左の 178バイトのデータをアプリが送信すると、 RS-WFIREX4 が発射した赤外線を受けて天井照明が点灯し、 RS-WFIREX4 から 6バイトの応答 「AA 00 02 11 00 D1」 が返ってきた。

送信データの最初の 4バイト 「AA 00 AE 11」がヘッダで、 ペイロードの長さが 00AE (10進数だと 174) であることが分かる。 ヘッダ末尾の 「11」 が、 「赤外線を発射せよ」 という命令なのだろう (図中では 「射」 と略記)。 アプリを操作して RS-WFIREX4 にいろいろ (長さが異なる) 赤外線を発射させてみたところ、 3行目 「22 11 04 04 ...」から始まる 170バイトが赤外線の波形データ (後述) で、 その直前 (2行目) の 「00 AA」 (10進数で 170) が赤外線の波形データの長さ (図中では「デー長」と略記) を表わしているようだ。

┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│頭│ペイ長│射│0│デー長│赤外線の波形データ│検│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
│←─ヘッダ─→│←─────ペイロード─────→│

ペイロード末尾の 1バイト 「69」 の算出方法は不明だが、 赤外線の波形データによって値が変わることと、 末尾であることからチェックサムのようなものと思われる。 もちろん単純なチェックサムではなく、 なんらかのエラー検出符号のようなものなのだろう (図中では 「検」 と略記)。 この 1バイトは、 「ペイロード長」には含まれるが、 「赤外線の波形データの長さ」には含まれない (12月18日追記: 「射」をヘッダではなくペイロードに含めて、その代わり「検」をペイロードから外すべきだった。後述)。

このペイロード末尾の 1バイトだけ異なるデータを送信すると RS-WFIREX4 はこの 178バイトの送信データ全体を無視する。 赤外線も発射しないし、 何の応答も返さない。 前述したように家中モードでは何のセキュリティもないので、 この末尾の 1バイトが (正規の) アプリから送信されたことを保証する唯一の認証手段なのだろう。 ここでは、 RS-WFIREX4 に対する送信データ (あるいは RS-WFIREX4 からの応答データ) におけるこの末尾の 1バイトを「チェックサム」と呼ぶことにする。

170バイトの赤外線の波形データは、 1バイト目の 「22」(10進数だと 34) が赤外線の ON の区間を表し、 2バイト目の 「11」が赤外線の OFF の区間を表し、 以下同様に奇数バイト目が赤外線の ON の区間を表し、 偶数バイト目が赤外線の OFF の区間を表す。 各バイトの数値の 1/10000 が各区間の秒数になる。 例えば 「04」 の場合は 0.4ミリ秒になり、 家製協(AEHA)フォーマットの 1T 区間に相当する。 1バイト目の 「22」は 8T 区間、 2バイト目の 「11」は 4T 区間、 8バイト目の 「0D」は 3T 区間に相当する。

IR wave

つまり赤外線の波形データの最初の行 (3行目) は、 赤外線 ON が 8T (3.2ミリ秒) 続き、 次に OFF が 4T (1.6ミリ秒) 続き、 以下 ON 1T, OFF 1T, ON 1T, OFF 1T, ON 1T, OFF 3T, ON 1T, OFF 1T, ON 1T, OFF 3T, ON 1T, OFF 1T という波形になる (上図 ↑)。 ただし ON の区間は赤外線が点きっぱなしになっているのではなく、 38kHz の赤外線パルス (デューティ比 1/3) を送信している。

この赤外線の波形データは、 アプリで 「リモコンデータ受け渡し⇒エクスポート⇒メールで送信」 を行うことで得られる XML データに含まれる (<code>...</code> の部分)。 あるいは他の学習リモコンの赤外線波形データを変換しても良い。

実を言うと、 ここまでは昨年の段階で解析済だった。 ペイロード末尾の 1バイトの算出方法が判明したら公開しようと思っていたのだが、 いろいろ他にも忙しくて :-) 放置してしまっていた (12月18日追記: 単なる CRC-8 だと判明。後述)。 アプリを逆コンパイルするのは骨が折れるのと、 算出しなくても tcpdump で見れば値が得られるので実用上は困らなかったから。 で、今回 IFTTT が有料化したので急遽公開することにした次第。

チェックサムの算出方法が分からないといっても高々 1バイトである。 256通りなんてブルートフォース攻撃というほど brute でもない。 幸い、 RS-WFIREX4 はチェックサムが違うデータを立て続けに受信しても、 異常動作することはないようだ (もちろん常時チェックサム違いのデータを送信することは推奨できない)。

赤外線の波形データごとに 00 〜 FF まで 256通りのチェックサムを試して、 RS-WFIREX4 から応答が返ってきたら、 赤外線の波形データにそのチェックサムを付加して記憶しておけばよい。 赤外線の波形データの前の 3バイトおよびヘッダは、 発射する際に都度算出すれば良い。

チェックサムが正しい場合、 RS-WFIREX4 は赤外線を発射して 「AA 00 02 11 00 D1」 を返す。 ヘッダ末尾の 「11」 は、 命令 「11」 (赤外線を発射せよ) の応答であることを示す。

┌─┬─┬─┬─┬─┬─┐
│頭│ペイ長│射│0│検│
└─┴─┴─┴─┴─┴─┘
│←─ヘッダ─→│←ペ→│

ペイロードは 「00 D1」 の 2バイトだが、 ペイロードが 2バイト以上の場合、 ペイロードの先頭は常に 「00」 であるようだ (図中では 「0」 と表記)。 末尾の 「D1」 はチェックサムだろう。 実質的にチェックサムだけのペイロードならば、 1バイトのペイロードで充分だと思うが、 アプリが送信するデータの場合は 1バイトのペイロードが有り得ても、 RS-WFIREX4 が返す応答データの場合は常に 2バイト以上になるのかもしれない。

RS-WFIREX4 をコントロールする perl スクリプト wfirex.pl を以下に示す:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Std;
use IO::Socket::INET;

my %Ir;
$Ir{'on'} = pack("H*", "221104040504040D040D0404050C050404040504040D04040504040D0404050C0504040D04040504040D040405040404050404040504040D040D0404050C05040404050C0504040D04040504040D0404050404FFFFFF07231005040404050C050C0504040D040405040404050C05040404050C0504040D0404050C05040404050C050404040504040405040404050C050C0504040D04040504040D0404050C05040404050C050404040569");
$Ir{'off'} = pack("H*", "221005040404050C050C0504040D040405040404050C05040404050C0504040D0404050C05040404050C0504040405040404050C050C050C050C0504040D040405040404050C050C05040404050C0504040405FFFFFF07221104040504040D040D0404050C050404040504040D04040504040D0404050C0504040D04040504040D0404050404040504040D040D040D040D0404050C050404040504040D040D04040504040D040405040437");
$Ir{'small'} = pack("H*", "221104040405040D040D0404040D040504040504040D04040405040D0404050C0504040D04040504040D04040405040404050404050C050C050C0504040D04040405040D040D040D04040504040D0404040504FFFFFF08221104040405040D040D0404050C040504040405040D04040405040D0404050C0405040D04040405040D04040504040404050404040D050C050C0504040D04040504040D040D040D04040405040D0404040504E9");
$Ir{'aoff'} = pack("H*", "211005040404040d0404040d040404040504040c050c040405040404040d040c0405040404040504040405040404040405040404050404040404050c0404050404040405040404040504040c0504040404050404040d040404040504040404050404040c0504040c050c040d040c040d040c04d7");
$Ir{'study_on'} = pack("H*", "2210050C04050404040405040404050404040405040404050404040D0404040504040405040404050404040D040D040404050404040504040405040C0504040D0404040D040D040C050C0405040C0504040D04040405040404050404040405040404050C050C040D040C050C050C040D040D0404050C040405040404050404040504040C0504040D040C050C050C040D040D0404040D0404050404040504040C050C050C0405040C050C040D040D0404040504FF2E2111040C05040405040404050404040404050404040504040405040C0504040404050404040504040405040D040C040504040405040404050404040D0404050C0405040C050C040D040D0404040D0404050C04050404040504040404050404040504040D040C050C040D040D040C050C040D0404050C040504040405040404050404040D0404040D040D040D040C050C050C0405040C0504040404050404040D040D040C0504040D040C050C050C0405040404FF292111040D04040405040404050404040504040405040404050404040D0404040504040405040404050404040D040D040404050404040504040404050C0405040C0504040D040C050C040D0404050C0405040C05040404050404040504040404050404040D040D040C050C050C040D040C050C0504040D040404040504040405040404050C0504040C050C050C040D040D040C0504040D0404040504040404050C040D040D0404050C040D040C050C05040404042F");
$Ir{'study_off'} = pack("H*", "2210040D04050404040504040405040404040504040404050404040D0405040404050404040503050404040D040D040404050404040504040405040C0504040D0404040D040D040C040D0405040C0504040D04040405040404050404040504040405040C050C040D040D040C050C040D040D040C050C04050404040504040405040404050404040D040D040C050C040D040D0404040D0405040404040405040D040D040C0405040C050C040D040D0404040504FF2E2111040D04040405040404050404040504040405040404050404040D0404040504040405040404050404040D040C050404050305040404050404040D0405040C0504040D040C050C040D0404050C0405040C05040404050404040405040404050404040D040D040C050C040D040D040C050C040D040D04040504040405040404050404040504040C050C050C040D040C050C0504040D0404040504040404050C040D040D0404040D040D040C050C0504040404FF292210050C04050404040504040405040404050404040404050404040D0405040404050404040404050404040D040D040404050404040504040405040C0504040D0404040D040D040C050C0405040C0504040D04040405040404050404040405040404050C040D040D040C050C040D040D040D040C050C04050404040405040404050404040504040D040C050C040D040D040D0404040D0404040504040405040D040C040D0405040C040D040D040D040404050408");
$Ir{'study_aoff'} = pack("H*", "2c2b070f0610060f061006050605060f060506050605060406050610060f0605061006050604060506050605060505100610060f06100610060f0610060f060506050605060506040605060506050605060f06100605060f0610060506040605060506050605050506050605061005100610060506040605060506050605050506050610060505100610060505100610060505332d2b0610060f0610061005050605061006050505060506050605060f06100605060f0605060506050605050506050610060f0610061005100610060f06100605060506040605060506050605050506050610060f06050610060f06050605060506050505060506050605060505100610060f06050605060506050505060506050605060f06050610060f06050610060f06050675");

our ($opt_v, $opt_s);
getopts('vs') || help();
my $ip = shift || help();
my $command = shift || help();

if ($command eq "get") {
    my ($il, $te, $hu) = get_wfirex($ip);
    if (defined $il) {
        $te /= 10;
        $hu /= 10;
        print "il=$il te=$te hu=$hu\n";
    } else {
        print "wfirex get TIMEOUT $ip\n";
    }
} elsif (defined $Ir{$command}) {
    my $ret;
    if ($opt_s) {
        my $ir = substr($Ir{$command}, 0, length($Ir{$command})-1);
        for (my $i=0; $i < 256; $i++) {
            my $checksum = pack("C", $i);
            printf("try %02x ...\n", $i);
            $ret = send_wfirex($ip, $ir . $checksum);
            if ($ret) {
                printf("success ! checksum=%02x ret=%02x\n", $i, $ret);
                last;
            }
            sleep 1;
        }
    } else {
        $ret = send_wfirex($ip, $Ir{$command});
    }
}
exit 0;

sub get_wfirex {
    my ($ip) = @_;
    my $sock = IO::Socket::INET->new(
        PeerAddr  => $ip,
        PeerPort  => 60001,
        Proto     => "tcp",
        Timeout   => 5,
        );
    if ($sock) {
        print $sock "\xaa\x00\x01\x18\x50";
        my $buf;
        my $flags;
        $sock->recv($buf, 256, $flags);
        my @data = unpack("CCCCCnnnCC", $buf);
        close($sock);
        print join(" ", @data) . "\n" if $opt_v;
        return ($data[7], $data[6], $data[5]);
    }
    return undef;
}

sub send_wfirex {
    my ($ip, $ir) = @_;
    my $len = length($ir) - 1;
    $ir = "\xaa" . pack("n", $len+4) . "\x11\x00" . pack("n", $len) . $ir;
    my $sock = IO::Socket::INET->new(
        PeerAddr  => $ip,
        PeerPort  => 60001,
        Proto     => "tcp",
        Timeout   => 5,
        );
    if ($sock) {        
        print $sock $ir;
        my $buf;
        my $flags;
        $sock->recv($buf, 256, $flags);
        my @data = unpack("CCCCCC", $buf);
        close($sock);
        if (@data) {
            print join(" ", @data) . "\n" if $opt_v;
        }
        return $data[5];
    }
    return undef;
}

sub help {
    print <<EOF;
Usage: wfirex <opt> <IP> <com>
opt:   -v   ; verbose
       -s   ; scan checksum
com: get    ; get sensor value
EOF
    print "     " . join(" ", sort keys %Ir) . "\n";
    exit 1;
}

連想配列 %Ir に赤外線の波形データを 16進数の文字列で格納しておく。 末尾の 2文字 (つまり 16進数 1バイト) がチェックサム。 チェックサムが不明のときは、 とりあえず「00」をつけておいて「-s」オプションでチェックサムを探索する。

senri:~ $ ./wfirex -vs wfirex4l on
try 00 ...
try 01 ...
try 02 ...
try 03 ...

  … 中略 …

try 67 ...
try 68 ...
try 69 ...
170 0 2 17 0 209
success ! checksum=69 ret=d1

赤外線の波形データ $Ir{'on'} のチェックサムが 「69」 (16進数) であることが判明したので、 とりあえずつけた末尾の 「00」 を 「69」 で置き換える (前掲のスクリプトは置き換え済)。

12月18日 追記:

コメントでチェックサムの計算方法を教えて頂いた。 ラトックシステム社は、Raspberry Pi 関連製品に関してはサンプルコードを公開していて、 RPi-IREX 用の赤外線リモコン信号を出力するプログラムに、 チェックサムを計算するコードが含まれているとのこと。

このコードを調べてみると、 生成多項式が x8 + x7 + x2 + 1 の CRC-8 だった。 CPAN (perl のライブラリ) を使えば、 以下のように簡潔に書けてしまう。

use Digest::CRC qw(crc);

sub crc8_calc {
    my ($buf) = @_;
    return crc($buf, 8, 0, 0, 0, 0x85, 0);
}

実を言うと、 チェックサムが CRC-8 である可能性は、 昨年の段階で真っ先に検討していた。 しかし一般的に使われる生成多項式を片っ端から試してみた限りでは計算結果が一致するものはなく、 仮に CRC だとしても何らかの目眩ましが施されているのだろうと思い、 解析を中断してしまっていた。

いま思えば、CRC-8 の生成多項式は高々 256個しかないわけで、 一般的に使われるものだけでなく、とりあえず全てを試してみるべきだった。

というわけで、 さっそく上記 crc8_calc を wfirex.pl に組み込んでみた:

#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Std;
use IO::Socket::INET;
use Digest::CRC qw(crc);

my %Ir;
$Ir{'on'} = pack("H*", "221104040504040D040D0404050C050404040504040D04040504040D0404050C0504040D04040504040D040405040404050404040504040D040D0404050C05040404050C0504040D04040504040D0404050404FFFFFF07231005040404050C050C0504040D040405040404050C05040404050C0504040D0404050C05040404050C050404040504040405040404050C050C0504040D04040504040D0404050C05040404050C0504040405");
$Ir{'off'} = pack("H*", "221005040404050C050C0504040D040405040404050C05040404050C0504040D0404050C05040404050C0504040405040404050C050C050C050C0504040D040405040404050C050C05040404050C0504040405FFFFFF07221104040504040D040D0404050C050404040504040D04040504040D0404050C0504040D04040504040D0404050404040504040D040D040D040D0404050C050404040504040D040D04040504040D0404050404");
$Ir{'small'} = pack("H*", "221104040405040D040D0404040D040504040504040D04040405040D0404050C0504040D04040504040D04040405040404050404050C050C050C0504040D04040405040D040D040D04040504040D0404040504FFFFFF08221104040405040D040D0404050C040504040405040D04040405040D0404050C0405040D04040405040D04040504040404050404040D050C050C0504040D04040504040D040D040D04040405040D0404040504");
$Ir{'aoff'} = pack("H*", "211005040404040d0404040d040404040504040c050c040405040404040d040c0405040404040504040405040404040405040404050404040404050c0404050404040405040404040504040c0504040404050404040d040404040504040404050404040c0504040c050c040d040c040d040c04");
$Ir{'study_on'} = pack("H*", "2210050C04050404040405040404050404040405040404050404040D0404040504040405040404050404040D040D040404050404040504040405040C0504040D0404040D040D040C050C0405040C0504040D04040405040404050404040405040404050C050C040D040C050C050C040D040D0404050C040405040404050404040504040C0504040D040C050C050C040D040D0404040D0404050404040504040C050C050C0405040C050C040D040D0404040504FF2E2111040C05040405040404050404040404050404040504040405040C0504040404050404040504040405040D040C040504040405040404050404040D0404050C0405040C050C040D040D0404040D0404050C04050404040504040404050404040504040D040C050C040D040D040C050C040D0404050C040504040405040404050404040D0404040D040D040D040C050C050C0405040C0504040404050404040D040D040C0504040D040C050C050C0405040404FF292111040D04040405040404050404040504040405040404050404040D0404040504040405040404050404040D040D040404050404040504040404050C0405040C0504040D040C050C040D0404050C0405040C05040404050404040504040404050404040D040D040C050C050C040D040C050C0504040D040404040504040405040404050C0504040C050C050C040D040D040C0504040D0404040504040404050C040D040D0404050C040D040C050C0504040404");
$Ir{'study_off'} = pack("H*", "2210040D04050404040504040405040404040504040404050404040D0405040404050404040503050404040D040D040404050404040504040405040C0504040D0404040D040D040C040D0405040C0504040D04040405040404050404040504040405040C050C040D040D040C050C040D040D040C050C04050404040504040405040404050404040D040D040C050C040D040D0404040D0405040404040405040D040D040C0405040C050C040D040D0404040504FF2E2111040D04040405040404050404040504040405040404050404040D0404040504040405040404050404040D040C050404050305040404050404040D0405040C0504040D040C050C040D0404050C0405040C05040404050404040405040404050404040D040D040C050C040D040D040C050C040D040D04040504040405040404050404040504040C050C050C040D040C050C0504040D0404040504040404050C040D040D0404040D040D040C050C0504040404FF292210050C04050404040504040405040404050404040404050404040D0405040404050404040404050404040D040D040404050404040504040405040C0504040D0404040D040D040C050C0405040C0504040D04040405040404050404040405040404050C040D040D040C050C040D040D040D040C050C04050404040405040404050404040504040D040C050C040D040D040D0404040D0404040504040405040D040C040D0405040C040D040D040D0404040504");
$Ir{'study_aoff'} = pack("H*", "2c2b070f0610060f061006050605060f060506050605060406050610060f0605061006050604060506050605060505100610060f06100610060f0610060f060506050605060506040605060506050605060f06100605060f0610060506040605060506050605050506050605061005100610060506040605060506050605050506050610060505100610060505100610060505332d2b0610060f0610061005050605061006050505060506050605060f06100605060f0605060506050605050506050610060f0610061005100610060f06100605060506040605060506050605050506050610060f06050610060f06050605060506050505060506050605060505100610060f06050605060506050505060506050605060f06050610060f06050610060f060506");

sub crc8_calc {
    my ($buf) = @_;
    return crc($buf, 8, 0, 0, 0, 0x85, 0);
}

our ($opt_v, $opt_s);
getopts('vs') || help();
my $ip = shift || help();
my $command = shift || help();

sub get_wfirex {
    my ($ip) = @_;
    my $sock = IO::Socket::INET->new(
        PeerAddr  => $ip,
        PeerPort  => 60001,
        Proto     => "tcp",
        Timeout   => 5,
        );
    if ($sock) {
        print $sock "\xaa\x00\x01\x18\x50";
        my $buf;
        my $flags;
        $sock->recv($buf, 256, $flags);
        my @data = unpack("CCCCCnnnCC", $buf);
        close($sock);
        my $crc = crc8_calc(substr($buf, 3, length($buf) - 4));
        print join(" ", @data) . " crc:$crc\n" if $opt_v;
        return ($data[7], $data[6], $data[5]);
    }
    return undef;
}

sub send_wfirex {
    my ($ip, $ir) = @_;
    my $len = length($ir);
    $ir = "\x11\x00" . pack("n", $len) . $ir;
    my $crc = pack("C", crc8_calc($ir));
    my $sock = IO::Socket::INET->new(
        PeerAddr  => $ip,
        PeerPort  => 60001,
        Proto     => "tcp",
        Timeout   => 5,
        );
    if ($sock) {        
        print $sock "\xaa" . pack("n", $len+4) . $ir . $crc;
        my $buf;
        my $flags;
        $sock->recv($buf, 256, $flags);
        my @data = unpack("CCCCCC", $buf);
        close($sock);
        my $crc = crc8_calc(substr($buf, 3, length($buf) - 4));
        if (@data) {
            print join(" ", @data) . " crc:$crc\n" if $opt_v;
        }
        return $data[5];
    }
    return undef;
}

if ($command eq "get") {
    my ($il, $te, $hu) = get_wfirex($ip);
    if (defined $il) {
        $te /= 10;
        $hu /= 10;
        print "il=$il te=$te hu=$hu\n";
    } else {
        print "wfirex get TIMEOUT $ip\n";
    }
} elsif (defined $Ir{$command}) {
    send_wfirex($ip, $Ir{$command});
}
exit 0;

sub help {
    print <<EOF;
Usage: wfirex <opt> <IP> <com>
opt:   -v   ; verbose
com: get    ; get sensor value
EOF
    print "     " . join(" ", sort keys %Ir) . "\n";
    exit 1;
}

CRC 算出の対象となるペイロードが、 私が予想した範囲と 1バイトずれているので注意。 つまり私は、 「射」 (「赤外線を発射せよ」 という命令) がヘッダの一部と考えたが、 「射」 も CRC 算出の対象となっているので、 「射」 はヘッダではなくペイロードと考えるべきだろう。 1バイト増えたぶん、末尾の 「検」 (チェックサム) はペイロードから外れる。

┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│頭│ペイ長│射│0│デー長│赤外線の波形データ│検│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
│←ヘッダ→│←─────ペイロード─────→│

すると ←左図のように、 「ペイ長」 (ペイロードの長さ) の直後のバイトから 「検」 (CRC値) の直前のバイトまでがペイロードということになり、 確かにこのほうが自然。 「測」 (「計測データを送信せよ」 という命令) についても同様。

4 Comments »

  1. 素晴らしい解析内容を公開いただいて、ありがとうございます。
    checksumとしている箇所の計算方法がわかったので共有します。

    送信するパケットデータのうち、最初の3バイト「頭&ペイ長」と最後の1バイト「検」を除いたバイト列のCRC8を計算すると、「検」の部分になります。
    計測データも赤外線発射も同じ計算方法です。

    例えば、計測データ取得パケット列「AA 00 01 18 50」の場合、「18」のCRC8を計算することになり、結果は「50」ですので、「検」にあたる最後のデータと一致しています。

    同じRatoc製品のRPi-IREXのサンプルソースがヒントになりました。
    https://github.com/ratocsystems/rpi-irex/tree/master/python

    Comment by SnowCamp — 2020年12月14日 @ 09:49

  2. もう一つ判明しました。
    UDPで、xxx.xxx.xxx.255:50001に「AA 00 01 1E 44」をブロードキャストすると、
    同一ネットワーク内のwfirexシリーズが、ブロードキャストしたIP:50002にパケットを送り返してきます。
    送信元IPをARPテーブルでMACアドレスと照合すれば、DHCP接続のwfirexシリーズが複数あっても特定のリモコンを制御できるようになります。

    Comment by SnowCamp — 2020年12月14日 @ 13:02

  3. なるほど確かに、この CRC8Table 配列を使って計算すると、「検」にあたる最後のデータと一致しますね。教えていただき、ありがとうございます。

    CRC の計算プログラムとしては見慣れないものだったので面喰らいましたが、これは生成多項式が x8 + x7 + x2 + 1 の CRC-8 ですね。高速化のため配列を用いているのでしょう。

    「検」が CRC である可能性は (昨年の段階で) 真っ先に検討したのですが、CRC-8 で一般的に使われる生成多項式で除算した余りとは一致しなかったので、CRC ではないと判断してしまったのでした。

    いま思えば、CRC-8 の生成多項式は高々 256個しかないわけで、一般的に使われるものだけでなく、全てを試してみるべきでした。

    Comment by hiroaki_sengoku — 2020年12月17日 @ 10:00

  4. ようやくこちらのiPhone環境でパケットキャプチャできるようになりまして、もう一つ判明した情報を共有します。

    赤外線発信する際の「射」の後の「0」ですが、ソニー方式リモコンデータの場合は、「1」になります。
    「頭│ペイ長│射│ここ!│デー長│赤外線の波形データ│検」

    つまり、エクスポートしたxmlファイルの0 or 1の値を入れると良いと思われます。

    実際は、ゼロのままでも何故かソニーテレビを操作できたので、実用上の支障はなかったのですけど、気になったので調べてみました。

    Comment by SnowCamp — 2020年12月24日 @ 11:22

RSS feed for comments on this post. TrackBack URL

Leave a comment