Rollease Acmeda Automate Pulse hub integration

Re the voltage calculation, I have done a recording of a number of different units as well, and unfortunately it’s not linear, see the results here: https://github.com/sillyfrog/aiopulse2/wiki/API#voltage

Even accounting for the delay in the app by a couple of seconds, I don’t think it is linear. I have put in a support ticket earlier this week to see if they can give me the formula, unfortunately they have not responded to that one yet.

I don’t have any that are lower than that battery level (yet), but will put those up (on the wiki page) as they go flat.

Maybe some ideas here: https://stackoverflow.com/questions/56266857/how-do-i-convert-battery-voltage-into-battery-percentage-on-a-4-15v-li-ion-batte
(I have not had a chance to look at in detail yet as I need to head off. I think we’ll need some readings from a flat battery to get a correct formula).

I wouldn’t say Acmeda have nailed it yet either though… I charged one of mine overnight, it’s now at 12.47v which in the Pulse app is showing as 86%. It’s only a couple of months old, so can’t imagine a significant efficiency loss already.

Adding in your data mostly shows roughly the same linear formula:

percent = 27.9*volts - 261

I undertook some SSL packet sniffing on my local network to see what is being sent between the hub and the iOS app.

When you open the app and look at a device (HMU) in detail, the following is sent to the hub on port 443:

{“method”:“shadow”,“args”:{“desired”:{“shades”:{“HMU”:{“query”:true}}},“timeStamp”:1597837014.321}}

The hub then responds with (I have obscured some things):

{“id”:1597837186,“src”:“esp32_36AC39”,“dst”:“app”,“result”:{“reported”:{“hubId”:“xxxxxxx”,“name”:“xxxxx”,“mac”:“xx:xx:xx:xx:xx:xx”,“onlineLatest”:1597837187.561703,“mfi”:{“PD”:“xxxxxxxxxxxxxxxx”,“manufacturer”:“Rollease Acmeda”,“model”:“MT02-0401-067001”},“firmware”:{“version”:“1.0.0”,“RFversion”:“B10”},
“shades”:{“LLJ”:{“is”:true,“ol”:true,“mp”:100,“vo”:“12.2D24”,“ls”:1,“rs”:-93},
“8LJ”:{“is”:true,“ol”:true,“mp”:100,“vo”:“12.1D24”,“ls”:1,“rs”:-77},
“HMU”:{“is”:true,“ol”:true,“mp”:100,“vo”:“10.8D24”,“ls”:1,“rs”:-90},
“LSQ”:{“is”:true,“ol”:true,“mp”:100,“vo”:“11.6D24”,“ls”:1,“rs”:-80},
“ROI”:{“is”:true,“ol”:true,“mp”:100,“vo”:“10.9D24”,“ls”:1,“rs”:-81},
“MA0”:{“is”:true,“ol”:true,“mp”:100,“vo”:“10.9D24”,“ls”:1,“rs”:-81}}}}}

And then keeps sending out the same info every 5 seconds or so. What I take away from this:

  • The Pulse Hub is an ESP32 SoC IoT device (this is written on the bottom of it as well)
  • The battery % is calculated in the app from the voltage and is not sent by the hub. The hub does send the battery type (D24).
  • Two devices with the same voltage sent over SSL don’t have exactly the same battery %.
  • Signal strength is sent out by the hub to the app (the “rs” field), which the app displays directly.

Here is the data above aligned with the corresponding numbers from Pulse Linq software from my system:

id appbat% appsignal LinqV LinqR hub2appV
HMU 41% -90 Vc01077 R58 10.8
LSQ 62% -80 Vc01162 R6D 11.6
ROI 43% -81 Vc01092 R6A 10.9
MA0 41% -81 Vc01086 R6A 10.9
8LJ 76% -77 Vc01207 R72 12.1
LLJ 78% -93 Vc01223 R52 12.2

The signal strength data the hub sends on the LAN seem to be the HEX codes after the voltage. Below are the HEX codes, their decimal conversion and the RS signal.

Code Decimal _from_code RS
R58 88 -90
R6D 109 -80
R6A 106 -81
R6A 106 -81
R72 114 -77
R52 82 -93

From my searching around, signal strength is reported comonly as an RSSI which is an index set by the device maker ranging from 0 - M where M can be 255, 100, 60 etc. Here it seems they are using 255. The app must convert the RSSI to dBm values. Based on the above, a simple formula to produce the dBm value from the RSSI is dBm = RSSI/2 - 134. Judging by @fejta’s numbers and mine, -90 or less shows as an orange signal strength indicator, and -88 or above shows as green. What -89 does I don’t know.

Also I tried to dump the firmware from the Pulse Hub 2 over a virtual serial connection via ethernet. It didn’t work :face_with_raised_eyebrow:

Nice work on the 443 investigation @pss!

Does the app send a command on 443 to raise/lower/stop/set shade position? If we can do everything on 443 that might be a better option than 1487 (assuming multiple devices can connect on 443 concurrently).

I pushed another commit to the code above that attempts to provide a compatible interface with the v1 aiopulse Hub. Haven’t had the chance to test it yet in home-assistant though.

One way to test it would be to put the above code into components/acmeda/config_flow.py and call patch_aiopulse() before the call to aiopulse.Hub.discover(). Theoretically it should discover the v2 hubs, which should now return v2 hubs that look like v1 hubs, and everything in home assistant should work the same thereafter.

In reality I am sure the code will blow up (so don’t try it yourself unless that is something you’re comfortable recovering from).

1 Like

@pss That’s pretty interesting they have a different protocol again for the App. It does also appear that it maybe possible to connect to port 443 several times without issue (unlike port 1487).

How did you do the MITM? I tried (acting as a transparent router with MITM proxy), but it never sent the traffic directly to the hub, rather it went to the cloud. The only other way I can this is an Ethernet bridge, which with my setup is a bit of a pain (but if it works, I’ll do it).

I also tried to connect directly (using openssl s_client -connect) and sent the JSON you posted above (I fixed up the smart quotes as that was an issue at first), but didn’t get any reply (it also didn’t disconnect me until I put in an empty line, so must not be totally invalid).

I think the JSON format would be ideal (vs the funky serial style one on port 1487).

I have also started to have a look at modifying aiopulse to work with the v2 hub here: https://github.com/sillyfrog/aiopulse2 . All it does at the moment is connect and keep the connection alive. But it’s an update to the existing library to match the API, so integration should be easy (should that be the way to go).

Intercepting the packets was not quite as simple as I thought (a unifying principle of home automation it seems).
With an iPhone on ios13 and Windows 10, the process is:

  1. Install Charles.
  2. Turn off all software firewalls on the PC in Windows defender or whatever you use.
  3. In Charles, go to Help -> SSL proxying -> ‘Install Charles root certificate on a mobile device or remote browser’. This displays the Charles proxy IP address/port. Also note the address to download a certificate.
  4. On my system, the proxy IP it gave in the previous step was not actually correct, I had to use the IP address of my PC (this may be because I have WSL running). The port was correct though.
  5. On the iphone, go to Wifi -> info (the i in a circle next to your active wifi connection) -> Configure Proxy -> manual -> enter the IP address and port as above.
  6. Now you should see that Charles will start showing your non-SSL traffic (eg try baidu.com). It will show the connections from the Pulse iOS app, but you can’t look at the content without configuring SSL capture.
  7. To get SSL traffic, open Safari on the iphone (Chrome doesn’t work), and navigate to the address from point 3, and click to download the certificate. It should warn you that you are downloading a profile.
  8. After this, go to Settings -> General -> ‘Profile’ option under VPN. Go into this and install the Charles certificate.
  9. Then go to General -> About -> Certificate Trust Settings (at the bottom) -> Trust the Charles certificate.
  10. Back in Charles, go to Proxy -> SSL Proxying Settings and enable SSL proxying, and add the ip address of your Hub with port 443 under ‘Include’.

After all that, you should be able to insepct SSL traffic to the hub.
With recording running in Charles, initially when you open the Pulse app, you will see it connect to the auth server. Then a connection to the hub IP on port 443 will appear. If you look into the traffic on this connection, you will see the commands I posted earlier.

After you’re done you should untrust the Charles certificate, remove it and turn off the proxy setting.
Note the Charles software is a trial version, and will shut itself down after 30 minutes. You can re-open it to get another 30 minutes. I also tried to use Fiddler, but couldn’t get it working consistently.

Note that TCP port 80 is also open on the hub, not sure what it is used for, or if it would accept any traffic.

I didn’t try sending any commands @fejta.

@pss Thanks! Using as a direct proxy worked for me (I didn’t even try that as assumed it would fail :crazy_face: ). I used mitmproxy - worth checking out as well.

Unfortunately in this case it was not doing a good job with the WebSocket connections (which is what it’s using). So I did a sslkeylogfile dump, recorded the traffic using tcpdump and then looked at the data in Wireshark.

@fejta It looks like the commands are also sent as JSON, for example:

{"method":"shadow","args":{"desired":{"shades":{"S1S":{"movePercent":100}}},"timeStamp":1597906370.327}}

For further technical info, the WebSocket URL is /rpc, and the SSL cert is some sort of self signed cert from AWS.

Unfortunately, to get the detailed info on each blind (the name/description is the main thing), it makes a call to https://live.automate-arc.com/userInfo, returning data such as (slight censoring of IDs):

{
    "code": 200,
    "response": {
        "devices": [
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "S1S",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 76,
                "name": "Nook",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -77,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.3"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "D55",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 82,
                "name": "A1",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -70,
                "tiltAngle": 52,
                "version": "24",
                "voltage": "11.4"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "PF8",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": "A2",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -76,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "12.4"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "0R5",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": "B1",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -84,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.3"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "YQX",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": "B2",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -72,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.3"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "35W",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": "C1",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -77,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.4"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "Q46",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": " C2",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -78,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.2"
            },
            {
                "deviceCode": "D",
                "deviceType": "rollers",
                "hubId": "v2-RA-Pulse-1005576",
                "id": "VEB",
                "isOwned": true,
                "isStopped": true,
                "limitsSet": 1,
                "movePercent": 100,
                "name": "Bins",
                "online": true,
                "roomId": "1597195710943",
                "rssi": -80,
                "tiltAngle": 0,
                "version": "24",
                "voltage": "11.2"
            }
        ],
        "hubs": [
            "v2-RA-Pulse-1005576"
        ],
        "locations": [
            {
                "hubs": [
                    {
                        "hubID": "v2-RA-Pulse-1005576",
                        "mac": "c4:4f:33:36:b0:35",
                        "name": "default",
                        "onlineLatest": 1597906236.90139,
                        "version": "1.0.0"
                    }
                ],
                "id": "v2-e3f7fcd0-db9d-11ea-9f91-xxxxxxxxx",
                "name": "Place",
                "timersPaused": false
            }
        ],
        "rooms": [
            {
                "customPercent": 50,
                "hubId": "v2-RA-Pulse-1005576",
                "id": "1597195710943",
                "isOwned": true,
                "name": "Kitchen ",
                "pictureKey": "barRoom"
            }
        ],
        "scenes": [],
        "sharedHubs": [],
        "timers": []
    }
}

This request is made using Bearer auth (probably part of the AWS stack they are using), so I think it would be impractical to use this (short of getting them to login to HA, but I think given we have a local way to do it, the API maybe better).

There is also a request to https://live.automate-arc.com/appInfo/automate, which gives this - implying they have an adjustable threshold for the battery (I have not looked closely yet, but looks about right based on the above). This also implies different batteries (and probably versions) have different thresholds - maybe it just does a regression in that range? It’s a bit different to what I was expecting.

{
    "code": 200,
    "response": {
        "appVersion": "2.0.13",
        "powerLevels": {
            "A": {
                "DEFAULT": {
                    "intervals": [],
                    "states": [
                        "ac_full"
                    ]
                }
            },
            "C": {
                "10": {
                    "intervals": [
                        13.3,
                        15.5,
                        16.4,
                        17.5
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full",
                        "ac_full"
                    ]
                },
                "DEFAULT": {
                    "intervals": [
                        8.946,
                        9.407,
                        12.6
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                }
            },
            "D": {
                "14": {
                    "intervals": [
                        9.8,
                        11.8,
                        12.6,
                        14
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full",
                        "dc_full"
                    ]
                },
                "20": {
                    "intervals": [
                        6.9,
                        7.9,
                        8.4,
                        12
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full",
                        "dc_full"
                    ]
                },
                "22": {
                    "intervals": [
                        9.7,
                        11.7,
                        12.6
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                },
                "24": {
                    "intervals": [
                        9.3,
                        11.6,
                        12.6,
                        13
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full",
                        "dc_full"
                    ]
                },
                "25": {
                    "intervals": [
                        6.9,
                        7.8,
                        8.2
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                },
                "DEFAULT": {
                    "intervals": [
                        8.946,
                        9.407,
                        12.6
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                }
            },
            "DEFAULT": {
                "intervals": [
                    8.946,
                    9.407,
                    12.6
                ],
                "states": [
                    "battery_low",
                    "battery_medium",
                    "battery_full",
                    "battery_full"
                ]
            },
            "d": {
                "14": {
                    "intervals": [
                        9.9,
                        11.7,
                        12.6
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                },
                "DEFAULT": {
                    "intervals": [
                        8.946,
                        9.407,
                        12.6
                    ],
                    "states": [
                        "battery_low",
                        "battery_medium",
                        "battery_full",
                        "battery_full"
                    ]
                }
            }
        },
        "timeOfRelease": 1597171465
    }
}

If I get a chance I’ll see if I can do a quick test script to get info on the blinds and open/close them etc (not the name, rather the state if open/closed, and sending commands). Maybe a final solution could use port 1487 for getting data (name etc), then use the WebSocket connection for everything else?

So it would help if I put in the correct URL… (wrong version deleted).

Updated version here. This moves the given blind to 80%, and then prints the progress of that blind every few seconds. Just a demo of the WebSocket interface:

#!/usr/bin/env python

import asyncio
import ssl
import websockets
import time
import json

ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

HOST = <ip address>
BLIND_ID = "S1S"

# import logging
# logger = logging.getLogger("websockets")
# logger.setLevel(logging.DEBUG)
# logger.addHandler(logging.StreamHandler())


async def hello():
    uri = "wss://{}:443/rpc".format(HOST)
    async with websockets.connect(uri, ssl=ssl_context) as websocket:

        qry = {"method": "shadow", "src": "app", "id": int(time.time())}

        # this moves the blind
        qry = {
            "method": "shadow",
            "args": {
                "desired": {"shades": {BLIND_ID: {"movePercent": 80}}},
                "timeStamp": time.time(),
            },
        }
        await websocket.send(json.dumps(qry))
        print("Send qry", qry)

        for i in range(5):
            await asyncio.sleep(3)
            qry = {"method": "shadow", "src": "app", "id": int(time.time())}
            # This never returns a result
            # qry = {
            #     "method": "shadow",
            #     "args": {
            #         "desired": {"shades": {BLIND_ID: {"query": True}}},
            #         "timeStamp": time.time(),
            #     },
            # }
            await websocket.send(json.dumps(qry))
            print("Send qry", qry)
            response = json.loads(await websocket.recv())
            print("Response:")
            print(
                json.dumps(
                    response["result"]["reported"]["shades"][BLIND_ID], indent="  "
                )
            )


asyncio.get_event_loop().run_until_complete(hello())

The response for a specific blind is:

{
  "is": true,
  "ol": true,
  "mp": 80,
  "vo": "11.3D24",
  "ls": 1,
  "rs": -81
}

My guess as to each value:
is: Is the blind stationary, if it’s moving, this is false.
ol: Maybe “Online”?
mp: The current position of the blind, this is not updated as it moves.
vo: Voltage, and the model (after the letter) of the battery
ls: no idea
rs: RSSI (signal strength)

This is great work, looks pretty promising.
It surprises me that you can just communicate with the hub over SSL like that.
I don’t quite understand the role of authentication in the app in this context.

Anyway, I think ls is “limitsSet” which is in the detailed blind info sent back from automate-arc.com you posted earlier. Presume this refers to whether the motor has had limits set. I think limits are always set when the blinds are installed. mp is likely to be “movePercent”. Probably this is a target which the blind moves to and then stops, rather than a real time position.

I wonder how HomeKit sends commands… should have checked that.

Re auth, that’s all for the Cloud side of the communication, as most of the configuration state (name etc), appears to be stored there for use by the app. I’ve not used the feature, but I’m guessing this is how the sharing of accounts and access etc is done.

ls for limitsSet makes sense (I didn’t really look at that data). There is a lot of talk in the serial docs about handling blinds with and without limits and the handling in the protocol. It would make sense for all current model blinds to always have a limit set.

Re HomeKit, the protocol is documented here: https://developer.apple.com/homekit/ You need an Apple ID / Developer account to download. I have had a look, there’s a fair bit going on in the protocol - but I think we’d be better off sticking with this format (the JSON and/or the port 1487) as my understanding is you can only have one “account” (Apple ID) paired with the hub at a time. So if you were to pair this integration with the Apple ID, you would not then be able to use the native Apple stuff.

I have just pushed a working module mostly using websockets, and a little bit of the serial style connection for the device names (this connection is then dropped when it’s no longer needed).

It has only had basic testing, and is more a proof of concept. When I get a chance I’ll try and integrate with HA. The main thing is going to need is an interface to prompt the user to enter an IP address/host name for the hub. It should be easy, but have not started on that side yet at all.

I have done all testing so far using the demo.py script, this allows me to connect, list the blinds etc, and control them. I’ll post again if/when I get anywhere with the actual HA integration.

I tried out your demo.py script and it works well for me. Your documentation says that you have an update command, but it crashed the demo app any time I tried it. Otherwise, the listing and controls worked just fine for me.

Sorry, yes, probably a few things like that that were left around. I have cleaned that up now, and fixed a number of other things, including changed the module name to aiopulse2 so it does not conflict with aiopulse.

I have pushed further updates to the module.

I have also attempted an integration, which you can see here. To use, drop the automate folder into a custom_components folder in your config folder. Then make sure the latest aiopulse2 is in the python path for HA (this includes the HA core folder, if you put it in there, it’ll work, but site-packages is more correct).

This however is not working as intended, in that the WiFi signal strength is not grouped with the actual cover device (the battery is sometimes - not in the code pushed at the moment). There are still a bunch of print statements in the code etc. I have spent way more time on the HA integration than the actual code to speak with the hub, so if anyone here has half a clue how HA hangs together, I have posted my issues here. It’s really close, but HA is lacking any actual documentation (that I can find) as to how to integrate a cover device with additional “sensor” devices (I have tried looking at a bunch of other integrations, but they all do it slightly differently, and I have been unable to actually find a combination that works for me - very frustrating! Any help appreciated!)

1 Like

It’s working as expected now :slight_smile:

I have now released the aiopulse2 package to PyPI, so the process to install the (maybe final?) integration is to:

  • Download this repo.
  • Copy the automate folder to your config/custom_components folder in HA.
  • Restart HA

You will then be able to add the Automate Pulse Hub v2 integration via the HA UI (Configuration > Integrations > + ). Just enter the IP address of your Pulse v2 Hub, and you should be away :slight_smile:

Again, if someone can give this a go on their setup that would be great.

If any one is up for following the above steps and making sure it working with their setup, that would be appreciated. Please let me know how you go as I only have 1 hub etc, it will be good to get some more testing. I can then start the process to get it integrated into HA. I still have a lot of clean up to do (documentation etc), but the core code is there, and should now be pretty stable.

It currently does not work with devices that have tilt as I’m not sure of the keys it will use (I can guess, but best to see some real data). So if someone does have a device that has tilt, it would be great if we can work to capture some traffic (or I can put together a test script) to round this out (otherwise I’ll submit it with just up and down support).

I had a crack, installed it on my setup and it works quite well. Only have DC rollers, so can’t test any other devices but I’ll give it a go over the next week or so and see how it goes.

1 Like

Great news! Thanks for the update.

I have a PR in for this as well in HA core here: https://github.com/home-assistant/core/pull/39501

Hopefully it’ll make it in the next release.

Incredible work @sillyfrog, thank you so much for doing this!
It works great for me with a single hub and blinds.
Have set it up to adjust blind position based on sun azimuth/elevation.