From faeac9c548e43822697c7d3ef1e5da4f3ae0f92e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:17:37 -0300 Subject: [PATCH 1/7] chore(ci): pre-commit autoupdate (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.2...v0.5.4 - https://github.com/commitizen-tools/commitizen/compare/v3.27.0...v3.28.0 Co-authored-by: Lucas MindĂȘllo de Andrade --- .pre-commit-config.yaml | 4 ++-- midealocal/devices/e2/__init__.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6accf789..cd040734 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,14 +16,14 @@ repos: - id: no-commit-to-branch args: ["--branch", "main"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.5.4 hooks: - id: ruff args: - --fix - id: ruff-format - repo: https://github.com/commitizen-tools/commitizen - rev: v3.27.0 + rev: v3.28.0 hooks: - id: commitizen stages: [commit-msg] diff --git a/midealocal/devices/e2/__init__.py b/midealocal/devices/e2/__init__.py index 98748e95..ebc874f3 100644 --- a/midealocal/devices/e2/__init__.py +++ b/midealocal/devices/e2/__init__.py @@ -103,8 +103,7 @@ def _normalize_old_protocol(self, value: str | bool | int) -> OldProtocol: if return_value == OldProtocol.auto: result = ( self.subtype <= E2SubType.T82 - or self.subtype == E2SubType.T85 - or self.subtype == E2SubType.T36353 + or self.subtype in [E2SubType.T85, E2SubType.T36353], ) return_value = OldProtocol.true if result else OldProtocol.false if isinstance(value, bool | int): From c636eeef5128504c079704d023e2215896e3c770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Tue, 23 Jul 2024 14:25:49 -0300 Subject: [PATCH 2/7] feat(message): body parsers (#235) ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Introduced a new framework for parsing message bodies, allowing for flexible and type-safe handling of various data types. - Added specific parsers for boolean, integer, and enumeration types to the message parsing logic. - Enhanced the `MessageBody` class with a method to parse multiple attributes automatically. - **Tests** - Added a comprehensive suite of unit tests for the message parsing features, ensuring functionality and error handling across various parser classes. --------- Co-authored-by: Simone Chemelli --- midealocal/message.py | 151 ++++++++++++++++++++++++++++++++- tests/message_test.py | 189 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 tests/message_test.py diff --git a/midealocal/message.py b/midealocal/message.py index cec603fa..070d05ad 100644 --- a/midealocal/message.py +++ b/midealocal/message.py @@ -2,7 +2,7 @@ import logging from enum import IntEnum -from typing import SupportsIndex, cast +from typing import Generic, SupportsIndex, TypeVar, cast _LOGGER = logging.getLogger(__name__) @@ -300,12 +300,156 @@ def body(self) -> bytearray: return bytearray([0x00] * 19) +T = TypeVar("T") +E = TypeVar("E", bound="IntEnum") + + +class BodyParser(Generic[T]): + """Body parser to decode message.""" + + def __init__( + self, + name: str, + byte: int, + bit: int | None = None, + length_in_bytes: int = 1, + first_upper: bool = True, + default_raw_value: int = 0, + ) -> None: + """Init body parser with attribute name.""" + self.name = name + self._byte = byte + self._bit = bit + self._length_in_bytes = length_in_bytes + self._first_upper = first_upper + self._default_raw_value = default_raw_value + if length_in_bytes < 0: + raise ValueError("Length in bytes must be a positive value.") + if bit is not None and (bit < 0 or bit >= length_in_bytes * 8): + raise ValueError( + "Bit, if set, must be a valid value position for %d bytes.", + length_in_bytes, + ) + + def _get_raw_value(self, body: bytearray) -> int: + """Get raw value from body.""" + if len(body) < self._byte + self._length_in_bytes: + return self._default_raw_value + data = 0 + for i in range(self._length_in_bytes): + byte = ( + self._byte + self._length_in_bytes - 1 - i + if self._first_upper + else self._byte + i + ) + data += body[byte] << (8 * i) + if self._bit is not None: + data = (data & (1 << self._bit)) >> self._bit + return data + + def get_value(self, body: bytearray) -> T: + """Get attribute value.""" + return self._parse(self._get_raw_value(body)) + + def _parse(self, raw_value: int) -> T: + """Convert raw value to attribute value.""" + raise NotImplementedError + + +class BoolParser(BodyParser[bool]): + """Bool message body parser.""" + + def __init__( + self, + name: str, + byte: int, + bit: int | None = None, + true_value: int = 1, + false_value: int = 0, + default_value: bool = True, + ) -> None: + """Init bool body parser.""" + super().__init__(name, byte, bit) + self._true_value = true_value + self._default_value = default_value + self._false_value = false_value + + def _parse(self, raw_value: int) -> bool: + if raw_value not in [self._true_value, self._false_value]: + return self._default_value + return raw_value == self._true_value + + +class IntEnumParser(BodyParser[E]): + """IntEnum message body parser.""" + + def __init__( + self, + name: str, + byte: int, + enum_class: type[E], + length_in_bytes: int = 1, + first_upper: bool = False, + default_value: E | None = None, + ) -> None: + """Init IntEnum body parser.""" + super().__init__( + name, + byte, + length_in_bytes=length_in_bytes, + first_upper=first_upper, + ) + self._enum_class = enum_class + self._default_value = default_value + + def _parse(self, raw_value: int) -> E: + try: + return self._enum_class(raw_value) + except ValueError: + return ( + self._default_value + if self._default_value is not None + else self._enum_class(0) + ) + + +class IntParser(BodyParser[int]): + """IntEnum message body parser.""" + + def __init__( + self, + name: str, + byte: int, + max_value: int = 255, + min_value: int = 0, + length_in_bytes: int = 1, + first_upper: bool = False, + ) -> None: + """Init IntEnum body parser.""" + super().__init__( + name, + byte, + length_in_bytes=length_in_bytes, + first_upper=first_upper, + ) + self._max_value = max_value + self._min_value = min_value + + def _parse(self, raw_value: int) -> int: + if raw_value > self._max_value: + return self._max_value + if raw_value < self._min_value: + return self._min_value + return raw_value + + class MessageBody: """Message body.""" def __init__(self, body: bytearray) -> None: """Initialize message body.""" self._data = body + self.parser_list: list[BodyParser] = [] @property def data(self) -> bytearray: @@ -322,6 +466,11 @@ def read_byte(body: bytearray, byte: int, default_value: int = 0) -> int: """Read bytes for message body.""" return body[byte] if len(body) > byte else default_value + def parse_all(self) -> None: + """Process parses and set body attrs.""" + for parse in self.parser_list: + setattr(self, parse.name, parse.get_value(self._data)) + class NewProtocolPackLength(IntEnum): """New Protocol Pack Length.""" diff --git a/tests/message_test.py b/tests/message_test.py new file mode 100644 index 00000000..f9a43283 --- /dev/null +++ b/tests/message_test.py @@ -0,0 +1,189 @@ +"""Midea local message test.""" + +import pytest + +from midealocal.message import ( + BodyParser, + BodyType, + BoolParser, + IntEnumParser, + IntParser, + MessageBody, +) + + +def test_init_validations() -> None: + """Test body parser init validations.""" + with pytest.raises( + ValueError, + match="Length in bytes must be a positive value.", + ): + BodyParser[int]("name", byte=3, length_in_bytes=-1) + + with pytest.raises( + ValueError, + match="('Bit, if set, must be a valid value position for %d bytes.', 2)", + ): + BodyParser[int]("name", byte=3, length_in_bytes=2, bit=-1) + + with pytest.raises( + ValueError, + match="('Bit, if set, must be a valid value position for %d bytes.', 3)", + ): + BodyParser[int]("name", byte=3, length_in_bytes=3, bit=24) + + +class TestBodyParser: + """Body parser test.""" + + @pytest.fixture(autouse=True) + def _setup_body(self) -> None: + """Create body for test.""" + self.body = bytearray( + [ + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + ], + ) + + def test_get_raw_value_1_byte(self) -> None: + """Test get raw value with 1 byte.""" + parser = BodyParser[int]("name", 2) + value = parser._get_raw_value(self.body) + assert value == 0x02 + + def test_get_raw_value_2_bytes(self) -> None: + """Test get raw value with 2 bytes.""" + parser = BodyParser[int]("name", 2, length_in_bytes=2) + value = parser._get_raw_value(self.body) + assert value == 0x0203 + + def test_get_raw_value_2_bytes_first_lower(self) -> None: + """Test get raw value with 2 bytes first lower.""" + parser = BodyParser[int]("name", 2, length_in_bytes=2, first_upper=False) + value = parser._get_raw_value(self.body) + assert value == 0x0302 + + def test_get_raw_out_of_bounds(self) -> None: + """Test get raw value out of bounds.""" + parser = BodyParser[int]("name", 6) + value = parser._get_raw_value(self.body) + assert value == 0 + + def test_get_raw_data_size_out_of_bounds(self) -> None: + """Test get raw value out of bounds.""" + parser = BodyParser[int]("name", 5, length_in_bytes=2) + value = parser._get_raw_value(self.body) + assert value == 0 + + def test_get_raw_data_bit(self) -> None: + """Test get raw value out of bounds.""" + for i in range(16): + parser = BodyParser[int]("name", 4, length_in_bytes=2, bit=i) + value = parser._get_raw_value(self.body) + assert value == (1 if i in [0, 2, 10] else 0) + + def test_parse_unimplemented(self) -> None: + """Test parse unimplemented.""" + parser = BodyParser[int]("name", 4, length_in_bytes=2, bit=2) + with pytest.raises(NotImplementedError): + parser.get_value(self.body) + + +class TestBoolParser: + """Test BoolParser.""" + + def test_bool_default(self) -> None: + """Test default behaviour.""" + parser = BoolParser("name", 0) + assert parser._parse(0) is False + assert parser._parse(1) is True + assert parser._parse(2) is True + + def test_bool_default_false(self) -> None: + """Test default behaviour with default value false.""" + parser = BoolParser("name", 0, default_value=False) + assert parser._parse(0) is False + assert parser._parse(1) is True + assert parser._parse(2) is False + + def test_bool_inverted(self) -> None: + """Test True=0 and False=1.""" + parser = BoolParser("name", 0, true_value=0, false_value=1) + assert parser._parse(0) is True + assert parser._parse(1) is False + assert parser._parse(2) is True + + +class TestIntEnumParser: + """Test IntEnumParser.""" + + def test_intenum_default(self) -> None: + """Test default behaviour.""" + parser = IntEnumParser[BodyType]("name", 0, BodyType) + assert parser._parse(0x01) == BodyType.X01 + assert parser._parse(0x00) == BodyType.X00 + assert parser._parse(0x10) == BodyType.X00 + + parser = IntEnumParser[BodyType]("name", 0, BodyType, default_value=BodyType.A0) + assert parser._parse(0x01) == BodyType.X01 + assert parser._parse(0x00) == BodyType.X00 + assert parser._parse(0x10) == BodyType.A0 + + +class TestIntParser: + """Test IntParser.""" + + def test_int_default(self) -> None: + """Test default behaviour.""" + parser = IntParser("name", 0) + for i in range(-10, 260): + if i < 0: + assert parser._parse(i) == 0 + elif i > 255: + assert parser._parse(i) == 255 + else: + assert parser._parse(i) == i + + +class TestMessageBody: + """Test message body.""" + + def test_parse_all(self) -> None: + """Test parse all.""" + data = bytearray( + [ + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + ], + ) + + body = MessageBody(data) + body.parser_list.extend( + [ + IntEnumParser("bt", 0, BodyType), + BoolParser("power", 1), + BoolParser("feature_1", 2, 0), + BoolParser("feature_2", 2, 1), + IntParser("speed", 3), + ], + ) + body.parse_all() + assert hasattr(body, "bt") is True + assert getattr(body, "bt", None) == BodyType.X00 + assert hasattr(body, "power") is True + assert getattr(body, "power", False) is True + assert hasattr(body, "feature_1") is True + assert getattr(body, "feature_1", True) is False + assert hasattr(body, "feature_2") is True + assert getattr(body, "feature_2", False) is True + assert hasattr(body, "speed") is True + assert getattr(body, "speed", 0) == 3 From c9e7d9ea85aa4f588aab3170ef0f4689a15a267e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Wed, 24 Jul 2024 10:08:21 -0300 Subject: [PATCH 3/7] chore(main): release 2.3.0 (#239) :robot: I have created a release *beep* *boop* --- ## [2.3.0](https://github.com/rokam/midea-local/compare/v2.2.0...v2.3.0) (2024-07-23) ### Features * **message:** body parsers ([#235](https://github.com/rokam/midea-local/issues/235)) ([c636eee](https://github.com/rokam/midea-local/commit/c636eeef5128504c079704d023e2215896e3c770)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). ## Summary by CodeRabbit - **New Features** - Updated changelog now includes a section for version 2.3.0, highlighting new features related to message body parsers, enhancing user documentation. - **Version Update** - Incremented version number to 2.3.0, signaling the potential availability of new features and improvements. --- CHANGELOG.md | 7 +++++++ midealocal/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afc6b5d9..df154ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.3.0](https://github.com/rokam/midea-local/compare/v2.2.0...v2.3.0) (2024-07-23) + + +### Features + +* **message:** body parsers ([#235](https://github.com/rokam/midea-local/issues/235)) ([c636eee](https://github.com/rokam/midea-local/commit/c636eeef5128504c079704d023e2215896e3c770)) + ## [2.2.0](https://github.com/rokam/midea-local/compare/v2.1.1...v2.2.0) (2024-07-20) diff --git a/midealocal/version.py b/midealocal/version.py index 9dcf884c..fe4890d1 100644 --- a/midealocal/version.py +++ b/midealocal/version.py @@ -1,3 +1,3 @@ """Midea Local Version.""" -__version__ = "2.2.0" +__version__ = "2.3.0" From 681bd79f078deafe82cc4708f47c53c808dca064 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Jul 2024 16:07:24 +0200 Subject: [PATCH 4/7] feat: segregate connect/auth/refresh/enable device duties (#233) --- midealocal/device.py | 106 +++++++++++++++++++-------------------- midealocal/exceptions.py | 4 ++ tests/device_test.py | 60 ++++++---------------- 3 files changed, 73 insertions(+), 97 deletions(-) diff --git a/midealocal/device.py b/midealocal/device.py index 10d92ded..ad15bf18 100644 --- a/midealocal/device.py +++ b/midealocal/device.py @@ -8,7 +8,7 @@ from enum import IntEnum, StrEnum from typing import Any -from .exceptions import SocketException +from .exceptions import CannotConnect, SocketException from .message import ( MessageApplianceResponse, MessageQueryAppliance, @@ -140,7 +140,7 @@ def __init__( self._updates: list[Callable[[dict[str, Any]], None]] = [] self._unsupported_protocol: list[str] = [] self._is_run = False - self._available = True + self._available = False self._appliance_query = True self._refresh_interval = 30 self._heartbeat_interval = 10 @@ -190,67 +190,66 @@ def fetch_v2_message(msg: bytes) -> tuple[list, bytes]: break return result, msg - def connect( - self, - refresh_status: bool = True, - get_capabilities: bool = True, - ) -> bool: + def _authenticate_refresh_capabilities(self) -> None: + if self._protocol == ProtocolVersion.V3: + self.authenticate() + self.refresh_status(wait_response=True) + self.get_capabilities() + + def connect(self) -> bool: """Connect to device.""" connected = False - try: - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.settimeout(10) - _LOGGER.debug( - "[%s] Connecting to %s:%s", - self._device_id, - self._ip_address, - self._port, - ) - self._socket.connect((self._ip_address, self._port)) - _LOGGER.debug("[%s] Connected", self._device_id) - if self._protocol == ProtocolVersion.V3: - self.authenticate() - _LOGGER.debug("[%s] Authentication success", self._device_id) - if refresh_status: - self.refresh_status(wait_response=True) - if get_capabilities: - self.get_capabilities() - connected = True - except TimeoutError: - _LOGGER.debug("[%s] Connection timed out", self._device_id) - except OSError: - _LOGGER.debug("[%s] Connection error", self._device_id) - except AuthException: - _LOGGER.debug("[%s] Authentication failed", self._device_id) - except RefreshFailed: - _LOGGER.debug("[%s] Refresh status is timed out", self._device_id) - except Exception as e: - file = None - lineno = None - if e.__traceback__: - file = e.__traceback__.tb_frame.f_globals["__file__"] # pylint: disable=E1101 - lineno = e.__traceback__.tb_lineno - _LOGGER.exception( - "[%s] Unknown error : %s, %s", - self._device_id, - file, - lineno, - ) + for _ in range(3): + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(10) + _LOGGER.debug( + "[%s] Connecting to %s:%s", + self._device_id, + self._ip_address, + self._port, + ) + self._socket.connect((self._ip_address, self._port)) + _LOGGER.debug("[%s] Connected", self._device_id) + connected = True + except TimeoutError: + _LOGGER.debug("[%s] Connection timed out", self._device_id) + except OSError: + _LOGGER.debug("[%s] Connection error", self._device_id) + except AuthException: + _LOGGER.debug("[%s] Authentication failed", self._device_id) + except RefreshFailed: + _LOGGER.debug("[%s] Refresh status is timed out", self._device_id) + except Exception as e: + file = None + lineno = None + if e.__traceback__: + file = e.__traceback__.tb_frame.f_globals["__file__"] # pylint: disable=E1101 + lineno = e.__traceback__.tb_lineno + _LOGGER.exception( + "[%s] Unknown error : %s, %s", + self._device_id, + file, + lineno, + ) self.enable_device(connected) return connected def authenticate(self) -> None: """Authenticate to device. V3 only.""" request = self._security.encode_8370(self._token, MSGTYPE_HANDSHAKE_REQUEST) - _LOGGER.debug("[%s] Handshaking", self._device_id) + _LOGGER.debug("[%s] Authentication handshaking", self._device_id) if not self._socket: + self.enable_device(False) raise SocketException self._socket.send(request) response = self._socket.recv(512) if len(response) < MIN_AUTH_RESPONSE: + self.enable_device(False) raise AuthException response = response[8:72] self._security.tcp_key(response, self._key) + _LOGGER.debug("[%s] Authentication success", self._device_id) def send_message(self, data: bytes) -> None: """Send message.""" @@ -462,6 +461,7 @@ def update_all(self, status: dict[str, Any]) -> None: def enable_device(self, available: bool = True) -> None: """Enable device.""" + _LOGGER.debug("[%s] Enabling device", self._device_id) self._available = available status = {"available": available} self.update_all(status) @@ -510,14 +510,14 @@ def _check_heartbeat(self, now: float) -> None: def run(self) -> None: """Run loop.""" while self._is_run: - while self._socket is None: - if self.connect(refresh_status=True) is False: - self.close_socket() - time.sleep(5) + if not self.connect(): + raise CannotConnect + if not self._socket: + raise SocketException + self._authenticate_refresh_capabilities() timeout_counter = 0 start = time.time() - self._previous_refresh = start - self._previous_heartbeat = start + self._previous_refresh = self._previous_heartbeat = start self._socket.settimeout(1) while True: try: diff --git a/midealocal/exceptions.py b/midealocal/exceptions.py index f2c7ba95..7cd5c74d 100644 --- a/midealocal/exceptions.py +++ b/midealocal/exceptions.py @@ -11,6 +11,10 @@ class CannotAuthenticate(MideaLocalError): """Exception raised when credentials are incorrect.""" +class CannotConnect(MideaLocalError): + """Exception raised when connection fails.""" + + class DataUnexpectedLength(MideaLocalError): """Exception raised when data length is less or more than expected.""" diff --git a/tests/device_test.py b/tests/device_test.py index 491813be..88b8ba03 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -1,6 +1,5 @@ """Midea Local device test.""" -from unittest import IsolatedAsyncioTestCase from unittest.mock import MagicMock, patch import pytest @@ -28,7 +27,7 @@ def test_fetch_v2_message() -> None: ) -class MideaDeviceTest(IsolatedAsyncioTestCase): +class MideaDeviceTest: """Midea device test case.""" device: MideaDevice @@ -59,55 +58,28 @@ def test_initial_attributes(self) -> None: assert self.device.model == "test_model" assert self.device.subtype == 1 - def test_connect(self) -> None: + @pytest.mark.parametrize( + ("exc", "result"), + [ + (TimeoutError, False), + (OSError, False), + (AuthException, False), + (RefreshFailed, False), + (None, True), + ], + ) + def test_connect(self, exc: Exception, result: bool) -> None: """Test connect.""" - with ( - patch("socket.socket.connect") as connect_mock, - patch.object( - self.device, - "authenticate", - side_effect=[AuthException(), None, None], - ), - patch.object( - self.device, - "refresh_status", - side_effect=[RefreshFailed(), None], - ), - patch.object( - self.device, - "get_capabilities", - side_effect=[None], - ), - ): - connect_mock.side_effect = [ - TimeoutError(), - OSError(), - None, - None, - None, - None, - ] - assert self.device.connect(True, True) is False - assert self.device.available is False - - assert self.device.connect(True, True) is False - assert self.device.available is False - - assert self.device.connect(True, True) is False - assert self.device.available is False - - assert self.device.connect(True, True) is False - assert self.device.available is False - - assert self.device.connect(True, True) is True - assert self.device.available is True + with patch("socket.socket.connect", side_effect=exc): + assert self.device.connect() is result + assert self.device.available is result def test_connect_generic_exception(self) -> None: """Test connect with generic exception.""" with patch("socket.socket.connect") as connect_mock: connect_mock.side_effect = Exception() - assert self.device.connect(True, True) is False + assert self.device.connect() is False assert self.device.available is False def test_authenticate(self) -> None: From 6f0a10942c0b5defd3622c14bcd2795b34ec01a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Wed, 24 Jul 2024 12:56:04 -0300 Subject: [PATCH 5/7] feat(cli): set attribute from device (#241) ## Summary by CodeRabbit - **New Features** - Introduced a new debugging configuration for setting device attributes in the Python application. - Added a command for setting attributes on discovered devices through the CLI, enhancing device management. - **Bug Fixes** - Improved the device discovery process by allowing it to return a device object or `None`. - **Tests** - Added an asynchronous test for validating the setting of device attributes in the CLI, improving test coverage. --- .vscode/launch.json | 31 +++++++++++++++++++++ midealocal/cli.py | 65 ++++++++++++++++++++++++++++++++++++++++++--- tests/cli_test.py | 59 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6cfbfc23..86269376 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -51,6 +51,21 @@ "--device-sn", "${input:device-sn}" ] + }, + { + "name": "Python Debugger: Set device attribute", + "type": "debugpy", + "request": "launch", + "module": "midealocal.cli", + "args": [ + "setattr", + "-d", + "${input:host}", + "${input:attribute}", + "${input:value}", + "--attr-type", + "${input:attr-type}" + ] } ], "inputs": [ @@ -99,6 +114,22 @@ "type": "promptString", "password": true, "description": "Enter cloud password." + }, + { + "id": "attribute", + "type": "promptString", + "description": "Enter attribute name." + }, + { + "id": "value", + "type": "promptString", + "description": "Enter attribute value." + }, + { + "id": "attr-type", + "type": "pickString", + "options": ["bool", "int", "str"], + "description": "Choose attribute type." } ] } diff --git a/midealocal/cli.py b/midealocal/cli.py index dfcbcfdf..b834584b 100644 --- a/midealocal/cli.py +++ b/midealocal/cli.py @@ -15,7 +15,7 @@ from colorlog import ColoredFormatter from midealocal.cloud import SUPPORTED_CLOUDS, MideaCloud, get_midea_cloud -from midealocal.device import ProtocolVersion +from midealocal.device import MideaDevice, ProtocolVersion from midealocal.devices import device_selector from midealocal.discover import discover from midealocal.exceptions import ElementMissing @@ -59,13 +59,13 @@ async def _get_keys(self, device_id: int) -> dict[int, dict[str, Any]]: default_keys = await cloud.get_default_keys() return {**cloud_keys, **default_keys} - async def discover(self) -> None: + async def discover(self) -> MideaDevice | None: """Discover device information.""" devices = discover(ip_address=self.namespace.host) if len(devices) == 0: _LOGGER.error("No devices found.") - return + return None # Dump only basic device info from the base class _LOGGER.info("Found %d devices.", len(devices)) @@ -93,9 +93,10 @@ async def discover(self) -> None: _LOGGER.debug("Trying to connect with key: %s", key) if dev.connect(): _LOGGER.info("Found device:\n%s", dev.attributes) - break + return dev _LOGGER.debug("Unable to connect with key: %s", key) + return None def message(self) -> None: """Load message into device.""" @@ -159,6 +160,33 @@ async def download(self) -> None: lua = await cloud.download_lua(str(Path()), device_type, device_sn, model) _LOGGER.info("Downloaded lua file: %s", lua) + async def set_attribute(self) -> None: + """Set attribute for device.""" + device = await self.discover() + if device is None: + return + + _LOGGER.info( + "Setting attribute %s for %s [%s]", + self.namespace.attribute, + device.device_id, + device.device_type, + ) + device.set_attribute( + self.namespace.attribute, + self._cast_attr_value(), + ) + await asyncio.sleep(2) + device.refresh_status(True) + _LOGGER.info("New device status:\n%s", device.attributes) + + def _cast_attr_value(self) -> int | bool | str: + if self.namespace.attr_type == "bool": + return self.namespace.value not in ["false", "False", "0", ""] + if self.namespace.attr_type == "int": + return int(self.namespace.value) + return str(self.namespace.value) + def run(self, namespace: Namespace) -> None: """Do setup logging, validate args and execute the desired function.""" self.namespace = namespace @@ -306,6 +334,35 @@ def main() -> NoReturn: ) download_parser.set_defaults(func=cli.download) + attribute_parser = subparsers.add_parser( + "setattr", + description="Set device attribute after discover.", + parents=[common_parser], + ) + attribute_parser.add_argument( + "host", + help="Hostname or IP address of a single device.", + default=None, + ) + attribute_parser.add_argument( + "attribute", + help="Attribute name.", + default=None, + ) + attribute_parser.add_argument( + "value", + help="Attribute value.", + default=None, + ) + attribute_parser.add_argument( + "--attr-type", + help="Attribute type.", + type=str, + default="int", + choices=["bool", "int", "str"], + ) + attribute_parser.set_defaults(func=cli.set_attribute) + config = get_config_file_path() namespace = parser.parse_args() if config.exists(): diff --git a/tests/cli_test.py b/tests/cli_test.py index 79e786f6..0f87045c 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -36,6 +36,9 @@ def setUp(self) -> None: device_sn="", user=False, debug=True, + attribute="power", + value="0", + attr_type="bool", func=MagicMock(), ) self.cli.namespace = self.namespace @@ -198,6 +201,62 @@ async def test_download(self) -> None: mock_device["model"], ) + async def test_set_attribute(self) -> None: + """Test set attribute.""" + mock_device = { + "device_id": 1, + "protocol": ProtocolVersion.V3, + "type": 0xAC, + "ip_address": "192.168.0.2", + "port": 6444, + "model": "AC123", + "sn": "AC123", + } + mock_cloud_instance = AsyncMock() + socket_instance = AsyncMock() + mock_device_instance = MagicMock() + mock_device_instance.connect.return_value = True + mock_cloud_instance.get_cloud_keys.return_value = { + 0: {"token": "token", "key": "key"}, + } + mock_cloud_instance.get_default_keys.return_value = { + 99: {"token": "token", "key": "key"}, + } + with ( + patch( + "midealocal.cli.discover", + return_value={1: mock_device}, + ), + patch.object( + self.cli, + "_get_cloud", + return_value=mock_cloud_instance, + ), + patch("socket.socket", return_value=socket_instance), + patch( + "midealocal.cli.device_selector", + return_value=mock_device_instance, + ), + ): + await self.cli.set_attribute() + mock_device_instance.set_attribute.assert_called_once_with("power", False) + mock_device_instance.reset_mock() + + self.namespace.attribute = "mode" + self.namespace.value = "2" + self.namespace.attr_type = "int" + + await self.cli.set_attribute() + mock_device_instance.set_attribute.assert_called_once_with("mode", 2) + mock_device_instance.reset_mock() + + self.namespace.attribute = "attr" + self.namespace.value = "string" + self.namespace.attr_type = "str" + + await self.cli.set_attribute() + mock_device_instance.set_attribute.assert_called_once_with("attr", "string") + def test_run(self) -> None: """Test run.""" mock_logger = MagicMock() From fbbc1c7ee67e849a2da3d05f3fe39394b3e42abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Wed, 24 Jul 2024 14:59:58 -0300 Subject: [PATCH 6/7] chore(main): release 2.4.0 (#240) :robot: I have created a release *beep* *boop* --- ## [2.4.0](https://github.com/rokam/midea-local/compare/v2.3.0...v2.4.0) (2024-07-24) ### Features * **cli:** set attribute from device ([#241](https://github.com/rokam/midea-local/issues/241)) ([6f0a109](https://github.com/rokam/midea-local/commit/6f0a10942c0b5defd3622c14bcd2795b34ec01a8)) * segregate connect/auth/refresh/enable device duties ([#233](https://github.com/rokam/midea-local/issues/233)) ([681bd79](https://github.com/rokam/midea-local/commit/681bd79f078deafe82cc4708f47c53c808dca064)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). ## Summary by CodeRabbit - **New Features** - Introduced the ability to set device attributes directly from the command-line interface. - **Improvements** - Enhanced modularity of device management functionalities for better maintainability and clarity, addressing user feedback from issue #233. - **Version Update** - Updated the project version to 2.4.0, indicating the inclusion of new features and improvements. --- CHANGELOG.md | 8 ++++++++ midealocal/version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df154ddf..1d211454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.4.0](https://github.com/rokam/midea-local/compare/v2.3.0...v2.4.0) (2024-07-24) + + +### Features + +* **cli:** set attribute from device ([#241](https://github.com/rokam/midea-local/issues/241)) ([6f0a109](https://github.com/rokam/midea-local/commit/6f0a10942c0b5defd3622c14bcd2795b34ec01a8)) +* segregate connect/auth/refresh/enable device duties ([#233](https://github.com/rokam/midea-local/issues/233)) ([681bd79](https://github.com/rokam/midea-local/commit/681bd79f078deafe82cc4708f47c53c808dca064)) + ## [2.3.0](https://github.com/rokam/midea-local/compare/v2.2.0...v2.3.0) (2024-07-23) diff --git a/midealocal/version.py b/midealocal/version.py index fe4890d1..601a5069 100644 --- a/midealocal/version.py +++ b/midealocal/version.py @@ -1,3 +1,3 @@ """Midea Local Version.""" -__version__ = "2.3.0" +__version__ = "2.4.0" From 06c4e025f4aa8914185a6593d6c2db61b33e5367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 25 Jul 2024 03:39:51 -0300 Subject: [PATCH 7/7] chore: pr auto label (#243) ## Summary by CodeRabbit - **New Features** - Introduced an automated labeling system for pull requests, enhancing organization and project management. - Added a workflow to streamline the application of labels based on predefined rules. - **Bug Fixes** - Improved accuracy of labeling by defining specific categories for changes and breaking modifications. --- .github/pr-labeler.yml | 10 ++++++++++ .github/workflows/lint-pr.yml | 20 ++++++++++++++++++++ .github/workflows/pr.yml | 17 ----------------- 3 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 .github/pr-labeler.yml create mode 100644 .github/workflows/lint-pr.yml delete mode 100644 .github/workflows/pr.yml diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 00000000..20f9166f --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,10 @@ +clear-prexisting: true +include-title: true +label-for-breaking-changes: "breaking change" +label-mapping: + bug: ["fix"] + configuration: ["build", "ci"] + documentation: ["docs"] + enhancement: ["feat"] + misc: ["chore", "performance", "refactor", "style"] + test: ["test"] diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..aba438dc --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,20 @@ +name: "Check PR" + +on: + pull_request: + types: [opened, edited, reopened, labeled, unlabeled] + +jobs: + lint-pr-name: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + label-pr: + runs-on: ubuntu-latest + steps: + - uses: grafana/pr-labeler-action@v0.1.0 + with: + token: ${{ secrets.MIDEA_GITHUB_PAT }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index abc7305f..00000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Lint PR" - -on: - pull_request: - types: [opened, edited, reopened] - -permissions: - pull-requests: read - -jobs: - main: - name: Validate PR title - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}