├── .gitignore ├── LICENSE ├── README.md ├── broadlink └── __init__.py ├── cli ├── README.md ├── broadlink_cli └── broadlink_discovery ├── protocol.md ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mike Ryan 4 | Copyright (c) 2016 Matthew Garrett 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.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 | ``` -------------------------------------------------------------------------------- /broadlink/__init__.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 | import codecs 15 | 16 | def gendevice(devtype, host, mac): 17 | if devtype == 0: # SP1 18 | return sp1(host=host, mac=mac, devtype=devtype) 19 | elif devtype == 0x2711: # SP2 20 | return sp2(host=host, mac=mac, devtype=devtype) 21 | elif devtype == 0x2719 or devtype == 0x7919 or devtype == 0x271a or devtype == 0x791a: # Honeywell SP2 22 | return sp2(host=host, mac=mac, devtype=devtype) 23 | elif devtype == 0x2720: # SPMini 24 | return sp2(host=host, mac=mac, devtype=devtype) 25 | elif devtype == 0x753e: # SP3 26 | return sp2(host=host, mac=mac, devtype=devtype) 27 | elif devtype == 0x947a or devtype == 0x9479: # SP3S 28 | return sp2(host=host, mac=mac, devtype=devtype) 29 | elif devtype == 0x2728: # SPMini2 30 | return sp2(host=host, mac=mac, devtype=devtype) 31 | elif devtype == 0x2733 or devtype == 0x273e: # OEM branded SPMini 32 | return sp2(host=host, mac=mac, devtype=devtype) 33 | elif devtype >= 0x7530 and devtype <= 0x7918: # OEM branded SPMini2 34 | return sp2(host=host, mac=mac, devtype=devtype) 35 | elif devtype == 0x2736: # SPMiniPlus 36 | return sp2(host=host, mac=mac, devtype=devtype) 37 | elif devtype == 0x2712: # RM2 38 | return rm(host=host, mac=mac, devtype=devtype) 39 | elif devtype == 0x2737: # RM Mini 40 | return rm(host=host, mac=mac, devtype=devtype) 41 | elif devtype == 0x273d: # RM Pro Phicomm 42 | return rm(host=host, mac=mac, devtype=devtype) 43 | elif devtype == 0x2783: # RM2 Home Plus 44 | return rm(host=host, mac=mac, devtype=devtype) 45 | elif devtype == 0x277c: # RM2 Home Plus GDT 46 | return rm(host=host, mac=mac, devtype=devtype) 47 | elif devtype == 0x272a: # RM2 Pro Plus 48 | return rm(host=host, mac=mac, devtype=devtype) 49 | elif devtype == 0x2787: # RM2 Pro Plus2 50 | return rm(host=host, mac=mac, devtype=devtype) 51 | elif devtype == 0x279d: # RM2 Pro Plus3 52 | return rm(host=host, mac=mac, devtype=devtype) 53 | elif devtype == 0x27a9: # RM2 Pro Plus_300 54 | return rm(host=host, mac=mac, devtype=devtype) 55 | elif devtype == 0x278b: # RM2 Pro Plus BL 56 | return rm(host=host, mac=mac, devtype=devtype) 57 | elif devtype == 0x2797: # RM2 Pro Plus HYC 58 | return rm(host=host, mac=mac, devtype=devtype) 59 | elif devtype == 0x27a1: # RM2 Pro Plus R1 60 | return rm(host=host, mac=mac, devtype=devtype) 61 | elif devtype == 0x27a6: # RM2 Pro PP 62 | return rm(host=host, mac=mac, devtype=devtype) 63 | elif devtype == 0x278f: # RM Mini Shate 64 | return rm(host=host, mac=mac, devtype=devtype) 65 | elif devtype == 0x2714: # A1 66 | return a1(host=host, mac=mac, devtype=devtype) 67 | elif devtype == 0x4EB5 or devtype == 0x4EF7: # MP1: 0x4eb5, honyar oem mp1: 0x4ef7 68 | return mp1(host=host, mac=mac, devtype=devtype) 69 | elif devtype == 0x4EAD: # Hysen controller 70 | return hysen(host=host, mac=mac) 71 | elif devtype == 0x2722: # S1 (SmartOne Alarm Kit) 72 | return S1C(host=host, mac=mac, devtype=devtype) 73 | elif devtype == 0x4E4D: # Dooya DT360E (DOOYA_CURTAIN_V2) 74 | return dooya(host=host, mac=mac, devtype=devtype) 75 | else: 76 | return device(host=host, mac=mac, devtype=devtype) 77 | 78 | def discover(timeout=None, local_ip_address=None): 79 | if local_ip_address is None: 80 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 81 | s.connect(('8.8.8.8', 53)) # connecting to a UDP address doesn't send packets 82 | local_ip_address = s.getsockname()[0] 83 | address = local_ip_address.split('.') 84 | cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 85 | cs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 86 | cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 87 | cs.bind((local_ip_address,0)) 88 | port = cs.getsockname()[1] 89 | starttime = time.time() 90 | 91 | devices = [] 92 | 93 | timezone = int(time.timezone/-3600) 94 | packet = bytearray(0x30) 95 | 96 | year = datetime.now().year 97 | 98 | if timezone < 0: 99 | packet[0x08] = 0xff + timezone - 1 100 | packet[0x09] = 0xff 101 | packet[0x0a] = 0xff 102 | packet[0x0b] = 0xff 103 | else: 104 | packet[0x08] = timezone 105 | packet[0x09] = 0 106 | packet[0x0a] = 0 107 | packet[0x0b] = 0 108 | packet[0x0c] = year & 0xff 109 | packet[0x0d] = year >> 8 110 | packet[0x0e] = datetime.now().minute 111 | packet[0x0f] = datetime.now().hour 112 | subyear = str(year)[2:] 113 | packet[0x10] = int(subyear) 114 | packet[0x11] = datetime.now().isoweekday() 115 | packet[0x12] = datetime.now().day 116 | packet[0x13] = datetime.now().month 117 | packet[0x18] = int(address[0]) 118 | packet[0x19] = int(address[1]) 119 | packet[0x1a] = int(address[2]) 120 | packet[0x1b] = int(address[3]) 121 | packet[0x1c] = port & 0xff 122 | packet[0x1d] = port >> 8 123 | packet[0x26] = 6 124 | checksum = 0xbeaf 125 | 126 | for i in range(len(packet)): 127 | checksum += packet[i] 128 | checksum = checksum & 0xffff 129 | packet[0x20] = checksum & 0xff 130 | packet[0x21] = checksum >> 8 131 | 132 | cs.sendto(packet, ('255.255.255.255', 80)) 133 | if timeout is None: 134 | response = cs.recvfrom(1024) 135 | responsepacket = bytearray(response[0]) 136 | host = response[1] 137 | mac = responsepacket[0x3a:0x40] 138 | devtype = responsepacket[0x34] | responsepacket[0x35] << 8 139 | 140 | 141 | return gendevice(devtype, host, mac) 142 | else: 143 | while (time.time() - starttime) < timeout: 144 | cs.settimeout(timeout - (time.time() - starttime)) 145 | try: 146 | response = cs.recvfrom(1024) 147 | except socket.timeout: 148 | return devices 149 | responsepacket = bytearray(response[0]) 150 | host = response[1] 151 | devtype = responsepacket[0x34] | responsepacket[0x35] << 8 152 | mac = responsepacket[0x3a:0x40] 153 | dev = gendevice(devtype, host, mac) 154 | devices.append(dev) 155 | return devices 156 | 157 | 158 | 159 | class device: 160 | def __init__(self, host, mac, devtype, timeout=10): 161 | self.host = host 162 | self.mac = mac 163 | self.devtype = devtype 164 | self.timeout = timeout 165 | self.count = random.randrange(0xffff) 166 | self.key = bytearray([0x09, 0x76, 0x28, 0x34, 0x3f, 0xe9, 0x9e, 0x23, 0x76, 0x5c, 0x15, 0x13, 0xac, 0xcf, 0x8b, 0x02]) 167 | self.iv = bytearray([0x56, 0x2e, 0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58]) 168 | self.id = bytearray([0, 0, 0, 0]) 169 | self.cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 170 | self.cs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 171 | self.cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 172 | self.cs.bind(('',0)) 173 | self.type = "Unknown" 174 | self.lock = threading.Lock() 175 | 176 | if 'pyaes' in globals(): 177 | self.encrypt = self.encrypt_pyaes 178 | self.decrypt = self.decrypt_pyaes 179 | else: 180 | self.encrypt = self.encrypt_pycrypto 181 | self.decrypt = self.decrypt_pycrypto 182 | 183 | def encrypt_pyaes(self, payload): 184 | aes = pyaes.AESModeOfOperationCBC(self.key, iv = bytes(self.iv)) 185 | return "".join([aes.encrypt(bytes(payload[i:i+16])) for i in range(0, len(payload), 16)]) 186 | 187 | def decrypt_pyaes(self, payload): 188 | aes = pyaes.AESModeOfOperationCBC(self.key, iv = bytes(self.iv)) 189 | return "".join([aes.decrypt(bytes(payload[i:i+16])) for i in range(0, len(payload), 16)]) 190 | 191 | def encrypt_pycrypto(self, payload): 192 | aes = AES.new(bytes(self.key), AES.MODE_CBC, bytes(self.iv)) 193 | return aes.encrypt(bytes(payload)) 194 | 195 | def decrypt_pycrypto(self, payload): 196 | aes = AES.new(bytes(self.key), AES.MODE_CBC, bytes(self.iv)) 197 | return aes.decrypt(bytes(payload)) 198 | 199 | def auth(self): 200 | payload = bytearray(0x50) 201 | payload[0x04] = 0x31 202 | payload[0x05] = 0x31 203 | payload[0x06] = 0x31 204 | payload[0x07] = 0x31 205 | payload[0x08] = 0x31 206 | payload[0x09] = 0x31 207 | payload[0x0a] = 0x31 208 | payload[0x0b] = 0x31 209 | payload[0x0c] = 0x31 210 | payload[0x0d] = 0x31 211 | payload[0x0e] = 0x31 212 | payload[0x0f] = 0x31 213 | payload[0x10] = 0x31 214 | payload[0x11] = 0x31 215 | payload[0x12] = 0x31 216 | payload[0x1e] = 0x01 217 | payload[0x2d] = 0x01 218 | payload[0x30] = ord('T') 219 | payload[0x31] = ord('e') 220 | payload[0x32] = ord('s') 221 | payload[0x33] = ord('t') 222 | payload[0x34] = ord(' ') 223 | payload[0x35] = ord(' ') 224 | payload[0x36] = ord('1') 225 | 226 | response = self.send_packet(0x65, payload) 227 | 228 | payload = self.decrypt(response[0x38:]) 229 | 230 | if not payload: 231 | return False 232 | 233 | key = payload[0x04:0x14] 234 | if len(key) % 16 != 0: 235 | return False 236 | 237 | self.id = payload[0x00:0x04] 238 | self.key = key 239 | 240 | return True 241 | 242 | def get_type(self): 243 | return self.type 244 | 245 | def send_packet(self, command, payload): 246 | self.count = (self.count + 1) & 0xffff 247 | packet = bytearray(0x38) 248 | packet[0x00] = 0x5a 249 | packet[0x01] = 0xa5 250 | packet[0x02] = 0xaa 251 | packet[0x03] = 0x55 252 | packet[0x04] = 0x5a 253 | packet[0x05] = 0xa5 254 | packet[0x06] = 0xaa 255 | packet[0x07] = 0x55 256 | packet[0x24] = 0x2a 257 | packet[0x25] = 0x27 258 | packet[0x26] = command 259 | packet[0x28] = self.count & 0xff 260 | packet[0x29] = self.count >> 8 261 | packet[0x2a] = self.mac[0] 262 | packet[0x2b] = self.mac[1] 263 | packet[0x2c] = self.mac[2] 264 | packet[0x2d] = self.mac[3] 265 | packet[0x2e] = self.mac[4] 266 | packet[0x2f] = self.mac[5] 267 | packet[0x30] = self.id[0] 268 | packet[0x31] = self.id[1] 269 | packet[0x32] = self.id[2] 270 | packet[0x33] = self.id[3] 271 | 272 | # pad the payload for AES encryption 273 | if len(payload)>0: 274 | numpad=(len(payload)//16+1)*16 275 | payload=payload.ljust(numpad,b"\x00") 276 | 277 | checksum = 0xbeaf 278 | for i in range(len(payload)): 279 | checksum += payload[i] 280 | checksum = checksum & 0xffff 281 | 282 | payload = self.encrypt(payload) 283 | 284 | packet[0x34] = checksum & 0xff 285 | packet[0x35] = checksum >> 8 286 | 287 | for i in range(len(payload)): 288 | packet.append(payload[i]) 289 | 290 | checksum = 0xbeaf 291 | for i in range(len(packet)): 292 | checksum += packet[i] 293 | checksum = checksum & 0xffff 294 | packet[0x20] = checksum & 0xff 295 | packet[0x21] = checksum >> 8 296 | 297 | starttime = time.time() 298 | with self.lock: 299 | while True: 300 | try: 301 | self.cs.sendto(packet, self.host) 302 | self.cs.settimeout(1) 303 | response = self.cs.recvfrom(2048) 304 | break 305 | except socket.timeout: 306 | if (time.time() - starttime) > self.timeout: 307 | raise 308 | return bytearray(response[0]) 309 | 310 | 311 | class mp1(device): 312 | def __init__ (self, host, mac, devtype): 313 | device.__init__(self, host, mac, devtype) 314 | self.type = "MP1" 315 | 316 | def set_power_mask(self, sid_mask, state): 317 | """Sets the power state of the smart power strip.""" 318 | 319 | packet = bytearray(16) 320 | packet[0x00] = 0x0d 321 | packet[0x02] = 0xa5 322 | packet[0x03] = 0xa5 323 | packet[0x04] = 0x5a 324 | packet[0x05] = 0x5a 325 | packet[0x06] = 0xb2 + ((sid_mask<<1) if state else sid_mask) 326 | packet[0x07] = 0xc0 327 | packet[0x08] = 0x02 328 | packet[0x0a] = 0x03 329 | packet[0x0d] = sid_mask 330 | packet[0x0e] = sid_mask if state else 0 331 | 332 | response = self.send_packet(0x6a, packet) 333 | 334 | err = response[0x22] | (response[0x23] << 8) 335 | 336 | def set_power(self, sid, state): 337 | """Sets the power state of the smart power strip.""" 338 | sid_mask = 0x01 << (sid - 1) 339 | return self.set_power_mask(sid_mask, state) 340 | 341 | def check_power_raw(self): 342 | """Returns the power state of the smart power strip in raw format.""" 343 | packet = bytearray(16) 344 | packet[0x00] = 0x0a 345 | packet[0x02] = 0xa5 346 | packet[0x03] = 0xa5 347 | packet[0x04] = 0x5a 348 | packet[0x05] = 0x5a 349 | packet[0x06] = 0xae 350 | packet[0x07] = 0xc0 351 | packet[0x08] = 0x01 352 | 353 | response = self.send_packet(0x6a, packet) 354 | err = response[0x22] | (response[0x23] << 8) 355 | if err == 0: 356 | payload = self.decrypt(bytes(response[0x38:])) 357 | if type(payload[0x4]) == int: 358 | state = payload[0x0e] 359 | else: 360 | state = ord(payload[0x0e]) 361 | return state 362 | 363 | def check_power(self): 364 | """Returns the power state of the smart power strip.""" 365 | state = self.check_power_raw() 366 | data = {} 367 | data['s1'] = bool(state & 0x01) 368 | data['s2'] = bool(state & 0x02) 369 | data['s3'] = bool(state & 0x04) 370 | data['s4'] = bool(state & 0x08) 371 | return data 372 | 373 | 374 | class sp1(device): 375 | def __init__ (self, host, mac, devtype): 376 | device.__init__(self, host, mac, devtype) 377 | self.type = "SP1" 378 | 379 | def set_power(self, state): 380 | packet = bytearray(4) 381 | packet[0] = state 382 | self.send_packet(0x66, packet) 383 | 384 | 385 | class sp2(device): 386 | def __init__ (self, host, mac, devtype): 387 | device.__init__(self, host, mac, devtype) 388 | self.type = "SP2" 389 | 390 | def set_power(self, state): 391 | """Sets the power state of the smart plug.""" 392 | packet = bytearray(16) 393 | packet[0] = 2 394 | packet[4] = 1 if state else 0 395 | self.send_packet(0x6a, packet) 396 | 397 | def check_power(self): 398 | """Returns the power state of the smart plug.""" 399 | packet = bytearray(16) 400 | packet[0] = 1 401 | response = self.send_packet(0x6a, packet) 402 | err = response[0x22] | (response[0x23] << 8) 403 | if err == 0: 404 | payload = self.decrypt(bytes(response[0x38:])) 405 | if type(payload[0x4]) == int: 406 | state = bool(payload[0x4]) 407 | else: 408 | state = bool(ord(payload[0x4])) 409 | return state 410 | 411 | def get_energy(self): 412 | packet = bytearray([8, 0, 254, 1, 5, 1, 0, 0, 0, 45]) 413 | response = self.send_packet(0x6a, packet) 414 | err = response[0x22] | (response[0x23] << 8) 415 | if err == 0: 416 | payload = self.decrypt(bytes(response[0x38:])) 417 | if type(payload[0x07]) == int: 418 | energy = int(hex(payload[0x07] * 256 + payload[0x06])[2:]) + int(hex(payload[0x05])[2:])/100.0 419 | else: 420 | energy = int(hex(ord(payload[0x07]) * 256 + ord(payload[0x06]))[2:]) + int(hex(ord(payload[0x05]))[2:])/100.0 421 | return energy 422 | 423 | 424 | class a1(device): 425 | def __init__ (self, host, mac, devtype): 426 | device.__init__(self, host, mac, devtype) 427 | self.type = "A1" 428 | 429 | def check_sensors(self): 430 | packet = bytearray(16) 431 | packet[0] = 1 432 | response = self.send_packet(0x6a, packet) 433 | err = response[0x22] | (response[0x23] << 8) 434 | if err == 0: 435 | data = {} 436 | payload = self.decrypt(bytes(response[0x38:])) 437 | if type(payload[0x4]) == int: 438 | data['temperature'] = (payload[0x4] * 10 + payload[0x5]) / 10.0 439 | data['humidity'] = (payload[0x6] * 10 + payload[0x7]) / 10.0 440 | light = payload[0x8] 441 | air_quality = payload[0x0a] 442 | noise = payload[0xc] 443 | else: 444 | data['temperature'] = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 445 | data['humidity'] = (ord(payload[0x6]) * 10 + ord(payload[0x7])) / 10.0 446 | light = ord(payload[0x8]) 447 | air_quality = ord(payload[0x0a]) 448 | noise = ord(payload[0xc]) 449 | if light == 0: 450 | data['light'] = 'dark' 451 | elif light == 1: 452 | data['light'] = 'dim' 453 | elif light == 2: 454 | data['light'] = 'normal' 455 | elif light == 3: 456 | data['light'] = 'bright' 457 | else: 458 | data['light'] = 'unknown' 459 | if air_quality == 0: 460 | data['air_quality'] = 'excellent' 461 | elif air_quality == 1: 462 | data['air_quality'] = 'good' 463 | elif air_quality == 2: 464 | data['air_quality'] = 'normal' 465 | elif air_quality == 3: 466 | data['air_quality'] = 'bad' 467 | else: 468 | data['air_quality'] = 'unknown' 469 | if noise == 0: 470 | data['noise'] = 'quiet' 471 | elif noise == 1: 472 | data['noise'] = 'normal' 473 | elif noise == 2: 474 | data['noise'] = 'noisy' 475 | else: 476 | data['noise'] = 'unknown' 477 | return data 478 | 479 | def check_sensors_raw(self): 480 | packet = bytearray(16) 481 | packet[0] = 1 482 | response = self.send_packet(0x6a, packet) 483 | err = response[0x22] | (response[0x23] << 8) 484 | if err == 0: 485 | data = {} 486 | payload = self.decrypt(bytes(response[0x38:])) 487 | if type(payload[0x4]) == int: 488 | data['temperature'] = (payload[0x4] * 10 + payload[0x5]) / 10.0 489 | data['humidity'] = (payload[0x6] * 10 + payload[0x7]) / 10.0 490 | data['light'] = payload[0x8] 491 | data['air_quality'] = payload[0x0a] 492 | data['noise'] = payload[0xc] 493 | else: 494 | data['temperature'] = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 495 | data['humidity'] = (ord(payload[0x6]) * 10 + ord(payload[0x7])) / 10.0 496 | data['light'] = ord(payload[0x8]) 497 | data['air_quality'] = ord(payload[0x0a]) 498 | data['noise'] = ord(payload[0xc]) 499 | return data 500 | 501 | 502 | class rm(device): 503 | def __init__ (self, host, mac, devtype): 504 | device.__init__(self, host, mac, devtype) 505 | self.type = "RM2" 506 | 507 | def check_data(self): 508 | packet = bytearray(16) 509 | packet[0] = 4 510 | response = self.send_packet(0x6a, packet) 511 | err = response[0x22] | (response[0x23] << 8) 512 | if err == 0: 513 | payload = self.decrypt(bytes(response[0x38:])) 514 | return payload[0x04:] 515 | 516 | def send_data(self, data): 517 | packet = bytearray([0x02, 0x00, 0x00, 0x00]) 518 | packet += data 519 | self.send_packet(0x6a, packet) 520 | 521 | def enter_learning(self): 522 | packet = bytearray(16) 523 | packet[0] = 3 524 | self.send_packet(0x6a, packet) 525 | 526 | def check_temperature(self): 527 | packet = bytearray(16) 528 | packet[0] = 1 529 | response = self.send_packet(0x6a, packet) 530 | err = response[0x22] | (response[0x23] << 8) 531 | if err == 0: 532 | payload = self.decrypt(bytes(response[0x38:])) 533 | if type(payload[0x4]) == int: 534 | temp = (payload[0x4] * 10 + payload[0x5]) / 10.0 535 | else: 536 | temp = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0 537 | return temp 538 | 539 | 540 | # For legacy compatibility - don't use this 541 | class rm2(rm): 542 | def __init__ (self): 543 | device.__init__(self, None, None, None) 544 | 545 | def discover(self): 546 | dev = discover() 547 | self.host = dev.host 548 | self.mac = dev.mac 549 | 550 | 551 | class hysen(device): 552 | def __init__ (self, host, mac, devtype): 553 | device.__init__(self, host, mac, devtype) 554 | self.type = "Hysen heating controller" 555 | 556 | # Send a request 557 | # input_payload should be a bytearray, usually 6 bytes, e.g. bytearray([0x01,0x06,0x00,0x02,0x10,0x00]) 558 | # Returns decrypted payload 559 | # New behaviour: raises a ValueError if the device response indicates an error or CRC check fails 560 | # The function prepends length (2 bytes) and appends CRC 561 | def send_request(self,input_payload): 562 | 563 | from PyCRC.CRC16 import CRC16 564 | crc = CRC16(modbus_flag=True).calculate(bytes(input_payload)) 565 | 566 | # first byte is length, +2 for CRC16 567 | request_payload = bytearray([len(input_payload) + 2,0x00]) 568 | request_payload.extend(input_payload) 569 | 570 | # append CRC 571 | request_payload.append(crc & 0xFF) 572 | request_payload.append((crc >> 8) & 0xFF) 573 | 574 | # send to device 575 | response = self.send_packet(0x6a, request_payload) 576 | 577 | # check for error 578 | err = response[0x22] | (response[0x23] << 8) 579 | if err: 580 | raise ValueError('broadlink_response_error',err) 581 | 582 | response_payload = bytearray(self.decrypt(bytes(response[0x38:]))) 583 | 584 | # experimental check on CRC in response (first 2 bytes are len, and trailing bytes are crc) 585 | response_payload_len = response_payload[0] 586 | if response_payload_len + 2 > len(response_payload): 587 | raise ValueError('hysen_response_error','first byte of response is not length') 588 | crc = CRC16(modbus_flag=True).calculate(bytes(response_payload[2:response_payload_len])) 589 | if (response_payload[response_payload_len] == crc & 0xFF) and (response_payload[response_payload_len+1] == (crc >> 8) & 0xFF): 590 | return response_payload[2:response_payload_len] 591 | else: 592 | raise ValueError('hysen_response_error','CRC check on response failed') 593 | 594 | 595 | # Get current room temperature in degrees celsius 596 | def get_temp(self): 597 | payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x08])) 598 | return payload[0x05] / 2.0 599 | 600 | # Get current external temperature in degrees celsius 601 | def get_external_temp(self): 602 | payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x08])) 603 | return payload[18] / 2.0 604 | 605 | # Get full status (including timer schedule) 606 | def get_full_status(self): 607 | payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x16])) 608 | data = {} 609 | data['remote_lock'] = payload[3] & 1 610 | data['power'] = payload[4] & 1 611 | data['active'] = (payload[4] >> 4) & 1 612 | data['temp_manual'] = (payload[4] >> 6) & 1 613 | data['room_temp'] = (payload[5] & 255)/2.0 614 | data['thermostat_temp'] = (payload[6] & 255)/2.0 615 | data['auto_mode'] = payload[7] & 15 616 | data['loop_mode'] = (payload[7] >> 4) & 15 617 | data['sensor'] = payload[8] 618 | data['osv'] = payload[9] 619 | data['dif'] = payload[10] 620 | data['svh'] = payload[11] 621 | data['svl'] = payload[12] 622 | data['room_temp_adj'] = ((payload[13] << 8) + payload[14])/2.0 623 | if data['room_temp_adj'] > 32767: 624 | data['room_temp_adj'] = 32767 - data['room_temp_adj'] 625 | data['fre'] = payload[15] 626 | data['poweron'] = payload[16] 627 | data['unknown'] = payload[17] 628 | data['external_temp'] = (payload[18] & 255)/2.0 629 | data['hour'] = payload[19] 630 | data['min'] = payload[20] 631 | data['sec'] = payload[21] 632 | data['dayofweek'] = payload[22] 633 | 634 | weekday = [] 635 | for i in range(0, 6): 636 | weekday.append({'start_hour':payload[2*i + 23], 'start_minute':payload[2*i + 24],'temp':payload[i + 39]/2.0}) 637 | 638 | data['weekday'] = weekday 639 | weekend = [] 640 | for i in range(6, 8): 641 | weekend.append({'start_hour':payload[2*i + 23], 'start_minute':payload[2*i + 24],'temp':payload[i + 39]/2.0}) 642 | 643 | data['weekend'] = weekend 644 | return data 645 | 646 | # Change controller mode 647 | # auto_mode = 1 for auto (scheduled/timed) mode, 0 for manual mode. 648 | # Manual mode will activate last used temperature. In typical usage call set_temp to activate manual control and set temp. 649 | # loop_mode refers to index in [ "12345,67", "123456,7", "1234567" ] 650 | # E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday follow the "weekend" schedule 651 | # loop_mode = 2 ("1234567") means every day (including Saturday and Sunday) follows the "weekday" schedule 652 | # The sensor command is currently experimental 653 | def set_mode(self, auto_mode, loop_mode,sensor=0): 654 | mode_byte = ( (loop_mode + 1) << 4) + auto_mode 655 | # print 'Mode byte: 0x'+ format(mode_byte, '02x') 656 | self.send_request(bytearray([0x01,0x06,0x00,0x02,mode_byte,sensor])) 657 | 658 | def set_advanced(self, loop_mode, sensor, osv, dif, svh, svl, adj, fre, poweron): 659 | input_payload = bytearray([0x01,0x10,0x00,0x02,0x00,0x05,0x0a, loop_mode, sensor, osv, dif, svh, svl, (int(adj*2)>>8 & 0xff), (int(adj*2) & 0xff), fre, poweron]) 660 | self.send_request(input_payload) 661 | 662 | # For backwards compatibility only. Prefer calling set_mode directly. Note this function invokes loop_mode=0 and sensor=0. 663 | def switch_to_auto(self): 664 | self.set_mode(auto_mode=1, loop_mode=0) 665 | 666 | def switch_to_manual(self): 667 | self.set_mode(auto_mode=0, loop_mode=0) 668 | 669 | # Set temperature for manual mode (also activates manual mode if currently in automatic) 670 | def set_temp(self, temp): 671 | self.send_request(bytearray([0x01,0x06,0x00,0x01,0x00,int(temp * 2)]) ) 672 | 673 | # Set device on(1) or off(0), does not deactivate Wifi connectivity. Remote lock disables control by buttons on thermostat. 674 | def set_power(self, power=1, remote_lock=0): 675 | self.send_request(bytearray([0x01,0x06,0x00,0x00,remote_lock,power]) ) 676 | 677 | # set time on device 678 | # n.b. day=1 is Monday, ..., day=7 is Sunday 679 | def set_time(self, hour, minute, second, day): 680 | self.send_request(bytearray([0x01,0x10,0x00,0x08,0x00,0x02,0x04, hour, minute, second, day ])) 681 | 682 | # Set timer schedule 683 | # Format is the same as you get from get_full_status. 684 | # weekday is a list (ordered) of 6 dicts like: 685 | # {'start_hour':17, 'start_minute':30, 'temp': 22 } 686 | # Each one specifies the thermostat temp that will become effective at start_hour:start_minute 687 | # weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon) 688 | def set_schedule(self,weekday,weekend): 689 | # Begin with some magic values ... 690 | input_payload = bytearray([0x01,0x10,0x00,0x0a,0x00,0x0c,0x18]) 691 | 692 | # Now simply append times/temps 693 | # weekday times 694 | for i in range(0, 6): 695 | input_payload.append( weekday[i]['start_hour'] ) 696 | input_payload.append( weekday[i]['start_minute'] ) 697 | 698 | # weekend times 699 | for i in range(0, 2): 700 | input_payload.append( weekend[i]['start_hour'] ) 701 | input_payload.append( weekend[i]['start_minute'] ) 702 | 703 | # weekday temperatures 704 | for i in range(0, 6): 705 | input_payload.append( int(weekday[i]['temp'] * 2) ) 706 | 707 | # weekend temperatures 708 | for i in range(0, 2): 709 | input_payload.append( int(weekend[i]['temp'] * 2) ) 710 | 711 | self.send_request(input_payload) 712 | 713 | 714 | S1C_SENSORS_TYPES = { 715 | 0x31: 'Door Sensor', # 49 as hex 716 | 0x91: 'Key Fob', # 145 as hex, as serial on fob corpse 717 | 0x21: 'Motion Sensor' # 33 as hex 718 | } 719 | 720 | 721 | class S1C(device): 722 | """ 723 | Its VERY VERY VERY DIRTY IMPLEMENTATION of S1C 724 | """ 725 | def __init__(self, *a, **kw): 726 | device.__init__(self, *a, **kw) 727 | self.type = 'S1C' 728 | 729 | def get_sensors_status(self): 730 | packet = bytearray(16) 731 | packet[0] = 0x06 # 0x06 - get sensors info, 0x07 - probably add sensors 732 | response = self.send_packet(0x6a, packet) 733 | err = response[0x22] | (response[0x23] << 8) 734 | if err == 0: 735 | aes = AES.new(bytes(self.key), AES.MODE_CBC, bytes(self.iv)) 736 | 737 | payload = aes.decrypt(bytes(response[0x38:])) 738 | if payload: 739 | head = payload[:4] 740 | count = payload[0x4] #need to fix for python 2.x 741 | sensors = payload[0x6:] 742 | sensors_a = [bytearray(sensors[i * 83:(i + 1) * 83]) for i in range(len(sensors) // 83)] 743 | 744 | sens_res = [] 745 | for sens in sensors_a: 746 | status = ord(chr(sens[0])) 747 | _name = str(bytes(sens[4:26]).decode()) 748 | _order = ord(chr(sens[1])) 749 | _type = ord(chr(sens[3])) 750 | _serial = bytes(codecs.encode(sens[26:30],"hex")).decode() 751 | 752 | type_str = S1C_SENSORS_TYPES.get(_type, 'Unknown') 753 | 754 | r = { 755 | 'status': status, 756 | 'name': _name.strip('\x00'), 757 | 'type': type_str, 758 | 'order': _order, 759 | 'serial': _serial, 760 | } 761 | if r['serial'] != '00000000': 762 | sens_res.append(r) 763 | result = { 764 | 'count': count, 765 | 'sensors': sens_res 766 | } 767 | return result 768 | 769 | 770 | class dooya(device): 771 | def __init__ (self, host, mac, devtype): 772 | device.__init__(self, host, mac, devtype) 773 | self.type = "Dooya DT360E" 774 | 775 | def _send(self, magic1, magic2): 776 | packet = bytearray(16) 777 | packet[0] = 0x09 778 | packet[2] = 0xbb 779 | packet[3] = magic1 780 | packet[4] = magic2 781 | packet[9] = 0xfa 782 | packet[10] = 0x44 783 | response = self.send_packet(0x6a, packet) 784 | err = response[0x22] | (response[0x23] << 8) 785 | if err == 0: 786 | payload = self.decrypt(bytes(response[0x38:])) 787 | return ord(payload[4]) 788 | 789 | def open(self): 790 | return self._send(0x01, 0x00) 791 | 792 | def close(self): 793 | return self._send(0x02, 0x00) 794 | 795 | def stop(self): 796 | return self._send(0x03, 0x00) 797 | 798 | def get_percentage(self): 799 | return self._send(0x06, 0x5d) 800 | 801 | def set_percentage_and_wait(self, new_percentage): 802 | current = self.get_percentage() 803 | if current > new_percentage: 804 | self.close() 805 | while current is not None and current > new_percentage: 806 | time.sleep(0.2) 807 | current = self.get_percentage() 808 | 809 | elif current < new_percentage: 810 | self.open() 811 | while current is not None and current < new_percentage: 812 | time.sleep(0.2) 813 | current = self.get_percentage() 814 | self.stop() 815 | 816 | 817 | # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode. 818 | # Only tested with Broadlink RM3 Mini (Blackbean) 819 | def setup(ssid, password, security_mode): 820 | # Security mode options are (0 - none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) 821 | payload = bytearray(0x88) 822 | payload[0x26] = 0x14 # This seems to always be set to 14 823 | # Add the SSID to the payload 824 | ssid_start = 68 825 | ssid_length = 0 826 | for letter in ssid: 827 | payload[(ssid_start + ssid_length)] = ord(letter) 828 | ssid_length += 1 829 | # Add the WiFi password to the payload 830 | pass_start = 100 831 | pass_length = 0 832 | for letter in password: 833 | payload[(pass_start + pass_length)] = ord(letter) 834 | pass_length += 1 835 | 836 | payload[0x84] = ssid_length # Character length of SSID 837 | payload[0x85] = pass_length # Character length of password 838 | payload[0x86] = security_mode # Type of encryption (00 - none, 01 = WEP, 02 = WPA1, 03 = WPA2, 04 = WPA1/2) 839 | 840 | checksum = 0xbeaf 841 | for i in range(len(payload)): 842 | checksum += payload[i] 843 | checksum = checksum & 0xffff 844 | 845 | payload[0x20] = checksum & 0xff # Checksum 1 position 846 | payload[0x21] = checksum >> 8 # Checksum 2 position 847 | 848 | sock = socket.socket(socket.AF_INET, # Internet 849 | socket.SOCK_DGRAM) # UDP 850 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 851 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 852 | sock.sendto(payload, ('255.255.255.255', 80)) 853 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | Command line interface for python-broadlink 2 | =========================================== 3 | 4 | This is a command line interface for broadlink python library 5 | 6 | Tested with BroadLink RMPRO / RM2 7 | 8 | 9 | Requirements 10 | ------------ 11 | You should have the broadlink python installed, this can be made in many linux distributions using : 12 | ``` 13 | sudo pip install broadlink 14 | ``` 15 | 16 | Instalation 17 | ----------- 18 | Just copy this files 19 | 20 | 21 | Programs 22 | -------- 23 | 24 | 25 | * broadlink_discovery 26 | used to run the discovery in the network 27 | this program withh show the command line parameters to be used with 28 | broadlink_cli to select broadlink device 29 | 30 | * broadlink_cli 31 | used to send commands and query the broadlink device 32 | 33 | 34 | device specification formats 35 | ---------------------------- 36 | 37 | Using separate parameters for each information: 38 | ``` 39 | broadlink_cli --type 0x2712 --host 1.1.1.1 --mac aaaaaaaaaa --temp 40 | ``` 41 | 42 | Using all parameters as a single argument: 43 | ``` 44 | broadlink_cli --device "0x2712 1.1.1.1 aaaaaaaaaa" --temp 45 | ``` 46 | 47 | Using file with parameters: 48 | ``` 49 | broadlink_cli --device @BEDROOM.device --temp 50 | ``` 51 | This is prefered as the configuration is stored in file and you can change 52 | just a file to point to a different hardware 53 | 54 | Sample usage 55 | ------------ 56 | 57 | Learn commands : 58 | ``` 59 | # Learn and save to file 60 | broadlink_cli --device @BEDROOM.device --learnfile LG-TV.power 61 | # LEard and show at console 62 | broadlink_cli --device @BEDROOM.device --learn 63 | ``` 64 | 65 | 66 | Send command : 67 | ``` 68 | broadlink_cli --device @BEDROOM.device --send @LG-TV.power 69 | broadlink_cli --device @BEDROOM.device --send ....datafromlearncommand... 70 | ``` 71 | 72 | Get Temperature : 73 | ``` 74 | broadlink_cli --device @BEDROOM.device --temperature 75 | ``` 76 | -------------------------------------------------------------------------------- /cli/broadlink_cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import broadlink 4 | import sys 5 | import argparse 6 | import time 7 | 8 | TICK = 32.84 9 | IR_TOKEN = 0x26 10 | 11 | 12 | def auto_int(x): 13 | return int(x, 0) 14 | 15 | 16 | def to_microseconds(bytes): 17 | result = [] 18 | # print bytes[0] # 0x26 = 38for IR 19 | length = bytes[2] + 256 * bytes[3] # presently ignored 20 | index = 4 21 | while index < len(bytes): 22 | chunk = bytes[index] 23 | index += 1 24 | if chunk == 0: 25 | chunk = bytes[index] 26 | chunk = 256 * chunk + bytes[index + 1] 27 | index += 2 28 | result.append(int(round(chunk*TICK))) 29 | if chunk == 0x0d05: 30 | break 31 | return result 32 | 33 | 34 | def durations_to_broadlink(durations): 35 | result = bytearray() 36 | result.append(IR_TOKEN) 37 | result.append(0) 38 | result.append(len(durations) % 256) 39 | result.append(len(durations) / 256) 40 | for dur in durations: 41 | num = int(round(dur/TICK)) 42 | if num > 255: 43 | result.append(0) 44 | result.append(num / 256) 45 | result.append(num % 256) 46 | return result 47 | 48 | 49 | def format_durations(data): 50 | result = '' 51 | for i in range(0, len(data)): 52 | if len(result) > 0: 53 | result += ' ' 54 | result += ('+' if i % 2 == 0 else '-') + str(data[i]) 55 | return result 56 | 57 | 58 | def parse_durations(str): 59 | result = [] 60 | for s in str.split(): 61 | result.append(abs(int(s))) 62 | return result 63 | 64 | 65 | parser = argparse.ArgumentParser(fromfile_prefix_chars='@'); 66 | parser.add_argument("--device", help="device definition as 'type host mac'") 67 | parser.add_argument("--type", type=auto_int, default=0x2712, help="type of device") 68 | parser.add_argument("--host", help="host address") 69 | parser.add_argument("--mac", help="mac address (hex reverse), as used by python-broadlink library") 70 | parser.add_argument("--temperature",action="store_true", help="request temperature from device") 71 | parser.add_argument("--check", action="store_true", help="check current power state") 72 | parser.add_argument("--turnon", action="store_true", help="turn on device") 73 | parser.add_argument("--turnoff", action="store_true", help="turn off device") 74 | parser.add_argument("--switch", action="store_true", help="switch state from on to off and off to on") 75 | parser.add_argument("--send", action="store_true", help="send command") 76 | parser.add_argument("--sensors", action="store_true", help="check all sensors") 77 | parser.add_argument("--learn", action="store_true", help="learn command") 78 | parser.add_argument("--learnfile", help="learn command and save to specified file") 79 | parser.add_argument("--durations", action="store_true", help="use durations in micro seconds instead of the Broadlink format") 80 | parser.add_argument("--convert", action="store_true", help="convert input data to durations") 81 | parser.add_argument("data", nargs='*', help="Data to send or convert") 82 | args = parser.parse_args() 83 | 84 | if args.device: 85 | values = args.device.split(); 86 | type = int(values[0],0) 87 | host = values[1] 88 | mac = bytearray.fromhex(values[2]) 89 | elif args.mac: 90 | type = args.type 91 | host = args.host 92 | mac = bytearray.fromhex(args.mac) 93 | 94 | if args.host or args.device: 95 | dev = broadlink.gendevice(type, (host, 80), mac) 96 | dev.auth() 97 | 98 | if args.convert: 99 | data = bytearray.fromhex(''.join(args.data)) 100 | durations = to_microseconds(data) 101 | print format_durations(durations) 102 | if args.temperature: 103 | print dev.check_temperature() 104 | if args.sensors: 105 | try: 106 | data = dev.check_sensors() 107 | except: 108 | data = {} 109 | data['temperature'] = dev.check_temperature() 110 | for key in data: 111 | print "{} {}".format(key, data[key]) 112 | if args.send: 113 | data = durations_to_broadlink(parse_durations(' '.join(args.data))) \ 114 | if args.durations else bytearray.fromhex(''.join(args.data)) 115 | dev.send_data(data) 116 | if args.learn or args.learnfile: 117 | dev.enter_learning() 118 | data = None 119 | print "Learning..." 120 | timeout = 30 121 | while (data is None) and (timeout > 0): 122 | time.sleep(2) 123 | timeout -= 2 124 | data = dev.check_data() 125 | if data: 126 | learned = format_durations(to_microseconds(bytearray(data))) \ 127 | if args.durations \ 128 | else ''.join(format(x, '02x') for x in bytearray(data)) 129 | if args.learn: 130 | print learned 131 | if args.learnfile: 132 | print "Saving to {}".format(args.learnfile) 133 | with open(args.learnfile, "w") as text_file: 134 | text_file.write(learned) 135 | else: 136 | print "No data received..." 137 | if args.check: 138 | if dev.check_power(): 139 | print '* ON *' 140 | else: 141 | print '* OFF *' 142 | if args.turnon: 143 | dev.set_power(True) 144 | if dev.check_power(): 145 | print '== Turned * ON * ==' 146 | else: 147 | print '!! Still OFF !!' 148 | if args.turnoff: 149 | dev.set_power(False) 150 | if dev.check_power(): 151 | print '!! Still ON !!' 152 | else: 153 | print '== Turned * OFF * ==' 154 | if args.switch: 155 | if dev.check_power(): 156 | dev.set_power(False) 157 | print '* Switch to OFF *' 158 | else: 159 | dev.set_power(True) 160 | print '* Switch to ON *' 161 | -------------------------------------------------------------------------------- /cli/broadlink_discovery: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import broadlink 4 | import time 5 | import argparse 6 | 7 | parser = argparse.ArgumentParser(fromfile_prefix_chars='@'); 8 | parser.add_argument("--timeout", type=int, default=5, help="timeout to wait for receiving discovery responses") 9 | args = parser.parse_args() 10 | 11 | print "Discovering..." 12 | devices = broadlink.discover(timeout=args.timeout) 13 | for device in devices: 14 | if device.auth(): 15 | print "###########################################" 16 | print device.type 17 | print "# broadlink_cli --type {} --host {} --mac {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac)) 18 | print "Device file data (to be used with --device @filename in broadlink_cli) : " 19 | print "{} {} {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac)) 20 | if hasattr(device, 'check_temperature'): 21 | print "temperature = {}".format(device.check_temperature()) 22 | print "" 23 | else: 24 | print "Error authenticating with device : {}".format(device.host) 25 | -------------------------------------------------------------------------------- /protocol.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 seconds past the minute| 44 | |0x0f|Current number of minutes past the hour| 45 | |0x10|Current number of hours past midnight| 46 | |0x11|Current day of the week (Monday = 1, Tuesday = 2, etc)| 47 | |0x12|Current day in month| 48 | |0x13|Current month| 49 | |0x14-0x17|00| 50 | |0x18-0x1b|Local IP address| 51 | |0x1c-0x1d|Source port as a little-endian 16 bit integer| 52 | |0x1e-0x1f|00| 53 | |0x20-0x21|Checksum as a little-endian 16 bit integer| 54 | |0x22-0x25|00| 55 | |0x26|06| 56 | |0x27-0x2f|00| 57 | 58 | Send this packet as a UDP broadcast to 255.255.255.255 on port 80. 59 | 60 | Response (any unicast response): 61 | 62 | | Offset | Contents | 63 | |---------|----------| 64 | |0x34-0x35|Device type as a little-endian 16 bit integer (see device type mapping)| 65 | |0x3a-0x3f|MAC address of the target device| 66 | 67 | Device type mapping: 68 | 69 | | Device type in response packet | Device type | Treat as | 70 | |---------|----------|----------| 71 | |0|SP1|SP1| 72 | |0x2711|SP2|SP2| 73 | |0x2719 or 0x7919 or 0x271a or 0x791a|Honeywell SP2|SP2| 74 | |0x2720|SPMini|SP2| 75 | |0x753e|SP3|SP2| 76 | |0x2728|SPMini2|SP2 77 | |0x2733 or 0x273e|OEM branded SPMini|SP2| 78 | |>= 0x7530 and <= 0x7918|OEM branded SPMini2|SP2| 79 | |0x2736|SPMiniPlus|SP2| 80 | |0x2712|RM2|RM| 81 | |0x2737|RM Mini / RM3 Mini Blackbean|RM| 82 | |0x273d|RM Pro Phicomm|RM| 83 | |0x2783|RM2 Home Plus|RM| 84 | |0x277c|RM2 Home Plus GDT|RM| 85 | |0x272a|RM2 Pro Plus|RM| 86 | |0x2787|RM2 Pro Plus2|RM| 87 | |0x278b|RM2 Pro Plus BL|RM| 88 | |0x278f|RM Mini Shate|RM| 89 | |0x2714|A1|A1| 90 | |0x4EB5|MP1|MP1| 91 | 92 | 93 | Command packet format 94 | --------------------- 95 | 96 | The command packet header is 56 bytes long with the following format: 97 | 98 | |Offset|Contents| 99 | |------|--------| 100 | |0x00|0x5a| 101 | |0x01|0xa5| 102 | |0x02|0xaa| 103 | |0x03|0x55| 104 | |0x04|0x5a| 105 | |0x05|0xa5| 106 | |0x06|0xaa| 107 | |0x07|0x55| 108 | |0x08-0x1f|00| 109 | |0x20-0x21|Checksum of full packet as a little-endian 16 bit integer| 110 | |0x22-0x23|00| 111 | |0x24-0x25|Device type as a little-endian 16 bit integer| 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 unencrypted payload as a little-endian 16 bit integer 117 | |0x36-0x37|00| 118 | 119 | The payload is appended immediately after this. 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 unencrypted payload. Set 0x34-0x35 to this value. 123 | 3. Encrypt and 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 2^-15 s units (µs * 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 | pycryptodome==3.4.11 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | from setuptools import setup, find_packages 6 | import sys 7 | import warnings 8 | 9 | try: 10 | import pyaes 11 | dynamic_requires = ["pyaes==1.6.0"] 12 | except ImportError as e: 13 | dynamic_requires = ['pycryptodome==3.4.11'] 14 | 15 | # For Hysen thermostatic heating controller 16 | dynamic_requires.append('PyCRC') 17 | 18 | version = 0.6 19 | 20 | setup( 21 | name='broadlink', 22 | version=0.6, 23 | author='Matthew Garrett', 24 | author_email='mjg59@srcf.ucam.org', 25 | url='http://github.com/mjg59/python-broadlink', 26 | packages=find_packages(), 27 | scripts=[], 28 | install_requires=dynamic_requires, 29 | description='Python API for controlling Broadlink IR controllers', 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | ], 37 | include_package_data=True, 38 | zip_safe=False, 39 | ) 40 | --------------------------------------------------------------------------------