Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions src/pymelcloud/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""MEL API access."""
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from aiohttp import ClientResponseError, ContentTypeError

from aiohttp import ClientSession

Expand Down Expand Up @@ -172,14 +173,26 @@ 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()
try:
data = await resp.json()
except ContentTypeError:
return None
if isinstance(data, dict):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there also a more thorough way to determine the a successful call of this type? HTTP statuscode or content-type returned?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status code is checked by raise_for_status. I will add an additional check for content-type. Thank you for the idea

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.
Expand All @@ -197,23 +210,35 @@ 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()
try:
data = await resp.json()
except ContentTypeError:
return None
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.
Expand Down
1 change: 1 addition & 0 deletions src/pymelcloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DEVICE_TYPE_UNKNOWN = "unknown"

ACCESS_LEVEL = {
"USER": 2,
"GUEST": 3,
"OWNER": 4,
}
Expand Down
12 changes: 5 additions & 7 deletions src/pymelcloud/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand this correctly, removing the check if the access level is not guest, will allow the usage for delegated devices?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it because a try-catch block covers this case

self._device_units = await self._client.fetch_device_units(self)

async def set(self, properties: Dict[str, Any]):
Expand Down
12 changes: 1 addition & 11 deletions tests/test_ata_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
63 changes: 63 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Client tests."""
import pytest
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientResponseError, ClientSession
from pymelcloud.client import Client


@pytest.mark.asyncio
async def test_fetch_energy_report_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/EnergyCost/Report"

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

device = DummyDevice()
result = await client.fetch_energy_report(device)
assert result is None


@pytest.mark.asyncio
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"

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

device = DummyDevice()
result = await client.fetch_device_units(device)
assert result is None