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/CHANGELOG.md b/CHANGELOG.md index afc6b5d9..1d211454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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) + + +### 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/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/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/midealocal/version.py b/midealocal/version.py index 9dcf884c..601a5069 100644 --- a/midealocal/version.py +++ b/midealocal/version.py @@ -1,3 +1,3 @@ """Midea Local Version.""" -__version__ = "2.2.0" +__version__ = "2.4.0" 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() 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: