From b594ccd6da58c12c39d942f3ca52c0397f721602 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 16 Sep 2024 15:52:17 +0200 Subject: [PATCH] ruff and mypy clean up --- pyproject.toml | 83 +++------- src/pyatmo/account.py | 30 ++-- src/pyatmo/auth.py | 68 +++++--- src/pyatmo/const.py | 3 + src/pyatmo/event.py | 5 +- src/pyatmo/exceptions.py | 18 -- src/pyatmo/helpers.py | 14 +- src/pyatmo/home.py | 49 +++--- src/pyatmo/modules/base_class.py | 38 +++-- src/pyatmo/modules/device_types.py | 3 +- src/pyatmo/modules/idiamant.py | 8 - src/pyatmo/modules/module.py | 255 ++++++++++++++++------------- src/pyatmo/modules/netatmo.py | 38 +---- src/pyatmo/modules/somfy.py | 2 - src/pyatmo/person.py | 3 +- src/pyatmo/schedule.py | 5 +- tests/common.py | 3 +- tests/conftest.py | 14 +- tests/test_camera.py | 6 +- tests/test_climate.py | 40 ++--- tests/test_energy.py | 24 +-- tests/test_fan.py | 2 +- tests/test_home.py | 15 +- tests/test_shutter.py | 6 +- tests/test_switch.py | 4 +- tests/test_weather.py | 10 +- tests/testing_main_template.py | 4 - 27 files changed, 354 insertions(+), 396 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d528a67..e6ffb335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,66 +78,7 @@ fix = true line-length = 88 [tool.ruff.lint] -select = [ - "B002", # Python does not support the unary prefix increment - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "B023", # Function definition does not bind loop variable {name} - "B026", # Star-arg unpacking after a keyword argument is strongly discouraged - "C", # complexity - "COM818", # Trailing comma on bare tuple prohibited - "D", # docstrings - "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() - "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) - "E", # pycodestyle - "F", # pyflakes/autoflake - "G", # flake8-logging-format - "I", # isort - "ICN001", # import concentions; {name} should be imported as {asname} - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase - "S307", # No builtin eval() allowed - "PGH004", # Use specific rule codes when using noqa - "PLC0414", # Useless import alias. Import alias does not rename original package. - "PL", # pylint - "Q000", # Double quotes found but single quotes preferred - "RUF006", # Store a reference to the return value of asyncio.create_task - "S102", # Use of exec detected - "S103", # bad-file-permissions - "S108", # hardcoded-temp-file - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection - "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass - "SIM117", # Merge with-statements that use the same scope - "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() - "SIM201", # Use {left} != {right} instead of not {left} == {right} - "SIM208", # Use {expr} instead of not (not {expr}) - "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} - "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. - "SIM401", # Use get from dict with default instead of an if block - "T100", # Trace found: {name} used - "T20", # flake8-print - "TID251", # Banned imports - "TRY004", # Prefer TypeError exception for invalid type - "B904", # Use raise from to specify exception cause - "TRY302", # Remove exception handler; error is immediately re-raised - "UP", # pyupgrade - "W", # pycodestyle -] +select = ["ALL"] ignore = [ "D202", # No blank lines allowed after function docstring @@ -147,6 +88,7 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def + "N818", # Exception should be named with an Error suffix # False positives https://github.com/astral-sh/ruff/issues/5386 "PLC0208", # Use a sequence type instead of a `set` when iterating over values "PLR0911", # Too many return statements ({returns} > {max_returns}) @@ -175,9 +117,28 @@ split-on-trailing-comma = false [tool.ruff.lint.per-file-ignores] # Allow for main entry & scripts to write to stdout "src/pyatmo/__main__.py" = ["T201"] +"src/pyatmo/modules/module.py" = ["PGH003"] +"src/pyatmo/auth.py" = ["ASYNC109"] # Exceptions for tests -"tests/*" = ["D10"] +"tests/*" = [ + "D10", + "S105", + "S101", + "ANN201", + "ANN001", + "N802", + "ANN202", + "PTH123", + "ASYNC230", + "PT012", + "DTZ001", + "ANN003", + "ANN002", + "A001", + "ARG001", + "ANN204", +] [tool.ruff.lint.mccabe] max-complexity = 25 diff --git a/src/pyatmo/account.py b/src/pyatmo/account.py index ff871573..019b79ff 100644 --- a/src/pyatmo/account.py +++ b/src/pyatmo/account.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 from pyatmo import modules @@ -20,7 +20,7 @@ ) from pyatmo.helpers import extract_raw_data from pyatmo.home import Home -from pyatmo.modules.module import MeasureInterval, Module +from pyatmo.modules.module import Energy, MeasureInterval, Module if TYPE_CHECKING: from pyatmo.auth import AbstractAsyncAuth @@ -31,7 +31,11 @@ class AsyncAccount: """Async class of a Netatmo account.""" - def __init__(self, auth: AbstractAsyncAuth, favorite_stations: bool = True) -> None: + def __init__( + self, + auth: AbstractAsyncAuth, + favorite_stations: bool = True, # noqa: FBT001, FBT002 + ) -> None: """Initialize the Netatmo account.""" self.auth: AbstractAsyncAuth = auth @@ -72,7 +76,8 @@ def process_topology(self, disabled_homes_ids: list[str] | None = None) -> None: self.homes[home_id] = Home(self.auth, raw_data=home) async def async_update_topology( - self, disabled_homes_ids: list[str] | None = None + self, + disabled_homes_ids: list[str] | None = None, ) -> None: """Retrieve topology data from /homesdata.""" @@ -126,12 +131,15 @@ async def async_update_measures( ) -> None: """Retrieve measures data from /getmeasure.""" - await getattr(self.homes[home_id].modules[module_id], "async_update_measures")( - start_time=start_time, - end_time=end_time, - interval=interval, - days=days, - ) + module = self.homes[home_id].modules[module_id] + if module.has_feature("historical_data"): + module = cast(Energy, module) + await module.async_update_measures( + start_time=start_time, + end_time=end_time, + interval=interval, + days=days, + ) def register_public_weather_area( self, @@ -140,7 +148,7 @@ def register_public_weather_area( lat_sw: str, lon_sw: str, required_data_type: str | None = None, - filtering: bool = False, + filtering: bool = False, # noqa: FBT001, FBT002 *, area_id: str = str(uuid4()), ) -> str: diff --git a/src/pyatmo/auth.py b/src/pyatmo/auth.py index e401124d..6624225e 100644 --- a/src/pyatmo/auth.py +++ b/src/pyatmo/auth.py @@ -8,7 +8,13 @@ import logging from typing import Any -from aiohttp import ClientError, ClientResponse, ClientSession, ContentTypeError +from aiohttp import ( + ClientError, + ClientResponse, + ClientSession, + ClientTimeout, + ContentTypeError, +) from pyatmo.const import ( AUTHORIZATION_HEADER, @@ -51,7 +57,8 @@ async def async_get_image( try: access_token = await self.async_get_access_token() except ClientError as err: - raise ApiError(f"Access token failure: {err}") from err + msg = f"Access token failure: {err}" + raise ApiError(msg) from err headers = {AUTHORIZATION_HEADER: f"Bearer {access_token}"} req_args = {"data": params if params is not None else {}} @@ -59,19 +66,22 @@ async def async_get_image( url = (base_url or self.base_url) + endpoint async with self.websession.get( url, - **req_args, # type: ignore + **req_args, # type: ignore # noqa: PGH003 headers=headers, - timeout=timeout, + timeout=ClientTimeout(total=timeout), ) as resp: resp_content = await resp.read() if resp.headers.get("content-type") == "image/jpeg": return resp_content - raise ApiError( + msg = ( f"{resp.status} - " f"invalid content-type in response" - f"when accessing '{url}'", + f"when accessing '{url}'" + ) + raise ApiError( + msg, ) async def async_post_api_request( @@ -104,20 +114,21 @@ async def async_post_request( async with self.websession.post( url, - **req_args, + **req_args, # type: ignore # noqa: PGH003 headers=headers, - timeout=timeout, + timeout=ClientTimeout(total=timeout), ) as resp: return await self.process_response(resp, url) - async def get_access_token(self): + async def get_access_token(self) -> str: """Get access token.""" try: return await self.async_get_access_token() except ClientError as err: - raise ApiError(f"Access token failure: {err}") from err + msg = f"Access token failure: {err}" + raise ApiError(msg) from err - def prepare_request_arguments(self, params): + def prepare_request_arguments(self, params: dict | None) -> dict: """Prepare request arguments.""" req_args = {"data": params if params is not None else {}} @@ -131,7 +142,7 @@ def prepare_request_arguments(self, params): return req_args - async def process_response(self, resp, url): + async def process_response(self, resp: ClientResponse, url: str) -> ClientResponse: """Process response.""" resp_status = resp.status resp_content = await resp.read() @@ -142,7 +153,12 @@ async def process_response(self, resp, url): return await self.handle_success_response(resp, resp_content) - async def handle_error_response(self, resp, resp_status, url): + async def handle_error_response( + self, + resp: ClientResponse, + resp_status: int, + url: str, + ) -> None: """Handle error response.""" try: resp_json = await resp.json() @@ -159,19 +175,25 @@ async def handle_error_response(self, resp, resp_status, url): raise ApiErrorThrottling( message, ) - else: - raise ApiError( - message, - ) + raise ApiError( + message, + ) except (JSONDecodeError, ContentTypeError) as exc: - raise ApiError( + msg = ( f"{resp_status} - " f"{ERRORS.get(resp_status, '')} - " - f"when accessing '{url}'", + f"when accessing '{url}'" + ) + raise ApiError( + msg, ) from exc - async def handle_success_response(self, resp, resp_content): + async def handle_success_response( + self, + resp: ClientResponse, + resp_content: bytes, + ) -> ClientResponse: """Handle success response.""" try: if "application/json" in resp.headers.get("content-type", []): @@ -193,7 +215,8 @@ async def async_addwebhook(self, webhook_url: str) -> None: params={"url": webhook_url}, ) except asyncio.exceptions.TimeoutError as exc: - raise ApiError("Webhook registration timed out") from exc + msg = "Webhook registration timed out" + raise ApiError(msg) from exc else: LOG.debug("addwebhook: %s", resp) @@ -205,6 +228,7 @@ async def async_dropwebhook(self) -> None: params={"app_types": "app_security"}, ) except asyncio.exceptions.TimeoutError as exc: - raise ApiError("Webhook registration timed out") from exc + msg = "Webhook registration timed out" + raise ApiError(msg) from exc else: LOG.debug("dropwebhook: %s", resp) diff --git a/src/pyatmo/const.py b/src/pyatmo/const.py index 0b141743..71402d45 100644 --- a/src/pyatmo/const.py +++ b/src/pyatmo/const.py @@ -106,3 +106,6 @@ MAX_HISTORY_TIME_FRAME = 24 * 2 * 3600 UNKNOWN = "unknown" + +ON = True +OFF = False diff --git a/src/pyatmo/event.py b/src/pyatmo/event.py index c2fa97ed..b52232ba 100644 --- a/src/pyatmo/event.py +++ b/src/pyatmo/event.py @@ -4,8 +4,10 @@ from dataclasses import dataclass from enum import Enum +from typing import TYPE_CHECKING -from pyatmo.const import RawData +if TYPE_CHECKING: + from pyatmo.const import RawData EVENT_ATTRIBUTES_MAP = {"id": "entity_id", "type": "event_type", "time": "event_time"} @@ -86,6 +88,7 @@ class Event: message: str | None = None camera_id: str | None = None device_id: str | None = None + module_id: str | None = None person_id: str | None = None video_id: str | None = None sub_type: int | None = None diff --git a/src/pyatmo/exceptions.py b/src/pyatmo/exceptions.py index 31cc8c69..cbf74a07 100644 --- a/src/pyatmo/exceptions.py +++ b/src/pyatmo/exceptions.py @@ -4,52 +4,34 @@ class NoSchedule(Exception): """Raised when no schedule is found.""" - pass - class InvalidSchedule(Exception): """Raised when an invalid schedule is encountered.""" - pass - class InvalidHome(Exception): """Raised when an invalid home is encountered.""" - pass - class InvalidRoom(Exception): """Raised when an invalid room is encountered.""" - pass - class NoDevice(Exception): """Raised when no device is found.""" - pass - class ApiError(Exception): """Raised when an API error is encountered.""" - pass - class ApiErrorThrottling(ApiError): """Raised when an API error is encountered.""" - pass - class ApiHomeReachabilityError(ApiError): """Raised when an API error is encountered.""" - pass - class InvalidState(Exception): """Raised when an invalid state is encountered.""" - - pass diff --git a/src/pyatmo/helpers.py b/src/pyatmo/helpers.py index 9833d5b0..a778449d 100644 --- a/src/pyatmo/helpers.py +++ b/src/pyatmo/helpers.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast -from pyatmo.const import RawData from pyatmo.exceptions import NoDevice +if TYPE_CHECKING: + from pyatmo.const import RawData + LOG: logging.Logger = logging.getLogger(__name__) @@ -31,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]: +def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: # noqa: ANN401 """Extract raw data from server response.""" raw_data = {} @@ -40,7 +42,8 @@ def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: if resp is None or "body" not in resp or tag not in resp["body"]: LOG.debug("Server response (tag: %s): %s", tag, resp) - raise NoDevice("No device found, errors in response") + msg = "No device found, errors in response" + raise NoDevice(msg) if tag == "homes": return { @@ -50,6 +53,7 @@ def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: if not (raw_data := fix_id(resp["body"].get(tag))): LOG.debug("Server response (tag: %s): %s", tag, resp) - raise NoDevice("No device data available") + msg = "No device data available" + raise NoDevice(msg) return {tag: raw_data, "errors": resp["body"].get("errors", [])} diff --git a/src/pyatmo/home.py b/src/pyatmo/home.py index 17a3bc80..20240ece 100644 --- a/src/pyatmo/home.py +++ b/src/pyatmo/home.py @@ -3,9 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any - -from aiohttp import ClientResponse +from typing import TYPE_CHECKING, Any, cast from pyatmo import modules from pyatmo.const import ( @@ -26,13 +24,16 @@ InvalidState, NoSchedule, ) -from pyatmo.modules import Module +from pyatmo.modules.netatmo import NACamera from pyatmo.person import Person from pyatmo.room import Room from pyatmo.schedule import Schedule if TYPE_CHECKING: + from aiohttp import ClientResponse + from pyatmo.auth import AbstractAsyncAuth + from pyatmo.modules import Module LOG = logging.getLogger(__name__) @@ -98,7 +99,7 @@ def get_module(self, module: dict) -> Module: ) except AttributeError: LOG.info("Unknown device type %s", module["type"]) - return getattr(modules, "NLunknown")( + return modules.NLunknown( home=self, module=module, ) @@ -150,7 +151,7 @@ def update_topology(self, raw_data: RawData) -> None: async def update( self, raw_data: RawData, - do_raise_for_reachability_error=False, + do_raise_for_reachability_error: bool = False, # noqa: FBT002, FBT001 ) -> None: """Update home with the latest data.""" has_error = False @@ -187,15 +188,12 @@ async def update( if module.reachable: has_one_module_reachable = True if hasattr(module, "events"): - setattr( - module, - "events", - [ - event - for event in self.events.values() - if getattr(event, "module_id") == module.entity_id - ], - ) + module = cast(NACamera, module) + module.events = [ + event + for event in self.events.values() + if event.module_id == module.entity_id + ] if ( do_raise_for_reachability_error @@ -203,8 +201,9 @@ async def update( and has_one_module_reachable is False and has_an_update is False ): + msg = "No Home update could be performed, all modules unreachable and not updated" raise ApiHomeReachabilityError( - "No Home update could be performed, all modules unreachable and not updated", + msg, ) def get_selected_schedule(self) -> Schedule | None: @@ -252,9 +251,11 @@ async def async_set_thermmode( ) -> bool: """Set thermotat mode.""" if schedule_id is not None and not self.is_valid_schedule(schedule_id): - raise NoSchedule(f"{schedule_id} is not a valid schedule id.") + msg = f"{schedule_id} is not a valid schedule id." + raise NoSchedule(msg) if mode is None: - raise NoSchedule(f"{mode} is not a valid mode.") + msg = f"{mode} is not a valid mode." + raise NoSchedule(msg) post_params = {"home_id": self.entity_id, "mode": mode} if end_time is not None and mode in {"hg", "away"}: post_params["endtime"] = str(end_time) @@ -277,7 +278,8 @@ async def async_set_thermmode( async def async_switch_schedule(self, schedule_id: str) -> bool: """Switch the schedule.""" if not self.is_valid_schedule(schedule_id): - raise NoSchedule(f"{schedule_id} is not a valid schedule id") + msg = f"{schedule_id} is not a valid schedule id" + raise NoSchedule(msg) LOG.debug("Setting home (%s) schedule to %s", self.entity_id, schedule_id) resp = await self.auth.async_post_api_request( endpoint=SWITCHHOMESCHEDULE_ENDPOINT, @@ -289,7 +291,8 @@ async def async_switch_schedule(self, schedule_id: str) -> bool: async def async_set_state(self, data: dict[str, Any]) -> bool: """Set state using given data.""" if not is_valid_state(data): - raise InvalidState("Data for '/set_state' contains errors.") + msg = "Data for '/set_state' contains errors." + raise InvalidState(msg) LOG.debug("Setting state for home (%s) according to %s", self.entity_id, data) resp = await self.auth.async_post_api_request( endpoint=SETSTATE_ENDPOINT, @@ -335,7 +338,8 @@ async def async_set_schedule_temperatures( selected_schedule = self.get_selected_schedule() if selected_schedule is None: - raise NoSchedule("Could not determine selected schedule.") + msg = "Could not determine selected schedule." + raise NoSchedule(msg) zones = [] @@ -383,7 +387,8 @@ async def async_sync_schedule( ) -> None: """Modify an existing schedule.""" if not is_valid_schedule(schedule): - raise InvalidSchedule("Data for '/synchomeschedule' contains errors.") + msg = "Data for '/synchomeschedule' contains errors." + raise InvalidSchedule(msg) LOG.debug( "Setting schedule (%s) for home (%s) to %s", schedule_id, diff --git a/src/pyatmo/modules/base_class.py b/src/pyatmo/modules/base_class.py index db4446d4..2e59f7ca 100644 --- a/src/pyatmo/modules/base_class.py +++ b/src/pyatmo/modules/base_class.py @@ -4,7 +4,6 @@ from abc import ABC import bisect -from collections.abc import Iterable from dataclasses import dataclass import logging from operator import itemgetter @@ -14,11 +13,14 @@ from pyatmo.modules.device_types import DeviceType if TYPE_CHECKING: - from pyatmo.event import EventTypes + from collections.abc import Iterator + from pyatmo.home import Home from time import time +from pyatmo.event import EventTypes + LOG = logging.getLogger(__name__) @@ -29,13 +31,13 @@ "event_type": lambda x, y: EventTypes(x.get("type", y)), "reachable": lambda x, _: x.get("reachable", False), "monitoring": lambda x, _: x.get("monitoring", False) == "on", - "battery_level": lambda x, y: x.get("battery_vp", x.get("battery_level")), + "battery_level": lambda x, _: x.get("battery_vp", x.get("battery_level")), "place": lambda x, _: Place(x.get("place")), "target_position__step": lambda x, _: x.get("target_position:step"), } -def default(key: str, val: Any) -> Any: +def default(key: str, val: Any) -> Any: # noqa: ANN401 """Return default value.""" return lambda x, _: x.get(key, val) @@ -59,6 +61,11 @@ class EntityBase: history_features_values: dict name: str + def has_feature(self, feature: str) -> bool: + """Check if the entity has the given feature.""" + + return hasattr(self, feature) or feature in self.history_features + class NetatmoBase(EntityBase, ABC): """Base class for Netatmo entities.""" @@ -76,13 +83,6 @@ def update_topology(self, raw_data: RawData) -> None: self._update_attributes(raw_data) - if ( - self.bridge - and self.bridge in self.home.modules - and getattr(self, "device_category") == "weather" - ): - self.name = update_name(self.name, self.home.modules[self.bridge].name) - def _update_attributes(self, raw_data: RawData) -> None: """Update attributes.""" @@ -100,7 +100,12 @@ def _update_attributes(self, raw_data: RawData) -> None: self.add_history_data(hist_feature, val, now) - def add_history_data(self, feature: str, value, time: int) -> None: + def add_history_data( + self, + feature: str, + value: Any, # noqa: ANN401 + time: int, + ) -> None: """Add historical data at the given time.""" # get the feature values rolling buffer @@ -119,7 +124,12 @@ def add_history_data(self, feature: str, value, time: int) -> None: while len(hist_f) > 0 and hist_f[-1][0] - hist_f[0][0] > MAX_HISTORY_TIME_FRAME: hist_f.pop(0) - def get_history_data(self, feature: str, from_ts: int, to_ts: int | None = None): + def get_history_data( + self, + feature: str, + from_ts: float, + to_ts: float | None = None, + ) -> list[Any]: """Retrieve historical data.""" hist_f = self.history_features_values.get(feature, []) @@ -150,7 +160,7 @@ def __init__(self, longitude: float, latitude: float) -> None: self.latitude = latitude self.longitude = longitude - def __iter__(self) -> Iterable[float]: + def __iter__(self) -> Iterator[float]: """Iterate over latitude and longitude.""" yield self.longitude diff --git a/src/pyatmo/modules/device_types.py b/src/pyatmo/modules/device_types.py index 6b2a27a3..4b904774 100644 --- a/src/pyatmo/modules/device_types.py +++ b/src/pyatmo/modules/device_types.py @@ -4,6 +4,7 @@ from enum import Enum import logging +from typing import Literal LOG = logging.getLogger(__name__) @@ -116,7 +117,7 @@ class DeviceType(str, Enum): # pylint: enable=C0103 @classmethod - def _missing_(cls, key): + def _missing_(cls, key) -> Literal[DeviceType.NLunknown]: # noqa: ANN001 """Handle unknown device types.""" msg = f"{key} device is unknown" diff --git a/src/pyatmo/modules/idiamant.py b/src/pyatmo/modules/idiamant.py index a968205e..7fdeac9e 100644 --- a/src/pyatmo/modules/idiamant.py +++ b/src/pyatmo/modules/idiamant.py @@ -18,22 +18,14 @@ class NBG(FirmwareMixin, WifiMixin, Module): """Class to represent a iDiamant NBG.""" - ... - class NBR(FirmwareMixin, RfMixin, ShutterMixin, Module): """Class to represent a iDiamant NBR.""" - ... - class NBO(FirmwareMixin, RfMixin, ShutterMixin, Module): """Class to represent a iDiamant NBO.""" - ... - class NBS(FirmwareMixin, RfMixin, ShutterMixin, Module): """Class to represent a iDiamant NBS.""" - - ... diff --git a/src/pyatmo/modules/module.py b/src/pyatmo/modules/module.py index a068294c..c3d19a7e 100644 --- a/src/pyatmo/modules/module.py +++ b/src/pyatmo/modules/module.py @@ -5,13 +5,13 @@ from datetime import UTC, datetime, timedelta from enum import Enum import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, LiteralString from aiohttp import ClientConnectorError -from pyatmo.const import GETMEASURE_ENDPOINT, RawData +from pyatmo.const import GETMEASURE_ENDPOINT, OFF, ON, RawData from pyatmo.exceptions import ApiError -from pyatmo.modules.base_class import EntityBase, NetatmoBase, Place +from pyatmo.modules.base_class import EntityBase, NetatmoBase, Place, update_name from pyatmo.modules.device_types import DEVICE_CATEGORY_MAP, DeviceCategory, DeviceType if TYPE_CHECKING: @@ -66,7 +66,7 @@ def process_battery_state(data: str) -> int: class FirmwareMixin(EntityBase): """Mixin for firmware data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize firmware mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 self.firmware_revision: int | None = None @@ -76,7 +76,7 @@ def __init__(self, home: Home, module: ModuleT): class WifiMixin(EntityBase): """Mixin for wifi data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize wifi mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 self.wifi_strength: int | None = None @@ -85,7 +85,7 @@ def __init__(self, home: Home, module: ModuleT): class RfMixin(EntityBase): """Mixin for rf data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize rf mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -95,7 +95,7 @@ def __init__(self, home: Home, module: ModuleT): class RainMixin(EntityBase): """Mixin for rain data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize rain mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -107,7 +107,7 @@ def __init__(self, home: Home, module: ModuleT): class WindMixin(EntityBase): """Mixin for wind data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize wind mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -152,7 +152,7 @@ def process_angle(angle: int) -> str: class TemperatureMixin(EntityBase): """Mixin for temperature data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize temperature mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -169,7 +169,7 @@ def __init__(self, home: Home, module: ModuleT): class HumidityMixin(EntityBase): """Mixin for humidity data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize humidity mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -179,7 +179,7 @@ def __init__(self, home: Home, module: ModuleT): class CO2Mixin(EntityBase): """Mixin for CO2 data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize CO2 mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -189,7 +189,7 @@ def __init__(self, home: Home, module: ModuleT): class HealthIndexMixin(EntityBase): """Mixin for health index data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize health index mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -199,7 +199,7 @@ def __init__(self, home: Home, module: ModuleT): class NoiseMixin(EntityBase): """Mixin for noise data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize noise mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -209,7 +209,7 @@ def __init__(self, home: Home, module: ModuleT): class PressureMixin(EntityBase): """Mixin for pressure data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize pressure mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -221,7 +221,7 @@ def __init__(self, home: Home, module: ModuleT): class BoilerMixin(EntityBase): """Mixin for boiler data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize boiler mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -231,7 +231,7 @@ def __init__(self, home: Home, module: ModuleT): class CoolerMixin(EntityBase): """Mixin for cooler data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize cooler mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -241,7 +241,7 @@ def __init__(self, home: Home, module: ModuleT): class BatteryMixin(EntityBase): """Mixin for battery data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize battery mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -263,7 +263,7 @@ def battery(self) -> int: class PlaceMixin(EntityBase): """Mixin for place data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize place mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -273,7 +273,7 @@ def __init__(self, home: Home, module: ModuleT): class DimmableMixin(EntityBase): """Mixin for dimmable data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize dimmable mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -297,7 +297,7 @@ async def async_set_brightness(self, brightness: int) -> bool: class ApplianceTypeMixin(EntityBase): """Mixin for appliance type data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize appliance type mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -307,7 +307,7 @@ def __init__(self, home: Home, module: ModuleT): class PowerMixin(EntityBase): """Mixin for power data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize power mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -318,7 +318,7 @@ def __init__(self, home: Home, module: ModuleT): class EventMixin(EntityBase): """Mixin for event data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize event mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -328,7 +328,7 @@ def __init__(self, home: Home, module: ModuleT): class ContactorMixin(EntityBase): """Mixin for contactor data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize contactor mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -338,7 +338,7 @@ def __init__(self, home: Home, module: ModuleT): class OffloadMixin(EntityBase): """Mixin for offload data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize offload mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -348,13 +348,13 @@ def __init__(self, home: Home, module: ModuleT): class SwitchMixin(EntityBase): """Mixin for switch data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize switch mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 self.on: bool | None = None - async def async_set_switch(self, target_position: int) -> bool: + async def async_set_switch(self, target_position: bool) -> bool: # noqa: FBT001 """Set switch to target position.""" json_switch = { @@ -371,18 +371,18 @@ async def async_set_switch(self, target_position: int) -> bool: async def async_on(self) -> bool: """Switch on.""" - return await self.async_set_switch(True) + return await self.async_set_switch(ON) async def async_off(self) -> bool: """Switch off.""" - return await self.async_set_switch(False) + return await self.async_set_switch(OFF) class FanSpeedMixin(EntityBase): """Mixin for fan speed data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize fan speed mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -414,7 +414,7 @@ class ShutterMixin(EntityBase): __stop_position = -1 __preferred_position = -2 - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize shutter mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -465,7 +465,7 @@ async def async_move_to_preferred_position(self) -> bool: class CameraMixin(EntityBase): """Mixin for camera data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize camera mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -520,7 +520,10 @@ async def _async_check_url(self, url: str) -> str | None: LOG.debug("Api error for camera url %s", url) return None - assert not isinstance(resp, bytes) + if isinstance(resp, bytes): + msg = "Invalid response from camera url" + raise ApiError(msg) + resp_data = await resp.json() return resp_data.get("local_url") if resp_data else None @@ -528,7 +531,7 @@ async def _async_check_url(self, url: str) -> str | None: class FloodlightMixin(EntityBase): """Mixin for floodlight data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize floodlight mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -566,7 +569,7 @@ async def async_floodlight_auto(self) -> bool: class StatusMixin(EntityBase): """Mixin for status data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize status mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -576,7 +579,7 @@ def __init__(self, home: Home, module: ModuleT): class MonitoringMixin(EntityBase): """Mixin for monitoring data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize monitoring mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 @@ -654,8 +657,8 @@ class MeasureType(Enum): def compute_riemann_sum( power_data: list[tuple[int, float]], - conservative: bool = False, -): + conservative: bool = False, # noqa: FBT001, FBT002 +) -> float: """Compute energy from power with a rieman sum.""" delta_energy = 0.0 @@ -684,44 +687,48 @@ def compute_riemann_sum( class EnergyHistoryMixin(EntityBase): """Mixin for Energy history data.""" - def __init__(self, home: Home, module: ModuleT): + def __init__(self, home: Home, module: ModuleT) -> None: """Initialize history mixin.""" super().__init__(home, module) # type: ignore # mypy issue 4335 - self.historical_data: list[dict[str, Any]] = [] - self.start_time: int | None = None - self.end_time: int | None = None + self.historical_data: list[dict[str, Any]] | None = None + self.start_time: float | None = None + self.end_time: float | None = None self.interval: MeasureInterval | None = None - self.sum_energy_elec: int | None = None - self.sum_energy_elec_peak: int | None = None - self.sum_energy_elec_off_peak: int | None = None - self._anchor_for_power_adjustment: int | None = None + self.sum_energy_elec: float = 0.0 + self.sum_energy_elec_peak: float = 0.0 + self.sum_energy_elec_off_peak: float = 0.0 + self._anchor_for_power_adjustment: float | None = None self.in_reset: bool = False - def reset_measures(self, start_power_time, in_reset=True): + def reset_measures( + self, + start_power_time: datetime, + in_reset: bool = True, # noqa: FBT001, FBT002 + ) -> None: """Reset energy measures.""" self.in_reset = in_reset self.historical_data = [] + self.sum_energy_elec = 0.0 + self.sum_energy_elec_peak = 0.0 + self.sum_energy_elec_off_peak = 0.0 if start_power_time is None: self._anchor_for_power_adjustment = start_power_time else: self._anchor_for_power_adjustment = int(start_power_time.timestamp()) - self.sum_energy_elec = 0 - self.sum_energy_elec_peak = 0 - self.sum_energy_elec_off_peak = 0 def get_sum_energy_elec_power_adapted( self, - to_ts: int | None = None, - conservative: bool = False, - ): + to_ts: float | None = None, + conservative: bool = False, # noqa: FBT001, FBT002 + ) -> tuple[None, float] | tuple[float, float]: """Compute proper energy value with adaptation from power.""" v = self.sum_energy_elec if v is None: - return None, 0 + return None, 0.0 - delta_energy = 0 + delta_energy = 0.0 if not self.in_reset: if to_ts is None: @@ -748,18 +755,22 @@ def get_sum_energy_elec_power_adapted( return v, delta_energy - def _log_energy_error(self, start_time, end_time, msg=None, body=None): - if body is None: - body = "NO BODY" + def _log_energy_error( + self, + start_time: float, + end_time: float, + msg: str | None = None, + body: dict | None = None, + ) -> None: LOG.debug( "ENERGY collection error %s %s %s %s %s %s %s", msg, self.name, - datetime.fromtimestamp(start_time), - datetime.fromtimestamp(end_time), + datetime.fromtimestamp(start_time), # noqa: DTZ006 + datetime.fromtimestamp(end_time), # noqa: DTZ006 start_time, end_time, - body, + body or "NO BODY", ) async def async_update_measures( @@ -772,10 +783,10 @@ async def async_update_measures( """Update historical data.""" if end_time is None: - end_time = int(datetime.now().timestamp()) + end_time = int(datetime.now().timestamp()) # noqa: DTZ005 if start_time is None: - end = datetime.fromtimestamp(end_time) + end = datetime.fromtimestamp(end_time) # noqa: DTZ006 start_time = int((end - timedelta(days=days)).timestamp()) prev_start_time = self.start_time @@ -793,7 +804,7 @@ async def async_update_measures( delta_range = MEASURE_INTERVAL_TO_SECONDS.get(interval, 0) // 2 - filters, raw_data = await self._energy_API_calls(start_time, end_time, interval) + filters, raw_data = await self._energy_api_calls(start_time, end_time, interval) hist_good_vals = await self._get_aligned_energy_values_and_mode( start_time, @@ -803,9 +814,9 @@ async def async_update_measures( ) prev_sum_energy_elec = self.sum_energy_elec - self.sum_energy_elec = 0 - self.sum_energy_elec_peak = 0 - self.sum_energy_elec_off_peak = 0 + self.sum_energy_elec = 0.0 + self.sum_energy_elec_peak = 0.0 + self.sum_energy_elec_off_peak = 0.0 # no data at all: we know nothing for the end: best guess, it is the start self._anchor_for_power_adjustment = start_time @@ -820,8 +831,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), - datetime.fromtimestamp(end_time), + datetime.fromtimestamp(start_time), # noqa: DTZ006 + datetime.fromtimestamp(end_time), # noqa: DTZ006 prev_sum_energy_elec if prev_sum_energy_elec is not None else "NOTHING", ) else: @@ -830,25 +841,25 @@ async def async_update_measures( end_time, delta_range, hist_good_vals, - prev_end_time, - prev_start_time, - prev_sum_energy_elec, + prev_end_time or 0.0, + prev_start_time or 0.0, + prev_sum_energy_elec or 0.0, ) async def _prepare_exported_historical_data( self, - start_time, - end_time, - delta_range, - hist_good_vals, - prev_end_time, - prev_start_time, - prev_sum_energy_elec, - ): + start_time: float, + end_time: float, + delta_range: float, + hist_good_vals: list[tuple[int, float, list[float]]], + prev_end_time: float, + prev_start_time: float, + prev_sum_energy_elec: float | None, + ) -> None: self.historical_data = [] - computed_start = 0 - computed_end = 0 - computed_end_for_calculus = 0 + computed_start = 0.0 + computed_end = 0.0 + computed_end_for_calculus = 0.0 for cur_start_time, val, vals in hist_good_vals: self.sum_energy_elec += val @@ -871,8 +882,7 @@ async def _prepare_exported_historical_data( computed_start = c_start computed_end = c_end - # - delta_range not sure, revert ... it seems the energy value effectively stops at those mid values - computed_end_for_calculus = c_end # - delta_range + computed_end_for_calculus = c_end start_time_string = f"{datetime.fromtimestamp(c_start + 1, tz=UTC).isoformat().split('+')[0]}Z" end_time_string = ( @@ -902,14 +912,14 @@ async def _prepare_exported_historical_data( LOG.debug( msg, self.name, - datetime.fromtimestamp(start_time), - datetime.fromtimestamp(end_time), - datetime.fromtimestamp(computed_start), - datetime.fromtimestamp(computed_end), + datetime.fromtimestamp(start_time), # noqa: DTZ006 + datetime.fromtimestamp(end_time), # noqa: DTZ006 + datetime.fromtimestamp(computed_start), # noqa: DTZ006 + datetime.fromtimestamp(computed_end), # noqa: DTZ006 self.sum_energy_elec, prev_sum_energy_elec, - datetime.fromtimestamp(prev_start_time), - datetime.fromtimestamp(prev_end_time), + datetime.fromtimestamp(prev_start_time), # noqa: DTZ006 + datetime.fromtimestamp(prev_end_time), # noqa: DTZ006 ) else: msg = ( @@ -919,10 +929,10 @@ async def _prepare_exported_historical_data( LOG.debug( msg, self.name, - datetime.fromtimestamp(start_time), - datetime.fromtimestamp(end_time), - datetime.fromtimestamp(computed_start), - datetime.fromtimestamp(computed_end), + datetime.fromtimestamp(start_time), # noqa: DTZ006 + datetime.fromtimestamp(end_time), # noqa: DTZ006 + datetime.fromtimestamp(computed_start), # noqa: DTZ006 + datetime.fromtimestamp(computed_end), # noqa: DTZ006 self.sum_energy_elec, prev_sum_energy_elec if prev_sum_energy_elec is not None else "NOTHING", ) @@ -931,27 +941,30 @@ async def _prepare_exported_historical_data( async def _get_aligned_energy_values_and_mode( self, - start_time, - end_time, - delta_range, - raw_data, - ): + start_time: float, + end_time: float, + delta_range: float, + raw_data: dict, + ) -> list[Any]: hist_good_vals = [] values_lots = raw_data for values_lot in values_lots: try: start_lot_time = int(values_lot["beg_time"]) - except Exception: + except KeyError: self._log_energy_error( start_time, end_time, msg="beg_time missing", body=values_lots, ) - raise ApiError( + msg = ( f"Energy badly formed resp beg_time missing: {values_lots} - " - f"module: {self.name}", + f"module: {self.name}" + ) + raise ApiError( + msg, ) from None interval_sec = values_lot.get("step_time") @@ -983,13 +996,17 @@ async def _get_aligned_energy_values_and_mode( hist_good_vals.append((cur_start_time, val, vals)) cur_start_time = cur_start_time + interval_sec - hist_good_vals = sorted(hist_good_vals, key=itemgetter(0)) - return hist_good_vals + return sorted(hist_good_vals, key=itemgetter(0)) - def _get_energy_filers(self): + def _get_energy_filers(self) -> LiteralString: return ENERGY_FILTERS - async def _energy_API_calls(self, start_time, end_time, interval): + async def _energy_api_calls( + self, + start_time: float, + end_time: float, + interval: MeasureInterval, + ) -> tuple[LiteralString, Any]: filters = self._get_energy_filers() params = { @@ -1016,11 +1033,12 @@ async def _energy_API_calls(self, start_time, end_time, interval): msg=f"direct from {filters}", body=rw_dt_f, ) - raise ApiError( + msg = ( f"Energy badly formed resp: {rw_dt_f} - " f"module: {self.name} - " - f"when accessing '{filters}'", + f"when accessing '{filters}'" ) + raise ApiError(msg) raw_data = rw_dt @@ -1030,7 +1048,7 @@ async def _energy_API_calls(self, start_time, end_time, interval): class EnergyHistoryLegacyMixin(EnergyHistoryMixin): """Mixin for Energy history data, Using legacy APis (used for NLE).""" - def _get_energy_filers(self): + def _get_energy_filers(self) -> LiteralString: return ENERGY_FILTERS_LEGACY @@ -1064,6 +1082,15 @@ async def update(self, raw_data: RawData) -> None: """Update module with the latest data.""" self.update_topology(raw_data) + + if ( + self.bridge + and self.bridge in self.home.modules + and hasattr(self, "device_category") + and self.device_category == "weather" + ): + self.name = update_name(self.name, self.home.modules[self.bridge].name) + self.update_features() # If we have an NLE as a bridge all its bridged modules will have to be reachable @@ -1117,25 +1144,21 @@ async def update(self, raw_data: RawData) -> None: class Switch(FirmwareMixin, EnergyHistoryMixin, PowerMixin, SwitchMixin, Module): """Class to represent a Netatmo switch.""" - ... - class Dimmer(DimmableMixin, Switch): """Class to represent a Netatmo dimmer.""" - ... - class Shutter(FirmwareMixin, ShutterMixin, Module): """Class to represent a Netatmo shutter.""" - ... - class Fan(FirmwareMixin, FanSpeedMixin, PowerMixin, Module): """Class to represent a Netatmo ventilation device.""" - ... + +class Energy(EnergyHistoryMixin, Module): + """Class to represent a Netatmo energy module.""" # pylint: enable=too-many-ancestors diff --git a/src/pyatmo/modules/netatmo.py b/src/pyatmo/modules/netatmo.py index 047c4eb1..297f758d 100644 --- a/src/pyatmo/modules/netatmo.py +++ b/src/pyatmo/modules/netatmo.py @@ -49,50 +49,34 @@ class NRV(FirmwareMixin, RfMixin, BatteryMixin, Module): """Class to represent a Netatmo NRV.""" - ... - class NATherm1(FirmwareMixin, RfMixin, BatteryMixin, BoilerMixin, Module): """Class to represent a Netatmo NATherm1.""" - ... - class NAPlug(FirmwareMixin, RfMixin, WifiMixin, Module): """Class to represent a Netatmo NAPlug.""" - ... - class OTH(FirmwareMixin, WifiMixin, Module): """Class to represent a Netatmo OTH.""" - ... - class OTM(FirmwareMixin, RfMixin, BatteryMixin, BoilerMixin, Module): """Class to represent a Netatmo OTM.""" - ... - class NACamera(Camera): """Class to represent a Netatmo NACamera.""" - ... - class NOC(FloodlightMixin, Camera): """Class to represent a Netatmo NOC.""" - ... - class NDB(Camera): """Class to represent a Netatmo NDB.""" - ... - class NAMain( TemperatureMixin, @@ -107,8 +91,6 @@ class NAMain( ): """Class to represent a Netatmo NAMain.""" - ... - class NAModule1( TemperatureMixin, @@ -121,20 +103,14 @@ class NAModule1( ): """Class to represent a Netatmo NAModule1.""" - ... - class NAModule2(WindMixin, RfMixin, FirmwareMixin, BatteryMixin, PlaceMixin, Module): """Class to represent a Netatmo NAModule2.""" - ... - class NAModule3(RainMixin, RfMixin, FirmwareMixin, BatteryMixin, PlaceMixin, Module): """Class to represent a Netatmo NAModule3.""" - ... - class NAModule4( TemperatureMixin, @@ -148,8 +124,6 @@ class NAModule4( ): """Class to represent a Netatmo NAModule4.""" - ... - class NHC( TemperatureMixin, @@ -165,14 +139,10 @@ class NHC( ): """Class to represent a Netatmo NHC.""" - ... - class NACamDoorTag(StatusMixin, FirmwareMixin, BatteryMixin, RfMixin, Module): """Class to represent a Netatmo NACamDoorTag.""" - ... - class NIS( StatusMixin, @@ -184,8 +154,6 @@ class NIS( ): """Class to represent a Netatmo NIS.""" - ... - class NSD( FirmwareMixin, @@ -193,8 +161,6 @@ class NSD( ): """Class to represent a Netatmo NSD.""" - ... - class NCO( FirmwareMixin, @@ -202,8 +168,6 @@ class NCO( ): """Class to represent a Netatmo NCO.""" - ... - @dataclass class Location: @@ -230,7 +194,7 @@ def __init__( lat_sw: str, lon_sw: str, required_data_type: str | None = None, - filtering: bool = False, + filtering: bool = False, # noqa: FBT001, FBT002 ) -> None: """Initialize self.""" diff --git a/src/pyatmo/modules/somfy.py b/src/pyatmo/modules/somfy.py index 67c69f98..4d76042e 100644 --- a/src/pyatmo/modules/somfy.py +++ b/src/pyatmo/modules/somfy.py @@ -11,5 +11,3 @@ class TPSRS(RfMixin, Shutter): """Class to represent a somfy TPSRS.""" - - ... diff --git a/src/pyatmo/person.py b/src/pyatmo/person.py index 9ab89b2c..b15cae4b 100644 --- a/src/pyatmo/person.py +++ b/src/pyatmo/person.py @@ -6,10 +6,11 @@ import logging from typing import TYPE_CHECKING -from pyatmo.const import RawData from pyatmo.modules.base_class import NetatmoBase if TYPE_CHECKING: + from pyatmo.const import RawData + from .home import Home LOG = logging.getLogger(__name__) diff --git a/src/pyatmo/schedule.py b/src/pyatmo/schedule.py index d603801e..2df909a6 100644 --- a/src/pyatmo/schedule.py +++ b/src/pyatmo/schedule.py @@ -6,11 +6,12 @@ import logging from typing import TYPE_CHECKING -from pyatmo.const import RawData from pyatmo.modules.base_class import NetatmoBase from pyatmo.room import Room if TYPE_CHECKING: + from pyatmo.const import RawData + from .home import Home LOG = logging.getLogger(__name__) @@ -65,7 +66,7 @@ def __init__(self, home: Home, raw_data: RawData) -> None: self.home = home self.type = raw_data.get("type", 0) - def room_factory(home: Home, room_raw_data: RawData): + def room_factory(home: Home, room_raw_data: RawData) -> Room: room = Room(home, room_raw_data, {}) room.update(room_raw_data) return room diff --git a/tests/common.py b/tests/common.py index 3bff5404..7fb2f026 100644 --- a/tests/common.py +++ b/tests/common.py @@ -78,5 +78,4 @@ async def fake_post_request(*args, **kwargs): async def fake_post_request_multi(*args, **kwargs): kwargs["POSTFIX"] = "multi" - r = await fake_post_request(*args, **kwargs) - return r + return await fake_post_request(*args, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 6b7c53a7..f1111090 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,14 +16,14 @@ def does_not_raise(): yield -@pytest.fixture(scope="function") +@pytest.fixture async def async_auth(): """AsyncAuth fixture.""" with patch("pyatmo.auth.AbstractAsyncAuth", AsyncMock()) as auth: yield auth -@pytest.fixture(scope="function") +@pytest.fixture async def async_account(async_auth): """AsyncAccount fixture.""" account = pyatmo.AsyncAccount(async_auth) @@ -42,15 +42,15 @@ async def async_account(async_auth): yield account -@pytest.fixture(scope="function") +@pytest.fixture async def async_home(async_account): """AsyncClimate fixture for home_id 91763b24c43d3e344f424e8b.""" home_id = "91763b24c43d3e344f424e8b" await async_account.async_update_status(home_id) - yield async_account.homes[home_id] + return async_account.homes[home_id] -@pytest.fixture(scope="function") +@pytest.fixture async def async_account_multi(async_auth): """AsyncAccount fixture.""" account = pyatmo.AsyncAccount(async_auth) @@ -71,9 +71,9 @@ async def async_account_multi(async_auth): yield account -@pytest.fixture(scope="function") +@pytest.fixture async def async_home_multi(async_account_multi): """AsyncClimate fixture for home_id 91763b24c43d3e344f424e8b.""" home_id = "aaaaaaaaaaabbbbbbbbbbccc" await async_account_multi.async_update_status(home_id) - yield async_account_multi.homes[home_id] + return async_account_multi.homes[home_id] diff --git a/tests/test_camera.py b/tests/test_camera.py index 79b7c01e..c12ecf89 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -11,7 +11,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_camera_NACamera(async_home): # pylint: disable=invalid-name """Test Netatmo indoor camera module.""" module_id = "12:34:56:00:f1:62" @@ -29,7 +29,7 @@ async def test_async_camera_NACamera(async_home): # pylint: disable=invalid-nam assert person.last_seen == 1557071156 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_NOC(async_home): # pylint: disable=invalid-name """Test basic outdoor camera functionality.""" module_id = "12:34:56:10:b9:0e" @@ -84,7 +84,7 @@ def gen_json_data(state): ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_camera_monitoring(async_home): """Test basic camera monitoring functionality.""" module_id = "12:34:56:10:b9:0e" diff --git a/tests/test_climate.py b/tests/test_climate.py index e0303840..64eb9acd 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -14,7 +14,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_room(async_home): """Test room with climate devices.""" room_id = "2746182631" @@ -29,7 +29,7 @@ async def test_async_climate_room(async_home): assert len(room.modules) == 1 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_NATherm1(async_home): # pylint: disable=invalid-name """Test NATherm1 climate device.""" module_id = "12:34:56:00:01:ae" @@ -43,7 +43,7 @@ async def test_async_climate_NATherm1(async_home): # pylint: disable=invalid-na assert module.rf_strength == 58 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_NRV(async_home): # pylint: disable=invalid-name """Test NRV climate device.""" module_id = "12:34:56:03:a5:54" @@ -57,7 +57,7 @@ async def test_async_climate_NRV(async_home): # pylint: disable=invalid-name assert module.firmware_revision == 79 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_NAPlug(async_home): # pylint: disable=invalid-name """Test NAPlug climate device.""" module_id = "12:34:56:00:fa:d0" @@ -70,7 +70,7 @@ async def test_async_climate_NAPlug(async_home): # pylint: disable=invalid-name assert module.firmware_revision == 174 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_NIS(async_home): # pylint: disable=invalid-name """Test Netatmo siren.""" module_id = "12:34:56:00:e3:9b" @@ -82,7 +82,7 @@ async def test_async_climate_NIS(async_home): # pylint: disable=invalid-name assert module.monitoring is False -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_OTM(async_home): # pylint: disable=invalid-name """Test OTM climate device.""" module_id = "12:34:56:20:f5:8c" @@ -96,7 +96,7 @@ async def test_async_climate_OTM(async_home): # pylint: disable=invalid-name assert module.rf_strength == 64 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_OTH(async_home): # pylint: disable=invalid-name """Test OTH climate device.""" module_id = "12:34:56:20:f5:44" @@ -108,7 +108,7 @@ async def test_async_climate_OTH(async_home): # pylint: disable=invalid-name assert module.firmware_revision == 22 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_BNS(async_home): # pylint: disable=invalid-name """Test Smarther BNS climate module.""" module_id = "10:20:30:bd:b8:1e" @@ -125,7 +125,7 @@ async def test_async_climate_BNS(async_home): # pylint: disable=invalid-name assert room.features == {"humidity", DeviceCategory.climate} -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_update(async_account): """Test basic climate state update.""" home_id = "91763b24c43d3e344f424e8b" @@ -182,7 +182,7 @@ async def test_async_climate_update(async_account): @pytest.mark.parametrize( - "t_sched_id, expected", + ("t_sched_id", "expected"), [ ("591b54a2764ff4d50d8b5795", does_not_raise()), ( @@ -191,7 +191,7 @@ async def test_async_climate_update(async_account): ), ], ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_switch_schedule( async_home, t_sched_id, @@ -213,7 +213,7 @@ async def test_async_climate_switch_schedule( @pytest.mark.parametrize( - "temp, end_time", + ("temp", "end_time"), [ ( 14, @@ -233,7 +233,7 @@ async def test_async_climate_switch_schedule( ), ], ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_room_therm_set( async_home, temp, @@ -273,7 +273,7 @@ async def test_async_climate_room_therm_set( @pytest.mark.parametrize( - "mode, end_time, schedule_id, json_fixture, expected, exception", + ("mode", "end_time", "schedule_id", "json_fixture", "expected", "exception"), [ ( "away", @@ -315,14 +315,6 @@ async def test_async_climate_room_therm_set( False, pytest.raises(NoSchedule), ), - ( - None, - None, - None, - "home_status_error_mode_is_missing.json", - False, - pytest.raises(NoSchedule), - ), ( "away", 1559162650, @@ -341,7 +333,7 @@ async def test_async_climate_room_therm_set( ), ], ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_set_thermmode( async_home, mode, @@ -369,7 +361,7 @@ async def test_async_climate_set_thermmode( assert expected is resp -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_climate_empty_home(async_account): """Test climate setup with empty home.""" home_id = "91763b24c43d3e344f424e8c" diff --git a/tests/test_energy.py b/tests/test_energy.py index 735d34f6..3210806a 100644 --- a/tests/test_energy.py +++ b/tests/test_energy.py @@ -2,7 +2,7 @@ import datetime as dt import json -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest import time_machine @@ -14,7 +14,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_energy_NLPC(async_home): # pylint: disable=invalid-name """Test Legrand / BTicino connected energy meter module.""" module_id = "12:34:56:00:00:a1:4c:da" @@ -25,7 +25,7 @@ async def test_async_energy_NLPC(async_home): # pylint: disable=invalid-name @time_machine.travel(dt.datetime(2022, 2, 12, 7, 59, 49)) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_historical_data_retrieval(async_account): """Test retrieval of historical measurements.""" home_id = "91763b24c43d3e344f424e8b" @@ -63,7 +63,7 @@ async def test_historical_data_retrieval(async_account): @time_machine.travel(dt.datetime(2024, 7, 24, 22, 00, 10)) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_historical_data_retrieval_multi(async_account_multi): """Test retrieval of historical measurements.""" home_id = "aaaaaaaaaaabbbbbbbbbbccc" @@ -125,7 +125,8 @@ async def test_historical_data_retrieval_multi(async_account_multi): assert module.sum_energy_elec_peak == 10177 -async def test_disconnected_main_bridge(async_account_multi): +@patch("pyatmo.auth.AbstractAsyncAuth.async_post_api_request") +async def test_disconnected_main_bridge(mock_home_status, async_account_multi): """Test retrieval of historical measurements.""" home_id = "aaaaaaaaaaabbbbbbbbbbccc" @@ -135,14 +136,7 @@ async def test_disconnected_main_bridge(async_account_multi): ) as json_file: home_status_fixture = json.load(json_file) mock_home_status_resp = MockResponse(home_status_fixture, 200) + mock_home_status.return_value = mock_home_status_resp - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_home_status_resp), - ): - try: - await async_account_multi.async_update_status(home_id) - except ApiHomeReachabilityError: - pass # expected error - else: - assert False + with pytest.raises(ApiHomeReachabilityError): + await async_account_multi.async_update_status(home_id) diff --git a/tests/test_fan.py b/tests/test_fan.py index a6e5535f..27b975e5 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -7,7 +7,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_fan_NLLF(async_home): # pylint: disable=invalid-name """Test NLLF Legrand centralized ventilation controller.""" module_id = "12:34:56:00:01:01:01:b1" diff --git a/tests/test_home.py b/tests/test_home.py index dc77d8cb..c7cebf1c 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -1,6 +1,5 @@ """Define tests for home module.""" -# import datetime as dt import json from unittest.mock import AsyncMock, patch @@ -10,10 +9,8 @@ from pyatmo import DeviceType, NoDevice from tests.common import MockResponse -# pylint: disable=F6401 - -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_home(async_home): """Test basic home setup.""" room_id = "3688132631" @@ -42,7 +39,7 @@ async def test_async_home(async_home): assert async_home.temperature_control_mode == "cooling" -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_home_set_schedule(async_home): """Test home schedule.""" schedule_id = "591b54a2764ff4d50d8b5795" @@ -54,7 +51,7 @@ async def test_async_home_set_schedule(async_home): assert async_home.get_away_temp() == 14 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_home_data_no_body(async_auth): with open("fixtures/homesdata_emtpy_home.json", encoding="utf-8") as fixture_file: json_fixture = json.load(fixture_file) @@ -70,7 +67,7 @@ async def test_async_home_data_no_body(async_auth): mock_request.assert_called() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_set_persons_home(async_account): """Test marking a person being at home.""" home_id = "91763b24c43d3e344f424e8b" @@ -96,7 +93,7 @@ async def test_async_set_persons_home(async_account): ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_set_persons_away(async_account): """Test marking a set of persons being away.""" home_id = "91763b24c43d3e344f424e8b" @@ -125,7 +122,7 @@ async def test_async_set_persons_away(async_account): ) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_home_event_update(async_account): """Test basic event update.""" home_id = "91763b24c43d3e344f424e8b" diff --git a/tests/test_shutter.py b/tests/test_shutter.py index 004f9648..94486561 100644 --- a/tests/test_shutter.py +++ b/tests/test_shutter.py @@ -11,7 +11,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_shutter_NBR(async_home): # pylint: disable=invalid-name """Test NLP Bubendorf iDiamant roller shutter.""" module_id = "0009999992" @@ -22,7 +22,7 @@ async def test_async_shutter_NBR(async_home): # pylint: disable=invalid-name assert module.current_position == 0 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_shutter_NBO(async_home): # pylint: disable=invalid-name """Test NBO Bubendorf iDiamant roller shutter.""" module_id = "0009999993" @@ -33,7 +33,7 @@ async def test_async_shutter_NBO(async_home): # pylint: disable=invalid-name assert module.current_position == 0 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_shutters(async_home): """Test basic shutter functionality.""" room_id = "3688132631" diff --git a/tests/test_switch.py b/tests/test_switch.py index 846bd57f..64cbae5f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -7,7 +7,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_switch_NLP(async_home): # pylint: disable=invalid-name """Test NLP Legrand plug.""" module_id = "12:34:56:80:00:12:ac:f2" @@ -19,7 +19,7 @@ async def test_async_switch_NLP(async_home): # pylint: disable=invalid-name assert module.power == 0 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_switch_NLF(async_home): # pylint: disable=invalid-name """Test NLF Legrand dimmer.""" module_id = "00:11:22:33:00:11:45:fe" diff --git a/tests/test_weather.py b/tests/test_weather.py index c83fb0d1..c2d297f3 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -9,7 +9,7 @@ # pylint: disable=F6401 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_weather_NAMain(async_home): # pylint: disable=invalid-name """Test Netatmo weather station main module.""" module_id = "12:34:56:80:bb:26" @@ -18,7 +18,7 @@ async def test_async_weather_NAMain(async_home): # pylint: disable=invalid-name assert module.device_type == DeviceType.NAMain -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_weather_update(async_account): """Test basic weather station update.""" home_id = "91763b24c43d3e344f424e8b" @@ -165,7 +165,7 @@ async def test_async_weather_update(async_account): assert module.gust_angle == 206 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_weather_favorite(async_account): """Test favorite weather station.""" await async_account.async_update_weather_stations() @@ -231,7 +231,7 @@ async def test_async_weather_favorite(async_account): assert module.humidity == 87 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_air_care_update(async_account): """Test basic air care update.""" await async_account.async_update_air_care() @@ -273,7 +273,7 @@ async def test_async_air_care_update(async_account): assert module.health_idx == 1 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_public_weather_update(async_account): """Test basic public weather update.""" lon_ne = "6.221652" diff --git a/tests/testing_main_template.py b/tests/testing_main_template.py index 329ace5b..b4e3e81f 100644 --- a/tests/testing_main_template.py +++ b/tests/testing_main_template.py @@ -37,10 +37,6 @@ async def main(): end_time=end, ) - # print(account) - if __name__ == "__main__": topology = asyncio.run(main()) - - # print(topology)