CodeFoodPixels / robovac

Add a Eufy RoboVac easily to Home Assistant
Other
147 stars 32 forks source link

Add support for Eufy X10 Pro Omni #68

Open RickSisco opened 8 months ago

RickSisco commented 8 months ago

Are there plans to add support for the X10 Pro Omni vac?

CodeFoodPixels commented 8 months ago

As part of the work I'm doing on the better-dps branch, I'm making it so that devices with different commands can be supported. I'd need people to contribute/test the commands for the vacuums though.

russilker commented 8 months ago

Happy to be a tester for my X10 Pro Omni as needed.

RickSisco commented 8 months ago

If step by step instructions are supplied to test the commands, I would be willing to help test on my X10 Pro Omni

On Thu, Mar 21, 2024 at 11:11 PM russilker @.***> wrote:

Happy to be a tester for my X10 Pro Omni as needed.

— Reply to this email directly, view it on GitHub https://github.com/CodeFoodPixels/robovac/issues/68#issuecomment-2014268828, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABYCTSZ4Y3RTYSPTFPQUHXLYZOOMXAVCNFSM6AAAAABFA2MBG2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDAMJUGI3DQOBSHA . You are receiving this because you authored the thread.Message ID: @.***>

damfuu commented 8 months ago

I am happy to help as well.

RoadXY commented 8 months ago

Does that mean there might be some light at the horizon for: https://github.com/CodeFoodPixels/robovac/issues/46 ass well? 😎

CodeFoodPixels commented 8 months ago

Yep, all of these are related to #28

On Fri, 22 Mar 2024, 15:08 Robin van Kekem, @.***> wrote:

Does that mean there might be some light at the horizon for: #46 https://github.com/CodeFoodPixels/robovac/issues/46 ass well? 😎

— Reply to this email directly, view it on GitHub https://github.com/CodeFoodPixels/robovac/issues/68#issuecomment-2015305298, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABEPFVAGWZHQUAFZGC4ZUDLYZRCQJAVCNFSM6AAAAABFA2MBG2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDAMJVGMYDKMRZHA . You are receiving this because you commented.Message ID: @.***>

daschick111 commented 8 months ago

Count me in as a tester for the #46 issue

gmagnus1 commented 8 months ago

Happy to help here as well.

terabyte128 commented 8 months ago

Hello, I've been doing some work with my X10 to try and figure out where the setup process stalls. So far, I've noticed a couple of things:

First, the Eufy API endpoint to list devices seems to have changed. When hitting api/v1/device/list/devices-and-groups, I don't see anything in the items array.

{
  "res_code": 1,
  "message": "",
  "items": [],
  "global_config": {
    "enabled_multi_color_modes": [
      0,
      1,
      2,
      3
    ]
  }
}

I was able to MITM the traffic from the Eufy Clean app, though, and it looks like it instead makes a request to /api/v1/device/v2, which actually gives the device info:

{
  "res_code": 1,
  "message": "",
  "devices": [
    {
      "id": "<snipped>",
      "sn": "",
      "name": "X10 Pro Omni",
      "alias_name": "Dustin",
      "bluetooth": null,
      "wifi": {
        "mac": "<snipped>",
        "wifi_ssid": "",
        "lan_ip_addr": ""
      },
      "product": {
        "id": "<uuid>",
        "name": "X10 Pro Omni",
        "region": "[{\"regions\":[\"ALL\"],\"date\":\"2022-09-01 20:22:49\"}]",
        "default_name": "RoboVac",
        "icon_url": "https://d1teb1w17vl5yo.cloudfront.net/eufyhome/products/T2182_addProduct.png",
        "category": "Home",
        "appliance": "Cleaning",
        "connect_type": 2,
        "description": "T2182 eufy RoboVac L80, connected via EufyHome",
        "product_code": "T2351",
        "wifi_ssid_prefix": "eufy Clean X10 Pro Omni",
        "wifi_ssid_prefix_full": "eufy Clean X10 Pro Omni-",
        "index": 1,
        "create_time": 1640585895,
        "update_time": 1707192290,
        "is_show": false,
        "tuya_pid": "<id>",
        "app_ble_ssid_prefix": "eufy Clean X10 Pro Omni-",
        "device_ble_ssid_prefix": "eufy Clean X10 Omni-",
        "wifi_ssid_prefix_list": [
          "eufy Clean X10 Omni-",
          "eufy Clean X10 Pro Omni-"
        ],
        "device_ble_ssid_prefix_list": [
          "eufy Clean X10 Omni-",
          "eufy Clean X10 Pro Omni-"
        ]
      },
      "user_id": "<uuid>",
      "owner_info": null,
      "home_id": "<uuid>",
      "home_name": "",
      "room_id": "<uuid>",
      "room_name": "Default Room",
      "connect_type": 2,
      "grant_by": 0,
      "software_version": "",
      "index": 0,
      "device_key": "",
      "create_time": 1711493109,
      "update_time": 1711493549,
      "hardware_version": "",
      "scale_type": "",
      "local_code": "",
      "needUpdate": false,
      "setting": {
        "id": "",
        "device_id": "",
        "is_default": true
      },
      "update_packages": []
    }
  ],
  "groups": []
}

Next, I connected to the Tuya API and tried to retrieve the device with:

tuya.get_device("<device id>")

with both devices[0]['id'] and devices[0]['product']['id']

but I've only gotten back

{'t': 1711599563374, 'success': False, 'errorCode': 'PERMISSION_DENIED', 'status': 'error', 'errorMsg': 'PERMISSION_DENIED'}

and I'm not sure what to do from here. It looks like the response from Tuya is pretty generic, so I possibly messed up something about the parameters.

This is the code that I've been playing around with, for reference:

import requests

from tuyawebapi import TuyaAPISession

login = requests.post(
    "https://home-api.eufylife.com/v1/user/email/login",
    json={
        "client_id": "eufyhome-app",
        "client_secret": "GQCpr9dSp3uQpsOMgJ4xQ",
        "email": "<snipped>",
        "password": "<snipped>",
    },
)
login.raise_for_status()
login_data = login.json()

access_token = login_data["access_token"]
refresh_token = login_data["refresh_token"]
base = login_data["user_info"]["request_host"]

phone_code = login_data["user_info"]["phone_code"]
country = login_data["user_info"]["country"]
timezone = login_data["user_info"]["timezone"]
user_id = login_data["user_info"]["id"]

session = requests.Session()
session.headers["token"] = access_token

devices = session.get(f"{base}/v1/device/v2", headers={"category": "Home"})
devices.raise_for_status()
devices_data = devices.json()

tuya = TuyaAPISession(
    username=f"eh-{user_id}",
    region="AZ",
    timezone=timezone,
    phone_code=phone_code,
)

I also port-scanned the vacuum, in case that's helpful, but didn't find any of the known local Tuya ports to be open:

Nmap scan report for <snipped>
Host is up (0.010s latency).
Not shown: 1000 closed tcp ports (conn-refused)
PORT     STATE SERVICE
9668/tcp open  tec5-sdctp

Nmap done: 1 IP address (1 host up) scanned in 2.08 seconds

Unfortunately I wasn't able to glean that much other useful information from the app either. Starting a cleaning cycle triggers a bunch of requests to log.eufylife.com/push_log_hdfs and log.eufylife.com/push_log_es but nothing that is obviously the start signal. I'm happy to share more details about the flows. Note that I was only snooping on HTTP traffic, though, so if the app communicated locally with the vacuum over something else, I wouldn't have seen it.

CodeFoodPixels commented 8 months ago

That's some amazing work @terabyte128! Looks like the eufy endpoint you found works for older devices too, so I'm going to switch over to using that.

terabyte128 commented 8 months ago

Thanks! I also noticed that the vacuum is sending out broadcast UDP packets to port 9667 (not 6666/6667), so that may also be part of the puzzle. Unfortunately I'm not sure how to decrypt them. image (I doubt there's anything personal in the payload, but I'm gonna avoid posting it publicly – if you want it, let me know.)

RickSisco commented 8 months ago

Great work Sam. Look forward to seeing the progress Luke. :)

On Thu, Mar 28, 2024 at 7:08 PM Sam Wolfson @.***> wrote:

Thanks! I also noticed that the vacuum is sending out broadcast UDP packets from port 9667 (not 6666/6667), so that may also be part of the puzzle. Unfortunately I'm not sure how to decrypt them.

image.png (view on web) https://github.com/CodeFoodPixels/robovac/assets/1189703/d571e910-cb67-4ae5-b4fe-9a050c496620

(I doubt there's anything personal in the payload, but I'm gonna avoid posting it publicly – if you want it, let me know.)

— Reply to this email directly, view it on GitHub https://github.com/CodeFoodPixels/robovac/issues/68#issuecomment-2026292287, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABYCTS5ZHRNAFOCU4UOF74DY2SPGDAVCNFSM6AAAAABFA2MBG2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDAMRWGI4TEMRYG4 . You are receiving this because you authored the thread.Message ID: @.***>

terabyte128 commented 8 months ago

@CodeFoodPixels do you know how the username eh-{user_id} was deduced? (from here). I think there might be something wrong with how I'm using the Tuya API.

Entering a random ID and calling list_homes() gives more or less the same answer as when I use my real ID, though, so it's hard to tell if my login is correct or not.

For example:

tuya = TuyaAPISession(
    username=f"FAKEUSERID"
    region="AZ",
    timezone=timezone,
    phone_code=phone_code,
)

gives the response:

[{'geoName': '', 'rooms': [], 'gmtModified': 1711668412, 'role': 2, 'gid': 192829708, 'groupId': 192829708, 'displayOrder': 1, 'admin': True, 'dealStatus': 2, 'gmtCreate': 1711668412, 'ownerId': '192829708', 'uid': 'az1711668412228EOl6W', 'groupUserId': 157327173, 'background': '', 'name': 'My Home', 'id': 147452158, 'status': True}]

and making the same request with eh-{user_id} gives the exact same response shape, just with different IDs etc.

CodeFoodPixels commented 8 months ago

Thanks! I also noticed that the vacuum is sending out broadcast UDP packets to port 9667 (not 6666/6667), so that may also be part of the puzzle.

Good spot! Doing a quick search through a decompiled version of the eufy app, 9667 and 9668 seem to be used in place of 6667 and 6668.

@CodeFoodPixels do you know how the username eh-{user_id} was deduced?

It was deduced before I had anything to do with this, but again, looking at the app code, it's right. Did you use an actual ID or literally FAKEUSERID?

terabyte128 commented 8 months ago

I literally

It was deduced before I had anything to do with this, but again, looking at the app code, it's right. Did you use an actual ID or literally FAKEUSERID?

I literally used FAKEUSERID, lol. Maybe that's a way for them to defend against attackers trying to enumerate valid user IDs? Especially since they don't seem to require any sort of client secret.

terabyte128 commented 8 months ago

I didn't notice any requests from the app going directly to Tuya servers, but I wasn't specifically looking for that – I'll take another look.

CodeFoodPixels commented 8 months ago

I think the contact with the tuya servers is mainly to get keys etc, possibly also when you're not on the same network as the vacuum.

CodeFoodPixels commented 8 months ago

@terabyte128 If you want some code to play around with, you can use this as a base: https://github.com/CodeFoodPixels/robovac-schema

terabyte128 commented 8 months ago

I see, I wonder if it only contacts the Tuya server for local keys on initial setup. Getting them on different LANs is a good tip. Thanks for the link – I’ll poke at it more this weekend!

terabyte128 commented 8 months ago

I was able to capture some more information! I found that the app was making requests to /api.json, but on a different server than was encoded in tuyawebapi.py – in my case, https://a1-us.iotbing.com/api.json. Not sure if that makes a difference.

Perhaps more importantly, this is what the request format looks like:

Host:             a1-us.iotbing.com
Content-Type:     application/x-www-form-urlencoded
User-Agent:       EufyHome/20 CFNetwork/1474 Darwin/23.0.0
Connection:       keep-alive
Accept:           */*
Accept-Language:  en-US,en;q=0.9
Content-Length:   1555
Accept-Encoding:  gzip, deflate, br
Cache-Control:    no-cache
==== FORM DATA ===
lang:              en
bizData:           {"nd":1,"customDomainSupport":"1"}
deviceId:          83C29192-633C-4829-8CC0-CB30B65E6167
et:                0.0.2
osSystem:          17.0
bundleId:          com.eufylife.EufyHome
time:              1711840368
lon:               0
channel:           sdk
nd:                1
appVersion:        3.1.0
ttid:              appstore_d
os:                IOS
v:                 1.0
sid:               <snipped>
sign:              <snipped>
platform:          iPhone 11 Pro
postData:          USQUo1wT3zC1Mggnw4Dq8GiAp+pAFKpnkC1Bfiz53pphbXbtpMH2two1Bt/d9foJlYJXqxCUBe23WxRQy2w56kfAXQ18GEWFRK+u5TSULsgRJRW6q1ZOYkKBAU4Is++NMseQ2kMUXr/T3f+UvA4JeD+6xcoTqwrkd3zOa7Sbw878YMxkYklrFaaKOvK8lB+KL2h
zkOx/5rG34U8DQBwMv+aoVlMCZldQVNtd+5ahJhBweBIQ9gLZZkX90JarFy4F6dt4a+R3jwsZcv7L61ly3gJhopchbtzlWv0ETtx5F0NAN2MclLuyMvfoke4vR2XlihNzvdbZfVqEpzhvvMqDw2LFwVXy1Pfw8SHVRzuQCixxmI4lNJx/Jz3Ay++XKMQFTGPumbU7ryn4N+3ptsxGMIN/7
UP5WS3P5NQL50XvrjPsowkPAuMiCwnGw/Hfj29uIi2IobRL8MBspo6iM6pNCNZjexCFDJB4BkS4Vxk8YbqfIbCMmuHQJHoIE/qmico5q3684aVhNnI+fR/nRVRExHCkvl33+mpK8pSqVTRTyficFrpnHBbLnC1HdBpniayunxI5XNHjLfW3Pooj5Vp6DXE1t6pvuxqBmqpftQfQyFpKjwL
UAX2xccHevYnEMInLPxWuThci6yvQdU5yJEZOssfHpUpLnzDIcLTB8i5jc6PxZAnyi/FJPAiH5SUEhr0poIr0U4QCHS5AakCsNtz2p7bcwkrDQ9BwzMX7RoyxJKjDhc6nCyj9B3Pk6vXOyzx3WjEka0fSGfRRP+VlUY2z2sK2hRdF3ge6WH9IgesVmBokh2XQGggBGuSwab37PmgR6Iava
9rfU6bWO8qQX4kJGw==
uid:               az1711493114821bjS7C
sdkVersion:        4.3.0
timeZoneId:        America/Los_Angeles
requestId:         <snipped>
lat:               0
gid:               192606234
clientId:          <snipped>
deviceCoreVersion: 5.3.0
a:                 smartlife.m.api.batch.invoke
cp:                gzip

The most interesting thing here is that postData appears to be encrypted, which is different than what tuyawebapi.py does. (Simply base64-decoding it doesn't produce anything readable.) Looking at this link supports this idea as well, and it also offers a function that can apparently perform the encryption.

That's as far as I've gotten for now. I haven't looked into how to obtain tuya_bmpkey or tuya_appsecret.

terabyte128 commented 7 months ago

I have another progress update. Long story short, from what I can tell it appears that the Eufy Clean app doesn't communicate directly with the vacuum at all. Instead, it sends all commands through a Eufy MQTT server (in my case, aiot-mqtt-us.anker.com:8883). The MQTT server uses TLS.

{
    "head": {
        "client_id": "android-eufy_home-{user id}-eufy_android_Android SDK built for arm64_{some random hex digits, maybe a version number?}",
        "cmd": 65537,
        "cmd_status": 2,
        "msg_seq": 1,
        "seed": "",
        "sess_id": "android-eufy_home-{user id}-eufy_android_Android SDK built for arm64_{same hex digits as above}",
        "sign_code": 0,
        "timestamp": 1712295579241,
        "version": "1.0.0.1"
    },
    "payload": "{\"account_id\":\"{account id}\",\"data\":{\"153\":\"BwjpnJTm6jE\\u003d\"},\"device_sn\":\"{vacuum id}\",\"protocol\":2,\"t\":1712295579241}"
}

payload['data'] appears to be base64-encoded, and happily, not encrypted. The bytes seem to correspond exactly to the action performed, and don't change. For example (first digit is the key in the data dict):

154 16,10,14,10,2,8,2,26,0,34,2,8,1,42,2,8,1  # turn on 'auto' mode
154 14,10,12,10,2,8,2,26,0,34,2,8,1,42,0  # turn off 'auto' mode

Unfortunately I haven't been able to send out commands on my own yet. I suspect there might be another piece of auth that I'm missing before I can publish commands to the topic, and/or it might want a client certificate.

RickSisco commented 7 months ago

Thanks for the update. Glad to see you finding progress.

On Fri, Apr 5, 2024 at 1:45 AM Sam Wolfson @.***> wrote:

I have another progress update. Long story short, from what I can tell it appears that the Eufy Clean app doesn't communicate directly with the vacuum at all. Instead, it sends all commands through a Eufy MQTT server (in my case, aiot-mqtt-us.anker.com:8883). The MQTT server uses TLS.

  • The username for the MQTT server appears to be {user id}-eufy_home, with no password.
  • The topic in my case is cmd/eufy_home/T2351/{vacuum ID}/req (not sure what T2351 represents)
  • The commands themselves look like:

{ "head": { "client_id": "android-eufy_home-{user id}-eufy_androidAndroid SDK built for arm64{some random hex digits, maybe a version number?}", "cmd": 65537, "cmd_status": 2, "msg_seq": 1, "seed": "", "sess_id": "android-eufy_home-{user id}-eufy_androidAndroid SDK built for arm64{same hex digits as above}", "sign_code": 0, "timestamp": 1712295579241, "version": "1.0.0.1" }, "payload": "{\"account_id\":\"{account id}\",\"data\":{\"153\":\"BwjpnJTm6jE\u003d\"},\"device_sn\":\"{vacuum id}\",\"protocol\":2,\"t\":1712295579241}" }

payload['data'] appears to be base64-encoded, and happily, not encrypted. The bytes seem to correspond exactly to the action performed, and don't change. For example (first digit is the key in the data dict):

154 16,10,14,10,2,8,2,26,0,34,2,8,1,42,2,8,1 # turn on 'auto' mode 154 14,10,12,10,2,8,2,26,0,34,2,8,1,42,0 # turn off 'auto' mode

Unfortunately I haven't been able to send out commands on my own yet. I suspect there might be another piece of auth that I'm missing before I can publish commands to the topic, and/or it might want a client certificate.

— Reply to this email directly, view it on GitHub https://github.com/CodeFoodPixels/robovac/issues/68#issuecomment-2038993537, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABYCTS3Y7ISHWKZJZUPUJA3Y3Y3A7AVCNFSM6AAAAABFA2MBG2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDAMZYHE4TGNJTG4 . You are receiving this because you authored the thread.Message ID: @.***>

RoadXY commented 7 months ago

T2351 is the model number of your vacuum. T2351 = X10 Pro omni

https://support.eufy.com/s/article/T2351-EU-DOC

terabyte128 commented 7 months ago

I was able to connect to the MQTT server and inspect all the messages sent to the relevant topics. I could also publish on the topics and cause the vacuum to perform actions. The MQTT topics are:

Below is the snippet that I used. The important pieces are:

Java.perform(function () {
  let MqttConnectionNew = Java.use("com.anker.aiot_sdk.mqtt.MqttConnectionNew");
  MqttConnectionNew["s"].implementation = function (domainMqttBean) {
    console.log(
      `MqttConnectionNew.s is called: domainMqttBean=${domainMqttBean}`,
    );
    let result = this["s"](domainMqttBean);
    console.log(`MqttConnectionNew.s result=${result}`);
    return result;
  };
});

and the code I wrote to connect to the MQTT server:

import json
from base64 import b64decode

import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion

# the following can be retrieved using existing scripts against Eufy's API
USER_ID = "<snipped>"
VACUUM_SERIAL = "<snipped>"

client = mqtt.Client(
    CallbackAPIVersion.VERSION2,
    client_id=(
        f"android-eufy_home-{USER_ID}-eufy_android_Android"
        f" SDK built for arm64_{USER_ID}"
    ),
)
client.tls_set(
    certfile="./certs/certificate.pem",
    keyfile="./certs/private_key.pem",
)

def print_bytes(bs: bytes):
    ret = ""
    for b in bs:
        formatted = f"{b:02x} "
        ret += formatted

    return ret

def on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage):
    topic = message.topic
    payload = json.loads(message.payload)

    if isinstance(payload["payload"], str):
        payload["payload"] = json.loads(payload["payload"])

    if "res" in topic:
        return  # comment this if you also want the vacuum's responses printed out
        print("<-- ", end="")
    else:
        print("--> ", end="")

    for k, v in payload["payload"]["data"].items():
        try:
            decoded = b64decode(v)
            print(f"{k}: {print_bytes(decoded)}")
            print(v)
        except:
            print(f"failed to decode {k}: {v}")

    # print(topic)
    # print(json.dumps(payload, indent=4))

client.on_message = on_message
client.username = f"{USER_ID}-eufy_home"
client.connect("aiot-mqtt-us.anker.com", 8883)

client.subscribe(f"cmd/eufy_home/T2351/{VACUUM_SERIAL}/#")

client.loop_forever()

The Python snippet just prints out the payloads of each message. Next step is figuring out what they actually mean. It seems like most payloads carry the entire configuration of the vaccum's "mode", but since they're variable length, it's difficult to grok what bytes mean what. Here are some examples:

--> 154: 10 0a 0e 0a 02 08 02 1a 00 22 02 08 01 2a 02 08 01  # smart mode on
--> 154: 0e 0a 0c 0a 02 08 02 1a 00 22 02 08 01 2a 00  # smart mode off
--> 154: 10 0a 0e 0a 02 08 02 1a 02 08 02 22 02 08 01 2a 00 # fast, vacuum+mop, standard suction, medium water level
terabyte128 commented 7 months ago

Eufy appears to be using some obscenely complicated protocol to generate the "set mode" payloads (seems like they're using an AST to build up the message like programming language syntax...why???) so rather than try and wade through all the code, I just brute-forced it. Below is the file that corresponds to the base64-encoded messages associated with each mode-tuple. Every "mode" message appears to be in the format of:

{
    "154": "<mode code>"
}

codes.csv

terabyte128 commented 7 months ago

In a nicer format: https://gist.github.com/terabyte128/0598dcb735ec73842dfc5d204d968320

CodeFoodPixels commented 7 months ago

Are they encoded as protobufs? That was the case for another device: https://github.com/CodeFoodPixels/robovac/issues/40#issuecomment-2027798578

terabyte128 commented 7 months ago

Hmm, could be, I'll dig into that.

samonthenetuk commented 7 months ago

Has anyone got this working with their x10 pro? I just got my new hoover yesterday and cannot get it work.

apeschel780 commented 7 months ago

Home assistant Integration for the Eufy x10 is still in progress, but if you only want to start a cleaning cycle with a Lovelace card or automation, you can integrate your x10 with the Eufy skill in Alexa and create a cleaning routine in Alexa as well. In home assistant you can use the Alexa media player integration to start your routine. Here is my example:

service: media_player.play_media target: entity_id: media_player.echo_im_bad data: media_content_id: staubsaugen media_content_type: routine metadata: {}

Antopeto commented 7 months ago

I am happy to be a guinea pig but i am not hot on coding so clear instructions will help me a lot!

terabyte128 commented 7 months ago

I am still working on this but other things have taken priority recently, unfortunately.

terabyte128 commented 7 months ago

@CodeFoodPixels unfortunately they don't seem to be protobufs, I don't see a consistent decoding using protoscope:

$ base64 -d <<< "DAoKCgAaACIAKgIIAQ==" | protoscope
1:EGROUP
1: {
  1: {}
  3: {}
  4: {}
  5: {1: 1}
}
~ $ base64 -d <<< "CgoICgAaACIAKgA=" | protoscope
`0a0a080a001a0022002a00`
RickSisco commented 7 months ago

We understand and appreciate your time. :)

On Sun, Apr 21, 2024 at 4:59 PM Sam Wolfson @.***> wrote:

I am still working on this but other things have taken priority recently, unfortunately.

— Reply to this email directly, view it on GitHub https://github.com/CodeFoodPixels/robovac/issues/68#issuecomment-2068196120, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABYCTS2A2SBC3SH5LAUL7J3Y6QSDRAVCNFSM6AAAAABFA2MBG2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDANRYGE4TMMJSGA . You are receiving this because you authored the thread.Message ID: @.***>

martijnpoppen commented 7 months ago

@CodeFoodPixels @terabyte128 for what it's worth. I came accross the IObroker integration, and it seems he found a way to debug it. Not sure if it's helpful: https://github.com/TA2k/ioBroker.euhome/blob/main/main.js#L118

terabyte128 commented 7 months ago

Ooh that is a good find! Looks very similar to what I’ve been experimenting with, but much more fleshed out.

samonthenetuk commented 7 months ago

Home assistant Integration for the Eufy x10 is still in progress, but if you only want to start a cleaning cycle with a Lovelace card or automation, you can integrate your x10 with the Eufy skill in Alexa and create a cleaning routine in Alexa as well. In home assistant you can use the Alexa media player integration to start your routine. Here is my example:

service: media_player.play_media target: entity_id: media_player.echo_im_bad data: media_content_id: staubsaugen media_content_type: routine metadata: {}

I was hoping to integrate with Home Assistant so that I could add it to homekit.

martijnpoppen commented 7 months ago

@terabyte128 I tried also multiple things to decode the strings. Seems like it's a Tuya specific encoding. (i thought i might be eufy encoding aswell).

But found this thread: https://github.com/codetheweb/tuyapi/discussions/440#discussioncomment-488832. Couldn't get it to work yet.

martijnpoppen commented 6 months ago

@terabyte128 @CodeFoodPixels Had a breaktrough here ;)

So the values are Protobufs. But they are encoded delimited. (got some inspiration here: https://github.com/tuya/tuya-home-assistant/issues/495)

I made a setup in JS (sorry about that ;) )

const protobuf = require('protobufjs');

const main = async function () {
    const Fan = await getData('proto/cloud/clean_param.proto', 'Fan', 'EAoOCgIIAhoAIgQIAhABKgA=');
    const MopMode = await getData('proto/cloud/clean_param.proto', 'MopMode', 'EAoOCgIIAhoAIgQIAhABKgA=');
    const CleanCarpet = await getData('proto/cloud/clean_param.proto', 'CleanCarpet', 'EAoOCgIIAhoAIgQIAhABKgA=');
    const CleanType = await getData('proto/cloud/clean_param.proto', 'CleanType', 'EAoOCgIIAhoAIgQIAhABKgA=');
    const CleanExtent = await getData('proto/cloud/clean_param.proto', 'CleanExtent', 'EAoOCgIIAhoAIgQIAhABKgA=');
    const CleanTimes = await getData('proto/cloud/clean_param.proto', 'CleanTimes', 'EAoOCgIIAhoAIgQIAhABKgA=');

    const WorkStatus = await getData('proto/cloud/work_status.proto', 'WorkStatus', 'ChADGgIIAXICIgA=');

    const AnalysisStatistics = await getData('proto/cloud/analysis.proto', 'AnalysisStatistics', 'GxIZOhcIsof5sQYQZBhjIPF+KBEyBsLhAurpAg==');

    console.log('\n\n-------- CleanParam (DP: 154)--------')
    console.log('Fan', Fan);
    console.log('MopMode', MopMode);
    console.log('CleanCarpet', CleanCarpet);
    console.log('CleanType', CleanType);
    console.log('CleanExtent', CleanExtent);
    console.log('CleanTimes', CleanTimes);

    console.log('\n\n-------- WorkStatus (DP: 153) --------')
    console.log('WorkStatus', WorkStatus);

    console.log('\n\n-------- AnalysisStatistics (DP: 176)--------')
    console.log('AnalysisStatistics', AnalysisStatistics);
};

const getData = async function (proto, type, base64Value) {
    const root = await protobuf.load(proto);
    const protoLookupType = root.lookupType(type);

    const buffer = Buffer.from(base64Value, 'base64');
    const decodedMessage = protoLookupType.decodeDelimited(buffer);

    // Convert the decoded message to a plain JavaScript object
    const decodedObject = protoLookupType.toObject(decodedMessage, {
        longs: String, // Long objects will be converted to strings
        enums: String, // Enum values will be converted to strings
        bytes: String // Bytes will be converted to base64 strings
    });

    return decodedObject;
};

main();

This results in:

-------- CleanParam (DP: 154)--------

Fan { suction: 'TURBO' }
MopMode { level: 'HIGH' }
CleanCarpet { strategy: 'IGNORE' }
CleanType { value: 'SWEEP_AND_MOP' }
CleanExtent { value: 'QUICK' }
CleanTimes { autoClean: 2, selectRooms: 1, spotClean: 4 }

-------- WorkStatus (DP: 153) --------

WorkStatus {
  state: 'CHARGING',
  charging: { state: 'DONE' },
  station: { waterTankState: {} }
}

-------- AnalysisStatistics (DP: 176)--------
AnalysisStatistics { gohome: {} }

The Proto files I got from the decompiled APK.

terabyte128 commented 6 months ago

@martijnpoppen very cool, would you mind giving me a hint about where you found those files in the APK? A path would be great :)

martijnpoppen commented 6 months ago

@terabyte128 resources/proto/cloud, i can also send you the proto files

Edit: I sent them via mail to you

terabyte128 commented 6 months ago

Finally have some time to look at this again :)

It looks like even though they have the total length at the beginning of the message, only one message is ever sent as a time. I think the reason that you could interpret EAoOCgIIAhoAIgQIAhABKgA= as all those types is because it is actually a CleanParamRequest, which contains all of them:

message CleanParam {
    CleanType clean_type = 1;
    CleanCarpet clean_carpet = 2;
    CleanExtent clean_extent = 3;
    MopMode mop_mode = 4;
    Switch smart_mode_sw = 5; // 智能省心模式开关
    Fan fan = 6;  // 从 x10 项目开始使用这里的风机档位,旧项目使用单独 dp
    uint32 clean_times = 7; // 清扫次数,非 0 有效
}

message CleanParamRequest {
    CleanParam clean_param = 1;
    CleanParam area_clean_param = 2;
}

I was able to get this working in Python by just chopping off the first byte from the decoded base64 (because it's a VARINT and the lengths are never long enough to require more than one byte):

from typing import Type, TypeVar

from google.protobuf.message import Message
from proto.cloud.clean_param_pb2 import CleanParamRequest
from proto.cloud.work_status_pb2 import WorkStatus
from base64 import b64decode

DP_MAP: dict[int, Type[Message]] = {
    153: WorkStatus,
    154: CleanParamRequest,
}

T = TypeVar("T", bound=Type[Message])

def decode(b64_data: str, to_type: T, has_length: bool = True) -> T:
    data = b64decode(b64_data)

    if has_length:
        data = data[1:]

    return to_type().FromString(data)

def encode(data: Message, has_length: bool = True) -> bytes:
    out = data.SerializeToString()

    if has_length:
        out = bytes([len(out)]) + out

    return out
martijnpoppen commented 6 months ago

@terabyte128 if you do your decode like i did in JS:

    const decodedObject = protoLookupType.toObject(message, {
        longs: String, // Long objects will be converted to strings
        enums: String, // Enum values will be converted to strings
        bytes: String // Bytes will be converted to base64 strings
    });

you'll get the value from the proto file aswell:

instead of:

{ cleanType: CleanType { value: 1 } }

you'll get:

{ cleanType: { value: 'MOP_ONLY' } }

I think you can to that with MessageToDict

Btw, did you get my mail with files? :)

terabyte128 commented 6 months ago

Yes I did, thanks!

Unfortunately the official Python library for protobufs is not as expressive as the one you used for JS, and I ran into issues with this alternative.

But printing out these objects does show the enum name, at least:

clean_param {
  clean_type {
    value: SWEEP_AND_MOP
  }
  clean_carpet {
  }
  clean_extent {
    value: NARROW
  }
  mop_mode {
    level: HIGH
  }
  smart_mode_sw {
  }
}

edit: ah, i re-read your comment and yes, MessageToDict is handy too :)

terabyte128 commented 6 months ago

BTW: what does "DPs" actually stand for?

martijnpoppen commented 6 months ago

@terabyte128 Datapoints

BarryOCathain commented 6 months ago

Late to the party guys, but I've recently purchased an X10, so I can help out with debugging etc.

cronner commented 5 months ago

it's been a couple of months, any chance taht it's gonna be supported? could be really nice

terabyte128 commented 5 months ago

Been busy with a variety of other things, but in the interest of having something that can be worked on, I've published my progress so far at https://github.com/terabyte128/eufy-mqtt-vacuum.

This contains all the pieces to authenticate against the MQTT server, subscribe to status updates, and decode a subset of them from protobuf messages into Python dataclasses. Happy to answer any questions about how it works or accept PRs for added functionality (beyond just logging what is going on).

I'm not that familiar with how MQTT integrates into tools like home assistant, so I haven't been able to take much of a crack at that, but hopefully this is helpful for solving vacuum auth side of things. I'll probably keep poking at it as time allows, but hey, it's free labor – what do you expect :)

terabyte128 commented 5 months ago

I figured it would make sense to split this into its own repo given that it's more-or-less an entirely new strategy for device authentication and is worth existing on its own so folks can use with tools over than HA.