<filename>thermiser.indigoPlugin/Contents/Server Plugin/pymiser.py
# -*- coding: utf-8 -*-

#  Implements a PyMiser low-level communication object to interface with HeatMiser DT / DT-E / PRT / PRT-E / PRT-HW
#  thermostats over a serial connection and RS485 interface.

#  MIT License
#
#  Copyright (c) 2020 <NAME>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
#  SOFTWARE.

# version 1.0.0
# last modified 17 jun 2020

import datetime
from pm_crc import *

FUNCTION_READ = 0
FUNCTION_WRITE = 1
MASTER_ADDRESS = 0x81  # address of 'master' unit, i.e. this computer.
MAX_REPLY_SIZE = 200
MODEL_NAMES = ['DT', 'DT-E', 'PRT', 'PRT-E', 'PRT-HW']
SENSOR_NAMES = ['built-in air only', 'remote air only', 'floor only', 'built-in air + floor', 'remote-air + floor']
FROST_PROTECTION_NAMES = ['disabled', 'enabled']
RUN_MODE_NAMES = ['heating', 'frost protection']
PROGRAM_MODE_NAMES = ['5/2 mode', '7 day mode']
UNKNOWN_NAME = '(unknown)'
MIN_TEMP_C = 5
MAX_TEMP_C = 35
TEMP_UNIT_CELSIUS = 0
TEMP_UNIT_FAHRENHEIT = 1
TEMP_UNIT_SYMBOL_CELSIUS = u"℃"
TEMP_UNIT_SYMBOL_FAHRENHEIT = u"℉"


class PyMiser(object):
    def __init__(self, owner):
        self.crc = crc()  # CRC calculator object
        self.owner = owner  # Indigo plugin
        self.deviceInfo = dict()

    def fahrenheit (self, celsius):
        """ Converts celsius to Fahrenheit"""
        return celsius * (9.0 / 5.0) + 32

    def _parseDCB(self, dcb):
        """Returns a dictionary with the contents of the dcb"""
        result = {'frameLength': dcb[2] * 256 + dcb[1], 'address': dcb[3]}
        dcb_offset = 9  # type: int
        result['dcbLen'] = dcb[dcb_offset] * 256 + dcb[dcb_offset+1]
        vendor_id = dcb[dcb_offset+2]
        if vendor_id == 0:
            result['vendor'] = 'Heatmiser'
        else:
            result['vendor'] = 'o.e.m.'

        result['softwareVersion'] = dcb[dcb_offset + 3] & 0x7f
        result['floorLimitState'] = dcb[dcb_offset + 3] >> 7

        model_id = dcb[dcb_offset + 4]
        result['modelID'] = model_id
        if 0 <= model_id < len(MODEL_NAMES):
            result['model'] = MODEL_NAMES[model_id]
        else:
            result['model'] = UNKNOWN_NAME

        if dcb[dcb_offset + 5] == 0:
            result['temperatureFormat'] = 'C'
        else:
            result['temperatureFormat'] = 'F'

        result['switchDifferential'] = dcb[dcb_offset + 6]

        frost_protection_code = dcb[dcb_offset + 7]
        if 0 <= frost_protection_code < len(FROST_PROTECTION_NAMES):
            result['frostProtection'] = FROST_PROTECTION_NAMES[frost_protection_code]
        else:
            result['frostProtection'] = UNKNOWN_NAME

        result['calibrationOffset'] = dcb[dcb_offset +8] * 256 + dcb[dcb_offset+9]
        result['outputDelay'] = dcb[dcb_offset+10]
        # offset +11 is the address, which we already know, so we're ignoring it.
        result['upDownKeyLimit'] = dcb[dcb_offset+12]

        sensor_selection_code = dcb[dcb_offset+13]
        result['sensorSelectionCode'] = sensor_selection_code
        if 0 <= sensor_selection_code < len(SENSOR_NAMES):
            result['sensor'] = SENSOR_NAMES[sensor_selection_code]
        else:
            result['sensor'] = UNKNOWN_NAME

        result['optimumStart'] = dcb[dcb_offset+14]
        result['rateOfChange'] = dcb[dcb_offset+15]

        program_mode_code = dcb[dcb_offset+16]
        result['programModeCode'] = program_mode_code
        if 0 <= program_mode_code < len(PROGRAM_MODE_NAMES):
            result['programMode'] = PROGRAM_MODE_NAMES[program_mode_code]

        result['frostProtectTemp'] = dcb[dcb_offset+17]
        result['setRoomTemp'] = dcb[dcb_offset+18]
        result['floorMaxLimit'] = dcb[dcb_offset + 19]
        result['floorMaxLimitEnable'] = dcb[dcb_offset + 19]
        result['On'] = dcb[dcb_offset + 21]
        result['keyLock'] = dcb[dcb_offset + 22]

        run_mode_code = dcb[dcb_offset + 23]
        if 0 <= run_mode_code < len(RUN_MODE_NAMES):
            result['runMode'] = RUN_MODE_NAMES[run_mode_code]
        else:
            result['runMode'] = UNKNOWN_NAME

        result['holidayHours'] = dcb[dcb_offset + 24] * 256 + dcb[dcb_offset+25]
        result['tempHoldMinutes'] = dcb[dcb_offset + 26] * 256 + dcb[dcb_offset+27]

        remote_air_temp = (dcb[dcb_offset + 28] * 256 + dcb[dcb_offset+29]) / 10
        if remote_air_temp == 0xFFFF:
            remote_air_temp = None
        result['remoteAirTemp'] = remote_air_temp

        floor_temp = (dcb[dcb_offset + 30] * 256 + dcb[dcb_offset + 31]) / 10
        if floor_temp == 0xFFFF:
            floor_temp = None
        result['floorTemp'] = floor_temp

        air_temp = (dcb[dcb_offset + 32] * 256 + dcb[dcb_offset + 33]) / 10
        if air_temp == 0xFFFF:
            air_temp = None
        result['airTemp'] = air_temp
        result['heatingOn'] = dcb[dcb_offset+35]

        # This is where the DCB finishes for DT / DT-E model
        if model_id == 0 or model_id == 1:
            return result

        # PRT-HW has an extra byte in the DCB for hot water, so after this everything shifts by one byte.
        if model_id == 4:
            result['hotWaterOn'] = dcb[dcb_offset+36]
            # shift subsequent offsets:
            dcb_offset = dcb_offset + 1

        result['weekDayNumber'] = dcb[dcb_offset+36]
        result['timeHour'] = dcb[dcb_offset+37]
        result['timeMinute'] = dcb[dcb_offset+38]
        result['timeSecond'] = dcb[dcb_offset+39]

        return result

    def _form_frame(self, destination, start, end, payload):
        """Returns a valid frame including checksum based on start and end addresses and payload."""
        if payload is None:
            function = FUNCTION_READ  # read
            payload_len = 0
        else:
            function = FUNCTION_WRITE  # write
            payload_len = len(payload)
        frame_length = 10 + payload_len
        frame = bytearray(frame_length)
        frame[0] = destination
        frame[1] = frame_length
        frame[2] = MASTER_ADDRESS
        frame[3] = function
        frame[4] = start & 0xFF
        frame[5] = start >> 8
        frame[6] = end & 0xFF
        frame[7] = end >> 8
        if payload is not None:
            offset = 8
            for x in payload:
                frame[offset] = x
                offset = offset + 1
        return self.crc.addCCITTtoBytearray(frame)

    def _request_dcb(self, destination):
        """Requests a DCD from the device at address destination and returns the raw reply."""
        frame = self._form_frame(destination, 0, 0xFFFF, None)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE))

        if self.crc.verifyCCITTfromByteArray(reply):
            return reply
        else:
            return None

    def update_device_info(self, address):
        """Requests dcd from device at specified address and populates the deviceInfo dictionary"""
        reply = self._request_dcb(address)
        if reply is not None:
            self.deviceInfo = self._parseDCB(reply)
            if self.deviceInfo:
                return True

        return False

    def syncClock(self, address, currentRoomSetTemp, temperature_unit):
        """Updates device clock to current local time"""
        # when setting the clock, the thermostats revert to the frost temp if no schedules
        # are programmed, so we're going to re-set the temp after setting the clock.
        if not self._syncClock(address):
            self.owner.errorLog(u"syncClock: clock-sync did not work for address %d" % address)
            return False

        if not self.set_temp(address, currentRoomSetTemp, temperature_unit):
            self.owner.errorLog(u"syncClock: re-setting temperature post clock-sync did not work for address %d" % address)
            return False
        else:
            if self.owner.detailed_debug:
                self.owner.debugLog(u"syncClock: re-set temperature post clock-sync for address %d" % address)

        return True

    def _syncClock(self, address):
        dt = datetime.datetime.now()
        weekday = dt.isoweekday()
        second = dt.second + 2  # the +2 is to compensate for the time it takes to set the clock
        if second > 59:
            second = second - 59
        payload = [weekday, dt.hour, dt.minute, second]
        frame = self._form_frame(address, 43, len(payload), payload)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE)) # read up to 200 bytes

        if reply is None:
            self.owner.errorLog(u"syncClock: no reply from address %d" % address)
            return False

        if len(reply) != 7:
            self.owner.errorLog(u"syncClock: received reply with incorrect length of reply from address %d" % address)
            return False

        if not self.crc.verifyCCITTfromByteArray(reply):
            self.owner.errorLog(u"syncClock: received reply with incorrect CRC from address %d" % address)
            if self.owner.detailed_debug:
                self.owner.debugLog(u"syncClock: received OK reply from address %d" % address)
            return False
        return True

    def set_temp(self, address, temp, temperature_unit):
        """ sets the desired temperature for device with given address"""
        if address is None:
            self.owner.errorLog(u"setTemp: no address specified.")
            return False

        if temp is None:
            self.owner.errorLog(u"setTemp: no temperature specified.")
            return False

        if temperature_unit == TEMP_UNIT_FAHRENHEIT:
            minimum_set_temperature = self.fahrenheit(MIN_TEMP_C)
            maximum_set_temperature = self.fahrenheit(MAX_TEMP_C)
            temperature_unit_symbol = TEMP_UNIT_SYMBOL_FAHRENHEIT
        else:
            minimum_set_temperature = MIN_TEMP_C
            maximum_set_temperature = MAX_TEMP_C
            temperature_unit_symbol = TEMP_UNIT_SYMBOL_CELSIUS

        if temp < minimum_set_temperature:
            self.owner.errorLog(u"setTemp: specified temp (%d) is below minimum (%d deg %s)"
                                % (temp, minimum_set_temperature, temperature_unit_symbol))
            return False

        if temp > maximum_set_temperature:
            self.owner.errorLog(u"setTemp: specified temp (%d) is above maximum (%d deg %s)"
                                % (temp, maximum_set_temperature, temperature_unit_symbol))
            return False

        payload = [temp]
        frame = self._form_frame(address, 18, len(payload), payload)

        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE)) # read up to 200 bytes

        if self.crc.verifyCCITTfromByteArray(reply):
            if self.owner.detailed_debug:
                self.owner.debugLog(u"setTemp: received OK reply from address %d" % address)
            return True
        else:
            self.owner.errorLog(u"setTemp: received reply with incorrect CRC from address %d" % address)
            return False


    def set_hw_on_state(self, address, state):
        if address is None:
            self.owner.errorLog(u"set_hw_on_state: no address specified.")
            return False
        payload = [state]
        frame = self._form_frame(address, 42, len(payload), payload)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE))
        if self.crc.verifyCCITTfromByteArray(reply):
            self.owner.detailDebugLog(u"setTemp: received OK reply from address %d" % address)
            return True
        else:
            return False


<filename>thermiser.indigoPlugin/Contents/Server Plugin/pymiser.py
# -*- coding: utf-8 -*-

#  Implements a PyMiser low-level communication object to interface with HeatMiser DT / DT-E / PRT / PRT-E / PRT-HW
#  thermostats over a serial connection and RS485 interface.

#  MIT License
#
#  Copyright (c) 2020 <NAME>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
#  SOFTWARE.

# version 1.0.0
# last modified 17 jun 2020

import datetime
from pm_crc import *

FUNCTION_READ = 0
FUNCTION_WRITE = 1
MASTER_ADDRESS = 0x81  # address of 'master' unit, i.e. this computer.
MAX_REPLY_SIZE = 200
MODEL_NAMES = ['DT', 'DT-E', 'PRT', 'PRT-E', 'PRT-HW']
SENSOR_NAMES = ['built-in air only', 'remote air only', 'floor only', 'built-in air + floor', 'remote-air + floor']
FROST_PROTECTION_NAMES = ['disabled', 'enabled']
RUN_MODE_NAMES = ['heating', 'frost protection']
PROGRAM_MODE_NAMES = ['5/2 mode', '7 day mode']
UNKNOWN_NAME = '(unknown)'
MIN_TEMP_C = 5
MAX_TEMP_C = 35
TEMP_UNIT_CELSIUS = 0
TEMP_UNIT_FAHRENHEIT = 1
TEMP_UNIT_SYMBOL_CELSIUS = u"℃"
TEMP_UNIT_SYMBOL_FAHRENHEIT = u"℉"


class PyMiser(object):
    def __init__(self, owner):
        self.crc = crc()  # CRC calculator object
        self.owner = owner  # Indigo plugin
        self.deviceInfo = dict()

    def fahrenheit (self, celsius):
        """ Converts celsius to Fahrenheit"""
        return celsius * (9.0 / 5.0) + 32

    def _parseDCB(self, dcb):
        """Returns a dictionary with the contents of the dcb"""
        result = {'frameLength': dcb[2] * 256 + dcb[1], 'address': dcb[3]}
        dcb_offset = 9  # type: int
        result['dcbLen'] = dcb[dcb_offset] * 256 + dcb[dcb_offset+1]
        vendor_id = dcb[dcb_offset+2]
        if vendor_id == 0:
            result['vendor'] = 'Heatmiser'
        else:
            result['vendor'] = 'o.e.m.'

        result['softwareVersion'] = dcb[dcb_offset + 3] & 0x7f
        result['floorLimitState'] = dcb[dcb_offset + 3] >> 7

        model_id = dcb[dcb_offset + 4]
        result['modelID'] = model_id
        if 0 <= model_id < len(MODEL_NAMES):
            result['model'] = MODEL_NAMES[model_id]
        else:
            result['model'] = UNKNOWN_NAME

        if dcb[dcb_offset + 5] == 0:
            result['temperatureFormat'] = 'C'
        else:
            result['temperatureFormat'] = 'F'

        result['switchDifferential'] = dcb[dcb_offset + 6]

        frost_protection_code = dcb[dcb_offset + 7]
        if 0 <= frost_protection_code < len(FROST_PROTECTION_NAMES):
            result['frostProtection'] = FROST_PROTECTION_NAMES[frost_protection_code]
        else:
            result['frostProtection'] = UNKNOWN_NAME

        result['calibrationOffset'] = dcb[dcb_offset +8] * 256 + dcb[dcb_offset+9]
        result['outputDelay'] = dcb[dcb_offset+10]
        # offset +11 is the address, which we already know, so we're ignoring it.
        result['upDownKeyLimit'] = dcb[dcb_offset+12]

        sensor_selection_code = dcb[dcb_offset+13]
        result['sensorSelectionCode'] = sensor_selection_code
        if 0 <= sensor_selection_code < len(SENSOR_NAMES):
            result['sensor'] = SENSOR_NAMES[sensor_selection_code]
        else:
            result['sensor'] = UNKNOWN_NAME

        result['optimumStart'] = dcb[dcb_offset+14]
        result['rateOfChange'] = dcb[dcb_offset+15]

        program_mode_code = dcb[dcb_offset+16]
        result['programModeCode'] = program_mode_code
        if 0 <= program_mode_code < len(PROGRAM_MODE_NAMES):
            result['programMode'] = PROGRAM_MODE_NAMES[program_mode_code]

        result['frostProtectTemp'] = dcb[dcb_offset+17]
        result['setRoomTemp'] = dcb[dcb_offset+18]
        result['floorMaxLimit'] = dcb[dcb_offset + 19]
        result['floorMaxLimitEnable'] = dcb[dcb_offset + 19]
        result['On'] = dcb[dcb_offset + 21]
        result['keyLock'] = dcb[dcb_offset + 22]

        run_mode_code = dcb[dcb_offset + 23]
        if 0 <= run_mode_code < len(RUN_MODE_NAMES):
            result['runMode'] = RUN_MODE_NAMES[run_mode_code]
        else:
            result['runMode'] = UNKNOWN_NAME

        result['holidayHours'] = dcb[dcb_offset + 24] * 256 + dcb[dcb_offset+25]
        result['tempHoldMinutes'] = dcb[dcb_offset + 26] * 256 + dcb[dcb_offset+27]

        remote_air_temp = (dcb[dcb_offset + 28] * 256 + dcb[dcb_offset+29]) / 10
        if remote_air_temp == 0xFFFF:
            remote_air_temp = None
        result['remoteAirTemp'] = remote_air_temp

        floor_temp = (dcb[dcb_offset + 30] * 256 + dcb[dcb_offset + 31]) / 10
        if floor_temp == 0xFFFF:
            floor_temp = None
        result['floorTemp'] = floor_temp

        air_temp = (dcb[dcb_offset + 32] * 256 + dcb[dcb_offset + 33]) / 10
        if air_temp == 0xFFFF:
            air_temp = None
        result['airTemp'] = air_temp
        result['heatingOn'] = dcb[dcb_offset+35]

        # This is where the DCB finishes for DT / DT-E model
        if model_id == 0 or model_id == 1:
            return result

        # PRT-HW has an extra byte in the DCB for hot water, so after this everything shifts by one byte.
        if model_id == 4:
            result['hotWaterOn'] = dcb[dcb_offset+36]
            # shift subsequent offsets:
            dcb_offset = dcb_offset + 1

        result['weekDayNumber'] = dcb[dcb_offset+36]
        result['timeHour'] = dcb[dcb_offset+37]
        result['timeMinute'] = dcb[dcb_offset+38]
        result['timeSecond'] = dcb[dcb_offset+39]

        return result

    def _form_frame(self, destination, start, end, payload):
        """Returns a valid frame including checksum based on start and end addresses and payload."""
        if payload is None:
            function = FUNCTION_READ  # read
            payload_len = 0
        else:
            function = FUNCTION_WRITE  # write
            payload_len = len(payload)
        frame_length = 10 + payload_len
        frame = bytearray(frame_length)
        frame[0] = destination
        frame[1] = frame_length
        frame[2] = MASTER_ADDRESS
        frame[3] = function
        frame[4] = start & 0xFF
        frame[5] = start >> 8
        frame[6] = end & 0xFF
        frame[7] = end >> 8
        if payload is not None:
            offset = 8
            for x in payload:
                frame[offset] = x
                offset = offset + 1
        return self.crc.addCCITTtoBytearray(frame)

    def _request_dcb(self, destination):
        """Requests a DCD from the device at address destination and returns the raw reply."""
        frame = self._form_frame(destination, 0, 0xFFFF, None)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE))

        if self.crc.verifyCCITTfromByteArray(reply):
            return reply
        else:
            return None

    def update_device_info(self, address):
        """Requests dcd from device at specified address and populates the deviceInfo dictionary"""
        reply = self._request_dcb(address)
        if reply is not None:
            self.deviceInfo = self._parseDCB(reply)
            if self.deviceInfo:
                return True

        return False

    def syncClock(self, address, currentRoomSetTemp, temperature_unit):
        """Updates device clock to current local time"""
        # when setting the clock, the thermostats revert to the frost temp if no schedules
        # are programmed, so we're going to re-set the temp after setting the clock.
        if not self._syncClock(address):
            self.owner.errorLog(u"syncClock: clock-sync did not work for address %d" % address)
            return False

        if not self.set_temp(address, currentRoomSetTemp, temperature_unit):
            self.owner.errorLog(u"syncClock: re-setting temperature post clock-sync did not work for address %d" % address)
            return False
        else:
            if self.owner.detailed_debug:
                self.owner.debugLog(u"syncClock: re-set temperature post clock-sync for address %d" % address)

        return True

    def _syncClock(self, address):
        dt = datetime.datetime.now()
        weekday = dt.isoweekday()
        second = dt.second + 2  # the +2 is to compensate for the time it takes to set the clock
        if second > 59:
            second = second - 59
        payload = [weekday, dt.hour, dt.minute, second]
        frame = self._form_frame(address, 43, len(payload), payload)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE)) # read up to 200 bytes

        if reply is None:
            self.owner.errorLog(u"syncClock: no reply from address %d" % address)
            return False

        if len(reply) != 7:
            self.owner.errorLog(u"syncClock: received reply with incorrect length of reply from address %d" % address)
            return False

        if not self.crc.verifyCCITTfromByteArray(reply):
            self.owner.errorLog(u"syncClock: received reply with incorrect CRC from address %d" % address)
            if self.owner.detailed_debug:
                self.owner.debugLog(u"syncClock: received OK reply from address %d" % address)
            return False
        return True

    def set_temp(self, address, temp, temperature_unit):
        """ sets the desired temperature for device with given address"""
        if address is None:
            self.owner.errorLog(u"setTemp: no address specified.")
            return False

        if temp is None:
            self.owner.errorLog(u"setTemp: no temperature specified.")
            return False

        if temperature_unit == TEMP_UNIT_FAHRENHEIT:
            minimum_set_temperature = self.fahrenheit(MIN_TEMP_C)
            maximum_set_temperature = self.fahrenheit(MAX_TEMP_C)
            temperature_unit_symbol = TEMP_UNIT_SYMBOL_FAHRENHEIT
        else:
            minimum_set_temperature = MIN_TEMP_C
            maximum_set_temperature = MAX_TEMP_C
            temperature_unit_symbol = TEMP_UNIT_SYMBOL_CELSIUS

        if temp < minimum_set_temperature:
            self.owner.errorLog(u"setTemp: specified temp (%d) is below minimum (%d deg %s)"
                                % (temp, minimum_set_temperature, temperature_unit_symbol))
            return False

        if temp > maximum_set_temperature:
            self.owner.errorLog(u"setTemp: specified temp (%d) is above maximum (%d deg %s)"
                                % (temp, maximum_set_temperature, temperature_unit_symbol))
            return False

        payload = [temp]
        frame = self._form_frame(address, 18, len(payload), payload)

        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE)) # read up to 200 bytes

        if self.crc.verifyCCITTfromByteArray(reply):
            if self.owner.detailed_debug:
                self.owner.debugLog(u"setTemp: received OK reply from address %d" % address)
            return True
        else:
            self.owner.errorLog(u"setTemp: received reply with incorrect CRC from address %d" % address)
            return False


    def set_hw_on_state(self, address, state):
        if address is None:
            self.owner.errorLog(u"set_hw_on_state: no address specified.")
            return False
        payload = [state]
        frame = self._form_frame(address, 42, len(payload), payload)
        self.owner.comm_port.write(frame)
        reply = bytearray(self.owner.comm_port.read(MAX_REPLY_SIZE))
        if self.crc.verifyCCITTfromByteArray(reply):
            self.owner.detailDebugLog(u"setTemp: received OK reply from address %d" % address)
            return True
        else:
            return False


# -*- coding: utf-8 -*- #  Implements a PyMiser low-level communication object to interface with HeatMiser DT / DT-E / PRT / PRT-E / PRT-HW #  thermostats over a serial connection and RS485 interface. #  MIT License # #  Copyright (c) 2020 <NAME> # #  Permission is hereby granted, free of charge, to any person obtaining a copy #  of this software and associated documentation files (the "Software"), to deal #  in the Software without restriction, including without limitation the rights #  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell #  copies of the Software, and to permit persons to whom the Software is #  furnished to do so, subject to the following conditions: # #  The above copyright notice and this permission notice shall be included in all #  copies or substantial portions of the Software. # #  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR #  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, #  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE #  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER #  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, #  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE #  SOFTWARE. # version 1.0.0 # last modified 17 jun 2020 # address of 'master' unit, i.e. this computer. # CRC calculator object # Indigo plugin Converts celsius to Fahrenheit Returns a dictionary with the contents of the dcb # type: int # offset +11 is the address, which we already know, so we're ignoring it. # This is where the DCB finishes for DT / DT-E model # PRT-HW has an extra byte in the DCB for hot water, so after this everything shifts by one byte. # shift subsequent offsets: Returns a valid frame including checksum based on start and end addresses and payload. # read # write Requests a DCD from the device at address destination and returns the raw reply. Requests dcd from device at specified address and populates the deviceInfo dictionary Updates device clock to current local time # when setting the clock, the thermostats revert to the frost temp if no schedules # are programmed, so we're going to re-set the temp after setting the clock. # the +2 is to compensate for the time it takes to set the clock # read up to 200 bytes sets the desired temperature for device with given address # read up to 200 bytes