├── .coveragerc ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE ├── Makefile ├── README.rst ├── maxcube ├── __init__.py ├── commander.py ├── connection.py ├── cube.py ├── deadline.py ├── device.py ├── message.py ├── room.py ├── thermostat.py ├── wallthermostat.py └── windowshutter.py ├── prog.py ├── pyproject.toml ├── sample └── sample.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── result.txt ├── test_commander.py ├── test_connection.py ├── test_cube.py ├── test_deadline.py ├── test_message.py └── test_thermostat.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = maxcube 3 | 4 | omit = 5 | maxcube/connection.py 6 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: python-maxcube-api commit validation 4 | 5 | on: 6 | - push 7 | - pull_request 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [3.7, 3.8, 3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install coveralls 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Pre-commit hooks 29 | uses: pre-commit/action@v2.0.0 30 | with: 31 | extra_args: --hook-stage=push 32 | - name: Unit Test Coverage 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: | 36 | coverage run setup.py test 37 | coveralls --service=github 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | maxcube.egg-info 3 | maxcube/__pycache__ 4 | tests/__pycache__ 5 | .cache/v/cache 6 | .coverage 7 | setup.cpython-35-PYTEST.pyc 8 | python_maxcube_api.egg-info 9 | dist 10 | *.pyc 11 | build 12 | maxcube_api.egg-info 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | args: 7 | - --safe 8 | - --quiet 9 | files: ^((maxcube|sample|tests)/.+)?[^/]+\.py$ 10 | - repo: https://github.com/codespell-project/codespell 11 | rev: v2.0.0 12 | hooks: 13 | - id: codespell 14 | args: 15 | - --ignore-words-list=maxcube 16 | - --skip="./.*" 17 | - --quiet-level=2 18 | exclude_types: [csv, json] 19 | exclude: ^tests/fixtures/ 20 | - repo: https://gitlab.com/pycqa/flake8 21 | rev: 3.9.0 22 | hooks: 23 | - id: flake8 24 | additional_dependencies: 25 | - pycodestyle==2.7.0 26 | - pyflakes==2.3.1 27 | - pydocstyle==6.0.0 28 | - flake8-comprehensions==3.4.0 29 | - flake8-noqa==1.1.0 30 | files: ^(maxcube|sample|tests)/.+\.py$ 31 | - repo: https://github.com/PyCQA/isort 32 | rev: 5.7.0 33 | hooks: 34 | - id: isort 35 | - repo: https://github.com/pre-commit/pre-commit-hooks 36 | rev: v3.2.0 37 | hooks: 38 | - id: check-executables-have-shebangs 39 | stages: [manual] 40 | - id: no-commit-to-branch 41 | stages: [commit] 42 | args: 43 | - --branch=master 44 | - repo: https://github.com/adrienverge/yamllint.git 45 | rev: v1.24.2 46 | hooks: 47 | - id: yamllint 48 | - repo: https://github.com/pre-commit/mirrors-prettier 49 | rev: v2.2.1 50 | hooks: 51 | - id: prettier 52 | stages: [manual] 53 | - repo: https://github.com/cdce8p/python-typing-update 54 | rev: v0.3.2 55 | hooks: 56 | # Run `python-typing-update` hook manually from time to time 57 | # to update python typing syntax. 58 | # Will require manual work, before submitting changes! 59 | - id: python-typing-update 60 | stages: [manual] 61 | args: 62 | - --py38-plus 63 | - --force 64 | - --keep-updates 65 | files: ^(homeassistant|tests|script)/.+\.py$ 66 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ### Version 0.4.0 2 | * Issues fixed: 3 | - [MaxCube reset to fatory defaults](https://github.com/hackercowboy/python-maxcube-api/issues/12) 4 | - [Unable to change back to scheduled temperature (auto mode)](https://github.com/hackercowboy/python-maxcube-api/issues/24 5 | 6 | * Breaking changes: 7 | - Increased minimum supported version of Python to >= 3.7 8 | - MaxCubeConnection object removed. MaxCube constructor now receives 9 | the host and port parameters directly 10 | - The connection is not released after each command is send. This 11 | can block other applications to connect to the Max! Cube. You 12 | can call cube disconnect() method to release the connection 13 | manually. 14 | 15 | ### Version 0.4.1 16 | * Several minor changes: 17 | - Allow to rollback to non-persistent connections to keep using original software for unsupported operations 18 | - Serial number of the cube was not extracted from handshake 19 | - When changing to auto mode, use weekly programme to fetch current temperature 20 | - Make MAX! Cube TCP port optional 21 | 22 | ### Version 0.4.2 23 | * Bug fixes: 24 | - Interpret correctly S command error responses (https://github.com/home-assistant/core/issues/49075) 25 | - Support application timezone configuration (https://github.com/home-assistant/core/issues/49076) 26 | * Improvements in device logging 27 | * Build improvements: 28 | * Move from Travis to Github Actions to execute validation actions 29 | * Add code style and quality checks to the validation actions 30 | 31 | ### Version 0.4.3 32 | * Improve logging for invalid message and expired deadlines 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 David Übelacker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deploy: 2 | rm -rf dist 3 | python setup.py bdist_wheel 4 | twine upload dist/* 5 | 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | eQ-3/ELV MAX! Cube Python API |Build Status| |Coverage Status| 2 | =============================================================== 3 | 4 | A python api to control the Max! Cube thermostats: 5 | 6 | - get basic info about the cube itself 7 | - get info about the max thermostats connected to the cube (mode, temperatures ...) 8 | 9 | Basic usage: 10 | 11 | .. code:: python 12 | 13 | from maxcube.cube import MaxCube 14 | 15 | cube = MaxCube('192.168.0.20') 16 | 17 | for device in cube.devices: 18 | print(device.name) 19 | print(device.actual_temperature) 20 | 21 | This api was build for the integration of the Max! thermostats into `Home Assistant `__ and 22 | mostly only covers the functions needed for the integration. 23 | 24 | It does also include functions needed to save and restore thermostat programmes. For example: 25 | 26 | 27 | .. code:: shell 28 | 29 | # dump programmes (and other data) to a JSON file 30 | python3 prog.py dump --host=192.168.0.11 > backup.json 31 | 32 | # load programmes (not other data!) from a JSON file 33 | python3 prog.py load --host=192.168.0.11 < backup.json 34 | 35 | 36 | Running tests 37 | ============= 38 | 39 | .. code:: python 40 | 41 | python3 -m unittest discover tests/ 42 | 43 | Acknowledgements 44 | ================ 45 | 46 | Thanks to: 47 | 48 | - `https://github.com/Bouni/max-cube-protocol `__ 49 | - `https://github.com/ercpe/pymax `__ 50 | - `https://github.com/aleszoulek/maxcube `__ 51 | - `openhab integration `__ 52 | 53 | .. |Build Status| image:: https://travis-ci.org/hackercowboy/python-maxcube-api.svg?branch=master 54 | :target: https://travis-ci.org/hackercowboy/python-maxcube-api 55 | .. |Coverage Status| image:: https://coveralls.io/repos/hackercowboy/python-maxcube-api/badge.svg?branch=master&service=github 56 | :target: https://coveralls.io/github/hackercowboy/python-maxcube-api?branch=master 57 | -------------------------------------------------------------------------------- /maxcube/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uebelack/python-maxcube-api/0d9351aeadb81d50aec1421335bc75a0d6d24cfe/maxcube/__init__.py -------------------------------------------------------------------------------- /maxcube/commander.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from time import sleep 4 | from typing import List 5 | 6 | from .connection import Connection 7 | from .deadline import Deadline, Timeout 8 | from .message import Message 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | QUIT_MSG = Message("q") 13 | L_MSG = Message("l") 14 | L_REPLY_CMD = L_MSG.reply_cmd() 15 | 16 | UPDATE_TIMEOUT = Timeout("update", 3.0) 17 | CONNECT_TIMEOUT = Timeout("connect", 3.0) 18 | FLUSH_INPUT_TIMEOUT = Timeout("flush-input", 0) 19 | SEND_RADIO_MSG_TIMEOUT = Timeout("send-radio-msg", 30.0) 20 | CMD_REPLY_TIMEOUT = Timeout("cmd-reply", 2.0) 21 | 22 | 23 | class Commander(object): 24 | def __init__(self, host: str, port: int): 25 | self.__host: str = host 26 | self.__port: int = port 27 | self.use_persistent_connection = True 28 | self.__connection: Connection = None 29 | self.__unsolicited_messages: List[Message] = [] 30 | 31 | def disconnect(self): 32 | if self.__connection: 33 | try: 34 | self.__connection.send(QUIT_MSG) 35 | except Exception: 36 | logger.debug( 37 | "Unable to properly shutdown MAX Cube connection. Resetting it..." 38 | ) 39 | finally: 40 | self.__close() 41 | 42 | def get_unsolicited_messages(self) -> List[Message]: 43 | result = self.__unsolicited_messages 44 | self.__unsolicited_messages = [] 45 | return result 46 | 47 | def update(self) -> List[Message]: 48 | deadline = Deadline(UPDATE_TIMEOUT) 49 | if self.__is_connected(): 50 | try: 51 | response = self.__call(L_MSG, deadline) 52 | if response: 53 | self.__unsolicited_messages.append(response) 54 | except Exception: 55 | self.__connect(deadline) 56 | else: 57 | self.__connect(deadline) 58 | if not self.use_persistent_connection: 59 | self.disconnect() 60 | return self.get_unsolicited_messages() 61 | 62 | def send_radio_msg(self, hex_radio_msg: str) -> bool: 63 | deadline = Deadline(SEND_RADIO_MSG_TIMEOUT) 64 | request = Message( 65 | "s", base64.b64encode(bytearray.fromhex(hex_radio_msg)).decode("utf-8") 66 | ) 67 | while not deadline.is_expired(): 68 | if self.__cmd_send_radio_msg(request, deadline): 69 | return True 70 | return False 71 | 72 | def __cmd_send_radio_msg(self, request: Message, deadline: Deadline) -> bool: 73 | try: 74 | response = self.__call(request, deadline) 75 | duty_cycle, status_code, free_slots = response.arg.split(",", 3) 76 | if status_code == "0": 77 | return True 78 | logger.debug( 79 | "Radio message %s was not send [DutyCycle:%s, StatusCode:%s, FreeSlots:%s]" 80 | % (request, duty_cycle, status_code, free_slots) 81 | ) 82 | if int(duty_cycle, 16) == 100 and int(free_slots, 16) == 0: 83 | sleep(deadline.remaining(upper_bound=10.0)) 84 | except Exception as ex: 85 | logger.error("Error sending radio message to Max! Cube: " + str(ex)) 86 | return False 87 | 88 | def __call(self, msg: Message, deadline: Deadline) -> Message: 89 | already_connected = self.__is_connected() 90 | if not already_connected: 91 | self.__connect(deadline.subtimeout(CONNECT_TIMEOUT)) 92 | else: 93 | # Protection in case some late answer arrives for a previous command 94 | self.__wait_for_reply(None, deadline.subtimeout(FLUSH_INPUT_TIMEOUT)) 95 | 96 | try: 97 | self.__connection.send(msg) 98 | subdeadline = deadline.subtimeout(CMD_REPLY_TIMEOUT) 99 | result = self.__wait_for_reply(msg.reply_cmd(), subdeadline) 100 | if result is None: 101 | raise TimeoutError(str(subdeadline)) 102 | return result 103 | 104 | except Exception: 105 | self.__close() 106 | if already_connected: 107 | return self.__call(msg, deadline) 108 | else: 109 | raise 110 | 111 | finally: 112 | if not self.use_persistent_connection: 113 | self.disconnect() 114 | 115 | def __is_connected(self) -> bool: 116 | return self.__connection is not None 117 | 118 | def __connect(self, deadline: Deadline): 119 | self.__unsolicited_messages = [] 120 | self.__connection = Connection(self.__host, self.__port) 121 | reply = self.__wait_for_reply( 122 | L_REPLY_CMD, deadline.subtimeout(CMD_REPLY_TIMEOUT) 123 | ) 124 | if reply: 125 | self.__unsolicited_messages.append(reply) 126 | 127 | def __wait_for_reply(self, reply_cmd: str, deadline: Deadline) -> Message: 128 | while True: 129 | msg = self.__connection.recv(deadline) 130 | if msg is None: 131 | return None 132 | elif reply_cmd and msg.cmd == reply_cmd: 133 | return msg 134 | else: 135 | self.__unsolicited_messages.append(msg) 136 | 137 | def __close(self): 138 | self.__connection.close() 139 | self.__connection = None 140 | -------------------------------------------------------------------------------- /maxcube/connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | 4 | from .deadline import Deadline 5 | from .message import Message 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | BLOCK_SIZE = 4096 10 | DEFAULT_TIMEOUT = 2.0 11 | 12 | 13 | class Connection(object): 14 | def __init__(self, host: str, port: int): 15 | self.__buffer: bytearray = bytearray() 16 | self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 17 | self.__socket.settimeout(DEFAULT_TIMEOUT) 18 | self.__socket.connect((host, port)) 19 | logger.debug("Connected to %s:%d!" % (host, port)) 20 | 21 | def __read_buffered_msg(self) -> Message: 22 | buf = self.__buffer 23 | pos = buf.find(b"\r\n") 24 | if pos < 0: 25 | return None 26 | result = buf[0:pos] 27 | del buf[0 : pos + 2] 28 | return Message.decode(result) 29 | 30 | def recv(self, deadline: Deadline) -> Message: 31 | msg = self.__read_buffered_msg() 32 | try: 33 | while msg is None: 34 | self.__socket.settimeout(deadline.remaining(lower_bound=0.001)) 35 | tmp = self.__socket.recv(BLOCK_SIZE) 36 | if len(tmp) > 0: 37 | self.__buffer.extend(tmp) 38 | msg = self.__read_buffered_msg() 39 | logger.debug("received: %s" % msg) 40 | else: 41 | logger.debug("Connection shutdown by remote peer") 42 | self.close() 43 | return None 44 | except socket.timeout: 45 | logger.debug("readline timed out") 46 | finally: 47 | self.__socket.settimeout(DEFAULT_TIMEOUT) 48 | return msg 49 | 50 | def send(self, msg: Message): 51 | self.__socket.send(msg.encode()) 52 | logger.debug("sent: %s" % msg) 53 | 54 | def close(self): 55 | try: 56 | self.__socket.close() 57 | logger.debug("closed") 58 | except Exception: 59 | logger.debug("Unable to close connection. Dropping it...") 60 | -------------------------------------------------------------------------------- /maxcube/cube.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from datetime import datetime 3 | import json 4 | import logging 5 | import struct 6 | from typing import Callable 7 | 8 | from maxcube.device import ( 9 | MAX_CUBE, 10 | MAX_DEVICE_MODE_AUTOMATIC, 11 | MAX_DEVICE_MODE_MANUAL, 12 | MAX_THERMOSTAT, 13 | MAX_THERMOSTAT_PLUS, 14 | MAX_WALL_THERMOSTAT, 15 | MAX_WINDOW_SHUTTER, 16 | MaxDevice, 17 | ) 18 | from maxcube.room import MaxRoom 19 | from maxcube.thermostat import MaxThermostat 20 | from maxcube.wallthermostat import MaxWallThermostat 21 | from maxcube.windowshutter import MaxWindowShutter 22 | 23 | from .commander import Commander 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | CMD_SET_PROG = "10" 28 | UNKNOWN = "00" 29 | RF_FLAG_IS_ROOM = "04" 30 | RF_FLAG_IS_DEVICE = "00" 31 | RF_NULL_ADDRESS = "000000" 32 | DEFAULT_PORT = 62910 33 | DAYS = [ 34 | "saturday", 35 | "sunday", 36 | "monday", 37 | "tuesday", 38 | "wednesday", 39 | "thursday", 40 | "friday", 41 | "saturday", 42 | "sunday", 43 | ] 44 | 45 | 46 | class MaxCube(MaxDevice): 47 | def __init__( 48 | self, 49 | host: str, 50 | port: int = DEFAULT_PORT, 51 | now: Callable[[], datetime] = datetime.now, 52 | ): 53 | super(MaxCube, self).__init__() 54 | self.__commander = Commander(host, port) 55 | self.name = "Cube" 56 | self.type = MAX_CUBE 57 | self.firmware_version = None 58 | self.devices = [] 59 | self.rooms = [] 60 | self._now: Callable[[], datetime] = now 61 | self.update() 62 | self.log() 63 | 64 | @property 65 | def use_persistent_connection(self) -> bool: 66 | return self.__commander.use_persistent_connection 67 | 68 | @use_persistent_connection.setter 69 | def use_persistent_connection(self, value: bool) -> None: 70 | self.__commander.use_persistent_connection = value 71 | 72 | def disconnect(self): 73 | self.__commander.disconnect() 74 | 75 | def __str__(self): 76 | return self.describe("CUBE", f"firmware={self.firmware_version}") 77 | 78 | def log(self): 79 | logger.info(str(self)) 80 | for room in self.rooms: 81 | logger.info(f" * ROOM {room.name}") 82 | for device in self.devices_by_room(room): 83 | logger.info(" --- " + str(device)) 84 | 85 | def update(self): 86 | self.__parse_responses(self.__commander.update()) 87 | 88 | def get_devices(self): 89 | return self.devices 90 | 91 | def device_by_rf(self, rf): 92 | for device in self.devices: 93 | if device.rf_address == rf: 94 | return device 95 | return None 96 | 97 | def devices_by_room(self, room): 98 | rooms = [] 99 | 100 | for device in self.devices: 101 | if device.room_id == room.id: 102 | rooms.append(device) 103 | 104 | return rooms 105 | 106 | def get_rooms(self): 107 | return self.rooms 108 | 109 | def room_by_id(self, id): 110 | for room in self.rooms: 111 | if room.id == id: 112 | return room 113 | return None 114 | 115 | def __parse_responses(self, messages): 116 | for msg in messages: 117 | try: 118 | cmd = msg.cmd 119 | if cmd == "C": 120 | self.parse_c_message(msg.arg) 121 | elif cmd == "H": 122 | self.parse_h_message(msg.arg) 123 | elif cmd == "L": 124 | self.parse_l_message(msg.arg) 125 | elif cmd == "M": 126 | self.parse_m_message(msg.arg) 127 | else: 128 | logger.debug("Ignored unsupported message: %s" % (msg)) 129 | except Exception: 130 | logger.warn(f"Error processing response message {msg}", exc_info=True) 131 | 132 | def parse_c_message(self, message): 133 | logger.debug("Parsing c_message: " + message) 134 | params = message.split(",") 135 | device_rf_address = params[0].upper() 136 | data = bytearray(base64.b64decode(params[1])) 137 | 138 | device = self.device_by_rf(device_rf_address) 139 | if device and device.is_thermostat(): 140 | device.comfort_temperature = data[18] / 2.0 141 | device.eco_temperature = data[19] / 2.0 142 | device.max_temperature = data[20] / 2.0 143 | device.min_temperature = data[21] / 2.0 144 | device.programme = get_programme(data[29:]) 145 | 146 | if device and device.is_wallthermostat(): 147 | device.comfort_temperature = data[18] / 2.0 148 | device.eco_temperature = data[19] / 2.0 149 | device.max_temperature = data[20] / 2.0 150 | device.min_temperature = data[21] / 2.0 151 | 152 | if device and device.is_windowshutter(): 153 | # Pure Speculation based on this: 154 | # Before: [17][12][162][178][4][0][20][15]KEQ0839778 155 | # After: [17][12][162][178][4][1][20][15]KEQ0839778 156 | device.initialized = data[5] 157 | 158 | def parse_h_message(self, message): 159 | logger.debug("Parsing h_message: " + message) 160 | tokens = message.split(",") 161 | self.serial = tokens[0] 162 | self.rf_address = tokens[1] 163 | self.firmware_version = (tokens[2][0:2]) + "." + (tokens[2][2:4]) 164 | 165 | def parse_m_message(self, message): 166 | logger.debug("Parsing m_message: " + message) 167 | data = bytearray(base64.b64decode(message.split(",")[2])) 168 | num_rooms = data[2] 169 | 170 | pos = 3 171 | for _ in range(0, num_rooms): 172 | room_id = struct.unpack("bb", data[pos : pos + 2])[0] 173 | name_length = struct.unpack("bb", data[pos : pos + 2])[1] 174 | pos += 1 + 1 175 | name = data[pos : pos + name_length].decode("utf-8") 176 | pos += name_length 177 | device_rf_address = self.parse_rf_address(data[pos : pos + 3]) 178 | pos += 3 179 | 180 | room = self.room_by_id(room_id) 181 | 182 | if not room: 183 | room = MaxRoom() 184 | room.id = room_id 185 | room.name = name 186 | self.rooms.append(room) 187 | else: 188 | room.name = name 189 | 190 | num_devices = data[pos] 191 | pos += 1 192 | 193 | for device_idx in range(0, num_devices): 194 | device_type = data[pos] 195 | device_rf_address = self.parse_rf_address(data[pos + 1 : pos + 1 + 3]) 196 | device_serial = data[pos + 4 : pos + 14].decode("utf-8") 197 | device_name_length = data[pos + 14] 198 | device_name = data[pos + 15 : pos + 15 + device_name_length].decode("utf-8") 199 | room_id = data[pos + 15 + device_name_length] 200 | 201 | device = self.device_by_rf(device_rf_address) 202 | 203 | if not device: 204 | if device_type == MAX_THERMOSTAT or device_type == MAX_THERMOSTAT_PLUS: 205 | device = MaxThermostat() 206 | 207 | if device_type == MAX_WINDOW_SHUTTER: 208 | device = MaxWindowShutter() 209 | 210 | if device_type == MAX_WALL_THERMOSTAT: 211 | device = MaxWallThermostat() 212 | 213 | if device: 214 | self.devices.append(device) 215 | 216 | if device: 217 | device.type = device_type 218 | device.rf_address = device_rf_address 219 | device.room_id = room_id 220 | device.name = device_name 221 | device.serial = device_serial 222 | 223 | pos += 1 + 3 + 10 + device_name_length + 2 224 | 225 | def parse_l_message(self, message): 226 | logger.debug("Parsing l_message: " + message) 227 | data = bytearray(base64.b64decode(message)) 228 | pos = 0 229 | 230 | while pos < len(data): 231 | length = data[pos] 232 | device_rf_address = self.parse_rf_address(data[pos + 1 : pos + 4]) 233 | 234 | device = self.device_by_rf(device_rf_address) 235 | 236 | if device: 237 | bits1, bits2 = struct.unpack("BB", bytearray(data[pos + 5 : pos + 7])) 238 | device.battery = self.resolve_device_battery(bits2) 239 | 240 | # Thermostat or Wall Thermostat 241 | if device and (device.is_thermostat() or device.is_wallthermostat()): 242 | device.target_temperature = (data[pos + 8] & 0x7F) / 2.0 243 | bits1, bits2 = struct.unpack("BB", bytearray(data[pos + 5 : pos + 7])) 244 | device.mode = self.resolve_device_mode(bits2) 245 | 246 | # Thermostat 247 | if device and device.is_thermostat(): 248 | device.valve_position = data[pos + 7] 249 | if ( 250 | device.mode == MAX_DEVICE_MODE_MANUAL 251 | or device.mode == MAX_DEVICE_MODE_AUTOMATIC 252 | ): 253 | actual_temperature = ( 254 | (data[pos + 9] & 0xFF) * 256 + (data[pos + 10] & 0xFF) 255 | ) / 10.0 256 | if actual_temperature != 0: 257 | device.actual_temperature = actual_temperature 258 | else: 259 | device.actual_temperature = None 260 | 261 | # Wall Thermostat 262 | if device and device.is_wallthermostat(): 263 | device.actual_temperature = ( 264 | ((data[pos + 8] & 0x80) << 1) + data[pos + 12] 265 | ) / 10.0 266 | 267 | # Window Shutter 268 | if device and device.is_windowshutter(): 269 | status = data[pos + 6] & 0x03 270 | if status > 0: 271 | device.is_open = True 272 | else: 273 | device.is_open = False 274 | 275 | # Advance our pointer to the next submessage 276 | pos += length + 1 277 | 278 | def set_target_temperature(self, thermostat, temperature): 279 | return self.set_temperature_mode(thermostat, temperature, None) 280 | 281 | def set_mode(self, thermostat, mode): 282 | return self.set_temperature_mode(thermostat, None, mode) 283 | 284 | def set_temperature_mode(self, thermostat, temperature, mode): 285 | logger.debug( 286 | "Setting temperature %s and mode %s on %s!", 287 | temperature, 288 | mode, 289 | thermostat.rf_address, 290 | ) 291 | 292 | if not thermostat.is_thermostat() and not thermostat.is_wallthermostat(): 293 | logger.error("%s is no (wall-)thermostat!", thermostat.rf_address) 294 | return 295 | 296 | if mode is None: 297 | mode = thermostat.mode 298 | if temperature is None: 299 | temperature = ( 300 | 0 301 | if mode == MAX_DEVICE_MODE_AUTOMATIC 302 | else thermostat.target_temperature 303 | ) 304 | 305 | rf_address = thermostat.rf_address 306 | room = to_hex(thermostat.room_id) 307 | target_temperature = int(temperature * 2) + (mode << 6) 308 | 309 | byte_cmd = "000440000000" + rf_address + room + to_hex(target_temperature) 310 | if self.__commander.send_radio_msg(byte_cmd): 311 | thermostat.mode = mode 312 | if temperature > 0: 313 | thermostat.target_temperature = int(temperature * 2) / 2.0 314 | elif mode == MAX_DEVICE_MODE_AUTOMATIC: 315 | thermostat.target_temperature = thermostat.get_programmed_temp_at( 316 | self._now() 317 | ) 318 | return True 319 | return False 320 | 321 | def set_programme(self, thermostat, day, metadata): 322 | # compare with current programme 323 | if thermostat.programme[day] == metadata: 324 | logger.debug("Skipping setting unchanged programme for " + day) 325 | return 326 | 327 | heat_time_tuples = [(x["temp"], x["until"]) for x in metadata] 328 | # pad heat_time_tuples so that there are always seven 329 | for _ in range(7 - len(heat_time_tuples)): 330 | heat_time_tuples.append((0, "00:00")) 331 | command = "" 332 | if thermostat.is_room(): 333 | rf_flag = RF_FLAG_IS_ROOM 334 | devices = self.devices_by_room(thermostat) 335 | else: 336 | rf_flag = RF_FLAG_IS_DEVICE 337 | devices = [thermostat] 338 | command += UNKNOWN + rf_flag + CMD_SET_PROG + RF_NULL_ADDRESS 339 | for device in devices: 340 | command += device.rf_address 341 | command += to_hex(device.room_id) 342 | command += to_hex(n_from_day_of_week(day)) 343 | for heat, time in heat_time_tuples: 344 | command += temp_and_time(heat, time) 345 | return self.__commander.send_radio_msg(command) 346 | 347 | def devices_as_json(self): 348 | devices = [] 349 | for device in self.devices: 350 | devices.append(device.to_dict()) 351 | return json.dumps(devices, indent=2) 352 | 353 | def set_programmes_from_config(self, config_file): 354 | config = json.load(config_file) 355 | for device_config in config: 356 | device = self.device_by_rf(device_config["rf_address"]) 357 | programme = device_config["programme"] 358 | if not programme: 359 | # e.g. a wall thermostat 360 | continue 361 | for day, metadata in programme.items(): 362 | self.set_programme(device, day, metadata) 363 | 364 | @classmethod 365 | def resolve_device_mode(cls, bits): 366 | return bits & 3 367 | 368 | @classmethod 369 | def resolve_device_battery(cls, bits): 370 | return bits >> 7 371 | 372 | @classmethod 373 | def parse_rf_address(cls, address): 374 | return "".join("{:02X}".format(x) for x in address) 375 | 376 | 377 | def get_programme(bits): 378 | n = 26 379 | programme = {} 380 | days = [bits[i : i + n] for i in range(0, len(bits), n)] 381 | for j, day in enumerate(days): 382 | n = 2 383 | settings = [day[i : i + n] for i in range(0, len(day), n)] 384 | day_programme = [] 385 | for setting in settings: 386 | word = format(setting[0], "08b") + format(setting[1], "08b") 387 | temp = int(int(word[:7], 2) / 2) 388 | time_mins = int(word[7:], 2) * 5 389 | mins = time_mins % 60 390 | hours = int((time_mins - mins) / 60) 391 | time = "{:02d}:{:02d}".format(hours, mins) 392 | day_programme.append({"temp": temp, "until": time}) 393 | if time == "24:00": 394 | # This appears to flag the end of usable set points 395 | break 396 | programme[day_of_week_from_n(j)] = day_programme 397 | return programme 398 | 399 | 400 | def n_from_day_of_week(day): 401 | return DAYS.index(day) 402 | 403 | 404 | def day_of_week_from_n(day): 405 | return DAYS[day] 406 | 407 | 408 | def temp_and_time(temp, time): 409 | temp = float(temp) 410 | assert temp <= 32, "Temp must be 32 or lower" 411 | assert temp % 0.5 == 0, "Temp must be increments of 0.5" 412 | temp = int(temp * 2) 413 | hours, mins = [int(x) for x in time.split(":")] 414 | assert mins % 5 == 0, "Time must be a multiple of 5 mins" 415 | mins = hours * 60 + mins 416 | bits = format(temp, "07b") + format(int(mins / 5), "09b") 417 | return to_hex(int(bits, 2)) 418 | 419 | 420 | def to_hex(value): 421 | "Return value as hex word" 422 | return format(value, "02X") 423 | -------------------------------------------------------------------------------- /maxcube/deadline.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from math import inf 3 | from time import time 4 | 5 | 6 | @dataclass(frozen=True) 7 | class Timeout: 8 | name: str 9 | duration: float 10 | 11 | 12 | class Deadline: 13 | def __init__(self, timeout: Timeout, *, parent=None): 14 | if parent is None: 15 | self.__deadline = time() + timeout.duration 16 | else: 17 | self.__deadline = min(time() + timeout.duration, parent.__deadline) 18 | self.__timeout = timeout 19 | self.__parent = parent 20 | 21 | def name(self) -> str: 22 | return f"{self.__timeout.name}[{self.remaining():.3g}/{self.__timeout.duration:.3g}]" 23 | 24 | def fullname(self) -> str: 25 | if self.__parent is None: 26 | return self.name() 27 | return self.__parent.fullname() + ":" + self.name() 28 | 29 | def remaining(self, *, lower_bound: float = 0, upper_bound: float = inf) -> float: 30 | return min(max(lower_bound, self.__deadline - time()), upper_bound) 31 | 32 | def is_expired(self) -> bool: 33 | return self.remaining() <= 0 34 | 35 | def subtimeout(self, timeout: Timeout) -> "Deadline": 36 | return Deadline(timeout, parent=self) 37 | 38 | def __str__(self) -> str: 39 | return "Deadline " + self.fullname() 40 | -------------------------------------------------------------------------------- /maxcube/device.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | MAX_CUBE = 0 4 | MAX_THERMOSTAT = 1 5 | MAX_THERMOSTAT_PLUS = 2 6 | MAX_WALL_THERMOSTAT = 3 7 | MAX_WINDOW_SHUTTER = 4 8 | MAX_PUSH_BUTTON = 5 9 | 10 | MAX_DEVICE_MODE_AUTOMATIC = 0 11 | MAX_DEVICE_MODE_MANUAL = 1 12 | MAX_DEVICE_MODE_VACATION = 2 13 | MAX_DEVICE_MODE_BOOST = 3 14 | 15 | MAX_DEVICE_BATTERY_OK = 0 16 | MAX_DEVICE_BATTERY_LOW = 1 17 | 18 | MODE_NAMES = { 19 | MAX_DEVICE_MODE_AUTOMATIC: "auto", 20 | MAX_DEVICE_MODE_MANUAL: "manual", 21 | MAX_DEVICE_MODE_VACATION: "away", 22 | MAX_DEVICE_MODE_BOOST: "boost", 23 | } 24 | 25 | 26 | class MaxDevice(object): 27 | def __init__(self): 28 | self.type = None 29 | self.rf_address = None 30 | self.room_id = None 31 | self.name = None 32 | self.serial = None 33 | self.battery = None 34 | self.programme = None 35 | 36 | def is_thermostat(self): 37 | return self.type in (MAX_THERMOSTAT, MAX_THERMOSTAT_PLUS) 38 | 39 | def is_wallthermostat(self): 40 | return self.type == MAX_WALL_THERMOSTAT 41 | 42 | def is_windowshutter(self): 43 | return self.type == MAX_WINDOW_SHUTTER 44 | 45 | def is_room(self): 46 | return False 47 | 48 | def describe(self, kind: str, *args: Tuple[str]): 49 | state = "".join("," + s for s in args if s) 50 | if self.battery == MAX_DEVICE_BATTERY_LOW: 51 | state = ",LOW_BATT" + state 52 | return f"{kind} sn={self.serial},rf={self.rf_address},name={self.name}" + state 53 | 54 | def __str__(self): 55 | return self.describe(str(self.type)) 56 | 57 | def to_dict(self): 58 | data = {} 59 | keys = [ 60 | "type", 61 | "rf_address", 62 | "room_id", 63 | "name", 64 | "serial", 65 | "battery", 66 | "comfort_temperature", 67 | "eco_temperature", 68 | "max_temperature", 69 | "min_temperature", 70 | "valve_position", 71 | "target_temperature", 72 | "actual_temperature", 73 | "mode", 74 | "programme", 75 | ] 76 | for key in keys: 77 | data[key] = getattr(self, key, None) 78 | data["rf_address"] = self.rf_address 79 | return data 80 | -------------------------------------------------------------------------------- /maxcube/message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class Message: 6 | cmd: str 7 | arg: str = "" 8 | 9 | def reply_cmd(self) -> str: 10 | return self.cmd.upper() 11 | 12 | def __str__(self) -> str: 13 | return f"{self.cmd}:{self.arg}" 14 | 15 | def encode(self) -> bytes: 16 | return (f"{self.cmd}:{self.arg}\r\n").encode("utf-8") 17 | 18 | @staticmethod 19 | def decode(line: bytes) -> "Message": 20 | comps = line.decode("utf-8").strip().split(":", 1) 21 | return Message(comps[0], comps[1] if len(comps) > 1 else "") 22 | -------------------------------------------------------------------------------- /maxcube/room.py: -------------------------------------------------------------------------------- 1 | class MaxRoom(object): 2 | def __init__(self): 3 | self.id = None 4 | self.name = None 5 | -------------------------------------------------------------------------------- /maxcube/thermostat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, List 3 | 4 | from maxcube.device import MODE_NAMES, MaxDevice 5 | 6 | PROG_DAYS = [ 7 | "monday", 8 | "tuesday", 9 | "wednesday", 10 | "thursday", 11 | "friday", 12 | "saturday", 13 | "sunday", 14 | ] 15 | 16 | 17 | class MaxThermostat(MaxDevice): 18 | def __init__(self): 19 | super(MaxThermostat, self).__init__() 20 | self.comfort_temperature = None 21 | self.eco_temperature = None 22 | self.max_temperature = None 23 | self.min_temperature = None 24 | self.valve_position = None 25 | self.target_temperature = None 26 | self.actual_temperature = None 27 | self.mode = None 28 | self.programme: Dict[str, List[Dict[str, int]]] = {} 29 | 30 | def __str__(self): 31 | return self.describe( 32 | "THERMOSTAT", 33 | f"mode={MODE_NAMES.get(self.mode, str(self.mode))}", 34 | f"actual={self.actual_temperature}", 35 | f"target={self.target_temperature}", 36 | f"eco={self.eco_temperature}", 37 | f"comfort={self.comfort_temperature}", 38 | f"range=[{self.min_temperature},{self.max_temperature}]", 39 | f"valve={self.valve_position}", 40 | ) 41 | 42 | def get_programmed_temp_at(self, dt: datetime): 43 | """Retrieve the programmed temperature at the given instant.""" 44 | weekday = PROG_DAYS[dt.weekday()] 45 | time = f"{dt.hour:02}:{dt.minute:02}" 46 | for point in self.programme.get(weekday, []): 47 | if time < point["until"]: 48 | return point["temp"] 49 | return None 50 | 51 | def get_current_temp_in_auto_mode(self): 52 | """DEPRECATED: use get_programmed_temp_at instead.""" 53 | return self.get_programmed_temp_at(datetime.now()) 54 | -------------------------------------------------------------------------------- /maxcube/wallthermostat.py: -------------------------------------------------------------------------------- 1 | from maxcube.device import MODE_NAMES, MaxDevice 2 | 3 | 4 | class MaxWallThermostat(MaxDevice): 5 | def __init__(self): 6 | super(MaxWallThermostat, self).__init__() 7 | self.comfort_temperature = None 8 | self.eco_temperature = None 9 | self.max_temperature = None 10 | self.min_temperature = None 11 | self.actual_temperature = None 12 | self.target_temperature = None 13 | self.mode = None 14 | 15 | def __str__(self): 16 | return self.describe( 17 | "WALLTHERMO", 18 | f"mode={MODE_NAMES.get(self.mode, str(self.mode))}", 19 | f"actual={self.actual_temperature}", 20 | f"target={self.target_temperature}", 21 | f"eco={self.eco_temperature}", 22 | f"comfort={self.comfort_temperature}", 23 | f"range=[{self.min_temperature},{self.max_temperature}]", 24 | ) 25 | -------------------------------------------------------------------------------- /maxcube/windowshutter.py: -------------------------------------------------------------------------------- 1 | from maxcube.device import MaxDevice 2 | 3 | 4 | class MaxWindowShutter(MaxDevice): 5 | def __init__(self): 6 | super(MaxWindowShutter, self).__init__() 7 | self.is_open = False 8 | self.initialized = None 9 | 10 | def __str__(self): 11 | return self.describe("WINDOW", f"open={self.is_open}") 12 | -------------------------------------------------------------------------------- /prog.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from maxcube.cube import DEFAULT_PORT, MaxCube 5 | 6 | if __name__ == "__main__": 7 | parser = argparse.ArgumentParser(description="Set or dump thermostat programmes") 8 | parser.add_argument("--host", required=True) 9 | parser.add_argument("--port", default=DEFAULT_PORT, type=int) 10 | parser.add_argument("cmd", choices=["load", "dump"]) 11 | args = parser.parse_args() 12 | cube = MaxCube(args.host, args.port) 13 | if args.cmd == "load": 14 | cube.set_programmes_from_config(sys.stdin) 15 | elif args.cmd == "dump": 16 | print(cube.devices_as_json()) 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py38"] 3 | 4 | [tool.isort] 5 | # https://github.com/PyCQA/isort/wiki/isort-Settings 6 | profile = "black" 7 | # will group `import x` and `from x import` of the same module. 8 | force_sort_within_sections = true 9 | known_first_party = [ 10 | "maxcube", 11 | "tests", 12 | ] 13 | forced_separate = [ 14 | "tests", 15 | ] 16 | combine_as_imports = true 17 | -------------------------------------------------------------------------------- /sample/sample.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from maxcube.cube import MaxCube 4 | from maxcube.device import ( 5 | MAX_DEVICE_MODE_AUTOMATIC, 6 | MAX_DEVICE_MODE_MANUAL, 7 | MAX_THERMOSTAT, 8 | MAX_THERMOSTAT_PLUS, 9 | MAX_WALL_THERMOSTAT, 10 | MAX_WINDOW_SHUTTER, 11 | ) 12 | 13 | cube = MaxCube(os.environ.get("MAXCUBE_IP", "192.168.0.20"), 62910) 14 | 15 | print("Serial: %s" % (cube.serial)) 16 | for room in cube.rooms: 17 | print("Room: " + room.name) 18 | for device in cube.devices_by_room(room): 19 | print("Device: " + device.name) 20 | 21 | print("") 22 | 23 | for device in cube.devices: 24 | if device.type == MAX_THERMOSTAT: 25 | type = "MAX_THERMOSTAT" 26 | elif device.type == MAX_THERMOSTAT_PLUS: 27 | type = "MAX_THERMOSTAT_PLUS" 28 | elif device.type == MAX_WINDOW_SHUTTER: 29 | type = "MAX_WINDOW_SHUTTER" 30 | elif device.type == MAX_WALL_THERMOSTAT: 31 | type = "MAX_WALL_THERMOSTAT" 32 | print("Type: " + type) 33 | print("RF: " + device.rf_address) 34 | print("Room ID:" + str(device.room_id)) 35 | print("Room: " + cube.room_by_id(device.room_id).name) 36 | print("Name: " + device.name) 37 | print("Serial: " + device.serial) 38 | 39 | if device.type == MAX_THERMOSTAT: 40 | print("MaxSetP:" + str(device.max_temperature)) 41 | print("MinSetP:" + str(device.min_temperature)) 42 | if device.mode == MAX_DEVICE_MODE_AUTOMATIC: 43 | mode = "AUTO" 44 | elif device.mode == MAX_DEVICE_MODE_MANUAL: 45 | mode = "MANUAL" 46 | print("Mode: " + mode) 47 | print("Actual: " + str(device.actual_temperature)) 48 | print("Target: " + str(device.target_temperature)) 49 | 50 | if device.type == MAX_WALL_THERMOSTAT: 51 | print("MaxSetP:" + str(device.max_temperature)) 52 | print("MinSetP:" + str(device.min_temperature)) 53 | if device.mode == MAX_DEVICE_MODE_AUTOMATIC: 54 | mode = "AUTO" 55 | elif device.mode == MAX_DEVICE_MODE_MANUAL: 56 | mode = "MANUAL" 57 | print("Mode: " + mode) 58 | print("Actual: " + str(device.actual_temperature)) 59 | print("Target: " + str(device.target_temperature)) 60 | 61 | if device.type == MAX_WINDOW_SHUTTER: 62 | print("IsOpen: " + str(device.is_open)) 63 | 64 | print("") 65 | 66 | for device in cube.devices: 67 | print(device) 68 | if device.is_wallthermostat() or device.is_thermostat(): 69 | print("Setting temp") 70 | cube.set_target_temperature(device, 8) 71 | else: 72 | print("No Thermostat") 73 | 74 | print("") 75 | 76 | for device in cube.devices: 77 | print(device) 78 | if device.is_wallthermostat(): 79 | print("Setting mode") 80 | cube.set_mode(device, MAX_DEVICE_MODE_MANUAL) 81 | else: 82 | print("No Wall Thermostat") 83 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 6 | doctests = True 7 | # To work with Black 8 | # E501: line too long 9 | # W503: Line break occurred before a binary operator 10 | # E203: Whitespace before ':' 11 | # D202 No blank lines allowed after function docstring 12 | # W504 line break after binary operator 13 | ignore = 14 | E501, 15 | W503, 16 | E203, 17 | D202, 18 | W504 19 | noqa-require-code = True 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | with open("README.rst") as f: 5 | readme = f.read() 6 | 7 | setup( 8 | name="maxcube-api", 9 | version="0.4.3", 10 | description="eQ-3/ELV MAX! Cube Python API", 11 | long_description=readme, 12 | author="David Uebelacker", 13 | author_email="david@uebelacker.ch", 14 | url="https://github.com/hackercowboy/python-maxcube-api.git", 15 | license='MIT', 16 | packages=["maxcube"], 17 | test_suite="tests", 18 | python_requires=">=3.7", 19 | ) 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uebelack/python-maxcube-api/0d9351aeadb81d50aec1421335bc75a0d6d24cfe/tests/__init__.py -------------------------------------------------------------------------------- /tests/result.txt: -------------------------------------------------------------------------------- 1 | H:KEQ0566338,0b6475,0113,00000000,74b7b6f7,00,32,0f0c19,1527,03,0000 2 | M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvFNLRVEwMzM2MTA4B0tpdGNoZW4BAQa8WktFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNsZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ== 3 | C:0b6475,7QtkdQATAf9LRVEwNTY2MzM4AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf///////////////////////////2h0dHA6Ly93d3cubWF4LXBvcnRhbC5lbHYuZGU6ODAvY3ViZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA== 4 | C:06bc53,0ga8UwEBGP9LRVEwMzM2MTA4KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== 5 | C:06bc5a,0ga8WgECGP9LRVEwMzM2MTAwKCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== 6 | C:06bc5c,0ga8XAEEGP9LRVEwMzM2MTA0KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== 7 | C:08ab82,0girggEDGP9LRVEwMzM1NjYyKCEyCQcYAzAM/wBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIA== 8 | L:Cwa8U/EaGBsqAOwACwa8WgkSGCMqAOcACwa8XAkSGAsqAOcACwirggMaGAAiAAAA 9 | -------------------------------------------------------------------------------- /tests/test_commander.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from unittest import TestCase 3 | from unittest.mock import MagicMock, call, patch 4 | 5 | from maxcube.commander import Commander 6 | from maxcube.connection import Connection 7 | from maxcube.deadline import Deadline, Timeout 8 | from maxcube.message import Message 9 | 10 | L_CMD = Message("l") 11 | L_CMD_SUCCESS = Message("L") 12 | S_CMD_HEX = "FF00" 13 | S_CMD = Message("s", base64.b64encode(bytearray.fromhex(S_CMD_HEX)).decode("utf-8")) 14 | S_CMD_SUCCESS = Message("S", "00,0,31") 15 | S_CMD_ERROR = Message("S", "64,1,1f") 16 | S_CMD_THROTTLE_ERROR = Message("S", "64,1,0") 17 | Q_CMD = Message("q") 18 | 19 | TEST_TIMEOUT = Timeout("test", 1.0) 20 | 21 | 22 | @patch("maxcube.commander.Connection", spec=True) 23 | class TestCommander(TestCase): 24 | """ Test Max! Cube command handler """ 25 | 26 | def init(self, ClassMock): 27 | self.connection = ClassMock.return_value 28 | self.commander = Commander("host", 1234) 29 | 30 | def testDisconnectIsNoopIfAlreadyDisconnected(self, ClassMock): 31 | self.init(ClassMock) 32 | self.commander.disconnect() 33 | 34 | ClassMock.assert_not_called() 35 | 36 | self.connection.send.assert_not_called() 37 | self.connection.recv.assert_not_called() 38 | self.connection.close.assert_not_called() 39 | 40 | def testUpdateOpensNewNonPersistantConnectionAndClosesIt(self, ClassMock): 41 | messages = [Message("H"), Message("L")] 42 | self.init(ClassMock) 43 | self.commander.use_persistent_connection = False 44 | self.connection.recv.side_effect = messages 45 | 46 | self.assertEqual(messages, self.commander.update()) 47 | 48 | self.connection.send.assert_called_once_with(Q_CMD) 49 | self.assertEqual(2, self.connection.recv.call_count) 50 | self.connection.close.assert_called_once() 51 | 52 | def testUpdateSendsCommandAfterAfterTimeout(self, ClassMock): 53 | messages = [Message("H"), None] 54 | self.init(ClassMock) 55 | self.connection.recv.side_effect = messages 56 | 57 | self.assertEqual(messages[:1], self.commander.update()) 58 | 59 | self.connection.send.assert_not_called() 60 | self.assertEqual(2, self.connection.recv.call_count) 61 | self.connection.close.assert_not_called() 62 | 63 | self.connection.recv.reset_mock() 64 | self.__mockCommandResponse(L_CMD_SUCCESS) 65 | 66 | self.assertEqual([L_CMD_SUCCESS], self.commander.update()) 67 | 68 | self.connection.send.assert_called_once_with(Message("l")) 69 | self.assertEqual(2, self.connection.recv.call_count) 70 | self.connection.close.assert_not_called() 71 | 72 | def testSendRadioMsgAutoconnects(self, ClassMock=None): 73 | self.init(ClassMock) 74 | self.connection.recv.side_effect = [ 75 | L_CMD_SUCCESS, # connection preamble 76 | S_CMD_SUCCESS, 77 | ] 78 | 79 | self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) 80 | self.connection.send.assert_called_once_with(S_CMD) 81 | self.assertEqual(2, self.connection.recv.call_count) 82 | self.connection.close.assert_not_called() 83 | 84 | def testSendRadioMsgOpensNewNonPersistentConnectionAndClosesIt( 85 | self, ClassMock=None 86 | ): 87 | self.init(ClassMock) 88 | self.commander.use_persistent_connection = False 89 | self.connection.recv.side_effect = [ 90 | L_CMD_SUCCESS, # connection preamble 91 | S_CMD_SUCCESS, 92 | ] 93 | 94 | self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) 95 | self.connection.send.assert_has_calls([call(S_CMD), call(Q_CMD)]) 96 | self.assertEqual(2, self.connection.recv.call_count) 97 | self.connection.close.assert_called_once() 98 | 99 | def testSendRadioMsgReusesConnection(self, ClassMock): 100 | self.testSendRadioMsgAutoconnects() 101 | 102 | self.connection.send.reset_mock() 103 | self.connection.recv.reset_mock() 104 | self.connection.recv.side_effect = [None, S_CMD_SUCCESS] 105 | 106 | self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) 107 | 108 | self.connection.send.assert_called_once_with(S_CMD) 109 | self.assertEqual(2, self.connection.recv.call_count) 110 | self.connection.close.assert_not_called() 111 | 112 | def testSendRadioMsgClosesConnectionOnErrorAndRetriesIfReusingConnection( 113 | self, ClassMock 114 | ): 115 | self.testSendRadioMsgAutoconnects() 116 | 117 | self.connection.recv.reset_mock() 118 | self.connection.recv.side_effect = [ 119 | None, # First read before first try 120 | ] 121 | self.connection.send.reset_mock() 122 | self.connection.send.side_effect = [OSError] 123 | 124 | newConnection = MagicMock(Connection) 125 | ClassMock.side_effect = [newConnection] 126 | newConnection.recv.side_effect = [ 127 | L_CMD_SUCCESS, # Connection preamble 128 | S_CMD_SUCCESS, 129 | ] 130 | 131 | self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) 132 | 133 | self.connection.recv.assert_called_once() 134 | self.connection.send.assert_called_once_with(S_CMD) 135 | self.connection.close.assert_called_once() 136 | 137 | self.assertEqual(2, newConnection.recv.call_count) 138 | newConnection.send.assert_called_once_with(S_CMD) 139 | newConnection.close.assert_not_called() 140 | 141 | def __send_radio_msg(self, msg: Message, deadline: Deadline): 142 | with patch("maxcube.commander.Deadline") as deadlineMock: 143 | deadlineMock.return_value = deadline 144 | return self.commander.send_radio_msg(msg) 145 | 146 | def testSendRadioMsgShouldNotRetryOnErrorWhenConnectionIsNew(self, ClassMock): 147 | self.init(ClassMock) 148 | self.connection.recv.side_effect = [L_CMD_SUCCESS] 149 | self.connection.send.side_effect = [OSError] 150 | 151 | deadline = MagicMock(Deadline) 152 | deadline.is_expired.side_effect = [False, True] 153 | deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) 154 | 155 | self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) 156 | 157 | self.connection.send.assert_called_once_with(S_CMD) 158 | self.connection.recv.assert_called_once() 159 | self.connection.close.assert_called_once() 160 | 161 | def testSendRadioMsgFailsOnLogicalError(self, ClassMock): 162 | self.init(ClassMock) 163 | self.connection.recv.side_effect = [L_CMD_SUCCESS, S_CMD_ERROR] 164 | 165 | deadline: Deadline = MagicMock(Deadline) 166 | deadline.is_expired.side_effect = [False, True] 167 | deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) 168 | self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) 169 | 170 | self.connection.send.assert_called_once_with(S_CMD) 171 | self.assertEqual(2, self.connection.recv.call_count) 172 | self.connection.close.assert_not_called() 173 | deadline.remaining.assert_not_called() 174 | 175 | def testSendRadioMsgRetriesOnThrottlingError(self, ClassMock): 176 | self.init(ClassMock) 177 | self.connection.recv.side_effect = [L_CMD_SUCCESS, S_CMD_THROTTLE_ERROR] 178 | 179 | deadline: Deadline = MagicMock(Deadline) 180 | deadline.is_expired.side_effect = [False, True] 181 | deadline.remaining.return_value = 0.1 182 | deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) 183 | self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) 184 | 185 | self.connection.send.assert_called_once_with(S_CMD) 186 | self.assertEqual(2, self.connection.recv.call_count) 187 | self.connection.close.assert_not_called() 188 | deadline.remaining.assert_called_once() 189 | 190 | def __mockCommandResponse(self, response): 191 | self.connection.recv.side_effect = [None, response] 192 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from maxcube.connection import Connection 6 | from maxcube.deadline import Deadline, Timeout 7 | from maxcube.message import Message 8 | 9 | TEST_TIMEOUT = Timeout("test", 1000.0) 10 | 11 | 12 | @patch("socket.socket", spec=True) 13 | class TestConnection(TestCase): 14 | """ Test Max! Cube connections """ 15 | 16 | def connect(self, socketMock): 17 | self.socket = socketMock.return_value 18 | self.connection = Connection("host", 1234) 19 | self.socket.settimeout.assert_called_once_with(2.0) 20 | self.socket.connect.assert_called_once_with(("host", 1234)) 21 | 22 | def testReadAMessage(self, socketMock): 23 | self.connect(socketMock) 24 | self.socket.recv.return_value = b"A:B\r\n" 25 | 26 | self.assertEqual( 27 | Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) 28 | ) 29 | self.socket.close.assert_not_called() 30 | 31 | def testReadPartialLine(self, socketMock): 32 | self.connect(socketMock) 33 | self.socket.recv.side_effect = [b"A:", b"B\r\n"] 34 | 35 | self.assertEqual( 36 | Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) 37 | ) 38 | 39 | def testReadMultipleLines(self, socketMock): 40 | self.connect(socketMock) 41 | self.socket.recv.return_value = b"A:B\r\nC\r\n" 42 | 43 | self.assertEqual( 44 | Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) 45 | ) 46 | self.socket.recv.reset_mock() 47 | self.assertEqual(Message("C", ""), self.connection.recv(Deadline(TEST_TIMEOUT))) 48 | 49 | def testReadAtConnectionClosing(self, socketMock): 50 | self.connect(socketMock) 51 | self.socket.recv.return_value = b"" 52 | 53 | self.assertIsNone(self.connection.recv(Deadline(TEST_TIMEOUT))) 54 | self.socket.close.assert_called_once() 55 | 56 | def testReadTimeout(self, socketMock): 57 | self.connect(socketMock) 58 | self.socket.recv.side_effect = [socket.timeout] 59 | 60 | self.assertIsNone(self.connection.recv(Deadline(TEST_TIMEOUT))) 61 | self.socket.close.assert_not_called() 62 | 63 | def testSendMessage(self, socketMock): 64 | self.connect(socketMock) 65 | self.connection.send(Message("A", "B")) 66 | self.socket.send.assert_called_with(b"A:B\r\n") 67 | 68 | def testCloseErrorsAreIgnored(self, socketMock): 69 | self.connect(socketMock) 70 | self.socket.close.side_effect = [OSError] 71 | self.connection.close() 72 | self.socket.close.assert_called_once() 73 | -------------------------------------------------------------------------------- /tests/test_cube.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | from unittest import TestCase 4 | from unittest.mock import patch 5 | 6 | from maxcube.cube import MaxCube 7 | from maxcube.device import ( 8 | MAX_CUBE, 9 | MAX_DEVICE_BATTERY_LOW, 10 | MAX_DEVICE_MODE_AUTOMATIC, 11 | MAX_DEVICE_MODE_MANUAL, 12 | MAX_THERMOSTAT, 13 | MAX_THERMOSTAT_PLUS, 14 | MAX_WALL_THERMOSTAT, 15 | MAX_WINDOW_SHUTTER, 16 | MaxDevice, 17 | ) 18 | from maxcube.message import Message 19 | from maxcube.room import MaxRoom 20 | 21 | 22 | def to_messages(lines): 23 | return [Message.decode(line) for line in lines] 24 | 25 | 26 | INIT_RESPONSE_1 = to_messages( 27 | [ 28 | b"H:KEQ0566338,0b6475,0113,00000000,74b7b6f7,00,32,0f0c19,1527,03,0000", 29 | b"M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvFNLRVEwMzM2MTA4B0tpdGNoZW4BAQa8Wk" 30 | b"tFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNsZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ==", 31 | b"C:0b6475,7QtkdQATAf9LRVEwNTY2MzM4AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf////////////////" 32 | b"///////////2h0dHA6Ly93d3cubWF4LXBvcnRhbC5lbHYuZGU6ODAvY3ViZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 33 | b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA==", 34 | b"C:06bc53,0ga8UwEBGP9LRVEwMzM2MTA4KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRS" 35 | b"BFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgR" 36 | b"EhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", 37 | b"C:06bc5a,0ga8WgECGP9LRVEwMzM2MTAwKCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRS" 38 | b"BFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgR" 39 | b"EhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", 40 | b"C:06bc5c,0ga8XAEEGP9LRVEwMzM2MTA0KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgR" 41 | b"SBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEU" 42 | b"gREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", 43 | b"C:08ab82,0girggEDGP9LRVEwMzM1NjYyKCEyCQcYAzAM/wBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgR" 44 | b"SBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEU" 45 | b"gRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIA==", 46 | b"L:Cwa8U/EaGBsqAOwACwa8WgkSGCMqAOcACwa8XAkSGAsqAOcACwirggMaGAAiAAAA", 47 | ] 48 | ) 49 | 50 | INIT_RESPONSE_2 = to_messages( 51 | [ 52 | b"H:JEQ0341267,015d2a,0113,00000000,0336f10a,4b,29,110203,172a,03,0000", 53 | b"M:00,01,VgICAQpCYWRlemltbWVyDi66AgpXb2huemltbWVyAAAAAwEOLrpLRVExMDg2NDM3ClRoZXJtb3N0YXQBAwoIgUtFUTA2NTU3NDMOV2" 54 | b"FuZHRoZXJtb3N0YXQCBAyisktFUTA4Mzk3NzgORmVuc3RlcmtvbnRha3QBAQ==", 55 | b"M:INVALID_M_MESSAGE", 56 | b"ZZ:INVALID_MESSAGE_TYPE", 57 | b"C:015d2a,7QFdKgATAf9KRVEwMzQxMjY3AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf///////////////" 58 | b"////////////2h0dHA6Ly9tYXguZXEtMy5kZTo4MC9jdWJlADAvbG9va3VwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 59 | b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA==", 60 | b"C:0a0881,zgoIgQMCEP9LRVEwNjU1NzQzKyE9CURIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhVCEUgRSBFIEUgRSBFIEUgRSBFIEUgRSBES" 61 | b"FRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMx" 62 | b"VFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgBxgw", 63 | b"C:0ca2b2,EQyisgQBFA9LRVEwODM5Nzc4", 64 | b"C:0e2eba,0g4uugEBEKBLRVExMDg2NDM3KyE9CQcYAzAM/wAgYFR4ISAhICEgISAhIEUgRSBFIEUgRSBFICBgVHghICEgISAhICEgRSBFIEUgR" 65 | b"SBFIEUgIEJUTiEfISAhICEgISBFIEUgRSBFIEUgRSAgQlROIR8hICEgISAhIEUgRSBFIEUgRSBFICBCVE4hHyEgISAhICEgRSBFIEUgRSBFIEU" 66 | b"gIEJUTiEfISAhICEgISBFIEUgRSBFIEUgRSAgQlROIR8hICEgISAhIEUgRSBFIEUgRSBFIA==", 67 | b"L:DAoIgewSGAQQAAAA5QYMorL3EpALDi66ChIYABAAAAA=", 68 | ] 69 | ) 70 | 71 | LAST_STATE_MSG = Message.decode( 72 | b"L:Cwa8U/ESGAAiAAAACwa8WgkSGAAiAAAACwa8XAkSGAUiAAAACwirggMSGAUiAAAA" 73 | ) 74 | 75 | SEND_CMD_OK_RESPONSE = "S:04,0,31" 76 | 77 | WORKDAY_PROGRAMME_1 = [ 78 | {"until": "05:30", "temp": 8}, 79 | {"until": "06:30", "temp": 21}, 80 | {"until": "23:55", "temp": 8}, 81 | {"until": "24:00", "temp": 8}, 82 | ] 83 | 84 | WEEKEND_PROGRAME_1 = [ 85 | {"until": "08:00", "temp": 8}, 86 | {"until": "10:00", "temp": 21}, 87 | {"until": "24:00", "temp": 8}, 88 | ] 89 | 90 | INIT_PROGRAMME_1 = { 91 | "monday": WORKDAY_PROGRAMME_1, 92 | "tuesday": WORKDAY_PROGRAMME_1, 93 | "wednesday": WORKDAY_PROGRAMME_1, 94 | "thursday": WORKDAY_PROGRAMME_1, 95 | "friday": WORKDAY_PROGRAMME_1, 96 | "saturday": WEEKEND_PROGRAME_1, 97 | "sunday": WEEKEND_PROGRAME_1, 98 | } 99 | 100 | 101 | @patch("maxcube.cube.Commander", spec=True) 102 | class TestMaxCube(TestCase): 103 | """ Test the Max! Cube. """ 104 | 105 | def init(self, ClassMock, responses): 106 | self.commander = ClassMock.return_value 107 | self.commander.update.return_value = responses 108 | 109 | self.cube = MaxCube("host", 1234, now=lambda: datetime(2012, 10, 22, 5, 30)) 110 | 111 | self.commander.update.assert_called_once() 112 | self.commander.update.reset_mock() 113 | self.commander.send_radio_msg.return_value = True 114 | 115 | def test_init(self, ClassMock): 116 | self.init(ClassMock, INIT_RESPONSE_1) 117 | self.assertEqual("KEQ0566338", self.cube.serial) 118 | self.assertEqual("0b6475", self.cube.rf_address) 119 | self.assertEqual("Cube", self.cube.name) 120 | self.assertEqual("01.13", self.cube.firmware_version) 121 | self.assertEqual(4, len(self.cube.devices)) 122 | 123 | device = self.cube.devices[0] 124 | self.assertEqual(4.5, device.min_temperature) 125 | self.assertEqual(25.0, device.max_temperature) 126 | self.assertEqual("06BC53", device.rf_address) 127 | self.assertEqual("Kitchen", device.name) 128 | 129 | self.assertEqual("06BC5A", self.cube.devices[1].rf_address) 130 | self.assertEqual("Living", self.cube.devices[1].name) 131 | 132 | self.assertEqual("08AB82", self.cube.devices[2].rf_address) 133 | self.assertEqual("Sleeping", self.cube.devices[2].name) 134 | 135 | self.assertEqual("06BC5C", self.cube.devices[3].rf_address) 136 | self.assertEqual("Work", self.cube.devices[3].name) 137 | 138 | def __update(self, responses: List[Message]): 139 | self.commander.update.return_value = responses 140 | self.cube.update() 141 | self.commander.update.assert_called_once() 142 | 143 | def test_parse_auto_l_message(self, ClassMock): 144 | self.init(ClassMock, INIT_RESPONSE_1) 145 | self.__update([LAST_STATE_MSG]) 146 | 147 | device = self.cube.devices[0] 148 | self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) 149 | self.assertEqual(23.6, device.actual_temperature) 150 | self.assertEqual(17.0, device.target_temperature) 151 | 152 | def test_parse_manual_l_message(self, ClassMock): 153 | self.init(ClassMock, INIT_RESPONSE_1) 154 | self.__update( 155 | [ 156 | Message.decode( 157 | b"L:Cwa8U/ESGQkhALMACwa8WgkSGQAhAMAACwa8XAkSGQUhALIACwirggMSGQUhAAAA" 158 | ) 159 | ] 160 | ) 161 | 162 | device = self.cube.devices[0] 163 | self.assertEqual(MAX_DEVICE_MODE_MANUAL, device.mode) 164 | self.assertEqual(17.9, device.actual_temperature) 165 | self.assertEqual(16.5, device.target_temperature) 166 | 167 | def test_disconnect(self, ClassMock): 168 | self.init(ClassMock, INIT_RESPONSE_1) 169 | self.cube.disconnect() 170 | self.commander.disconnect.assert_called_once() 171 | 172 | def test_use_persistent_connection(self, ClassMock): 173 | self.init(ClassMock, INIT_RESPONSE_1) 174 | self.commander.use_persistent_connection = True 175 | self.assertTrue(self.cube.use_persistent_connection) 176 | self.cube.use_persistent_connection = False 177 | self.assertFalse(self.commander.use_persistent_connection) 178 | 179 | def test_is_thermostat(self, _): 180 | device = MaxDevice() 181 | device.type = MAX_CUBE 182 | self.assertFalse(device.is_thermostat()) 183 | device.type = MAX_THERMOSTAT 184 | self.assertTrue(device.is_thermostat()) 185 | device.type = MAX_THERMOSTAT_PLUS 186 | self.assertTrue(device.is_thermostat()) 187 | device.type = MAX_WALL_THERMOSTAT 188 | self.assertFalse(device.is_thermostat()) 189 | device.type = MAX_WINDOW_SHUTTER 190 | self.assertFalse(device.is_thermostat()) 191 | 192 | def test_is_wall_thermostat(self, _): 193 | device = MaxDevice() 194 | device.type = MAX_CUBE 195 | self.assertFalse(device.is_wallthermostat()) 196 | device.type = MAX_THERMOSTAT 197 | self.assertFalse(device.is_wallthermostat()) 198 | device.type = MAX_THERMOSTAT_PLUS 199 | self.assertFalse(device.is_wallthermostat()) 200 | device.type = MAX_WALL_THERMOSTAT 201 | self.assertTrue(device.is_wallthermostat()) 202 | device.type = MAX_WINDOW_SHUTTER 203 | self.assertFalse(device.is_wallthermostat()) 204 | 205 | def test_is_window_shutter(self, _): 206 | device = MaxDevice() 207 | device.type = MAX_CUBE 208 | self.assertFalse(device.is_windowshutter()) 209 | device.type = MAX_THERMOSTAT 210 | self.assertFalse(device.is_windowshutter()) 211 | device.type = MAX_THERMOSTAT_PLUS 212 | self.assertFalse(device.is_windowshutter()) 213 | device.type = MAX_WALL_THERMOSTAT 214 | self.assertFalse(device.is_windowshutter()) 215 | device.type = MAX_WINDOW_SHUTTER 216 | self.assertTrue(device.is_windowshutter()) 217 | 218 | def test_set_target_temperature(self, ClassMock): 219 | self.init(ClassMock, INIT_RESPONSE_1) 220 | 221 | self.assertTrue(self.cube.set_target_temperature(self.cube.devices[0], 24.5)) 222 | 223 | self.assertEqual(24.5, self.cube.devices[0].target_temperature) 224 | self.commander.send_radio_msg.assert_called_once() 225 | self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") 226 | 227 | def test_do_not_update_if_set_target_temperature_fails(self, ClassMock): 228 | self.init(ClassMock, INIT_RESPONSE_1) 229 | self.commander.send_radio_msg.return_value = False 230 | 231 | self.assertFalse(self.cube.set_target_temperature(self.cube.devices[0], 24.5)) 232 | 233 | self.assertEqual(21, self.cube.devices[0].target_temperature) 234 | self.commander.send_radio_msg.assert_called_once() 235 | self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") 236 | 237 | def test_set_target_temperature_should_round_temperature(self, ClassMock): 238 | self.init(ClassMock, INIT_RESPONSE_1) 239 | 240 | self.cube.set_target_temperature(self.cube.devices[0], 24.6) 241 | 242 | self.assertEqual(24.5, self.cube.devices[0].target_temperature) 243 | self.commander.send_radio_msg.assert_called_once() 244 | self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") 245 | 246 | def test_set_target_temperature_is_ignored_by_windowshutter(self, ClassMock): 247 | self.init(ClassMock, INIT_RESPONSE_2) 248 | self.cube.set_target_temperature(self.cube.devices[2], 24.5) 249 | self.commander.send_radio_msg.assert_not_called() 250 | 251 | def test_set_mode_thermostat(self, ClassMock): 252 | self.init(ClassMock, INIT_RESPONSE_1) 253 | device = self.cube.devices[0] 254 | self.assertEqual(21.0, device.target_temperature) 255 | self.cube.set_mode(device, MAX_DEVICE_MODE_MANUAL) 256 | 257 | self.assertEqual(MAX_DEVICE_MODE_MANUAL, device.mode) 258 | self.commander.send_radio_msg.assert_called_once() 259 | self.commander.send_radio_msg.assert_called_with("00044000000006BC53016A") 260 | 261 | def test_init_2(self, ClassMock): 262 | self.init(ClassMock, INIT_RESPONSE_2) 263 | self.assertEqual("JEQ0341267", self.cube.serial) 264 | self.assertEqual("015d2a", self.cube.rf_address) 265 | self.assertEqual("Cube", self.cube.name) 266 | self.assertEqual("01.13", self.cube.firmware_version) 267 | self.assertEqual(3, len(self.cube.devices)) 268 | 269 | device = self.cube.devices[0] 270 | self.assertEqual(21.5, device.comfort_temperature) 271 | self.assertEqual(16.5, device.eco_temperature) 272 | self.assertEqual(4.5, device.min_temperature) 273 | self.assertEqual(30.5, device.max_temperature) 274 | device = self.cube.devices[1] 275 | self.assertEqual(21.5, device.comfort_temperature) 276 | self.assertEqual(16.5, device.eco_temperature) 277 | self.assertEqual(4.5, device.min_temperature) 278 | self.assertEqual(30.5, device.max_temperature) 279 | device = self.cube.devices[2] 280 | self.assertEqual(1, device.initialized) 281 | 282 | device = self.cube.devices[0] 283 | self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) 284 | self.assertIsNone(device.actual_temperature) 285 | self.assertEqual(8.0, device.target_temperature) 286 | 287 | device = self.cube.devices[1] 288 | self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) 289 | self.assertEqual(22.9, device.actual_temperature) 290 | self.assertEqual(8.0, device.target_temperature) 291 | 292 | device = self.cube.devices[2] 293 | self.assertFalse(device.is_open) 294 | self.assertTrue(device.battery == MAX_DEVICE_BATTERY_LOW) 295 | 296 | def test_parse_m_message(self, ClassMock): 297 | self.init(ClassMock, INIT_RESPONSE_2) 298 | self.__update( 299 | [ 300 | Message.decode( 301 | b"M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvF" 302 | b"NLRVEwMzM2MTA4B0tpdGNoZW4BAQa8WktFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNs" 303 | b"ZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ==" 304 | ), 305 | INIT_RESPONSE_2[-1], 306 | ] 307 | ) 308 | 309 | self.assertEqual("0E2EBA", self.cube.devices[0].rf_address) 310 | self.assertEqual("Thermostat", self.cube.devices[0].name) 311 | self.assertEqual(MAX_THERMOSTAT, self.cube.devices[0].type) 312 | self.assertEqual("KEQ1086437", self.cube.devices[0].serial) 313 | self.assertEqual(1, self.cube.devices[0].room_id) 314 | 315 | self.assertEqual("0A0881", self.cube.devices[1].rf_address) 316 | self.assertEqual("Wandthermostat", self.cube.devices[1].name) 317 | self.assertEqual(MAX_WALL_THERMOSTAT, self.cube.devices[1].type) 318 | self.assertEqual("KEQ0655743", self.cube.devices[1].serial) 319 | self.assertEqual(2, self.cube.devices[1].room_id) 320 | 321 | self.assertEqual("0CA2B2", self.cube.devices[2].rf_address) 322 | self.assertEqual("Fensterkontakt", self.cube.devices[2].name) 323 | self.assertEqual(MAX_WINDOW_SHUTTER, self.cube.devices[2].type) 324 | self.assertEqual("KEQ0839778", self.cube.devices[2].serial) 325 | self.assertEqual(1, self.cube.devices[2].room_id) 326 | 327 | self.assertEqual("Kitchen", self.cube.rooms[0].name) 328 | self.assertEqual(1, self.cube.rooms[0].id) 329 | 330 | self.assertEqual("Living", self.cube.rooms[1].name) 331 | self.assertEqual(2, self.cube.rooms[1].id) 332 | 333 | def test_get_devices(self, ClassMock): 334 | self.init(ClassMock, INIT_RESPONSE_2) 335 | devices = self.cube.get_devices() 336 | self.assertEqual(3, len(devices)) 337 | 338 | def test_device_by_rf(self, ClassMock): 339 | self.init(ClassMock, INIT_RESPONSE_2) 340 | device = self.cube.device_by_rf("0CA2B2") 341 | 342 | self.assertEqual("0CA2B2", device.rf_address) 343 | self.assertEqual("Fensterkontakt", device.name) 344 | self.assertEqual(MAX_WINDOW_SHUTTER, device.type) 345 | self.assertEqual("KEQ0839778", device.serial) 346 | self.assertEqual(1, device.room_id) 347 | 348 | def test_device_by_rf_negative(self, ClassMock): 349 | self.init(ClassMock, INIT_RESPONSE_2) 350 | device = self.cube.device_by_rf("DEADBEEF") 351 | 352 | self.assertIsNone(device) 353 | 354 | def test_devices_by_room(self, ClassMock): 355 | self.init(ClassMock, INIT_RESPONSE_2) 356 | room = MaxRoom() 357 | room.id = 1 358 | devices = self.cube.devices_by_room(room) 359 | self.assertEqual(2, len(devices)) 360 | 361 | def test_devices_by_room_negative(self, ClassMock): 362 | self.init(ClassMock, INIT_RESPONSE_2) 363 | room = MaxRoom() 364 | room.id = 3 365 | devices = self.cube.devices_by_room(room) 366 | self.assertEqual(0, len(devices)) 367 | 368 | def test_get_rooms(self, ClassMock): 369 | self.init(ClassMock, INIT_RESPONSE_2) 370 | rooms = self.cube.get_rooms() 371 | 372 | self.assertEqual("Badezimmer", rooms[0].name) 373 | self.assertEqual(1, rooms[0].id) 374 | 375 | self.assertEqual("Wohnzimmer", rooms[1].name) 376 | self.assertEqual(2, rooms[1].id) 377 | 378 | def test_room_by_id(self, ClassMock): 379 | self.init(ClassMock, INIT_RESPONSE_2) 380 | room = self.cube.room_by_id(1) 381 | 382 | self.assertEqual("Badezimmer", room.name) 383 | self.assertEqual(1, room.id) 384 | 385 | def test_room_by_id_negative(self, ClassMock): 386 | self.init(ClassMock, INIT_RESPONSE_2) 387 | room = self.cube.room_by_id(3) 388 | 389 | self.assertIsNone(room) 390 | 391 | def test_set_programme(self, ClassMock): 392 | self.init(ClassMock, INIT_RESPONSE_2) 393 | self.commander.send_radio_msg.return_value = True 394 | result = self.cube.set_programme( 395 | self.cube.devices[0], 396 | "saturday", 397 | [{"temp": 20.5, "until": "13:30"}, {"temp": 18, "until": "24:00"}], 398 | ) 399 | self.assertTrue(result) 400 | self.commander.send_radio_msg.assert_called_once() 401 | self.commander.send_radio_msg.assert_called_with( 402 | "0000100000000E2EBA010052A249200000000000" 403 | ) 404 | 405 | def test_set_programme_already_existing_does_nothing(self, ClassMock): 406 | self.init(ClassMock, INIT_RESPONSE_2) 407 | result = self.cube.set_programme( 408 | self.cube.devices[0], "saturday", INIT_PROGRAMME_1["saturday"] 409 | ) 410 | self.assertIsNone(result) 411 | self.commander.send_radio_msg.assert_not_called() 412 | 413 | def test_get_device_as_dict(self, ClassMock): 414 | self.init(ClassMock, INIT_RESPONSE_2) 415 | device = self.cube.devices[0] 416 | result = device.to_dict() 417 | self.assertEqual(result["name"], "Thermostat") 418 | self.assertEqual(result["comfort_temperature"], 21.5) 419 | self.assertEqual( 420 | result["programme"]["monday"], 421 | [ 422 | {"until": "05:30", "temp": 8}, 423 | {"until": "06:30", "temp": 21}, 424 | {"until": "23:55", "temp": 8}, 425 | {"until": "24:00", "temp": 8}, 426 | ], 427 | ) 428 | 429 | def test_set_auto_mode_read_temp_from_program(self, ClassMock): 430 | self.init(ClassMock, INIT_RESPONSE_2) 431 | device = self.cube.devices[0] 432 | self.assertEqual(8.0, device.target_temperature) 433 | self.cube.set_mode(device, MAX_DEVICE_MODE_AUTOMATIC) 434 | self.assertEqual(21.0, device.target_temperature) 435 | self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) 436 | self.commander.send_radio_msg.assert_called_once() 437 | self.commander.send_radio_msg.assert_called_with("0004400000000E2EBA0100") 438 | -------------------------------------------------------------------------------- /tests/test_deadline.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | 4 | from maxcube.deadline import Deadline, Timeout 5 | 6 | ZERO_TIMEOUT = Timeout("zero", 0) 7 | ONE_TIMEOUT = Timeout("one", 1) 8 | ROOT_TIMEOUT = Timeout("root", 10) 9 | 10 | 11 | @patch("maxcube.deadline.time") 12 | class TestDeadline(TestCase): 13 | """ Test Deadlines """ 14 | 15 | def testDeadlineLowerBoundForRemainingTime(self, timeMock): 16 | timeMock.side_effect = [0, 0.4, 0.6] 17 | deadline = Deadline(ONE_TIMEOUT) 18 | self.assertAlmostEqual(0.6, deadline.remaining(lower_bound=0.50)) 19 | self.assertAlmostEqual(0.5, deadline.remaining(lower_bound=0.50)) 20 | 21 | def testDeadlineUpperBoundForRemainingTime(self, timeMock): 22 | timeMock.side_effect = [0, 0.4, 0.6] 23 | deadline = Deadline(ONE_TIMEOUT) 24 | self.assertAlmostEqual(0.5, deadline.remaining(upper_bound=0.50)) 25 | self.assertAlmostEqual(0.4, deadline.remaining(upper_bound=0.50)) 26 | 27 | def testDeadlineIsExpired(self, timeMock): 28 | timeMock.side_effect = [0, 0.4, 1.0, 1.000001] 29 | deadline = Deadline(ONE_TIMEOUT) 30 | self.assertFalse(deadline.is_expired()) 31 | self.assertTrue(deadline.is_expired()) 32 | self.assertTrue(deadline.is_expired()) 33 | 34 | def testZeroDeadlineIsAlreadyExpired(self, timeMock): 35 | timeMock.return_value = 0.0 36 | deadline = Deadline(ZERO_TIMEOUT) 37 | self.assertEqual("zero[0/0]", deadline.fullname()) 38 | self.assertTrue(deadline.is_expired()) 39 | 40 | def testSubtimeoutHandling(self, timeMock): 41 | timeMock.side_effect = [0.0, 0.1, 0.2, 0.2, 0.3] 42 | deadline = Deadline(ROOT_TIMEOUT) 43 | subdeadline = deadline.subtimeout(ONE_TIMEOUT) 44 | self.assertEqual("root[9.8/10]:one[0.9/1]", subdeadline.fullname()) 45 | self.assertAlmostEqual(0.8, subdeadline.remaining()) 46 | 47 | def testSubtimeoutHandlingWhenLargerThanTimeout(self, timeMock): 48 | timeMock.side_effect = [0.0, 9.1, 9.2] 49 | deadline = Deadline(ROOT_TIMEOUT) 50 | subdeadline = deadline.subtimeout(ONE_TIMEOUT) 51 | self.assertAlmostEqual(0.8, subdeadline.remaining()) 52 | 53 | def testToString(self, timeMock): 54 | timeMock.side_effect = [0.0, 0.1] 55 | deadline = Deadline(ONE_TIMEOUT) 56 | self.assertEqual("Deadline one[0.9/1]", str(deadline)) 57 | 58 | def testToStringForExpiredDeadline(self, timeMock): 59 | timeMock.side_effect = [0.0, 1.1] 60 | deadline = Deadline(ONE_TIMEOUT) 61 | self.assertEqual("Deadline one[0/1]", str(deadline)) 62 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from maxcube.message import Message 4 | 5 | 6 | class TestMessage(TestCase): 7 | """ Test Max! Cube messages """ 8 | 9 | def testDecodeValidMessage(self): 10 | line = b"s:AARAAAAABrxTAWo=\r\n" 11 | msg = Message.decode(line) 12 | self.assertEqual(Message("s", "AARAAAAABrxTAWo="), msg) 13 | self.assertEqual("S", msg.reply_cmd()) 14 | self.assertEqual(line, msg.encode()) 15 | 16 | def testDecodeEmptyMessage(self): 17 | self.assertEqual(Message(""), Message.decode(b"\r\n")) 18 | -------------------------------------------------------------------------------- /tests/test_thermostat.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from maxcube.thermostat import MaxThermostat 4 | 5 | 6 | class TestMessage(TestCase): 7 | """ Test Max! thermostat """ 8 | 9 | def testGetCurrentTemperatureReturnsNoneIfUninitialized(self): 10 | t = MaxThermostat() 11 | self.assertIsNone(t.get_current_temp_in_auto_mode()) 12 | --------------------------------------------------------------------------------