├── .gitattributes ├── .gitignore ├── BlackBeanControl.ini ├── BlackBeanControl.py ├── README.md ├── README_blackbeancontrol.md ├── README_broadlink.md ├── Settings.py ├── broadlink.py ├── protocol_broadlink.md ├── requirements.txt └── test_run.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask instance folder 57 | instance/ 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # IPython Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # dotenv 78 | .env 79 | 80 | # virtualenv 81 | venv/ 82 | ENV/ 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # ========================= 91 | # Operating System Files 92 | # ========================= 93 | 94 | # OSX 95 | # ========================= 96 | 97 | .DS_Store 98 | .AppleDouble 99 | .LSOverride 100 | 101 | # Thumbnails 102 | ._* 103 | 104 | # Files that might appear in the root of a volume 105 | .DocumentRevisions-V100 106 | .fseventsd 107 | .Spotlight-V100 108 | .TemporaryItems 109 | .Trashes 110 | .VolumeIcon.icns 111 | 112 | # Directories potentially created on remote AFP share 113 | .AppleDB 114 | .AppleDesktop 115 | Network Trash Folder 116 | Temporary Items 117 | .apdisk 118 | 119 | # Windows 120 | # ========================= 121 | 122 | # Windows image file caches 123 | Thumbs.db 124 | ehthumbs.db 125 | 126 | # Folder config file 127 | Desktop.ini 128 | 129 | # Recycle Bin used on file shares 130 | $RECYCLE.BIN/ 131 | 132 | # Windows Installer files 133 | *.cab 134 | *.msi 135 | *.msm 136 | *.msp 137 | 138 | # Windows shortcuts 139 | *.lnk 140 | 141 | #PyCharm config dir 142 | .idea 143 | .vscode/ 144 | -------------------------------------------------------------------------------- /BlackBeanControl.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | IPAddress = 3 | Port = 80 4 | MACAddress = 5 | Timeout = 3 6 | 7 | [Commands] 8 | -------------------------------------------------------------------------------- /BlackBeanControl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import broadlink, configparser 4 | import sys, getopt 5 | import time, binascii 6 | import netaddr 7 | import Settings 8 | import re 9 | from os import path 10 | from Crypto.Cipher import AES 11 | 12 | SettingsFile = configparser.ConfigParser() 13 | SettingsFile.optionxform = str 14 | SettingsFile.read(Settings.BlackBeanControlSettings) 15 | 16 | def execute_command( 17 | SentCommand, 18 | DeviceName='', 19 | ReKeyCommand=False, 20 | AlternativeIPAddress='', 21 | AlternativePort='', 22 | AlternativeMACAddress='', 23 | AlternativeTimeout='' 24 | ): 25 | if SentCommand == '': 26 | print('Command name parameter is mandatory') 27 | return 2 28 | 29 | if SentCommand == 'DISCOVER': 30 | print('Scanning network for Broadlink devices ... ') 31 | 32 | mydevices = broadlink.discover(timeout=5) 33 | print(('Found ' + str(len(mydevices )) + ' broadlink device(s)')) 34 | time.sleep(1) 35 | for index, item in enumerate(mydevices): 36 | mydevices[index].auth() 37 | 38 | m = re.match(r"\('([0-9.]+)', ([0-9]+)", str(mydevices[index].host)) 39 | ipadd = m.group(1) 40 | port = m.group(2) 41 | 42 | macadd = str(''.join(format(x, '02x') for x in mydevices[index].mac[::-1])) 43 | macadd = macadd[:2] + ":" + macadd[2:4] + ":" + macadd[4:6] + ":" + macadd[6:8] + ":" + macadd[8:10] + ":" + macadd[10:12] 44 | 45 | print(('Device ' + str(index + 1) +':\nIPAddress = ' + ipadd + '\nPort = ' + port + '\nMACAddress = ' + macadd)) 46 | return 47 | 48 | if (DeviceName != '') and ( 49 | (AlternativeIPAddress != '') or 50 | (AlternativePort != '') or 51 | (AlternativeMACAddress != '') or 52 | (AlternativeTimeout != '') 53 | ): 54 | print('Device name parameter can not be used in conjunction with IP Address/Port/MAC Address/Timeout parameters') 55 | return 2 56 | 57 | if ( 58 | ( 59 | (AlternativeIPAddress != '') or 60 | (AlternativePort != '') or 61 | (AlternativeMACAddress != '') or 62 | (AlternativeTimeout != '') 63 | ) and ( 64 | (AlternativeIPAddress == '') or 65 | (AlternativePort == '') or 66 | (AlternativeMACAddress == '') or 67 | (AlternativeTimeout == '') 68 | ) 69 | ): 70 | print('IP Address, Port, MAC Address and Timeout parameters can not be used separately') 71 | return 2 72 | 73 | if DeviceName != '': 74 | if SettingsFile.has_section(DeviceName): 75 | if SettingsFile.has_option(DeviceName, 'IPAddress'): 76 | DeviceIPAddress = SettingsFile.get(DeviceName, 'IPAddress') 77 | else: 78 | DeviceIPAddress = '' 79 | 80 | if SettingsFile.has_option(DeviceName, 'Port'): 81 | DevicePort = SettingsFile.get(DeviceName, 'Port') 82 | else: 83 | DevicePort = '' 84 | 85 | if SettingsFile.has_option(DeviceName, 'MACAddress'): 86 | DeviceMACAddress = SettingsFile.get(DeviceName, 'MACAddress') 87 | else: 88 | DeviceMACAddress = '' 89 | 90 | if SettingsFile.has_option(DeviceName, 'Timeout'): 91 | DeviceTimeout = SettingsFile.get(DeviceName, 'Timeout') 92 | else: 93 | DeviceTimeout = '' 94 | else: 95 | print('Device does not exist in BlackBeanControl.ini') 96 | return 2 97 | 98 | if (DeviceName != '') and (DeviceIPAddress == ''): 99 | print('IP address must exist in BlackBeanControl.ini for the selected device') 100 | return 2 101 | 102 | if (DeviceName != '') and (DevicePort == ''): 103 | print('Port must exist in BlackBeanControl.ini for the selected device') 104 | return 2 105 | 106 | if (DeviceName != '') and (DeviceMACAddress == ''): 107 | print('MAC address must exist in BlackBeanControl.ini for the selected device') 108 | return 2 109 | 110 | if (DeviceName != '') and (DeviceTimeout == ''): 111 | print('Timeout must exist in BlackBeanControl.ini for the selected device') 112 | return 2 113 | 114 | if DeviceName != '': 115 | RealIPAddress = DeviceIPAddress 116 | elif AlternativeIPAddress != '': 117 | RealIPAddress = AlternativeIPAddress 118 | else: 119 | RealIPAddress = Settings.IPAddress 120 | 121 | if RealIPAddress == '': 122 | print('IP address must exist in BlackBeanControl.ini or it should be entered as a command line parameter') 123 | return 2 124 | 125 | if DeviceName != '': 126 | RealPort = DevicePort 127 | elif AlternativePort != '': 128 | RealPort = AlternativePort 129 | else: 130 | RealPort = Settings.Port 131 | 132 | if RealPort == '': 133 | print('Port must exist in BlackBeanControl.ini or it should be entered as a command line parameter') 134 | return 2 135 | else: 136 | RealPort = int(RealPort) 137 | 138 | if DeviceName != '': 139 | RealMACAddress = DeviceMACAddress 140 | elif AlternativeMACAddress != '': 141 | RealMACAddress = AlternativeMACAddress 142 | else: 143 | RealMACAddress = Settings.MACAddress 144 | 145 | if RealMACAddress == '': 146 | print('MAC address must exist in BlackBeanControl.ini or it should be entered as a command line parameter') 147 | return 2 148 | else: 149 | RealMACAddress = netaddr.EUI(RealMACAddress) 150 | 151 | if DeviceName != '': 152 | RealTimeout = DeviceTimeout 153 | elif AlternativeTimeout != '': 154 | RealTimeout = AlternativeTimeout 155 | else: 156 | RealTimeout = Settings.Timeout 157 | 158 | if RealTimeout == '': 159 | print('Timeout must exist in BlackBeanControl.ini or it should be entered as a command line parameter') 160 | return 2 161 | else: 162 | RealTimeout = int(RealTimeout) 163 | 164 | RM3Device = broadlink.rm((RealIPAddress, RealPort), RealMACAddress) 165 | RM3Device.auth() 166 | 167 | if ReKeyCommand: 168 | if SettingsFile.has_option('Commands', SentCommand): 169 | CommandFromSettings = SettingsFile.get('Commands', SentCommand) 170 | 171 | if CommandFromSettings[0:4] != '2600': 172 | RM3Key = RM3Device.key 173 | RM3IV = RM3Device.iv 174 | 175 | DecodedCommand = binascii.unhexlify(CommandFromSettings) 176 | AESEncryption = AES.new(str(RM3Key), AES.MODE_CBC, str(RM3IV)) 177 | EncodedCommand = AESEncryption.encrypt(str(DecodedCommand)) 178 | FinalCommand = EncodedCommand[0x04:] 179 | EncodedCommand = binascii.hexlify(FinalCommand).decode("ascii") 180 | 181 | BlackBeanControlIniFile = open(path.join(Settings.ApplicationDir, 'BlackBeanControl.ini'), 'w') 182 | SettingsFile.set('Commands', SentCommand, EncodedCommand) 183 | SettingsFile.write(BlackBeanControlIniFile) 184 | BlackBeanControlIniFile.close() 185 | sys.exit() 186 | else: 187 | print("Command appears to already be re-keyed.") 188 | return 2 189 | else: 190 | print("Command not found in ini file for re-keying.") 191 | return 2 192 | 193 | 194 | if SettingsFile.has_option('Commands', SentCommand): 195 | CommandFromSettings = SettingsFile.get('Commands', SentCommand) 196 | else: 197 | CommandFromSettings = '' 198 | 199 | if CommandFromSettings != '': 200 | DecodedCommand = binascii.unhexlify(CommandFromSettings) 201 | RM3Device.send_data(DecodedCommand) 202 | else: 203 | RM3Device.enter_learning() 204 | time.sleep(RealTimeout) 205 | LearnedCommand = RM3Device.check_data() 206 | 207 | if LearnedCommand is None: 208 | print('Command not received') 209 | sys.exit() 210 | 211 | print(LearnedCommand) 212 | # EncodedCommand = LearnedCommand.encode('hex') 213 | EncodedCommand = binascii.hexlify(LearnedCommand).decode("ascii") 214 | print(EncodedCommand) 215 | 216 | if EncodedCommand: 217 | BlackBeanControlIniFile = open(path.join(Settings.ApplicationDir, 'BlackBeanControl.ini'), 'w') 218 | SettingsFile.set('Commands', SentCommand, EncodedCommand) 219 | SettingsFile.write(BlackBeanControlIniFile) 220 | BlackBeanControlIniFile.close() 221 | print('Set command {0}'.format(SentCommand)) 222 | 223 | 224 | if __name__ == "__main__": 225 | SentCommand = '' 226 | ReKeyCommand = False 227 | DeviceName='' 228 | DeviceIPAddress = '' 229 | DevicePort = '' 230 | DeviceMACAddres = '' 231 | DeviceTimeout = '' 232 | AlternativeIPAddress = '' 233 | AlternativePort = '' 234 | AlternativeMACAddress = '' 235 | AlternativeTimeout = '' 236 | 237 | try: 238 | Options, args = getopt.getopt(sys.argv[1:], 'c:d:r:i:p:m:t:h', ['command=','device=','rekey=','ipaddress=','port=','macaddress=','timeout=','help']) 239 | except getopt.GetoptError: 240 | print('BlackBeanControl.py -c [-d ] [-i ] [-p ] [-m ] [-t ] [-r ]') 241 | sys.exit(2) 242 | 243 | for Option, Argument in Options: 244 | if Option in ('-h', '--help'): 245 | print('BlackBeanControl.py -c [-d ] [-i ] [-p ] [-m ] [-t [-r ]') 246 | sys.exit() 247 | elif Option in ('-c', '--command'): 248 | SentCommand = Argument.strip() 249 | elif Option in ('-d', '--device'): 250 | DeviceName = Argument.strip() 251 | elif Option in ('-r', '--rekey'): 252 | ReKeyCommand = True 253 | SentCommand = Argument.strip() 254 | elif Option in ('-i', '--ipaddress'): 255 | AlternativeIPAddress = Argument.strip() 256 | elif Option in ('-p', '--port'): 257 | AlternativePort = Argument.strip() 258 | elif Option in ('-m', '--macaddress'): 259 | AlternativeMACAddress = Argument.strip() 260 | elif Option in ('-t', '--timeout'): 261 | AlternativeTimeout = Argument 262 | 263 | execute_command( 264 | SentCommand, 265 | DeviceName=DeviceName, 266 | ReKeyCommand=ReKeyCommand, 267 | AlternativeIPAddress=AlternativeIPAddress, 268 | AlternativePort=AlternativePort, 269 | AlternativeMACAddress=AlternativeMACAddress, 270 | AlternativeTimeout=AlternativeTimeout 271 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BlackBeanControl - Broadlink RM 3 Mini (aka Black Bean) control script 2 | This repo use most of the code from 3 | https://github.com/davorf/BlackBeanControl and 4 | https://github.com/mjg59/python-broadlink 5 | Just put it together, add some enchance and update to support python3 6 | 7 | 8 | ## Setup 9 | ``` 10 | git clone https://github.com/TheGU/rm3_mini_controller.git 11 | cd rm3_mini_controller 12 | pip install -r requirements.txt 13 | python test_run.py 14 | ``` 15 | After call test run. The script will dicover RM3 in your network and display info about it. Then ask you to test input any remote key to rm3 and it will repeat signal to test read and send ir remote. 16 | 17 | Use the information you got from test_run to config BlackBeanControl.ini something like this: 18 | ``` 19 | [General] 20 | IPAddress = 192.168.0.1 21 | Port = 80 22 | MACAddress = AA:BB:CC:DD:EE:FF 23 | Timeout = 30 24 | ``` 25 | 26 | ## Usage 27 | For more detail command please see [README_blackbeancontrol.md](README_blackbeancontrol.md) 28 | #### Learn command run 29 | ``` 30 | python BlackBeanControl.py -c 31 | ``` 32 | After learned command. will appear in BlackBeanControl.ini file under [Command] section with learned ir code 33 | 34 | #### Send command 35 | ``` 36 | python BlackBeanControl.py -c 37 | ``` 38 | If exist in BlackBeanControl.ini, script will forward ir code for RM3 to broadcast. 39 | 40 | -------------------------------------------------------------------------------- /README_blackbeancontrol.md: -------------------------------------------------------------------------------- 1 | # BlackBeanControl - Broadlink RM 3 Mini (aka Black Bean) control script 2 | 3 | A simple Python 2 script, which uses python-broadlink package. It can be used for both, learning and sending IR commands 4 | 5 | ### Installation 6 | 7 | Before cloning/downloading the script, you should install all dependencies: 8 | 9 | Prerequisites for Linux users: 10 | 11 | - Install pip package: 12 | * wget https://bootstrap.pypa.io/get-pip.py 13 | * Run get-pip.py 14 | - Install python-dev package: apt-get install python-dev 15 | 16 | Prerequisites for Windows users: 17 | 18 | - Install Microsoft Visual C++ Compiler for Python 2.7 19 | * Download https://www.microsoft.com/en-us/download/details.aspx?id=44266 20 | * Run VCforPython27.msi 21 | 22 | Dependencies for Windows/Linux: 23 | 24 | - Install configparser package: python -m pip install configparser 25 | - Install netaddr package: python -m pip install netaddr 26 | - Install pycrypto package: python -m pip install pycrypto 27 | - Download python-broadlink package - you can find it on the github by the package name (github user: mjg59) 28 | - Unzip it to some local folder and install it: setup.py install 29 | 30 | Now you can clone/download BlackBeanControl (in case you download it as archive, unzip it to some local folder). 31 | 32 | ### Configuration 33 | 34 | All required configuration is held within BlackBeanControl.ini file. It consists of the following parameters: 35 | 36 | [General] 37 | - IPAddress - an IP address of RM 3 Mini (RM 3 Mini must have local IP address) 38 | - Port - a port used for UDP communication (in most cases, 80) 39 | - MACAddress - a MAC address of RM 3 Mini (should be in format: MM:MM:MM:SS:SS:SS) 40 | - Timeout - a time in seconds script should wait for an answer after starting a learn process (should be less then 60 seconds) 41 | 42 | [Commands] 43 | - This section should be populated by using the script, not manually 44 | 45 | Configuration file could optionally contain multiple device sections (with a custom names, must not contain any blanks). The device section must have all the parameters General section has. It allows user to control multiple RM 3 Minis without passing all the parameters separately (IP Address, Port, MAC Address and Timeout). Instead, only -d (--device) parameter should be passed, with a section name containing connection parameters for the specific device. 46 | 47 | #### Example of a custom device section: 48 | ``` 49 | [RM3LivingRoom] 50 | IPAddress = 192.168.0.1 51 | Port = 80 52 | MACAddress = AA:BB:CC:DD:EE:FF 53 | Timeout = 30 54 | ``` 55 | 56 | ### Syntax and usage 57 | ``` 58 | BlackBeanControl.py -c [-d ] [-i ] [-p ] [-m ] [-t ] [-r ] 59 | ``` 60 | 61 | Parameters explanation: 62 | - Command name - mandatory parameter. If the sript is called with a command name not contained in the configuration file (BlackBeanControl.ini), it will start a learning process. After putting RM 3 Mini in the learning state, IR command should be sent to RM 3 Mini (usually a button press on the remote control). When defined timout expires, captured IR command will be saved in the configuration file - in the [Commands] section. In case the script is called with a command name contained in the configuration file, it will send that command to RM 3 Mini. 63 | - Device name - optional parameter. If the script is called with Device name parameter, IP address, port, MAC address and timeout parameters found in the General section of the configuration file will be ignored, and a script will use parameters found in a device section of the configuration file. Device name parameter can not be used in conjunction with IP Address, Port, MAC Address and Timeout command line parameters. 64 | - IP Address - optional parameter. If the script is called with IP Address parameter, IP address found in the configuration file will be ignored, and a script will use IP address from this parameter. 65 | - Port - optional parameter. If the script is called with Port parameter, port found in the configuration file will be ignored, and a script will use port from this parameter. 66 | - MAC Address - optional parameter. If the script is called with MAC address parameter, MAC address found in the configuration file will be ignored, and a script will use MAC address from this parameter. 67 | - Timeout - optional parameter. If the script is called with Timeout parameter, Timeout found in the configuration file will be ignored, and a script will use Timeout from this parameter. 68 | - Re-Key - optional parameter. This will re-key existing IR data to a new format that does not use the device key for storage. If the data was stored previously with a specific Broadlink device that device name will need to be provided for re-keying by providing a device name using -d parameter. 69 | 70 | IP Address, Port, MAC Address and Timeout command line parameters can not be used separately. 71 | 72 | ### Donations 73 | 74 | This script is available for free under the GPL license. If you use the script, and would like to donate, feel free to send any amount through paypal. 75 | 76 | Note: Since standard Donate option is not available on my PayPal account this is a workaround solution. 77 | 78 | [![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=CCZRY3C8RXSRW&lc=BA&item_name=Donation%20%2d%20BlackBeanControl&item_number=1&button_subtype=services¤cy_code=EUR&bn=PP%2dBuyNowBF%3abtn_paynowCC_LG%2egif%3aNonHosted) 79 | 80 | ### License 81 | 82 | Software licensed under GPL version 3 available on http://www.gnu.org/licenses/gpl.txt. 83 | -------------------------------------------------------------------------------- /README_broadlink.md: -------------------------------------------------------------------------------- 1 | Python control for Broadlink RM2 IR controllers 2 | =============================================== 3 | 4 | A simple Python API for controlling IR controllers from [Broadlink](http://www.ibroadlink.com/rm/). At present, only RM Pro (referred to as RM2 in the codebase) and A1 sensor platform devices are supported. There is currently no support for the cloud API. 5 | 6 | Example use 7 | ----------- 8 | 9 | Setup a new device on your local wireless network: 10 | 11 | 1. Put the device into AP Mode 12 | 1. Long press the reset button until the blue LED is blinking quickly. 13 | 2. Long press again until blue LED is blinking slowly. 14 | 3. Manually connect to the WiFi SSID named BroadlinkProv. 15 | 2. Run setup() and provide your ssid, network password (if secured), and set the security mode 16 | 1. Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) 17 | ``` 18 | import broadlink 19 | 20 | broadlink.setup('myssid', 'mynetworkpass', 3) 21 | ``` 22 | 23 | Discover available devices on the local network: 24 | ``` 25 | import broadlink 26 | 27 | devices = broadlink.discover(timeout=5) 28 | ``` 29 | 30 | Obtain the authentication key required for further communication: 31 | ``` 32 | devices[0].auth() 33 | ``` 34 | 35 | Enter learning mode: 36 | ``` 37 | devices[0].enter_learning() 38 | ``` 39 | 40 | Obtain an IR or RF packet while in learning mode: 41 | ``` 42 | ir_packet = devices[0].check_data() 43 | ``` 44 | (This will return None if the device does not have a packet to return) 45 | 46 | Send an IR or RF packet: 47 | ``` 48 | devices[0].send_data(ir_packet) 49 | ``` 50 | 51 | Obtain temperature data from an RM2: 52 | ``` 53 | devices[0].check_temperature() 54 | ``` 55 | 56 | Obtain sensor data from an A1: 57 | ``` 58 | data = devices[0].check_sensors() 59 | ``` 60 | 61 | Set power state on a SmartPlug SP2/SP3: 62 | ``` 63 | devices[0].set_power(True) 64 | ``` 65 | 66 | Check power state on a SmartPlug: 67 | ``` 68 | state = devices[0].check_power() 69 | ``` 70 | 71 | Set power state for S1 on a SmartPowerStrip MP1: 72 | ``` 73 | devices[0].set_power(1, True) 74 | ``` 75 | 76 | Check power state on a SmartPowerStrip: 77 | ``` 78 | state = devices[0].check_power() 79 | ``` -------------------------------------------------------------------------------- /Settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from os import path 3 | 4 | ApplicationDir = path.dirname(path.abspath(__file__)) 5 | BlackBeanControlSettings = path.join(ApplicationDir, 'BlackBeanControl.ini') 6 | 7 | Settings = configparser.ConfigParser() 8 | Settings.read(BlackBeanControlSettings) 9 | 10 | IPAddress = Settings.get('General', 'IPAddress') 11 | Port = Settings.get('General', 'Port') 12 | MACAddress = Settings.get('General', 'MACAddress') 13 | Timeout = Settings.get('General', 'Timeout') 14 | -------------------------------------------------------------------------------- /broadlink.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from datetime import datetime 4 | try: 5 | from Crypto.Cipher import AES 6 | except ImportError as e: 7 | import pyaes 8 | 9 | import time 10 | import random 11 | import socket 12 | import sys 13 | import threading 14 | 15 | def gendevice(devtype, host, mac): 16 | if devtype == 0: # SP1 17 | return sp1(host=host, mac=mac) 18 | if devtype == 0x2711: # SP2 19 | return sp2(host=host, mac=mac) 20 | if devtype == 0x2719 or devtype == 0x7919 or devtype == 0x271a or devtype == 0x791a: # Honeywell SP2 21 | return sp2(host=host, mac=mac) 22 | if devtype == 0x2720: # SPMini 23 | return sp2(host=host, mac=mac) 24 | elif devtype == 0x753e: # SP3 25 | return sp2(host=host, mac=mac) 26 | elif devtype == 0x2728: # SPMini2 27 | return sp2(host=host, mac=mac) 28 | elif devtype == 0x2733 or devtype == 0x273e: # OEM branded SPMini 29 | return sp2(host=host, mac=mac) 30 | elif devtype >= 0x7530 and devtype <= 0x7918: # OEM branded SPMini2 31 | return sp2(host=host, mac=mac) 32 | elif devtype == 0x2736: # SPMiniPlus 33 | return sp2(host=host, mac=mac) 34 | elif devtype == 0x2712: # RM2 35 | return rm(host=host, mac=mac) 36 | elif devtype == 0x2737: # RM Mini 37 | return rm(host=host, mac=mac) 38 | elif devtype == 0x273d: # RM Pro Phicomm 39 | return rm(host=host, mac=mac) 40 | elif devtype == 0x2783: # RM2 Home Plus 41 | return rm(host=host, mac=mac) 42 | elif devtype == 0x277c: # RM2 Home Plus GDT 43 | return rm(host=host, mac=mac) 44 | elif devtype == 0x272a: # RM2 Pro Plus 45 | return rm(host=host, mac=mac) 46 | elif devtype == 0x2787: # RM2 Pro Plus2 47 | return rm(host=host, mac=mac) 48 | elif devtype == 0x278b: # RM2 Pro Plus BL 49 | return rm(host=host, mac=mac) 50 | elif devtype == 0x278f: # RM Mini Shate 51 | return rm(host=host, mac=mac) 52 | elif devtype == 0x2714: # A1 53 | return a1(host=host, mac=mac) 54 | elif devtype == 0x4EB5: # MP1 55 | return mp1(host=host, mac=mac) 56 | else: 57 | return device(host=host, mac=mac) 58 | 59 | def discover(timeout=None, local_ip_address=None): 60 | if local_ip_address is None: 61 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 62 | s.connect(('8.8.8.8', 53)) # connecting to a UDP address doesn't send packets 63 | local_ip_address = s.getsockname()[0] 64 | address = local_ip_address.split('.') 65 | cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 66 | cs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 67 | cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 68 | cs.bind((local_ip_address,0)) 69 | port = cs.getsockname()[1] 70 | starttime = time.time() 71 | 72 | devices = [] 73 | 74 | timezone = int(time.timezone/-3600) 75 | packet = bytearray(0x30) 76 | 77 | year = datetime.now().year 78 | 79 | if timezone < 0: 80 | packet[0x08] = 0xff + timezone - 1 81 | packet[0x09] = 0xff 82 | packet[0x0a] = 0xff 83 | packet[0x0b] = 0xff 84 | else: 85 | packet[0x08] = timezone 86 | packet[0x09] = 0 87 | packet[0x0a] = 0 88 | packet[0x0b] = 0 89 | packet[0x0c] = year & 0xff 90 | packet[0x0d] = year >> 8 91 | packet[0x0e] = datetime.now().minute 92 | packet[0x0f] = datetime.now().hour 93 | subyear = str(year)[2:] 94 | packet[0x10] = int(subyear) 95 | packet[0x11] = datetime.now().isoweekday() 96 | packet[0x12] = datetime.now().day 97 | packet[0x13] = datetime.now().month 98 | packet[0x18] = int(address[0]) 99 | packet[0x19] = int(address[1]) 100 | packet[0x1a] = int(address[2]) 101 | packet[0x1b] = int(address[3]) 102 | packet[0x1c] = port & 0xff 103 | packet[0x1d] = port >> 8 104 | packet[0x26] = 6 105 | checksum = 0xbeaf 106 | 107 | for i in range(len(packet)): 108 | checksum += packet[i] 109 | checksum = checksum & 0xffff 110 | packet[0x20] = checksum & 0xff 111 | packet[0x21] = checksum >> 8 112 | 113 | cs.sendto(packet, ('255.255.255.255', 80)) 114 | if timeout is None: 115 | response = cs.recvfrom(1024) 116 | responsepacket = bytearray(response[0]) 117 | host = response[1] 118 | mac = responsepacket[0x3a:0x40] 119 | devtype = responsepacket[0x34] | responsepacket[0x35] << 8 120 | return gendevice(devtype, host, mac) 121 | else: 122 | while (time.time() - starttime) < timeout: 123 | cs.settimeout(timeout - (time.time() - starttime)) 124 | try: 125 | response = cs.recvfrom(1024) 126 | except socket.timeout: 127 | return devices 128 | responsepacket = bytearray(response[0]) 129 | host = response[1] 130 | devtype = responsepacket[0x34] | responsepacket[0x35] << 8 131 | mac = responsepacket[0x3a:0x40] 132 | dev = gendevice(devtype, host, mac) 133 | devices.append(dev) 134 | return devices 135 | 136 | 137 | class device: 138 | def __init__(self, host, mac, timeout=10): 139 | self.host = host 140 | self.mac = mac 141 | self.timeout = timeout 142 | self.count = random.randrange(0xffff) 143 | self.key = bytearray([0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02]) 144 | self.iv = bytearray([0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58]) 145 | self.id = bytearray([0, 0, 0, 0]) 146 | self.cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 147 | self.cs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 148 | self.cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 149 | self.cs.bind(('',0)) 150 | self.type = "Unknown" 151 | self.lock = threading.Lock() 152 | 153 | if 'pyaes' in sys.modules: 154 | self.encrypt = self.encrypt_pyaes 155 | self.decrypt = self.decrypt_pyaes 156 | else: 157 | self.encrypt = self.encrypt_pycrypto 158 | self.decrypt = self.decrypt_pycrypto 159 | 160 | def encrypt_pyaes(self, payload): 161 | aes = pyaes.AESModeOfOperationCBC(self.key, iv = bytes(self.iv)) 162 | return "".join([aes.encrypt(bytes(payload[i:i+16])) for i in range(0, len(payload), 16)]) 163 | 164 | def decrypt_pyaes(self, payload): 165 | aes = pyaes.AESModeOfOperationCBC(self.key, iv = bytes(self.iv)) 166 | return "".join([aes.decrypt(bytes(payload[i:i+16])) for i in range(0, len(payload), 16)]) 167 | 168 | def encrypt_pycrypto(self, payload): 169 | aes = AES.new(bytes(self.key), AES.MODE_CBC, bytes(self.iv)) 170 | return aes.encrypt(bytes(payload)) 171 | 172 | def decrypt_pycrypto(self, payload): 173 | aes = AES.new(bytes(self.key), AES.MODE_CBC, bytes(self.iv)) 174 | return aes.decrypt(bytes(payload)) 175 | 176 | def auth(self): 177 | payload = bytearray(0x50) 178 | payload[0x04] = 0x31 179 | payload[0x05] = 0x31 180 | payload[0x06] = 0x31 181 | payload[0x07] = 0x31 182 | payload[0x08] = 0x31 183 | payload[0x09] = 0x31 184 | payload[0x0a] = 0x31 185 | payload[0x0b] = 0x31 186 | payload[0x0c] = 0x31 187 | payload[0x0d] = 0x31 188 | payload[0x0e] = 0x31 189 | payload[0x0f] = 0x31 190 | payload[0x10] = 0x31 191 | payload[0x11] = 0x31 192 | payload[0x12] = 0x31 193 | payload[0x1e] = 0x01 194 | payload[0x2d] = 0x01 195 | payload[0x30] = ord('T') 196 | payload[0x31] = ord('e') 197 | payload[0x32] = ord('s') 198 | payload[0x33] = ord('t') 199 | payload[0x34] = ord(' ') 200 | payload[0x35] = ord(' ') 201 | payload[0x36] = ord('1') 202 | 203 | response = self.send_packet(0x65, payload) 204 | 205 | payload = self.decrypt(response[0x38:]) 206 | 207 | if not payload: 208 | return False 209 | 210 | key = payload[0x04:0x14] 211 | if len(key) % 16 != 0: 212 | return False 213 | 214 | self.id = payload[0x00:0x04] 215 | self.key = key 216 | return True 217 | 218 | def get_type(self): 219 | return self.type 220 | 221 | def send_packet(self, command, payload): 222 | self.count = (self.count + 1) & 0xffff 223 | packet = bytearray(0x38) 224 | packet[0x00] = 0x5a 225 | packet[0x01] = 0xa5 226 | packet[0x02] = 0xaa 227 | packet[0x03] = 0x55 228 | packet[0x04] = 0x5a 229 | packet[0x05] = 0xa5 230 | packet[0x06] = 0xaa 231 | packet[0x07] = 0x55 232 | packet[0x24] = 0x2a 233 | packet[0x25] = 0x27 234 | packet[0x26] = command 235 | packet[0x28] = self.count & 0xff 236 | packet[0x29] = self.count >> 8 237 | packet[0x2a] = self.mac[0] 238 | packet[0x2b] = self.mac[1] 239 | packet[0x2c] = self.mac[2] 240 | packet[0x2d] = self.mac[3] 241 | packet[0x2e] = self.mac[4] 242 | packet[0x2f] = self.mac[5] 243 | packet[0x30] = self.id[0] 244 | packet[0x31] = self.id[1] 245 | packet[0x32] = self.id[2] 246 | packet[0x33] = self.id[3] 247 | 248 | # pad the payload for AES encryption 249 | if len(payload)>0: 250 | numpad=(len(payload)//16+1)*16 251 | payload=payload.ljust(numpad,b"\x00") 252 | 253 | checksum = 0xbeaf 254 | for i in range(len(payload)): 255 | checksum += payload[i] 256 | checksum = checksum & 0xffff 257 | 258 | payload = self.encrypt(payload) 259 | 260 | packet[0x34] = checksum & 0xff 261 | packet[0x35] = checksum >> 8 262 | 263 | for i in range(len(payload)): 264 | packet.append(payload[i]) 265 | 266 | checksum = 0xbeaf 267 | for i in range(len(packet)): 268 | checksum += packet[i] 269 | checksum = checksum & 0xffff 270 | packet[0x20] = checksum & 0xff 271 | packet[0x21] = checksum >> 8 272 | 273 | starttime = time.time() 274 | with self.lock: 275 | while True: 276 | try: 277 | self.cs.sendto(packet, self.host) 278 | self.cs.settimeout(1) 279 | response = self.cs.recvfrom(1024) 280 | break 281 | except socket.timeout: 282 | if (time.time() - starttime) > self.timeout: 283 | raise 284 | return bytearray(response[0]) 285 | 286 | 287 | class mp1(device): 288 | def __init__ (self, host, mac): 289 | device.__init__(self, host, mac) 290 | self.type = "MP1" 291 | 292 | def set_power_mask(self, sid_mask, state): 293 | """Sets the power state of the smart power strip.""" 294 | 295 | packet = bytearray(16) 296 | packet[0x00] = 0x0d 297 | packet[0x02] = 0xa5 298 | packet[0x03] = 0xa5 299 | packet[0x04] = 0x5a 300 | packet[0x05] = 0x5a 301 | packet[0x06] = 0xb2 + ((sid_mask<<1) if state else sid_mask) 302 | packet[0x07] = 0xc0 303 | packet[0x08] = 0x02 304 | packet[0x0a] = 0x03 305 | packet[0x0d] = sid_mask 306 | packet[0x0e] = sid_mask if state else 0 307 | 308 | response = self.send_packet(0x6a, packet) 309 | 310 | err = response[0x22] | (response[0x23] << 8) 311 | 312 | def set_power(self, sid, state): 313 | """Sets the power state of the smart power strip.""" 314 | sid_mask = 0x01 << (sid - 1) 315 | return self.set_power_mask(sid_mask, state) 316 | 317 | def check_power_raw(self): 318 | """Returns the power state of the smart power strip in raw format.""" 319 | packet = bytearray(16) 320 | packet[0x00] = 0x0a 321 | packet[0x02] = 0xa5 322 | packet[0x03] = 0xa5 323 | packet[0x04] = 0x5a 324 | packet[0x05] = 0x5a 325 | packet[0x06] = 0xae 326 | packet[0x07] = 0xc0 327 | packet[0x08] = 0x01 328 | 329 | response = self.send_packet(0x6a, packet) 330 | err = response[0x22] | (response[0x23] << 8) 331 | if err == 0: 332 | payload = self.decrypt(bytes(response[0x38:])) 333 | if type(payload[0x4]) == int: 334 | state = payload[0x0e] 335 | else: 336 | state = ord(payload[0x0e]) 337 | return state 338 | 339 | def check_power(self): 340 | """Returns the power state of the smart power strip.""" 341 | state = self.check_power_raw() 342 | data = {} 343 | data['s1'] = bool(state & 0x01) 344 | data['s2'] = bool(state & 0x02) 345 | data['s3'] = bool(state & 0x04) 346 | data['s4'] = bool(state & 0x08) 347 | return data 348 | 349 | 350 | class sp1(device): 351 | def __init__ (self, host, mac): 352 | device.__init__(self, host, mac) 353 | self.type = "SP1" 354 | 355 | def set_power(self, state): 356 | packet = bytearray(4) 357 | packet[0] = state 358 | self.send_packet(0x66, packet) 359 | 360 | 361 | class sp2(device): 362 | def __init__ (self, host, mac): 363 | device.__init__(self, host, mac) 364 | self.type = "SP2" 365 | 366 | def set_power(self, state): 367 | """Sets the power state of the smart plug.""" 368 | packet = bytearray(16) 369 | packet[0] = 2 370 | packet[4] = 1 if state else 0 371 | self.send_packet(0x6a, packet) 372 | 373 | def check_power(self): 374 | """Returns the power state of the smart plug.""" 375 | packet = bytearray(16) 376 | packet[0] = 1 377 | response = self.send_packet(0x6a, packet) 378 | err = response[0x22] | (response[0x23] << 8) 379 | if err == 0: 380 | payload = self.decrypt(bytes(response[0x38:])) 381 | if type(payload[0x4]) == int: 382 | state = bool(payload[0x4]) 383 | else: 384 | state = bool(ord(payload[0x4])) 385 | return state 386 | 387 | class a1(device): 388 | def __init__ (self, host, mac): 389 | device.__init__(self, host, mac) 390 | self.type = "A1" 391 | 392 | def check_sensors(self): 393 | packet = bytearray(16) 394 | packet[0] = 1 395 | response = self.send_packet(0x6a, packet) 396 | err = response[0x22] | (response[0x23] << 8) 397 | if err == 0: 398 | data = {} 399 | payload = self.decrypt(bytes(response[0x38:])) 400 | if type(payload[0x4]) == int: 401 | data['temperature'] = (payload[0x4] * 10 + payload[0x5]) / 10.0 402 | data['humidity'] = (payload[0x6] * 10 + payload[0x7]) / 10.0 403 | light = payload[0x8] 404 | air_quality = payload[0x0a] 405 | noise = payload[0xc] 406 | else: 407 | data['temperature'] = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 408 | data['humidity'] = (ord(payload[0x6]) * 10 + ord(payload[0x7])) / 10.0 409 | light = ord(payload[0x8]) 410 | air_quality = ord(payload[0x0a]) 411 | noise = ord(payload[0xc]) 412 | if light == 0: 413 | data['light'] = 'dark' 414 | elif light == 1: 415 | data['light'] = 'dim' 416 | elif light == 2: 417 | data['light'] = 'normal' 418 | elif light == 3: 419 | data['light'] = 'bright' 420 | else: 421 | data['light'] = 'unknown' 422 | if air_quality == 0: 423 | data['air_quality'] = 'excellent' 424 | elif air_quality == 1: 425 | data['air_quality'] = 'good' 426 | elif air_quality == 2: 427 | data['air_quality'] = 'normal' 428 | elif air_quality == 3: 429 | data['air_quality'] = 'bad' 430 | else: 431 | data['air_quality'] = 'unknown' 432 | if noise == 0: 433 | data['noise'] = 'quiet' 434 | elif noise == 1: 435 | data['noise'] = 'normal' 436 | elif noise == 2: 437 | data['noise'] = 'noisy' 438 | else: 439 | data['noise'] = 'unknown' 440 | return data 441 | 442 | def check_sensors_raw(self): 443 | packet = bytearray(16) 444 | packet[0] = 1 445 | response = self.send_packet(0x6a, packet) 446 | err = response[0x22] | (response[0x23] << 8) 447 | if err == 0: 448 | data = {} 449 | payload = self.decrypt(bytes(response[0x38:])) 450 | if type(payload[0x4]) == int: 451 | data['temperature'] = (payload[0x4] * 10 + payload[0x5]) / 10.0 452 | data['humidity'] = (payload[0x6] * 10 + payload[0x7]) / 10.0 453 | data['light'] = payload[0x8] 454 | data['air_quality'] = payload[0x0a] 455 | data['noise'] = payload[0xc] 456 | else: 457 | data['temperature'] = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 458 | data['humidity'] = (ord(payload[0x6]) * 10 + ord(payload[0x7])) / 10.0 459 | data['light'] = ord(payload[0x8]) 460 | data['air_quality'] = ord(payload[0x0a]) 461 | data['noise'] = ord(payload[0xc]) 462 | return data 463 | 464 | 465 | class rm(device): 466 | def __init__ (self, host, mac): 467 | device.__init__(self, host, mac) 468 | self.type = "RM2" 469 | 470 | def check_data(self): 471 | packet = bytearray(16) 472 | packet[0] = 4 473 | response = self.send_packet(0x6a, packet) 474 | err = response[0x22] | (response[0x23] << 8) 475 | if err == 0: 476 | payload = self.decrypt(bytes(response[0x38:])) 477 | return payload[0x04:] 478 | 479 | def send_data(self, data): 480 | packet = bytearray([0x02, 0x00, 0x00, 0x00]) 481 | packet += data 482 | self.send_packet(0x6a, packet) 483 | 484 | def enter_learning(self): 485 | packet = bytearray(16) 486 | packet[0] = 3 487 | self.send_packet(0x6a, packet) 488 | 489 | def check_temperature(self): 490 | packet = bytearray(16) 491 | packet[0] = 1 492 | response = self.send_packet(0x6a, packet) 493 | err = response[0x22] | (response[0x23] << 8) 494 | if err == 0: 495 | payload = self.decrypt(bytes(response[0x38:])) 496 | if type(payload[0x4]) == int: 497 | temp = (payload[0x4] * 10 + payload[0x5]) / 10.0 498 | else: 499 | temp = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 500 | return temp 501 | 502 | # For legay compatibility - don't use this 503 | class rm2(rm): 504 | def __init__ (self): 505 | device.__init__(self, None, None) 506 | 507 | def discover(self): 508 | dev = discover() 509 | self.host = dev.host 510 | self.mac = dev.mac 511 | 512 | # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode. 513 | # Only tested with Broadlink RM3 Mini (Blackbean) 514 | def setup(ssid, password, security_mode): 515 | # Security mode options are (0 - none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) 516 | payload = bytearray(0x88) 517 | payload[0x26] = 0x14 # This seems to always be set to 14 518 | # Add the SSID to the payload 519 | ssid_start = 68 520 | ssid_length = 0 521 | for letter in ssid: 522 | payload[(ssid_start + ssid_length)] = ord(letter) 523 | ssid_length += 1 524 | # Add the WiFi password to the payload 525 | pass_start = 100 526 | pass_length = 0 527 | for letter in password: 528 | payload[(pass_start + pass_length)] = ord(letter) 529 | pass_length += 1 530 | 531 | payload[0x84] = ssid_length # Character length of SSID 532 | payload[0x85] = pass_length # Character length of password 533 | payload[0x86] = security_mode # Type of encryption (00 - none, 01 = WEP, 02 = WPA1, 03 = WPA2, 04 = WPA1/2) 534 | 535 | checksum = 0xbeaf 536 | for i in range(len(payload)): 537 | checksum += payload[i] 538 | checksum = checksum & 0xffff 539 | 540 | payload[0x20] = checksum & 0xff # Checksum 1 position 541 | payload[0x21] = checksum >> 8 # Checksum 2 position 542 | 543 | sock = socket.socket(socket.AF_INET, # Internet 544 | socket.SOCK_DGRAM) # UDP 545 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 546 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 547 | sock.sendto(payload, ('255.255.255.255', 80)) 548 | -------------------------------------------------------------------------------- /protocol_broadlink.md: -------------------------------------------------------------------------------- 1 | Broadlink RM2 network protocol 2 | ============================== 3 | 4 | Encryption 5 | ---------- 6 | 7 | Packets include AES-based encryption in CBC mode. The initial key is 0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02. The IV is 0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58. 8 | 9 | Checksum 10 | -------- 11 | 12 | Construct the packet and set checksum bytes to zero. Add each byte to the starting value of 0xbeaf, wrapping after 0xffff. 13 | 14 | New device setup 15 | ---------------- 16 | 17 | To setup a new Broadlink device while in AP Mode a 136 byte packet needs to be sent to the device as follows: 18 | 19 | | Offset | Contents | 20 | |---------|----------| 21 | |0x00-0x19|00| 22 | |0x20-0x21|Checksum as a little-endian 16 bit integer| 23 | |0x26|14 (Always 14)| 24 | |0x44-0x63|SSID Name (zero padding is appended)| 25 | |0x64-0x83|Password (zero padding is appended)| 26 | |0x84|Character length of SSID| 27 | |0x85|Character length of password| 28 | |0x86|Wireless security mode (00 - none, 01 = WEP, 02 = WPA1, 03 = WPA2, 04 = WPA1/2)| 29 | |0x87-88|00| 30 | 31 | Send this packet as a UDP broadcast to 255.255.255.255 on port 80. 32 | 33 | Network discovery 34 | ----------------- 35 | 36 | To discover Broadlink devices on the local network, send a 48 byte packet with the following contents: 37 | 38 | | Offset | Contents | 39 | |---------|----------| 40 | |0x00-0x07|00| 41 | |0x08-0x0b|Current offset from GMT as a little-endian 32 bit integer| 42 | |0x0c-0x0d|Current year as a little-endian 16 bit integer| 43 | |0x0e|Current number of minutes past the hour| 44 | |0x0f|Current number of hours past midnight| 45 | |0x10|Current number of years past the century| 46 | |0x11|Current day of the week (Monday = 0, Tuesday = 1, etc)| 47 | |0x12|Current day in month| 48 | |0x13|Current month| 49 | |0x19-0x1b|Local IP address| 50 | |0x1c-0x1d|Source port as a little-endian 16 bit integer| 51 | |0x1e-0x1f|00| 52 | |0x20-0x21|Checksum as a little-endian 16 bit integer| 53 | |0x22-0x25|00| 54 | |0x26|06| 55 | |0x27-0x2f|00| 56 | 57 | Send this packet as a UDP broadcast to 255.255.255.255 on port 80. 58 | 59 | Response (any unicast response): 60 | 61 | | Offset | Contents | 62 | |---------|----------| 63 | |0x34-0x35|Device type as a little-endian 16 bit integer (see device type mapping)| 64 | |0x3a-0x40|MAC address of the target device| 65 | 66 | Device type mapping: 67 | 68 | | Device type in response packet | Device type | Treat as | 69 | |---------|----------|----------| 70 | |0|SP1|SP1| 71 | |0x2711|SP2|SP2| 72 | |0x2719 or 0x7919 or 0x271a or 0x791a|Honeywell SP2|SP2| 73 | |0x2720|SPMini|SP2| 74 | |0x753e|SP3|SP2| 75 | |0x2728|SPMini2|SP2 76 | |0x2733 or 0x273e|OEM branded SPMini|SP2| 77 | |>= 0x7530 and <= 0x7918|OEM branded SPMini2|SP2| 78 | |0x2736|SPMiniPlus|SP2| 79 | |0x2712|RM2|RM| 80 | |0x2737|RM Mini / RM3 Mini Blackbean|RM| 81 | |0x273d|RM Pro Phicomm|RM| 82 | |0x2783|RM2 Home Plus|RM| 83 | |0x277c|RM2 Home Plus GDT|RM| 84 | |0x272a|RM2 Pro Plus|RM| 85 | |0x2787|RM2 Pro Plus2|RM| 86 | |0x278b|RM2 Pro Plus BL|RM| 87 | |0x278f|RM Mini Shate|RM| 88 | |0x2714|A1|A1| 89 | |0x4EB5|MP1|MP1| 90 | 91 | 92 | Command packet format 93 | --------------------- 94 | 95 | The command packet header is 56 bytes long with the following format: 96 | 97 | |Offset|Contents| 98 | |------|--------| 99 | |0x00|0x5a| 100 | |0x01|0xa5| 101 | |0x02|0xaa| 102 | |0x03|0x55| 103 | |0x04|0x5a| 104 | |0x05|0xa5| 105 | |0x06|0xaa| 106 | |0x07|0x55| 107 | |0x08-0x1f|00| 108 | |0x20-0x21|Checksum of full packet as a little-endian 16 bit integer| 109 | |0x22-0x23|00| 110 | |0x24|0x2a| 111 | |0x25|0x27| 112 | |0x26-0x27|Command code as a little-endian 16 bit integer| 113 | |0x28-0x29|Packet count as a little-endian 16 bit integer| 114 | |0x2a-0x2f|Local MAC address| 115 | |0x30-0x33|Local device ID (obtained during authentication, 00 before authentication)| 116 | |0x34-0x35|Checksum of packet header as a little-endian 16 bit integer 117 | |0x36-0x37|00| 118 | 119 | The payload is appended immediately after this. The checksum at 0x34 is calculated *before* the payload is appended, and covers only the header. The checksum at 0x20 is calculated *after* the payload is appended, and covers the entire packet (including the checksum at 0x34). Therefore: 120 | 121 | 1. Generate packet header with checksum values set to 0 122 | 2. Set the checksum initialisation value to 0xbeaf and calculate the checksum of the packet header. Set 0x34-0x35 to this value. 123 | 3. Append the payload 124 | 4. Set the checksum initialisation value to 0xbeaf and calculate the checksum of the entire packet. Set 0x20-0x21 to this value. 125 | 126 | Authorisation 127 | ------------- 128 | 129 | You must obtain an authorisation key from the device before you can communicate. To do so, generate an 80 byte packet with the following contents: 130 | 131 | |Offset|Contents| 132 | |------|--------| 133 | |0x00-0x03|00| 134 | |0x04-0x12|A 15-digit value that represents this device. Broadlink's implementation uses the IMEI.| 135 | |0x13|01| 136 | |0x14-0x2c|00| 137 | |0x2d|0x01| 138 | |0x30-0x7f|NULL-terminated ASCII string containing the device name| 139 | 140 | Send this payload with a command value of 0x0065. The response packet will contain an encrypted payload from byte 0x38 onwards. Decrypt this using the default key and IV. The format of the decrypted payload is: 141 | 142 | |Offset|Contents| 143 | |------|--------| 144 | |0x00-0x03|Device ID| 145 | |0x04-0x13|Device encryption key| 146 | 147 | All further command packets must use this encryption key and device ID. 148 | 149 | Entering learning mode 150 | ---------------------- 151 | 152 | Send the following 16 byte payload with a command value of 0x006a: 153 | 154 | |Offset|Contents| 155 | |------|--------| 156 | |0x00|0x03| 157 | |0x01-0x0f|0x00| 158 | 159 | Reading back data from learning mode 160 | ------------------------------------ 161 | 162 | Send the following 16 byte payload with a command value of 0x006a: 163 | 164 | |Offset|Contents| 165 | |------|--------| 166 | |0x00|0x04| 167 | |0x01-0x0f|0x00| 168 | 169 | Byte 0x22 of the response contains a little-endian 16 bit error code. If this is 0, a code has been obtained. Bytes 0x38 and onward of the response are encrypted. Decrypt them. Bytes 0x04 and onward of the decrypted payload contain the captured data. 170 | 171 | Sending data 172 | ------------ 173 | 174 | Send the following payload with a command byte of 0x006a 175 | 176 | |Offset|Contents| 177 | |------|--------| 178 | |0x00|0x02| 179 | |0x01-0x03|0x00| 180 | |0x04|0x26 = IR, 0xb2 for RF 433Mhz, 0xd7 for RF 315Mhz| 181 | |0x05|repeat count, (0 = no repeat, 1 send twice, .....)| 182 | |0x06-0x07|Length of the following data in little endian| 183 | |0x08 ....|Pulse lengths in 32,84ms units (ms * 269 / 8192 works very well)| 184 | |....|0x0d 0x05 at the end for IR only| 185 | 186 | Each value is represented by one byte. If the length exceeds one byte 187 | then it is stored big endian with a leading 0. 188 | 189 | Example: The header for my Optoma projector is 8920 4450 190 | 8920 * 269 / 8192 = 0x124 191 | 4450 * 269 / 8192 = 0x92 192 | 193 | So the data starts with `0x00 0x1 0x24 0x92 ....` 194 | 195 | 196 | Todo 197 | ---- 198 | 199 | * Support for other devices using the Broadlink protocol (various smart home devices) 200 | * Figure out what the format of the data packets actually is. 201 | * Deal with the response after AP Mode WiFi network setup. 202 | 203 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | configparser==3.5.0 2 | netaddr==0.7.19 3 | pycryptodome==3.19.1 4 | -------------------------------------------------------------------------------- /test_run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import broadlink 4 | import time 5 | import re 6 | import binascii 7 | 8 | print('Scanning network for Broadlink devices (5s timeout) ... ') 9 | devices = broadlink.discover(timeout=5) 10 | print(('Found ' + str(len(devices )) + ' broadlink device(s)')) 11 | time.sleep(1) 12 | for index, item in enumerate(devices): 13 | devices[index].auth() 14 | m = re.match(r"\('([0-9.]+)', ([0-9]+)", str(devices[index].host)) 15 | ipadd = m.group(1) 16 | port = m.group(2) 17 | macadd = str(''.join(format(x, '02x') for x in devices[index].mac[::-1])) 18 | macadd = macadd[:2] + ":" + macadd[2:4] + ":" + macadd[4:6] + ":" + macadd[6:8] + ":" + macadd[8:10] + ":" + macadd[10:12] 19 | print(('Device ' + str(index + 1) +':\nIPAddress = ' + ipadd + '\nPort = ' + port + '\nMACAddress = ' + macadd)) 20 | print("enter_learning (5s timeout) please press any key on remote to test") 21 | devices[0].enter_learning() 22 | time.sleep(5) 23 | print("Check data") 24 | ir_packet = devices[0].check_data() 25 | if ir_packet: 26 | decode_command = binascii.hexlify(ir_packet).decode("ascii") 27 | print(decode_command) 28 | encode_command = binascii.unhexlify(decode_command) 29 | print("Test resend") 30 | devices[0].send_data(encode_command) 31 | else: 32 | print("RM3 not receive any remote command") --------------------------------------------------------------------------------