From 6c3992ecb50d28cfe4cd9f8db79d63e23e955bec Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 18 Sep 2024 00:30:05 +0200 Subject: [PATCH] fix: enhance schedule handling and cooling (#511) --- .pre-commit-config.yaml | 2 +- fixtures/homesdata.json | 2 +- pyproject.toml | 8 +++++ src/pyatmo/account.py | 4 +-- src/pyatmo/auth.py | 4 +-- src/pyatmo/const.py | 15 +++++--- src/pyatmo/helpers.py | 2 +- src/pyatmo/home.py | 29 ++++++++++++++-- src/pyatmo/modules/base_class.py | 4 +-- src/pyatmo/modules/device_types.py | 4 +-- src/pyatmo/modules/module.py | 55 ++++++++++++++++-------------- src/pyatmo/modules/netatmo.py | 2 +- src/pyatmo/room.py | 51 ++++++++++++++++++++++++++- src/pyatmo/schedule.py | 18 +++++++++- tests/testing_main_template.py | 2 +- 15 files changed, 154 insertions(+), 48 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 21375531..d201ad09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ exclude: ^(fixtures/) repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.6.5 hooks: - id: ruff args: diff --git a/fixtures/homesdata.json b/fixtures/homesdata.json index e5623ad4..6b038798 100644 --- a/fixtures/homesdata.json +++ b/fixtures/homesdata.json @@ -657,7 +657,7 @@ "name": "Default", "selected": true, "id": "591b54a2764ff4d50d8b5795", - "type": "therm" + "type": "cooling" }, { "zones": [ diff --git a/pyproject.toml b/pyproject.toml index 985ebfaf..1e54b360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,14 @@ ignore = [ "UP007", # keep type annotation style as is # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + # need cleanup + "FBT001", + "FBT002", + "FBT003", + "DTZ006", + "DTZ005", + "PGH003", + "ANN401", ] [tool.ruff.lint.flake8-pytest-style] diff --git a/src/pyatmo/account.py b/src/pyatmo/account.py index 019b79ff..1e5e4b0d 100644 --- a/src/pyatmo/account.py +++ b/src/pyatmo/account.py @@ -34,7 +34,7 @@ class AsyncAccount: def __init__( self, auth: AbstractAsyncAuth, - favorite_stations: bool = True, # noqa: FBT001, FBT002 + favorite_stations: bool = True, ) -> None: """Initialize the Netatmo account.""" @@ -148,7 +148,7 @@ def register_public_weather_area( lat_sw: str, lon_sw: str, required_data_type: str | None = None, - filtering: bool = False, # noqa: FBT001, FBT002 + filtering: bool = False, *, area_id: str = str(uuid4()), ) -> str: diff --git a/src/pyatmo/auth.py b/src/pyatmo/auth.py index 0afde5a6..04c2ab40 100644 --- a/src/pyatmo/auth.py +++ b/src/pyatmo/auth.py @@ -67,7 +67,7 @@ async def async_get_image( url = (base_url or self.base_url) + endpoint async with self.websession.get( url, - **req_args, # type: ignore # noqa: PGH003 + **req_args, # type: ignore headers=headers, timeout=ClientTimeout(total=timeout), ) as resp: @@ -115,7 +115,7 @@ async def async_post_request( async with self.websession.post( url, - **req_args, # type: ignore # noqa: PGH003 + **req_args, # type: ignore headers=headers, timeout=ClientTimeout(total=timeout), ) as resp: diff --git a/src/pyatmo/const.py b/src/pyatmo/const.py index 71402d45..681552ac 100644 --- a/src/pyatmo/const.py +++ b/src/pyatmo/const.py @@ -81,12 +81,20 @@ "write_thermostat", # Netatmo climate products ] +EVENTS = "events" +SCHEDULES = "schedules" + MANUAL = "manual" MAX = "max" HOME = "home" FROSTGUARD = "hg" -SCHEDULES = "schedules" -EVENTS = "events" +SCHEDULE = "schedule" +OFF = "off" +AWAY = "away" + +HEATING = "heating" +COOLING = "cooling" +IDLE = "idle" STATION_TEMPERATURE_TYPE = "temperature" STATION_PRESSURE_TYPE = "pressure" @@ -106,6 +114,3 @@ MAX_HISTORY_TIME_FRAME = 24 * 2 * 3600 UNKNOWN = "unknown" - -ON = True -OFF = False diff --git a/src/pyatmo/helpers.py b/src/pyatmo/helpers.py index a778449d..68b3218c 100644 --- a/src/pyatmo/helpers.py +++ b/src/pyatmo/helpers.py @@ -33,7 +33,7 @@ def fix_id(raw_data: RawData) -> dict[str, Any]: return raw_data -def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: # noqa: ANN401 +def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: """Extract raw data from server response.""" raw_data = {} diff --git a/src/pyatmo/home.py b/src/pyatmo/home.py index 20240ece..8123d83b 100644 --- a/src/pyatmo/home.py +++ b/src/pyatmo/home.py @@ -27,7 +27,7 @@ from pyatmo.modules.netatmo import NACamera from pyatmo.person import Person from pyatmo.room import Room -from pyatmo.schedule import Schedule +from pyatmo.schedule import Schedule, ScheduleType if TYPE_CHECKING: from aiohttp import ClientResponse @@ -38,6 +38,12 @@ LOG = logging.getLogger(__name__) +SCHEDULE_TYPE_MAPPING = { + "heating": ScheduleType.THERM, + "cooling": ScheduleType.COOLING, +} + + class Home: """Class to represent a Netatmo home.""" @@ -151,7 +157,7 @@ def update_topology(self, raw_data: RawData) -> None: async def update( self, raw_data: RawData, - do_raise_for_reachability_error: bool = False, # noqa: FBT002, FBT001 + do_raise_for_reachability_error: bool = False, ) -> None: """Update home with the latest data.""" has_error = False @@ -210,10 +216,27 @@ def get_selected_schedule(self) -> Schedule | None: """Return selected schedule for given home.""" return next( - (schedule for schedule in self.schedules.values() if schedule.selected), + ( + schedule + for schedule in self.schedules.values() + if schedule.selected + and self.temperature_control_mode + and schedule.type + == SCHEDULE_TYPE_MAPPING[self.temperature_control_mode] + ), None, ) + def get_available_schedules(self) -> list[Schedule]: + """Return available schedules for given home.""" + + return [ + schedule + for schedule in self.schedules.values() + if self.temperature_control_mode + and schedule.type == SCHEDULE_TYPE_MAPPING[self.temperature_control_mode] + ] + def is_valid_schedule(self, schedule_id: str) -> bool: """Check if valid schedule.""" diff --git a/src/pyatmo/modules/base_class.py b/src/pyatmo/modules/base_class.py index 2e59f7ca..f536e51a 100644 --- a/src/pyatmo/modules/base_class.py +++ b/src/pyatmo/modules/base_class.py @@ -37,7 +37,7 @@ } -def default(key: str, val: Any) -> Any: # noqa: ANN401 +def default(key: str, val: Any) -> Any: """Return default value.""" return lambda x, _: x.get(key, val) @@ -103,7 +103,7 @@ def _update_attributes(self, raw_data: RawData) -> None: def add_history_data( self, feature: str, - value: Any, # noqa: ANN401 + value: Any, time: int, ) -> None: """Add historical data at the given time.""" diff --git a/src/pyatmo/modules/device_types.py b/src/pyatmo/modules/device_types.py index 4b904774..f7615e98 100644 --- a/src/pyatmo/modules/device_types.py +++ b/src/pyatmo/modules/device_types.py @@ -4,7 +4,7 @@ from enum import Enum import logging -from typing import Literal +from typing import Any, Literal LOG = logging.getLogger(__name__) @@ -117,7 +117,7 @@ class DeviceType(str, Enum): # pylint: enable=C0103 @classmethod - def _missing_(cls, key) -> Literal[DeviceType.NLunknown]: # noqa: ANN001 + def _missing_(cls, key: Any) -> Literal[DeviceType.NLunknown]: """Handle unknown device types.""" msg = f"{key} device is unknown" diff --git a/src/pyatmo/modules/module.py b/src/pyatmo/modules/module.py index c3d19a7e..9d68cc3c 100644 --- a/src/pyatmo/modules/module.py +++ b/src/pyatmo/modules/module.py @@ -9,7 +9,7 @@ from aiohttp import ClientConnectorError -from pyatmo.const import GETMEASURE_ENDPOINT, OFF, ON, RawData +from pyatmo.const import GETMEASURE_ENDPOINT, RawData from pyatmo.exceptions import ApiError from pyatmo.modules.base_class import EntityBase, NetatmoBase, Place, update_name from pyatmo.modules.device_types import DEVICE_CATEGORY_MAP, DeviceCategory, DeviceType @@ -23,8 +23,8 @@ LOG = logging.getLogger(__name__) -ModuleT = dict[str, Any] +ModuleT = dict[str, Any] # Hide from features list ATTRIBUTE_FILTER = { "battery_state", @@ -226,6 +226,7 @@ def __init__(self, home: Home, module: ModuleT) -> None: super().__init__(home, module) # type: ignore # mypy issue 4335 self.boiler_status: bool | None = None + self.boiler_valve_comfort_boost: bool | None = None class CoolerMixin(EntityBase): @@ -354,7 +355,7 @@ def __init__(self, home: Home, module: ModuleT) -> None: super().__init__(home, module) # type: ignore # mypy issue 4335 self.on: bool | None = None - async def async_set_switch(self, target_position: bool) -> bool: # noqa: FBT001 + async def async_set_switch(self, target_position: bool) -> bool: """Set switch to target position.""" json_switch = { @@ -371,12 +372,12 @@ async def async_set_switch(self, target_position: bool) -> bool: # noqa: FBT001 async def async_on(self) -> bool: """Switch on.""" - return await self.async_set_switch(ON) + return await self.async_set_switch(True) async def async_off(self) -> bool: """Switch off.""" - return await self.async_set_switch(OFF) + return await self.async_set_switch(False) class FanSpeedMixin(EntityBase): @@ -657,7 +658,7 @@ class MeasureType(Enum): def compute_riemann_sum( power_data: list[tuple[int, float]], - conservative: bool = False, # noqa: FBT001, FBT002 + conservative: bool = False, ) -> float: """Compute energy from power with a rieman sum.""" @@ -690,7 +691,7 @@ class EnergyHistoryMixin(EntityBase): def __init__(self, home: Home, module: ModuleT) -> None: """Initialize history mixin.""" - super().__init__(home, module) # type: ignore # mypy issue 4335 + super().__init__(home, module) # type: ignore self.historical_data: list[dict[str, Any]] | None = None self.start_time: float | None = None self.end_time: float | None = None @@ -704,7 +705,7 @@ def __init__(self, home: Home, module: ModuleT) -> None: def reset_measures( self, start_power_time: datetime, - in_reset: bool = True, # noqa: FBT001, FBT002 + in_reset: bool = True, ) -> None: """Reset energy measures.""" self.in_reset = in_reset @@ -720,7 +721,7 @@ def reset_measures( def get_sum_energy_elec_power_adapted( self, to_ts: float | None = None, - conservative: bool = False, # noqa: FBT001, FBT002 + conservative: bool = False, ) -> tuple[None, float] | tuple[float, float]: """Compute proper energy value with adaptation from power.""" v = self.sum_energy_elec @@ -766,8 +767,8 @@ def _log_energy_error( "ENERGY collection error %s %s %s %s %s %s %s", msg, self.name, - datetime.fromtimestamp(start_time), # noqa: DTZ006 - datetime.fromtimestamp(end_time), # noqa: DTZ006 + datetime.fromtimestamp(start_time), + datetime.fromtimestamp(end_time), start_time, end_time, body or "NO BODY", @@ -783,10 +784,10 @@ async def async_update_measures( """Update historical data.""" if end_time is None: - end_time = int(datetime.now().timestamp()) # noqa: DTZ005 + end_time = int(datetime.now().timestamp()) if start_time is None: - end = datetime.fromtimestamp(end_time) # noqa: DTZ006 + end = datetime.fromtimestamp(end_time) start_time = int((end - timedelta(days=days)).timestamp()) prev_start_time = self.start_time @@ -831,8 +832,8 @@ async def async_update_measures( LOG.debug( "NO VALUES energy update %s from: %s to %s, prev_sum=%s", self.name, - datetime.fromtimestamp(start_time), # noqa: DTZ006 - datetime.fromtimestamp(end_time), # noqa: DTZ006 + datetime.fromtimestamp(start_time), + datetime.fromtimestamp(end_time), prev_sum_energy_elec if prev_sum_energy_elec is not None else "NOTHING", ) else: @@ -912,14 +913,14 @@ async def _prepare_exported_historical_data( LOG.debug( msg, self.name, - datetime.fromtimestamp(start_time), # noqa: DTZ006 - datetime.fromtimestamp(end_time), # noqa: DTZ006 - datetime.fromtimestamp(computed_start), # noqa: DTZ006 - datetime.fromtimestamp(computed_end), # noqa: DTZ006 + datetime.fromtimestamp(start_time), + datetime.fromtimestamp(end_time), + datetime.fromtimestamp(computed_start), + datetime.fromtimestamp(computed_end), self.sum_energy_elec, prev_sum_energy_elec, - datetime.fromtimestamp(prev_start_time), # noqa: DTZ006 - datetime.fromtimestamp(prev_end_time), # noqa: DTZ006 + datetime.fromtimestamp(prev_start_time), + datetime.fromtimestamp(prev_end_time), ) else: msg = ( @@ -929,10 +930,10 @@ async def _prepare_exported_historical_data( LOG.debug( msg, self.name, - datetime.fromtimestamp(start_time), # noqa: DTZ006 - datetime.fromtimestamp(end_time), # noqa: DTZ006 - datetime.fromtimestamp(computed_start), # noqa: DTZ006 - datetime.fromtimestamp(computed_end), # noqa: DTZ006 + datetime.fromtimestamp(start_time), + datetime.fromtimestamp(end_time), + datetime.fromtimestamp(computed_start), + datetime.fromtimestamp(computed_end), self.sum_energy_elec, prev_sum_energy_elec if prev_sum_energy_elec is not None else "NOTHING", ) @@ -1161,4 +1162,8 @@ class Energy(EnergyHistoryMixin, Module): """Class to represent a Netatmo energy module.""" +class Boiler(BoilerMixin, Module): + """Class to represent a Netatmo boiler.""" + + # pylint: enable=too-many-ancestors diff --git a/src/pyatmo/modules/netatmo.py b/src/pyatmo/modules/netatmo.py index 297f758d..ac44120d 100644 --- a/src/pyatmo/modules/netatmo.py +++ b/src/pyatmo/modules/netatmo.py @@ -194,7 +194,7 @@ def __init__( lat_sw: str, lon_sw: str, required_data_type: str | None = None, - filtering: bool = False, # noqa: FBT001, FBT002 + filtering: bool = False, ) -> None: """Initialize self.""" diff --git a/src/pyatmo/room.py b/src/pyatmo/room.py index a07d0077..ff07ea72 100644 --- a/src/pyatmo/room.py +++ b/src/pyatmo/room.py @@ -4,18 +4,23 @@ from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pyatmo.const import ( + COOLING, FROSTGUARD, + HEATING, HOME, + IDLE, MANUAL, + OFF, SETROOMTHERMPOINT_ENDPOINT, UNKNOWN, RawData, ) from pyatmo.modules.base_class import NetatmoBase from pyatmo.modules.device_types import DeviceType +from pyatmo.modules.module import Boiler if TYPE_CHECKING: from pyatmo.home import Home @@ -228,3 +233,47 @@ async def _async_set_thermpoint( endpoint=SETROOMTHERMPOINT_ENDPOINT, params=post_params, ) + + @property + def boiler_status(self) -> bool | None: + """Return the boiler status.""" + + for module in self.modules.values(): + if hasattr(module, "boiler_status"): + module = cast(Boiler, module) + if (boiler_status := module.boiler_status) is not None: + return boiler_status + + return None + + @property + def setpoint_mode(self) -> str: + """Return the current setpoint mode.""" + + return self.therm_setpoint_mode or self.cooling_setpoint_mode or UNKNOWN + + @property + def setpoint_temperature(self) -> float | None: + """Return the current setpoint temperature.""" + + return ( + self.therm_setpoint_temperature or self.cooling_setpoint_temperature or None + ) + + @property + def hvac_action(self) -> str: + """Return the current HVAC action.""" + + if self.setpoint_mode == OFF: + return OFF + + if self.boiler_status is True: + return HEATING + + if self.heating_power_request is not None and self.heating_power_request > 0: + return HEATING + + if self.cooling_setpoint_temperature: + return COOLING + + return IDLE diff --git a/src/pyatmo/schedule.py b/src/pyatmo/schedule.py index 2df909a6..0d6d55ec 100644 --- a/src/pyatmo/schedule.py +++ b/src/pyatmo/schedule.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum import logging from typing import TYPE_CHECKING @@ -17,22 +18,37 @@ LOG = logging.getLogger(__name__) +class ScheduleType(StrEnum): + """Enum representing the type of a schedule.""" + + THERM = "therm" + COOLING = "cooling" + ELECTRICITY = "electricity" + EVENT = "event" + + @dataclass class Schedule(NetatmoBase): """Class to represent a Netatmo schedule.""" - selected: bool + type: ScheduleType away_temp: float | None hg_temp: float | None + cooling_away_temp: float | None timetable: list[TimetableEntry] + selected: bool + default: bool def __init__(self, home: Home, raw_data: RawData) -> None: """Initialize a Netatmo schedule instance.""" super().__init__(raw_data) self.home = home + self.type = ScheduleType(raw_data.get("type", ScheduleType.THERM)) + self.default = raw_data.get("default", False) self.selected = raw_data.get("selected", False) self.hg_temp = raw_data.get("hg_temp") self.away_temp = raw_data.get("away_temp") + self.cooling_away_temp = raw_data.get("cooling_away_temp") self.timetable = [ TimetableEntry(home, r) for r in raw_data.get("timetable", []) ] diff --git a/tests/testing_main_template.py b/tests/testing_main_template.py index b4e3e81f..393f1b11 100644 --- a/tests/testing_main_template.py +++ b/tests/testing_main_template.py @@ -27,7 +27,7 @@ async def main(): await account.async_update_status(home_id=home_id) - strt = 1709766000 + 10 * 60 # 1709421000+15*60 + strt = 1709766000 + 10 * 60 end = 1709852400 + 10 * 60 await account.async_update_measures( home_id=home_id,