From 2c734e803ea1e09c74a2e0df306ec6067c2bc993 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sat, 30 Dec 2023 14:02:18 +0100 Subject: [PATCH 1/2] Added services for report & export, refactored tests, additional documentation on new services --- .pre-commit-config.yaml | 3 +- custom_components/mypyllant/__init__.py | 70 +++++++++++++++++++++- custom_components/mypyllant/const.py | 2 + custom_components/mypyllant/manifest.json | 4 +- custom_components/mypyllant/services.yaml | 54 +++++++++++++++++ dev-requirements.txt | 2 +- docs/docs/2-services.md | 6 ++ docs/docs/3-contributing.md | 32 ++++++---- tests/test_binary_sensor.py | 2 +- tests/test_climate.py | 2 +- tests/test_init.py | 9 ++- tests/test_quota.py | 2 +- tests/test_sensor.py | 2 +- tests/test_services.py | 71 +++++++++++++++++++++++ tests/test_water_heater.py | 2 +- tests/utils.py | 45 ++++++++++++++ 16 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 tests/test_services.py create mode 100644 tests/utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62b52d8..9edc89b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v0.1.9 hooks: - id: ruff + args: [ --fix ] files: ^(homeassistant|script|tests|custom_components)/.+\.py$ - id: ruff-format files: ^(homeassistant|script|tests|custom_components)/.+\.py$ @@ -34,7 +35,7 @@ repos: - aioresponses - pytest - types-requests - - myPyllant==0.6.5 + - myPyllant==0.6.7 - polyfactory - repo: local hooks: diff --git a/custom_components/mypyllant/__init__.py b/custom_components/mypyllant/__init__.py index 82389fe..60df919 100644 --- a/custom_components/mypyllant/__init__.py +++ b/custom_components/mypyllant/__init__.py @@ -5,7 +5,7 @@ from asyncio.exceptions import CancelledError from datetime import datetime, timedelta from typing import TypedDict - +import voluptuous as vol from aiohttp.client_exceptions import ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -15,7 +15,11 @@ ServiceCall, ServiceResponse, ) +from homeassistant.helpers import selector +from homeassistant.helpers.template import as_datetime + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from myPyllant import export, report from myPyllant.api import MyPyllantAPI from myPyllant.const import DEFAULT_BRAND @@ -33,6 +37,8 @@ OPTION_UPDATE_INTERVAL, QUOTA_PAUSE_INTERVAL, SERVICE_GENERATE_TEST_DATA, + SERVICE_EXPORT, + SERVICE_REPORT, ) from .utils import is_quota_exceeded_exception @@ -45,6 +51,11 @@ Platform.WATER_HEATER, ] +_DEVICE_DATA_BUCKET_RESOLUTION_OPTIONS = [ + selector.SelectOptionDict(value=v.value, label=v.value.title()) + for v in DeviceDataBucketResolution +] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if _LOGGER.isEnabledFor(logging.DEBUG): @@ -93,6 +104,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async def handle_export(call: ServiceCall) -> ServiceResponse: + return { + "export": await export.main( + user=username, + password=password, + brand=brand, + country=country, + data=call.data.get("data", False), + resolution=call.data.get("resolution", DeviceDataBucketResolution.DAY), + start=call.data.get("start"), + end=call.data.get("end"), + ) + } + async def handle_generate_test_data(call: ServiceCall) -> ServiceResponse: return await generate_test_data.main( user=username, @@ -102,12 +127,55 @@ async def handle_generate_test_data(call: ServiceCall) -> ServiceResponse: write_results=False, ) + async def handle_report(call: ServiceCall) -> ServiceResponse: + return { + f.file_name: f.file_content + async for f in report.main( + user=username, + password=password, + brand=brand, + country=country, + year=call.data.get("year"), + write_results=False, + ) + } + + hass.services.async_register( + DOMAIN, + SERVICE_EXPORT, + handle_export, + schema=vol.Schema( + { + vol.Optional("data"): bool, + vol.Optional("resolution"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=_DEVICE_DATA_BUCKET_RESOLUTION_OPTIONS, + mode=selector.SelectSelectorMode.LIST, + ), + ), + vol.Optional("start"): vol.Coerce(as_datetime), + vol.Optional("end"): vol.Coerce(as_datetime), + } + ), + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_GENERATE_TEST_DATA, handle_generate_test_data, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_REPORT, + handle_report, + schema=vol.Schema( + { + vol.Required("year", default=datetime.now().year): vol.Coerce(int), + } + ), + supports_response=SupportsResponse.ONLY, + ) return True diff --git a/custom_components/mypyllant/const.py b/custom_components/mypyllant/const.py index 35ad25e..1eca746 100644 --- a/custom_components/mypyllant/const.py +++ b/custom_components/mypyllant/const.py @@ -22,4 +22,6 @@ SERVICE_SET_DHW_TIME_PROGRAM = "set_dhw_time_program" SERVICE_SET_DHW_CIRCULATION_TIME_PROGRAM = "set_dhw_circulation_time_program" SERVICE_SET_VENTILATION_FAN_STAGE = "set_ventilation_fan_stage" +SERVICE_EXPORT = "export" SERVICE_GENERATE_TEST_DATA = "generate_test_data" +SERVICE_REPORT = "report" diff --git a/custom_components/mypyllant/manifest.json b/custom_components/mypyllant/manifest.json index fb3a3b4..9784328 100644 --- a/custom_components/mypyllant/manifest.json +++ b/custom_components/mypyllant/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/signalkraft/mypyllant-component/issues", - "requirements": ["myPyllant==0.6.5"], - "version": "v0.6.1" + "requirements": ["myPyllant==0.6.7"], + "version": "v0.7.0b3" } diff --git a/custom_components/mypyllant/services.yaml b/custom_components/mypyllant/services.yaml index e146e54..f3aeb89 100644 --- a/custom_components/mypyllant/services.yaml +++ b/custom_components/mypyllant/services.yaml @@ -218,3 +218,57 @@ set_dhw_circulation_time_program: sunday: - start_time: 450 end_time: 1260 + +export: + name: Export Data + description: Exports data from the mypyllant library + fields: + data: + name: Data + description: Whether to export device data (default off) + example: False + selector: + select: + options: + - True + - False + resolution: + name: Data Resolution + description: The time resolution of the data export (default DAY) + example: DAY + selector: + select: + options: + - "HOUR" + - "DAY" + - "MONTH" + start: + name: Start Date + description: Start date od the data export + example: '"2023-01-01 14:00:00"' + selector: + datetime: + end: + name: End Date + description: End date of the data export + example: '"2023-01-02 0:00:00"' + selector: + datetime: + +generate_test_data: + name: Generate Test Data + description: Generates test data for the mypyllant library and returns it as YAML + +report: + name: Export Yearly Energy Reports + description: Exports energy reports in CSV format per year + fields: + year: + name: Year + description: The year of the energy report + example: 2023 + required: true + selector: + number: + step: 1 + mode: box diff --git a/dev-requirements.txt b/dev-requirements.txt index 699ff39..4b151d9 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -13,5 +13,5 @@ ruff~=0.1.9 pytest==7.4.3 pytest-cov==4.1.0 pytest-homeassistant-custom-component==0.13.77 -myPyllant==0.6.5 +myPyllant==0.6.7 dacite~=1.7.0 \ No newline at end of file diff --git a/docs/docs/2-services.md b/docs/docs/2-services.md index b68e2ff..ea0d6b4 100644 --- a/docs/docs/2-services.md +++ b/docs/docs/2-services.md @@ -14,6 +14,12 @@ Search for "myvaillant" in Developer Tools > Services in your Home Assistant ins [![Open your Home Assistant instance and show your service developer tools with a specific service selected.](https://my.home-assistant.io/badges/developer_call_service.svg)](https://my.home-assistant.io/redirect/developer_call_service/?service=mypyllant.set_holiday) +## Exporting Data + +* `mypyllant.report` for exporting yearly energy reports (in CSV format) +* `mypyllant.export` for exporting raw data of your system +* `mypyllant.generate_test_data` for generating test data to contribute to the [myPyllant library](https://github.com/signalkraft/mypyllant) + ## Setting a Time Program The following services can be used to set time programs: diff --git a/docs/docs/3-contributing.md b/docs/docs/3-contributing.md index 7a76bfb..495c621 100644 --- a/docs/docs/3-contributing.md +++ b/docs/docs/3-contributing.md @@ -103,18 +103,28 @@ Copy the resulting dictionary into [https://github.com/signalkraft/myPyllant/blo Because the myVAILLANT API isn't documented, you can help the development of this library by contributing test data: -```shell -python3 -m myPyllant.tests.generate_test_data -h -python3 -m myPyllant.tests.generate_test_data username password brand --country country -``` +=== "Home Assistant Service" + + [![Open your Home Assistant instance and show your service developer tools with a specific service selected.](https://my.home-assistant.io/badges/developer_call_service.svg)](https://my.home-assistant.io/redirect/developer_call_service/?service=mypyllant.set_holiday) + + Select `mypyllant.generate_test_data` and call the service. + +=== "Shell" + + ```shell + python3 -m myPyllant.tests.generate_test_data -h + python3 -m myPyllant.tests.generate_test_data username password brand --country country + ``` + +=== "Docker" + + ```shell + docker run -v $(pwd)/test_data:/build/src/myPyllant/tests/json -ti ghcr.io/signalkraft/mypyllant:latest python3 -m myPyllant.tests.generate_test_data username password brand --country country + ``` + + With docker, the results will be put into `test_data/`. -..or use Docker: - -```shell -docker run -v $(pwd)/test_data:/build/src/myPyllant/tests/json -ti ghcr.io/signalkraft/mypyllant:latest python3 -m myPyllant.tests.generate_test_data username password brand --country country -``` - -With docker, the results will be put into `test_data/`. +--- You can then either create a PR with the created folder, or zip it and [attach it to an issue](https://github.com/signalkraft/myPyllant/issues/new). diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index cdbffcd..73f6c25 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -1,7 +1,7 @@ import pytest from myPyllant.api import MyPyllantAPI from myPyllant.models import System -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant.binary_sensor import ( CircuitEntity, diff --git a/tests/test_climate.py b/tests/test_climate.py index 66b035b..d273b7c 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -4,7 +4,7 @@ from homeassistant.const import ATTR_TEMPERATURE from myPyllant.api import MyPyllantAPI from myPyllant.const import DEFAULT_QUICK_VETO_DURATION -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant import SystemCoordinator from custom_components.mypyllant.climate import VentilationClimate, ZoneClimate diff --git a/tests/test_init.py b/tests/test_init.py index 2d9c1fb..48d7fdf 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_registry import DATA_REGISTRY, EntityRegistry from homeassistant.loader import DATA_COMPONENTS, DATA_INTEGRATIONS from myPyllant.api import MyPyllantAPI -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant import DOMAIN, async_setup_entry, async_unload_entry from custom_components.mypyllant.config_flow import DATA_SCHEMA @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) -data = { +test_user_input = { "username": "username", "password": "password", "country": "germany", @@ -57,7 +57,7 @@ async def test_user_flow_minimum_fields(hass: HomeAssistant): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=data, + user_input=test_user_input, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -68,7 +68,6 @@ async def test_async_setup( hass, mypyllant_aioresponses, mocked_api: MyPyllantAPI, - system_coordinator_mock, test_data, ): hass.data[DATA_COMPONENTS] = {} @@ -78,7 +77,7 @@ async def test_async_setup( config_entry = MockConfigEntry( domain=DOMAIN, title="Mock Title", - data=data, + data=test_user_input, options=TEST_OPTIONS, ) mock.patch("myPyllant.api.MyPyllantAPI", mocked_api) diff --git a/tests/test_quota.py b/tests/test_quota.py index 4ba4088..09bcfdf 100644 --- a/tests/test_quota.py +++ b/tests/test_quota.py @@ -7,7 +7,7 @@ from freezegun import freeze_time from homeassistant.helpers.update_coordinator import UpdateFailed from myPyllant.api import MyPyllantAPI -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant import API_DOWN_PAUSE_INTERVAL, QUOTA_PAUSE_INTERVAL diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 40036db..430a047 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,7 +1,7 @@ import pytest as pytest from myPyllant.api import MyPyllantAPI from myPyllant.models import CircuitState, DeviceData -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant.sensor import ( CircuitFlowTemperatureSensor, diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..cd016ea --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,71 @@ +import json + +import pytest + +from custom_components.mypyllant import ( + SERVICE_GENERATE_TEST_DATA, + SERVICE_EXPORT, +) +from myPyllant.api import MyPyllantAPI +from myPyllant.tests.utils import list_test_data + +from tests.utils import call_service + + +@pytest.mark.parametrize("test_data", list_test_data()) +@pytest.mark.enable_socket +async def test_service_generate_test_data( + hass, + mypyllant_aioresponses, + mocked_api: MyPyllantAPI, + system_coordinator_mock, + test_data, +): + result = await call_service( + SERVICE_GENERATE_TEST_DATA, + {"blocking": True, "return_response": True}, + hass, + mypyllant_aioresponses, + mocked_api, + system_coordinator_mock, + test_data, + ) + if "homes" not in result: + await mocked_api.aiohttp_session.close() + pytest.skip("No home data") + assert "homes" in result + assert all(h["systemId"] in result for h in result["homes"]) + await mocked_api.aiohttp_session.close() + + +@pytest.mark.parametrize("test_data", list_test_data()) +async def test_service_export( + hass, + mypyllant_aioresponses, + mocked_api: MyPyllantAPI, + system_coordinator_mock, + test_data, +): + result = await call_service( + SERVICE_EXPORT, + {"blocking": True, "return_response": True}, + hass, + mypyllant_aioresponses, + mocked_api, + system_coordinator_mock, + test_data, + ) + assert isinstance(result, dict) + assert isinstance(result["export"], list) + assert "home" in result["export"][0] + + assert isinstance( + json.dumps( + result, + indent=2, + default=str, + ), + str, + ) + + await mocked_api.aiohttp_session.close() diff --git a/tests/test_water_heater.py b/tests/test_water_heater.py index 5c1d3d8..c57a068 100644 --- a/tests/test_water_heater.py +++ b/tests/test_water_heater.py @@ -2,7 +2,7 @@ from homeassistant.const import ATTR_TEMPERATURE from myPyllant.api import MyPyllantAPI from myPyllant.models import DHWOperationMode -from myPyllant.tests.test_api import list_test_data +from myPyllant.tests.utils import list_test_data from custom_components.mypyllant.water_heater import DomesticHotWaterEntity diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..62e10f9 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,45 @@ +from unittest import mock + +from custom_components.mypyllant import ( + DOMAIN, + async_setup_entry, +) +from tests.conftest import MockConfigEntry, TEST_OPTIONS +from tests.test_init import test_user_input + + +async def call_service( + service_name, + service_kwargs, + hass, + mypyllant_aioresponses, + mocked_api, + system_coordinator_mock, + test_data, +): + with mypyllant_aioresponses(test_data) as _: + system_coordinator_mock.data = ( + await system_coordinator_mock._async_update_data() + ) + with mock.patch( + "custom_components.mypyllant.MyPyllantAPI", + side_effect=lambda *args, **kwargs: mocked_api, + ), mock.patch( + "custom_components.mypyllant.SystemCoordinator", + side_effect=lambda *args: system_coordinator_mock, + ), mock.patch("custom_components.mypyllant.PLATFORMS", []): + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Mock Title", + data=test_user_input, + options=TEST_OPTIONS, + ) + config_entry.add_to_hass(hass) + await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + result = await hass.services.async_call( + DOMAIN, + service_name, + **service_kwargs, + ) + return result From 7c48b2b4ca1f745b9ee715fecc13fbc762fde260 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sat, 30 Dec 2023 14:06:39 +0100 Subject: [PATCH 2/2] boolean selector for export.data --- custom_components/mypyllant/services.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/custom_components/mypyllant/services.yaml b/custom_components/mypyllant/services.yaml index f3aeb89..e09d077 100644 --- a/custom_components/mypyllant/services.yaml +++ b/custom_components/mypyllant/services.yaml @@ -226,12 +226,8 @@ export: data: name: Data description: Whether to export device data (default off) - example: False selector: - select: - options: - - True - - False + boolean: resolution: name: Data Resolution description: The time resolution of the data export (default DAY)