diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 38522e8..4c004db 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,8 +15,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9] - type: [unit, functional] + python: ['3.9', '3.10'] + type: [unit] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -34,7 +34,7 @@ jobs: pip install setuptools wheel twine - name: Install requirements - run: pip install -r requirements.txt + run: pip install -r requirements_test.txt - name: Run ${{ matrix.type }} tests run: python -m unittest discover -s tests/${{ matrix.type }} -v diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..ea47d2e --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +bluepy \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 8d5befa..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -from .unit import * diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e863797..e69de29 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,2 +0,0 @@ -from .test_tion import retryTests -from .test_tion import TionTests diff --git a/tests/unit/test_decode.py b/tests/unit/test_decode.py new file mode 100644 index 0000000..56aea75 --- /dev/null +++ b/tests/unit/test_decode.py @@ -0,0 +1,88 @@ +import pytest +from typing import List, Type, Union + +import tion_btle + + +scenarios: List[dict[str, Union[Type[tion_btle.Tion], bytearray, dict]]] = [ + { + "instance": tion_btle.s3.S3, + "data": [0xb3, 0x10, 0x24, 0x14, 0x03, 0x00, 0x15, 0x14, 0x14, 0x8f, 0x00, 0x0c, 0x0a, 0x00, 0x4b, 0x0a, 0x00, + 0x33, 0x00, 0x5a], + "results": { + 'state': 'on', + 'heater': 'on', + 'sound': 'off', + 'mode': 'outside', + 'out_temp': 20, + 'in_temp': 20, + 'target_temp': 20, + 'fan_speed': 4, + '_filter_remain': 143, + 'model': 'S3', + } + }, + { + "instance": tion_btle.lite.Lite, + "data": [0xcf, 0xd8, 0x02, 0x0f, 0x04, 0x09, 0x0f, 0x1a, 0x80, 0x8e, 0x05, 0x00, 0xe9, 0x8b, 0x05, 0x00, 0x17, 0xc2, + 0xe7, 0x00, 0x26, 0x1b, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, 0x02, 0x04, 0x06, + 0x06, 0x18, 0x00], + "results": { + 'state': 'on', + 'heater': 'on', + 'sound': 'on', + 'mode': 'outside', + 'out_temp': 15, + 'in_temp': 9, + 'target_temp': 15, + 'fan_speed': 4, + '_filter_remain': 175.7928587962963, + 'model': 'Lite', + '_device_work_time': 18.284884259259258, + '_electronic_work_time': 4.214814814814815, + '_electronic_temp': 26, + '_co2_auto_control': 0, + '_filter_change_required': 0, + 'light': 'on', + } + }, + { + "instance": tion_btle.S4, + "data": [0x2f, 0x51, 0x00, 0x19, 0x04, 0x0e, 0x10, 0x1b, 0x26, 0x3b, 0x6e, 0x07, 0x00, 0xfa, 0x4e, 0x07, 0x00, + 0x06, 0xff, 0xe5, 0x00, 0xa6, 0xe9, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00], + "results": { + 'state': 'on', + 'heater': 'on', + 'sound': 'on', + 'mode': 'outside', + 'out_temp': 16, + 'in_temp': 14, + 'target_temp': 25, + 'fan_speed': 4, + '_filter_remain': 174.45636574074075, + 'model': 'S4', + } + } +] + + +def pytest_generate_tests(metafunc): + global scenarios + tests = [] + + for scenario in scenarios: + tion_type: Type[tion_btle.Tion] = scenario["instance"] + tion: tion_btle.Tion = tion_type(mac="") + tion._decode_response(response=scenario["data"]) + + for k in scenario["results"].keys(): + v = scenario["results"][k] + _id = f"{tion.__class__.__name__}-{k}" + tests.append(pytest.param(_id, getattr(tion, k), v, id=_id)) + + metafunc.parametrize("parameter,decoded_value,expected_value", tests) + + +def test_param(parameter, decoded_value, expected_value): + assert decoded_value == expected_value diff --git a/tests/unit/test_light_decode.py b/tests/unit/test_light_decode.py deleted file mode 100644 index 8c4f607..0000000 --- a/tests/unit/test_light_decode.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/python -import sys -import os -import unittest - -PACKAGE_PARENT = '..' -SCRIPT_DIR = os.path.dirname(os.path.realpath(os.path.join(os.getcwd(), os.path.expanduser(__file__)))) -sys.path.append(os.path.normpath(os.path.join(SCRIPT_DIR, PACKAGE_PARENT))) - -from tion_btle.lite import Lite - - -class LiteTests(unittest.TestCase): - data = bytearray() - - def test_decode(self): - self.lite = Lite("dummy") - self.lite._decode_response(self.data) - name_value = [ - ('state', 'on'), - ('heater', 'on'), - ('sound', 'on'), - ('mode', 'outside'), - ('out_temp', 15), - ('in_temp', 9), - ('target_temp', 15), - ('fan_speed', 4), - ('_filter_remain', 175.7928587962963), - ('model', 'Lite'), - ('_device_work_time', 18.284884259259258), - ('_electronic_work_time', 4.214814814814815), - ('_electronic_temp', 26), - ('_co2_auto_control', 0), - ('_filter_change_required', 0), - ('light', 'on') - ] - for s in name_value: - with self.subTest(s[0]): - self.assertEqual(getattr(self.lite, s[0]), s[1], "%s should be '%s'" % (s[0], s[1]) ) - - -LiteTests.data = bytearray([ - 0xcf, 0xd8, 0x02, 0x0f, 0x04, 0x09, 0x0f, 0x1a, 0x80, 0x8e, 0x05, 0x00, 0xe9, 0x8b, 0x05, 0x00, 0x17, 0xc2, - 0xe7, 0x00, 0x26, 0x1b, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, - 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, 0x02, 0x04, 0x06, - 0x06, 0x18, 0x00 -]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/unit/test_s3_decode.py b/tests/unit/test_s3_decode.py deleted file mode 100644 index a9bd977..0000000 --- a/tests/unit/test_s3_decode.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/python -import sys -import os -import unittest - -PACKAGE_PARENT = '..' -SCRIPT_DIR = os.path.dirname(os.path.realpath(os.path.join(os.getcwd(), os.path.expanduser(__file__)))) -sys.path.append(os.path.normpath(os.path.join(SCRIPT_DIR, PACKAGE_PARENT))) - -from tion_btle.s3 import S3 - - -class TestLite(unittest.TestCase): - data = bytearray() - - def test_decode(self): - self.lite = S3("dummy") - self.lite._decode_response(self.data) - name_value = [ - ('state', 'on'), - ('heater', 'on'), - ('sound', 'off'), - ('mode', 'outside'), - ('out_temp', 20), - ('in_temp', 20), - ('target_temp', 20), - ('fan_speed', 4), - ('_filter_remain', 143), - ('model', 'S3'), - ] - for s in name_value: - with self.subTest(s[0]): - self.assertEqual(getattr(self.lite, s[0]), s[1], "%s should be '%s'" % (s[0], s[1]) ) - - -TestLite.data = bytearray([ - 0xb3, 0x10, 0x24, 0x14, 0x03, 0x00, 0x15, 0x14, 0x14, 0x8f, 0x00, 0x0c, 0x0a, 0x00, 0x4b, 0x0a, 0x00, 0x33, 0x00, - 0x5a -]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/unit/test_tion.py b/tests/unit/test_tion.py index 4acc515..726673f 100644 --- a/tests/unit/test_tion.py +++ b/tests/unit/test_tion.py @@ -1,7 +1,8 @@ +import bluepy import time -import unittest +import pytest import unittest.mock as mock -import bluepy + import tion_btle.tion from tion_btle.tion import tion @@ -12,7 +13,45 @@ from tion_btle.tion import retry, MaxTriesExceededError -class retryTests(unittest.TestCase): +@pytest.mark.parametrize( + "retries, repeats, succeed_run, t_delay", + [ + pytest.param(0, 1, 0, 0, id="Succeed after first attempt with no retry"), + pytest.param(1, 1, 0, 0, id="Succeed after first attempt with retry"), + pytest.param(5, 4, 3, 0, id="Succeed after first 3rd attempt with 5 retry"), + pytest.param(1, 2, 3, 0, id="Fail after one retry"), + pytest.param(2, 2, 1, 2, id="Delay between retries"), + ] +) +def test_retry(retries: int, repeats: int, succeed_run: int, t_delay: int): + class TestRetry: + count = 0 + + @retry(retries=retries, delay=t_delay) + def a(self, _succeed_run: int = 0): + if self.count <= _succeed_run: + self.count += 1 + if self.count - 1 == _succeed_run: + return "expected_result" + + raise bluepy.btle.BTLEDisconnectError + + i = TestRetry() + start = time.time() + + if succeed_run < repeats: + assert i.a(_succeed_run=succeed_run) == "expected_result" + else: + with pytest.raises(MaxTriesExceededError) as c: + i.a(_succeed_run=succeed_run) + + end = time.time() + + assert i.count == repeats + assert end - start >= t_delay + + +class TestLogLevels: def setUp(self): self.count = 0 tion_btle.tion._LOGGER.debug = mock.MagicMock(name='method') @@ -20,60 +59,6 @@ def setUp(self): tion_btle.tion._LOGGER.warning = mock.MagicMock(name='method') tion_btle.tion._LOGGER.critical = mock.MagicMock(name='method') - def test_success_single_try(self): - @retry(retries=0) - def a(): - self.count += 1 - return "expected_result" - self.assertEqual(a(), "expected_result") - self.assertEqual(self.count, 1) - - def test_success_two_tries(self): - @retry(retries=1) - def a(): - self.count += 1 - return "expected_result" - self.assertEqual(a(), "expected_result") - self.assertEqual(self.count, 1) - - def test_failure_two_tries(self): - @retry(retries=1) - def a(): - self.count += 1 - raise Exception() - try: - a() - self.fail() - except MaxTriesExceededError: - self.assertEqual(self.count, 2) - - def test_success_after_third_try(self): - @retry(retries=5) - def a(): - self.count += 1 - if self.count == 3: - return "expected_result" - else: - raise Exception() - self.assertEqual(a(), "expected_result") - self.assertEqual(self.count, 3) - - def test_delay(self): - t_delay = 2 - - @retry(retries=2, delay=t_delay) - def a(): - self.count += 1 - if self.count == 2: - return "expected_result" - else: - raise Exception() - - start = time.time() - a() - end = time.time() - self.assertGreaterEqual(end-start, t_delay) - def test_debug_log_level(self): @retry(retries=0) def debug(): @@ -133,27 +118,23 @@ def critical(): pass log_mock.assert_called() - def test_MaxTriesExceededError(self): - @retry(retries=0) - def e(): - raise Exception - - with self.assertRaises(MaxTriesExceededError) as c: - e() - -class TionTests(unittest.TestCase): - def setUp(self): - self.instances = [tion, LiteFamily, Lite, S3, S4] - - def test_DecodeTemperature(self): - self.assertEqual(tion.decode_temperature(0x09), 9, "Should be 9") - self.assertEqual(tion.decode_temperature(0xFF), -1, "Should be -1") - - def test_mac(self): - """mac property should be same as in init""" - target = 'foo' - for s in self.instances: - with self.subTest(test_instance=s): - t_tion = s(target) - self.assertEqual(t_tion.mac, target) +@pytest.mark.parametrize( + "raw_temperature, result", + [ + [0x09, 9], + [0xFF, -1] + ] +) +def test_decode_temperature(raw_temperature, result): + assert tion.decode_temperature(raw_temperature) == result + + +@pytest.mark.parametrize( + "instance", + [tion, LiteFamily, Lite, S3, S4] +) +def test_mac(instance): + target = 'foo' + t_tion = instance(target) + assert t_tion.mac == target diff --git a/tests/unit/tion.py b/tests/unit/tion.py index e928df5..793579c 100644 --- a/tests/unit/tion.py +++ b/tests/unit/tion.py @@ -1,37 +1,23 @@ -import sys -import unittest -import unittest.mock as mock -from unittest.mock import MagicMock -sys.modules['bluepy'] = MagicMock() -sys.modules['bluepy.btle'] = MagicMock() -import tion_btle.tion # noqa: E402 +import pytest +from tion_btle.tion import tion as Tion -class TionTests(unittest.TestCase): - def setUp(self): - self.patch = mock.patch('tion_btle.tion.tion.__init__', return_value=None) - self.patch.start() - self._tion = tion_btle.tion.tion("") - def tearDown(self): - self.patch.stop() +@pytest.mark.parametrize( + "heater, heater_temp, in_temp, out_temp, result", + [ + ["on", 20, -2, 21, "on"], + ["on", 20, 15, 21, "on"], + ["on", 16, 15, 21, "off"], + ["off", 20, 15, 21, "off"], + ]) +def test__detect_heating_state(heater, in_temp, out_temp, heater_temp, result): + """Test heating detection""" - def test___detect_heating_state(self): - # state, in, out, target, heater - _variants = [ - ["on", -2, 21, 20, "on"], - ["on", 15, 21, 20, "on"], - ["off", 15, 21, 16, "on"], - ["off", 15, 21, 20, "off"], - ] + tion = Tion(mac="") + tion.heater = heater + tion._in_temp = in_temp + tion._out_temp = out_temp + tion._heater_temp = heater_temp - for expect, in_temp, out_temp, heater_temp, heater in _variants: - with self.subTest(expect=expect, - in_temp=in_temp, out_temp=out_temp, heater_temp=heater_temp, heater=heater): - # call private __detect_heating_state from tion class - self._tion._tion__detect_heating_state(in_temp, out_temp, heater_temp, heater) - self.assertEqual(self._tion.heating, expect) - - -if __name__ == '__main__': - unittest.main() + assert tion.heating == result diff --git a/tion_btle/__init__.py b/tion_btle/__init__.py index c1f5387..47745b5 100644 --- a/tion_btle/__init__.py +++ b/tion_btle/__init__.py @@ -1,2 +1,4 @@ from .s3 import S3 from .lite import Lite +from .s4 import S4 +from .tion import tion as Tion \ No newline at end of file diff --git a/tion_btle/lite.py b/tion_btle/lite.py index 3fee785..1106088 100644 --- a/tion_btle/lite.py +++ b/tion_btle/lite.py @@ -140,12 +140,3 @@ def _packages(self) -> list: bytearray([0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00]), bytearray([0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, 0x02, 0x04, 0x06, 0x06, 0x18, 0x00, 0xb5, 0xad]) ] - - @property - def _dummy_data(self) -> bytearray: - return bytearray([ - 0xcf, 0xd8, 0x02, 0x0f, 0x04, 0x09, 0x0f, 0x1a, 0x80, 0x8e, 0x05, 0x00, 0xe9, 0x8b, 0x05, 0x00, 0x17, - 0xc2, 0xe7, 0x00, 0x26, 0x1b, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, - 0x02, 0x04, 0x06, 0x06, 0x18, 0x00 - ]) diff --git a/tion_btle/s3.py b/tion_btle/s3.py index 2fbf6c9..9739ab2 100644 --- a/tion_btle/s3.py +++ b/tion_btle/s3.py @@ -132,8 +132,3 @@ def _encode_request(self, request: dict) -> bytearray: def _send_request(self, request: bytearray): self._try_write(request) - - @property - def _dummy_data(self) -> bytearray: - return bytearray([0xb3, 0x10, 0x24, 0x14, 0x03, 0x00, 0x15, 0x14, 0x14, 0x8f, 0x00, 0x0c, 0x0a, 0x00, 0x4b, - 0x0a, 0x00, 0x33, 0x00, 0x5a]) diff --git a/tion_btle/s4.py b/tion_btle/s4.py index a84bdd0..419ac40 100644 --- a/tion_btle/s4.py +++ b/tion_btle/s4.py @@ -90,14 +90,6 @@ def _packages(self) -> list: bytearray([0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x98, 0x5d]) ] - @property - def _dummy_data(self) -> bytearray: - full_message = bytearray([0x00, 0x2f, 0x00, 0x3a, 0x27, 0x31, 0x32, 0x72, 0x7b, 0x64, 0xd7, 0x31, 0xea, 0x58, - 0x3a, 0x2f, 0x51, 0x00, 0x19, 0x04, 0x0e, 0x10, 0x1b, 0x26, 0x3b, 0x6e, 0x07, 0x00, - 0xfa, 0x4e, 0x07, 0x00, 0x06, 0xff, 0xe5, 0x00, 0xa6, 0xe9, 0x22, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x06, 0x00, 0x98, 0x5d]) - return full_message[15:-2] - @property def command_getStatus(self) -> bytearray: return bytearray([LiteFamily.SINGLE_PACKET_ID, 0x10, 0x00, self.MAGIC_NUMBER, 0xa1] + diff --git a/tion_btle/tion.py b/tion_btle/tion.py index c913d75..f0b4ecb 100644 --- a/tion_btle/tion.py +++ b/tion_btle/tion.py @@ -131,7 +131,6 @@ def __init__(self, mac: str): self._state: bool = False self._heater: bool = False self._sound: bool = False - self._heating: bool = False self._filter_remain: float = 0.0 self._error_code: int = 0 self.__failed_connects: int = 0 @@ -209,35 +208,16 @@ def __generate_common_json(self) -> dict: "model": self.model, } - def __detect_heating_state(self, - in_temp: int = None, - out_temp: int = None, - heater_temp: int = None, - heater: str = None) -> None: - """ - Tries to guess is heater working right now - :param in_temp: air intake temperature - :param out_temp: ait outtake temperature - :param heater_temp: target temperature for heater - :param heater: heater state - :return: None - """ - if in_temp is None: - in_temp = self.in_temp - if out_temp is None: - out_temp = self.out_temp - if heater_temp is None: - heater_temp = self.heater_temp - if heater is None: - heater = self.heater - - if heater == "off": - self.heating = "off" - else: - if heater_temp - in_temp > 3 and out_temp > in_temp: - self.heating = "on" - else: - self.heating = "off" + @property + def heating(self) -> str: + """Tries to guess is heater working right now.""" + if self.heater == "off": + return "off" + + if self.heater_temp - self.in_temp > 3 and self.out_temp > self.in_temp: + return "on" + + return "off" def get_state_from_breezer(self, keep_connection: bool = False) -> None: """ @@ -257,7 +237,6 @@ def get_state_from_breezer(self, keep_connection: bool = False) -> None: self.__connections_count -= 1 self._decode_response(response) - self.__detect_heating_state() def get(self, keep_connection: bool = False, skip_update: bool = False) -> dict: """ @@ -509,14 +488,6 @@ def sound(self, new_state: str): def filter_remain(self) -> float: return self._filter_remain - @property - def heating(self) -> str: - return self._decode_state(self._heating) - - @heating.setter - def heating(self, new_state: str): - self._heating = self._encode_state(new_state) - @property def mode(self): return self._process_mode(self._mode)