pax_global_header00006660000000000000000000000064140435271640014520gustar00rootroot0000000000000052 comment=8eebfd4e944b22c738d509b8ef4d7f35a4a48870 python-maxcube-api-0.4.3/000077500000000000000000000000001404352716400152365ustar00rootroot00000000000000python-maxcube-api-0.4.3/.coveragerc000066400000000000000000000000711404352716400173550ustar00rootroot00000000000000[run] source = maxcube omit = maxcube/connection.py python-maxcube-api-0.4.3/.github/000077500000000000000000000000001404352716400165765ustar00rootroot00000000000000python-maxcube-api-0.4.3/.github/workflows/000077500000000000000000000000001404352716400206335ustar00rootroot00000000000000python-maxcube-api-0.4.3/.github/workflows/validate.yml000066400000000000000000000016411404352716400231510ustar00rootroot00000000000000--- name: python-maxcube-api commit validation on: - push - pull_request jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install coveralls if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Pre-commit hooks uses: pre-commit/action@v2.0.0 with: extra_args: --hook-stage=push - name: Unit Test Coverage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | coverage run setup.py test coveralls --service=github python-maxcube-api-0.4.3/.gitignore000066400000000000000000000002641404352716400172300ustar00rootroot00000000000000.idea maxcube.egg-info maxcube/__pycache__ tests/__pycache__ .cache/v/cache .coverage setup.cpython-35-PYTEST.pyc python_maxcube_api.egg-info dist *.pyc build maxcube_api.egg-info python-maxcube-api-0.4.3/.pre-commit-config.yaml000066400000000000000000000034441404352716400215240ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 20.8b1 hooks: - id: black args: - --safe - --quiet files: ^((maxcube|sample|tests)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.0.0 hooks: - id: codespell args: - --ignore-words-list=maxcube - --skip="./.*" - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/ - repo: https://gitlab.com/pycqa/flake8 rev: 3.9.0 hooks: - id: flake8 additional_dependencies: - pycodestyle==2.7.0 - pyflakes==2.3.1 - pydocstyle==6.0.0 - flake8-comprehensions==3.4.0 - flake8-noqa==1.1.0 files: ^(maxcube|sample|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort rev: 5.7.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.2.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: no-commit-to-branch stages: [commit] args: - --branch=master - repo: https://github.com/adrienverge/yamllint.git rev: v1.24.2 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.2.1 hooks: - id: prettier stages: [manual] - repo: https://github.com/cdce8p/python-typing-update rev: v0.3.2 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! - id: python-typing-update stages: [manual] args: - --py38-plus - --force - --keep-updates files: ^(homeassistant|tests|script)/.+\.py$ python-maxcube-api-0.4.3/CHANGELOG.rst000066400000000000000000000027741404352716400172710ustar00rootroot00000000000000### Version 0.4.0 * Issues fixed: - [MaxCube reset to fatory defaults](https://github.com/hackercowboy/python-maxcube-api/issues/12) - [Unable to change back to scheduled temperature (auto mode)](https://github.com/hackercowboy/python-maxcube-api/issues/24 * Breaking changes: - Increased minimum supported version of Python to >= 3.7 - MaxCubeConnection object removed. MaxCube constructor now receives the host and port parameters directly - The connection is not released after each command is send. This can block other applications to connect to the Max! Cube. You can call cube disconnect() method to release the connection manually. ### Version 0.4.1 * Several minor changes: - Allow to rollback to non-persistent connections to keep using original software for unsupported operations - Serial number of the cube was not extracted from handshake - When changing to auto mode, use weekly programme to fetch current temperature - Make MAX! Cube TCP port optional ### Version 0.4.2 * Bug fixes: - Interpret correctly S command error responses (https://github.com/home-assistant/core/issues/49075) - Support application timezone configuration (https://github.com/home-assistant/core/issues/49076) * Improvements in device logging * Build improvements: * Move from Travis to Github Actions to execute validation actions * Add code style and quality checks to the validation actions ### Version 0.4.3 * Improve logging for invalid message and expired deadlines python-maxcube-api-0.4.3/LICENSE000066400000000000000000000020741404352716400162460ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2015 David Übelacker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-maxcube-api-0.4.3/Makefile000066400000000000000000000001101404352716400166660ustar00rootroot00000000000000deploy: rm -rf dist python setup.py bdist_wheel twine upload dist/* python-maxcube-api-0.4.3/README.rst000066400000000000000000000035751404352716400167370ustar00rootroot00000000000000eQ-3/ELV MAX! Cube Python API |Build Status| |Coverage Status| =============================================================== A python api to control the Max! Cube thermostats: - get basic info about the cube itself - get info about the max thermostats connected to the cube (mode, temperatures ...) Basic usage: .. code:: python from maxcube.cube import MaxCube cube = MaxCube('192.168.0.20') for device in cube.devices: print(device.name) print(device.actual_temperature) This api was build for the integration of the Max! thermostats into `Home Assistant `__ and mostly only covers the functions needed for the integration. It does also include functions needed to save and restore thermostat programmes. For example: .. code:: shell # dump programmes (and other data) to a JSON file python3 prog.py dump --host=192.168.0.11 > backup.json # load programmes (not other data!) from a JSON file python3 prog.py load --host=192.168.0.11 < backup.json Running tests ============= .. code:: python python3 -m unittest discover tests/ Acknowledgements ================ Thanks to: - `https://github.com/Bouni/max-cube-protocol `__ - `https://github.com/ercpe/pymax `__ - `https://github.com/aleszoulek/maxcube `__ - `openhab integration `__ .. |Build Status| image:: https://travis-ci.org/hackercowboy/python-maxcube-api.svg?branch=master :target: https://travis-ci.org/hackercowboy/python-maxcube-api .. |Coverage Status| image:: https://coveralls.io/repos/hackercowboy/python-maxcube-api/badge.svg?branch=master&service=github :target: https://coveralls.io/github/hackercowboy/python-maxcube-api?branch=master python-maxcube-api-0.4.3/maxcube/000077500000000000000000000000001404352716400166625ustar00rootroot00000000000000python-maxcube-api-0.4.3/maxcube/__init__.py000066400000000000000000000000001404352716400207610ustar00rootroot00000000000000python-maxcube-api-0.4.3/maxcube/commander.py000066400000000000000000000113461404352716400212060ustar00rootroot00000000000000import base64 import logging from time import sleep from typing import List from .connection import Connection from .deadline import Deadline, Timeout from .message import Message logger = logging.getLogger(__name__) QUIT_MSG = Message("q") L_MSG = Message("l") L_REPLY_CMD = L_MSG.reply_cmd() UPDATE_TIMEOUT = Timeout("update", 3.0) CONNECT_TIMEOUT = Timeout("connect", 3.0) FLUSH_INPUT_TIMEOUT = Timeout("flush-input", 0) SEND_RADIO_MSG_TIMEOUT = Timeout("send-radio-msg", 30.0) CMD_REPLY_TIMEOUT = Timeout("cmd-reply", 2.0) class Commander(object): def __init__(self, host: str, port: int): self.__host: str = host self.__port: int = port self.use_persistent_connection = True self.__connection: Connection = None self.__unsolicited_messages: List[Message] = [] def disconnect(self): if self.__connection: try: self.__connection.send(QUIT_MSG) except Exception: logger.debug( "Unable to properly shutdown MAX Cube connection. Resetting it..." ) finally: self.__close() def get_unsolicited_messages(self) -> List[Message]: result = self.__unsolicited_messages self.__unsolicited_messages = [] return result def update(self) -> List[Message]: deadline = Deadline(UPDATE_TIMEOUT) if self.__is_connected(): try: response = self.__call(L_MSG, deadline) if response: self.__unsolicited_messages.append(response) except Exception: self.__connect(deadline) else: self.__connect(deadline) if not self.use_persistent_connection: self.disconnect() return self.get_unsolicited_messages() def send_radio_msg(self, hex_radio_msg: str) -> bool: deadline = Deadline(SEND_RADIO_MSG_TIMEOUT) request = Message( "s", base64.b64encode(bytearray.fromhex(hex_radio_msg)).decode("utf-8") ) while not deadline.is_expired(): if self.__cmd_send_radio_msg(request, deadline): return True return False def __cmd_send_radio_msg(self, request: Message, deadline: Deadline) -> bool: try: response = self.__call(request, deadline) duty_cycle, status_code, free_slots = response.arg.split(",", 3) if status_code == "0": return True logger.debug( "Radio message %s was not send [DutyCycle:%s, StatusCode:%s, FreeSlots:%s]" % (request, duty_cycle, status_code, free_slots) ) if int(duty_cycle, 16) == 100 and int(free_slots, 16) == 0: sleep(deadline.remaining(upper_bound=10.0)) except Exception as ex: logger.error("Error sending radio message to Max! Cube: " + str(ex)) return False def __call(self, msg: Message, deadline: Deadline) -> Message: already_connected = self.__is_connected() if not already_connected: self.__connect(deadline.subtimeout(CONNECT_TIMEOUT)) else: # Protection in case some late answer arrives for a previous command self.__wait_for_reply(None, deadline.subtimeout(FLUSH_INPUT_TIMEOUT)) try: self.__connection.send(msg) subdeadline = deadline.subtimeout(CMD_REPLY_TIMEOUT) result = self.__wait_for_reply(msg.reply_cmd(), subdeadline) if result is None: raise TimeoutError(str(subdeadline)) return result except Exception: self.__close() if already_connected: return self.__call(msg, deadline) else: raise finally: if not self.use_persistent_connection: self.disconnect() def __is_connected(self) -> bool: return self.__connection is not None def __connect(self, deadline: Deadline): self.__unsolicited_messages = [] self.__connection = Connection(self.__host, self.__port) reply = self.__wait_for_reply( L_REPLY_CMD, deadline.subtimeout(CMD_REPLY_TIMEOUT) ) if reply: self.__unsolicited_messages.append(reply) def __wait_for_reply(self, reply_cmd: str, deadline: Deadline) -> Message: while True: msg = self.__connection.recv(deadline) if msg is None: return None elif reply_cmd and msg.cmd == reply_cmd: return msg else: self.__unsolicited_messages.append(msg) def __close(self): self.__connection.close() self.__connection = None python-maxcube-api-0.4.3/maxcube/connection.py000066400000000000000000000035061404352716400213770ustar00rootroot00000000000000import logging import socket from .deadline import Deadline from .message import Message logger = logging.getLogger(__name__) BLOCK_SIZE = 4096 DEFAULT_TIMEOUT = 2.0 class Connection(object): def __init__(self, host: str, port: int): self.__buffer: bytearray = bytearray() self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.__socket.settimeout(DEFAULT_TIMEOUT) self.__socket.connect((host, port)) logger.debug("Connected to %s:%d!" % (host, port)) def __read_buffered_msg(self) -> Message: buf = self.__buffer pos = buf.find(b"\r\n") if pos < 0: return None result = buf[0:pos] del buf[0 : pos + 2] return Message.decode(result) def recv(self, deadline: Deadline) -> Message: msg = self.__read_buffered_msg() try: while msg is None: self.__socket.settimeout(deadline.remaining(lower_bound=0.001)) tmp = self.__socket.recv(BLOCK_SIZE) if len(tmp) > 0: self.__buffer.extend(tmp) msg = self.__read_buffered_msg() logger.debug("received: %s" % msg) else: logger.debug("Connection shutdown by remote peer") self.close() return None except socket.timeout: logger.debug("readline timed out") finally: self.__socket.settimeout(DEFAULT_TIMEOUT) return msg def send(self, msg: Message): self.__socket.send(msg.encode()) logger.debug("sent: %s" % msg) def close(self): try: self.__socket.close() logger.debug("closed") except Exception: logger.debug("Unable to close connection. Dropping it...") python-maxcube-api-0.4.3/maxcube/cube.py000066400000000000000000000334741404352716400201650ustar00rootroot00000000000000import base64 from datetime import datetime import json import logging import struct from typing import Callable from maxcube.device import ( MAX_CUBE, MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_MANUAL, MAX_THERMOSTAT, MAX_THERMOSTAT_PLUS, MAX_WALL_THERMOSTAT, MAX_WINDOW_SHUTTER, MaxDevice, ) from maxcube.room import MaxRoom from maxcube.thermostat import MaxThermostat from maxcube.wallthermostat import MaxWallThermostat from maxcube.windowshutter import MaxWindowShutter from .commander import Commander logger = logging.getLogger(__name__) CMD_SET_PROG = "10" UNKNOWN = "00" RF_FLAG_IS_ROOM = "04" RF_FLAG_IS_DEVICE = "00" RF_NULL_ADDRESS = "000000" DEFAULT_PORT = 62910 DAYS = [ "saturday", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ] class MaxCube(MaxDevice): def __init__( self, host: str, port: int = DEFAULT_PORT, now: Callable[[], datetime] = datetime.now, ): super(MaxCube, self).__init__() self.__commander = Commander(host, port) self.name = "Cube" self.type = MAX_CUBE self.firmware_version = None self.devices = [] self.rooms = [] self._now: Callable[[], datetime] = now self.update() self.log() @property def use_persistent_connection(self) -> bool: return self.__commander.use_persistent_connection @use_persistent_connection.setter def use_persistent_connection(self, value: bool) -> None: self.__commander.use_persistent_connection = value def disconnect(self): self.__commander.disconnect() def __str__(self): return self.describe("CUBE", f"firmware={self.firmware_version}") def log(self): logger.info(str(self)) for room in self.rooms: logger.info(f" * ROOM {room.name}") for device in self.devices_by_room(room): logger.info(" --- " + str(device)) def update(self): self.__parse_responses(self.__commander.update()) def get_devices(self): return self.devices def device_by_rf(self, rf): for device in self.devices: if device.rf_address == rf: return device return None def devices_by_room(self, room): rooms = [] for device in self.devices: if device.room_id == room.id: rooms.append(device) return rooms def get_rooms(self): return self.rooms def room_by_id(self, id): for room in self.rooms: if room.id == id: return room return None def __parse_responses(self, messages): for msg in messages: try: cmd = msg.cmd if cmd == "C": self.parse_c_message(msg.arg) elif cmd == "H": self.parse_h_message(msg.arg) elif cmd == "L": self.parse_l_message(msg.arg) elif cmd == "M": self.parse_m_message(msg.arg) else: logger.debug("Ignored unsupported message: %s" % (msg)) except Exception: logger.warn(f"Error processing response message {msg}", exc_info=True) def parse_c_message(self, message): logger.debug("Parsing c_message: " + message) params = message.split(",") device_rf_address = params[0].upper() data = bytearray(base64.b64decode(params[1])) device = self.device_by_rf(device_rf_address) if device and device.is_thermostat(): device.comfort_temperature = data[18] / 2.0 device.eco_temperature = data[19] / 2.0 device.max_temperature = data[20] / 2.0 device.min_temperature = data[21] / 2.0 device.programme = get_programme(data[29:]) if device and device.is_wallthermostat(): device.comfort_temperature = data[18] / 2.0 device.eco_temperature = data[19] / 2.0 device.max_temperature = data[20] / 2.0 device.min_temperature = data[21] / 2.0 if device and device.is_windowshutter(): # Pure Speculation based on this: # Before: [17][12][162][178][4][0][20][15]KEQ0839778 # After: [17][12][162][178][4][1][20][15]KEQ0839778 device.initialized = data[5] def parse_h_message(self, message): logger.debug("Parsing h_message: " + message) tokens = message.split(",") self.serial = tokens[0] self.rf_address = tokens[1] self.firmware_version = (tokens[2][0:2]) + "." + (tokens[2][2:4]) def parse_m_message(self, message): logger.debug("Parsing m_message: " + message) data = bytearray(base64.b64decode(message.split(",")[2])) num_rooms = data[2] pos = 3 for _ in range(0, num_rooms): room_id = struct.unpack("bb", data[pos : pos + 2])[0] name_length = struct.unpack("bb", data[pos : pos + 2])[1] pos += 1 + 1 name = data[pos : pos + name_length].decode("utf-8") pos += name_length device_rf_address = self.parse_rf_address(data[pos : pos + 3]) pos += 3 room = self.room_by_id(room_id) if not room: room = MaxRoom() room.id = room_id room.name = name self.rooms.append(room) else: room.name = name num_devices = data[pos] pos += 1 for device_idx in range(0, num_devices): device_type = data[pos] device_rf_address = self.parse_rf_address(data[pos + 1 : pos + 1 + 3]) device_serial = data[pos + 4 : pos + 14].decode("utf-8") device_name_length = data[pos + 14] device_name = data[pos + 15 : pos + 15 + device_name_length].decode("utf-8") room_id = data[pos + 15 + device_name_length] device = self.device_by_rf(device_rf_address) if not device: if device_type == MAX_THERMOSTAT or device_type == MAX_THERMOSTAT_PLUS: device = MaxThermostat() if device_type == MAX_WINDOW_SHUTTER: device = MaxWindowShutter() if device_type == MAX_WALL_THERMOSTAT: device = MaxWallThermostat() if device: self.devices.append(device) if device: device.type = device_type device.rf_address = device_rf_address device.room_id = room_id device.name = device_name device.serial = device_serial pos += 1 + 3 + 10 + device_name_length + 2 def parse_l_message(self, message): logger.debug("Parsing l_message: " + message) data = bytearray(base64.b64decode(message)) pos = 0 while pos < len(data): length = data[pos] device_rf_address = self.parse_rf_address(data[pos + 1 : pos + 4]) device = self.device_by_rf(device_rf_address) if device: bits1, bits2 = struct.unpack("BB", bytearray(data[pos + 5 : pos + 7])) device.battery = self.resolve_device_battery(bits2) # Thermostat or Wall Thermostat if device and (device.is_thermostat() or device.is_wallthermostat()): device.target_temperature = (data[pos + 8] & 0x7F) / 2.0 bits1, bits2 = struct.unpack("BB", bytearray(data[pos + 5 : pos + 7])) device.mode = self.resolve_device_mode(bits2) # Thermostat if device and device.is_thermostat(): device.valve_position = data[pos + 7] if ( device.mode == MAX_DEVICE_MODE_MANUAL or device.mode == MAX_DEVICE_MODE_AUTOMATIC ): actual_temperature = ( (data[pos + 9] & 0xFF) * 256 + (data[pos + 10] & 0xFF) ) / 10.0 if actual_temperature != 0: device.actual_temperature = actual_temperature else: device.actual_temperature = None # Wall Thermostat if device and device.is_wallthermostat(): device.actual_temperature = ( ((data[pos + 8] & 0x80) << 1) + data[pos + 12] ) / 10.0 # Window Shutter if device and device.is_windowshutter(): status = data[pos + 6] & 0x03 if status > 0: device.is_open = True else: device.is_open = False # Advance our pointer to the next submessage pos += length + 1 def set_target_temperature(self, thermostat, temperature): return self.set_temperature_mode(thermostat, temperature, None) def set_mode(self, thermostat, mode): return self.set_temperature_mode(thermostat, None, mode) def set_temperature_mode(self, thermostat, temperature, mode): logger.debug( "Setting temperature %s and mode %s on %s!", temperature, mode, thermostat.rf_address, ) if not thermostat.is_thermostat() and not thermostat.is_wallthermostat(): logger.error("%s is no (wall-)thermostat!", thermostat.rf_address) return if mode is None: mode = thermostat.mode if temperature is None: temperature = ( 0 if mode == MAX_DEVICE_MODE_AUTOMATIC else thermostat.target_temperature ) rf_address = thermostat.rf_address room = to_hex(thermostat.room_id) target_temperature = int(temperature * 2) + (mode << 6) byte_cmd = "000440000000" + rf_address + room + to_hex(target_temperature) if self.__commander.send_radio_msg(byte_cmd): thermostat.mode = mode if temperature > 0: thermostat.target_temperature = int(temperature * 2) / 2.0 elif mode == MAX_DEVICE_MODE_AUTOMATIC: thermostat.target_temperature = thermostat.get_programmed_temp_at( self._now() ) return True return False def set_programme(self, thermostat, day, metadata): # compare with current programme if thermostat.programme[day] == metadata: logger.debug("Skipping setting unchanged programme for " + day) return heat_time_tuples = [(x["temp"], x["until"]) for x in metadata] # pad heat_time_tuples so that there are always seven for _ in range(7 - len(heat_time_tuples)): heat_time_tuples.append((0, "00:00")) command = "" if thermostat.is_room(): rf_flag = RF_FLAG_IS_ROOM devices = self.devices_by_room(thermostat) else: rf_flag = RF_FLAG_IS_DEVICE devices = [thermostat] command += UNKNOWN + rf_flag + CMD_SET_PROG + RF_NULL_ADDRESS for device in devices: command += device.rf_address command += to_hex(device.room_id) command += to_hex(n_from_day_of_week(day)) for heat, time in heat_time_tuples: command += temp_and_time(heat, time) return self.__commander.send_radio_msg(command) def devices_as_json(self): devices = [] for device in self.devices: devices.append(device.to_dict()) return json.dumps(devices, indent=2) def set_programmes_from_config(self, config_file): config = json.load(config_file) for device_config in config: device = self.device_by_rf(device_config["rf_address"]) programme = device_config["programme"] if not programme: # e.g. a wall thermostat continue for day, metadata in programme.items(): self.set_programme(device, day, metadata) @classmethod def resolve_device_mode(cls, bits): return bits & 3 @classmethod def resolve_device_battery(cls, bits): return bits >> 7 @classmethod def parse_rf_address(cls, address): return "".join("{:02X}".format(x) for x in address) def get_programme(bits): n = 26 programme = {} days = [bits[i : i + n] for i in range(0, len(bits), n)] for j, day in enumerate(days): n = 2 settings = [day[i : i + n] for i in range(0, len(day), n)] day_programme = [] for setting in settings: word = format(setting[0], "08b") + format(setting[1], "08b") temp = int(int(word[:7], 2) / 2) time_mins = int(word[7:], 2) * 5 mins = time_mins % 60 hours = int((time_mins - mins) / 60) time = "{:02d}:{:02d}".format(hours, mins) day_programme.append({"temp": temp, "until": time}) if time == "24:00": # This appears to flag the end of usable set points break programme[day_of_week_from_n(j)] = day_programme return programme def n_from_day_of_week(day): return DAYS.index(day) def day_of_week_from_n(day): return DAYS[day] def temp_and_time(temp, time): temp = float(temp) assert temp <= 32, "Temp must be 32 or lower" assert temp % 0.5 == 0, "Temp must be increments of 0.5" temp = int(temp * 2) hours, mins = [int(x) for x in time.split(":")] assert mins % 5 == 0, "Time must be a multiple of 5 mins" mins = hours * 60 + mins bits = format(temp, "07b") + format(int(mins / 5), "09b") return to_hex(int(bits, 2)) def to_hex(value): "Return value as hex word" return format(value, "02X") python-maxcube-api-0.4.3/maxcube/deadline.py000066400000000000000000000022151404352716400210010ustar00rootroot00000000000000from dataclasses import dataclass from math import inf from time import time @dataclass(frozen=True) class Timeout: name: str duration: float class Deadline: def __init__(self, timeout: Timeout, *, parent=None): if parent is None: self.__deadline = time() + timeout.duration else: self.__deadline = min(time() + timeout.duration, parent.__deadline) self.__timeout = timeout self.__parent = parent def name(self) -> str: return f"{self.__timeout.name}[{self.remaining():.3g}/{self.__timeout.duration:.3g}]" def fullname(self) -> str: if self.__parent is None: return self.name() return self.__parent.fullname() + ":" + self.name() def remaining(self, *, lower_bound: float = 0, upper_bound: float = inf) -> float: return min(max(lower_bound, self.__deadline - time()), upper_bound) def is_expired(self) -> bool: return self.remaining() <= 0 def subtimeout(self, timeout: Timeout) -> "Deadline": return Deadline(timeout, parent=self) def __str__(self) -> str: return "Deadline " + self.fullname() python-maxcube-api-0.4.3/maxcube/device.py000066400000000000000000000037211404352716400204760ustar00rootroot00000000000000from typing import Tuple MAX_CUBE = 0 MAX_THERMOSTAT = 1 MAX_THERMOSTAT_PLUS = 2 MAX_WALL_THERMOSTAT = 3 MAX_WINDOW_SHUTTER = 4 MAX_PUSH_BUTTON = 5 MAX_DEVICE_MODE_AUTOMATIC = 0 MAX_DEVICE_MODE_MANUAL = 1 MAX_DEVICE_MODE_VACATION = 2 MAX_DEVICE_MODE_BOOST = 3 MAX_DEVICE_BATTERY_OK = 0 MAX_DEVICE_BATTERY_LOW = 1 MODE_NAMES = { MAX_DEVICE_MODE_AUTOMATIC: "auto", MAX_DEVICE_MODE_MANUAL: "manual", MAX_DEVICE_MODE_VACATION: "away", MAX_DEVICE_MODE_BOOST: "boost", } class MaxDevice(object): def __init__(self): self.type = None self.rf_address = None self.room_id = None self.name = None self.serial = None self.battery = None self.programme = None def is_thermostat(self): return self.type in (MAX_THERMOSTAT, MAX_THERMOSTAT_PLUS) def is_wallthermostat(self): return self.type == MAX_WALL_THERMOSTAT def is_windowshutter(self): return self.type == MAX_WINDOW_SHUTTER def is_room(self): return False def describe(self, kind: str, *args: Tuple[str]): state = "".join("," + s for s in args if s) if self.battery == MAX_DEVICE_BATTERY_LOW: state = ",LOW_BATT" + state return f"{kind} sn={self.serial},rf={self.rf_address},name={self.name}" + state def __str__(self): return self.describe(str(self.type)) def to_dict(self): data = {} keys = [ "type", "rf_address", "room_id", "name", "serial", "battery", "comfort_temperature", "eco_temperature", "max_temperature", "min_temperature", "valve_position", "target_temperature", "actual_temperature", "mode", "programme", ] for key in keys: data[key] = getattr(self, key, None) data["rf_address"] = self.rf_address return data python-maxcube-api-0.4.3/maxcube/message.py000066400000000000000000000010151404352716400206550ustar00rootroot00000000000000from dataclasses import dataclass @dataclass(frozen=True) class Message: cmd: str arg: str = "" def reply_cmd(self) -> str: return self.cmd.upper() def __str__(self) -> str: return f"{self.cmd}:{self.arg}" def encode(self) -> bytes: return (f"{self.cmd}:{self.arg}\r\n").encode("utf-8") @staticmethod def decode(line: bytes) -> "Message": comps = line.decode("utf-8").strip().split(":", 1) return Message(comps[0], comps[1] if len(comps) > 1 else "") python-maxcube-api-0.4.3/maxcube/room.py000066400000000000000000000001371404352716400202110ustar00rootroot00000000000000class MaxRoom(object): def __init__(self): self.id = None self.name = None python-maxcube-api-0.4.3/maxcube/thermostat.py000066400000000000000000000032121404352716400214240ustar00rootroot00000000000000from datetime import datetime from typing import Dict, List from maxcube.device import MODE_NAMES, MaxDevice PROG_DAYS = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ] class MaxThermostat(MaxDevice): def __init__(self): super(MaxThermostat, self).__init__() self.comfort_temperature = None self.eco_temperature = None self.max_temperature = None self.min_temperature = None self.valve_position = None self.target_temperature = None self.actual_temperature = None self.mode = None self.programme: Dict[str, List[Dict[str, int]]] = {} def __str__(self): return self.describe( "THERMOSTAT", f"mode={MODE_NAMES.get(self.mode, str(self.mode))}", f"actual={self.actual_temperature}", f"target={self.target_temperature}", f"eco={self.eco_temperature}", f"comfort={self.comfort_temperature}", f"range=[{self.min_temperature},{self.max_temperature}]", f"valve={self.valve_position}", ) def get_programmed_temp_at(self, dt: datetime): """Retrieve the programmed temperature at the given instant.""" weekday = PROG_DAYS[dt.weekday()] time = f"{dt.hour:02}:{dt.minute:02}" for point in self.programme.get(weekday, []): if time < point["until"]: return point["temp"] return None def get_current_temp_in_auto_mode(self): """DEPRECATED: use get_programmed_temp_at instead.""" return self.get_programmed_temp_at(datetime.now()) python-maxcube-api-0.4.3/maxcube/wallthermostat.py000066400000000000000000000014751404352716400223150ustar00rootroot00000000000000from maxcube.device import MODE_NAMES, MaxDevice class MaxWallThermostat(MaxDevice): def __init__(self): super(MaxWallThermostat, self).__init__() self.comfort_temperature = None self.eco_temperature = None self.max_temperature = None self.min_temperature = None self.actual_temperature = None self.target_temperature = None self.mode = None def __str__(self): return self.describe( "WALLTHERMO", f"mode={MODE_NAMES.get(self.mode, str(self.mode))}", f"actual={self.actual_temperature}", f"target={self.target_temperature}", f"eco={self.eco_temperature}", f"comfort={self.comfort_temperature}", f"range=[{self.min_temperature},{self.max_temperature}]", ) python-maxcube-api-0.4.3/maxcube/windowshutter.py000066400000000000000000000004471404352716400221670ustar00rootroot00000000000000from maxcube.device import MaxDevice class MaxWindowShutter(MaxDevice): def __init__(self): super(MaxWindowShutter, self).__init__() self.is_open = False self.initialized = None def __str__(self): return self.describe("WINDOW", f"open={self.is_open}") python-maxcube-api-0.4.3/prog.py000066400000000000000000000011021404352716400165510ustar00rootroot00000000000000import argparse import sys from maxcube.cube import DEFAULT_PORT, MaxCube if __name__ == "__main__": parser = argparse.ArgumentParser(description="Set or dump thermostat programmes") parser.add_argument("--host", required=True) parser.add_argument("--port", default=DEFAULT_PORT, type=int) parser.add_argument("cmd", choices=["load", "dump"]) args = parser.parse_args() cube = MaxCube(args.host, args.port) if args.cmd == "load": cube.set_programmes_from_config(sys.stdin) elif args.cmd == "dump": print(cube.devices_as_json()) python-maxcube-api-0.4.3/pyproject.toml000066400000000000000000000005171404352716400201550ustar00rootroot00000000000000[tool.black] target-version = ["py38"] [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings profile = "black" # will group `import x` and `from x import` of the same module. force_sort_within_sections = true known_first_party = [ "maxcube", "tests", ] forced_separate = [ "tests", ] combine_as_imports = true python-maxcube-api-0.4.3/sample/000077500000000000000000000000001404352716400165175ustar00rootroot00000000000000python-maxcube-api-0.4.3/sample/sample.py000066400000000000000000000050021404352716400203470ustar00rootroot00000000000000import os from maxcube.cube import MaxCube from maxcube.device import ( MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_MANUAL, MAX_THERMOSTAT, MAX_THERMOSTAT_PLUS, MAX_WALL_THERMOSTAT, MAX_WINDOW_SHUTTER, ) cube = MaxCube(os.environ.get("MAXCUBE_IP", "192.168.0.20"), 62910) print("Serial: %s" % (cube.serial)) for room in cube.rooms: print("Room: " + room.name) for device in cube.devices_by_room(room): print("Device: " + device.name) print("") for device in cube.devices: if device.type == MAX_THERMOSTAT: type = "MAX_THERMOSTAT" elif device.type == MAX_THERMOSTAT_PLUS: type = "MAX_THERMOSTAT_PLUS" elif device.type == MAX_WINDOW_SHUTTER: type = "MAX_WINDOW_SHUTTER" elif device.type == MAX_WALL_THERMOSTAT: type = "MAX_WALL_THERMOSTAT" print("Type: " + type) print("RF: " + device.rf_address) print("Room ID:" + str(device.room_id)) print("Room: " + cube.room_by_id(device.room_id).name) print("Name: " + device.name) print("Serial: " + device.serial) if device.type == MAX_THERMOSTAT: print("MaxSetP:" + str(device.max_temperature)) print("MinSetP:" + str(device.min_temperature)) if device.mode == MAX_DEVICE_MODE_AUTOMATIC: mode = "AUTO" elif device.mode == MAX_DEVICE_MODE_MANUAL: mode = "MANUAL" print("Mode: " + mode) print("Actual: " + str(device.actual_temperature)) print("Target: " + str(device.target_temperature)) if device.type == MAX_WALL_THERMOSTAT: print("MaxSetP:" + str(device.max_temperature)) print("MinSetP:" + str(device.min_temperature)) if device.mode == MAX_DEVICE_MODE_AUTOMATIC: mode = "AUTO" elif device.mode == MAX_DEVICE_MODE_MANUAL: mode = "MANUAL" print("Mode: " + mode) print("Actual: " + str(device.actual_temperature)) print("Target: " + str(device.target_temperature)) if device.type == MAX_WINDOW_SHUTTER: print("IsOpen: " + str(device.is_open)) print("") for device in cube.devices: print(device) if device.is_wallthermostat() or device.is_thermostat(): print("Setting temp") cube.set_target_temperature(device, 8) else: print("No Thermostat") print("") for device in cube.devices: print(device) if device.is_wallthermostat(): print("Setting mode") cube.set_mode(device, MAX_DEVICE_MODE_MANUAL) else: print("No Wall Thermostat") python-maxcube-api-0.4.3/setup.cfg000066400000000000000000000006331404352716400170610ustar00rootroot00000000000000[bdist_wheel] universal=1 [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build doctests = True # To work with Black # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504 noqa-require-code = True python-maxcube-api-0.4.3/setup.py000066400000000000000000000007341404352716400167540ustar00rootroot00000000000000# -*- coding: utf-8 -*- from setuptools import setup with open("README.rst") as f: readme = f.read() setup( name="maxcube-api", version="0.4.3", description="eQ-3/ELV MAX! Cube Python API", long_description=readme, author="David Uebelacker", author_email="david@uebelacker.ch", url="https://github.com/hackercowboy/python-maxcube-api.git", license=license, packages=["maxcube"], test_suite="tests", python_requires=">=3.7", ) python-maxcube-api-0.4.3/tests/000077500000000000000000000000001404352716400164005ustar00rootroot00000000000000python-maxcube-api-0.4.3/tests/__init__.py000066400000000000000000000000001404352716400204770ustar00rootroot00000000000000python-maxcube-api-0.4.3/tests/result.txt000066400000000000000000000034671404352716400204710ustar00rootroot00000000000000H:KEQ0566338,0b6475,0113,00000000,74b7b6f7,00,32,0f0c19,1527,03,0000 M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvFNLRVEwMzM2MTA4B0tpdGNoZW4BAQa8WktFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNsZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ== C:0b6475,7QtkdQATAf9LRVEwNTY2MzM4AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf///////////////////////////2h0dHA6Ly93d3cubWF4LXBvcnRhbC5lbHYuZGU6ODAvY3ViZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA== C:06bc53,0ga8UwEBGP9LRVEwMzM2MTA4KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== C:06bc5a,0ga8WgECGP9LRVEwMzM2MTAwKCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== C:06bc5c,0ga8XAEEGP9LRVEwMzM2MTA0KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA== C:08ab82,0girggEDGP9LRVEwMzM1NjYyKCEyCQcYAzAM/wBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIA== L:Cwa8U/EaGBsqAOwACwa8WgkSGCMqAOcACwa8XAkSGAsqAOcACwirggMaGAAiAAAA python-maxcube-api-0.4.3/tests/test_commander.py000066400000000000000000000161151404352716400217620ustar00rootroot00000000000000import base64 from unittest import TestCase from unittest.mock import MagicMock, call, patch from maxcube.commander import Commander from maxcube.connection import Connection from maxcube.deadline import Deadline, Timeout from maxcube.message import Message L_CMD = Message("l") L_CMD_SUCCESS = Message("L") S_CMD_HEX = "FF00" S_CMD = Message("s", base64.b64encode(bytearray.fromhex(S_CMD_HEX)).decode("utf-8")) S_CMD_SUCCESS = Message("S", "00,0,31") S_CMD_ERROR = Message("S", "64,1,1f") S_CMD_THROTTLE_ERROR = Message("S", "64,1,0") Q_CMD = Message("q") TEST_TIMEOUT = Timeout("test", 1.0) @patch("maxcube.commander.Connection", spec=True) class TestCommander(TestCase): """ Test Max! Cube command handler """ def init(self, ClassMock): self.connection = ClassMock.return_value self.commander = Commander("host", 1234) def testDisconnectIsNoopIfAlreadyDisconnected(self, ClassMock): self.init(ClassMock) self.commander.disconnect() ClassMock.assert_not_called() self.connection.send.assert_not_called() self.connection.recv.assert_not_called() self.connection.close.assert_not_called() def testUpdateOpensNewNonPersistantConnectionAndClosesIt(self, ClassMock): messages = [Message("H"), Message("L")] self.init(ClassMock) self.commander.use_persistent_connection = False self.connection.recv.side_effect = messages self.assertEqual(messages, self.commander.update()) self.connection.send.assert_called_once_with(Q_CMD) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_called_once() def testUpdateSendsCommandAfterAfterTimeout(self, ClassMock): messages = [Message("H"), None] self.init(ClassMock) self.connection.recv.side_effect = messages self.assertEqual(messages[:1], self.commander.update()) self.connection.send.assert_not_called() self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() self.connection.recv.reset_mock() self.__mockCommandResponse(L_CMD_SUCCESS) self.assertEqual([L_CMD_SUCCESS], self.commander.update()) self.connection.send.assert_called_once_with(Message("l")) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() def testSendRadioMsgAutoconnects(self, ClassMock=None): self.init(ClassMock) self.connection.recv.side_effect = [ L_CMD_SUCCESS, # connection preamble S_CMD_SUCCESS, ] self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) self.connection.send.assert_called_once_with(S_CMD) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() def testSendRadioMsgOpensNewNonPersistentConnectionAndClosesIt( self, ClassMock=None ): self.init(ClassMock) self.commander.use_persistent_connection = False self.connection.recv.side_effect = [ L_CMD_SUCCESS, # connection preamble S_CMD_SUCCESS, ] self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) self.connection.send.assert_has_calls([call(S_CMD), call(Q_CMD)]) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_called_once() def testSendRadioMsgReusesConnection(self, ClassMock): self.testSendRadioMsgAutoconnects() self.connection.send.reset_mock() self.connection.recv.reset_mock() self.connection.recv.side_effect = [None, S_CMD_SUCCESS] self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) self.connection.send.assert_called_once_with(S_CMD) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() def testSendRadioMsgClosesConnectionOnErrorAndRetriesIfReusingConnection( self, ClassMock ): self.testSendRadioMsgAutoconnects() self.connection.recv.reset_mock() self.connection.recv.side_effect = [ None, # First read before first try ] self.connection.send.reset_mock() self.connection.send.side_effect = [OSError] newConnection = MagicMock(Connection) ClassMock.side_effect = [newConnection] newConnection.recv.side_effect = [ L_CMD_SUCCESS, # Connection preamble S_CMD_SUCCESS, ] self.assertTrue(self.commander.send_radio_msg(S_CMD_HEX)) self.connection.recv.assert_called_once() self.connection.send.assert_called_once_with(S_CMD) self.connection.close.assert_called_once() self.assertEqual(2, newConnection.recv.call_count) newConnection.send.assert_called_once_with(S_CMD) newConnection.close.assert_not_called() def __send_radio_msg(self, msg: Message, deadline: Deadline): with patch("maxcube.commander.Deadline") as deadlineMock: deadlineMock.return_value = deadline return self.commander.send_radio_msg(msg) def testSendRadioMsgShouldNotRetryOnErrorWhenConnectionIsNew(self, ClassMock): self.init(ClassMock) self.connection.recv.side_effect = [L_CMD_SUCCESS] self.connection.send.side_effect = [OSError] deadline = MagicMock(Deadline) deadline.is_expired.side_effect = [False, True] deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) self.connection.send.assert_called_once_with(S_CMD) self.connection.recv.assert_called_once() self.connection.close.assert_called_once() def testSendRadioMsgFailsOnLogicalError(self, ClassMock): self.init(ClassMock) self.connection.recv.side_effect = [L_CMD_SUCCESS, S_CMD_ERROR] deadline: Deadline = MagicMock(Deadline) deadline.is_expired.side_effect = [False, True] deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) self.connection.send.assert_called_once_with(S_CMD) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() deadline.remaining.assert_not_called() def testSendRadioMsgRetriesOnThrottlingError(self, ClassMock): self.init(ClassMock) self.connection.recv.side_effect = [L_CMD_SUCCESS, S_CMD_THROTTLE_ERROR] deadline: Deadline = MagicMock(Deadline) deadline.is_expired.side_effect = [False, True] deadline.remaining.return_value = 0.1 deadline.subtimeout.return_value = Deadline(TEST_TIMEOUT) self.assertFalse(self.__send_radio_msg(S_CMD_HEX, deadline)) self.connection.send.assert_called_once_with(S_CMD) self.assertEqual(2, self.connection.recv.call_count) self.connection.close.assert_not_called() deadline.remaining.assert_called_once() def __mockCommandResponse(self, response): self.connection.recv.side_effect = [None, response] python-maxcube-api-0.4.3/tests/test_connection.py000066400000000000000000000045771404352716400221650ustar00rootroot00000000000000import socket from unittest import TestCase from unittest.mock import patch from maxcube.connection import Connection from maxcube.deadline import Deadline, Timeout from maxcube.message import Message TEST_TIMEOUT = Timeout("test", 1000.0) @patch("socket.socket", spec=True) class TestConnection(TestCase): """ Test Max! Cube connections """ def connect(self, socketMock): self.socket = socketMock.return_value self.connection = Connection("host", 1234) self.socket.settimeout.assert_called_once_with(2.0) self.socket.connect.assert_called_once_with(("host", 1234)) def testReadAMessage(self, socketMock): self.connect(socketMock) self.socket.recv.return_value = b"A:B\r\n" self.assertEqual( Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) ) self.socket.close.assert_not_called() def testReadPartialLine(self, socketMock): self.connect(socketMock) self.socket.recv.side_effect = [b"A:", b"B\r\n"] self.assertEqual( Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) ) def testReadMultipleLines(self, socketMock): self.connect(socketMock) self.socket.recv.return_value = b"A:B\r\nC\r\n" self.assertEqual( Message("A", "B"), self.connection.recv(Deadline(TEST_TIMEOUT)) ) self.socket.recv.reset_mock() self.assertEqual(Message("C", ""), self.connection.recv(Deadline(TEST_TIMEOUT))) def testReadAtConnectionClosing(self, socketMock): self.connect(socketMock) self.socket.recv.return_value = b"" self.assertIsNone(self.connection.recv(Deadline(TEST_TIMEOUT))) self.socket.close.assert_called_once() def testReadTimeout(self, socketMock): self.connect(socketMock) self.socket.recv.side_effect = [socket.timeout] self.assertIsNone(self.connection.recv(Deadline(TEST_TIMEOUT))) self.socket.close.assert_not_called() def testSendMessage(self, socketMock): self.connect(socketMock) self.connection.send(Message("A", "B")) self.socket.send.assert_called_with(b"A:B\r\n") def testCloseErrorsAreIgnored(self, socketMock): self.connect(socketMock) self.socket.close.side_effect = [OSError] self.connection.close() self.socket.close.assert_called_once() python-maxcube-api-0.4.3/tests/test_cube.py000066400000000000000000000445311404352716400207360ustar00rootroot00000000000000from datetime import datetime from typing import List from unittest import TestCase from unittest.mock import patch from maxcube.cube import MaxCube from maxcube.device import ( MAX_CUBE, MAX_DEVICE_BATTERY_LOW, MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_MANUAL, MAX_THERMOSTAT, MAX_THERMOSTAT_PLUS, MAX_WALL_THERMOSTAT, MAX_WINDOW_SHUTTER, MaxDevice, ) from maxcube.message import Message from maxcube.room import MaxRoom def to_messages(lines): return [Message.decode(line) for line in lines] INIT_RESPONSE_1 = to_messages( [ b"H:KEQ0566338,0b6475,0113,00000000,74b7b6f7,00,32,0f0c19,1527,03,0000", b"M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvFNLRVEwMzM2MTA4B0tpdGNoZW4BAQa8Wk" b"tFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNsZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ==", b"C:0b6475,7QtkdQATAf9LRVEwNTY2MzM4AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf////////////////" b"///////////2h0dHA6Ly93d3cubWF4LXBvcnRhbC5lbHYuZGU6ODAvY3ViZQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA==", b"C:06bc53,0ga8UwEBGP9LRVEwMzM2MTA4KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRS" b"BFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgR" b"EhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", b"C:06bc5a,0ga8WgECGP9LRVEwMzM2MTAwKCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgRS" b"BFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgR" b"EhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", b"C:06bc5c,0ga8XAEEGP9LRVEwMzM2MTA0KCEyCQcYAzAM/wBESFUIRSBFIEUgRSBFIEUgRSBFIEUgRSBFIERIVQhFIEUgRSBFIEUgRSBFIEUgR" b"SBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEU" b"gREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIA==", b"C:08ab82,0girggEDGP9LRVEwMzM1NjYyKCEyCQcYAzAM/wBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgR" b"SBFIEUgRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIERgVGxEzEUURSBFIEUgRSBFIEUgRSBFIEU" b"gRGBUbETMRRRFIEUgRSBFIEUgRSBFIEUgRSBEYFRsRMxFFEUgRSBFIEUgRSBFIEUgRSBFIA==", b"L:Cwa8U/EaGBsqAOwACwa8WgkSGCMqAOcACwa8XAkSGAsqAOcACwirggMaGAAiAAAA", ] ) INIT_RESPONSE_2 = to_messages( [ b"H:JEQ0341267,015d2a,0113,00000000,0336f10a,4b,29,110203,172a,03,0000", b"M:00,01,VgICAQpCYWRlemltbWVyDi66AgpXb2huemltbWVyAAAAAwEOLrpLRVExMDg2NDM3ClRoZXJtb3N0YXQBAwoIgUtFUTA2NTU3NDMOV2" b"FuZHRoZXJtb3N0YXQCBAyisktFUTA4Mzk3NzgORmVuc3RlcmtvbnRha3QBAQ==", b"M:INVALID_M_MESSAGE", b"ZZ:INVALID_MESSAGE_TYPE", b"C:015d2a,7QFdKgATAf9KRVEwMzQxMjY3AQsABEAAAAAAAAAAAP///////////////////////////wsABEAAAAAAAAAAQf///////////////" b"////////////2h0dHA6Ly9tYXguZXEtMy5kZTo4MC9jdWJlADAvbG9va3VwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAENFVAAACgADAAAOEENFU1QAAwACAAAcIA==", b"C:0a0881,zgoIgQMCEP9LRVEwNjU1NzQzKyE9CURIVQhFIEUgRSBFIEUgRSBFIEUgRSBFIEUgREhVCEUgRSBFIEUgRSBFIEUgRSBFIEUgRSBES" b"FRsRMxVFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgREhUbETMVRRFIEUgRSBFIEUgRSBFIEUgRSBESFRsRMx" b"VFEUgRSBFIEUgRSBFIEUgRSBFIERIVGxEzFUURSBFIEUgRSBFIEUgRSBFIEUgBxgw", b"C:0ca2b2,EQyisgQBFA9LRVEwODM5Nzc4", b"C:0e2eba,0g4uugEBEKBLRVExMDg2NDM3KyE9CQcYAzAM/wAgYFR4ISAhICEgISAhIEUgRSBFIEUgRSBFICBgVHghICEgISAhICEgRSBFIEUgR" b"SBFIEUgIEJUTiEfISAhICEgISBFIEUgRSBFIEUgRSAgQlROIR8hICEgISAhIEUgRSBFIEUgRSBFICBCVE4hHyEgISAhICEgRSBFIEUgRSBFIEU" b"gIEJUTiEfISAhICEgISBFIEUgRSBFIEUgRSAgQlROIR8hICEgISAhIEUgRSBFIEUgRSBFIA==", b"L:DAoIgewSGAQQAAAA5QYMorL3EpALDi66ChIYABAAAAA=", ] ) LAST_STATE_MSG = Message.decode( b"L:Cwa8U/ESGAAiAAAACwa8WgkSGAAiAAAACwa8XAkSGAUiAAAACwirggMSGAUiAAAA" ) SEND_CMD_OK_RESPONSE = "S:04,0,31" WORKDAY_PROGRAMME_1 = [ {"until": "05:30", "temp": 8}, {"until": "06:30", "temp": 21}, {"until": "23:55", "temp": 8}, {"until": "24:00", "temp": 8}, ] WEEKEND_PROGRAME_1 = [ {"until": "08:00", "temp": 8}, {"until": "10:00", "temp": 21}, {"until": "24:00", "temp": 8}, ] INIT_PROGRAMME_1 = { "monday": WORKDAY_PROGRAMME_1, "tuesday": WORKDAY_PROGRAMME_1, "wednesday": WORKDAY_PROGRAMME_1, "thursday": WORKDAY_PROGRAMME_1, "friday": WORKDAY_PROGRAMME_1, "saturday": WEEKEND_PROGRAME_1, "sunday": WEEKEND_PROGRAME_1, } @patch("maxcube.cube.Commander", spec=True) class TestMaxCube(TestCase): """ Test the Max! Cube. """ def init(self, ClassMock, responses): self.commander = ClassMock.return_value self.commander.update.return_value = responses self.cube = MaxCube("host", 1234, now=lambda: datetime(2012, 10, 22, 5, 30)) self.commander.update.assert_called_once() self.commander.update.reset_mock() self.commander.send_radio_msg.return_value = True def test_init(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.assertEqual("KEQ0566338", self.cube.serial) self.assertEqual("0b6475", self.cube.rf_address) self.assertEqual("Cube", self.cube.name) self.assertEqual("01.13", self.cube.firmware_version) self.assertEqual(4, len(self.cube.devices)) device = self.cube.devices[0] self.assertEqual(4.5, device.min_temperature) self.assertEqual(25.0, device.max_temperature) self.assertEqual("06BC53", device.rf_address) self.assertEqual("Kitchen", device.name) self.assertEqual("06BC5A", self.cube.devices[1].rf_address) self.assertEqual("Living", self.cube.devices[1].name) self.assertEqual("08AB82", self.cube.devices[2].rf_address) self.assertEqual("Sleeping", self.cube.devices[2].name) self.assertEqual("06BC5C", self.cube.devices[3].rf_address) self.assertEqual("Work", self.cube.devices[3].name) def __update(self, responses: List[Message]): self.commander.update.return_value = responses self.cube.update() self.commander.update.assert_called_once() def test_parse_auto_l_message(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.__update([LAST_STATE_MSG]) device = self.cube.devices[0] self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) self.assertEqual(23.6, device.actual_temperature) self.assertEqual(17.0, device.target_temperature) def test_parse_manual_l_message(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.__update( [ Message.decode( b"L:Cwa8U/ESGQkhALMACwa8WgkSGQAhAMAACwa8XAkSGQUhALIACwirggMSGQUhAAAA" ) ] ) device = self.cube.devices[0] self.assertEqual(MAX_DEVICE_MODE_MANUAL, device.mode) self.assertEqual(17.9, device.actual_temperature) self.assertEqual(16.5, device.target_temperature) def test_disconnect(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.cube.disconnect() self.commander.disconnect.assert_called_once() def test_use_persistent_connection(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.commander.use_persistent_connection = True self.assertTrue(self.cube.use_persistent_connection) self.cube.use_persistent_connection = False self.assertFalse(self.commander.use_persistent_connection) def test_is_thermostat(self, _): device = MaxDevice() device.type = MAX_CUBE self.assertFalse(device.is_thermostat()) device.type = MAX_THERMOSTAT self.assertTrue(device.is_thermostat()) device.type = MAX_THERMOSTAT_PLUS self.assertTrue(device.is_thermostat()) device.type = MAX_WALL_THERMOSTAT self.assertFalse(device.is_thermostat()) device.type = MAX_WINDOW_SHUTTER self.assertFalse(device.is_thermostat()) def test_is_wall_thermostat(self, _): device = MaxDevice() device.type = MAX_CUBE self.assertFalse(device.is_wallthermostat()) device.type = MAX_THERMOSTAT self.assertFalse(device.is_wallthermostat()) device.type = MAX_THERMOSTAT_PLUS self.assertFalse(device.is_wallthermostat()) device.type = MAX_WALL_THERMOSTAT self.assertTrue(device.is_wallthermostat()) device.type = MAX_WINDOW_SHUTTER self.assertFalse(device.is_wallthermostat()) def test_is_window_shutter(self, _): device = MaxDevice() device.type = MAX_CUBE self.assertFalse(device.is_windowshutter()) device.type = MAX_THERMOSTAT self.assertFalse(device.is_windowshutter()) device.type = MAX_THERMOSTAT_PLUS self.assertFalse(device.is_windowshutter()) device.type = MAX_WALL_THERMOSTAT self.assertFalse(device.is_windowshutter()) device.type = MAX_WINDOW_SHUTTER self.assertTrue(device.is_windowshutter()) def test_set_target_temperature(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.assertTrue(self.cube.set_target_temperature(self.cube.devices[0], 24.5)) self.assertEqual(24.5, self.cube.devices[0].target_temperature) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") def test_do_not_update_if_set_target_temperature_fails(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.commander.send_radio_msg.return_value = False self.assertFalse(self.cube.set_target_temperature(self.cube.devices[0], 24.5)) self.assertEqual(21, self.cube.devices[0].target_temperature) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") def test_set_target_temperature_should_round_temperature(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) self.cube.set_target_temperature(self.cube.devices[0], 24.6) self.assertEqual(24.5, self.cube.devices[0].target_temperature) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with("00044000000006BC530131") def test_set_target_temperature_is_ignored_by_windowshutter(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) self.cube.set_target_temperature(self.cube.devices[2], 24.5) self.commander.send_radio_msg.assert_not_called() def test_set_mode_thermostat(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_1) device = self.cube.devices[0] self.assertEqual(21.0, device.target_temperature) self.cube.set_mode(device, MAX_DEVICE_MODE_MANUAL) self.assertEqual(MAX_DEVICE_MODE_MANUAL, device.mode) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with("00044000000006BC53016A") def test_init_2(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) self.assertEqual("JEQ0341267", self.cube.serial) self.assertEqual("015d2a", self.cube.rf_address) self.assertEqual("Cube", self.cube.name) self.assertEqual("01.13", self.cube.firmware_version) self.assertEqual(3, len(self.cube.devices)) device = self.cube.devices[0] self.assertEqual(21.5, device.comfort_temperature) self.assertEqual(16.5, device.eco_temperature) self.assertEqual(4.5, device.min_temperature) self.assertEqual(30.5, device.max_temperature) device = self.cube.devices[1] self.assertEqual(21.5, device.comfort_temperature) self.assertEqual(16.5, device.eco_temperature) self.assertEqual(4.5, device.min_temperature) self.assertEqual(30.5, device.max_temperature) device = self.cube.devices[2] self.assertEqual(1, device.initialized) device = self.cube.devices[0] self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) self.assertIsNone(device.actual_temperature) self.assertEqual(8.0, device.target_temperature) device = self.cube.devices[1] self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) self.assertEqual(22.9, device.actual_temperature) self.assertEqual(8.0, device.target_temperature) device = self.cube.devices[2] self.assertFalse(device.is_open) self.assertTrue(device.battery == MAX_DEVICE_BATTERY_LOW) def test_parse_m_message(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) self.__update( [ Message.decode( b"M:00,01,VgIEAQdLaXRjaGVuBrxTAgZMaXZpbmcGvFoDCFNsZWVwaW5nCKuCBARXb3JrBrxcBAEGvF" b"NLRVEwMzM2MTA4B0tpdGNoZW4BAQa8WktFUTAzMzYxMDAGTGl2aW5nAgEIq4JLRVEwMzM1NjYyCFNs" b"ZWVwaW5nAwEGvFxLRVEwMzM2MTA0BFdvcmsEAQ==" ), INIT_RESPONSE_2[-1], ] ) self.assertEqual("0E2EBA", self.cube.devices[0].rf_address) self.assertEqual("Thermostat", self.cube.devices[0].name) self.assertEqual(MAX_THERMOSTAT, self.cube.devices[0].type) self.assertEqual("KEQ1086437", self.cube.devices[0].serial) self.assertEqual(1, self.cube.devices[0].room_id) self.assertEqual("0A0881", self.cube.devices[1].rf_address) self.assertEqual("Wandthermostat", self.cube.devices[1].name) self.assertEqual(MAX_WALL_THERMOSTAT, self.cube.devices[1].type) self.assertEqual("KEQ0655743", self.cube.devices[1].serial) self.assertEqual(2, self.cube.devices[1].room_id) self.assertEqual("0CA2B2", self.cube.devices[2].rf_address) self.assertEqual("Fensterkontakt", self.cube.devices[2].name) self.assertEqual(MAX_WINDOW_SHUTTER, self.cube.devices[2].type) self.assertEqual("KEQ0839778", self.cube.devices[2].serial) self.assertEqual(1, self.cube.devices[2].room_id) self.assertEqual("Kitchen", self.cube.rooms[0].name) self.assertEqual(1, self.cube.rooms[0].id) self.assertEqual("Living", self.cube.rooms[1].name) self.assertEqual(2, self.cube.rooms[1].id) def test_get_devices(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) devices = self.cube.get_devices() self.assertEqual(3, len(devices)) def test_device_by_rf(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) device = self.cube.device_by_rf("0CA2B2") self.assertEqual("0CA2B2", device.rf_address) self.assertEqual("Fensterkontakt", device.name) self.assertEqual(MAX_WINDOW_SHUTTER, device.type) self.assertEqual("KEQ0839778", device.serial) self.assertEqual(1, device.room_id) def test_device_by_rf_negative(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) device = self.cube.device_by_rf("DEADBEEF") self.assertIsNone(device) def test_devices_by_room(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) room = MaxRoom() room.id = 1 devices = self.cube.devices_by_room(room) self.assertEqual(2, len(devices)) def test_devices_by_room_negative(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) room = MaxRoom() room.id = 3 devices = self.cube.devices_by_room(room) self.assertEqual(0, len(devices)) def test_get_rooms(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) rooms = self.cube.get_rooms() self.assertEqual("Badezimmer", rooms[0].name) self.assertEqual(1, rooms[0].id) self.assertEqual("Wohnzimmer", rooms[1].name) self.assertEqual(2, rooms[1].id) def test_room_by_id(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) room = self.cube.room_by_id(1) self.assertEqual("Badezimmer", room.name) self.assertEqual(1, room.id) def test_room_by_id_negative(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) room = self.cube.room_by_id(3) self.assertIsNone(room) def test_set_programme(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) self.commander.send_radio_msg.return_value = True result = self.cube.set_programme( self.cube.devices[0], "saturday", [{"temp": 20.5, "until": "13:30"}, {"temp": 18, "until": "24:00"}], ) self.assertTrue(result) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with( "0000100000000E2EBA010052A249200000000000" ) def test_set_programme_already_existing_does_nothing(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) result = self.cube.set_programme( self.cube.devices[0], "saturday", INIT_PROGRAMME_1["saturday"] ) self.assertIsNone(result) self.commander.send_radio_msg.assert_not_called() def test_get_device_as_dict(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) device = self.cube.devices[0] result = device.to_dict() self.assertEqual(result["name"], "Thermostat") self.assertEqual(result["comfort_temperature"], 21.5) self.assertEqual( result["programme"]["monday"], [ {"until": "05:30", "temp": 8}, {"until": "06:30", "temp": 21}, {"until": "23:55", "temp": 8}, {"until": "24:00", "temp": 8}, ], ) def test_set_auto_mode_read_temp_from_program(self, ClassMock): self.init(ClassMock, INIT_RESPONSE_2) device = self.cube.devices[0] self.assertEqual(8.0, device.target_temperature) self.cube.set_mode(device, MAX_DEVICE_MODE_AUTOMATIC) self.assertEqual(21.0, device.target_temperature) self.assertEqual(MAX_DEVICE_MODE_AUTOMATIC, device.mode) self.commander.send_radio_msg.assert_called_once() self.commander.send_radio_msg.assert_called_with("0004400000000E2EBA0100") python-maxcube-api-0.4.3/tests/test_deadline.py000066400000000000000000000045771404352716400215730ustar00rootroot00000000000000from unittest import TestCase from unittest.mock import patch from maxcube.deadline import Deadline, Timeout ZERO_TIMEOUT = Timeout("zero", 0) ONE_TIMEOUT = Timeout("one", 1) ROOT_TIMEOUT = Timeout("root", 10) @patch("maxcube.deadline.time") class TestDeadline(TestCase): """ Test Deadlines """ def testDeadlineLowerBoundForRemainingTime(self, timeMock): timeMock.side_effect = [0, 0.4, 0.6] deadline = Deadline(ONE_TIMEOUT) self.assertAlmostEqual(0.6, deadline.remaining(lower_bound=0.50)) self.assertAlmostEqual(0.5, deadline.remaining(lower_bound=0.50)) def testDeadlineUpperBoundForRemainingTime(self, timeMock): timeMock.side_effect = [0, 0.4, 0.6] deadline = Deadline(ONE_TIMEOUT) self.assertAlmostEqual(0.5, deadline.remaining(upper_bound=0.50)) self.assertAlmostEqual(0.4, deadline.remaining(upper_bound=0.50)) def testDeadlineIsExpired(self, timeMock): timeMock.side_effect = [0, 0.4, 1.0, 1.000001] deadline = Deadline(ONE_TIMEOUT) self.assertFalse(deadline.is_expired()) self.assertTrue(deadline.is_expired()) self.assertTrue(deadline.is_expired()) def testZeroDeadlineIsAlreadyExpired(self, timeMock): timeMock.return_value = 0.0 deadline = Deadline(ZERO_TIMEOUT) self.assertEqual("zero[0/0]", deadline.fullname()) self.assertTrue(deadline.is_expired()) def testSubtimeoutHandling(self, timeMock): timeMock.side_effect = [0.0, 0.1, 0.2, 0.2, 0.3] deadline = Deadline(ROOT_TIMEOUT) subdeadline = deadline.subtimeout(ONE_TIMEOUT) self.assertEqual("root[9.8/10]:one[0.9/1]", subdeadline.fullname()) self.assertAlmostEqual(0.8, subdeadline.remaining()) def testSubtimeoutHandlingWhenLargerThanTimeout(self, timeMock): timeMock.side_effect = [0.0, 9.1, 9.2] deadline = Deadline(ROOT_TIMEOUT) subdeadline = deadline.subtimeout(ONE_TIMEOUT) self.assertAlmostEqual(0.8, subdeadline.remaining()) def testToString(self, timeMock): timeMock.side_effect = [0.0, 0.1] deadline = Deadline(ONE_TIMEOUT) self.assertEqual("Deadline one[0.9/1]", str(deadline)) def testToStringForExpiredDeadline(self, timeMock): timeMock.side_effect = [0.0, 1.1] deadline = Deadline(ONE_TIMEOUT) self.assertEqual("Deadline one[0/1]", str(deadline)) python-maxcube-api-0.4.3/tests/test_message.py000066400000000000000000000007731404352716400214440ustar00rootroot00000000000000from unittest import TestCase from maxcube.message import Message class TestMessage(TestCase): """ Test Max! Cube messages """ def testDecodeValidMessage(self): line = b"s:AARAAAAABrxTAWo=\r\n" msg = Message.decode(line) self.assertEqual(Message("s", "AARAAAAABrxTAWo="), msg) self.assertEqual("S", msg.reply_cmd()) self.assertEqual(line, msg.encode()) def testDecodeEmptyMessage(self): self.assertEqual(Message(""), Message.decode(b"\r\n")) python-maxcube-api-0.4.3/tests/test_thermostat.py000066400000000000000000000004511404352716400222030ustar00rootroot00000000000000from unittest import TestCase from maxcube.thermostat import MaxThermostat class TestMessage(TestCase): """ Test Max! thermostat """ def testGetCurrentTemperatureReturnsNoneIfUninitialized(self): t = MaxThermostat() self.assertIsNone(t.get_current_temp_in_auto_mode())