Skip to content

Commit abeb861

Browse files
author
Vilppu Vuorinen
committed
Add consumption report based daily energy consumption
1 parent e5159b1 commit abeb861

File tree

9 files changed

+152
-78
lines changed

9 files changed

+152
-78
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Add report based daily energy consumption for all devices.
10+
811
### Changed
912
- Guard against zero Ata device energy meter reading. Latest firmware returns occasional zeroes breaking energy consumption integrations.
1013
- Round temperatures being set to the nearest temperature_increment using round half up.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ Available properties:
4545
* `temp_unit`
4646
* `last_seen`
4747
* `power`
48+
* `daily_energy_consumed`
49+
* `wifi_signal`
50+
4851

4952
Other properties are available through `_` prefixed state objects if
5053
one has the time to go through the source.

pymelcloud/client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,27 @@ async def fetch_device_state(self, device) -> Optional[Dict[Any, Any]]:
196196
) as resp:
197197
return await resp.json()
198198

199+
async def fetch_energy_report(self, device) -> Optional[Dict[Any, Any]]:
200+
"""Fetch energy report for the current day.
201+
202+
The energy report rolls over at midnight MELCloud time.
203+
"""
204+
device_id = device.device_id
205+
today_str = datetime.today().strftime("%Y-%m-%d")
206+
tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y-%m-%d")
207+
async with self._session.post(
208+
f"{BASE_URL}/EnergyCost/Report",
209+
headers=_headers(self._token),
210+
json={
211+
"DeviceId": device_id,
212+
"UseCurrency": False,
213+
"FromDate": f"{today_str}T00:00:00",
214+
"ToDate": f"{tomorrow_str}T00:00:00"
215+
},
216+
raise_for_status=True,
217+
) as resp:
218+
return await resp.json()
219+
199220
async def set_device_state(self, device):
200221
"""Update device state.
201222

pymelcloud/device.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ async def update(self):
9393
and c.get("BuildingID") == self.building_id
9494
)
9595
self._state = await self._client.fetch_device_state(self)
96+
self._energy_report = await self._client.fetch_energy_report(self)
9697

9798
if self._device_units is None and self.access_level != ACCESS_LEVEL.get(
9899
"GUEST"
@@ -194,6 +195,21 @@ def power(self) -> Optional[bool]:
194195
return None
195196
return self._state.get("Power")
196197

198+
@property
199+
def daily_energy_consumed(self) -> Optional[float]:
200+
"""Return daily energy consumption for the current day in kWh.
201+
202+
The value resets at midnight MELCloud time.
203+
"""
204+
if self._energy_report is None:
205+
return None
206+
return (self._energy_report.get("TotalHeatingConsumed", 0.0)
207+
+ self._energy_report.get("TotalCoolingConsumed", 0.0)
208+
+ self._energy_report.get("TotalAutoConsumed", 0.0)
209+
+ self._energy_report.get("TotalDryConsumed", 0.0)
210+
+ self._energy_report.get("TotalFanConsumed", 0.0)
211+
+ self._energy_report.get("TotalOtherConsumed", 0.0))
212+
197213
@property
198214
def wifi_signal(self) -> Optional[int]:
199215
"""Return wifi signal in dBm (negative value)."""

tests/test_ata_properties.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def _build_device(device_conf_name: str, device_state_name: str) -> AtaDevice:
4747
_client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__())
4848
_client.fetch_device_units = CoroutineMock(return_value=[])
4949
_client.fetch_device_state = CoroutineMock(return_value=device_state)
50+
_client.fetch_energy_report = CoroutineMock(return_value=None)
5051
client = _client
5152

5253
return AtaDevice(device_conf, client)

tests/test_device.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Device tests."""
2+
from typing import Any, Dict, Optional
23

34
import pytest
45
from pymelcloud.ata_device import AtaDevice
56
from .util import build_device
67

78

8-
def _build_device(device_conf_name: str, device_state_name: str) -> AtaDevice:
9-
device_conf, client = build_device(device_conf_name, device_state_name)
9+
def _build_device(device_conf_name: str, device_state_name: str, energy_report: Optional[Dict[Any, Any]]=None) -> AtaDevice:
10+
device_conf, client = build_device(device_conf_name, device_state_name, energy_report)
1011
return AtaDevice(device_conf, client)
1112

1213

@@ -39,3 +40,29 @@ async def test_round_temperature():
3940
assert device.round_temperature(25.49999) == 25.0
4041
assert device.round_temperature(25.5) == 26.0
4142

43+
@pytest.mark.asyncio
44+
async def test_energy_report_none_if_no_report():
45+
device = _build_device("ata_listdevice.json", "ata_get.json")
46+
47+
await device.update()
48+
49+
assert device.daily_energy_consumed == None
50+
51+
@pytest.mark.asyncio
52+
async def test_round_temperature():
53+
device = _build_device(
54+
"ata_listdevice.json",
55+
"ata_get.json",
56+
{
57+
"TotalHeatingConsumed": 1.0,
58+
"TotalCoolingConsumed": 0.0,
59+
"TotalAutoConsumed": 2.0,
60+
"TotalDryConsumed": 0.0,
61+
"TotalFanConsumed": 3.0,
62+
},
63+
)
64+
65+
await device.update()
66+
67+
assert device.daily_energy_consumed == 6.0
68+

tests/test_erv_properties.py

Lines changed: 75 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,79 @@
1-
"""ERV tests."""
2-
import json
3-
import os
4-
5-
import pytest
6-
from asynctest import CoroutineMock, Mock, patch
7-
from pymelcloud import DEVICE_TYPE_ERV
8-
from pymelcloud.erv_device import (
9-
VENTILATION_MODE_AUTO,
10-
VENTILATION_MODE_BYPASS,
11-
VENTILATION_MODE_RECOVERY,
12-
ErvDevice,
13-
)
14-
15-
16-
def _build_device(device_conf_name: str, device_state_name: str) -> ErvDevice:
17-
test_dir = os.path.join(os.path.dirname(__file__), "samples")
18-
with open(os.path.join(test_dir, device_conf_name), "r") as json_file:
19-
device_conf = json.load(json_file)
20-
21-
with open(os.path.join(test_dir, device_state_name), "r") as json_file:
22-
device_state = json.load(json_file)
23-
24-
with patch("pymelcloud.client.Client") as _client:
25-
_client.update_confs = CoroutineMock()
26-
_client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__())
27-
_client.fetch_device_units = CoroutineMock(return_value=[])
28-
_client.fetch_device_state = CoroutineMock(return_value=device_state)
29-
client = _client
30-
31-
return ErvDevice(device_conf, client)
32-
33-
@pytest.mark.asyncio
34-
async def test_erv():
35-
device = _build_device("erv_listdevice.json", "erv_get.json")
36-
37-
assert device.name == ""
38-
assert device.device_type == DEVICE_TYPE_ERV
39-
assert device.temperature_increment == 1.0
40-
assert device.has_energy_consumed_meter is True
41-
assert device.total_energy_consumed == 0.1
42-
assert device.room_temperature is None
43-
assert device.outside_temperature is None
44-
assert device.room_co2_level is None
45-
46-
assert device.ventilation_mode is None
47-
assert device.ventilation_modes == [
48-
VENTILATION_MODE_RECOVERY,
49-
VENTILATION_MODE_BYPASS,
50-
VENTILATION_MODE_AUTO,
51-
]
52-
assert device.actual_ventilation_mode is None
53-
assert device.fan_speed is None
54-
assert device.fan_speeds is None
55-
assert device.actual_supply_fan_speed is None
56-
assert device.actual_exhaust_fan_speed is None
57-
assert device.core_maintenance_required is False
58-
assert device.filter_maintenance_required is False
59-
assert device.night_purge_mode is False
60-
61-
await device.update()
62-
63-
assert device.room_temperature == 29.0
64-
assert device.outside_temperature == 28.0
1+
"""ERV tests."""
2+
import json
3+
import os
4+
5+
import pytest
6+
from asynctest import CoroutineMock, Mock, patch
7+
from pymelcloud import DEVICE_TYPE_ERV
8+
from pymelcloud.erv_device import (
9+
VENTILATION_MODE_AUTO,
10+
VENTILATION_MODE_BYPASS,
11+
VENTILATION_MODE_RECOVERY,
12+
ErvDevice,
13+
)
14+
15+
16+
def _build_device(device_conf_name: str, device_state_name: str) -> ErvDevice:
17+
test_dir = os.path.join(os.path.dirname(__file__), "samples")
18+
with open(os.path.join(test_dir, device_conf_name), "r") as json_file:
19+
device_conf = json.load(json_file)
20+
21+
with open(os.path.join(test_dir, device_state_name), "r") as json_file:
22+
device_state = json.load(json_file)
23+
24+
with patch("pymelcloud.client.Client") as _client:
25+
_client.update_confs = CoroutineMock()
26+
_client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__())
27+
_client.fetch_device_units = CoroutineMock(return_value=[])
28+
_client.fetch_device_state = CoroutineMock(return_value=device_state)
29+
_client.fetch_energy_report = CoroutineMock(return_value=None)
30+
client = _client
31+
32+
return ErvDevice(device_conf, client)
33+
34+
@pytest.mark.asyncio
35+
async def test_erv():
36+
device = _build_device("erv_listdevice.json", "erv_get.json")
37+
38+
assert device.name == ""
39+
assert device.device_type == DEVICE_TYPE_ERV
40+
assert device.temperature_increment == 1.0
41+
assert device.has_energy_consumed_meter is True
42+
assert device.total_energy_consumed == 0.1
43+
assert device.room_temperature is None
44+
assert device.outside_temperature is None
45+
assert device.room_co2_level is None
46+
47+
assert device.ventilation_mode is None
48+
assert device.ventilation_modes == [
49+
VENTILATION_MODE_RECOVERY,
50+
VENTILATION_MODE_BYPASS,
51+
VENTILATION_MODE_AUTO,
52+
]
53+
assert device.actual_ventilation_mode is None
54+
assert device.fan_speed is None
55+
assert device.fan_speeds is None
56+
assert device.actual_supply_fan_speed is None
57+
assert device.actual_exhaust_fan_speed is None
58+
assert device.core_maintenance_required is False
59+
assert device.filter_maintenance_required is False
60+
assert device.night_purge_mode is False
61+
62+
await device.update()
63+
64+
assert device.room_temperature == 29.0
65+
assert device.outside_temperature == 28.0
6566
assert device.room_co2_level is None
66-
67-
assert device.ventilation_mode == VENTILATION_MODE_RECOVERY
68-
assert device.actual_ventilation_mode == VENTILATION_MODE_RECOVERY
69-
assert device.fan_speed == "3"
70-
assert device.fan_speeds == ["1", "2", "3", "4"]
71-
assert device.actual_supply_fan_speed == "3"
72-
assert device.actual_exhaust_fan_speed == "3"
73-
assert device.core_maintenance_required is False
74-
assert device.filter_maintenance_required is False
75-
assert device.night_purge_mode is False
67+
68+
assert device.ventilation_mode == VENTILATION_MODE_RECOVERY
69+
assert device.actual_ventilation_mode == VENTILATION_MODE_RECOVERY
70+
assert device.fan_speed == "3"
71+
assert device.fan_speeds == ["1", "2", "3", "4"]
72+
assert device.actual_supply_fan_speed == "3"
73+
assert device.actual_exhaust_fan_speed == "3"
74+
assert device.core_maintenance_required is False
75+
assert device.filter_maintenance_required is False
76+
assert device.night_purge_mode is False
7677

7778
assert device.wifi_signal == -65
7879
assert device.has_error is False

tests/util.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import json
22
import os
3+
from typing import Any, Dict, Optional
34

45
from asynctest import CoroutineMock, Mock, patch
56

6-
def build_device(device_conf_name: str, device_state_name: str):
7+
def build_device(device_conf_name: str, device_state_name: str, energy_report: Optional[Dict[Any, Any]]=None):
78
test_dir = os.path.join(os.path.dirname(__file__), "samples")
89
with open(os.path.join(test_dir, device_conf_name), "r") as json_file:
910
device_conf = json.load(json_file)
@@ -16,6 +17,7 @@ def build_device(device_conf_name: str, device_state_name: str):
1617
_client.device_confs.__iter__ = Mock(return_value=[device_conf].__iter__())
1718
_client.fetch_device_units = CoroutineMock(return_value=[])
1819
_client.fetch_device_state = CoroutineMock(return_value=device_state)
20+
_client.fetch_energy_report = CoroutineMock(return_value=energy_report)
1921
client = _client
2022

2123
return device_conf, client

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tox]
2-
envlist=py36,py37,py38,flake8,typing
2+
envlist=py36,py37,py38,py39,flake8,typing
33

44
[testenv]
55
deps=

0 commit comments

Comments
 (0)