diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3f0691e..66f9e89 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Build & Test on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: build: @@ -35,6 +35,6 @@ jobs: poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide poetry run flake8 . --count --exit-zero --max-complexity=12 --max-line-length=127 --statistics - - name: Test with pytest + - name: Test with pytest and generate terminal coverage report run: | - poetry run pytest + poetry run pytest --cov-report term --cov=heatmiserv3 tests/ diff --git a/.gitignore b/.gitignore index 76905da..5c34b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ test.py build/ dist/ .pytest_cache/v/cache/lastfailed +coverage.xml diff --git a/heatmiserv3/config.yml b/heatmiserv3/config.yml index b4f9deb..56e5925 100644 --- a/heatmiserv3/config.yml +++ b/heatmiserv3/config.yml @@ -64,5 +64,4 @@ prt: 60: Weekend 61: Weekend 62: Weekend - 63: Weekend - Responses: \ No newline at end of file + 63: Weekend \ No newline at end of file diff --git a/heatmiserv3/connection.py b/heatmiserv3/connection.py index dab1949..d142d4a 100644 --- a/heatmiserv3/connection.py +++ b/heatmiserv3/connection.py @@ -1,72 +1,32 @@ """This module is effectively a singleton for serial comms""" -import serial import logging -from . import constants -from heatmiserv3 import heatmiser - logging.basicConfig(level=logging.INFO) class HeatmiserUH1(object): """ Represents the UH1 interface that holds the serial - connection, and can have multiple thermostats + connection, and can manage up to 32 devices. """ - def __init__(self, ipaddress, port): - self.thermostats = {} - self._serport = serial.serial_for_url("socket://" + ipaddress + ":" + port) + def __init__(self, serialport): + + self.serialport = serialport # Ensures that the serial port has not # been left hanging around by a previous process. - serport_response = self._serport.close() - logging.info("SerialPortResponse: %s", serport_response) - self._serport.baudrate = constants.COM_BAUD - self._serport.bytesize = constants.COM_SIZE - self._serport.parity = constants.COM_PARITY - self._serport.stopbits = constants.COM_STOP - self._serport.timeout = constants.COM_TIMEOUT - self.status = False + self.serialport.close() self._open() def _open(self): - if not self.status: + if not self.serialport.is_open: logging.info("Opening serial port.") - self._serport.open() - self.status = True - return True + self.serialport.open() else: logging.info("Attempting to access already open port") - return False - + def reopen(self): - if not self.status: - self._serport.open() - self.status = True - return self.status - else: - logging.error("Cannot open serial port") + self.serialport.open() def __del__(self): logging.info("Closing serial port.") - self._serport.close() - - def registerThermostat(self, thermostat): - """Registers a thermostat with the UH1""" - try: - type(thermostat) == heatmiser.HeatmiserThermostat - if thermostat.address in self.thermostats.keys(): - raise ValueError("Key already present") - else: - self.thermostats[thermostat.address] = thermostat - except ValueError: - pass - except Exception as e: - logging.info("You're not adding a HeatmiiserThermostat Object") - logging.info(e.message) - return self._serport - - def listThermostats(self): - if self.thermostats: - return self.thermostats - else: - return None + self.serialport.close() diff --git a/heatmiserv3/crc16.py b/heatmiserv3/crc16.py new file mode 100644 index 0000000..7f64ada --- /dev/null +++ b/heatmiserv3/crc16.py @@ -0,0 +1,79 @@ + +from . import constants + +# Believe this is known as CCITT (0xFFFF) +# This is the CRC function converted directly from the Heatmiser C code +# provided in their API + +class CRC16: + """This is the CRC hashing mechanism used by the V3 protocol.""" + + LookupHigh = [ + 0x00, + 0x10, + 0x20, + 0x30, + 0x40, + 0x50, + 0x60, + 0x70, + 0x81, + 0x91, + 0xA1, + 0xB1, + 0xC1, + 0xD1, + 0xE1, + 0xF1, + ] + LookupLow = [ + 0x00, + 0x21, + 0x42, + 0x63, + 0x84, + 0xA5, + 0xC6, + 0xE7, + 0x08, + 0x29, + 0x4A, + 0x6B, + 0x8C, + 0xAD, + 0xCE, + 0xEF, + ] + + def __init__(self): + self.high = constants.BYTEMASK + self.low = constants.BYTEMASK + + def extract_bits(self, val): + """Extras the 4 bits, XORS the message data, and does table lookups.""" + # Step one, extract the Most significant 4 bits of the CRC register + thisval = self.high >> 4 + # XOR in the Message Data into the extracted bits + thisval = thisval ^ val + # Shift the CRC Register left 4 bits + self.high = (self.high << 4) | (self.low >> 4) + self.high = self.high & constants.BYTEMASK # force char + self.low = self.low << 4 + self.low = self.low & constants.BYTEMASK # force char + # Do the table lookups and XOR the result into the CRC tables + self.high = self.high ^ self.LookupHigh[thisval] + self.high = self.high & constants.BYTEMASK # force char + self.low = self.low ^ self.LookupLow[thisval] + self.low = self.low & constants.BYTEMASK # force char + + def update(self, val): + """Updates the CRC value using bitwise operations.""" + self.extract_bits(val >> 4) # High nibble first + self.extract_bits(val & 0x0F) # Low nibble + + def run(self, message): + """Calculates a CRC""" + for value in message: + self.update(value) + return [self.low, self.high] + diff --git a/heatmiserv3/formats.py b/heatmiserv3/formats.py new file mode 100644 index 0000000..46f62b5 --- /dev/null +++ b/heatmiserv3/formats.py @@ -0,0 +1,8 @@ +import struct + +commandFrameMessage = ">I" + +commandFrameReply = ">" + + +message = struct.Struct('I I I I I I I I I I I') \ No newline at end of file diff --git a/heatmiserv3/heatmiser.py b/heatmiserv3/heatmiser.py index 9472963..23c28ad 100644 --- a/heatmiserv3/heatmiser.py +++ b/heatmiserv3/heatmiser.py @@ -8,103 +8,25 @@ import logging from . import constants import importlib_resources +from . import crc16 -config_yml = importlib_resources.files('heatmiserv3').joinpath('config.yml') +config_yml = importlib_resources.files('heatmiserv3').joinpath('config.yml').read_bytes() logging.basicConfig(level=logging.INFO) -# -# Believe this is known as CCITT (0xFFFF) -# This is the CRC function converted directly from the Heatmiser C code -# provided in their API - - -class CRC16: - """This is the CRC hashing mechanism used by the V3 protocol.""" - - LookupHigh = [ - 0x00, - 0x10, - 0x20, - 0x30, - 0x40, - 0x50, - 0x60, - 0x70, - 0x81, - 0x91, - 0xA1, - 0xB1, - 0xC1, - 0xD1, - 0xE1, - 0xF1, - ] - LookupLow = [ - 0x00, - 0x21, - 0x42, - 0x63, - 0x84, - 0xA5, - 0xC6, - 0xE7, - 0x08, - 0x29, - 0x4A, - 0x6B, - 0x8C, - 0xAD, - 0xCE, - 0xEF, - ] - - def __init__(self): - self.high = constants.BYTEMASK - self.low = constants.BYTEMASK - - def extract_bits(self, val): - """Extras the 4 bits, XORS the message data, and does table lookups.""" - # Step one, extract the Most significant 4 bits of the CRC register - thisval = self.high >> 4 - # XOR in the Message Data into the extracted bits - thisval = thisval ^ val - # Shift the CRC Register left 4 bits - self.high = (self.high << 4) | (self.low >> 4) - self.high = self.high & constants.BYTEMASK # force char - self.low = self.low << 4 - self.low = self.low & constants.BYTEMASK # force char - # Do the table lookups and XOR the result into the CRC tables - self.high = self.high ^ self.LookupHigh[thisval] - self.high = self.high & constants.BYTEMASK # force char - self.low = self.low ^ self.LookupLow[thisval] - self.low = self.low & constants.BYTEMASK # force char - - def update(self, val): - """Updates the CRC value using bitwise operations.""" - self.extract_bits(val >> 4) # High nibble first - self.extract_bits(val & 0x0F) # Low nibble - - def run(self, message): - """Calculates a CRC""" - for value in message: - self.update(value) - return [self.low, self.high] - class HeatmiserThermostat(object): """Initialises a heatmiser thermostat, by taking an address and model.""" - - def __init__(self, address, model, uh1): + + def __init__(self, address, uh1): self.address = address - self.model = model + self.model = "prt" try: - with open(config_yml) as config_file: - self.config = yaml.safe_load(config_file)[model] + self.config = yaml.safe_load(config_yml)[self.model] except yaml.YAMLError as exc: logging.info("The YAML file is invalid: %s", exc) - self.conn = uh1.registerThermostat(self) + self.conn = uh1.serialport self.dcb = "" self.read_dcb() @@ -135,6 +57,7 @@ def _hm_form_message( ] if function == constants.FUNC_WRITE: msg = msg + payload + logging.debug("msg is of type, %s", type(msg)) type(msg) return msg else: @@ -147,7 +70,7 @@ def _hm_form_message_crc( data = self._hm_form_message( thermostat_id, protocol, source, function, start, payload ) - crc = CRC16() + crc = crc16.CRC16() data = data + crc.run(data) return data @@ -160,9 +83,13 @@ def _hm_verify_message_crc_uk( badresponse = 0 if protocol == constants.HMV3_ID: checksum = datal[len(datal) - 2:] + logging.info(f"Checksum value is: {checksum}") rxmsg = datal[: len(datal) - 2] - crc = CRC16() # Initialises the CRC + logging.info(f"RXmsg value is: {rxmsg}") + crc = crc16.CRC16() # Initialises the CRC expectedchecksum = crc.run(rxmsg) + logging.debug("Expected CRC: %s", expectedchecksum) + logging.debug("Actual CRC: %s", checksum) if expectedchecksum == checksum: logging.info("CRC is correct") else: @@ -248,8 +175,10 @@ def _hm_verify_message_crc_uk( def _hm_send_msg(self, message): """This is the only interface to the serial connection.""" + logging.debug("Sending serial message") try: serial_message = message + logging.debug(f"Writing {serial_message} to serial connection.") self.conn.write(serial_message) # Write a string except serial.SerialTimeoutException: serror = "Write timeout error: \n" @@ -258,10 +187,12 @@ def _hm_send_msg(self, message): byteread = self.conn.read(159) # NB max return is 75 in 5/2 mode or 159 in 7day mode datal = list(byteread) + logging.debug(f"Received message from serial {datal}") return datal def _hm_send_address(self, thermostat_id, address, state, readwrite): protocol = constants.HMV3_ID + logging.info("Sending data with protocol 3.") if protocol == constants.HMV3_ID: payload = [state] msg = self._hm_form_message_crc( @@ -272,21 +203,23 @@ def _hm_send_address(self, thermostat_id, address, state, readwrite): address, payload, ) + logging.info(f"Formed payload {msg}") else: assert 0, "Un-supported protocol found %s" % protocol string = bytes(msg) datal = self._hm_send_msg(string) - pro = protocol if readwrite == 1: + logging.debug("Verifying write CRC") verification = self._hm_verify_message_crc_uk( - 0x81, pro, thermostat_id, readwrite, 1, datal + 0x81, protocol, thermostat_id, readwrite, 1, datal ) if verification is False: logging.info("OH DEAR BAD RESPONSE") return datal else: + logging.debug("Verifying read CRC") verification = self._hm_verify_message_crc_uk( - 0x81, pro, thermostat_id, readwrite, 75, datal + 0x81, protocol, thermostat_id, readwrite, 75, datal ) if verification is False: logging.info("OH DEAR BAD RESPONSE") @@ -294,18 +227,21 @@ def _hm_send_address(self, thermostat_id, address, state, readwrite): def _hm_read_address(self): """Reads from the DCB and maps to yaml config file.""" + logging.info("Sending read frame") response = self._hm_send_address(self.address, 0, 0, 0) - lookup = self.config["keys"] - offset = self.config["offset"] - keydata = {} - for i in lookup: - try: - kdata = lookup[i] - ddata = response[i + offset] - keydata[i] = {"label": kdata, "value": ddata} - except IndexError: - logging.info("Finished processing at %d", i) - return keydata + logging.debug(response) + return response + # lookup = self.config["keys"] + # offset = self.config["offset"] + # keydata = {} + # for i in lookup: + # try: + # kdata = lookup[i] + # ddata = response[i + offset] + # keydata[i] = {"label": kdata, "value": ddata} + # except IndexError: + # logging.info("Finished processing at %d", i) + # return keydata def read_dcb(self): """ @@ -316,42 +252,44 @@ def read_dcb(self): self.dcb = self._hm_read_address() return self.dcb + +class HeatmiserThermostatPRT(HeatmiserThermostat): def get_frost_temp(self): """ Returns the temperature """ - return self._hm_read_address()[17]["value"] + return self.dcb[17] def get_target_temp(self): """ Returns the temperature """ - return self._hm_read_address()[18]["value"] + return self.dcb[18] def get_floormax_temp(self): """ Returns the temperature """ - return self._hm_read_address()[19]["value"] + return self.dcb[19] def get_status(self): - return self._hm_read_address()[21]["value"] + return self.dcb[21] def get_heating(self): - return self._hm_read_address()[23]["value"] + return self.dcb[23] def get_thermostat_id(self): - return self.dcb[11]["value"] + return self.dcb[11] def get_temperature_format(self): - temp_format = self.dcb[5]["value"] + temp_format = self.dcb[5] if temp_format == 00: return "C" else: return "F" def get_sensor_selection(self): - sensor = self.dcb[13]["value"] + sensor = self.dcb[13] answers = { 0: "Built in air sensor", 1: "Remote air sensor", @@ -362,7 +300,7 @@ def get_sensor_selection(self): return answers[sensor] def get_program_mode(self): - mode = self.dcb[16]["value"] + mode = self.dcb[16] modes = {0: "5/2 mode", 1: "7 day mode"} return modes[mode] @@ -371,16 +309,16 @@ def get_frost_protection(self): def get_floor_temp(self): return ( - (self.dcb[31]["value"] / 10) - if (int(self.dcb[13]["value"]) > 1) - else (self.dcb[33]["value"] / 10) + (self.dcb[31] / 10) + if (int(self.dcb[13]) > 1) + else (self.dcb[33] / 10) ) def get_sensor_error(self): - return self.dcb[34]["value"] + return self.dcb[34] def get_current_state(self): - return self.dcb[35]["value"] + return self.dcb[35] def set_frost_protect_mode(self, onoff): self._hm_send_address(self.address, 23, onoff, 1) @@ -403,6 +341,8 @@ def set_target_temp(self, temperature): return False else: self._hm_send_address(self.address, 18, temperature, 1) + self.read_dcb() + assert self.get_target_temp() == temperature return True def set_floormax_temp(self, floor_max): diff --git a/poetry.lock b/poetry.lock index 015d714..12468a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,24 @@ files = [ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + [[package]] name = "colorama" version = "0.4.6" @@ -89,6 +107,17 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -103,6 +132,20 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "flake8" version = "7.0.0" @@ -130,9 +173,6 @@ files = [ {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"}, ] -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"] @@ -148,6 +188,75 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipython" +version = "8.21.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.21.0-py3-none-any.whl", hash = "sha256:1050a3ab8473488d7eee163796b02e511d0735cf43a04ba2a8348bd0f2eaf8a5"}, + {file = "ipython-8.21.0.tar.gz", hash = "sha256:48fbc236fbe0e138b88773fa0437751f14c3645fb483f1d4c5dee58b37e5ce73"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.23)", "pandas", "pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "pickleshare", "pytest (<8)", "pytest-asyncio (<0.22)", "testpath", "trio"] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mccabe" version = "0.7.0" @@ -175,6 +284,17 @@ build = ["blurb", "twine", "wheel"] docs = ["sphinx"] test = ["pytest", "pytest-cov"] +[[package]] +name = "mock-serial" +version = "0.0.1" +description = "A mock utility for testing serial devices" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mock_serial-0.0.1-py3-none-any.whl", hash = "sha256:b6b8cc10c302354bf3ca270a3d4d6bf199c4bbe41478c65046db8f30ea967675"}, + {file = "mock_serial-0.0.1.tar.gz", hash = "sha256:9c92de7495ac375717bbbeb4993534079d2e634f3298d4c400420c4046add06e"}, +] + [[package]] name = "packaging" version = "23.2" @@ -186,6 +306,35 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + [[package]] name = "pluggy" version = "1.4.0" @@ -201,6 +350,45 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "prompt-toolkit" +version = "3.0.43" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, + {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycodestyle" version = "2.11.1" @@ -223,6 +411,21 @@ files = [ {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] +[[package]] +name = "pygments" +version = "2.17.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[package.extras] +plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyserial" version = "3.5" @@ -351,6 +554,36 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "tomli" version = "2.0.1" @@ -363,21 +596,32 @@ files = [ ] [[package]] -name = "zipp" -version = "3.17.0" -description = "Backport of pathlib-compatible object wrapper for zip files" +name = "traitlets" +version = "5.14.1" +description = "Traitlets Python configuration system" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, + {file = "traitlets-5.14.1-py3-none-any.whl", hash = "sha256:2e5a030e6eff91737c643231bfcf04a65b0132078dad75e4936700b213652e74"}, + {file = "traitlets-5.14.1.tar.gz", hash = "sha256:8585105b371a04b8316a43d5ce29c098575c2e477850b62b848b964f1444527e"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<7.5)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "a26b0702e6fc941f339621ca4976a15af2c1e3616360030efdb289cc2194b724" +python-versions = "^3.10" +content-hash = "3cac5fd20a7a5cf81a8215eddd8b9a40b353dfa791d5e8f8730d01269e615f6b" diff --git a/pyproject.toml b/pyproject.toml index b24ec7e..bbbcad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,13 @@ license = "MIT" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.10" appdirs = "^1.4.4" pyserial = "^3.5" pyserial-asyncio = "^0.6" pyyaml = "^6.0.1" importlib-resources = "^6.1.1" +mock-serial = "^0.0.1" [tool.poetry.group.test.dependencies] @@ -20,6 +21,7 @@ flake8 = "^7.0.0" pytest = "^8.0.0" pytest-cov = "^4.1.0" mock = "^5.1.0" +ipython = "^8.21.0" [build-system] requires = ["poetry-core"] diff --git a/schema/README.md b/schema/README.md new file mode 100644 index 0000000..b53c72e --- /dev/null +++ b/schema/README.md @@ -0,0 +1,116 @@ +# Schema Files + +I couldn't work out how to set the schema files appropriately, but it feels like jsonschema could be useful; so giving that a try. + + +## Messaging frames + +This folder contains some example schemas (.schema.json) and some examples in examples/folder to validate them. + +### message.schema.json + +This is the command frame format. + + +| Byte Order | Field | Value | +| -----------|-------|-------| +| 0 | destinationAddress | [1,32] | +| 1 | frameLength | 10 (when read), n+10 (when write) | +| 2 | sourceAddress | [129,160] The address this command is originating from | +| 3 | functionCode | 1 (write), 0 (read) | +| 4 | startAddressLow | [0, DCB_Len-1] different models of stats have different lengths of DCB. | +| 5 | startAddressHigh | [0, DCB_Len-1] different models of stats have different lengths of DCB. | +| 6 | endAddressLow | [1, DCB_Len], 255 means read the whole DCB | +| 7 | endAddressHigh | [1, DCB_Len], 255 means read the whole DCB | +| 8 ..(n+8) | contents | If function code is 0, no this segment | +| N+1 | crcLow | CRC code from 0 to n+8, crc code not included in calculation | +| N+2 | crcHigh | CRC code from 0 to n+8, crc code not included in calculation | + + +### reply.schema.json + +This is the command frame response format. + + +| Byte Order | Field | Value | +| -----------|-------|-------| +| 0 | destinationAddress | [129,160] | +| 1 | frameLengthLow | 7 (when write), 11+n (when read) - crc code included | +| 2 | frameLengthHigh | 7 (when write), 11+n (when read) - crc code included | +| 3 | sourceAddress | [1,32], broadcast frame has no reply | +| 4 | functionCode | 01 (write), 00 (read) | +| 5 | startAddressLow | If function code is 1, no these segments | +| 6 | startAddressHigh | If function code is 1, no these segments | +| 7 | endAddressLow | If function code is 1, no these segments| +| 8 | endAddressHigh | If function code is 1, no these segments. | +| 9 ..(n+9) | contents | Contents for reading | +| N+1 | crcLow | CRC code of this frame, crc code not included in calculation | +| N+2 | crcHigh | CRC code of this frame, crc code not included in calculation | + +## Reading and writing a COMMAND frame + +Only one master can be connected to a RS485 network and originate a session. The protocol does not allow for bus arbitration, so if more than one master is connected, data corruption will occur if there is a conflict. + +The master node can read all parts of the DCB from a slave node, and can write to some parts of the DCB by sending command frames. + +### Reading + +A broadcast frame cannot be used for reading data. + +Read the entire DCB for each device to ensure you have appropriate data before making a write command. + +To read a DCB (e.g. at destinationAddress 1), send the frame below: +```json +{ + "thermostat": 1, + "operation": 10, + "sourceAddress": 129, + "readFunctionCode": 0, + "startAddressLow": 0, + "startAddressHigh": 0, + "endAddressLow": 255, + "endAddressHigh": 255, + "crcLow": 212, + "crcHigh": 141, +} +``` + +N.B the crcLow and crcHigh are calculated by the CRC algorithm. + +Care should be taken when reading parts of the DCB, the addresses of all parameters must be contiguous, otherwise there will be no reply. + +If the DCB length of a particular model is shorter than that which we are attempting to read, then we will get no reply. + +### Writing + +Broadcast frames can be sent but do not generate a reply and receipt is not guaranteed. + +If they are used, the should be sent three times, to ensure reliability. + +Some DCB params are readonly, wherears others are read/write. +Sending a write comment to a read address will fail silently. + +See the DCB Schemas for each thermostat to note which are write and which are read/write. + +Within the DCB, any data can be read at random, but when writing you must write to the function starting address (it's lowest address). + +Example, if we want to modify the frost setting (address 17), and set the room temp (address 18), we cannot do them both at the same time, as even though they're contiguous, they are across two function groups. + +For parameters with two bytes, we only need to send one command to write two bytes, with the address of the low 8bit as the starting address. + +## DCB Structures + +The library was initially written for the PRT model thermostat over RS485. The following structures are defined in the schemas, but should allow you to understand how to access and write more helper functions for the thermostat class. + +For those with 2 byte parameters, when reading, high 8 bits are sent first, while for writing the low 8 bit should be sent first since the MCU used on the RS485 model thermostats is in big-endian format. + +Note: For backwards compatibility, Wifi devices output their data using the following data structures, but this library currently does not support communication over Wifi. + +### DT/DT-E/PRT/PRT-E + +Each model of thermostat uses different DCB structures, so data returned and the data length will differ. + +DT/DT-E has 36 bytes. +PRT/PRT-E has 64 bytes in 5/2 day programming mode. +PRT/PRT-E has 148 bytes in 7 day programming mode. + diff --git a/schema/dcb.schema.json b/schema/dcb.schema.json new file mode 100644 index 0000000..0fea10d --- /dev/null +++ b/schema/dcb.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/andylockran/heatmiserv3/schema/message.schema.json", + "title": "HeatmiserV3 - Message schema", + "description": "Messaging schema for sending messages via the heatmiserV3 protocol", + "type": "object", + "properties": { + "destinationAddress": { + "description": "Destination Address", + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "frameLength": { + "description": "frameLength, crc included. 10 (when read) or n+10 (when write)", + "type": "integer" + }, + "sourceAddress": { + "description": "The sourceAddress the query is coming from", + "type": "integer", + "minimum": 129, + "maximum": 160 + }, + "functionCode": { + "description": "1 = write, 0 = read", + "type": "integer", + "minimum": 0, + "maximum": 1 + }, + "startAddressLow": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "startAddressHigh": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressLow": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressHigh": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "contents": { + "description": "Content to be written (n bytes). If function code is 0, no this segment.", + "type": "array" + }, + "crcLow": { + "description": "CRC low bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "crcHigh": { + "description": "CRC high bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + } + \ No newline at end of file diff --git a/schema/examples/message.json b/schema/examples/message.json new file mode 100644 index 0000000..e7b120b --- /dev/null +++ b/schema/examples/message.json @@ -0,0 +1,10 @@ +{ + "thermostat": 1, + "operation": 10, + "sourceAddress": 129, + "readFunctionCode": 0, + "startAddressLow": 0, + "startAddressHigh": 0, + "endAddressLow": 255, + "endAddressHigh": 255 +} \ No newline at end of file diff --git a/schema/message.schema.json b/schema/message.schema.json new file mode 100644 index 0000000..3059af6 --- /dev/null +++ b/schema/message.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/andylockran/heatmiserv3/schema/message.schema.json", + "title": "HeatmiserV3 - Message schema", + "description": "Messaging schema for sending messages via the heatmiserV3 protocol", + "type": "object", + "properties": { + "destinationAddress": { + "description": "Destination Address", + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "frameLength": { + "description": "frameLength, crc included. 10 (when read) or n+10 (when write)", + "type": "integer" + }, + "sourceAddress": { + "description": "The sourceAddress the query is coming from", + "type": "integer", + "minimum": 129, + "maximum": 160 + }, + "functionCode": { + "description": "1 = write, 0 = read", + "type": "integer", + "minimum": 0, + "maximum": 1 + }, + "startAddressLow": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "startAddressHigh": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressLow": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressHigh": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "contents": { + "description": "Content to be written (n bytes). If function code is 0, no this segment.", + "type": "array" + }, + "crcLow": { + "description": "CRC low bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "crcHigh": { + "description": "CRC high bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } +} diff --git a/schema/reply.schema.json b/schema/reply.schema.json new file mode 100644 index 0000000..27617d9 --- /dev/null +++ b/schema/reply.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/andylockran/heatmiserv3/schemma/message.schema.json", + "title": "HeatmiserV3 - Reply schema", + "description": "Messaging schema for receiving messages via the heatmiserV3 protocol", + "type": "object", + "properties": { + "destinationAddress": { + "description": "Destination Address", + "type": "integer", + "minimum": 129, + "maximum": 160 + }, + "frameLengthLow": { + "description": "frameLength, crc included. 7 (when write) or 11+n (when read)", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "frameLengthHigh": { + "description": "frameLength, crc included. 7 (when write) or 11+n (when read)", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "sourceAddress": { + "description": "The sourceAddress the response is coming from. Broadcast frame has no reply.", + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "functionCode": { + "description": "01 = write, 00 = read", + "type": "number", + "minimum": 00, + "maximum": 01 + }, + "startAddressLow": { + "description": "If function code is 1 (write), no these segments.", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "startAddressHigh": { + "description": "If function code is 1 (write), no these segments.", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressLow": { + "description": "If function code is 1 (write), no these segments.", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "endAddressHigh": { + "description": "If function code is 1 (write), no these segments.", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "contents": { + "description": "Content for reading. ", + "type": "array" + }, + "crcLow": { + "description": "CRC low bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "crcHigh": { + "description": "CRC high bit (crc is not included)", + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + } + \ No newline at end of file diff --git a/tests/fixtures/living-prt.json b/tests/fixtures/living-prt.json new file mode 100644 index 0000000..7271d0b --- /dev/null +++ b/tests/fixtures/living-prt.json @@ -0,0 +1,69 @@ +{ + "0": { "label": "High 8 bit", "value": 0 }, + "1": { "label": "Low 8 bit", "value": 64 }, + "2": { "label": "Vendor ID", "value": 0 }, + "3": { + "label": "0-6 bits = Version, bit 7 = floor limit state.", + "value": 15 + }, + "4": { "label": "Model", "value": 2 }, + "5": { "label": "Temperature format", "value": 0 }, + "6": { "label": "Switch Differential", "value": 2 }, + "7": { "label": "Frost Protection Mode", "value": 1 }, + "8": { "label": "Calibration - high 8 bit", "value": 0 }, + "9": { "label": "Calibration - low 8 bit", "value": 0 }, + "10": { "label": "Output delay", "value": 0 }, + "11": { "label": "Address", "value": 1 }, + "12": { "label": "Up down key limit", "value": 0 }, + "13": { "label": "Sensor Selection", "value": 0 }, + "14": { "label": "Optimum start", "value": 0 }, + "15": { "label": "Rate of change", "value": 20 }, + "16": { "label": "Program mode", "value": 0 }, + "17": { "label": "Frost protect temp", "value": 15 }, + "18": { "label": "Set room temp", "value": 21 }, + "19": { "label": "Floor max limit", "value": 28 }, + "20": { "label": "Floor max limit enable/disable", "value": 1 }, + "21": { "label": "On/off", "value": 1 }, + "22": { "label": "Key lock", "value": 0 }, + "23": { "label": "Run mode", "value": 0 }, + "24": { "label": "Holiday hours", "value": 0 }, + "25": { "label": "Holiday hours", "value": 0 }, + "26": { "label": "Temp hold mins", "value": 0 }, + "27": { "label": "Temp hold mins", "value": 0 }, + "28": { "label": "Remote air temp", "value": 255 }, + "29": { "label": "Remote air temp", "value": 255 }, + "30": { "label": "Floor temp", "value": 255 }, + "31": { "label": "Floor temp", "value": 255 }, + "32": { "label": "Built in air temp", "value": 0 }, + "33": { "label": "Built in air temp", "value": 201 }, + "34": { "label": "Error code", "value": 0 }, + "35": { "label": "Current state", "value": 0 }, + "36": { "label": "Week 1~7", "value": 4 }, + "37": { "label": "Hour", "value": 16 }, + "38": { "label": "Min", "value": 22 }, + "39": { "label": "Sec", "value": 18 }, + "40": { "label": "Weekday", "value": 24 }, + "41": { "label": "Weekday", "value": 0 }, + "42": { "label": "Weekday", "value": 19 }, + "43": { "label": "Weekday", "value": 24 }, + "44": { "label": "Weekday", "value": 0 }, + "45": { "label": "Weekday", "value": 5 }, + "46": { "label": "Weekday", "value": 24 }, + "47": { "label": "Weekday", "value": 0 }, + "48": { "label": "Weekday", "value": 19 }, + "49": { "label": "Weekday", "value": 24 }, + "50": { "label": "Weekday", "value": 0 }, + "51": { "label": "Weekday", "value": 5 }, + "52": { "label": "Weekend", "value": 5 }, + "53": { "label": "Weekend", "value": 30 }, + "54": { "label": "Weekend", "value": 23 }, + "55": { "label": "Weekend", "value": 22 }, + "56": { "label": "Weekend", "value": 0 }, + "57": { "label": "Weekend", "value": 20 }, + "58": { "label": "Weekend", "value": 24 }, + "59": { "label": "Weekend", "value": 0 }, + "60": { "label": "Weekend", "value": 16 }, + "61": { "label": "Weekend", "value": 24 }, + "62": { "label": "Weekend", "value": 0 }, + "63": { "label": "Weekend", "value": 16 } +} diff --git a/tests/serial_stubs.py b/tests/serial_stubs.py new file mode 100644 index 0000000..143d4d2 --- /dev/null +++ b/tests/serial_stubs.py @@ -0,0 +1,131 @@ + +from heatmiserv3 import crc16, constants, connection, heatmiser +import serial +from mock_serial import MockSerial +from mock import patch +import logging, sys + + +logging.basicConfig( + stream=sys.stdout, + level=logging.DEBUG, + format="%(levelname)s - %(message)s" +) + +class MockHeatmiserPRT(heatmiser.HeatmiserThermostatPRT): + """ + This is a mock of the PRT Thermostat in 64 bit mode (5/2 day programming mode) + + If you need this to work with the 7 day programming mode, it'll need some more work. + """ + def __init__(self, address, uh1): + super(MockHeatmiserPRT, self).__init__(address, uh1) + self.dcb = bytearray([0,64,0,15,2,0,2,1,0,0,0,1,0,0,0,20,0,15,21,28,1,1,0,0,0,0,0,0,255,255,255,255,0,201,0,0,4,16,22,18,24,0,19,24,0,5,24,0,19,24,0,5,5,30,23,22,0,20,24,0,16,24,0,16]) + + def read_dcb(self): + return self.dcb + + def generate_response(self, message): + """ + Calculates the CRC of the response + """ + logging.info("Processing mock response.") + crc = crc16.CRC16() + logging.debug(f"Generating response to {message}") + response=bytearray(73) + logging.debug(type(response)) + response[0] = list(message)[2] # Destination address + response[1] = 7 if list(message)[1] > 10 else 75 ## Low 8 bit + response[2] = 0 ## High 8 bit + response[3] = list(message)[0] ## Source address + response[4] = 0 ## Function Code (00 read, 01 write) + response[5] = list(message)[5] ## Start Address (low 8 bit) + response[6] = list(message)[6] ## Start Address (high 8 bit) + response[7] = 64 ## Action number of bytes read (low 8 bit) + response[8] = 0 ## Action number of bytes read (high 8 bit) + logging.debug("Bytearray response is: %s", response) + response[9:73] = self.dcb + data = list(response) + data = data + crc.run(data) + checksum = data[len(data) - 2:] + logging.info(f"Checksum value is: {checksum}") + rxmsg = data[: len(data) - 2] + logging.info(f"RXmsg value is: {rxmsg}") + logging.debug("Final response is: %s", data ) + logging.debug("Bytearray response is: %s", data) + return bytearray(data) + + def generate_reply(self, message): + logging.debug(f"Attempting to write {message}") + crc = crc16.CRC16() + logging.debug("Testing logging") + response = bytearray(5) + response[0] = list(message)[2] # Destination address + response[1] = 7 if list(message)[1] > 10 else 75 ## Low 8 bit + response[2] = 0 ## High 8 bit + response[3] = list(message)[0] ## Source address + response[4] = 1 ## Write + logging.debug(f"Printing response {response}") + data = list(response) + data = data + crc.run(data) + logging.debug(f"Writing response to write request {data}") + return bytearray(data) + + def process_message(self, message): + logging.info("Processing mock message.") + logging.debug(f"Processing {message}") + data = list(message) + if data[3] == 1: + logging.debug("Processing write message") + return self.generate_reply(message) + elif data[3] == 0: + logging.debug("Processing read messages") + return self.generate_response(message) + else: + logging.debug(data[3]) + logging.error("Cannot process message") + raise Exception("Invalid functionCode, must be 1 or 0 for write or read.") + + +MockSerialPort = MockSerial() +MockSerialPort.open() +device = MockSerialPort +serialport = serial.Serial(device.port) +# self.serialport = serial.serial_for_url("socket://" + ipaddress + ":" + port) +### Serial Connection Settings +serialport.baudrate = constants.COM_BAUD +serialport.bytesize = constants.COM_SIZE +serialport.parity = constants.COM_PARITY +serialport.stopbits = constants.COM_STOP +serialport.timeout = constants.COM_TIMEOUT + + +MockUH1 = connection.HeatmiserUH1(serialport) +thermo1 = MockHeatmiserPRT(1, MockUH1) +thermo2 = MockHeatmiserPRT(2, MockUH1) +thermo3 = MockHeatmiserPRT(3, MockUH1) + + +## Mock Thermostat 1 +MockSerialPort.stub( + receive_bytes=b'\x01\n\x81\x00\x00\x00\xff\xff,\t', + send_bytes=thermo1.process_message(b'\x01\n\x81\x00\x00\x00\xff\xff,\t') + # send_bytes=calculate_crc_in_bytearray(b'\x01\n\x81\x00\x00\x00\xff\xff,\t') +) + +## Mock Thermostat 2 +MockSerialPort.stub( + receive_bytes=b'\x02\n\x81\x00\x00\x00\xff\xffY\xc1', + send_bytes=thermo2.process_message(b'\x02\n\x81\x00\x00\x00\xff\xffY\xc1') +) + +# Mock Thermostat 3 +MockSerialPort.stub( + receive_bytes=b'\x03\n\x81\x00\x00\x00\xff\xff\x8a\x86', + send_bytes=thermo3.process_message(b'\x03\n\x81\x00\x00\x00\xff\xff,\t,\xd4\x8d') +) + +MockSerialPort.stub( + receive_bytes=b'\x01\x0b\x81\x01\x12\x00\x01\x00\x16\xd8v', + send_bytes=thermo1.process_message(b'\x01\x0b\x81\x01\x12\x00\x01\x00\x16\xd8v') +) \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..2c578bd --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,25 @@ +import unittest +from heatmiserv3 import connection, constants +from .serial_stubs import MockUH1 + +class TestConnection(unittest.TestCase): + + def setUp(self): + """Initialise the serial port and the connection""" + + self.uh1 = MockUH1 + assert self.uh1.serialport.is_open == True + + """Tests for the connection code""" + def test_init(self): + assert self.uh1.serialport.is_open == True + + def test_open(self): + self.uh1._open() + assert self.uh1.serialport.is_open == True + + def test_reopen(self): + self.uh1.serialport.close() + assert self.uh1.serialport.is_open == False + self.uh1.reopen() + assert self.uh1.serialport.is_open == True \ No newline at end of file diff --git a/tests/test_crc.py b/tests/test_crc.py new file mode 100644 index 0000000..666ada2 --- /dev/null +++ b/tests/test_crc.py @@ -0,0 +1,40 @@ + +import unittest +from heatmiserv3 import crc16 + +class TestCRCMethods(unittest.TestCase): + """Tests for the CRC Methods""" + + def test_crc16(self): + """Test that CRC matches""" + crc = crc16.CRC16() + assert crc.high == crc.low + + def test_update_4_bits(self): + """Updating 4 bits""" + crc = crc16.CRC16() + assert crc.high == crc.low + crc.extract_bits(4) + assert crc.high == 78 + assert crc.low == 155 + + def test_update_8_bits(self): + crc = crc16.CRC16() + assert crc.high == crc.low + crc.extract_bits(8) + assert crc.high == 143 + assert crc.low == 23 + + def test_crc16_update(self): + """check that updates work with other numbers""" + crc = crc16.CRC16() + crc.update(4) + assert crc.high == 161 + assert crc.low == 116 + + def test_crc_run(self): + """Checks that crc runs against a single 8 byte run""" + example = [1,2,3,4,5,6,7,8] + crc = crc16.CRC16() + checksum = crc.run(example) + assert checksum == [ 146, 71 ] \ No newline at end of file diff --git a/tests/test_heatmiser.py b/tests/test_heatmiser.py index 01c3151..ddd650f 100644 --- a/tests/test_heatmiser.py +++ b/tests/test_heatmiser.py @@ -1,46 +1,33 @@ """Tests for HeatmiserThermostat and CRC Methods""" import unittest from heatmiserv3 import heatmiser +from heatmiserv3 import connection +from heatmiserv3 import crc16 +import logging, sys +from heatmiserv3.formats import message +from .serial_stubs import MockUH1 -class TestCRCMethods(unittest.TestCase): - """Tests for the CRC Methods""" - - def test_crc16(self): - """Test that CRC matches""" - crc = heatmiser.CRC16() - assert crc.high == crc.low - - def test_update_4_bits(self): - """Updating 4 bits""" - crc = heatmiser.CRC16() - assert crc.high == crc.low - crc.extract_bits(4) - assert crc.high == 78 - assert crc.low == 155 - - def test_update_8_bits(self): - crc = heatmiser.CRC16() - assert crc.high == crc.low - crc.extract_bits(8) - assert crc.high == 143 - assert crc.low == 23 - - def test_crc16_update(self): - """check that updates work with other numbers""" - crc = heatmiser.CRC16() - crc.update(4) - assert crc.high == 161 - assert crc.low == 116 +logging.basicConfig( + stream=sys.stdout, + level=logging.DEBUG, + format="%(levelname)s - %(message)s" +) class TestHeatmiserThermostatMethods(unittest.TestCase): """ - Tests for the Heatmiser functions. + This test case tests the PRT Thermostat in 5/2 mode, where there are 64 bytes of information """ def setUp(self): # @TODO - Setup the mock interface for serial to write the tests. - # self.uh1 = connection.HeatmiserUH1('192.168.1.57', '102') - # self.thermostat1 = heatmiser.HeatmiserThermostat(1, 'prt', self.uh1) - pass + self.uh1 = MockUH1 + + def test_thermo1_temperature(self): + """ Initialises the thermo1 thermostat, and checks the temperature is at 21*C""" + thermo1 = heatmiser.HeatmiserThermostat(1, self.uh1) + assert type(thermo1.dcb) == list + + def tearDown(self): + pass \ No newline at end of file diff --git a/tests/test_heatmiser_prt.py b/tests/test_heatmiser_prt.py new file mode 100644 index 0000000..fc983dc --- /dev/null +++ b/tests/test_heatmiser_prt.py @@ -0,0 +1,38 @@ +"""Tests for HeatmiserThermostat and CRC Methods""" +import unittest +from heatmiserv3 import heatmiser +from heatmiserv3 import connection +from heatmiserv3 import crc16 +import logging, sys +from heatmiserv3.formats import message + +from .serial_stubs import MockUH1, MockHeatmiserPRT + +logging.basicConfig( + stream=sys.stdout, + level=logging.DEBUG, + format="%(levelname)s - %(message)s" +) + + +class TestHeatmiserThermostatMethods(unittest.TestCase): + """ + This test case tests the PRT Thermostat in 5/2 mode, where there are 64 bytes of information + """ + + def setUp(self): + # @TODO - Setup the mock interface for serial to write the tests. + self.uh1 = MockUH1 + self.thermo1 = MockHeatmiserPRT(1, self.uh1) + + def test_thermo1_temperature(self): + """ Initialises the thermo1 thermostat, and checks the temperature is at 21*C""" + assert self.thermo1.get_target_temp() == 21 + + def xtest_thermo1_temperature(self): + """ Initialises the thermo1 thermostat, and checks the temperature is at 21*C""" + # self.thermo1.set_target_temp(22) + # assert self.thermo1.get_target_temp() == 22 + + def tearDown(self): + pass \ No newline at end of file diff --git a/tests/test_stubs.py b/tests/test_stubs.py new file mode 100644 index 0000000..d39a606 --- /dev/null +++ b/tests/test_stubs.py @@ -0,0 +1,39 @@ +"""Tests for HeatmiserThermostat and CRC Methods""" +import unittest +from heatmiserv3 import heatmiser +from heatmiserv3 import connection +from heatmiserv3 import crc16 +import logging, sys +from heatmiserv3.formats import message + +from .serial_stubs import MockUH1, MockHeatmiserPRT + +logging.basicConfig( + stream=sys.stdout, + level=logging.DEBUG, + format="%(levelname)s - %(message)s" +) + + +class TestStubs(unittest.TestCase): + """ + This test case tests the PRT Thermostat in 5/2 mode, where there are 64 bytes of information + """ + + def setUp(self): + # @TODO - Setup the mock interface for serial to write the tests. + self.uh1 = MockUH1 + self.thermo1 = MockHeatmiserPRT(1, self.uh1) + + def test_thermo1_get_target_temperature(self): + """ Initialises the thermo1 thermostat, and checks the temperature is at 21*C""" + assert self.thermo1.get_target_temp() == 21 + + # def test_thermo1_set_target_temperature(self): + # """ Initialises the thermo1 thermostat, and checks the temperature is at 21*C""" + # self.thermo1.set_target_temp(22) + # assert self.thermo1.get_target_temp() == 22 + + + def tearDown(self): + pass \ No newline at end of file