Welcome to telecotele.com » Projects

日立製洗濯機のAPIを洗う

日立製ドラム式洗濯機からどうにかこうにか動作状況を取得した記録です。

tags: house

日立製洗濯機のAPIを洗うのカバー画像

はじめに

1年ほど前に日立のドラム式洗濯機「BD-SX120H」を導入しました。 導入前は縦型洗濯機と浴室乾燥(と部屋干し)でしのいでおり、入浴タイミングを見極めないとならず乾き具合も難ありだったのがこれで改善してハッピーハッピーハッピーです。 洗剤自動投入機能もあってそんなの要る?と思っていましたが使ってみると楽で大変便利に使っています1。 しばらくして乾燥機能に不具合が出たものの「てまメンテ」2を行えばなんとかなります。

さて、この「BD-SX120H」をはじめ最近の日立製洗濯機はスマートフォンから操作が可能です。 これはWi-Fiと「洗濯コンシェルジュ」という専用アプリを使うもので、外出先からでも使えます。

そこで気になるのが、専用アプリに頼らずに操作できないか?ということです。 APIや連携仕様のようなものは公開されていません3でしたが専用アプリを洗いざらい4調査したところ、自前で動作状況の取得に成功しました。 本稿では調査の内容について述べます。

動作状況取得の方法

早く方法が知りたい? しょうがないにゃあ・・先に具体的な方法から載せます。

まず、洗濯機内部では50000/udpで何らかのサービスが動いているようです。 ここにコマンドを含んだリクエストを送信すると、送信者の50000/udpにレスポンスが返ってきます。 「送信者の送信ポートに」ではありません。 やり取りされるパケットの宛先はリクエストもレスポンスも常に50000/udpです5。 リクエストとレスポンスの本体はAES-256-CBCで暗号化されており、後ろにチェックサムのようなSHA256の一部も付いてきます。 暗号鍵とIVについてはおそらく各環境によって変わると思われます。 (鍵とIVの取得については後述しますが、結構手間がかかります。)

これを基に、動作状況取得コマンドを送信し応答をパースするPythonコードの例は次のとおりです。

長いので折りたたみ
from Crypto.Cipher import AES
import sys
import pprint
import socket
import hashlib

def getResponseData(res):
    for kv in res.split('&'):
        if '=' not in kv: continue
        key, value = kv.split('=', 2)
        if key == 'DATA': return value

def parseResponseForMoveInf(res):
    s = getResponseData(res).split(',')[1]

    date                    = f"{int(s[0:4],16):04}/{int(s[4:6],16):02}/{int(s[6:8],16):02}" # YYYY/MM/DD
    time                    = f"{int(s[8:10],16):02}:{int(s[10:12],16):02}" # hh:mm(現在時刻ではなく状態変化時刻の模様)
    statusCode              = s[16:18] # status
    processAll              = s[18:20] # ?
    process                 = s[20:22] # ?(statusとは違う模様)
    selCourse               = s[22:26] # コース番号(別途ダウンロードされるJSONを確認すればコース名が得られる)
    infoCodeC               = s[26:28] # 情報コード (crit?)
    infoCodeF               = s[28:30] # 情報コード (fatal? failure?)
    infoCodeI               = s[30:32] # 情報コード (info?)
    aiInfoFlag              = int(s[34:36], 16) # AIお洗濯の情報
    downloadCourse1         = s[36:48] # ?
    downloadCourse2         = s[48:60] # ?
    downloadCourse3         = s[60:72] # ?
    remainingTime           = int(s[72:76], 16) # 残り運転時間(分単位)のはずだがうまく取れない可能性あり
    reasonRemainingTimeFlag = int(s[76:78], 16) # 運転時間延長の理由
    yoyakuRemainingCounter  = f"{int(s[78:82],16):04}" # ?
    remoteOperationStatus   = s[82:84] # ?
    uniqueId                = s[84:88] # ?

    # Status
    status = {
        "00": "本体の電源が入っていないか、通信ができない状態です。",
        "05": "予約中",
        "06": "運転中",
        "07": "運転中",
        "40": "槽内冷却中",
        "41": "槽内冷却中",
        "42": "槽内冷却中",
        "43": "槽内冷却中",
        "44": "槽内冷却中",
        "45": "槽内冷却中",
        "46": "槽内冷却中",
        "47": "槽内冷却中",
        "86": "一時停止中",
        "87": "一時停止中",
    }.get(statusCode, "待機中")

    # Process
    process_status = {
        "FF": "運転終了",
        "FA": "乾燥終了・ふんわりガード運転中",
        "FB": "自動投入部お手入れ運転中",
    }.get(process, None)
    if process_status is not None: status = process_status

    # Info
    info = "お知らせがあります" if infoCodeC != "00" or infoCodeF != "00" or infoCodeI != "00" else ""
    if infoCodeF != "00":
        info += f"「コードF{'00' if infoCodeF == 'FF' else infoCodeF}」"
    elif infoCodeC != "00":
        info += f"「コードC{infoCodeC}」"
    elif infoCodeI in ["01", "02", "03", "04", "05", "06"]:
        infoCodeImsg = {
            "01": "ドア開閉確認",
            "02": "糸くずフィルター",
            "03": "乾燥フィルター",
            "04": "乾燥容量オーバー",
            "05": "槽洗浄おすすめサイン",
            "06": "槽洗いおすすめサイン",
        }[infoCodeI]
        info += f"「コードI{infoCodeI}:{infoCodeImsg}」"

    # AI Info
    aiInfos = []
    if aiInfoFlag & 0b100000: aiInfos.append("衣類から出る水分量が少ないので、脱水時間を短縮します。")
    if aiInfoFlag &  0b10000: aiInfos.append("衣類がすすげているので、すすぎ時間を短縮します。")
    if aiInfoFlag &   0b1000: aiInfos.append("化繊衣類等が多いので、すすぎの水量を減らします。")
    if aiInfoFlag &    0b100: aiInfos.append("汚れの量が多いので、洗い時間を延長します。")
    if aiInfoFlag &     0b10: aiInfos.append("水の硬度が低く水温が高いので、洗い時間を短縮します。")
    if aiInfoFlag &      0b1: aiInfos.append("水の硬度が低く水温が高いので、洗剤量を減らします。")

    # reasonRemainingTime
    reasonRemainingTime = []
    if reasonRemainingTimeFlag & 0b10000: reasonRemainingTime.append("衣類の片寄りを直しました。")
    if reasonRemainingTimeFlag &  0b1000: reasonRemainingTime.append("運転内容を見直しました。")
    if reasonRemainingTimeFlag &   0b100: reasonRemainingTime.append("運転内容を見直しています。")
    if reasonRemainingTimeFlag &    0b10: reasonRemainingTime.append("衣類の片寄りを直しています。")
    if reasonRemainingTimeFlag &     0b1: reasonRemainingTime.append("泡消し運転を実施しています。")

    return {
        "date":                    date,
        "time":                    time,
        "statusCode":              statusCode,
        "status":                  status,
        "processAll":              processAll,
        "process":                 process,
        "selCourse":               selCourse,
        "infoCodeC":               infoCodeC,
        "infoCodeF":               infoCodeF,
        "infoCodeI":               infoCodeI,
        "info":                    info,
        "aiInfoFlag":              aiInfoFlag,
        "aiInfos":                 aiInfos,
        "downloadCourse1":         downloadCourse1,
        "downloadCourse2":         downloadCourse2,
        "downloadCourse3":         downloadCourse3,
        "remainingTime":           remainingTime,
        "reasonRemainingTimeFlag": reasonRemainingTimeFlag,
        "reasonRemainingTime":     reasonRemainingTime,
        "yoyakuRemainingCounter":  yoyakuRemainingCounter,
        "remoteOperationStatus":   remoteOperationStatus,
        "uniqueId":                uniqueId,
    }

def parseResponseForGetInf(res):
    k, v = getResponseData(res).split(',', 2)
    if k == "0001":
        jidoTonyuZanryoFlag = int(v[54:56], 16)
        senzaiZanryoSho     = jidoTonyuZanryoFlag & 0b1
        nyunanzaiZanryoSho  = jidoTonyuZanryoFlag & 0b10
        return {
            "yoyaku":              f"{int(v[0:4],16):04}",
            "arai":                v[4:6],
            "susugi":              v[6:8],
            "dassui":              v[8:10],
            "kanso":               v[10:12],
            "onsuiMisth":          v[12:14],
            "sensor":              v[14:16],
            "autoOsouji":          v[16:18],
            "oyutori":             v[18:20],
            "untenStart":          v[20:22],
            "shuryoYokoku":        v[22:24],
            "dassuiGuai":          v[24:26],
            "kawakiGuai":          v[26:28],
            "onpuHogushi":         v[28:30],
            "funwari":             v[30:32],
            "atatameAuto":         v[32:34],
            "dassuikido":          v[34:36],
            "onpuDassui":          v[36:38],
            "joshitsu":            v[38:40],
            "oyutoriMemory":       v[40:42],
            "shimizuSusugi":       v[42:44],
            "suiryo":              v[44:46],
            "sosaMukoOn":          v[46:48],
            "suiryoOme":           v[48:50],
            "kaitenShawa":         v[50:52],
            "toridashi":           v[52:54],
            "jidoTonyuZanryoFlag": jidoTonyuZanryoFlag,
            "senzaiZanryoSho":     senzaiZanryoSho,
            "nyunanzaiZanryoSho":  nyunanzaiZanryoSho,
            "senzaiMeigara":       v[56:58],
            "senzaiKijun":         v[58:60],
            "senzaiAiSusugi":      v[60:62],
            "senzaiJidoTonyu":     v[62:64],
            "nyunanzaiMeigara":    v[64:66],
            "nyunanzaiKijun":      v[66:68],
            "nyunanzaiJidoTonyu":  v[68:70],
            "suion":               v[70:72],
        }
    if k == "0002":
        return {
            "sentakuArai":    v[0:2],
            "sentakuSusugi":  v[2:4],
            "sentakuDassui":  v[4:6],
            "sentakuKanso":   v[6:8],
            "araiInuiArai":   v[8:10],
            "araiInuiSusugi": v[10:12],
            "araiInuiDassui": v[12:14],
            "araiInuiKanso":  v[14:16],
            "sentakuSuiryo":  v[16:18],
            "araiInuiSuiryo": v[18:20],
        }
    if k == "0003":
        return {
            "yoyaku":     f"{int(v[0:4],16):04}",
            "arai":       v[4:6],
            "susugi":     v[6:8],
            "dassui":     v[8:10],
            "kanso":      v[10:12],
            "onsuiMist":  v[12:14],
            "sensor":     v[14:16],
            "autoOsouji": v[16:18],
            "oyutori":    v[18:20],
            "suiryo":     v[20:22],
            "senzai":     v[22:24],
            "nyunanzai":  v[24:26],
            "suion":      v[26:28],
        }
    if k == "0005": return {'remainingTime': int(v, 16)} # 残り運転時間(分単位)
    if k == "0006": return {'xxxxxxxxxxxxx': int(v, 16)} # 予約関連と思われるが詳細未確認
    return None

def encrypt(key, iv, data):
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return cipher.encrypt(data + b'\x00' * (AES.block_size - len(data) % AES.block_size))

def decrypt(key, iv, data):
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return cipher.decrypt(data).rstrip(b'\x00')

def getRequest(key, iv, seq, src, dst, cmd, data=None):
    req = f"SEQ={seq%256:02X}&SRC={src}&DST={dst}&GRP=REQ&CMD={cmd}&"
    if data is not None: req += f"DATA={data}"
    req_bytes = req.encode('utf-8')
    print("Request", req_bytes)
    sha256 = hashlib.sha256()
    sha256.update(req_bytes)
    return encrypt(key, iv, req_bytes) + sha256.digest()[0:16]

def parseResponse(key, iv, data):
    res = decrypt(key, iv, data[:-16]).decode('utf-8')
    print("Response", res)
    if ("GRP=ACK" in res) and ("CMD=MOVEINF" in res): pprint.pprint(parseResponseForMoveInf(res))
    if ("GRP=ACK" in res) and ("CMD=GETINF"  in res): pprint.pprint(parseResponseForGetInf(res))


if __name__ == '__main__':
    if len(sys.argv) != 2:
        print('DAME')
        exit(1)

    SEQUENCE = 0x20
    APP_SRC  = "XXXX" # YOUR SRC HERE
    APP_DST  = "XXXX" # YOUR DST HERE
    AES_KEY  = b'\x00\x00\x00\x00' # YOUR KEY HERE
    AES_IV   = b'\x00\x00\x00\x00' # YOUR IV HERE
    DATA     = None
    if sys.argv[1] == 'SEARCH':
        COMMAND = 'SEARCH'
        SERVER_ADDR = ("192.168.xx.255", 50000) # YOUR BROADCAST_ADDR HERE
    elif sys.argv[1] == 'MOVEINF':
        COMMAND = 'MOVEINF'
        SERVER_ADDR = ("192.168.xx.xxx", 50000) # YOUR DEVICE_ADDR HERE
    elif sys.argv[1].startswith('GETINF'):
        COMMAND = 'GETINF'
        DATA = sys.argv[1][6:]
        SERVER_ADDR = ("192.168.xx.xxx", 50000) # YOUR DEVICE_ADDR HERE
    else:
        print('DAMEYO')
        exit(1)

    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    s.settimeout(2)
    s.bind(('', 50000))

    s.sendto(getRequest(AES_KEY, AES_IV, SEQUENCE, APP_SRC, APP_DST, COMMAND, DATA), SERVER_ADDR)

    response, response_addr = s.recvfrom(1024)
    print('Response Addr', response_addr)
    parseResponse(AES_KEY, AES_IV, response)

このコードの実行例としてはこんな感じです。

# 洗濯機のIPアドレスを探す
# ブロードキャスト宛に投げると、洗濯機からユニキャストで返してくれる
$ python3 send_search_moveinf_getinf.py SEARCH

# 状態取得コマンド
# (洗濯~乾燥コース 標準 動作中)
$ python3 send_search_moveinf_getinf.py MOVEINF
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=MOVEINF&'
Response Addr ('192.168.xx.xx', 59009)
Response SEQ=20&SRC=XXXX&DST=XXXX&GRP=ACK&CMD=MOVEINF&DATA=0,07E802130E320901071E051201000000001(以下略)
{'aiInfoFlag': 16,
 'aiInfos': ['衣類がすすげているので、すすぎ時間を短縮します。'],
 'date': '2024/02/19',
 'downloadCourse1': '000000000000',
 'downloadCourse2': '000000000000',
 'downloadCourse3': '000000000000',
 'info': '',
 'infoCodeC': '00',
 'infoCodeF': '00',
 'infoCodeI': '00',
 'process': '05',
 'processAll': '1E',
 'reasonRemainingTime': [],
 'reasonRemainingTimeFlag': 0,
 'remainingTime': 0,
 'remoteOperationStatus': '00',
 'selCourse': '1201',
 'status': '運転中',
 'statusCode': '07',
 'time': '14:50',
 'uniqueId': '0000',
 'yoyakuRemainingCounter': '0000'}

# 残り運転時間(分単位)取得
# 機種依存?コースのせい?か、状態取得コマンドの "remainingTime" では取れなかった
$ python3 send_search_moveinf_getinf.py GETINF0005
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=GETINF&DATA=0005'
Response Addr ('192.168.xx.xx', 59016)
Response SEQ=20&SRC=XXXX&DST=XXXX&GRP=ACK&CMD=GETINF&DATA=0005,0066&
{'remainingTime': 102}

このときの「洗濯コンシェルジュ」アプリの様子は次のとおりです。

「洗濯コンシェルジュ」アプリの様子 「洗濯コンシェルジュ」アプリの様子

「AIお洗濯情報」と「残り時間」(102分 == 1時間42分)も一緒ですね。

AES_KEY, AES_IV, APP_SRC, APP_DSTの取得

先ほど示したコード中のAES_KEY,AES_IV,APP_SRC,APP_DSTはダミーです。 ここではこれらの値を取得するために行ったことを述べていきます。

専用アプリの調査

まず専用アプリの通信内容を調べないことには始まりません。 ということで確認したところ、専用アプリがアクセスしているところのうちで気になったのは次のとおりです。

  • 日立GLS系のWeb API その1
  • 日立GLS系のWeb API その2
  • 日立家電メンバーズクラブ系のWeb API
  • 洗濯機本体の50000/udp

「洗濯機本体の50000/udp」についてはこれまでに述べたとおりです。 Web APIについてはいずれもHTTPSで、その上ペイロードもまたAES-256-CBCによる独自の暗号化が施されています。 厳重すぎる、またはアンチデバッグの意思を感じる。

ただ、「日立GLS系のWeb API その1」と「日立家電メンバーズクラブ系のWeb API」の暗号鍵およびIVはハードコーディングされています。 そして、「日立家電メンバーズクラブ系のWeb API」はメンバーズクラブのログイン情報を送信すると、「日立GLS系のWeb API その2」と「洗濯機本体の50000/udp」の暗号鍵およびIVが手に入ります。 ややこしいですね。難しい話はちょっと……。

AES_KEY, AES_IVの取得

つまり「洗濯機本体の50000/udp」にアクセスするための暗号鍵とIVが欲しい場合、次の手順を踏む必要があります。

  1. 「日立家電メンバーズクラブ系のWeb API」にアクセスするための暗号鍵とIVを入手する
    • 専用アプリにハードコードされた値
  2. ログイン情報を含んだリクエストを作成し、入手した暗号鍵とIVで暗号化し、「日立家電メンバーズクラブ系のWeb API」に送信する
  3. レスポンスを取得し、暗号鍵とIVで復号する

こうして平文となったレスポンスの中に、「洗濯機本体の50000/udp」で使う暗号鍵およびIVが含まれています。 手間がかかりますね。

この鍵とIVについては、ログインし直したくらいでは、また数日置いても変わらないようでした。 おそらくアプリと洗濯機をペアリングした際に鍵とIVが作成され、以降はそのまま変わらないものと推測します。 さすがに自宅に洗濯機は2台も無く、また再度ペアリングするのも大変なので本当にそうなのかは未確認です。

なお、手元の環境ではPCでProxyを立ててiPhoneから経由するようにしていたものの、「日立家電メンバーズクラブ系のWeb API」へのアクセスではOSのProxy設定を読まないのか通信内容を確認できませんでした。 そのため、アプリのコードを調査することによりEndpointとリクエストの内容を推測しています。 もしDNSの調整や、Wi-Fi APと透過Proxyを立てるなどで通信内容を観測できるようであれば、自前でリクエストを送る必要は無いかも知れません。 また、ここで得られた暗号鍵とIVは端末のローカルに保存されるようなのでそこから取得できる可能性はあります。

APP_SRC, APP_DSTの取得

続いてAPP_SRCAPP_DSTの取得についてです。 ここでAPP_SRCの実態は「洗濯コンシェルジュアプリに紐づく番号」、 APP_DSTの実態は「洗濯機に紐づく番号」を指しています。

洗濯コンシェルジュアプリから「状態確認」の操作を行うと、 ブロードキャストの50000/udp宛にSEARCHコマンドが投げられます。 これをAES_KEYAES_IVで復号すると、SRC=xxxx&DST=xxxがあるのでここからAPP_SRCAPP_DSTを設定するのが簡単だと思います。 同一ブロードキャストドメインに居るだけで収集できますし6、どのみち後でこの鍵を使って暗号化と復号を行うことになりますからね。

なお、APP_DSTについては先ほど述べた「日立家電メンバーズクラブ系のWeb API」のレスポンスにも含まれているようです。 この情報からAPP_SRCも推測7、というのも可能かも知れません。

50000/udpの通信について

AES_KEY,AES_IV,APP_SRC,APP_DSTが取得できたことで、冒頭で示したコードが実行できるようになりました。 ここではそのコードを用いて、50000/udpでやり取りされるリクエスト(主に取得用コマンド)とレスポンスについて述べていきます。 なお、これ以外にも設定用コマンドなどがあるようでしたが、それについては未確認です。

SEARCHコマンド

「ブロードキャスト宛に投げると、洗濯機からユニキャストで返してくれる」SEARCHコマンドです。 洗濯機のIPアドレスは静的に設定できないためこのような仕組みがあると思われます。 一種のService Discoveryでしょう。 前回やったCube J1のAPI調査を思い出します。

実行例としてはこうですが……この文章を書いているときに自分が出したブロードキャストのパケットを拾ってResponseとしていることに気付きました。 リクエストを出していることは確かなので、レスポンスが返ってくるかはtcpdumpで見てください。 IPアドレスさえ分かれば良いんです。 そういった意味だと、このリクエストを再送することもできる(SEQもあるが変化させなくても受理される)ので、 専用アプリが作成したリクエストを再送しtcpdumpで観測すれば、暗号鍵やIVを知らずともSEARCHコマンドを叩いた相当のことはできるかも知れません。

$ python3 send_search_moveinf_getinf.py SEARCH
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=SEARCH&'
Response Addr ('192.168.xx.xx', 50000)
Response SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=SEARCH&

なお、このコマンドは絶対叩く必要があるというわけではありません。 DHCP固定割当の機能を使っているなど、すでにIPアドレスが判明している場合は省略して後述のMOVEINFやGETINFを叩いても問題ないようです。

MOVEINFコマンド

状態取得コマンドっぽいMOVEINFです。

「洗濯~乾燥コース 標準 動作中」のときに確認した例はこんな感じです。

$ python3 send_search_moveinf_getinf.py MOVEINF
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=MOVEINF&'
Response Addr ('192.168.xx.xx', 59009)
Response SEQ=20&SRC=XXXX&DST=XXXX&GRP=ACK&CMD=MOVEINF&DATA=0,07E802130E320901071E051201000000001(以下略)
{'aiInfoFlag': 16,
 'aiInfos': ['衣類がすすげているので、すすぎ時間を短縮します。'],
 'date': '2024/02/19',
 'downloadCourse1': '000000000000',
 'downloadCourse2': '000000000000',
 'downloadCourse3': '000000000000',
 'info': '',
 'infoCodeC': '00',
 'infoCodeF': '00',
 'infoCodeI': '00',
 'process': '05',
 'processAll': '1E',
 'reasonRemainingTime': [],
 'reasonRemainingTimeFlag': 0,
 'remainingTime': 0,
 'remoteOperationStatus': '00',
 'selCourse': '1201',
 'status': '運転中',
 'statusCode': '07',
 'time': '14:50',
 'uniqueId': '0000',
 'yoyakuRemainingCounter': '0000'}

別パターンとして「槽洗い 運転終了後」の例も載せます。

$ python3 send_search_moveinf_getinf.py MOVEINF
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=MOVEINF&'
Response Addr ('192.168.xx.xx', 51152)
Response SEQ=20&SRC=XXXX&DST=XXX&GRP=ACK&CMD=MOVEINF&DATA=0,07E802130D082901060CFF11420000020000(以下略)
{'aiInfoFlag': 0,
 'aiInfos': [],
 'date': '2024/02/19',
 'downloadCourse1': '000000000000',
 'downloadCourse2': '000000000000',
 'downloadCourse3': '000000000000',
 'info': 'お知らせがあります「コードI02:糸くずフィルター」',
 'infoCodeC': '00',
 'infoCodeF': '00',
 'infoCodeI': '02',
 'process': 'FF',
 'processAll': '0C',
 'reasonRemainingTime': [],
 'reasonRemainingTimeFlag': 0,
 'remainingTime': 0,
 'remoteOperationStatus': '00',
 'selCourse': '1142',
 'status': '運転終了',
 'statusCode': '06',
 'time': '13:08',
 'uniqueId': '0000',
 'yoyakuRemainingCounter': '0000'}

aiInfos(AIお洗濯情報)が取れるのが良いですね。 この辺の情報が気になっていました8remainingTimeについては後述のGETINFで取ります。 その他のレスポンスの意味としてはだいたいコード中の説明のとおりですが、 「datetimeは現在時刻ではなく状態変化(すすぎ開始、脱水開始、乾燥開始など)の時刻」 「コースの詳細はselCourseと『日立GLS系のWeb API その2』から入手できるJSON(約800KB)と突き合わせれば得られる」 です。

GETINFコマンド

これも取得系……に見えますが、MOVEINFが稼働中の状態取得に対し、GETINFが稼働中の設定取得っぽいです。 とはいえ拡張されているのか状態を取得する場合もあります。

GETINFはコマンドだけではなく、データも付けてリクエストしなければなりません。 データの内容は00010005といったもので、サブコマンドの番号?を表しているように見えます。

番号0001の実行例は次のとおりです。

$ python3 send_search_moveinf_getinf.py GETINF0001
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=GETINF&DATA=0001'
Response Addr ('192.168.xx.xxx', 58982)
Response SEQ=20&SRC=XXX&DST=XXXX&GRP=ACK&CMD=GETINF&DATA=0001,0000000000010002000000000202000000(以下略)
{'arai': '00',
 'atatameAuto': '00',
 'autoOsouji': '00',
 'dassui': '00',
 'dassuiGuai': '02',
 'dassuikido': '02',
 'funwari': '00',
 'jidoTonyuZanryoFlag': 0,
 'joshitsu': '01',
 'kaitenShawa': '00',
 'kanso': '01',
 'kawakiGuai': '02',
 'nyunanzaiJidoTonyu': '01',
 'nyunanzaiKijun': '0A',
 'nyunanzaiMeigara': '18',
 'nyunanzaiZanryoSho': 0,
 'onpuDassui': '02',
 'onpuHogushi': '00',
 'onsuiMist': '00',
 'oyutori': '00',
 'oyutoriMemory': '00',
 'sensor': '02',
 'senzaiAiSusugi': '01',
 'senzaiJidoTonyu': '01',
 'senzaiKijun': '06',
 'senzaiMeigara': '1A',
 'senzaiZanryoSho': 0,
 'shimizuSusugi': '00',
 'shuryoYokoku': '00',
 'sosaMukoOn': '01',
 'suion': '00',
 'suiryo': '00',
 'suiryoOme': '00',
 'susugi': '00',
 'toridashi': '00',
 'untenStart': '00',
 'yoyaku': '0000'}

現在のコースにおける各設定とその値っぽいですね。 洗剤や柔軟剤9の自動投入フラグのようなもの、水温(「自動」や「30℃」「40℃」などが選べる)の設定番号のようなものが見えます。 「設定番号」なので例えば水温センサで測った現在の水温が取れたりするわけではなさそうです。

ほか、データが00020003の場合も実行してみましたが、やはり設定値の取得っぽく見てもあまり嬉しいものはありませんでした。 そして、0004は欠番のようです。縁起悪いですからね。

0005の場合は状態取得っぽく、少なくとも自分の環境だと残り運転時間(分)を返してくれました。

$ python3 send_search_moveinf_getinf.py GETINF0005
Request b'SEQ=20&SRC=XXXX&DST=XXXX&GRP=REQ&CMD=GETINF&DATA=0005'
Response Addr ('192.168.xx.xx', 59016)
Response SEQ=20&SRC=XXXX&DST=XXXX&GRP=ACK&CMD=GETINF&DATA=0005,0066&
{'remainingTime': 102}

レスポンスには残り時間の情報しか含まれていないため(さらに暗号鍵とIVは固定のため)、専用アプリが作成したリクエストとレスポンスを注意深く観測すれば、暗号鍵とIVを知らずとも残り時間を知ることができるかも知れません。 (洗濯・乾燥時の運転時間がだいたい3時間~4時間と考えると、240回分のリクエストとレスポンスを観測することになりそうですが)

ちなみに0006は予約関連の何か、運転開始までの時間?それとも運転終了時間?かも知れませんが、詳細は未確認です。

余談: 「日立GLS系のWeb API その2」について

冒頭で「専用アプリを使えば外出先からでも操作できる」という旨を述べました。 これまでの話だと(暗黙的に)同一ブロードキャスト内から50000/udpにアクセスしており、これに外出先からアクセスするというのはどういうことなのでしょうか?もしかしてインターネットから直接50000/udpにアクセスされる?ポート開放が必要?STUNを使ってNAT超えをする?

そういうわけではないようです。 外出先から操作を行う場合、専用アプリは「日立GLS系のWeb API その2」を叩きます。 すると、日立GLS系のサーバと洗濯機の間でパケットが飛びます。 このパケットについては、ポート番号から推測する限りセキュアMQTTのようです。 ただ、パケットの中身はTLSにより保護されており、もちろん洗濯機(や場合によっては日立GLS系のサーバ)にルート証明書を入れられるわけもなく内容は確認できませんでした。

専用アプリと「日立GLS系のWeb API その2」の通信について、こちらもうまく観測できずコードから推測した結果ですが、 ローカルの50000/udpと通信する際と似たようなリクエストとレスポンスがやり取りされていると思われます。 暗号鍵とIVは変わる、また認証トークン(「日立家電メンバーズクラブ系のWeb API」で得られる)も必要な雰囲気と若干異なる点はありますが、このAPIは「(50000/udpの謎サービス over セキュアMQTT) over HTTPS」のような構成かも知れません。

これを利用して、自前で「日立GLS系のWeb API その2」を叩いて動作状況を取得……ということも可能だとは思いますが、やや面倒です。 というのも、認証トークンを得るために「日立家電メンバーズクラブ系のWeb API」を叩いてログインすると、既存のセッションは破棄されます。 たとえば、先に洗濯コンシェルジュアプリでログインしているとそちらはログアウト状態になります。 それでも別に構わないなら良いですが、やっぱり洗濯コンシェルジュアプリを使いたくなって再度ログイン、すると自前で叩く場合はまたトークンの取得からやり直しです。 1つの洗濯機に対して複数のスマートフォンをリンクするのは可能なようなので、 日立家電メンバーズクラブのアカウントを複数用意してアプリ用と自前用でアカウントを使い分けるという手もあると思いますが、これでも面倒に思うのと1人で複数アカウントを所持することは(いまさらですが)規約違反のためおすすめしません。

おわりに

日立製ドラム式洗濯機からどうにかこうにか動作状況を取得しました。

専用アプリ以上の情報を引き出すことはできず、ならアプリ使っておけば良いんじゃない?という気もしてきます。 でも運転状況を取得してどこかに残せると思うとちょっとアリかなという感じです。 アプリだと「お知らせ履歴」は出せますが運転履歴は出せないので。

あと、50000/udpのサービスはもちろん洗濯機が起動しているときでないとアクセスできず、どのように起動を知るか?は検討事項です。 とはいえpingにも反応してくれるし、洗濯機の起動時にDHCP Offerが出るのでそれを拾うこともできるし、そもそも別にMOVEINFやGETINFのコマンドを1[pps]か2[pps]くらいで常に送り続けても誰も困らないでしょう。 そのため大した話ではないです、おわり。

Footnotes

  1. 和泉紗霧さんもダンスしたくなる気持ちが分かる

  2. 【らくメンテ】乾かない日立ドラム式洗濯機を復活させる裏ワザ [日立/BD-STX120H] - YouTube

  3. hitachi仕様しよう

  4. 洗濯機だけに。

  5. こういうサービスもあるんですね、ACLの設定が若干面倒そうです。

  6. 逆に、たとえば洗濯機用にVLANを切っていると面倒かも知れません。

  7. 自分の環境だと、APP_DST1が加算された番号になっていました。

  8. アプリで「汚れの量が多いので、洗い時間を延長します。」とか書かれていると、ちゃんと機能している感があって気分が良い。

  9. “nyunan” らしいです。架線(がせん)やトラヒックみたいな業界用語?