From 71fe23e7f53d0c79a7b31831027924093c4c3165 Mon Sep 17 00:00:00 2001 From: Kanaduchi Date: Sat, 15 Nov 2025 22:33:45 +0000 Subject: [PATCH 1/4] #13 Refactor usage of roles to add support for delegated devices --- src/pymelcloud/client.py | 59 ++++++++++++++++++--------- src/pymelcloud/const.py | 1 + src/pymelcloud/device.py | 12 +++--- tests/test_ata_properties.py | 12 +----- tests/test_client.py | 79 ++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 38 deletions(-) create mode 100644 tests/test_client.py diff --git a/src/pymelcloud/client.py b/src/pymelcloud/client.py index b182362..c1cfd76 100644 --- a/src/pymelcloud/client.py +++ b/src/pymelcloud/client.py @@ -1,6 +1,7 @@ """MEL API access.""" from datetime import datetime, timedelta from typing import Any, Dict, List, Optional +from aiohttp import ClientResponseError from aiohttp import ClientSession @@ -172,14 +173,23 @@ async def fetch_device_units(self, device) -> Optional[Dict[Any, Any]]: User provided info such as indoor/outdoor unit model names and serial numbers. + If the request returns 403, then ignore it """ - async with self._session.post( - f"{BASE_URL}/Device/ListDeviceUnits", - headers=_headers(self._token), - json={"deviceId": device.device_id}, - raise_for_status=True, - ) as resp: - return await resp.json() + try: + async with self._session.post( + f"{BASE_URL}/Device/ListDeviceUnits", + headers=_headers(self._token), + json={"deviceId": device.device_id} + ) as resp: + resp.raise_for_status() + data = await resp.json() + if isinstance(data, dict): + return data + return None + except ClientResponseError as e: + if e.status == 403: + return None + raise async def fetch_device_state(self, device) -> Optional[Dict[Any, Any]]: """Fetch state information of a device. @@ -197,23 +207,32 @@ async def fetch_device_state(self, device) -> Optional[Dict[Any, Any]]: return await resp.json() async def fetch_energy_report(self, device) -> Optional[Dict[Any, Any]]: - """Fetch energy report containing today and 1-2 days from the past.""" + """Fetch energy report containing today and 1-2 days from the past. + If the request returns 403, then ignore it""" device_id = device.device_id from_str = (datetime.today() - timedelta(days=2)).strftime("%Y-%m-%d") to_str = (datetime.today() + timedelta(days=2)).strftime("%Y-%m-%d") - async with self._session.post( - f"{BASE_URL}/EnergyCost/Report", - headers=_headers(self._token), - json={ - "DeviceId": device_id, - "UseCurrency": False, - "FromDate": f"{from_str}T00:00:00", - "ToDate": f"{to_str}T00:00:00" - }, - raise_for_status=True, - ) as resp: - return await resp.json() + try: + async with self._session.post( + f"{BASE_URL}/EnergyCost/Report", + headers=_headers(self._token), + json={ + "DeviceId": device_id, + "UseCurrency": False, + "FromDate": f"{from_str}T00:00:00", + "ToDate": f"{to_str}T00:00:00" + } + ) as resp: + resp.raise_for_status() + data = await resp.json() + if isinstance(data, dict): + return data + return None + except ClientResponseError as e: + if e.status == 403: + return None + raise async def set_device_state(self, device): """Update device state. diff --git a/src/pymelcloud/const.py b/src/pymelcloud/const.py index eb12ee0..0bf3a5a 100644 --- a/src/pymelcloud/const.py +++ b/src/pymelcloud/const.py @@ -6,6 +6,7 @@ DEVICE_TYPE_UNKNOWN = "unknown" ACCESS_LEVEL = { + "USER": 2, "GUEST": 3, "OWNER": 4, } diff --git a/src/pymelcloud/device.py b/src/pymelcloud/device.py index c98eb18..336fd6d 100644 --- a/src/pymelcloud/device.py +++ b/src/pymelcloud/device.py @@ -24,10 +24,10 @@ class Device(ABC): """MELCloud base device representation.""" def __init__( - self, - device_conf: Dict[str, Any], - client: Client, - set_debounce=timedelta(seconds=1), + self, + device_conf: Dict[str, Any], + client: Client, + set_debounce=timedelta(seconds=1), ): """Initialize a device.""" self.device_id = device_conf.get("DeviceID") @@ -96,9 +96,7 @@ async def update(self): self._state = await self._client.fetch_device_state(self) self._energy_report = await self._client.fetch_energy_report(self) - if self._device_units is None and self.access_level != ACCESS_LEVEL.get( - "GUEST" - ): + if self._device_units is None: self._device_units = await self._client.fetch_device_units(self) async def set(self, properties: Dict[str, Any]): diff --git a/tests/test_ata_properties.py b/tests/test_ata_properties.py index 0801001..1072473 100644 --- a/tests/test_ata_properties.py +++ b/tests/test_ata_properties.py @@ -4,10 +4,9 @@ import pytest from unittest.mock import AsyncMock, Mock, patch -from aiohttp.web import HTTPForbidden + from src.pymelcloud import DEVICE_TYPE_ATA -import src.pymelcloud from src.pymelcloud.const import ACCESS_LEVEL from src.pymelcloud.ata_device import ( OPERATION_MODE_HEAT, @@ -92,12 +91,3 @@ async def test_ata(): assert device.wifi_signal == -51 assert device.has_error is False assert device.error_code == 8000 - - -@pytest.mark.asyncio -async def test_ata_guest(): - device = _build_device("ata_guest_listdevices.json", "ata_guest_get.json") - device._client.fetch_device_units = AsyncMock(side_effect=HTTPForbidden) - assert device.device_type == DEVICE_TYPE_ATA - assert device.access_level == ACCESS_LEVEL["GUEST"] - await device.update() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..ad7661b --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,79 @@ +"""Client tests.""" +import json +import os + +import pytest +from unittest.mock import AsyncMock, Mock, patch + +from aiohttp.web import HTTPForbidden +from src.pymelcloud import DEVICE_TYPE_ATA + +from src.pymelcloud.const import ACCESS_LEVEL +from src.pymelcloud.ata_device import (AtaDevice) + + +def _build_device(device_conf_name: str, device_state_name: str) -> AtaDevice: + test_dir = os.path.join(os.path.dirname(__file__), "samples") + with open(os.path.join(test_dir, device_conf_name), "r") as json_file: + device_conf = json.load(json_file) + + with open(os.path.join(test_dir, device_state_name), "r") as json_file: + device_state = json.load(json_file) + + with patch("src.pymelcloud.client.Client") as _client: + _client.update_confs = AsyncMock() + _client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__()) + _client.fetch_device_units = AsyncMock(return_value=[]) + _client.fetch_device_state = AsyncMock(return_value=device_state) + _client.fetch_energy_report = AsyncMock(return_value=None) + client = _client + + return AtaDevice(device_conf, client) + + +@pytest.mark.asyncio +async def test_ata_guest(): + device = _build_device("ata_guest_listdevices.json", "ata_guest_get.json") + assert device.device_type == DEVICE_TYPE_ATA + assert device.access_level == ACCESS_LEVEL["GUEST"] + + request_info = Mock() + request_info.real_url = "https://example.test/Device/ListDeviceUnits" + + device._client.fetch_device_units = AsyncMock(side_effect=HTTPForbidden) + + with pytest.raises(HTTPForbidden) as exc: + await device.update() + assert exc.value.status == 403 + + +@pytest.mark.asyncio +async def test_ata_energy_report_403(): + device = _build_device("ata_listdevice.json", "ata_get.json") + device._client.fetch_device_state = AsyncMock(return_value={}) + device._client.fetch_device_units = AsyncMock(return_value=None) + + request_info = Mock() + request_info.real_url = "https://example.test/EnergyCost/Report" + + device._client.fetch_energy_report = AsyncMock(side_effect=HTTPForbidden) + + with pytest.raises(HTTPForbidden) as exc: + await device.update() + assert exc.value.status == 403 + + +@pytest.mark.asyncio +async def test_ata_device_units_403(): + device = _build_device("ata_listdevice.json", "ata_get.json") + assert device.access_level == ACCESS_LEVEL["OWNER"] + device._client.fetch_device_state = AsyncMock(return_value={}) + + request_info = Mock() + request_info.real_url = "https://example.test/Device/ListDeviceUnits" + + device._client.fetch_device_units = AsyncMock(side_effect=HTTPForbidden) + + with pytest.raises(HTTPForbidden) as exc: + await device.update() + assert exc.value.status == 403 From dcbbdbd9fa6febf7b4b05a7a1918ac1cd9f6b781 Mon Sep 17 00:00:00 2001 From: Kanaduchi Date: Sun, 16 Nov 2025 10:25:49 +0000 Subject: [PATCH 2/4] #13 Improve tests --- tests/test_client.py | 102 ++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 59 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index ad7661b..1a27a40 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,79 +1,63 @@ """Client tests.""" -import json -import os - import pytest from unittest.mock import AsyncMock, Mock, patch - -from aiohttp.web import HTTPForbidden -from src.pymelcloud import DEVICE_TYPE_ATA - -from src.pymelcloud.const import ACCESS_LEVEL -from src.pymelcloud.ata_device import (AtaDevice) - - -def _build_device(device_conf_name: str, device_state_name: str) -> AtaDevice: - test_dir = os.path.join(os.path.dirname(__file__), "samples") - with open(os.path.join(test_dir, device_conf_name), "r") as json_file: - device_conf = json.load(json_file) - - with open(os.path.join(test_dir, device_state_name), "r") as json_file: - device_state = json.load(json_file) - - with patch("src.pymelcloud.client.Client") as _client: - _client.update_confs = AsyncMock() - _client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__()) - _client.fetch_device_units = AsyncMock(return_value=[]) - _client.fetch_device_state = AsyncMock(return_value=device_state) - _client.fetch_energy_report = AsyncMock(return_value=None) - client = _client - - return AtaDevice(device_conf, client) +from aiohttp import ClientResponseError, ClientSession +from pymelcloud.client import Client @pytest.mark.asyncio -async def test_ata_guest(): - device = _build_device("ata_guest_listdevices.json", "ata_guest_get.json") - assert device.device_type == DEVICE_TYPE_ATA - assert device.access_level == ACCESS_LEVEL["GUEST"] - - request_info = Mock() - request_info.real_url = "https://example.test/Device/ListDeviceUnits" - - device._client.fetch_device_units = AsyncMock(side_effect=HTTPForbidden) - - with pytest.raises(HTTPForbidden) as exc: - await device.update() - assert exc.value.status == 403 +async def test_fetch_energy_report_ignores_403(): + session = Mock(spec=ClientSession) + cm = AsyncMock() + session.post.return_value = cm - -@pytest.mark.asyncio -async def test_ata_energy_report_403(): - device = _build_device("ata_listdevice.json", "ata_get.json") - device._client.fetch_device_state = AsyncMock(return_value={}) - device._client.fetch_device_units = AsyncMock(return_value=None) + resp = Mock() + cm.__aenter__.return_value = resp request_info = Mock() request_info.real_url = "https://example.test/EnergyCost/Report" - device._client.fetch_energy_report = AsyncMock(side_effect=HTTPForbidden) + resp.raise_for_status.side_effect = ClientResponseError( + request_info=request_info, + history=(), + status=403, + message="Forbidden", + ) - with pytest.raises(HTTPForbidden) as exc: - await device.update() - assert exc.value.status == 403 + client = Client(token="dummy", session=session) + + class DummyDevice: + device_id = 123 + + device = DummyDevice() + result = await client.fetch_device_units(device) + assert result is None @pytest.mark.asyncio -async def test_ata_device_units_403(): - device = _build_device("ata_listdevice.json", "ata_get.json") - assert device.access_level == ACCESS_LEVEL["OWNER"] - device._client.fetch_device_state = AsyncMock(return_value={}) +async def test_fetch_device_units_ignores_403(): + session = Mock(spec=ClientSession) + cm = AsyncMock() + session.post.return_value = cm + + resp = Mock() + cm.__aenter__.return_value = resp request_info = Mock() request_info.real_url = "https://example.test/Device/ListDeviceUnits" - device._client.fetch_device_units = AsyncMock(side_effect=HTTPForbidden) + resp.raise_for_status.side_effect = ClientResponseError( + request_info=request_info, + history=(), + status=403, + message="Forbidden", + ) + + client = Client(token="dummy", session=session) + + class DummyDevice: + device_id = 123 - with pytest.raises(HTTPForbidden) as exc: - await device.update() - assert exc.value.status == 403 + device = DummyDevice() + result = await client.fetch_device_units(device) + assert result is None From a3a9be657c3a65fcee1f5778ddbe2c95c72fdca8 Mon Sep 17 00:00:00 2001 From: Kanaduchi Date: Sun, 16 Nov 2025 10:27:26 +0000 Subject: [PATCH 3/4] #13 Improve tests --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 1a27a40..3db3d56 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,7 +30,7 @@ class DummyDevice: device_id = 123 device = DummyDevice() - result = await client.fetch_device_units(device) + result = await client.fetch_energy_report(device) assert result is None From 70fd85a2b7dfe60ee63be8041d9b96f87b5ae816 Mon Sep 17 00:00:00 2001 From: Kanaduchi Date: Wed, 31 Dec 2025 23:15:29 +0000 Subject: [PATCH 4/4] #13 Improve check for content-type --- src/pymelcloud/client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pymelcloud/client.py b/src/pymelcloud/client.py index c1cfd76..52e6eff 100644 --- a/src/pymelcloud/client.py +++ b/src/pymelcloud/client.py @@ -1,7 +1,7 @@ """MEL API access.""" from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from aiohttp import ClientResponseError +from aiohttp import ClientResponseError, ContentTypeError from aiohttp import ClientSession @@ -182,7 +182,10 @@ async def fetch_device_units(self, device) -> Optional[Dict[Any, Any]]: json={"deviceId": device.device_id} ) as resp: resp.raise_for_status() - data = await resp.json() + try: + data = await resp.json() + except ContentTypeError: + return None if isinstance(data, dict): return data return None @@ -225,7 +228,10 @@ async def fetch_energy_report(self, device) -> Optional[Dict[Any, Any]]: } ) as resp: resp.raise_for_status() - data = await resp.json() + try: + data = await resp.json() + except ContentTypeError: + return None if isinstance(data, dict): return data return None