mjg59 / python-broadlink

Python module for controlling Broadlink RM2/3 (Pro) remote controls, A1 sensor platforms and SP2/3 smartplugs
MIT License
1.38k stars 478 forks source link

Add Support for S3 Hub 0xa59c #647

Closed brianyulke closed 2 years ago

brianyulke commented 2 years ago

Please add support for the S3 hub - 0xa59c. This hub accompanies the Smart Light Switch TC3 (in my case, the TC3-US-1).

stevendodd commented 2 years ago

+1 - I have the LC1 lights; is there anyway I can help I have basic python skills

stevendodd commented 2 years ago

+1 - I have the LC1 lights; is there anyway I can help I have basic python skills

Python 3.6.0 (v3.6.0:41df79263a11, Dec 22 2016, 17:23:13) 
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import broadlink
>>> device = broadlink.hello('192.168.1.99')
>>> device.auth()
True
>>> device.get_type()
'S3'
>>> device.get_state()
{'pwr': 0, 'indicator': 1, 'maxworktime': 0, 'childlock': 0}

Where get_state() is more or less a copy of what is in light.py; I'm at a loss now as there is no documentation from broadlink and I have no idea how to toggle sub devices or even list them.. This document gives me some idea, but nothing in terms of how to send the data to the hub https://docs-ibroadlink-com.translate.goog/public/appsdk_en/appsdk_05/?_x_tr_sl=zh-CN&_x_tr_tl=en&_x_tr_hl=en&_x_tr_pto=sc

stevendodd commented 2 years ago

Here is the implementation hub.py - the sub device ids (did) can be found in the app listed as MAC addresses

>>> import broadlink
>>> device = broadlink.hello('192.168.1.99')
>>> device.auth()
True
>>> device.print_payload()
{'did': '00000000000000000000a043b0d0783a'}
{'pwr1': 0, 'pwr2': 0, 'plugmode': 0, 'hb_timeout': 180, 'lb_online1': 1}
>>> device.set_state("00000000000000000000a043b0d0783a",1)
{'pwr1': 1, 'pwr2': 0, 'plugmode': 0, 'hb_timeout': 180, 'lb_online1': 1}
>>> device.set_state("00000000000000000000a043b0d0783a",0,1)
{'pwr1': 0, 'pwr2': 1, 'plugmode': 0, 'hb_timeout': 180, 'lb_online1': 1}
>>> device.set_state("00000000000000000000a043b0d0783a",None,1)
{'pwr1': 0, 'pwr2': 1, 'plugmode': 0, 'hb_timeout': 180, 'lb_online1': 1}
"""Support for hubs."""
import struct
import enum
import json

from . import exceptions as e
from .device import Device

class s3(Device):
    """Controls a Broadlink S3."""

    TYPE = "S3"

    def print_payload(self) -> None:
        hex_string = "80958b19c3cdbbf4e44a07285a1f64a860dda677568d5369134ebef0345244d55dd827fdf7227d6424c73159bc311d0a336eec48a57830d4ccec3ebd06c176a8"  
        payload = self.decrypt(bytearray.fromhex(hex_string))
        js_len = struct.unpack_from("<I", payload, 0x08)[0]
        print(json.loads(payload[0x0C : 0x0C + js_len]))

        hex_string = "1dd713e6bf12fee9a7e98a29148b1f4ba569e3106ead7fb38214fa66584a3c4e98669e027ba94df43b8015719ee1b0feb8d83f7d14b28170357094e283024bc68eddfceca43e6376e5ad3a713ad04714"
        payload = self.decrypt(bytearray.fromhex(hex_string))
        js_len = struct.unpack_from("<I", payload, 0x08)[0]
        print(json.loads(payload[0x0C : 0x0C + js_len]))

    def set_state(
        self,
        did: str = None,
        pwr1: bool = None,
        pwr2: bool = None,
        pwr3: bool = None,
    ) -> dict:
        """Set the power state of the device."""
        state = {}
        if did is not None:
            state["did"] = did
        if pwr1 is not None:
            state["pwr1"] = int(bool(pwr1))
        if pwr2 is not None:
            state["pwr2"] = int(bool(pwr2))
        if pwr3 is not None:
            state["pwr1"] = int(bool(pwr3))

        packet = self._encode(2, state)
        response = self.send_packet(0x6A, packet)
        e.check_error(response[0x22:0x24])
        return self._decode(response)

    def _encode(self, flag: int, state: dict) -> bytes:
        """Encode a JSON packet."""
        # flag: 1 for reading, 2 for writing.
        packet = bytearray(12)
        data = json.dumps(state, separators=(",", ":")).encode()
        struct.pack_into("<HHHBBI", packet, 0, 0xA5A5, 0x5A5A, 0, flag, 0x0B, len(data))
        packet.extend(data)
        checksum = sum(packet, 0xBEAF) & 0xFFFF
        packet[0x04:0x06] = checksum.to_bytes(2, "little")
        return packet

    def _decode(self, response: bytes) -> dict:
        """Decode a JSON packet."""
        payload = self.decrypt(response[0x38:])
        js_len = struct.unpack_from("<I", payload, 0x08)[0]
        state = json.loads(payload[0x0C : 0x0C + js_len])
        return state
stevendodd commented 2 years ago

https://hub.docker.com/r/stevendodd/s3-rest-api

dj-fiorex commented 2 years ago

Hello guys, i tried the fork of stevendodd, i also added all the files needed to turn this to a Home assistant addons, and it work well. I can connect to my Broadlink S3 Hub and get the actual state of the connected button Broadlink SR3-4KEY. device.get_state() return a json object with the latest state that the connected button sent last time a button was clicked; so for example if button2 was clicked then state = {...parameter, 'button1':0,'button2':1,'button3':0,'button4':0 } until another button will be clicked. So my quesion is how can i listen to button click on the connected button? i'm a software engineer so i can write the code my self and help this library.

stevendodd commented 2 years ago

Can you please share the output from device.get_state(did)

dj-fiorex commented 2 years ago

Yes of course

{
"datatype": "fw_snapshot_v1",
"heartbeat": 0,
"lb_online1": 1,
"lowbattery": 0,
"scenarioswitch_1": 0,
"scenarioswitch_2": 1,
"scenarioswitch_3": 0,
"scenarioswitch_4": 0
}

this is a sample when the last button clicked was 2

stevendodd commented 2 years ago

Does it stay like this until another button is pressed? What did you actually get working in home assistant have you also got light switches or other sub devices?

stevendodd commented 2 years ago

And if you press button two again does it turn it off?

dj-fiorex commented 2 years ago

Oh yes sorry, so

i started from the example addon on the HA github repo and then integrate it with your docker file, so i can run the s3 rest api server directly inside the addon (i use HA OS)

stevendodd commented 2 years ago

Does that mean that if you press button to for a second time the scenario that you've hooked up in the broadlink app doesn't play for the second time?

stevendodd commented 2 years ago

There must be some way to reset the button. I could extend my code so that you can turn the buttons on and off from the python libraries and the rest API. Then your logic would be something like if button state equals 1 do something; then set button state equal to 0

stevendodd commented 2 years ago

Could you also post the output from when you initialised the hub on the first request to the rest API

stevendodd commented 2 years ago

The documentation from broadlink as always is pretty weak; I suspect you have not got any routines or triggers set up in the app and the button stays pressed. If you triggered a function in the app I believe the button will probably be reset to 0 after the function has triggered. Could you please test this theory for me.

dj-fiorex commented 2 years ago

I already tested this, in fact i added push notification on the app settings, so if i click button 1 i will receive a notification on the phone. But after the notification the json response is the same

this is the sr3-4key if you have't see one before https://ae01.alicdn.com/kf/Hac952d828193462fbbefbef7df11b83fB/Broadlink-SR3-4-Key-Smart-Button-Switch-Wireless-Works-With-Alexa-Google-Home-IFTTT-Need-S3.jpg_640x640.jpg

stevendodd commented 2 years ago

If you press button one twice in a row do you get a second notification?

stevendodd commented 2 years ago

What happens if you press button one walk away for 5-minutes and then press button one again do you get two notifications

dj-fiorex commented 2 years ago

Hello steven, so I made a video that show you that every time i click a button i will receive a notification on the phone, https://we.tl/t-jsGE0Wdjlr

stevendodd commented 2 years ago

Sorry to be a pain with all the questions, but once you click the 'I know it' on the notification can you check the json again to see if the button state is reset to 0. If it is not then the switch is sending a message to the hub rather than the hub polling the switch.. As I said above I can update the Python libraries to try and set the button state on or off however it may not trigger a routine in the broadlink app. Let's try it and see... I'll update the code in a bit and let you know

stevendodd commented 2 years ago

Could you also post the output from when you initialised the hub on the first request to the rest API

dj-fiorex commented 2 years ago

No, don't worry for the questions, we are trying to help each other, like i said before the button state not reset to 0. I used Wireshark to see the comunication of the hub, and every time i click the button, the S3 sends an udp message to the broadlink server i think.

this is the output of homeassistant.local:5000/?hub=192.168.1.74 [{'did': '00000000000000000000ec0bae371ee1', 'pid': '00000000000000000000000048650000', 'name': '', 'offline': 0}]

stevendodd commented 2 years ago

Your switch must send a response to the hub when the button is pushed, the app can then monitor some sort of state on the hub in order to trigger a routine.

Try this: https://hub.docker.com/r/stevendodd/s3-rest-switch-api you should be able to set the state of the buttons on your switch with a POST to: homeassistant.local:5000/00000000000000000000ec0bae371ee1/1 for example. The post body needs to contain - {"active": "true"} to turn the button on or {"active": "false"}' for off.

I would be interested to know if you are able to turn the button on/off and when you turn it on is the routine triggered

dj-fiorex commented 2 years ago

Just tested this, but not with your premade docker image, but with your repo, i made a POST request with raw body

{
    "active":true
}

but i got a network timeout for no response

Traceback (most recent call last):
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 2073, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1518, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1516, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1502, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args)
  File "/test/s3_rest.py", line 105, in dynamic
    return handle_request(request, did, int(gang))
  File "/test/s3_rest.py", line 38, in handle_request
    return create_resp(device.set_state(did, pwr[1], pwr[2], pwr[3]), gang)
  File "/test/broadlink/hub.py", line 66, in set_state
    response = self.send_packet(0x6A, packet)
  File "/test/broadlink/device.py", line 309, in send_packet
    raise e.NetworkTimeoutError(
broadlink.exceptions.NetworkTimeoutError: [Errno -4000] Network timeout: No response received within 10s

I think that a device like the sr3-4key doesn't have the capability to turn on or off, i mean this is something that does not have sense on a button. we need to know how to intercept the call that the hub does to the broadlink backend

stevendodd commented 2 years ago

If you try with my Docker alternate image and then use a switch from home assistant to send the post data - let's see

stevendodd commented 2 years ago

The alternate image has new code to support your switch

    def set_state(
        self,
        did: str = None,
        pwr1: bool = None,
        pwr2: bool = None,
        pwr3: bool = None,
        pwr4: bool = None,
    ) -> dict:
        """Set the power state of the device."""
        state = {}
        if did is not None:
            state["did"] = did
        if pwr1 is not None:
            state["pwr1"] = int(bool(pwr1))
            state["scenarioswitch_1"] = int(bool(pwr1))
        if pwr2 is not None:
            state["pwr2"] = int(bool(pwr2))
            state["scenarioswitch_2"] = int(bool(pwr2))
        if pwr3 is not None:
            state["pwr3"] = int(bool(pwr3))
            state["scenarioswitch_3"] = int(bool(pwr3))
        if pwr4 is not None:
            state["scenarioswitch_4"] = int(bool(pwr4))

        packet = self._encode(2, state)
        response = self.send_packet(0x6A, packet)
        e.check_error(response[0x22:0x24])
        return self._decode(response)
dj-fiorex commented 2 years ago

just tried it but nothing changed, same error:

  File "/test/broadlink/device.py", line 309, in send_packet
    raise e.NetworkTimeoutError(
broadlink.exceptions.NetworkTimeoutError: [Errno -4000] Network timeout: No response received within 10s

this is the json that i send (i put a print on your code):

{'did': '00000000000000000000ec0bae371ee1', 'pwr2': 0, 'scenarioswitch_2': 0} 
stevendodd commented 2 years ago

Does the get state method it still work?

dj-fiorex commented 2 years ago

Now a GET request to homeassistant.local:5000/00000000000000000000ec0bae371ee1 give me this json response: { "status": 0 } same for GET on homeassistant.local:5000/00000000000000000000ec0bae371ee1/1

i tried clicked a button and the response change back to the "original" one:

{
"datatype": "fw_snapshot_v1",
"heartbeat": 0,
"lb_online1": 1,
"lowbattery": 0,
"scenarioswitch_1": 1,
"scenarioswitch_2": 0,
"scenarioswitch_3": 0,
"scenarioswitch_4": 0
}

I want to emphasize that the sr3-4key is not a switch, it is a portable button, so it is no sense to set state on a stateless button, we need, in my opinion, focus on the hub that receive the signal from the button

stevendodd commented 2 years ago

Can you reproduce the same failure; the fact that it keeps state means it acts like a switch.

The only way to further is to listen to packets from the hub to/from your phone https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=en_GB&gl=US

dj-fiorex commented 2 years ago

i checked with Wireshark and i can say that the hub and my phone didn't comunicate one to each other, the notification system go through the broadlink server. i tried with a router with no internet and when i click the button no notification is sent to my phone until i came back online and then all notifications comes

dj-fiorex commented 2 years ago

Can you reproduce the same failure; the fact that it keeps state means it acts like a switch.

no i disagree on this, i think that because of the power management the hub take a snapshot of the state of the button and respond with this, and the button work only when a button is clicked The fact here is to preserve battery life, so when a button is clicked -> send the state to the hub -> go to sleep

stevendodd commented 2 years ago

its not all UDP - this is how I figured out the LC1 wall light switches; I also thought the servers were involved with the local comms; a lot of the app state is on the servers; rather than relying on the notification routine is it possible to see just a button press in the app?

Screenshot (Feb 1, 2022 7_41_23 PM)

stevendodd commented 2 years ago

Screenshot (Feb 10, 2022 6:44:53 PM)

dj-fiorex commented 2 years ago

no i can't have a notification without setting an app notification photo_2022-02-10_21-18-01

stevendodd commented 2 years ago

Well there's not much more I can do at the moment I'm sorry, I am fairly sure that your button does not update the hub and it is in fact your app that is polling the button via the hub somehow. The only way to figure it out will be to intercept network traffic as you have been doing and then decoding the packets as per the protocol specified in this repository. I have looked through the packet captures that allowed me to reverse engineer the LC one switch and all of the traffic between the phone and hub was via UDP; the only TCP traffic was between the application and the broadlink servers

stevendodd commented 2 years ago

If you look above those 256 B UDP responses was each of the sub devices getting queried from my application via the hub. It does it constantly in the background

dj-fiorex commented 2 years ago

just tried with your app, here it is! packets 1 2 3

This is the packets sniffed when i click the button!

stevendodd commented 2 years ago

Should be able to find the Mac addresses of the hub in reverse order in those hex dumps fairly easily

dj-fiorex commented 2 years ago

Please can you help me? I have never done anything like this I can find the mac address of the hub thanks to my router, i don't undestand what you mean

stevendodd commented 2 years ago

Yeah I'll give it a go tomorrow and see if I can decode the first request / response

stevendodd commented 2 years ago

In the broadlink app when you click on device info could you tell me what device has the Mac address EC b0 ae 38 FC 83 it will either be the hub or the smart button

dj-fiorex commented 2 years ago

Here is the info: HUB photo_2022-02-10_22-32-41

SR3-4KEY photo_2022-02-10_22-32-47

stevendodd commented 2 years ago

Now a GET request to homeassistant.local:5000/00000000000000000000ec0bae371ee1 give me this json response: { "status": 0 } same for GET on homeassistant.local:5000/00000000000000000000ec0bae371ee1/1

i tried clicked a button and the response change back to the "original" one:

{
"datatype": "fw_snapshot_v1",
"heartbeat": 0,
"lb_online1": 1,
"lowbattery": 0,
"scenarioswitch_1": 1,
"scenarioswitch_2": 0,
"scenarioswitch_3": 0,
"scenarioswitch_4": 0
}

I want to emphasize that the sr3-4key is not a switch, it is a portable button, so it is no sense to set state on a stateless button, we need, in my opinion, focus on the hub that receive the signal from the button

It looks like just from the traffic pattern above your app is broadcasting without a specific address and then your hub is responding to the broadcast when the button is pressed. Looking at your quote above perhaps we also need to set the heartbeat parameter; but first I would love you to try:

  1. Press button one
  2. Connect to and authenticate to your hub via the python library
  3. Run the get state method
  4. turn on packet capture and then run
set_state(did,0,1,0,0)
  1. Run get state method again
  2. Paste python output here for all of the above
  3. Confirm if you saw anything similar with the packet capture to what you saw last night when you press the button
dj-fiorex commented 2 years ago

Ok so this is my test: Starting from all disconnected Put battery on the buttons Connect the hub to the electrical socket Connect to the hub -> http://homeassistant.local:5000/?hub=192.168.1.74 -> [{'did': '00000000000000000000ec0bae371ee1', 'pid': '00000000000000000000000048650000', 'name': '', 'offline': 0}] Run the get state method -> http://homeassistant.local:5000/00000000000000000000ec0bae371ee1 -> {"status": 0} Press button one Run the get state method -> http://homeassistant.local:5000/00000000000000000000ec0bae371ee1 ->

{
"datatype": "fw_snapshot_v1",
"heartbeat": 0,
"lb_online1": 1,
"lowbattery": 0,
"scenarioswitch_1": 1,
"scenarioswitch_2": 0,
"scenarioswitch_3": 0,
"scenarioswitch_4": 0
}

turn on packet capture run set state -> POST http://homeassistant.local:5000/00000000000000000000ec0bae371ee1/2 con {"active":"true"} -> payload to the hub {'did': '00000000000000000000ec0bae371ee1', 'scenarioswitch_3': 1} with no packet recorded and same python error:

Traceback (most recent call last):
  File "/test/broadlink/device.py", line 305, in send_packet
    resp = conn.recvfrom(2048)[0]
socket.timeout: timed out
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 2073, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1518, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1516, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/lib/python3.9/site-packages/flask/app.py", line 1502, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args)
  File "/test/s3_rest.py", line 107, in dynamic
    return handle_request(request, did, int(gang))
  File "/test/s3_rest.py", line 40, in handle_request
    return create_resp(device.set_state(did, pwr[0], pwr[1], pwr[2], pwr[3]), gang)
  File "/test/broadlink/hub.py", line 74, in set_state
    response = self.send_packet(0x6A, packet)
  File "/test/broadlink/device.py", line 309, in send_packet
    raise e.NetworkTimeoutError(
broadlink.exceptions.NetworkTimeoutError: [Errno -4000] Network timeout: No response received within 10s

run get state -> {"status": 0}

Edit: Tried with a simple python script, no different, set_state give NetworkTimeoutError, no response received within 10s

import broadlink

buttonsDid = "00000000000000000000ec0bae371ee1"

def main():
    device = broadlink.hello('192.168.1.74')
    device.auth()
    subD = device.get_subdevices()
    print(subD)
    state = device.get_state(buttonsDid)
    print(state)
    device.set_state(buttonsDid, 0, 1, 0, 0)

if __name__ == "__main__":
    main()
stevendodd commented 2 years ago

Thank you for doing that, I will look at decoding those packets for you, of interest it is not the heartbeat either.. https://docs.ibroadlink.com/public/configuration-sdk+ctc/openproxy/

dj-fiorex commented 2 years ago

if you can link me a guide i will try too, 4 eyes is better than 2 👍

stevendodd commented 2 years ago

It's all in the protocol.md file in this repository

dj-fiorex commented 2 years ago

yes, i saw protocol.md, but what type of messages could that be? command type?

stevendodd commented 2 years ago

My working theory is that the hub send a broadcast request and the button responds like what you captured above. The hub then potentially updates the broadlink servers directly and executes any routines that have been configured.

Out of interest could you please let me know what your intention is in terms of using the library? Do you want to poll the button for a button press?

All of the code I have written so far is command type however as we have tested and seen above that doesn't work and so I think it will be a discovery/broadcast message with a response from the button potentially via the hub. The library could be extended with a listen function that periodically sends out the broadcast message and does something if it gets a response.

stevendodd commented 2 years ago

If you look in device.py it does a broadcast message when trying to discover devices on the network