about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPeter Windridge <pete@windridge.org.uk>2018-03-18T22·03+0000
committerMatthew Garrett <mjg59-github@srcf.ucam.org>2018-03-18T22·03-0700
commit39cc64efcea723cdc5e97220bd345535f558bb8f (patch)
tree8d73f5285e24ca21ae78cbc6193d35e1611293f6
parent8754493951b887e850d93100898d6883443b124f (diff)
Basic support for Hysen Heating Controller (dev type 0x4ead) (#138)
* Initial support for Hysen heating controller device.  Only gets current temperature.

* Add switch_to_auto() to put the controller in (pre-programmed) timed mode

* Add set_temp() to manually set temperature.  Now requires PyCRC (payload needs modbus CRC16)

* Remove test script

* Get current timer schedule

* Get much more data from device

* Add PyCRC to install_requires setup.py

* Rewrite based on better understanding.  Allow setting schedule and changing 'loop mode'

* Add set_time function

* Support advanced settings and perform CRC check on responses

* Explain remaining unknowns for Hyson thermostat

The room_temp_adj (or simply 'adj') only applies to the room_temp. It's limited to -5.0..+5.0, but uses a 2 byte data type.
This leads to the assumption that external_temp could also use this data type, maybe for showing temperatures below 0 - but I cannot test this currently. Maybe I have to place it near a fridge to confirm.

* Fix get_temp and add get_external_temp for Hysen

Again: maybe payload[17] also belongs to the external temperature...

* remove comment about first 2 bytes and raise error if CRC check on response fails

* Remove comment about guessed meaning of unknown

Just confirmed, that lowest outside temp is 0. So it seems to only need 1 byte, as room temp does.

* add ability to toogle hysen device power

Turn display power on/off

* Update set_power() to support remote_lock for Hysen

Sorry, there was still one thing missing: set/unset remote_lock.
I captured again and changed the set_power accordingly.

* fix comments
-rw-r--r--broadlink/__init__.py173
-rw-r--r--setup.py3
2 files changed, 175 insertions, 1 deletions
diff --git a/broadlink/__init__.py b/broadlink/__init__.py
index 979e608007b9..2af62aabd0cf 100644
--- a/broadlink/__init__.py
+++ b/broadlink/__init__.py
@@ -66,6 +66,8 @@ def gendevice(devtype, host, mac):
     return a1(host=host, mac=mac, devtype=devtype)
   elif devtype == 0x4EB5 or devtype == 0x4EF7: # MP1: 0x4eb5, honyar oem mp1: 0x4ef7
     return mp1(host=host, mac=mac, devtype=devtype)
+  elif devtype == 0x4EAD: # Hysen controller
+    return hysen(host=host, mac=mac)
   elif devtype == 0x2722: # S1 (SmartOne Alarm Kit)
     return S1C(host=host, mac=mac, devtype=devtype)
   elif devtype == 0x4E4D: # Dooya DT360E (DOOYA_CURTAIN_V2)
@@ -134,6 +136,8 @@ def discover(timeout=None, local_ip_address=None):
     host = response[1]
     mac = responsepacket[0x3a:0x40]
     devtype = responsepacket[0x34] | responsepacket[0x35] << 8
+
+
     return gendevice(devtype, host, mac)
   else:
     while (time.time() - starttime) < timeout:
@@ -151,6 +155,7 @@ def discover(timeout=None, local_ip_address=None):
     return devices
 
 
+
 class device:
   def __init__(self, host, mac, devtype, timeout=10):
     self.host = host
@@ -231,6 +236,7 @@ class device:
 
     self.id = payload[0x00:0x04]
     self.key = key
+
     return True
 
   def get_type(self):
@@ -530,7 +536,8 @@ class rm(device):
         temp = (ord(payload[0x4]) * 10 + ord(payload[0x5])) / 10.0
       return temp
 
-# For legay compatibility - don't use this
+
+# For legacy compatibility - don't use this
 class rm2(rm):
   def __init__ (self):
     device.__init__(self, None, None, None)
@@ -541,6 +548,169 @@ class rm2(rm):
     self.mac = dev.mac
 
 
+class hysen(device):
+  def __init__ (self, host, mac, devtype):
+    device.__init__(self, host, mac, devtype)
+    self.type = "Hysen heating controller"
+
+  # Send a request
+  # input_payload should be a bytearray, usually 6 bytes, e.g. bytearray([0x01,0x06,0x00,0x02,0x10,0x00]) 
+  # Returns decrypted payload
+  # New behaviour: raises a ValueError if the device response indicates an error or CRC check fails
+  # The function prepends length (2 bytes) and appends CRC
+  def send_request(self,input_payload):
+    
+    from PyCRC.CRC16 import CRC16
+    crc = CRC16(modbus_flag=True).calculate(bytes(input_payload))
+
+    # first byte is length, +2 for CRC16
+    request_payload = bytearray([len(input_payload) + 2,0x00])
+    request_payload.extend(input_payload)
+    
+    # append CRC
+    request_payload.append(crc & 0xFF)
+    request_payload.append((crc >> 8) & 0xFF)
+
+    # send to device
+    response = self.send_packet(0x6a, request_payload)
+
+    # check for error
+    err = response[0x22] | (response[0x23] << 8)
+    if err: 
+      raise ValueError('broadlink_response_error',err)
+    
+    response_payload = bytearray(self.decrypt(bytes(response[0x38:])))
+
+    # experimental check on CRC in response (first 2 bytes are len, and trailing bytes are crc)
+    response_payload_len = response_payload[0]
+    if response_payload_len + 2 > len(response_payload):
+      raise ValueError('hysen_response_error','first byte of response is not length')
+    crc = CRC16(modbus_flag=True).calculate(bytes(response_payload[2:response_payload_len]))
+    if (response_payload[response_payload_len] == crc & 0xFF) and (response_payload[response_payload_len+1] == (crc >> 8) & 0xFF):
+      return response_payload[2:response_payload_len]
+    else: 
+      raise ValueError('hysen_response_error','CRC check on response failed')
+      
+
+  # Get current room temperature in degrees celsius
+  def get_temp(self):
+    payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x08]))
+    return payload[0x05] / 2.0
+
+  # Get current external temperature in degrees celsius
+  def get_external_temp(self):
+    payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x08]))
+    return payload[18] / 2.0
+
+  # Get full status (including timer schedule)
+  def get_full_status(self):
+    payload = self.send_request(bytearray([0x01,0x03,0x00,0x00,0x00,0x16]))    
+    data = {}
+    data['remote_lock'] =  payload[3] & 1
+    data['power'] =  payload[4] & 1
+    data['active'] =  (payload[4] >> 4) & 1
+    data['temp_manual'] =  (payload[4] >> 6) & 1
+    data['room_temp'] =  (payload[5] & 255)/2.0
+    data['thermostat_temp'] =  (payload[6] & 255)/2.0
+    data['auto_mode'] =  payload[7] & 15
+    data['loop_mode'] =  (payload[7] >> 4) & 15
+    data['sensor'] = payload[8]
+    data['osv'] = payload[9]
+    data['dif'] = payload[10]
+    data['svh'] = payload[11]
+    data['svl'] = payload[12]
+    data['room_temp_adj'] = ((payload[13] << 8) + payload[14])/2.0
+    if data['room_temp_adj'] > 32767:
+      data['room_temp_adj'] = 32767 - data['room_temp_adj']
+    data['fre'] = payload[15]
+    data['poweron'] = payload[16]
+    data['unknown'] = payload[17]
+    data['external_temp'] = (payload[18] & 255)/2.0
+    data['hour'] =  payload[19]
+    data['min'] =  payload[20]
+    data['sec'] =  payload[21]
+    data['dayofweek'] =  payload[22]
+    
+    weekday = []
+    for i in range(0, 6):
+      weekday.append({'start_hour':payload[2*i + 23], 'start_minute':payload[2*i + 24],'temp':payload[i + 39]/2.0})
+    
+    data['weekday'] = weekday
+    weekend = []
+    for i in range(6, 8):
+      weekend.append({'start_hour':payload[2*i + 23], 'start_minute':payload[2*i + 24],'temp':payload[i + 39]/2.0})
+
+    data['weekend'] = weekend
+    return data
+
+  # Change controller mode
+  # auto_mode = 1 for auto (scheduled/timed) mode, 0 for manual mode.
+  # Manual mode will activate last used temperature.  In typical usage call set_temp to activate manual control and set temp.
+  # loop_mode refers to index in [ "12345,67", "123456,7", "1234567" ]
+  # E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday follow the "weekend" schedule
+  # loop_mode = 2 ("1234567") means every day (including Saturday and Sunday) follows the "weekday" schedule
+  # The sensor command is currently experimental
+  def set_mode(self, auto_mode, loop_mode,sensor=0):
+    mode_byte = ( (loop_mode + 1) << 4) + auto_mode
+    # print 'Mode byte: 0x'+ format(mode_byte, '02x')
+    self.send_request(bytearray([0x01,0x06,0x00,0x02,mode_byte,sensor]))
+
+  def set_advanced(self, loop_mode, sensor, osv, dif, svh, svl, adj, fre, poweron):
+    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])
+    self.send_request(input_payload)
+
+  # For backwards compatibility only.  Prefer calling set_mode directly.  Note this function invokes loop_mode=0 and sensor=0.
+  def switch_to_auto(self):
+    self.set_mode(auto_mode=1, loop_mode=0)
+  
+  def switch_to_manual(self):
+    self.set_mode(auto_mode=0, loop_mode=0)
+
+  # Set temperature for manual mode (also activates manual mode if currently in automatic)
+  def set_temp(self, temp):
+    self.send_request(bytearray([0x01,0x06,0x00,0x01,0x00,int(temp * 2)]) )
+
+  # Set device on(1) or off(0), does not deactivate Wifi connectivity.  Remote lock disables control by buttons on thermostat.
+  def set_power(self, power=1, remote_lock=0):
+    self.send_request(bytearray([0x01,0x06,0x00,0x00,remote_lock,power]) )
+
+  # set time on device
+  # n.b. day=1 is Monday, ..., day=7 is Sunday
+  def set_time(self, hour, minute, second, day):
+    self.send_request(bytearray([0x01,0x10,0x00,0x08,0x00,0x02,0x04, hour, minute, second, day ]))
+
+  # Set timer schedule
+  # Format is the same as you get from get_full_status.
+  # weekday is a list (ordered) of 6 dicts like:
+  # {'start_hour':17, 'start_minute':30, 'temp': 22 }
+  # Each one specifies the thermostat temp that will become effective at start_hour:start_minute
+  # weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon)
+  def set_schedule(self,weekday,weekend):
+    # Begin with some magic values ...
+    input_payload = bytearray([0x01,0x10,0x00,0x0a,0x00,0x0c,0x18])
+
+    # Now simply append times/temps
+    # weekday times
+    for i in range(0, 6):
+      input_payload.append( weekday[i]['start_hour'] )
+      input_payload.append( weekday[i]['start_minute'] )
+
+    # weekend times
+    for i in range(0, 2):
+      input_payload.append( weekend[i]['start_hour'] )
+      input_payload.append( weekend[i]['start_minute'] )
+
+    # weekday temperatures
+    for i in range(0, 6):
+      input_payload.append( int(weekday[i]['temp'] * 2) )
+
+    # weekend temperatures
+    for i in range(0, 2):
+      input_payload.append( int(weekend[i]['temp'] * 2) )
+
+    self.send_request(input_payload)
+
+
 S1C_SENSORS_TYPES = {
     0x31: 'Door Sensor',  # 49 as hex
     0x91: 'Key Fob',  # 145 as hex, as serial on fob corpse
@@ -643,6 +813,7 @@ class dooya(device):
         current = self.get_percentage()
     self.stop()
 
+
 # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
 # Only tested with Broadlink RM3 Mini (Blackbean)
 def setup(ssid, password, security_mode):
diff --git a/setup.py b/setup.py
index c5c7e267d024..b982a11a0147 100644
--- a/setup.py
+++ b/setup.py
@@ -12,6 +12,9 @@ try:
 except ImportError as e:
     dynamic_requires = ['pycryptodome==3.4.11']
 
+# For Hysen thermostatic heating controller
+dynamic_requires.append('PyCRC')
+
 version = 0.6
 
 setup(