diff --git a/.devcontainer/Dockerfile.dev b/.devcontainer/Dockerfile.dev index 5cac57c8..94e028bc 100644 --- a/.devcontainer/Dockerfile.dev +++ b/.devcontainer/Dockerfile.dev @@ -6,11 +6,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] WORKDIR /workspaces -COPY Pipfile Pipfile.lock ./ +COPY Pipfile ./ # Create Python requirements files from pipenv (lockfile) RUN pip3 install -U pip \ && pip3 install pipenv \ + && pipenv lock \ && pipenv requirements > /tmp/requirements.txt \ && pipenv requirements --dev > /tmp/requirements_dev.txt diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 892dfcb4..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,68 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# ******** NOTE ******** - -name: "CodeQL" - -on: - push: - branches: [ development, master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ development ] - schedule: - - cron: '21 22 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 07b15286..dc1c567d 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: 3.10.8 - name: Install dependencies diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 92a08fc4..7aa636d6 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -4,7 +4,6 @@ on: push: branches: - development - - master jobs: @@ -17,7 +16,7 @@ jobs: ref: development fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: 3.10.8 - name: Install dependencies diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index c28b5721..54659359 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -23,7 +23,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -44,7 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -65,7 +65,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} - name: Run tests with tox diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index d08a8639..00000000 --- a/.pylintrc +++ /dev/null @@ -1,51 +0,0 @@ -[BASIC] -good-names-rgxs= - NA.* -good-names=on, off - -[MESSAGES CONTROL] -# Reasons disabled: -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# unused-argument - generic callbacks and setup methods create a lot of warnings -# global-statement - used for the on-demand requirement installation -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 -disable= - abstract-method, - cyclic-import, - duplicate-code, - global-statement, - inconsistent-return-statements, - missing-docstring, - too-few-public-methods, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, - too-many-public-methods, - too-many-return-statements, - too-many-statements, - abstract-method, - not-an-iterable, - format, - -[REPORTS] -reports=no - -[TYPECHECK] -# For attrs -ignored-classes=_CountingAttr -generated-members=botocore.errorfactory - -[FORMAT] -expected-line-ending-format=LF - -[EXCEPTIONS] -overgeneral-exceptions=Exception - -[MASTER] -load-plugins=pylint_pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c9fa85c..1fe8ddf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Bticino IP scopes +- Bticino dimmable light (BNLD) +- Start and end times to room class ### Changed -- +- Add power data to NLPD entities ### Deprecated @@ -20,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- +- deprecated code ### Fixed @@ -69,7 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add NLUF device stub - Add TPSRS Somfy shutters - ### Changed - Update test fixture data to be in line with HA tests @@ -128,11 +130,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated - The following modules are deprecated and will be removed in pyatmo 8.0.0 - - camera - - home_coach - - public_data - - thermostat - - weather_station + - camera + - home_coach + - public_data + - thermostat + - weather_station ## [7.0.0] - 2022-06-05 @@ -152,11 +154,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated - The following modules are deprecated and will be removed in pyatmo 8.0.0 - - camera - - home_coach - - public_data - - thermostat - - weather_station + - camera + - home_coach + - public_data + - thermostat + - weather_station ### Removed diff --git a/README.md b/README.md index 6b1fdad9..d83e0f36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -pyatmo -====== +# pyatmo [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![GitHub Actions](https://github.com/jabesq/pyatmo/workflows/Python%20package/badge.svg)](https://github.com/jabesq/pyatmo/actions?workflow=Python+package) @@ -11,16 +10,14 @@ pyatmo > > I apologize for any inconvenience this may cause, and I sincerely hope to have the capacity to allocate more time to this repository in the near future. Your understanding is greatly appreciated. -*** - +--- Simple API to access Netatmo devices and data like weather station or camera data from Python 3. For more detailed information see [dev.netatmo.com](http://dev.netatmo.com) This project has no relation with the Netatmo company. -Install -------- +## Install To install pyatmo simply run: @@ -31,14 +28,12 @@ Once installed you can simply add `pyatmo` to your Python 3 scripts by including import pyatmo -Note ----- +## Note -The module requires a valid user account and a registered application. See [usage.md](./usage.md) for further information. +The module requires a valid user account and a registered application. Be aware that the module may stop working if Netatmo decides to change their API. -Development ------------ +## Development Clone the repo and install dependencies: @@ -51,8 +46,7 @@ To add the pre-commit hook to your environment run: pip install pre-commit pre-commit install -Testing -------- +## Testing To run the full suite simply run the following command from within the virtual environment: @@ -64,7 +58,7 @@ or To generate code coverage xml (e.g. for use in VSCode) run - python -m pytest --cov-report xml:cov.xml --cov smart_home --cov-append tests/ + python -m pytest --cov-report xml:cov.xml --cov pyatmo --cov-append tests/ Another way to run the tests is by using `tox`. This runs the tests against the installed package and multiple versions of python. diff --git a/pyproject.toml b/pyproject.toml index 6d6e94fa..b5e0935e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,15 +94,6 @@ ignore = [ "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] -[tool.ruff.flake8-import-conventions.extend-aliases] -voluptuous = "vol" -"homeassistant.helpers.area_registry" = "ar" -"homeassistant.helpers.config_validation" = "cv" -"homeassistant.helpers.device_registry" = "dr" -"homeassistant.helpers.entity_registry" = "er" -"homeassistant.helpers.issue_registry" = "ir" -"homeassistant.util.dt" = "dt_util" - [tool.ruff.flake8-pytest-style] fixture-parentheses = false @@ -111,7 +102,6 @@ fixture-parentheses = false [tool.ruff.isort] force-sort-within-sections = true -known-first-party = ["homeassistant"] combine-as-imports = true split-on-trailing-comma = false diff --git a/setup.cfg b/setup.cfg index 25b7ef0f..66bae277 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,10 +39,6 @@ exclude = tests [options.package_data] pyatmo = py.typed -[flake8] -max-line-length = 88 -ignore = W503, E501 - [pep8] max-line-length = 88 ignore = W503, E501 diff --git a/src/pyatmo/__init__.py b/src/pyatmo/__init__.py index 55a6be8d..2bba15ea 100644 --- a/src/pyatmo/__init__.py +++ b/src/pyatmo/__init__.py @@ -1,44 +1,25 @@ """Expose submodules.""" from pyatmo import const, modules from pyatmo.account import AsyncAccount -from pyatmo.auth import AbstractAsyncAuth, ClientAuth, NetatmoOAuth2 -from pyatmo.camera import AsyncCameraData, CameraData +from pyatmo.auth import AbstractAsyncAuth from pyatmo.exceptions import ApiError, InvalidHome, InvalidRoom, NoDevice, NoSchedule from pyatmo.home import Home -from pyatmo.home_coach import AsyncHomeCoachData, HomeCoachData from pyatmo.modules import Module from pyatmo.modules.device_types import DeviceType -from pyatmo.public_data import AsyncPublicData, PublicData from pyatmo.room import Room -from pyatmo.thermostat import AsyncHomeData, AsyncHomeStatus, HomeData, HomeStatus -from pyatmo.weather_station import AsyncWeatherStationData, WeatherStationData __all__ = [ "AbstractAsyncAuth", "ApiError", "AsyncAccount", - "AsyncCameraData", - "AsyncHomeCoachData", - "AsyncHomeData", - "AsyncHomeStatus", - "AsyncPublicData", - "AsyncWeatherStationData", - "CameraData", - "ClientAuth", - "HomeCoachData", - "HomeData", - "HomeStatus", "InvalidHome", "InvalidRoom", "Home", "Module", "Room", "DeviceType", - "NetatmoOAuth2", "NoDevice", "NoSchedule", - "PublicData", - "WeatherStationData", "const", "modules", ] diff --git a/src/pyatmo/__main__.py b/src/pyatmo/__main__.py deleted file mode 100644 index a3966cfd..00000000 --- a/src/pyatmo/__main__.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Main entry point for pyatmo CLI.""" -import os -import sys -from warnings import warn - -from pyatmo.auth import ClientAuth -from pyatmo.camera import CameraData -from pyatmo.const import ALL_SCOPES -from pyatmo.exceptions import NoDevice -from pyatmo.home_coach import HomeCoachData -from pyatmo.public_data import PublicData -from pyatmo.thermostat import HomeData -from pyatmo.weather_station import WeatherStationData - -LON_NE = "6.221652" -LAT_NE = "46.610870" -LON_SW = "6.217828" -LAT_SW = "46.596485" - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -def tty_print(message: str) -> None: - """Print to stdout if in an interactive terminal.""" - - if sys.stdout.isatty(): - print(message) - - -def main() -> None: - """Run basic health checks.""" - client_id = os.getenv("CLIENT_ID", "") - client_secret = os.getenv("CLIENT_SECRET", "") - username = os.getenv("USERNAME", "") - password = os.getenv("PASSWORD", "") - - if not (client_id and client_secret and username and password): - sys.stderr.write( - "Missing credentials (client_id, client_secret, username, password)\n", - ) - sys.exit(1) - - auth = ClientAuth( - client_id=client_id, - client_secret=client_secret, - username=username, - password=password, - scope=" ".join(ALL_SCOPES), - ) - - try: - ws_data = WeatherStationData(auth) - ws_data.update() - except NoDevice: - tty_print("pyatmo: no weather station available for testing") - - try: - hc_data = HomeCoachData(auth) - hc_data.update() - except NoDevice: - tty_print("pyatmo: no home coach station available for testing") - - try: - camera_data = CameraData(auth) - camera_data.update() - except NoDevice: - tty_print("pyatmo: no camera available for testing") - - try: - home_data = HomeData(auth) - home_data.update() - except NoDevice: - tty_print("pyatmo: no thermostat available for testing") - - public_data = PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - public_data.update() - - # If we reach this line, all is OK - tty_print("pyatmo: OK") - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/src/pyatmo/account.py b/src/pyatmo/account.py index 9ef29f04..2986fcbb 100644 --- a/src/pyatmo/account.py +++ b/src/pyatmo/account.py @@ -17,7 +17,7 @@ SETSTATE_ENDPOINT, RawData, ) -from pyatmo.helpers import extract_raw_data_new +from pyatmo.helpers import extract_raw_data from pyatmo.home import Home from pyatmo.modules.module import MeasureInterval, Module @@ -63,7 +63,7 @@ async def async_update_topology(self) -> None: resp = await self.auth.async_post_api_request( endpoint=GETHOMESDATA_ENDPOINT, ) - self.raw_data = extract_raw_data_new(await resp.json(), "homes") + self.raw_data = extract_raw_data(await resp.json(), "homes") self.user = self.raw_data.get("user", {}).get("email") @@ -75,7 +75,7 @@ async def async_update_status(self, home_id: str) -> None: endpoint=GETHOMESTATUS_ENDPOINT, params={"home_id": home_id}, ) - raw_data = extract_raw_data_new(await resp.json(), HOME) + raw_data = extract_raw_data(await resp.json(), HOME) await self.homes[home_id].update(raw_data) async def async_update_events(self, home_id: str) -> None: @@ -84,7 +84,7 @@ async def async_update_events(self, home_id: str) -> None: endpoint=GETEVENTS_ENDPOINT, params={"home_id": home_id}, ) - raw_data = extract_raw_data_new(await resp.json(), HOME) + raw_data = extract_raw_data(await resp.json(), HOME) await self.homes[home_id].update(raw_data) async def async_update_weather_stations(self) -> None: @@ -165,7 +165,7 @@ async def _async_update_data( ) -> None: """Retrieve status data from .""" resp = await self.auth.async_post_api_request(endpoint=endpoint, params=params) - raw_data = extract_raw_data_new(await resp.json(), tag) + raw_data = extract_raw_data(await resp.json(), tag) await self.update_devices(raw_data, area_id) async def async_set_state(self, home_id: str, data: dict[str, Any]) -> None: diff --git a/src/pyatmo/auth.py b/src/pyatmo/auth.py index 8d20840c..970e52ba 100644 --- a/src/pyatmo/auth.py +++ b/src/pyatmo/auth.py @@ -3,21 +3,13 @@ from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable from json import JSONDecodeError import logging -from time import sleep from typing import Any from aiohttp import ClientError, ClientResponse, ClientSession, ContentTypeError -from oauthlib.oauth2 import LegacyApplicationClient, TokenExpiredError -import requests -from requests_oauthlib import OAuth2Session from pyatmo.const import ( - ALL_SCOPES, - AUTH_REQ_ENDPOINT, - AUTH_URL_ENDPOINT, AUTHORIZATION_HEADER, DEFAULT_BASE_URL, ERRORS, @@ -29,274 +21,6 @@ LOG = logging.getLogger(__name__) -class NetatmoOAuth2: - """Handle authentication with OAuth2.""" - - def __init__( - self, - client_id: str, - client_secret: str, - redirect_uri: str | None = None, - token: dict[str, str] | None = None, - token_updater: Callable[[str], None] | None = None, - scope: str | None = "read_station", - user_prefix: str | None = None, - base_url: str = DEFAULT_BASE_URL, - ) -> None: - """Initialize self.""" - - # Keyword Arguments: - # client_id {str} -- Application client ID delivered by Netatmo on dev.netatmo.com (default: {None}) - # client_secret {str} -- Application client secret delivered by Netatmo on dev.netatmo.com (default: {None}) - # redirect_uri {Optional[str]} -- Redirect URI where to the authorization server will redirect with an authorization code (default: {None}) - # token {Optional[Dict[str, str]]} -- Authorization token (default: {None}) - # token_updater {Optional[Callable[[str], None]]} -- Callback when the token is updated (default: {None}) - # scope {Optional[str]} -- List of scopes (default: {"read_station"}) - # read_station: to retrieve weather station data (Getstationsdata, Getmeasure) - # read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture) - # access_camera: to access the camera, the videos and the live stream - # write_camera: to set home/away status of persons (Setpersonsaway, Setpersonshome) - # read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata) - # write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint) - # read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture) - # access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status - # read_homecoach: to retrieve Home Coache data (Gethomecoachsdata) - # read_smokedetector: to retrieve the smoke detector status (Gethomedata) - # Several values can be used at the same time, ie: 'read_station read_camera' - # user_prefix {Optional[str]} -- API prefix for the Netatmo customer - # base_url {str} -- Base URL of the Netatmo API (default: {_DEFAULT_BASE_URL}) - - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.token_updater = token_updater - self.user_prefix = user_prefix - self.base_url = base_url - - if token: - self.scope = " ".join(token["scope"]) - - else: - self.scope = scope or " ".join(ALL_SCOPES) - - self.extra = {"client_id": self.client_id, "client_secret": self.client_secret} - - self._oauth = OAuth2Session( - client_id=self.client_id, - token=token, - token_updater=self.token_updater, - redirect_uri=self.redirect_uri, - scope=self.scope, - ) - - def refresh_tokens(self) -> Any: - """Refresh and return new tokens.""" - - token = self._oauth.refresh_token( - self.base_url + AUTH_REQ_ENDPOINT, - **self.extra, - ) - - if self.token_updater is not None: - self.token_updater(token) - - return token - - def post_api_request( - self, - endpoint: str, - params: dict[str, Any] | None = None, - timeout: int = 5, - ) -> requests.Response: - """Wrap post requests.""" - - return self.post_request( - url=self.base_url + endpoint, - params=params, - timeout=timeout, - ) - - def post_request( - self, - url: str, - params: dict[str, Any] | None = None, - timeout: int = 5, - ) -> requests.Response: - """Wrap post requests.""" - - resp = requests.Response() - req_args = {"data": params if params is not None else {}} - - if "json" in req_args["data"]: - req_args["json"] = req_args["data"]["json"] - req_args.pop("data") - - if "https://" not in url: - try: - resp = requests.post(url, data=params, timeout=timeout) - except requests.exceptions.ChunkedEncodingError: - LOG.debug("Encoding error when connecting to '%s'", url) - except requests.exceptions.ConnectTimeout: - LOG.debug("Connection to %s timed out", url) - except requests.exceptions.ConnectionError: - LOG.debug("Remote end closed connection without response (%s)", url) - - else: - - def query( - url: str, - params: dict[str, Any], - timeout: int, - retries: int, - ) -> Any: - if retries == 0: - LOG.error("Too many retries") - return requests.Response() - - try: - return self._oauth.post(url=url, timeout=timeout, **params) - - except ( - TokenExpiredError, - requests.exceptions.ReadTimeout, - requests.exceptions.ConnectionError, - ): - self._oauth.token = self.refresh_tokens() - # Sleep for 1 sec to prevent authentication related - # timeouts after a token refresh. - sleep(1) - return query(url, params, timeout * 2, retries - 1) - - resp = query(url, req_args, timeout, 3) - - if resp.status_code is None: - LOG.debug("Resp is None - %s", resp) - return requests.Response() - - if not resp.ok: - LOG.debug( - "The Netatmo API returned %s (%s)", - resp.content, - resp.status_code, - ) - try: - raise ApiError( - f"{resp.status_code} - " - f"{ERRORS.get(resp.status_code, '')} - " - f"{resp.json()['error']['message']} " - f"({resp.json()['error']['code']}) " - f"when accessing '{url}'", - ) - - except JSONDecodeError as exc: - raise ApiError( - f"{resp.status_code} - " - f"{ERRORS.get(resp.status_code, '')} - " - f"when accessing '{url}'", - ) from exc - - if "application/json" in resp.headers.get( - "content-type", - [], - ) or resp.content not in [b"", b"None"]: - return resp - - return requests.Response() - - def get_authorization_url(self, state: str | None = None) -> Any: - """Return the authorization URL.""" - - return self._oauth.authorization_url(self.base_url + AUTH_URL_ENDPOINT, state) - - def request_token( - self, - authorization_response: str | None = None, - code: str | None = None, - ) -> Any: - """Request token.""" - - return self._oauth.fetch_token( - self.base_url + AUTH_REQ_ENDPOINT, - authorization_response=authorization_response, - code=code, - client_secret=self.client_secret, - include_client_id=True, - user_prefix=self.user_prefix, - ) - - def addwebhook(self, webhook_url: str) -> None: - """Register webhook.""" - - post_params = {"url": webhook_url} - resp = self.post_api_request(WEBHOOK_URL_ADD_ENDPOINT, post_params) - LOG.debug("addwebhook: %s", resp) - - def dropwebhook(self) -> None: - """Unregister webhook.""" - - post_params = {"app_types": "app_security"} - resp = self.post_api_request(WEBHOOK_URL_DROP_ENDPOINT, post_params) - LOG.debug("dropwebhook: %s", resp) - - -class ClientAuth(NetatmoOAuth2): - """Request authentication and keep access token available through token method.""" - - # Renew it automatically if necessary - # Args: - # clientId (str): Application clientId delivered by Netatmo on dev.netatmo.com - # clientSecret (str): Application Secret key delivered by Netatmo on dev.netatmo.com - # username (str) - # password (str) - # scope (Optional[str]): - # read_station: to retrieve weather station data (Getstationsdata, Getmeasure) - # read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture) - # access_camera: to access the camera, the videos and the live stream - # write_camera: to set home/away status of persons (Setpersonsaway, Setpersonshome) - # read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata) - # write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint) - # read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture) - # access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status - # read_homecoach: to retrieve Home Coache data (Gethomecoachsdata) - # read_smokedetector: to retrieve the smoke detector status (Gethomedata) - # Several value can be used at the same time, ie: 'read_station read_camera' - # user_prefix (Optional[str]) -- API prefix for the Netatmo customer - # base_url (str) -- Base URL of the Netatmo API (default: {_DEFAULT_BASE_URL}). - - def __init__( - self, - client_id: str, - client_secret: str, - username: str, - password: str, - scope: str = "read_station", - user_prefix: str | None = None, - base_url: str = DEFAULT_BASE_URL, - ) -> None: - """Initialize self.""" - - super().__init__( - client_id=client_id, - client_secret=client_secret, - scope=scope, - user_prefix=user_prefix, - base_url=base_url, - ) - - self._oauth = OAuth2Session( - client=LegacyApplicationClient(client_id=self.client_id), - ) - self._oauth.fetch_token( - token_url=self.base_url + AUTH_REQ_ENDPOINT, - username=username, - password=password, - client_id=self.client_id, - client_secret=self.client_secret, - scope=self.scope, - user_prefix=self.user_prefix, - ) - - class AbstractAsyncAuth(ABC): """Abstract class to make authenticated requests.""" @@ -338,14 +62,13 @@ async def async_get_image( headers=headers, timeout=timeout, ) as resp: - resp_status = resp.status resp_content = await resp.read() if resp.headers.get("content-type") == "image/jpeg": return resp_content raise ApiError( - f"{resp_status} - " + f"{resp.status} - " f"invalid content-type in response" f"when accessing '{url}'", ) @@ -373,12 +96,28 @@ async def async_post_request( ) -> ClientResponse: """Wrap async post requests.""" + access_token = await self.get_access_token() + headers = {AUTHORIZATION_HEADER: f"Bearer {access_token}"} + + req_args = self.prepare_request_arguments(params) + + async with self.websession.post( + url, + **req_args, + headers=headers, + timeout=timeout, + ) as resp: + return await self.process_response(resp, url) + + async def get_access_token(self): + """Get access token.""" try: - access_token = await self.async_get_access_token() + return await self.async_get_access_token() except ClientError as err: raise ApiError(f"Access token failure: {err}") from err - headers = {AUTHORIZATION_HEADER: f"Bearer {access_token}"} + def prepare_request_arguments(self, params): + """Prepare request arguments.""" req_args = {"data": params if params is not None else {}} if "params" in req_args["data"]: @@ -389,43 +128,49 @@ async def async_post_request( req_args["json"] = req_args["data"]["json"] req_args.pop("data") - async with self.websession.post( - url, - **req_args, - headers=headers, - timeout=timeout, - ) as resp: - resp_status = resp.status - resp_content = await resp.read() + return req_args + + async def process_response(self, resp, url): + """Process response.""" + resp_status = resp.status + resp_content = await resp.read() + + if not resp.ok: + LOG.debug("The Netatmo API returned %s (%s)", resp_content, resp_status) + await self.handle_error_response(resp, resp_status, url) + + return await self.handle_success_response(resp, resp_content) + + async def handle_error_response(self, resp, resp_status, url): + """Handle error response.""" + try: + resp_json = await resp.json() + raise ApiError( + f"{resp_status} - " + f"{ERRORS.get(resp_status, '')} - " + f"{resp_json['error']['message']} " + f"({resp_json['error']['code']}) " + f"when accessing '{url}'", + ) + + except (JSONDecodeError, ContentTypeError) as exc: + raise ApiError( + f"{resp_status} - " + f"{ERRORS.get(resp_status, '')} - " + f"when accessing '{url}'", + ) from exc + + async def handle_success_response(self, resp, resp_content): + """Handle success response.""" + try: + if "application/json" in resp.headers.get("content-type", []): + return resp + + if resp_content not in [b"", b"None"]: + return resp - if not resp.ok: - LOG.debug("The Netatmo API returned %s (%s)", resp_content, resp_status) - try: - resp_json = await resp.json() - raise ApiError( - f"{resp_status} - " - f"{ERRORS.get(resp_status, '')} - " - f"{resp_json['error']['message']} " - f"({resp_json['error']['code']}) " - f"when accessing '{url}'", - ) - - except (JSONDecodeError, ContentTypeError) as exc: - raise ApiError( - f"{resp_status} - " - f"{ERRORS.get(resp_status, '')} - " - f"when accessing '{url}'", - ) from exc - - try: - if "application/json" in resp.headers.get("content-type", []): - return resp - - if resp_content not in [b"", b"None"]: - return resp - - except (TypeError, AttributeError): - LOG.debug("Invalid response %s", resp) + except (TypeError, AttributeError): + LOG.debug("Invalid response %s", resp) return resp diff --git a/src/pyatmo/camera.py b/src/pyatmo/camera.py deleted file mode 100644 index 7b1f5d5d..00000000 --- a/src/pyatmo/camera.py +++ /dev/null @@ -1,837 +0,0 @@ -"""Support for Netatmo security devices (cameras, smoke detectors, sirens, window sensors, events and persons).""" -from __future__ import annotations - -from abc import ABC -from collections import defaultdict -import imghdr # pylint: disable=deprecated-module -import time -from typing import Any -from warnings import warn - -import aiohttp -from requests.exceptions import ReadTimeout - -from pyatmo.auth import AbstractAsyncAuth, NetatmoOAuth2 -from pyatmo.const import ( - GETCAMERAPICTURE_ENDPOINT, - GETEVENTSUNTIL_ENDPOINT, - GETHOMEDATA_ENDPOINT, - SETPERSONSAWAY_ENDPOINT, - SETPERSONSHOME_ENDPOINT, - SETSTATE_ENDPOINT, -) -from pyatmo.exceptions import ApiError, NoDevice -from pyatmo.helpers import LOG, extract_raw_data - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -class AbstractCameraData(ABC): - """Abstract class of Netatmo camera data.""" - - raw_data: dict = defaultdict(dict) - homes: dict = defaultdict(dict) - persons: dict = defaultdict(dict) - events: dict = defaultdict(dict) - outdoor_events: dict = defaultdict(dict) - cameras: dict = defaultdict(dict) - smoke_detectors: dict = defaultdict(dict) - modules: dict = {} - last_event: dict = {} - outdoor_last_event: dict = {} - types: dict = defaultdict(dict) - - def process(self) -> None: - """Process data from API.""" - self.homes = {d["id"]: d for d in self.raw_data} - - for item in self.raw_data: - home_id: str = item.get("id", "") - - if not item.get("name"): - self.homes[home_id]["name"] = "Unknown" - - self._store_events(events=item.get("events", [])) - self._store_cameras(cameras=item.get("cameras", []), home_id=home_id) - self._store_smoke_detectors( - smoke_detectors=item.get("smokedetectors", []), - home_id=home_id, - ) - for person in item.get("persons", []): - self.persons[home_id][person["id"]] = person - - def _store_persons(self, persons: list) -> None: - for person in persons: - self.persons[person["id"]] = person - - def _store_smoke_detectors(self, smoke_detectors: list, home_id: str) -> None: - for smoke_detector in smoke_detectors: - self.smoke_detectors[home_id][smoke_detector["id"]] = smoke_detector - self.types[home_id][smoke_detector["type"]] = smoke_detector - - def _store_cameras(self, cameras: list, home_id: str) -> None: - for camera in cameras: - self.cameras[home_id][camera["id"]] = camera - self.types[home_id][camera["type"]] = camera - - if camera.get("name") is None: - self.cameras[home_id][camera["id"]]["name"] = camera["type"] - - self.cameras[home_id][camera["id"]]["home_id"] = home_id - if camera["type"] == "NACamera": - for module in camera.get("modules", []): - self.modules[module["id"]] = module - self.modules[module["id"]]["cam_id"] = camera["id"] - - def _store_events(self, events: list) -> None: - """Store all events.""" - for event in events: - if event["type"] == "outdoor": - self.outdoor_events[event["camera_id"]][event["time"]] = event - - else: - self.events[event["camera_id"]][event["time"]] = event - - def _store_last_event(self) -> None: - """Store last event for fast access.""" - for camera in self.events: - self.last_event[camera] = self.events[camera][ - sorted(self.events[camera])[-1] - ] - - for camera in self.outdoor_events: - self.outdoor_last_event[camera] = self.outdoor_events[camera][ - sorted(self.outdoor_events[camera])[-1] - ] - - def get_camera(self, camera_id: str) -> dict[str, str]: - """Get camera data.""" - return next( - ( - self.cameras[home_id][camera_id] - for home_id in self.cameras - if camera_id in self.cameras[home_id] - ), - {}, - ) - - def get_camera_home_id(self, camera_id: str) -> str | None: - """Get camera data.""" - return next( - (home_id for home_id in self.cameras if camera_id in self.cameras[home_id]), - None, - ) - - def get_module(self, module_id: str) -> dict | None: - """Get module data.""" - return None if module_id not in self.modules else self.modules[module_id] - - def get_smokedetector(self, smoke_id: str) -> dict | None: - """Get smoke detector.""" - return next( - ( - self.smoke_detectors[home_id][smoke_id] - for home_id in self.smoke_detectors - if smoke_id in self.smoke_detectors[home_id] - ), - None, - ) - - def camera_urls(self, camera_id: str) -> tuple[str | None, str | None]: - """Return the vpn_url and the local_url (if available) of a given camera.""" - - camera_data = self.get_camera(camera_id) - return camera_data.get("vpn_url", None), camera_data.get("local_url", None) - - def get_light_state(self, camera_id: str) -> str | None: - """Return the current mode of the floodlight of a presence camera.""" - camera_data = self.get_camera(camera_id) - if camera_data is None: - raise ValueError("Invalid Camera ID") - - return camera_data.get("light_mode_status") - - def persons_at_home(self, home_id: str | None = None) -> list: - """Return a list of known persons who are currently at home.""" - home_data = self.homes.get(home_id, {}) - return [ - person["pseudo"] - for person in home_data.get("persons", []) - if "pseudo" in person and not person["out_of_sight"] - ] - - def get_person_id(self, name: str, home_id: str) -> str | None: - """Retrieve the ID of a person.""" - return next( - ( - pid - for pid, data in self.persons[home_id].items() - if name == data.get("pseudo") - ), - None, - ) - - def build_event_id(self, event_id: str | None, device_type: str | None): - """Build event id.""" - - def get_event_id(data: dict): - events = {e["time"]: e for e in data.values()} - return min(events.items())[1].get("id") - - if not event_id: - # If no event is provided we need to retrieve the oldest of - # the last event seen by each camera - if device_type == "NACamera": - # for the Welcome camera - event_id = get_event_id(self.last_event) - - elif device_type in {"NOC", "NSD"}: - # for the Presence camera and for the smoke detector - event_id = get_event_id(self.outdoor_last_event) - - return event_id - - def person_seen_by_camera( - self, - name: str, - camera_id: str, - exclude: int = 0, - ) -> bool: - """Evaluate if a specific person has been seen.""" - home_id = self.get_camera_home_id(camera_id) - - if home_id is None: - raise NoDevice - - def _person_in_event(home_id: str, curr_event: dict, person_name: str) -> bool: - person_id = curr_event.get("person_id") - return ( - curr_event["type"] == "person" - and self.persons[home_id][person_id].get("pseudo") == person_name - ) - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events[camera_id], reverse=True) - - for time_ev in array_time_event: - if time_ev < limit: - return False - - current_event = self.events[camera_id][time_ev] - if _person_in_event(home_id, current_event, name): - return True - - return False - - current_event = self.last_event[camera_id] - return _person_in_event(home_id, current_event, name) - - def _known_persons(self, home_id: str) -> dict[str, dict]: - """Return all known persons.""" - return {pid: p for pid, p in self.persons[home_id].items() if "pseudo" in p} - - def known_persons(self, home_id: str) -> dict[str, str]: - """Return a dictionary of known person names.""" - return {pid: p["pseudo"] for pid, p in self._known_persons(home_id).items()} - - def known_persons_names(self, home_id: str) -> list[str]: - """Return a list of known person names.""" - return [person["pseudo"] for person in self._known_persons(home_id).values()] - - def someone_known_seen(self, camera_id: str, exclude: int = 0) -> bool: - """Evaluate if someone known has been seen.""" - if camera_id not in self.events: - raise NoDevice - - if (home_id := self.get_camera_home_id(camera_id)) is None: - raise NoDevice - - def _someone_known_seen(event: dict, home_id: str) -> bool: - return event["type"] == "person" and event[ - "person_id" - ] in self._known_persons(home_id) - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events[camera_id], reverse=True) - seen = False - - for time_ev in array_time_event: - if time_ev < limit: - continue - if seen := _someone_known_seen( - self.events[camera_id][time_ev], - home_id, - ): - break - - return seen - - return _someone_known_seen(self.last_event[camera_id], home_id) - - def someone_unknown_seen(self, camera_id: str, exclude: int = 0) -> bool: - """Evaluate if someone known has been seen.""" - if camera_id not in self.events: - raise NoDevice - - if (home_id := self.get_camera_home_id(camera_id)) is None: - raise NoDevice - - def _someone_unknown_seen(event: dict, home_id: str) -> bool: - return event["type"] == "person" and event[ - "person_id" - ] not in self._known_persons(home_id) - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events[camera_id], reverse=True) - seen = False - - for time_ev in array_time_event: - if time_ev < limit: - continue - - if seen := _someone_unknown_seen( - self.events[camera_id][time_ev], - home_id, - ): - break - - return seen - - return _someone_unknown_seen(self.last_event[camera_id], home_id) - - def motion_detected(self, camera_id: str, exclude: int = 0) -> bool: - """Evaluate if movement has been detected.""" - if camera_id not in self.events: - raise NoDevice - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events[camera_id], reverse=True) - - for time_ev in array_time_event: - if time_ev < limit: - return False - - if self.events[camera_id][time_ev]["type"] == "movement": - return True - - elif self.last_event[camera_id]["type"] == "movement": - return True - - return False - - def outdoor_motion_detected(self, camera_id: str, offset: int = 0) -> bool: - """Evaluate if outdoor movement has been detected.""" - if camera_id not in self.last_event: - return False - - last_event = self.last_event[camera_id] - return ( - last_event["type"] == "movement" - and last_event["video_status"] == "recording" - and last_event["time"] + offset > int(time.time()) - ) - - def _object_detected(self, object_name: str, camera_id: str, offset: int) -> bool: - """Evaluate if an object has been detected.""" - if self.outdoor_last_event[camera_id]["video_status"] == "recording": - for event in self.outdoor_last_event[camera_id]["event_list"]: - if event["type"] == object_name and ( - event["time"] + offset > int(time.time()) - ): - return True - - return False - - def human_detected(self, camera_id: str, offset: int = 0) -> bool: - """Evaluate if a human has been detected.""" - return self._object_detected("human", camera_id, offset) - - def animal_detected(self, camera_id: str, offset: int = 0) -> bool: - """Evaluate if an animal has been detected.""" - return self._object_detected("animal", camera_id, offset) - - def car_detected(self, camera_id: str, offset: int = 0) -> bool: - """Evaluate if a car has been detected.""" - return self._object_detected("vehicle", camera_id, offset) - - def module_motion_detected( - self, - module_id: str, - camera_id: str, - exclude: int = 0, - ) -> bool: - """Evaluate if movement has been detected.""" - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events.get(camera_id, []), reverse=True) - - for time_ev in array_time_event: - if time_ev < limit: - return False - - curr_event = self.events[camera_id][time_ev] - if ( - curr_event["type"] in {"tag_big_move", "tag_small_move"} - and curr_event["module_id"] == module_id - ): - return True - - else: - if camera_id not in self.last_event: - return False - - curr_event = self.last_event[camera_id] - if ( - curr_event["type"] in {"tag_big_move", "tag_small_move"} - and curr_event["module_id"] == module_id - ): - return True - - return False - - def module_opened(self, module_id: str, camera_id: str, exclude: int = 0) -> bool: - """Evaluate if module status is open.""" - - if exclude: - limit = time.time() - exclude - array_time_event = sorted(self.events.get(camera_id, []), reverse=True) - - for time_ev in array_time_event: - if time_ev < limit: - return False - - curr_event = self.events[camera_id][time_ev] - if ( - curr_event["type"] == "tag_open" - and curr_event["module_id"] == module_id - ): - return True - - else: - if camera_id not in self.last_event: - return False - - curr_event = self.last_event[camera_id] - if ( - curr_event["type"] == "tag_open" - and curr_event["module_id"] == module_id - ): - return True - - return False - - def build_state_params( - self, - camera_id: str, - home_id: str | None, - floodlight: str | None, - monitoring: str | None, - ): - """Build camera state parameters.""" - - if home_id is None: - home_id = self.get_camera(camera_id)["home_id"] - - module = {"id": camera_id} - - if floodlight: - param, val = "floodlight", floodlight.lower() - if val not in {"on", "off", "auto"}: - LOG.error("Invalid value for floodlight") - else: - module[param] = val - - if monitoring: - param, val = "monitoring", monitoring.lower() - if val not in {"on", "off"}: - LOG.error("Invalid value for monitoring") - else: - module[param] = val - - return {"id": home_id, "modules": [module]} - - -class CameraData(AbstractCameraData): - """Class of Netatmo camera data.""" - - def __init__(self, auth: NetatmoOAuth2) -> None: - """Initialize the Netatmo camera data.""" - - self.auth = auth - - def update(self, events: int = 30) -> None: - """Fetch and process data from API.""" - resp = self.auth.post_api_request( - endpoint=GETHOMEDATA_ENDPOINT, - params={"size": events}, - ) - - self.raw_data = extract_raw_data(resp.json(), "homes") - self.process() - self._update_all_camera_urls() - self._store_last_event() - - def _update_all_camera_urls(self) -> None: - """Update all camera urls.""" - - for home_id in self.homes: - for camera_id in self.cameras[home_id]: - self.update_camera_urls(camera_id) - - def update_camera_urls(self, camera_id: str) -> None: - """Update and validate the camera urls.""" - - camera_data = self.get_camera(camera_id) - home_id = camera_data["home_id"] - - if not camera_data or camera_data.get("status") == "disconnected": - self.cameras[home_id][camera_id]["local_url"] = None - self.cameras[home_id][camera_id]["vpn_url"] = None - return - - if (vpn_url := camera_data.get("vpn_url")) and camera_data.get("is_local"): - if temp_local_url := self._check_url(vpn_url): - if local_url := self._check_url(temp_local_url): - self.cameras[home_id][camera_id]["local_url"] = local_url - else: - LOG.warning( - "Invalid IP for camera %s (%s)", - self.cameras[home_id][camera_id]["name"], - temp_local_url, - ) - self.cameras[home_id][camera_id]["is_local"] = False - - def _check_url(self, url: str) -> str | None: - """Check if the url is valid.""" - - if url.startswith("http://169.254"): - return None - resp_json = {} - try: - resp = self.auth.post_request(url=f"{url}/command/ping") - if resp.status_code: - resp_json = resp.json() - else: - raise ReadTimeout - except ReadTimeout: - LOG.debug("Timeout validation of camera url %s", url) - return None - except ApiError: - LOG.debug("Api error for camera url %s", url) - return None - - return resp_json.get("local_url") if resp_json else None - - def set_state( - self, - camera_id: str, - home_id: str | None = None, - floodlight: str | None = None, - monitoring: str | None = None, - ) -> bool: - """Turn camera (light) on/off.""" - - # Arguments: - # camera_id {str} -- ID of a camera - # home_id {str} -- ID of a home - # floodlight {str} -- Mode for floodlight (on/off/auto) - # monitoring {str} -- Mode for monitoring (on/off) - - # Returns: - # Boolean -- Success of the request - - post_params = { - "json": { - "home": self.build_state_params( - camera_id, - home_id, - floodlight, - monitoring, - ), - }, - } - - try: - resp = self.auth.post_api_request( - endpoint=SETSTATE_ENDPOINT, - params=post_params, - ).json() - except ApiError as err_msg: - LOG.error("%s", err_msg) - return False - - if "error" in resp: - LOG.debug("%s", resp) - return False - - LOG.debug("%s", resp) - return True - - def set_persons_home(self, home_id: str, person_ids: list[str] | None = None): - """Mark persons as home.""" - post_params: dict[str, str | list] = {"home_id": home_id} - if person_ids: - post_params["person_ids[]"] = person_ids - return self.auth.post_api_request( - endpoint=SETPERSONSHOME_ENDPOINT, - params=post_params, - ).json() - - def set_persons_away(self, home_id: str, person_id: str | None = None): - """Mark a person as away or set the whole home to being empty.""" - post_params = {"home_id": home_id, "person_id": person_id} - return self.auth.post_api_request( - endpoint=SETPERSONSAWAY_ENDPOINT, - params=post_params, - ).json() - - def get_camera_picture( - self, - image_id: str, - key: str, - ) -> tuple[bytes, str | None]: - """Download a specific image (of an event or user face) from the camera.""" - post_params = {"image_id": image_id, "key": key} - resp = self.auth.post_api_request( - endpoint=GETCAMERAPICTURE_ENDPOINT, - params=post_params, - ).content - image_type = imghdr.what("NONE.FILE", resp) - return resp, image_type - - def get_profile_image( - self, - name: str, - home_id: str, - ) -> tuple[bytes | None, str | None]: - """Retrieve the face of a given person.""" - for person in self.persons[home_id].values(): - if name == person.get("pseudo"): - image_id = person["face"]["id"] - key = person["face"]["key"] - return self.get_camera_picture(image_id, key) - - return None, None - - def update_events( - self, - home_id: str, - event_id: str | None = None, - device_type: str | None = None, - ) -> None: - """Update the list of events.""" - if not (event_id or device_type): - raise ApiError - - post_params = { - "home_id": home_id, - "event_id": self.build_event_id(event_id, device_type), - } - - event_list: list = [] - resp: dict[str, Any] | None = None - try: - resp = self.auth.post_api_request( - endpoint=GETEVENTSUNTIL_ENDPOINT, - params=post_params, - ).json() - if resp is not None: - event_list = resp["body"]["events_list"] - except ApiError: - pass - except KeyError: - if resp is not None: - LOG.debug("event_list response: %s", resp) - LOG.debug("event_list body: %s", dict(resp)["body"]) - else: - LOG.debug("No resp received") - - self._store_events(event_list) - self._store_last_event() - - -class AsyncCameraData(AbstractCameraData): - """Class of Netatmo camera data.""" - - def __init__(self, auth: AbstractAsyncAuth) -> None: - """Initialize the Netatmo camera data.""" - - self.auth = auth - - async def async_update(self, events: int = 30) -> None: - """Fetch and process data from API.""" - - resp = await self.auth.async_post_api_request( - endpoint=GETHOMEDATA_ENDPOINT, - params={"size": events}, - ) - - assert not isinstance(resp, bytes) - self.raw_data = extract_raw_data(await resp.json(), "homes") - self.process() - - try: - await self._async_update_all_camera_urls() - except (aiohttp.ContentTypeError, aiohttp.ClientConnectorError) as err: - LOG.debug("One or more camera could not be reached. (%s)", err) - - self._store_last_event() - - async def _async_update_all_camera_urls(self) -> None: - """Update all camera urls.""" - - for home_id in self.homes: - for camera_id in self.cameras[home_id]: - await self.async_update_camera_urls(camera_id) - - async def async_set_state( - self, - camera_id: str, - home_id: str | None = None, - floodlight: str | None = None, - monitoring: str | None = None, - ) -> bool: - """Turn camera (light) on/off.""" - - # Arguments: - # camera_id {str} -- ID of a camera - # home_id {str} -- ID of a home - # floodlight {str} -- Mode for floodlight (on/off/auto) - # monitoring {str} -- Mode for monitoring (on/off) - - post_params = { - "json": { - "home": self.build_state_params( - camera_id, - home_id, - floodlight, - monitoring, - ), - }, - } - - try: - resp = await self.auth.async_post_api_request( - endpoint=SETSTATE_ENDPOINT, - params=post_params, - ) - except ApiError as err_msg: - LOG.error("%s", err_msg) - return False - - assert not isinstance(resp, bytes) - resp_data = await resp.json() - - if "error" in resp_data: - LOG.debug("%s", resp_data) - return False - - LOG.debug("%s", resp_data) - return True - - async def async_update_camera_urls(self, camera_id: str) -> None: - """Update and validate the camera urls.""" - camera_data = self.get_camera(camera_id) - home_id = camera_data["home_id"] - - if not camera_data or camera_data.get("status") == "disconnected": - self.cameras[home_id][camera_id]["local_url"] = None - self.cameras[home_id][camera_id]["vpn_url"] = None - return - - if (vpn_url := camera_data.get("vpn_url")) and camera_data.get("is_local"): - temp_local_url = await self._async_check_url(vpn_url) - if temp_local_url: - self.cameras[home_id][camera_id][ - "local_url" - ] = await self._async_check_url( - temp_local_url, - ) - - async def _async_check_url(self, url: str) -> str | None: - """Validate camera url.""" - try: - resp = await self.auth.async_post_request(url=f"{url}/command/ping") - except ReadTimeout: - LOG.debug("Timeout validation of camera url %s", url) - return None - except ApiError: - LOG.debug("Api error for camera url %s", url) - return None - - assert not isinstance(resp, bytes) - resp_data = await resp.json() - return resp_data.get("local_url") if resp_data else None - - async def async_set_persons_home( - self, - home_id: str, - person_ids: list[str] | None = None, - ): - """Mark persons as home.""" - post_params: dict[str, str | list] = {"home_id": home_id} - if person_ids: - post_params["person_ids[]"] = person_ids - return await self.auth.async_post_api_request( - endpoint=SETPERSONSHOME_ENDPOINT, - params=post_params, - ) - - async def async_set_persons_away(self, home_id: str, person_id: str | None = None): - """Mark a person as away or set the whole home to being empty.""" - post_params = {"home_id": home_id} - if person_id: - post_params["person_id"] = person_id - return await self.auth.async_post_api_request( - endpoint=SETPERSONSAWAY_ENDPOINT, - params=post_params, - ) - - async def async_get_live_snapshot(self, camera_id: str) -> bytes | None: - """Retrieve live snapshot from camera.""" - local, vpn = self.camera_urls(camera_id) - if not local and not vpn: - return None - resp = await self.auth.async_get_image( - endpoint=f"{(local or vpn)}/live/snapshot_720.jpg", - timeout=10, - ) - - return resp if isinstance(resp, bytes) else None - - async def async_get_camera_picture( - self, - image_id: str, - key: str, - ) -> tuple[bytes, str | None]: - """Download a specific image (of an event or user face) from the camera.""" - post_params = {"image_id": image_id, "key": key} - resp = await self.auth.async_get_image( - endpoint=GETCAMERAPICTURE_ENDPOINT, - params=post_params, - ) - - return ( - (resp, imghdr.what("NONE.FILE", resp)) - if isinstance(resp, bytes) - else (b"", None) - ) - - async def async_get_profile_image( - self, - name: str, - home_id: str, - ) -> tuple[bytes | None, str | None]: - """Retrieve the face of a given person.""" - for person in self.persons[home_id].values(): - if name == person.get("pseudo"): - image_id = person["face"]["id"] - key = person["face"]["key"] - return await self.async_get_camera_picture(image_id, key) - - return None, None diff --git a/src/pyatmo/const.py b/src/pyatmo/const.py index 58538f76..fccbd6a6 100644 --- a/src/pyatmo/const.py +++ b/src/pyatmo/const.py @@ -56,26 +56,28 @@ "access_doorbell", # Netatmo Smart Video Doorbell "access_presence", # Netatmo Smart Outdoor Camera "read_bubendorff", # Bubbendorf shutters + "read_bfi", # BTicino IP "read_camera", # Netatmo camera products "read_carbonmonoxidedetector", # Netatmo CO sensor "read_doorbell", # Netatmo Smart Video Doorbell "read_homecoach", # Netatmo Smart Indoor Air Quality Monitor "read_magellan", # Legrand Wiring device or Electrical panel products + "read_mhs1", # Bticino MyHome Server 1 modules "read_mx", # BTicino Classe 300 EOS "read_presence", # Netatmo Smart Outdoor Camera "read_smarther", # Smarther with Netatmo thermostat "read_smokedetector", # Smart Smoke Alarm information and events "read_station", # Netatmo weather station "read_thermostat", # Netatmo climate products - "read_mhs1", # Bticino MyHome Server 1 modules "write_bubendorff", # Bubbendorf shutters + "write_bfi", # BTicino IP "write_camera", # Netatmo camera products "write_magellan", # Legrand Wiring device or Electrical panel products + "write_mhs1", # Bticino MyHome Server 1 modules "write_mx", # BTicino Classe 300 EOS "write_presence", # Netatmo Smart Outdoor Camera "write_smarther", # Smarther products "write_thermostat", # Netatmo climate products - "write_mhs1", # Bticino MyHome Server 1 modules ] MANUAL = "manual" diff --git a/src/pyatmo/helpers.py b/src/pyatmo/helpers.py index d905eab2..1b538cdb 100644 --- a/src/pyatmo/helpers.py +++ b/src/pyatmo/helpers.py @@ -1,11 +1,8 @@ """Collection of helper functions.""" from __future__ import annotations -from calendar import timegm -from datetime import datetime, timezone import logging -import time -from typing import Any +from typing import Any, cast from pyatmo.const import RawData from pyatmo.exceptions import NoDevice @@ -13,29 +10,6 @@ LOG: logging.Logger = logging.getLogger(__name__) -def to_time_string(value: str) -> str: - """Convert epoch to time string.""" - - return ( - datetime.fromtimestamp(int(value), tz=timezone.utc) - .isoformat(sep="_") - .split("+")[0] - ) - - -def to_epoch(value: str) -> int: - """Convert time string to epoch.""" - - return timegm(time.strptime(f"{value}GMT", "%Y-%m-%d_%H:%M:%S%Z")) - - -def today_stamps() -> tuple[int, int]: - """Return today's start and end timestamps.""" - - today: int = timegm(time.strptime(time.strftime("%Y-%m-%d") + "GMT", "%Y-%m-%d%Z")) - return today, today + 3600 * 24 - - def fix_id(raw_data: RawData) -> dict[str, Any]: """Fix known errors in station ids like superfluous spaces.""" @@ -45,10 +19,10 @@ def fix_id(raw_data: RawData) -> dict[str, Any]: for station in raw_data: if not isinstance(station, dict): continue - if "_id" not in station: + if station.get("_id") is None: continue - station["_id"] = station["_id"].replace(" ", "") + station["_id"] = cast(dict, station)["_id"].replace(" ", "") for module in station.get("modules", {}): module["_id"] = module["_id"].replace(" ", "") @@ -57,24 +31,6 @@ def fix_id(raw_data: RawData) -> dict[str, Any]: def extract_raw_data(resp: Any, tag: str) -> dict[str, Any]: - """Extract raw data from server response.""" - if ( - resp is None - or "body" not in resp - or tag not in resp["body"] - or ("errors" in resp["body"] and "modules" not in resp["body"][tag]) - ): - LOG.debug("Server response: %s", resp) - raise NoDevice("No device found, errors in response") - - if not (raw_data := fix_id(resp["body"].get(tag))): - LOG.debug("Server response: %s", resp) - raise NoDevice("No device data available") - - return raw_data - - -def extract_raw_data_new(resp: Any, tag: str) -> dict[str, Any]: """Extract raw data from server response.""" raw_data = {} diff --git a/src/pyatmo/home_coach.py b/src/pyatmo/home_coach.py deleted file mode 100644 index 020dc92d..00000000 --- a/src/pyatmo/home_coach.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Support for Netatmo air care devices.""" -from warnings import warn - -from pyatmo.auth import AbstractAsyncAuth, NetatmoOAuth2 -from pyatmo.const import GETHOMECOACHDATA_ENDPOINT -from pyatmo.weather_station import AsyncWeatherStationData, WeatherStationData - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -class HomeCoachData(WeatherStationData): - """Class of Netatmo Home Coach devices (stations and modules).""" - - def __init__(self, auth: NetatmoOAuth2) -> None: - """Initialize self.""" - super().__init__(auth, endpoint=GETHOMECOACHDATA_ENDPOINT, favorites=False) - - -class AsyncHomeCoachData(AsyncWeatherStationData): - """Class of Netatmo Home Coach devices (stations and modules).""" - - def __init__(self, auth: AbstractAsyncAuth) -> None: - """Initialize self.""" - super().__init__(auth, endpoint=GETHOMECOACHDATA_ENDPOINT, favorites=False) diff --git a/src/pyatmo/modules/__init__.py b/src/pyatmo/modules/__init__.py index ffa4898c..4e697963 100644 --- a/src/pyatmo/modules/__init__.py +++ b/src/pyatmo/modules/__init__.py @@ -9,6 +9,7 @@ BNEU, BNFC, BNIL, + BNLD, BNMH, BNMS, BNSL, @@ -81,23 +82,25 @@ from .somfy import TPSRS __all__ = [ - "BNMS", - "BNAS", "BNAB", - "BNMH", - "BNTH", - "BNFC", - "BNTR", - "BNXM", + "BNAS", "BNCS", "BNCX", "BNDL", "BNEU", + "BNFC", + "BNIL", + "BNLD", + "BNMH", + "BNMS", "BNS", "BNSL", - "BNIL", + "BNTH", + "BNTR", + "BNXM", "Camera", "Dimmer", + "EBU", "Location", "Module", "NACamDoorTag", @@ -117,6 +120,8 @@ "NDB", "NHC", "NIS", + "NLAO", + "NLAS", "NLC", "NLD", "NLDD", @@ -126,7 +131,9 @@ "NLFN", "NLG", "NLIS", + "NLJ", "NLL", + "NLLF", "NLLM", "NLLV", "NLM", @@ -139,16 +146,13 @@ "NLPS", "NLPT", "NLT", + "NLTS", "NLUF", "NLUI", - "NLAO", - "NLLF", - "NLUO", "NLunknown", + "NLUO", "NLUP", "NLV", - "EBU", - "Z3L", "NOC", "NRV", "NSD", @@ -159,7 +163,5 @@ "Shutter", "Switch", "TPSRS", - "NLAS", - "NLTS", - "NLJ", + "Z3L", ] diff --git a/src/pyatmo/modules/bticino.py b/src/pyatmo/modules/bticino.py index 33391a52..f53d7eed 100644 --- a/src/pyatmo/modules/bticino.py +++ b/src/pyatmo/modules/bticino.py @@ -3,7 +3,7 @@ import logging -from pyatmo.modules.module import Module, Switch +from pyatmo.modules.module import Dimmer, Module, Switch LOG = logging.getLogger(__name__) @@ -62,3 +62,7 @@ class BNTR(Module): class BNIL(Switch): """BTicino itelligent light.""" + + +class BNLD(Dimmer): + """BTicino dimmer light.""" diff --git a/src/pyatmo/modules/device_types.py b/src/pyatmo/modules/device_types.py index 71873d9a..82b23efe 100644 --- a/src/pyatmo/modules/device_types.py +++ b/src/pyatmo/modules/device_types.py @@ -92,6 +92,7 @@ class DeviceType(str, Enum): BNFC = "BNFC" # fan coil BNTR = "BNTR" # radiator BNIL = "BNIL" # intelligent light + BNLD = "BNLD" # dimmer light # Bubbendorf shutters NBG = "NBG" # gateway @@ -194,6 +195,8 @@ class DeviceCategory(str, Enum): DeviceType.NLPD: DeviceCategory.switch, DeviceType.NLJ: DeviceCategory.shutter, DeviceType.BNIL: DeviceCategory.switch, + DeviceType.BNLD: DeviceCategory.dimmer, + DeviceType.NIS: DeviceCategory.siren, } @@ -264,6 +267,7 @@ class DeviceCategory(str, Enum): DeviceType.BNFC: ("BTicino", "Fan coil"), DeviceType.BNTR: ("BTicino", "Module towel rail"), DeviceType.BNIL: ("BTicino", "Intelligent light"), + DeviceType.BNLD: ("BTicino", "Dimmer"), # Bubbendorf shutters DeviceType.NBG: ("Bubbendorf", "Gateway"), DeviceType.NBR: ("Bubbendorf", "Roller Shutter"), diff --git a/src/pyatmo/modules/legrand.py b/src/pyatmo/modules/legrand.py index 21d97dee..03ab0a88 100644 --- a/src/pyatmo/modules/legrand.py +++ b/src/pyatmo/modules/legrand.py @@ -157,7 +157,7 @@ class NLTS(Module): """NLTS motion sensor.""" -class NLPD(FirmwareMixin, SwitchMixin, Module): +class NLPD(FirmwareMixin, SwitchMixin, EnergyMixin, PowerMixin, Module): """NLPD dry contact.""" diff --git a/src/pyatmo/modules/somfy.py b/src/pyatmo/modules/somfy.py index d364f795..05c0f34d 100644 --- a/src/pyatmo/modules/somfy.py +++ b/src/pyatmo/modules/somfy.py @@ -3,12 +3,12 @@ import logging -from pyatmo.modules.module import FirmwareMixin, Module, RfMixin, ShutterMixin +from pyatmo.modules.module import RfMixin, Shutter LOG = logging.getLogger(__name__) -class TPSRS(FirmwareMixin, RfMixin, ShutterMixin, Module): +class TPSRS(RfMixin, Shutter): """Class to represent a somfy TPSRS.""" ... diff --git a/src/pyatmo/public_data.py b/src/pyatmo/public_data.py deleted file mode 100644 index 117f8628..00000000 --- a/src/pyatmo/public_data.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Support for Netatmo public weather data.""" -from __future__ import annotations - -from abc import ABC -from collections import defaultdict -import dataclasses -from typing import Any -from warnings import warn - -from pyatmo.auth import AbstractAsyncAuth, NetatmoOAuth2 -from pyatmo.const import ( - ACCESSORY_GUST_ANGLE_TYPE, - ACCESSORY_GUST_STRENGTH_TYPE, - ACCESSORY_RAIN_24H_TYPE, - ACCESSORY_RAIN_60MIN_TYPE, - ACCESSORY_RAIN_LIVE_TYPE, - ACCESSORY_RAIN_TIME_TYPE, - ACCESSORY_WIND_ANGLE_TYPE, - ACCESSORY_WIND_STRENGTH_TYPE, - ACCESSORY_WIND_TIME_TYPE, - GETPUBLIC_DATA_ENDPOINT, - STATION_HUMIDITY_TYPE, - STATION_PRESSURE_TYPE, - STATION_TEMPERATURE_TYPE, -) -from pyatmo.exceptions import NoDevice -from pyatmo.modules import Location - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -class AbstractPublicData(ABC): - """Class of Netatmo public weather data.""" - - raw_data: dict = defaultdict(dict) - status: str = "" - - def process(self, resp: dict) -> None: - """Process data from API.""" - - self.status = resp.get("status", "") - - def stations_in_area(self) -> int: - """Return number of stations in area.""" - - return len(self.raw_data) - - def get_latest_rain(self) -> dict: - """Return latest rain measures.""" - - return self.get_accessory_data(ACCESSORY_RAIN_LIVE_TYPE) - - def get_average_rain(self) -> float: - """Return average rain measures.""" - - return average(self.get_latest_rain()) - - def get_60_min_rain(self) -> dict: - """Return 60 min rain measures.""" - - return self.get_accessory_data(ACCESSORY_RAIN_60MIN_TYPE) - - def get_average_60_min_rain(self) -> float: - """Return average 60 min rain measures.""" - - return average(self.get_60_min_rain()) - - def get_24_h_rain(self) -> dict: - """Return 24 h rain measures.""" - - return self.get_accessory_data(ACCESSORY_RAIN_24H_TYPE) - - def get_average_24_h_rain(self) -> float: - """Return average 24 h rain measures.""" - - return average(self.get_24_h_rain()) - - def get_latest_pressures(self) -> dict: - """Return latest pressure measures.""" - - return self.get_latest_station_measures(STATION_PRESSURE_TYPE) - - def get_average_pressure(self) -> float: - """Return average pressure measures.""" - - return average(self.get_latest_pressures()) - - def get_latest_temperatures(self) -> dict: - """Return latest temperature measures.""" - - return self.get_latest_station_measures(STATION_TEMPERATURE_TYPE) - - def get_average_temperature(self) -> float: - """Return average temperature measures.""" - - return average(self.get_latest_temperatures()) - - def get_latest_humidities(self) -> dict: - """Return latest humidity measures.""" - - return self.get_latest_station_measures(STATION_HUMIDITY_TYPE) - - def get_average_humidity(self) -> float: - """Return average humidity measures.""" - - return average(self.get_latest_humidities()) - - def get_latest_wind_strengths(self) -> dict: - """Return latest wind strengths.""" - - return self.get_accessory_data(ACCESSORY_WIND_STRENGTH_TYPE) - - def get_average_wind_strength(self) -> float: - """Return average wind strength.""" - - return average(self.get_latest_wind_strengths()) - - def get_latest_wind_angles(self) -> dict: - """Return latest wind angles.""" - - return self.get_accessory_data(ACCESSORY_WIND_ANGLE_TYPE) - - def get_latest_gust_strengths(self) -> dict: - """Return latest gust strengths.""" - - return self.get_accessory_data(ACCESSORY_GUST_STRENGTH_TYPE) - - def get_average_gust_strength(self) -> float: - """Return average gust strength.""" - - return average(self.get_latest_gust_strengths()) - - def get_latest_gust_angles(self): - """Return latest gust angles.""" - - return self.get_accessory_data(ACCESSORY_GUST_ANGLE_TYPE) - - def get_locations(self) -> dict: - """Return locations of stations.""" - - return { - station["_id"]: station["place"]["location"] for station in self.raw_data - } - - def get_time_for_rain_measures(self) -> dict: - """Return time for rain measures.""" - - return self.get_accessory_data(ACCESSORY_RAIN_TIME_TYPE) - - def get_time_for_wind_measures(self) -> dict: - """Return time for wind measures.""" - - return self.get_accessory_data(ACCESSORY_WIND_TIME_TYPE) - - def get_latest_station_measures(self, data_type) -> dict: - """Return latest station measures of a given type.""" - - measures: dict = {} - for station in self.raw_data: - for module in station["measures"].values(): - if ( - "type" in module - and data_type in module["type"] - and "res" in module - and module["res"] - ): - measure_index = module["type"].index(data_type) - latest_timestamp = sorted(module["res"], reverse=True)[0] - measures[station["_id"]] = module["res"][latest_timestamp][ - measure_index - ] - - return measures - - def get_accessory_data(self, data_type: str) -> dict[str, Any]: - """Return all accessory data of a given type.""" - - data: dict = {} - for station in self.raw_data: - for module in station["measures"].values(): - if data_type in module: - data[station["_id"]] = module[data_type] - - return data - - -class PublicData(AbstractPublicData): - """Class of Netatmo public weather data.""" - - def __init__( - self, - auth: NetatmoOAuth2, - lat_ne: str, - lon_ne: str, - lat_sw: str, - lon_sw: str, - required_data_type: str | None = None, - filtering: bool = False, - ) -> None: - """Initialize self.""" - - self.auth = auth - self.required_data_type = required_data_type - self.location = Location(lat_ne, lon_ne, lat_sw, lon_sw) - self.filtering: bool = filtering - - def update(self) -> None: - """Fetch and process data from API.""" - - post_params: dict = { - **dataclasses.asdict(self.location), - "filter": self.filtering, - } - - if self.required_data_type: - post_params["required_data"] = self.required_data_type - - resp = self.auth.post_api_request( - endpoint=GETPUBLIC_DATA_ENDPOINT, - params=post_params, - ).json() - try: - self.raw_data = resp["body"] - except (KeyError, TypeError) as exc: - raise NoDevice("No public weather data returned by Netatmo server") from exc - - self.process(resp) - - -class AsyncPublicData(AbstractPublicData): - """Class of Netatmo public weather data.""" - - def __init__( - self, - auth: AbstractAsyncAuth, - lat_ne: str, - lon_ne: str, - lat_sw: str, - lon_sw: str, - required_data_type: str | None = None, - filtering: bool = False, - ) -> None: - """Initialize self.""" - - self.auth = auth - self.required_data_type = required_data_type - self.location = Location(lat_ne, lon_ne, lat_sw, lon_sw) - self.filtering: bool = filtering - - async def async_update(self) -> None: - """Fetch and process data from API.""" - - post_params: dict = { - **dataclasses.asdict(self.location), - "filter": self.filtering, - } - - if self.required_data_type: - post_params["required_data"] = self.required_data_type - - resp = await self.auth.async_post_api_request( - endpoint=GETPUBLIC_DATA_ENDPOINT, - params=post_params, - ) - assert not isinstance(resp, bytes) - resp_data = await resp.json() - try: - self.raw_data = resp_data["body"] - except (KeyError, TypeError) as exc: - raise NoDevice("No public weather data returned by Netatmo server") from exc - - self.process(resp_data) - - -def average(data: dict) -> float: - """Calculate average value of a dict.""" - - return sum(data.values()) / len(data) if data else 0.0 diff --git a/src/pyatmo/room.py b/src/pyatmo/room.py index 6c2979a2..831d6e1e 100644 --- a/src/pyatmo/room.py +++ b/src/pyatmo/room.py @@ -34,6 +34,8 @@ class Room(NetatmoBase): therm_setpoint_temperature: float | None = None therm_setpoint_mode: str | None = None therm_measured_temperature: float | None = None + therm_setpoint_start_time: int | None = None + therm_setpoint_end_time: int | None = None def __init__( self, @@ -92,6 +94,8 @@ def update(self, raw_data: RawData) -> None: self.therm_measured_temperature = raw_data.get("therm_measured_temperature") self.therm_setpoint_mode = raw_data.get("therm_setpoint_mode") self.therm_setpoint_temperature = raw_data.get("therm_setpoint_temperature") + self.therm_setpoint_start_time = raw_data.get("therm_setpoint_start_time") + self.therm_setpoint_end_time = raw_data.get("therm_setpoint_end_time") async def async_therm_manual( self, diff --git a/src/pyatmo/thermostat.py b/src/pyatmo/thermostat.py deleted file mode 100644 index bfd59ba6..00000000 --- a/src/pyatmo/thermostat.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Support for Netatmo energy devices (relays, thermostats and valves).""" -from __future__ import annotations - -from abc import ABC -from collections import defaultdict -import logging -from typing import Any -from warnings import warn - -from pyatmo.auth import AbstractAsyncAuth, NetatmoOAuth2 -from pyatmo.const import ( - GETHOMESDATA_ENDPOINT, - GETHOMESTATUS_ENDPOINT, - SETROOMTHERMPOINT_ENDPOINT, - SETTHERMMODE_ENDPOINT, - SWITCHHOMESCHEDULE_ENDPOINT, -) -from pyatmo.exceptions import InvalidRoom, NoSchedule -from pyatmo.helpers import extract_raw_data - -LOG = logging.getLogger(__name__) - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -class AbstractHomeData(ABC): - """Abstract class of Netatmo energy devices.""" - - raw_data: dict = defaultdict(dict) - homes: dict = defaultdict(dict) - modules: dict = defaultdict(dict) - rooms: dict = defaultdict(dict) - schedules: dict = defaultdict(dict) - zones: dict = defaultdict(dict) - setpoint_duration: dict = defaultdict(dict) - - def process(self) -> None: - """Process data from API.""" - - self.homes = {d["id"]: d for d in self.raw_data} - - for item in self.raw_data: - home_id = item.get("id") - - if not (home_name := item.get("name")): - home_name = "Unknown" - self.homes[home_id]["name"] = home_name - - if "modules" not in item: - continue - - for module in item["modules"]: - self.modules[home_id][module["id"]] = module - - self.setpoint_duration[home_id] = item.get( - "therm_setpoint_default_duration", - ) - - for room in item.get("rooms", []): - self.rooms[home_id][room["id"]] = room - - for schedule in item.get("schedules", []): - schedule_id = schedule["id"] - self.schedules[home_id][schedule_id] = schedule - - if schedule_id not in self.zones[home_id]: - self.zones[home_id][schedule_id] = {} - - for zone in schedule["zones"]: - self.zones[home_id][schedule_id][zone["id"]] = zone - - def _get_selected_schedule(self, home_id: str) -> dict: - """Get the selected schedule for a given home ID.""" - - return next( - ( - value - for value in self.schedules.get(home_id, {}).values() - if "selected" in value - ), - {}, - ) - - def get_hg_temp(self, home_id: str) -> float | None: - """Return frost guard temperature value.""" - - return self._get_selected_schedule(home_id).get("hg_temp") - - def get_away_temp(self, home_id: str) -> float | None: - """Return the configured away temperature value.""" - - return self._get_selected_schedule(home_id).get("away_temp") - - def get_thermostat_type(self, home_id: str, room_id: str) -> str | None: - """Return the thermostat type of the room.""" - - return next( - ( - module.get("type") - for module in self.modules.get(home_id, {}).values() - if module.get("room_id") == room_id - ), - None, - ) - - def is_valid_schedule(self, home_id: str, schedule_id: str): - """Check if valid schedule.""" - - schedules = ( - self.schedules[home_id][s]["id"] for s in self.schedules.get(home_id, {}) - ) - return schedule_id in schedules - - -class HomeData(AbstractHomeData): - """Class of Netatmo energy devices.""" - - def __init__(self, auth: NetatmoOAuth2) -> None: - """Initialize the Netatmo home data.""" - - self.auth = auth - - def update(self) -> None: - """Fetch and process data from API.""" - - resp = self.auth.post_api_request(endpoint=GETHOMESDATA_ENDPOINT) - - self.raw_data = extract_raw_data(resp.json(), "homes") - self.process() - - def switch_home_schedule(self, home_id: str, schedule_id: str) -> Any: - """Switch the schedule for a give home ID.""" - - if not self.is_valid_schedule(home_id, schedule_id): - raise NoSchedule(f"{schedule_id} is not a valid schedule id") - - post_params = {"home_id": home_id, "schedule_id": schedule_id} - resp = self.auth.post_api_request( - endpoint=SWITCHHOMESCHEDULE_ENDPOINT, - params=post_params, - ) - LOG.debug("Response: %s", resp) - - -class AsyncHomeData(AbstractHomeData): - """Class of Netatmo energy devices.""" - - def __init__(self, auth: AbstractAsyncAuth) -> None: - """Initialize the Netatmo home data.""" - - self.auth = auth - - async def async_update(self): - """Fetch and process data from API.""" - - resp = await self.auth.async_post_api_request(endpoint=GETHOMESDATA_ENDPOINT) - - assert not isinstance(resp, bytes) - self.raw_data = extract_raw_data(await resp.json(), "homes") - self.process() - - async def async_switch_home_schedule(self, home_id: str, schedule_id: str) -> None: - """Switch the schedule for a give home ID.""" - - if not self.is_valid_schedule(home_id, schedule_id): - raise NoSchedule(f"{schedule_id} is not a valid schedule id") - - resp = await self.auth.async_post_api_request( - endpoint=SWITCHHOMESCHEDULE_ENDPOINT, - params={"home_id": home_id, "schedule_id": schedule_id}, - ) - LOG.debug("Response: %s", resp) - - -class AbstractHomeStatus(ABC): - """Abstract class of the Netatmo home status.""" - - raw_data: dict = defaultdict(dict) - rooms: dict = defaultdict(dict) - thermostats: dict = defaultdict(dict) - valves: dict = defaultdict(dict) - relays: dict = defaultdict(dict) - - def process(self) -> None: - """Process data from API.""" - - for room in self.raw_data.get("rooms", []): - self.rooms[room["id"]] = room - - for module in self.raw_data.get("modules", []): - if module["type"] in {"NATherm1", "OTM"}: - self.thermostats[module["id"]] = module - - elif module["type"] == "NRV": - self.valves[module["id"]] = module - - elif module["type"] in {"OTH", "NAPlug"}: - self.relays[module["id"]] = module - - def get_room(self, room_id: str) -> dict: - """Return room data for a given room id.""" - - for value in self.rooms.values(): - if value["id"] == room_id: - return value - - raise InvalidRoom(f"No room with ID {room_id}") - - def get_thermostat(self, room_id: str) -> dict: - """Return thermostat data for a given room id.""" - - for value in self.thermostats.values(): - if value["id"] == room_id: - return value - - raise InvalidRoom(f"No room with ID {room_id}") - - def get_relay(self, room_id: str) -> dict: - """Return relay data for a given room id.""" - - for value in self.relays.values(): - if value["id"] == room_id: - return value - - raise InvalidRoom(f"No room with ID {room_id}") - - def get_valve(self, room_id: str) -> dict: - """Return valve data for a given room id.""" - - for value in self.valves.values(): - if value["id"] == room_id: - return value - - raise InvalidRoom(f"No room with ID {room_id}") - - def set_point(self, room_id: str) -> float | None: - """Return the setpoint of a given room.""" - - return self.get_room(room_id).get("therm_setpoint_temperature") - - def set_point_mode(self, room_id: str) -> str | None: - """Return the setpointmode of a given room.""" - - return self.get_room(room_id).get("therm_setpoint_mode") - - def measured_temperature(self, room_id: str) -> float | None: - """Return the measured temperature of a given room.""" - - return self.get_room(room_id).get("therm_measured_temperature") - - def boiler_status(self, module_id: str) -> bool | None: - """Return the status of the boiler status.""" - - return self.get_thermostat(module_id).get("boiler_status") - - -class HomeStatus(AbstractHomeStatus): - """Class of the Netatmo home status.""" - - def __init__(self, auth: NetatmoOAuth2, home_id: str): - """Initialize the Netatmo home status.""" - - self.auth = auth - self.home_id = home_id - - def update(self) -> None: - """Fetch and process data from API.""" - - resp = self.auth.post_api_request( - endpoint=GETHOMESTATUS_ENDPOINT, - params={"home_id": self.home_id}, - ) - - self.raw_data = extract_raw_data(resp.json(), "home") - self.process() - - def set_thermmode( - self, - mode: str, - end_time: int | None = None, - schedule_id: str | None = None, - ) -> str | None: - """Set thermotat mode.""" - - post_params = {"home_id": self.home_id, "mode": mode} - if end_time is not None and mode in {"hg", "away"}: - post_params["endtime"] = str(end_time) - - if schedule_id is not None and mode == "schedule": - post_params["schedule_id"] = schedule_id - - return self.auth.post_api_request( - endpoint=SETTHERMMODE_ENDPOINT, - params=post_params, - ).json() - - def set_room_thermpoint( - self, - room_id: str, - mode: str, - temp: float | None = None, - end_time: int | None = None, - ) -> str | None: - """Set room themperature set point.""" - - post_params = {"home_id": self.home_id, "room_id": room_id, "mode": mode} - # Temp and endtime should only be sent when mode=='manual', but netatmo api can - # handle that even when mode == 'home' and these settings don't make sense - if temp is not None: - post_params["temp"] = str(temp) - - if end_time is not None: - post_params["endtime"] = str(end_time) - - return self.auth.post_api_request( - endpoint=SETROOMTHERMPOINT_ENDPOINT, - params=post_params, - ).json() - - -class AsyncHomeStatus(AbstractHomeStatus): - """Class of the Netatmo home status.""" - - def __init__(self, auth: AbstractAsyncAuth, home_id: str): - """Initialize the Netatmo home status.""" - - self.auth = auth - self.home_id = home_id - - async def async_update(self) -> None: - """Fetch and process data from API.""" - - resp = await self.auth.async_post_api_request( - endpoint=GETHOMESTATUS_ENDPOINT, - params={"home_id": self.home_id}, - ) - - assert not isinstance(resp, bytes) - self.raw_data = extract_raw_data(await resp.json(), "home") - self.process() - - async def async_set_thermmode( - self, - mode: str, - end_time: int | None = None, - schedule_id: str | None = None, - ) -> str | None: - """Set thermotat mode.""" - - post_params = {"home_id": self.home_id, "mode": mode} - if end_time is not None and mode in {"hg", "away"}: - post_params["endtime"] = str(end_time) - - if schedule_id is not None and mode == "schedule": - post_params["schedule_id"] = schedule_id - - resp = await self.auth.async_post_api_request( - endpoint=SETTHERMMODE_ENDPOINT, - params=post_params, - ) - assert not isinstance(resp, bytes) - return await resp.json() - - async def async_set_room_thermpoint( - self, - room_id: str, - mode: str, - temp: float | None = None, - end_time: int | None = None, - ) -> str | None: - """Set room themperature set point.""" - - post_params = {"home_id": self.home_id, "room_id": room_id, "mode": mode} - # Temp and endtime should only be sent when mode=='manual', but netatmo api can - # handle that even when mode == 'home' and these settings don't make sense - if temp is not None: - post_params["temp"] = str(temp) - - if end_time is not None: - post_params["endtime"] = str(end_time) - - resp = await self.auth.async_post_api_request( - endpoint=SETROOMTHERMPOINT_ENDPOINT, - params=post_params, - ) - assert not isinstance(resp, bytes) - return await resp.json() diff --git a/src/pyatmo/weather_station.py b/src/pyatmo/weather_station.py deleted file mode 100644 index 14b9ca71..00000000 --- a/src/pyatmo/weather_station.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Support for Netatmo weather station devices (stations and modules).""" -from __future__ import annotations - -from abc import ABC -from collections import defaultdict -import logging -import time -from warnings import warn - -from pyatmo.auth import AbstractAsyncAuth, NetatmoOAuth2 -from pyatmo.const import GETMEASURE_ENDPOINT, GETSTATIONDATA_ENDPOINT -from pyatmo.helpers import extract_raw_data, today_stamps - -LOG = logging.getLogger(__name__) - - -warn(f"The module {__name__} is deprecated.", DeprecationWarning, stacklevel=2) - - -class AbstractWeatherStationData(ABC): - """Abstract class of Netatmo Weather Station devices.""" - - raw_data: dict = defaultdict(dict) - stations: dict = defaultdict(dict) - modules: dict = defaultdict(dict) - - def process(self) -> None: - """Process data from API.""" - - self.stations = {d["_id"]: d for d in self.raw_data} - self.modules = {} - - for item in self.raw_data: - # The station name is sometimes not contained in the backend data - if "station_name" not in item: - item["station_name"] = item.get("home_name", item["type"]) - - if "modules" not in item: - item["modules"] = [item] - - for module in item["modules"]: - if "module_name" not in module and module["type"] == "NHC": - module["module_name"] = module["station_name"] - - self.modules[module["_id"]] = module - self.modules[module["_id"]]["main_device"] = item["_id"] - - def get_module_names(self, station_id: str) -> list: - """Return a list of all module names for a given station.""" - - if not (station_data := self.get_station(station_id)): - return [] - - res = {station_data.get("module_name", station_data.get("type"))} - for module in station_data["modules"]: - # Add module name, use module type if no name is available - res.add(module.get("module_name", module.get("type"))) - - return list(res) - - def get_modules(self, station_id: str) -> dict: - """Return a dict of modules per given station.""" - - if not (station_data := self.get_station(station_id)): - return {} - - res = {} - for station in [self.stations[station_data["_id"]]]: - station_type = station.get("type") - station_name = station.get("station_name", station_type) - res[station["_id"]] = { - "station_name": station_name, - "module_name": station.get("module_name", station_type), - "id": station["_id"], - } - - for module in station["modules"]: - res[module["_id"]] = { - "station_name": module.get("station_name", station_name), - "module_name": module.get("module_name", module.get("type")), - "id": module["_id"], - } - - return res - - def get_station(self, station_id: str) -> dict: - """Return station by id.""" - - return self.stations.get(station_id, {}) - - def get_module(self, module_id: str) -> dict: - """Return module by id.""" - - return self.modules.get(module_id, {}) - - def get_monitored_conditions(self, module_id: str) -> list: - """Return monitored conditions for given module.""" - - if not (module := (self.get_module(module_id) or self.get_station(module_id))): - return [] - - conditions = [] - for condition in module.get("data_type", []): - if condition == "Wind": - # the Wind meter actually exposes the following conditions - conditions.extend( - ["WindAngle", "WindStrength", "GustAngle", "GustStrength"], - ) - - elif condition == "Rain": - conditions.extend(["Rain", "sum_rain_24", "sum_rain_1"]) - - else: - conditions.append(condition) - - if module["type"] in ["NAMain", "NHC"]: - # the main module has wifi_status - conditions.append("wifi_status") - - else: - # assume all other modules have rf_status, battery_vp, and battery_percent - conditions.extend(["rf_status", "battery_vp", "battery_percent"]) - - if module["type"] in ["NAMain", "NAModule1", "NAModule4"]: - conditions.extend(["temp_trend"]) - - if module["type"] == "NAMain": - conditions.extend(["pressure_trend"]) - - if module["type"] in [ - "NAMain", - "NAModule1", - "NAModule2", - "NAModule3", - "NAModule4", - "NHC", - ]: - conditions.append("reachable") - - return conditions - - def get_last_data(self, station_id: str, exclude: int = 0) -> dict: - """Return data for a given station and time frame.""" - - key = "_id" - last_data: dict = {} - - if ( - not (station := self.get_station(station_id)) - or "dashboard_data" not in station - ): - LOG.debug("No dashboard data for station %s", station_id) - return last_data - - # Define oldest acceptable sensor measure event - limit = (time.time() - exclude) if exclude else 0 - - data = station["dashboard_data"] - if key in station and data["time_utc"] > limit: - last_data[station[key]] = data.copy() - last_data[station[key]]["When"] = last_data[station[key]].pop("time_utc") - last_data[station[key]]["wifi_status"] = station.get("wifi_status") - last_data[station[key]]["reachable"] = station.get("reachable") - - for module in station["modules"]: - if "dashboard_data" not in module or key not in module: - continue - - data = module["dashboard_data"] - if "time_utc" in data and data["time_utc"] > limit: - last_data[module[key]] = data.copy() - last_data[module[key]]["When"] = last_data[module[key]].pop("time_utc") - - # For potential use, add battery and radio coverage information - # to module data if present - for val in ( - "rf_status", - "battery_vp", - "battery_percent", - "reachable", - "wifi_status", - ): - if val in module: - last_data[module[key]][val] = module[val] - - return last_data - - def check_not_updated(self, station_id: str, delay: int = 3600) -> list: - """Check if a given station has not been updated.""" - - res = self.get_last_data(station_id) - return [ - key for key, value in res.items() if time.time() - value["When"] > delay - ] - - def check_updated(self, station_id: str, delay: int = 3600) -> list: - """Check if a given station has been updated.""" - - res = self.get_last_data(station_id) - return [ - key for key, value in res.items() if time.time() - value["When"] < delay - ] - - -class WeatherStationData(AbstractWeatherStationData): - """Class of Netatmo weather station devices.""" - - def __init__( - self, - auth: NetatmoOAuth2, - endpoint: str = GETSTATIONDATA_ENDPOINT, - favorites: bool = True, - ) -> None: - """Initialize the Netatmo weather station data.""" - - self.auth = auth - self.endpoint = endpoint - self.params = {"get_favorites": ("true" if favorites else "false")} - - def update(self): - """Fetch data from API.""" - - self.raw_data = extract_raw_data( - self.auth.post_api_request( - endpoint=self.endpoint, - params=self.params, - ).json(), - "devices", - ) - self.process() - - def get_data( - self, - device_id: str, - scale: str, - module_type: str, - module_id: str | None = None, - date_begin: float | None = None, - date_end: float | None = None, - limit: int | None = None, - optimize: bool = False, - real_time: bool = False, - ) -> dict | None: - """Retrieve data from a device or module.""" - - post_params = {"device_id": device_id} - if module_id: - post_params["module_id"] = module_id - - post_params["scale"] = scale - post_params["type"] = module_type - - if date_begin: - post_params["date_begin"] = f"{date_begin}" - - if date_end: - post_params["date_end"] = f"{date_end}" - - if limit: - post_params["limit"] = f"{limit}" - - post_params["optimize"] = "true" if optimize else "false" - post_params["real_time"] = "true" if real_time else "false" - - return self.auth.post_api_request( - endpoint=GETMEASURE_ENDPOINT, - params=post_params, - ).json() - - def get_min_max_t_h( - self, - station_id: str, - module_id: str | None = None, - frame: str = "last24", - ) -> tuple[float, float, float, float] | None: - """Return minimum and maximum temperature and humidity over the given timeframe.""" - - if frame == "last24": - end = time.time() - start = end - 24 * 3600 # 24 hours ago - - elif frame == "day": - start, end = today_stamps() - - else: - raise ValueError("'frame' value can only be 'last24' or 'day'") - - if resp := self.get_data( - device_id=station_id, - module_id=module_id, - scale="max", - module_type="Temperature,Humidity", - date_begin=start, - date_end=end, - ): - temperature = [temp[0] for temp in resp["body"].values()] - humidity = [hum[1] for hum in resp["body"].values()] - return min(temperature), max(temperature), min(humidity), max(humidity) - - return None - - -class AsyncWeatherStationData(AbstractWeatherStationData): - """Class of Netatmo weather station devices.""" - - def __init__( - self, - auth: AbstractAsyncAuth, - endpoint: str = GETSTATIONDATA_ENDPOINT, - favorites: bool = True, - ) -> None: - """Initialize the Netatmo weather station data.""" - - self.auth = auth - self.endpoint = endpoint - self.params = {"get_favorites": ("true" if favorites else "false")} - - async def async_update(self): - """Fetch data from API.""" - - resp = await self.auth.async_post_api_request( - endpoint=self.endpoint, - params=self.params, - ) - - assert not isinstance(resp, bytes) - self.raw_data = extract_raw_data(await resp.json(), "devices") - self.process() diff --git a/tests/conftest.py b/tests/conftest.py index a312ff0b..0b0c7703 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,12 @@ """Define shared fixtures.""" # pylint: disable=redefined-outer-name, protected-access from contextlib import contextmanager -import json from unittest.mock import AsyncMock, patch import pyatmo import pytest -from .common import MockResponse, fake_post_request +from .common import fake_post_request @contextmanager @@ -15,151 +14,6 @@ def does_not_raise(): yield -@pytest.fixture(scope="function") -def auth(requests_mock): - """Auth fixture.""" - with open("fixtures/oauth2_token.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.AUTH_REQ_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - return pyatmo.ClientAuth( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - username="USERNAME", - password="PASSWORD", - scope=" ".join(pyatmo.const.ALL_SCOPES), - ) - - -@pytest.fixture(scope="function") -def home_data(auth, requests_mock): - """HomeData fixture.""" - with open("fixtures/home_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_data = pyatmo.HomeData(auth) - home_data.update() - return home_data - - -@pytest.fixture(scope="function") -def home_status(auth, home_id, requests_mock): - """HomeStatus fixture.""" - with open("fixtures/home_status_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESTATUS_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_status = pyatmo.HomeStatus(auth, home_id) - home_status.update() - return home_status - - -@pytest.fixture(scope="function") -def public_data(auth, requests_mock): - """PublicData fixture.""" - with open("fixtures/public_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETPUBLIC_DATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - lon_ne = str(6.221652) - lat_ne = str(46.610870) - lon_sw = str(6.217828) - lat_sw = str(46.596485) - - public_data = pyatmo.PublicData(auth, lat_ne, lon_ne, lat_sw, lon_sw) - public_data.update() - return public_data - - -@pytest.fixture(scope="function") -def weather_station_data(auth, requests_mock): - """WeatherStationData fixture.""" - with open( - "fixtures/weatherstation_data_simple.json", - encoding="utf-8", - ) as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETSTATIONDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - wsd = pyatmo.WeatherStationData(auth) - wsd.update() - return wsd - - -@pytest.fixture(scope="function") -def home_coach_data(auth, requests_mock): - """HomeCoachData fixture.""" - with open("fixtures/home_coach_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMECOACHDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - hcd = pyatmo.HomeCoachData(auth) - hcd.update() - return hcd - - -@pytest.fixture(scope="function") -def camera_ping(requests_mock): - """Camera ping fixture.""" - for index in ["w", "z", "g"]: - vpn_url = ( - f"https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/" - f"6d278460699e56180d47ab47169efb31/" - f"MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTT{index},," - ) - with open("fixtures/camera_ping.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - f"{vpn_url}/command/ping", - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - local_url = "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d" - with open("fixtures/camera_ping.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - f"{local_url}/command/ping", - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - -@pytest.fixture(scope="function") -def camera_home_data(auth, camera_ping, requests_mock): # pylint: disable=W0613 - """CameraHomeData fixture.""" - with open("fixtures/camera_home_data.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMEDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - camera_data = pyatmo.CameraData(auth) - camera_data.update() - return camera_data - - @pytest.fixture(scope="function") async def async_auth(): """AsyncAuth fixture.""" @@ -167,108 +21,6 @@ async def async_auth(): yield auth -@pytest.fixture(scope="function") -async def async_camera_home_data(async_auth): - """AsyncCameraHomeData fixture.""" - with open("fixtures/camera_home_data.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_api_request, patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - camera_data = pyatmo.AsyncCameraData(async_auth) - await camera_data.async_update() - - mock_api_request.assert_called() - mock_request.assert_called() - yield camera_data - - -@pytest.fixture(scope="function") -async def async_home_coach_data(async_auth): - """AsyncHomeCoacheData fixture.""" - with open("fixtures/home_coach_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - hcd = pyatmo.AsyncHomeCoachData(async_auth) - await hcd.async_update() - - mock_request.assert_awaited() - yield hcd - - -@pytest.fixture(scope="function") -async def async_home_data(async_auth): - """AsyncHomeData fixture.""" - with open("fixtures/home_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - home_data = pyatmo.AsyncHomeData(async_auth) - await home_data.async_update() - - mock_request.assert_called() - return home_data - - -@pytest.fixture(scope="function") -async def async_home_status(async_auth, home_id): - """AsyncHomeStatus fixture.""" - with open("fixtures/home_status_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - home_status = pyatmo.AsyncHomeStatus(async_auth, home_id) - await home_status.async_update() - - mock_request.assert_called() - return home_status - - -@pytest.fixture(scope="function") -async def async_weather_station_data(async_auth): - """AsyncWeatherStationData fixture.""" - with open( - "fixtures/weatherstation_data_simple.json", - encoding="utf-8", - ) as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - wsd = pyatmo.AsyncWeatherStationData(async_auth) - await wsd.async_update() - - mock_request.assert_called() - return wsd - - @pytest.fixture(scope="function") async def async_account(async_auth): """AsyncAccount fixture.""" diff --git a/tests/test_async.py b/tests/test_async.py deleted file mode 100644 index cef46766..00000000 --- a/tests/test_async.py +++ /dev/null @@ -1,605 +0,0 @@ -"""Define tests for async methods.""" -# pylint: disable=protected-access -import json -from unittest.mock import AsyncMock, patch - -import pyatmo -import pytest - -from tests.conftest import MockResponse, does_not_raise - -LON_NE = "6.221652" -LAT_NE = "46.610870" -LON_SW = "6.217828" -LAT_SW = "46.596485" - - -@pytest.mark.asyncio -async def test_async_auth(async_auth, mocker): - with open("fixtures/camera_home_data.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_api_request, patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - camera_data = pyatmo.AsyncCameraData(async_auth) - await camera_data.async_update() - - mock_api_request.assert_awaited() - mock_request.assert_awaited() - assert camera_data.homes is not None - - -@pytest.mark.asyncio -async def test_async_camera_data(async_camera_home_data): - assert async_camera_home_data.homes is not None - - -@pytest.mark.asyncio -async def test_async_home_data_no_body(async_auth): - with open("fixtures/camera_data_empty.json", encoding="utf-8") as fixture_file: - json_fixture = json.load(fixture_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ) as mock_request: - camera_data = pyatmo.AsyncCameraData(async_auth) - - with pytest.raises(pyatmo.NoDevice): - await camera_data.async_update() - mock_request.assert_awaited() - - -@pytest.mark.asyncio -async def test_async_home_data_no_homes(async_auth): - with open( - "fixtures/camera_home_data_no_homes.json", - encoding="utf-8", - ) as fixture_file: - json_fixture = json.load(fixture_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ) as mock_request: - camera_data = pyatmo.AsyncCameraData(async_auth) - - with pytest.raises(pyatmo.NoDevice): - await camera_data.async_update() - mock_request.assert_awaited() - - -@pytest.mark.asyncio -async def test_async_home_coach_data(async_home_coach_data): - assert ( - async_home_coach_data.stations["12:34:56:26:69:0c"]["station_name"] == "Bedroom" - ) - - -@pytest.mark.asyncio -async def test_async_public_data(async_auth): - with open("fixtures/public_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ) as mock_request: - public_data = pyatmo.AsyncPublicData(async_auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - await public_data.async_update() - - mock_request.assert_awaited() - assert public_data.status == "ok" - - public_data = pyatmo.AsyncPublicData( - async_auth, - LAT_NE, - LON_NE, - LAT_SW, - LON_SW, - required_data_type="temperature,rain_live", - ) - await public_data.async_update() - assert public_data.status == "ok" - - -@pytest.mark.asyncio -async def test_async_public_data_error(async_auth): - with open("fixtures/public_data_error_mongo.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ): - public_data = pyatmo.AsyncPublicData(async_auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - - with pytest.raises(pyatmo.NoDevice): - await public_data.async_update() - - -@pytest.mark.asyncio -async def test_async_home_data(async_home_data): - expected = { - "12:34:56:00:fa:d0": { - "id": "12:34:56:00:fa:d0", - "type": "NAPlug", - "name": "Thermostat", - "setup_date": 1494963356, - "modules_bridged": [ - "12:34:56:00:01:ae", - "12:34:56:03:a0:ac", - "12:34:56:03:a5:54", - ], - }, - "12:34:56:00:01:ae": { - "id": "12:34:56:00:01:ae", - "type": "NATherm1", - "name": "Livingroom", - "setup_date": 1494963356, - "room_id": "2746182631", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:03:a5:54": { - "id": "12:34:56:03:a5:54", - "type": "NRV", - "name": "Valve1", - "setup_date": 1554549767, - "room_id": "2833524037", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:03:a0:ac": { - "id": "12:34:56:03:a0:ac", - "type": "NRV", - "name": "Valve2", - "setup_date": 1554554444, - "room_id": "2940411577", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:00:f1:62": { - "id": "12:34:56:00:f1:62", - "type": "NACamera", - "name": "Hall", - "setup_date": 1544828430, - "room_id": "3688132631", - }, - } - assert async_home_data.modules["91763b24c43d3e344f424e8b"] == expected - - -@pytest.mark.asyncio -async def test_async_home_data_no_data(async_auth): - mock_resp = MockResponse(None, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ), pytest.raises(pyatmo.NoDevice): - home_data = pyatmo.AsyncHomeData(async_auth) - await home_data.async_update() - - -@pytest.mark.asyncio -async def test_async_data_no_body(async_auth): - with open("fixtures/home_data_empty.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ): - home_data = pyatmo.AsyncHomeData(async_auth) - with pytest.raises(pyatmo.NoDevice): - await home_data.async_update() - - -@pytest.mark.parametrize( - "t_home_id, t_sched_id, expected", - [ - ("91763b24c43d3e344f424e8b", "591b54a2764ff4d50d8b5795", does_not_raise()), - ( - "91763b24c43d3e344f424e8b", - "123456789abcdefg12345678", - pytest.raises(pyatmo.NoSchedule), - ), - ], -) -@pytest.mark.asyncio -async def test_async_home_data_switch_home_schedule( - async_home_data, - t_home_id, - t_sched_id, - expected, -): - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ), expected: - await async_home_data.async_switch_home_schedule( - home_id=t_home_id, - schedule_id=t_sched_id, - ) - - -@pytest.mark.parametrize( - "home_id, room_id, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "2746182631", - { - "id": "2746182631", - "reachable": True, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0, - }, - ), - ], -) -@pytest.mark.asyncio -async def test_async_home_status(async_home_status, room_id, expected): - assert len(async_home_status.rooms) == 3 - assert async_home_status.rooms[room_id] == expected - - -@pytest.mark.parametrize( - "home_id, mode, end_time, schedule_id, json_fixture, expected", - [ - ( - None, - None, - None, - None, - "home_status_error_mode_is_missing.json", - "mode is missing", - ), - ( - "91763b24c43d3e344f424e8b", - None, - None, - None, - "home_status_error_mode_is_missing.json", - "mode is missing", - ), - ( - "invalidID", - "away", - None, - None, - "home_status_error_invalid_id.json", - "Invalid id", - ), - ("91763b24c43d3e344f424e8b", "away", None, None, "status_ok.json", "ok"), - ("91763b24c43d3e344f424e8b", "away", 1559162650, None, "status_ok.json", "ok"), - ( - "91763b24c43d3e344f424e8b", - "away", - 1559162650, - 0000000, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - None, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - 1559162650, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - None, - "blahblahblah", - "home_status_error_invalid_schedule_id.json", - "schedule is not therm schedule", - ), - ], -) -@pytest.mark.asyncio -async def test_async_home_status_set_thermmode( - async_home_status, - mode, - end_time, - schedule_id, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ): - res = await async_home_status.async_set_thermmode( - mode=mode, - end_time=end_time, - schedule_id=schedule_id, - ) - if "error" in res: - assert expected in res["error"]["message"] - else: - assert expected in res["status"] - - -@pytest.mark.parametrize( - "home_id, room_id, mode, temp, end_time, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - 14, - None, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - 14, - 1559162650, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - None, - None, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - None, - 1559162650, - "status_ok.json", - "ok", - ), - ], -) -@pytest.mark.asyncio -async def test_async_home_status_set_room_thermpoint( - async_home_status, - room_id, - mode, - temp, - end_time, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - - mock_resp = MockResponse(json_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_resp), - ): - result = await async_home_status.async_set_room_thermpoint( - room_id=room_id, - mode=mode, - temp=temp, - end_time=end_time, - ) - assert result["status"] == expected - - -@pytest.mark.asyncio -async def test_async_camera_live_snapshot(async_camera_home_data): - _id = "12:34:56:00:f1:62" - - assert async_camera_home_data.homes is not None - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_get_image", - AsyncMock(return_value=b"0000"), - ): - result = await async_camera_home_data.async_get_live_snapshot(camera_id=_id) - - assert result == b"0000" - - -@pytest.mark.asyncio -async def test_async_camera_data_get_camera_picture(async_camera_home_data): - image_id = "5c22739723720a6e278c43bf" - key = "276751836a6d1a71447f8d975494c87bc125766a970f7e022e79e001e021d756" - with open( - "fixtures/camera_image_sample.jpg", - "rb", - ) as fixture_file: - expect = fixture_file.read() - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_get_image", - AsyncMock(return_value=expect), - ): - assert await async_camera_home_data.async_get_camera_picture(image_id, key) == ( - expect, - "jpeg", - ) - - -@pytest.mark.asyncio -async def test_async_camera_data_get_profile_image(async_camera_home_data): - with open( - "fixtures/camera_image_sample.jpg", - "rb", - ) as fixture_file: - expect = fixture_file.read() - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_get_image", - AsyncMock(return_value=expect), - ): - assert await async_camera_home_data.async_get_profile_image( - "John Doe", - "91763b24c43d3e344f424e8b", - ) == ( - expect, - "jpeg", - ) - assert await async_camera_home_data.async_get_profile_image( - "Jack Foe", - "91763b24c43d3e344f424e8b", - ) == ( - None, - None, - ) - - -@pytest.mark.parametrize( - "home_id, person_id, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "91827374-7e04-5298-83ad-a0cb8372dff1", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "91827376-7e04-5298-83af-a0cb8372dff3", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - None, - "status_ok.json", - "ok", - ), - ], -) -@pytest.mark.asyncio -async def test_async_camera_data_set_persons_away( - async_camera_home_data, - home_id, - person_id, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ) as mock_req: - result = await async_camera_home_data.async_set_persons_away(home_id, person_id) - assert result["status"] == expected - - if person_id is not None: - mock_req.assert_awaited_once_with( - params={ - "home_id": home_id, - "person_id": person_id, - }, - endpoint="api/setpersonsaway", - ) - else: - mock_req.assert_awaited_once_with( - params={ - "home_id": home_id, - }, - endpoint="api/setpersonsaway", - ) - - -@pytest.mark.parametrize( - "home_id, person_ids, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - [ - "91827374-7e04-5298-83ad-a0cb8372dff1", - "91827376-7e04-5298-83af-a0cb8372dff3", - ], - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "91827376-7e04-5298-83af-a0cb8372dff3", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - None, - "status_ok.json", - "ok", - ), - ], -) -@pytest.mark.asyncio -async def test_async_camera_data_set_persons_home( - async_camera_home_data, - home_id, - person_ids, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ) as mock_req: - result = await async_camera_home_data.async_set_persons_home( - home_id, - person_ids, - ) - assert result["status"] == expected - - if isinstance(person_ids, list) or person_ids: - mock_req.assert_awaited_once_with( - params={ - "home_id": home_id, - "person_ids[]": person_ids, - }, - endpoint="api/setpersonshome", - ) - else: - mock_req.assert_awaited_once_with( - params={ - "home_id": home_id, - }, - endpoint="api/setpersonshome", - ) diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 00000000..ce2b495e --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,122 @@ +"""Define tests for climate module.""" +import json +from unittest.mock import AsyncMock, patch + +from pyatmo import DeviceType +import pytest + +from tests.common import MockResponse + +# pylint: disable=F6401 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + await module.async_update_camera_urls() + assert module.device_type == DeviceType.NACamera + assert module.is_local + assert module.local_url == "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d" + person_id = "91827374-7e04-5298-83ad-a0cb8372dff1" + assert person_id in module.home.persons + assert module.home.persons[person_id].pseudo == "John Doe" + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NOC + assert module.firmware_revision == 3002000 + assert module.firmware_name == "3.2.0" + assert module.monitoring is True + assert module.floodlight == "auto" + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + def gen_json_data(state): + return { + "json": { + "home": { + "id": "91763b24c43d3e344f424e8b", + "modules": [ + { + "id": module_id, + "floodlight": state, + }, + ], + }, + }, + } + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_resp: + assert await module.async_floodlight_on() + mock_resp.assert_awaited_with( + params=gen_json_data("on"), + endpoint="api/setstate", + ) + + assert await module.async_floodlight_off() + mock_resp.assert_awaited_with( + params=gen_json_data("off"), + endpoint="api/setstate", + ) + + assert await module.async_floodlight_auto() + mock_resp.assert_awaited_with( + params=gen_json_data("auto"), + endpoint="api/setstate", + ) + + +@pytest.mark.asyncio +async def test_async_camera_monitoring(async_home): + """Test basic camera monitoring functionality.""" + module_id = "12:34:56:10:b9:0e" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NOC + assert module.is_local is False + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + def gen_json_data(state): + return { + "json": { + "home": { + "id": "91763b24c43d3e344f424e8b", + "modules": [ + { + "id": module_id, + "monitoring": state, + }, + ], + }, + }, + } + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_resp: + assert await module.async_monitoring_on() + mock_resp.assert_awaited_with( + params=gen_json_data("on"), + endpoint="api/setstate", + ) + + assert await module.async_monitoring_off() + mock_resp.assert_awaited_with( + params=gen_json_data("off"), + endpoint="api/setstate", + ) diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 00000000..dbaa46e0 --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,379 @@ +"""Define tests for climate module.""" +import json +from unittest.mock import AsyncMock, patch + +from pyatmo import DeviceType, NoSchedule +from pyatmo.modules import NATherm1 +from pyatmo.modules.device_types import DeviceCategory +import pytest + +from tests.common import MockResponse, fake_post_request +from tests.conftest import does_not_raise + +# pylint: disable=F6401 + + +@pytest.mark.asyncio +async def test_async_climate_room(async_home): + """Test room with climate devices.""" + room_id = "2746182631" + assert room_id in async_home.rooms + + room = async_home.rooms[room_id] + assert room.reachable is True + assert room.device_types == {DeviceType.NATherm1} + + module_id = "12:34:56:00:01:ae" + assert module_id in room.modules + assert len(room.modules) == 1 + + +@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" + module = async_home.modules[module_id] + assert module.name == "Livingroom" + assert module.device_type == DeviceType.NATherm1 + assert module.reachable is True + assert module.boiler_status is False + assert module.firmware_revision == 65 + assert module.battery == 75 + assert module.rf_strength == 58 + + +@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" + module = async_home.modules[module_id] + assert module.name == "Valve1" + assert async_home.rooms[module.room_id].name == "Entrada" + assert module.device_type == DeviceType.NRV + assert module.reachable is True + assert module.rf_strength == 51 + assert module.battery == 90 + assert module.firmware_revision == 79 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NAPlug + assert len(module.modules) == 3 + assert module.rf_strength == 107 + assert module.wifi_strength == 42 + assert module.firmware_revision == 174 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NIS + assert module.firmware_revision == 209 + assert module.status == "no_sound" + assert module.monitoring is False + + +@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" + module = async_home.modules[module_id] + assert module.name == "Bureau Modulate" + assert module.device_type == DeviceType.OTM + assert module.reachable is True + assert module.boiler_status is False + assert module.firmware_revision == 6 + assert module.battery == 90 + assert module.rf_strength == 64 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.OTH + assert len(module.modules) == 1 + assert module.wifi_strength == 57 + assert module.firmware_revision == 22 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.BNS + assert module.name == "Smarther" + + room = async_home.rooms[module.room_id] + assert room.name == "Corridor" + assert room.device_types == { + DeviceType.BNS, + } + assert room.features == {"humidity", DeviceCategory.climate} + + +@pytest.mark.asyncio +async def test_async_climate_update(async_account): + """Test basic climate state update.""" + home_id = "91763b24c43d3e344f424e8b" + await async_account.async_update_status(home_id) + home = async_account.homes[home_id] + + room_id = "2746182631" + room = home.rooms[room_id] + + module_id = "12:34:56:00:01:ae" + module = home.modules[module_id] + assert room.reachable is True + assert room.humidity is None + assert module.name == "Livingroom" + assert module.device_type == DeviceType.NATherm1 + assert module.reachable is True + assert module.boiler_status is False + assert module.battery == 75 + + assert isinstance(module, NATherm1) + + with open( + "fixtures/home_status_error_disconnected.json", + encoding="utf-8", + ) as json_file: + home_status_fixture = json.load(json_file) + mock_home_status_resp = MockResponse(home_status_fixture, 200) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=mock_home_status_resp), + ) as mock_request: + await async_account.async_update_status(home_id) + mock_request.assert_called() + + assert room.reachable is None + assert module.reachable is False + + with open("fixtures/home_status_simple.json", encoding="utf-8") as json_file: + home_status_fixture = json.load(json_file) + mock_home_status_resp = MockResponse(home_status_fixture, 200) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=mock_home_status_resp), + ) as mock_request: + await async_account.async_update_status(home_id) + mock_request.assert_called() + + assert room.reachable is True + assert module.reachable is True + assert module.battery == 75 + assert module.rf_strength == 58 + + +@pytest.mark.parametrize( + "t_sched_id, expected", + [ + ("591b54a2764ff4d50d8b5795", does_not_raise()), + ( + "123456789abcdefg12345678", + pytest.raises(NoSchedule), + ), + ], +) +@pytest.mark.asyncio +async def test_async_climate_switch_schedule( + async_home, + t_sched_id, + expected, +): + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ), expected: + await async_home.async_switch_schedule( + schedule_id=t_sched_id, + ) + + +@pytest.mark.parametrize( + "temp, end_time", + [ + ( + 14, + None, + ), + ( + 14, + 1559162650, + ), + ( + None, + None, + ), + ( + None, + 1559162650, + ), + ], +) +@pytest.mark.asyncio +async def test_async_climate_room_therm_set( + async_home, + temp, + end_time, +): + room_id = "2746182631" + mode = "home" + + expected_params = { + "home_id": "91763b24c43d3e344f424e8b", + "room_id": room_id, + "mode": mode, + } + if temp: + expected_params["temp"] = str(temp) + if end_time: + expected_params["endtime"] = str(end_time) + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_post: + room = async_home.rooms[room_id] + + await room.async_therm_set( + mode=mode, + temp=temp, + end_time=end_time, + ) + mock_post.assert_awaited_once_with( + endpoint="api/setroomthermpoint", + params=expected_params, + ) + + +@pytest.mark.parametrize( + "mode, end_time, schedule_id, json_fixture, expected, exception", + [ + ( + "away", + None, + None, + "status_ok.json", + True, + does_not_raise(), + ), + ( + "away", + 1559162650, + None, + "status_ok.json", + True, + does_not_raise(), + ), + ( + "schedule", + None, + "591b54a2764ff4d50d8b5795", + "status_ok.json", + True, + does_not_raise(), + ), + ( + "schedule", + 1559162650, + "591b54a2764ff4d50d8b5795", + "status_ok.json", + True, + does_not_raise(), + ), + ( + None, + None, + None, + "home_status_error_mode_is_missing.json", + False, + pytest.raises(NoSchedule), + ), + ( + None, + None, + None, + "home_status_error_mode_is_missing.json", + False, + pytest.raises(NoSchedule), + ), + ( + "away", + 1559162650, + 0000000, + "status_ok.json", + True, + pytest.raises(NoSchedule), + ), + ( + "schedule", + None, + "blahblahblah", + "home_status_error_invalid_schedule_id.json", + False, + pytest.raises(NoSchedule), + ), + ], +) +@pytest.mark.asyncio +async def test_async_climate_set_thermmode( + async_home, + mode, + end_time, + schedule_id, + json_fixture, + expected, + exception, +): + with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: + response = json.load(json_file) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ), exception: + resp = await async_home.async_set_thermmode( + mode=mode, + end_time=end_time, + schedule_id=schedule_id, + ) + assert expected is resp + + +@pytest.mark.asyncio +async def test_async_climate_empty_home(async_account): + """Test climate setup with empty home.""" + home_id = "91763b24c43d3e344f424e8c" + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + fake_post_request, + ): + await async_account.async_update_status(home_id) + + assert home_id in async_account.homes + + home = async_account.homes[home_id] + assert len(home.rooms) == 0 diff --git a/tests/test_energy.py b/tests/test_energy.py new file mode 100644 index 00000000..5a7dce67 --- /dev/null +++ b/tests/test_energy.py @@ -0,0 +1,16 @@ +"""Define tests for climate module.""" + +from pyatmo import DeviceType +import pytest + +# pylint: disable=F6401 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NLPC + assert module.power == 476 diff --git a/tests/test_home.py b/tests/test_home.py new file mode 100644 index 00000000..df042098 --- /dev/null +++ b/tests/test_home.py @@ -0,0 +1,180 @@ +"""Define tests for climate module.""" +import datetime as dt +import json +from unittest.mock import AsyncMock, patch + +import pyatmo +from pyatmo import DeviceType, NoDevice +import pytest +import time_machine + +from tests.common import MockResponse + +# pylint: disable=F6401 + + +@pytest.mark.asyncio +async def test_async_home(async_home): + """Test basic home setup.""" + room_id = "3688132631" + room = async_home.rooms[room_id] + assert room.device_types == { + DeviceType.NDB, + DeviceType.NACamera, + DeviceType.NBR, + DeviceType.NIS, + DeviceType.NBO, + } + assert len(async_home.rooms) == 8 + assert len(async_home.modules) == 37 + assert async_home.modules != room.modules + + module_id = "12:34:56:10:f1:66" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NDB + + module_id = "12:34:56:10:b9:0e" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NOC + + +@pytest.mark.asyncio +async def test_async_home_set_schedule(async_home): + """Test home schedule.""" + schedule_id = "591b54a2764ff4d50d8b5795" + selected_schedule = async_home.get_selected_schedule() + assert selected_schedule.entity_id == schedule_id + assert async_home.is_valid_schedule(schedule_id) + assert not async_home.is_valid_schedule("123") + assert async_home.get_hg_temp() == 7 + assert async_home.get_away_temp() == 14 + + +@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) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=json_fixture), + ) as mock_request: + climate = pyatmo.AsyncAccount(async_auth) + + with pytest.raises(NoDevice): + await climate.async_update_topology() + mock_request.assert_called() + + +@pytest.mark.asyncio +async def test_async_set_persons_home(async_account): + """Test marking a person being at home.""" + home_id = "91763b24c43d3e344f424e8b" + home = async_account.homes[home_id] + + person_ids = [ + "91827374-7e04-5298-83ad-a0cb8372dff1", + "91827375-7e04-5298-83ae-a0cb8372dff2", + ] + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_resp: + await home.async_set_persons_home(person_ids) + + mock_resp.assert_awaited_with( + params={"home_id": home_id, "person_ids[]": person_ids}, + endpoint="api/setpersonshome", + ) + + +@pytest.mark.asyncio +async def test_async_set_persons_away(async_account): + """Test marking a set of persons being away.""" + home_id = "91763b24c43d3e344f424e8b" + home = async_account.homes[home_id] + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_resp: + person_id = "91827374-7e04-5298-83ad-a0cb8372dff1" + await home.async_set_persons_away(person_id) + + mock_resp.assert_awaited_with( + params={"home_id": home_id, "person_id": person_id}, + endpoint="api/setpersonsaway", + ) + + await home.async_set_persons_away() + + mock_resp.assert_awaited_with( + params={"home_id": home_id}, + endpoint="api/setpersonsaway", + ) + + +@pytest.mark.asyncio +async def test_home_event_update(async_account): + """Test basic event update.""" + home_id = "91763b24c43d3e344f424e8b" + await async_account.async_update_events(home_id=home_id) + home = async_account.homes[home_id] + + events = home.events + assert len(events) == 8 + + module_id = "12:34:56:10:b9:0e" + assert module_id in home.modules + module = home.modules[module_id] + + events = module.events + assert len(events) == 5 + assert events[0].event_type == "outdoor" + assert events[0].video_id == "11111111-2222-3333-4444-b42f0fc4cfad" + assert events[1].event_type == "connection" + + +@time_machine.travel(dt.datetime(2022, 2, 12, 7, 59, 49)) +@pytest.mark.asyncio +async def test_historical_data_retrieval(async_account): + """Test retrieval of historical measurements.""" + home_id = "91763b24c43d3e344f424e8b" + await async_account.async_update_events(home_id=home_id) + home = async_account.homes[home_id] + + module_id = "12:34:56:00:00:a1:4c:da" + assert module_id in home.modules + module = home.modules[module_id] + assert module.device_type == DeviceType.NLPC + + await async_account.async_update_measures(home_id=home_id, module_id=module_id) + assert module.historical_data[0] == { + "Wh": 197, + "duration": 60, + "startTime": "2022-02-05T08:29:50Z", + "endTime": "2022-02-05T09:29:49Z", + } + assert module.historical_data[-1] == { + "Wh": 259, + "duration": 60, + "startTime": "2022-02-12T07:29:50Z", + "endTime": "2022-02-12T08:29:49Z", + } + assert len(module.historical_data) == 168 + + +def test_device_types_missing(): + """Test handling of missing device types.""" + + assert DeviceType("NOC") == DeviceType.NOC + assert DeviceType("UNKNOWN") == DeviceType.NLunknown diff --git a/tests/test_pyatmo.py b/tests/test_pyatmo.py deleted file mode 100644 index c4d76d76..00000000 --- a/tests/test_pyatmo.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Define tests for untility methods.""" -# pylint: disable=protected-access -import json -import time - -import oauthlib -import pyatmo -import pytest - - -def test_client_auth(auth): - assert auth._oauth.token["access_token"] == ( - "91763b24c43d3e344f424e8b|880b55a08c758e87ff8755a00c6b8a12" - ) - assert auth._oauth.token["refresh_token"] == ( - "91763b24c43d3e344f424e8b|87ff8755a00c6b8a120b55a08c758e93" - ) - - -def test_client_auth_invalid(requests_mock): - with open("fixtures/invalid_grant.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.auth.AUTH_REQ_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(oauthlib.oauth2.rfc6749.errors.InvalidGrantError): - pyatmo.ClientAuth( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - username="USERNAME", - password="PASSWORD", - ) - - -def test_post_request_json(auth, requests_mock): - """Test wrapper for posting requests against the Netatmo API.""" - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL, - json={"a": "b"}, - headers={"content-type": "application/json"}, - ) - resp = auth.post_request(pyatmo.const.DEFAULT_BASE_URL, None).json() - assert resp == {"a": "b"} - - -def test_post_request_binary(auth, requests_mock): - """Test wrapper for posting requests against the Netatmo API.""" - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL, - text="Success", - headers={"content-type": "application/text"}, - ) - resp = auth.post_request(pyatmo.const.DEFAULT_BASE_URL, None).content - assert resp == b"Success" - - -@pytest.mark.parametrize("test_input,expected", [(200, None), (404, None), (401, None)]) -def test_post_request_fail(auth, requests_mock, test_input, expected): - """Test failing requests against the Netatmo API.""" - requests_mock.post(pyatmo.const.DEFAULT_BASE_URL, status_code=test_input) - - if test_input == 200: - resp = auth.post_request(pyatmo.const.DEFAULT_BASE_URL, None).content - assert resp is expected - else: - with pytest.raises(pyatmo.ApiError): - resp = auth.post_request(pyatmo.const.DEFAULT_BASE_URL, None).content - - -@pytest.mark.parametrize( - "test_input,expected", - [ - (1, "1970-01-01_00:00:01"), - (0, "1970-01-01_00:00:00"), - (-1, "1969-12-31_23:59:59"), - (2000000000, "2033-05-18_03:33:20"), - ("1", "1970-01-01_00:00:01"), - pytest.param("A", None, marks=pytest.mark.xfail), - pytest.param([1], None, marks=pytest.mark.xfail), - pytest.param({1}, None, marks=pytest.mark.xfail), - ], -) -def test_to_time_string(test_input, expected): - """Test time to string conversion.""" - assert pyatmo.helpers.to_time_string(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("1970-01-01_00:00:01", 1), - ("1970-01-01_00:00:00", 0), - ("1969-12-31_23:59:59", -1), - ("2033-05-18_03:33:20", 2000000000), - ], -) -def test_to_epoch(test_input, expected): - """Test time to epoch conversion.""" - assert pyatmo.helpers.to_epoch(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("2018-06-21", (1529539200, 1529625600)), - ("2000-01-01", (946684800, 946771200)), - pytest.param("2000-04-31", None, marks=pytest.mark.xfail), - ], -) -def test_today_stamps(monkeypatch, test_input, expected): - """Test today_stamps function.""" - - def mockreturn(_): - return test_input - - monkeypatch.setattr(time, "strftime", mockreturn) - assert pyatmo.helpers.today_stamps() == expected diff --git a/tests/test_pyatmo_camera.py b/tests/test_pyatmo_camera.py deleted file mode 100644 index d10d047e..00000000 --- a/tests/test_pyatmo_camera.py +++ /dev/null @@ -1,579 +0,0 @@ -"""Define tests for Camera module.""" -# pylint: disable=protected-access -import datetime as dt -import json - -import pyatmo -import pytest -import time_machine - -from .conftest import does_not_raise - - -def test_camera_data(camera_home_data): - assert camera_home_data.homes is not None - - -def test_home_data_no_body(auth, requests_mock): - with open("fixtures/camera_data_empty.json", encoding="utf-8") as fixture_file: - json_fixture = json.load(fixture_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMEDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - camera_data = pyatmo.CameraData(auth) - camera_data.update() - - -def test_home_data_no_homes(auth, requests_mock): - with open( - "fixtures/camera_home_data_no_homes.json", - encoding="utf-8", - ) as fixture_file: - json_fixture = json.load(fixture_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMEDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - camera_data = pyatmo.CameraData(auth) - camera_data.update() - - -@pytest.mark.parametrize( - "cid, expected", - [ - ("12:34:56:00:f1:62", "Hall"), - ("12:34:56:00:a5:a4", "Garden"), - ("12:34:56:00:a5:a6", "NOC"), - ("None", None), - (None, None), - ], -) -def test_camera_data_get_camera(camera_home_data, cid, expected): - camera = camera_home_data.get_camera(cid) - assert camera.get("name") == expected - - -def test_camera_data_get_module(camera_home_data): - assert camera_home_data.get_module("00:00:00:00:00:00") is None - - -def test_camera_data_camera_urls(camera_home_data, requests_mock): - cid = "12:34:56:00:f1:62" - vpn_url = ( - "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/" - "6d278460699e56180d47ab47169efb31/" - "MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTg,," - ) - local_url = "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d" - with open("fixtures/camera_ping.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - f"{vpn_url}/command/ping", - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - with open("fixtures/camera_ping.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - f"{local_url}/command/ping", - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - camera_home_data.update_camera_urls(cid) - - assert camera_home_data.camera_urls(cid) == (vpn_url, local_url) - - -def test_camera_data_update_camera_urls_empty(camera_home_data): - camera_id = "12:34:56:00:f1:62" - home_id = "91763b24c43d3e344f424e8b" - camera_home_data.cameras[home_id][camera_id]["vpn_url"] = None - camera_home_data.cameras[home_id][camera_id]["local_url"] = None - - camera_home_data.update_camera_urls(camera_id) - - assert camera_home_data.camera_urls(camera_id) == (None, None) - - -def test_camera_data_camera_urls_disconnected(auth, camera_ping, requests_mock): - with open( - "fixtures/camera_home_data_disconnected.json", - encoding="utf-8", - ) as fixture_file: - json_fixture = json.load(fixture_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMEDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - camera_data = pyatmo.CameraData(auth) - camera_data.update() - cid = "12:34:56:00:f1:62" - - camera_data.update_camera_urls(cid) - - assert camera_data.camera_urls(cid) == (None, None) - - -@pytest.mark.parametrize( - "home_id, expected", - [("91763b24c43d3e344f424e8b", ["Richard Doe"])], -) -def test_camera_data_persons_at_home(camera_home_data, home_id, expected): - assert camera_home_data.persons_at_home(home_id) == expected - - -@time_machine.travel(dt.datetime(2019, 6, 16)) -@pytest.mark.parametrize( - "name, cid, exclude, expected", - [ - ("John Doe", "12:34:56:00:f1:62", None, True), - ("Richard Doe", "12:34:56:00:f1:62", None, False), - ("Unknown", "12:34:56:00:f1:62", None, False), - ("John Doe", "12:34:56:00:f1:62", 1, False), - ("John Doe", "12:34:56:00:f1:62", 50000, True), - ("Jack Doe", "12:34:56:00:f1:62", None, False), - ], -) -def test_camera_data_person_seen_by_camera( - camera_home_data, - name, - cid, - exclude, - expected, -): - assert ( - camera_home_data.person_seen_by_camera(name, cid, exclude=exclude) is expected - ) - - -def test_camera_data__known_persons(camera_home_data): - known_persons = camera_home_data._known_persons("91763b24c43d3e344f424e8b") - assert len(known_persons) == 3 - assert known_persons["91827374-7e04-5298-83ad-a0cb8372dff1"]["pseudo"] == "John Doe" - - -def test_camera_data_known_persons(camera_home_data): - known_persons = camera_home_data.known_persons("91763b24c43d3e344f424e8b") - assert len(known_persons) == 3 - assert known_persons["91827374-7e04-5298-83ad-a0cb8372dff1"] == "John Doe" - - -def test_camera_data_known_persons_names(camera_home_data): - assert sorted(camera_home_data.known_persons_names("91763b24c43d3e344f424e8b")) == [ - "Jane Doe", - "John Doe", - "Richard Doe", - ] - - -@time_machine.travel(dt.datetime(2019, 6, 16)) -@pytest.mark.parametrize( - "name, home_id, expected", - [ - ( - "John Doe", - "91763b24c43d3e344f424e8b", - "91827374-7e04-5298-83ad-a0cb8372dff1", - ), - ( - "Richard Doe", - "91763b24c43d3e344f424e8b", - "91827376-7e04-5298-83af-a0cb8372dff3", - ), - ("Dexter Foe", "91763b24c43d3e344f424e8b", None), - ], -) -def test_camera_data_get_person_id(camera_home_data, name, home_id, expected): - assert camera_home_data.get_person_id(name, home_id) == expected - - -@pytest.mark.parametrize( - "home_id, person_id, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "91827374-7e04-5298-83ad-a0cb8372dff1", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "91827376-7e04-5298-83af-a0cb8372dff3", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - None, - "status_ok.json", - "ok", - ), - ], -) -def test_camera_data_set_persons_away( - camera_home_data, - requests_mock, - home_id, - person_id, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - mock_req = requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETPERSONSAWAY_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert camera_home_data.set_persons_away(home_id, person_id)["status"] == expected - if person_id is not None: - assert ( - mock_req.request_history[0].text - == f"home_id={home_id}&person_id={person_id}" - ) - else: - assert mock_req.request_history[0].text == f"home_id={home_id}" - - -@pytest.mark.parametrize( - "home_id, person_ids, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - [ - "91827374-7e04-5298-83ad-a0cb8372dff1", - "91827376-7e04-5298-83af-a0cb8372dff3", - ], - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "91827376-7e04-5298-83af-a0cb8372dff3", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - None, - "status_ok.json", - "ok", - ), - ], -) -def test_camera_data_set_persons_home( - camera_home_data, - requests_mock, - home_id, - person_ids, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - mock_req = requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETPERSONSHOME_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert camera_home_data.set_persons_home(home_id, person_ids)["status"] == expected - - if isinstance(person_ids, list): - assert ( - mock_req.request_history[0].text - == f"home_id={home_id}&person_ids%5B%5D={'&person_ids%5B%5D='.join(person_ids)}" - ) - elif person_ids: - assert ( - mock_req.request_history[0].text - == f"home_id={home_id}&person_ids%5B%5D={person_ids}" - ) - else: - assert mock_req.request_history[0].text == f"home_id={home_id}" - - -@time_machine.travel(dt.datetime(2019, 6, 16)) -@pytest.mark.parametrize( - "camera_id, exclude, expected, expectation", - [ - ("12:34:56:00:f1:62", None, True, does_not_raise()), - ("12:34:56:00:f1:62", 40000, True, does_not_raise()), - ("12:34:56:00:f1:62", 5, False, does_not_raise()), - (None, None, None, pytest.raises(pyatmo.NoDevice)), - ], -) -def test_camera_data_someone_known_seen( - camera_home_data, - camera_id, - exclude, - expected, - expectation, -): - with expectation: - assert camera_home_data.someone_known_seen(camera_id, exclude) == expected - - -@time_machine.travel(dt.datetime(2019, 6, 16)) -@pytest.mark.parametrize( - "camera_id, exclude, expected, expectation", - [ - ("12:34:56:00:f1:62", None, False, does_not_raise()), - ("12:34:56:00:f1:62", 40000, True, does_not_raise()), - ("12:34:56:00:f1:62", 100, False, does_not_raise()), - (None, None, None, pytest.raises(pyatmo.NoDevice)), - ], -) -def test_camera_data_someone_unknown_seen( - camera_home_data, - camera_id, - exclude, - expected, - expectation, -): - with expectation: - assert camera_home_data.someone_unknown_seen(camera_id, exclude) == expected - - -@time_machine.travel(dt.datetime(2019, 6, 16)) -@pytest.mark.parametrize( - "camera_id, exclude, expected, expectation", - [ - ("12:34:56:00:f1:62", None, False, does_not_raise()), - ("12:34:56:00:f1:62", 140000, True, does_not_raise()), - ("12:34:56:00:f1:62", 130000, False, does_not_raise()), - (None, None, False, pytest.raises(pyatmo.NoDevice)), - ], -) -def test_camera_data_motion_detected( - camera_home_data, - camera_id, - exclude, - expected, - expectation, -): - with expectation: - assert camera_home_data.motion_detected(camera_id, exclude) == expected - - -@pytest.mark.parametrize( - "sid, expected", - [ - ("12:34:56:00:8b:a2", "Hall"), - ("12:34:56:00:8b:ac", "Kitchen"), - ("None", None), - (None, None), - ], -) -def test_camera_data_get_smokedetector(camera_home_data, sid, expected): - if smokedetector := camera_home_data.get_smokedetector(sid): - assert smokedetector["name"] == expected - else: - assert smokedetector is expected - - -@pytest.mark.parametrize( - "home_id, camera_id, floodlight, monitoring, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "12:34:56:00:f1:ff", - "on", - None, - "camera_set_state_error.json", - False, - ), - ( - "91763b24c43d3e344f424e8b", - "12:34:56:00:f1:62", - None, - "on", - "camera_set_state_ok.json", - True, - ), - (None, "12:34:56:00:f1:62", None, "on", "camera_set_state_ok.json", True), - ( - "91763b24c43d3e344f424e8b", - "12:34:56:00:f1:62", - "auto", - "on", - "camera_set_state_ok.json", - True, - ), - ( - "91763b24c43d3e344f424e8b", - "12:34:56:00:f1:62", - None, - "on", - "camera_set_state_error_already_on.json", - True, - ), - ( - "91763b24c43d3e344f424e8b", - "12:34:56:00:f1:62", - "on", - None, - "camera_set_state_error_wrong_parameter.json", - False, - ), - ], -) -def test_camera_data_set_state( - camera_home_data, - requests_mock, - home_id, - camera_id, - floodlight, - monitoring, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as fixture_file: - json_fixture = json.load(fixture_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETSTATE_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert ( - camera_home_data.set_state( - home_id=home_id, - camera_id=camera_id, - floodlight=floodlight, - monitoring=monitoring, - ) - == expected - ) - - -def test_camera_data_get_light_state(camera_home_data): - camera_id = "12:34:56:00:a5:a4" - expected = "auto" - assert camera_home_data.get_light_state(camera_id) == expected - - -def test_camera_data_get_camera_picture(camera_home_data, requests_mock): - image_id = "5c22739723720a6e278c43bf" - key = "276751836a6d1a71447f8d975494c87bc125766a970f7e022e79e001e021d756" - with open( - "fixtures/camera_image_sample.jpg", - "rb", - ) as fixture_file: - expect = fixture_file.read() - - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETCAMERAPICTURE_ENDPOINT, - content=expect, - ) - - assert camera_home_data.get_camera_picture(image_id, key) == (expect, "jpeg") - - -def test_camera_data_get_profile_image(camera_home_data, requests_mock): - with open( - "fixtures/camera_image_sample.jpg", - "rb", - ) as fixture_file: - expect = fixture_file.read() - - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETCAMERAPICTURE_ENDPOINT, - content=expect, - ) - assert camera_home_data.get_profile_image( - "John Doe", - "91763b24c43d3e344f424e8b", - ) == (expect, "jpeg") - assert camera_home_data.get_profile_image( - "Jack Foe", - "91763b24c43d3e344f424e8b", - ) == (None, None) - - -@pytest.mark.parametrize( - "home_id, event_id, device_type, exception", - [ - ("91763b24c43d3e344f424e8b", None, None, pytest.raises(pyatmo.ApiError)), - ( - "91763b24c43d3e344f424e8b", - "a1b2c3d4e5f6abcdef123456", - None, - does_not_raise(), - ), - ("91763b24c43d3e344f424e8b", None, "NOC", does_not_raise()), - ("91763b24c43d3e344f424e8b", None, "NACamera", does_not_raise()), - ("91763b24c43d3e344f424e8b", None, "NSD", does_not_raise()), - ], -) -def test_camera_data_update_events( - camera_home_data, - requests_mock, - home_id, - event_id, - device_type, - exception, -): - with open( - "fixtures/camera_data_events_until.json", - encoding="utf-8", - ) as fixture_file: - json_fixture = json.load(fixture_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETEVENTSUNTIL_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with exception: - assert ( - camera_home_data.update_events( - home_id=home_id, - event_id=event_id, - device_type=device_type, - ) - is None - ) - - -def test_camera_data_outdoor_motion_detected(camera_home_data): - camera_id = "12:34:56:00:a5:a4" - assert camera_home_data.outdoor_motion_detected(camera_id) is False - assert camera_home_data.outdoor_motion_detected(camera_id, 100) is False - - -def test_camera_data_human_detected(camera_home_data): - camera_id = "12:34:56:00:a5:a4" - assert camera_home_data.human_detected(camera_id) is False - assert camera_home_data.human_detected(camera_id, 100) is False - - -def test_camera_data_animal_detected(camera_home_data): - camera_id = "12:34:56:00:a5:a4" - assert camera_home_data.animal_detected(camera_id) is False - assert camera_home_data.animal_detected(camera_id, 100) is False - - -def test_camera_data_car_detected(camera_home_data): - camera_id = "12:34:56:00:a5:a4" - assert camera_home_data.car_detected(camera_id) is False - assert camera_home_data.car_detected(camera_id, 100) is False - - -def test_camera_data_module_motion_detected(camera_home_data): - camera_id = "12:34:56:00:f1:62" - module_id = "12:34:56:00:f2:f1" - assert camera_home_data.module_motion_detected(camera_id, module_id) is False - assert camera_home_data.module_motion_detected(camera_id, module_id, 100) is False - - -def test_camera_data_module_opened(camera_home_data): - camera_id = "12:34:56:00:f1:62" - module_id = "12:34:56:00:f2:f1" - assert camera_home_data.module_opened(camera_id, module_id) is False - assert camera_home_data.module_opened(camera_id, module_id, 100) is False diff --git a/tests/test_pyatmo_homecoach.py b/tests/test_pyatmo_homecoach.py deleted file mode 100644 index 2fa475e1..00000000 --- a/tests/test_pyatmo_homecoach.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Define tests for HomeCoach module.""" -# pylint: disable=protected-access -import json - -import pyatmo -import pytest - - -def test_home_coach_data(home_coach_data): - assert home_coach_data.stations["12:34:56:26:69:0c"]["station_name"] == "Bedroom" - - -@pytest.mark.parametrize( - "station_id, expected", - [ - ("12:34:56:26:69:0c", ["Bedroom"]), - pytest.param( - "NoValidStation", - None, - marks=pytest.mark.xfail( - reason="Invalid station names are not handled yet.", - ), - ), - ], -) -def test_home_coach_data_get_module_names(home_coach_data, station_id, expected): - assert sorted(home_coach_data.get_module_names(station_id)) == expected - - -@pytest.mark.parametrize( - "station_id, expected", - [ - (None, {}), - ( - "12:34:56:26:69:0c", - { - "12:34:56:26:69:0c": { - "station_name": "Bedroom", - "module_name": "Bedroom", - "id": "12:34:56:26:69:0c", - }, - }, - ), - pytest.param( - "NoValidStation", - None, - marks=pytest.mark.xfail( - reason="Invalid station names are not handled yet.", - ), - ), - ], -) -def test_home_coach_data_get_modules(home_coach_data, station_id, expected): - assert home_coach_data.get_modules(station_id) == expected - - -def test_home_coach_data_no_devices(auth, requests_mock): - with open("fixtures/home_coach_no_devices.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMECOACHDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - hcd = pyatmo.home_coach.HomeCoachData(auth) - hcd.update() diff --git a/tests/test_pyatmo_publicdata.py b/tests/test_pyatmo_publicdata.py deleted file mode 100644 index c844e8ec..00000000 --- a/tests/test_pyatmo_publicdata.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Define tests for Public weather module.""" -# pylint: disable=protected-access -import json - -import pyatmo -import pytest - -LON_NE = "6.221652" -LAT_NE = "46.610870" -LON_SW = "6.217828" -LAT_SW = "46.596485" - - -def test_public_data(auth, requests_mock): - with open("fixtures/public_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETPUBLIC_DATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - - public_data = pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - public_data.update() - assert public_data.status == "ok" - - public_data = pyatmo.PublicData( - auth, - LAT_NE, - LON_NE, - LAT_SW, - LON_SW, - required_data_type="temperature,rain_live", - ) - public_data.update() - assert public_data.status == "ok" - - -def test_public_data_unavailable(auth, requests_mock): - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETPUBLIC_DATA_ENDPOINT, - status_code=404, - ) - with pytest.raises(pyatmo.ApiError): - public_data = pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - public_data.update() - - -def test_public_data_error(auth, requests_mock): - with open("fixtures/public_data_error_mongo.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETPUBLIC_DATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - public_data = pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW) - public_data.update() - - -def test_public_data_stations_in_area(public_data): - assert public_data.stations_in_area() == 8 - - -def test_public_data_get_latest_rain(public_data): - expected = { - "70:ee:50:1f:68:9e": 0, - "70:ee:50:27:25:b0": 0, - "70:ee:50:36:94:7c": 0.5, - "70:ee:50:36:a9:fc": 0, - } - assert public_data.get_latest_rain() == expected - - -def test_public_data_get_average_rain(public_data): - assert public_data.get_average_rain() == 0.125 - - -def test_public_data_get_60_min_rain(public_data): - expected = { - "70:ee:50:1f:68:9e": 0, - "70:ee:50:27:25:b0": 0, - "70:ee:50:36:94:7c": 0.2, - "70:ee:50:36:a9:fc": 0, - } - assert public_data.get_60_min_rain() == expected - - -def test_public_data_get_average_60_min_rain(public_data): - assert public_data.get_average_60_min_rain() == 0.05 - - -def test_public_data_get_24_h_rain(public_data): - expected = { - "70:ee:50:1f:68:9e": 9.999, - "70:ee:50:27:25:b0": 11.716000000000001, - "70:ee:50:36:94:7c": 12.322000000000001, - "70:ee:50:36:a9:fc": 11.009, - } - assert public_data.get_24_h_rain() == expected - - -def test_public_data_get_average_24_h_rain(public_data): - assert public_data.get_average_24_h_rain() == 11.261500000000002 - - -def test_public_data_get_latest_pressures(public_data): - expected = { - "70:ee:50:1f:68:9e": 1007.3, - "70:ee:50:27:25:b0": 1012.8, - "70:ee:50:36:94:7c": 1010.6, - "70:ee:50:36:a9:fc": 1010, - "70:ee:50:01:20:fa": 1014.4, - "70:ee:50:04:ed:7a": 1005.4, - "70:ee:50:27:9f:2c": 1010.6, - "70:ee:50:3c:02:78": 1011.7, - } - assert public_data.get_latest_pressures() == expected - - -def test_public_data_get_average_pressure(public_data): - assert public_data.get_average_pressure() == 1010.3499999999999 - - -def test_public_data_get_latest_temperatures(public_data): - expected = { - "70:ee:50:1f:68:9e": 21.1, - "70:ee:50:27:25:b0": 23.2, - "70:ee:50:36:94:7c": 21.4, - "70:ee:50:36:a9:fc": 20.1, - "70:ee:50:01:20:fa": 27.4, - "70:ee:50:04:ed:7a": 19.8, - "70:ee:50:27:9f:2c": 25.5, - "70:ee:50:3c:02:78": 23.3, - } - assert public_data.get_latest_temperatures() == expected - - -def test_public_data_get_average_temperature(public_data): - assert public_data.get_average_temperature() == 22.725 - - -def test_public_data_get_latest_humidities(public_data): - expected = { - "70:ee:50:1f:68:9e": 69, - "70:ee:50:27:25:b0": 60, - "70:ee:50:36:94:7c": 62, - "70:ee:50:36:a9:fc": 67, - "70:ee:50:01:20:fa": 58, - "70:ee:50:04:ed:7a": 76, - "70:ee:50:27:9f:2c": 56, - "70:ee:50:3c:02:78": 58, - } - assert public_data.get_latest_humidities() == expected - - -def test_public_data_get_average_humidity(public_data): - assert public_data.get_average_humidity() == 63.25 - - -def test_public_data_get_latest_wind_strengths(public_data): - expected = {"70:ee:50:36:a9:fc": 15} - assert public_data.get_latest_wind_strengths() == expected - - -def test_public_data_get_average_wind_strength(public_data): - assert public_data.get_average_wind_strength() == 15 - - -def test_public_data_get_latest_wind_angles(public_data): - expected = {"70:ee:50:36:a9:fc": 17} - assert public_data.get_latest_wind_angles() == expected - - -def test_public_data_get_latest_gust_strengths(public_data): - expected = {"70:ee:50:36:a9:fc": 31} - assert public_data.get_latest_gust_strengths() == expected - - -def test_public_data_get_average_gust_strength(public_data): - assert public_data.get_average_gust_strength() == 31 - - -def test_public_data_get_latest_gust_angles(public_data): - expected = {"70:ee:50:36:a9:fc": 217} - assert public_data.get_latest_gust_angles() == expected - - -def test_public_data_get_locations(public_data): - expected = { - "70:ee:50:1f:68:9e": [8.795445200000017, 50.2130169], - "70:ee:50:27:25:b0": [8.7807159, 50.1946167], - "70:ee:50:36:94:7c": [8.791382999999996, 50.2136394], - "70:ee:50:36:a9:fc": [8.801164269110814, 50.19596181704958], - "70:ee:50:01:20:fa": [8.7953, 50.195241], - "70:ee:50:04:ed:7a": [8.785034, 50.192169], - "70:ee:50:27:9f:2c": [8.785342, 50.193573], - "70:ee:50:3c:02:78": [8.795953681700666, 50.19530139868166], - } - assert public_data.get_locations() == expected - - -def test_public_data_get_time_for_rain_measures(public_data): - expected = { - "70:ee:50:36:a9:fc": 1560248184, - "70:ee:50:1f:68:9e": 1560248344, - "70:ee:50:27:25:b0": 1560247896, - "70:ee:50:36:94:7c": 1560248022, - } - assert public_data.get_time_for_rain_measures() == expected - - -def test_public_data_get_time_for_wind_measures(public_data): - expected = {"70:ee:50:36:a9:fc": 1560248190} - assert public_data.get_time_for_wind_measures() == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ( - "pressure", - { - "70:ee:50:01:20:fa": 1014.4, - "70:ee:50:04:ed:7a": 1005.4, - "70:ee:50:1f:68:9e": 1007.3, - "70:ee:50:27:25:b0": 1012.8, - "70:ee:50:27:9f:2c": 1010.6, - "70:ee:50:36:94:7c": 1010.6, - "70:ee:50:36:a9:fc": 1010, - "70:ee:50:3c:02:78": 1011.7, - }, - ), - ( - "temperature", - { - "70:ee:50:01:20:fa": 27.4, - "70:ee:50:04:ed:7a": 19.8, - "70:ee:50:1f:68:9e": 21.1, - "70:ee:50:27:25:b0": 23.2, - "70:ee:50:27:9f:2c": 25.5, - "70:ee:50:36:94:7c": 21.4, - "70:ee:50:36:a9:fc": 20.1, - "70:ee:50:3c:02:78": 23.3, - }, - ), - ( - "humidity", - { - "70:ee:50:01:20:fa": 58, - "70:ee:50:04:ed:7a": 76, - "70:ee:50:1f:68:9e": 69, - "70:ee:50:27:25:b0": 60, - "70:ee:50:27:9f:2c": 56, - "70:ee:50:36:94:7c": 62, - "70:ee:50:36:a9:fc": 67, - "70:ee:50:3c:02:78": 58, - }, - ), - ], -) -def test_public_data_get_latest_station_measures(public_data, test_input, expected): - assert public_data.get_latest_station_measures(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ("wind_strength", {"70:ee:50:36:a9:fc": 15}), - ("wind_angle", {"70:ee:50:36:a9:fc": 17}), - ("gust_strength", {"70:ee:50:36:a9:fc": 31}), - ("gust_angle", {"70:ee:50:36:a9:fc": 217}), - ("wind_timeutc", {"70:ee:50:36:a9:fc": 1560248190}), - ], -) -def test_public_data_get_accessory_data(public_data, test_input, expected): - assert public_data.get_accessory_data(test_input) == expected - - -@pytest.mark.parametrize( - "test_input,expected", - [ - ( - { - "70:ee:50:01:20:fa": 1014.4, - "70:ee:50:04:ed:7a": 1005.4, - "70:ee:50:1f:68:9e": 1007.3, - "70:ee:50:27:25:b0": 1012.8, - "70:ee:50:27:9f:2c": 1010.6, - "70:ee:50:36:94:7c": 1010.6, - "70:ee:50:36:a9:fc": 1010, - "70:ee:50:3c:02:78": 1011.7, - }, - 1010.35, - ), - ( - { - "70:ee:50:01:20:fa": 27.4, - "70:ee:50:04:ed:7a": 19.8, - "70:ee:50:1f:68:9e": 21.1, - "70:ee:50:27:25:b0": 23.2, - "70:ee:50:27:9f:2c": 25.5, - "70:ee:50:36:94:7c": 21.4, - "70:ee:50:36:a9:fc": 20.1, - "70:ee:50:3c:02:78": 23.3, - }, - 22.725, - ), - ({}, 0), - ], -) -def test_public_data_average(test_input, expected): - assert pyatmo.public_data.average(test_input) == expected diff --git a/tests/test_pyatmo_refactor.py b/tests/test_pyatmo_refactor.py deleted file mode 100644 index 956f5cb7..00000000 --- a/tests/test_pyatmo_refactor.py +++ /dev/null @@ -1,1137 +0,0 @@ -"""Define tests for climate module.""" -import datetime as dt -import json -from unittest.mock import AsyncMock, patch - -import pyatmo -from pyatmo import DeviceType, NoDevice, NoSchedule -from pyatmo.modules import NATherm1 -from pyatmo.modules.base_class import Location, Place -from pyatmo.modules.device_types import DeviceCategory -import pytest -import time_machine - -from tests.common import fake_post_request -from tests.conftest import MockResponse, does_not_raise - -# pylint: disable=F6401 - - -@pytest.mark.asyncio -async def test_async_home(async_home): - """Test basic home setup.""" - room_id = "3688132631" - room = async_home.rooms[room_id] - assert room.device_types == { - DeviceType.NDB, - DeviceType.NACamera, - DeviceType.NBR, - DeviceType.NIS, - DeviceType.NBO, - } - assert len(async_home.rooms) == 8 - assert len(async_home.modules) == 37 - assert async_home.modules != room.modules - - module_id = "12:34:56:10:f1:66" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NDB - - module_id = "12:34:56:10:b9:0e" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NOC - - -@pytest.mark.asyncio -async def test_async_climate_room(async_home): - """Test room with climate devices.""" - room_id = "2746182631" - assert room_id in async_home.rooms - - room = async_home.rooms[room_id] - assert room.reachable is True - assert room.device_types == {DeviceType.NATherm1} - - module_id = "12:34:56:00:01:ae" - assert module_id in room.modules - assert len(room.modules) == 1 - - -@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" - module = async_home.modules[module_id] - assert module.name == "Livingroom" - assert module.device_type == DeviceType.NATherm1 - assert module.reachable is True - assert module.boiler_status is False - assert module.firmware_revision == 65 - assert module.battery == 75 - assert module.rf_strength == 58 - - -@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" - module = async_home.modules[module_id] - assert module.name == "Valve1" - assert async_home.rooms[module.room_id].name == "Entrada" - assert module.device_type == DeviceType.NRV - assert module.reachable is True - assert module.rf_strength == 51 - assert module.battery == 90 - assert module.firmware_revision == 79 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NAPlug - assert len(module.modules) == 3 - assert module.rf_strength == 107 - assert module.wifi_strength == 42 - assert module.firmware_revision == 174 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NIS - assert module.firmware_revision == 209 - assert module.status == "no_sound" - assert module.monitoring is False - - -@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" - module = async_home.modules[module_id] - assert module.name == "Bureau Modulate" - assert module.device_type == DeviceType.OTM - assert module.reachable is True - assert module.boiler_status is False - assert module.firmware_revision == 6 - assert module.battery == 90 - assert module.rf_strength == 64 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.OTH - assert len(module.modules) == 1 - assert module.wifi_strength == 57 - assert module.firmware_revision == 22 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NLP - assert module.firmware_revision == 62 - assert module.on - assert module.power == 0 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NLF - assert module.firmware_revision == 57 - assert module.on is False - assert module.brightness == 63 - assert module.power == 0 - - -@pytest.mark.asyncio -async def test_async_shutter_NBR(async_home): # pylint: disable=invalid-name - """Test NLP Bubendorf iDiamant roller shutter.""" - module_id = "0009999992" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NBR - assert module.firmware_revision == 16 - assert module.current_position == 0 - - -@pytest.mark.asyncio -async def test_async_shutter_NBO(async_home): # pylint: disable=invalid-name - """Test NBO Bubendorf iDiamant roller shutter.""" - module_id = "0009999993" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NBO - assert module.firmware_revision == 22 - assert module.current_position == 0 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NAMain - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - await module.async_update_camera_urls() - assert module.device_type == DeviceType.NACamera - assert module.is_local - assert module.local_url == "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d" - person_id = "91827374-7e04-5298-83ad-a0cb8372dff1" - assert person_id in module.home.persons - assert module.home.persons[person_id].pseudo == "John Doe" - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NLPC - assert module.power == 476 - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.BNS - assert module.name == "Smarther" - - room = async_home.rooms[module.room_id] - assert room.name == "Corridor" - assert room.device_types == { - DeviceType.BNS, - } - assert room.features == {"humidity", DeviceCategory.climate} - - -@pytest.mark.asyncio -async def test_async_home_set_schedule(async_home): - """Test home schedule.""" - schedule_id = "591b54a2764ff4d50d8b5795" - selected_schedule = async_home.get_selected_schedule() - assert selected_schedule.entity_id == schedule_id - assert async_home.is_valid_schedule(schedule_id) - assert not async_home.is_valid_schedule("123") - assert async_home.get_hg_temp() == 7 - assert async_home.get_away_temp() == 14 - - -@pytest.mark.asyncio -async def test_async_climate_update(async_account): - """Test basic climate state update.""" - home_id = "91763b24c43d3e344f424e8b" - await async_account.async_update_status(home_id) - home = async_account.homes[home_id] - - room_id = "2746182631" - room = home.rooms[room_id] - - module_id = "12:34:56:00:01:ae" - module = home.modules[module_id] - assert room.reachable is True - assert room.humidity is None - assert module.name == "Livingroom" - assert module.device_type == DeviceType.NATherm1 - assert module.reachable is True - assert module.boiler_status is False - assert module.battery == 75 - - assert isinstance(module, NATherm1) - - with open( - "fixtures/home_status_error_disconnected.json", - encoding="utf-8", - ) as json_file: - home_status_fixture = json.load(json_file) - mock_home_status_resp = MockResponse(home_status_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_home_status_resp), - ) as mock_request: - await async_account.async_update_status(home_id) - mock_request.assert_called() - - assert room.reachable is None - assert module.reachable is False - - with open("fixtures/home_status_simple.json", encoding="utf-8") as json_file: - home_status_fixture = json.load(json_file) - mock_home_status_resp = MockResponse(home_status_fixture, 200) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=mock_home_status_resp), - ) as mock_request: - await async_account.async_update_status(home_id) - mock_request.assert_called() - - assert room.reachable is True - assert module.reachable is True - assert module.battery == 75 - assert module.rf_strength == 58 - - -@pytest.mark.parametrize( - "t_sched_id, expected", - [ - ("591b54a2764ff4d50d8b5795", does_not_raise()), - ( - "123456789abcdefg12345678", - pytest.raises(NoSchedule), - ), - ], -) -@pytest.mark.asyncio -async def test_async_climate_switch_schedule( - async_home, - t_sched_id, - expected, -): - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ), expected: - await async_home.async_switch_schedule( - schedule_id=t_sched_id, - ) - - -@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) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=json_fixture), - ) as mock_request: - climate = pyatmo.AsyncAccount(async_auth) - - with pytest.raises(NoDevice): - await climate.async_update_topology() - mock_request.assert_called() - - -@pytest.mark.parametrize( - "temp, end_time", - [ - ( - 14, - None, - ), - ( - 14, - 1559162650, - ), - ( - None, - None, - ), - ( - None, - 1559162650, - ), - ], -) -@pytest.mark.asyncio -async def test_async_climate_room_therm_set( - async_home, - temp, - end_time, -): - room_id = "2746182631" - mode = "home" - - expected_params = { - "home_id": "91763b24c43d3e344f424e8b", - "room_id": room_id, - "mode": mode, - } - if temp: - expected_params["temp"] = str(temp) - if end_time: - expected_params["endtime"] = str(end_time) - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_post: - room = async_home.rooms[room_id] - - await room.async_therm_set( - mode=mode, - temp=temp, - end_time=end_time, - ) - mock_post.assert_awaited_once_with( - endpoint="api/setroomthermpoint", - params=expected_params, - ) - - -@pytest.mark.parametrize( - "mode, end_time, schedule_id, json_fixture, expected, exception", - [ - ( - "away", - None, - None, - "status_ok.json", - True, - does_not_raise(), - ), - ( - "away", - 1559162650, - None, - "status_ok.json", - True, - does_not_raise(), - ), - ( - "schedule", - None, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - True, - does_not_raise(), - ), - ( - "schedule", - 1559162650, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - True, - does_not_raise(), - ), - ( - None, - None, - None, - "home_status_error_mode_is_missing.json", - False, - pytest.raises(NoSchedule), - ), - ( - None, - None, - None, - "home_status_error_mode_is_missing.json", - False, - pytest.raises(NoSchedule), - ), - ( - "away", - 1559162650, - 0000000, - "status_ok.json", - True, - pytest.raises(NoSchedule), - ), - ( - "schedule", - None, - "blahblahblah", - "home_status_error_invalid_schedule_id.json", - False, - pytest.raises(NoSchedule), - ), - ], -) -@pytest.mark.asyncio -async def test_async_climate_set_thermmode( - async_home, - mode, - end_time, - schedule_id, - json_fixture, - expected, - exception, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - response = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ), exception: - resp = await async_home.async_set_thermmode( - mode=mode, - end_time=end_time, - schedule_id=schedule_id, - ) - assert expected is resp - - -@pytest.mark.asyncio -async def test_async_climate_empty_home(async_account): - """Test climate setup with empty home.""" - home_id = "91763b24c43d3e344f424e8c" - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - fake_post_request, - ): - await async_account.async_update_status(home_id) - - assert home_id in async_account.homes - - home = async_account.homes[home_id] - assert len(home.rooms) == 0 - - -@pytest.mark.asyncio -async def test_async_shutters(async_home): - """Test basic shutter functionality.""" - room_id = "3688132631" - assert room_id in async_home.rooms - - module_id = "0009999992" - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NBR - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - def gen_json_data(position): - return { - "json": { - "home": { - "id": "91763b24c43d3e344f424e8b", - "modules": [ - { - "bridge": "12:34:56:30:d5:d4", - "id": module_id, - "target_position": position, - }, - ], - }, - }, - } - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_resp: - assert await module.async_open() - mock_resp.assert_awaited_with( - params=gen_json_data(100), - endpoint="api/setstate", - ) - - assert await module.async_close() - mock_resp.assert_awaited_with( - params=gen_json_data(0), - endpoint="api/setstate", - ) - - assert await module.async_stop() - mock_resp.assert_awaited_with( - params=gen_json_data(-1), - endpoint="api/setstate", - ) - - assert await module.async_set_target_position(47) - mock_resp.assert_awaited_with( - params=gen_json_data(47), - endpoint="api/setstate", - ) - - assert await module.async_set_target_position(-10) - mock_resp.assert_awaited_with( - params=gen_json_data(-1), - endpoint="api/setstate", - ) - - assert await module.async_set_target_position(101) - mock_resp.assert_awaited_with( - params=gen_json_data(100), - endpoint="api/setstate", - ) - - -@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" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NOC - assert module.firmware_revision == 3002000 - assert module.firmware_name == "3.2.0" - assert module.monitoring is True - assert module.floodlight == "auto" - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - def gen_json_data(state): - return { - "json": { - "home": { - "id": "91763b24c43d3e344f424e8b", - "modules": [ - { - "id": module_id, - "floodlight": state, - }, - ], - }, - }, - } - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_resp: - assert await module.async_floodlight_on() - mock_resp.assert_awaited_with( - params=gen_json_data("on"), - endpoint="api/setstate", - ) - - assert await module.async_floodlight_off() - mock_resp.assert_awaited_with( - params=gen_json_data("off"), - endpoint="api/setstate", - ) - - assert await module.async_floodlight_auto() - mock_resp.assert_awaited_with( - params=gen_json_data("auto"), - endpoint="api/setstate", - ) - - -@pytest.mark.asyncio -async def test_async_camera_monitoring(async_home): - """Test basic camera monitoring functionality.""" - module_id = "12:34:56:10:b9:0e" - assert module_id in async_home.modules - module = async_home.modules[module_id] - assert module.device_type == DeviceType.NOC - assert module.is_local is False - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - def gen_json_data(state): - return { - "json": { - "home": { - "id": "91763b24c43d3e344f424e8b", - "modules": [ - { - "id": module_id, - "monitoring": state, - }, - ], - }, - }, - } - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_resp: - assert await module.async_monitoring_on() - mock_resp.assert_awaited_with( - params=gen_json_data("on"), - endpoint="api/setstate", - ) - - assert await module.async_monitoring_off() - mock_resp.assert_awaited_with( - params=gen_json_data("off"), - endpoint="api/setstate", - ) - - -@pytest.mark.asyncio -async def test_async_weather_update(async_account): - """Test basic weather station update.""" - home_id = "91763b24c43d3e344f424e8b" - await async_account.async_update_weather_stations() - home = async_account.homes[home_id] - - module_id = "12:34:56:80:bb:26" - assert module_id in home.modules - module = home.modules[module_id] - assert module.device_type == DeviceType.NAMain - assert module.name == "Villa" - assert module.modules == [ - "12:34:56:80:44:92", - "12:34:56:80:7e:18", - "12:34:56:80:1c:42", - "12:34:56:80:c1:ea", - ] - assert module.features == { - "temperature", - "humidity", - "co2", - "noise", - "pressure", - "absolute_pressure", - "temp_trend", - "pressure_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "reachable", - "wifi_strength", - "place", - } - assert module.firmware_revision == 181 - assert module.wifi_strength == 57 - assert module.temperature == 21.1 - assert module.humidity == 45 - assert module.co2 == 1339 - assert module.pressure == 1026.8 - assert module.noise == 35 - assert module.absolute_pressure == 974.5 - assert module.place == Place( - { - "altitude": 329, - "city": "Someplace", - "country": "FR", - "location": Location(longitude=6.1234567, latitude=46.123456), - "timezone": "Europe/Paris", - }, - ) - - module_id = "12:34:56:80:44:92" - assert module_id in home.modules - module = home.modules[module_id] - assert module.name == "Villa Bedroom" - assert module.features == { - "temperature", - "temp_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "reachable", - "rf_strength", - "co2", - "humidity", - "battery", - "place", - } - assert module.device_type == DeviceType.NAModule4 - assert module.modules is None - assert module.firmware_revision == 51 - assert module.rf_strength == 67 - assert module.temperature == 19.3 - assert module.humidity == 53 - assert module.battery == 28 - - module_id = "12:34:56:80:c1:ea" - assert module_id in home.modules - module = home.modules[module_id] - assert module.name == "Villa Rain" - assert module.features == { - "sum_rain_1", - "sum_rain_24", - "rain", - "reachable", - "rf_strength", - "battery", - "place", - } - assert module.device_type == DeviceType.NAModule3 - assert module.modules is None - assert module.firmware_revision == 12 - assert module.rf_strength == 79 - assert module.rain == 3.7 - - module_id = "12:34:56:80:1c:42" - assert module_id in home.modules - module = home.modules[module_id] - assert module.name == "Villa Outdoor" - assert module.features == { - "temperature", - "humidity", - "temp_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "reachable", - "rf_strength", - "battery", - "place", - } - assert module.device_type == DeviceType.NAModule1 - assert module.modules is None - assert module.firmware_revision == 50 - assert module.rf_strength == 68 - assert module.temperature == 9.4 - assert module.humidity == 57 - - module_id = "12:34:56:03:1b:e4" - assert module_id in home.modules - module = home.modules[module_id] - assert module.name == "Villa Garden" - assert module.features == { - "wind_strength", - "gust_strength", - "gust_angle", - "gust_direction", - "wind_angle", - "wind_direction", - "reachable", - "rf_strength", - "battery", - "place", - } - assert module.device_type == DeviceType.NAModule2 - assert module.modules is None - assert module.firmware_revision == 19 - assert module.rf_strength == 59 - assert module.wind_strength == 4 - assert module.wind_angle == 217 - assert module.gust_strength == 9 - assert module.gust_angle == 206 - - -@pytest.mark.asyncio -async def test_async_weather_favorite(async_account): - """Test favorite weather station.""" - await async_account.async_update_weather_stations() - - module_id = "00:11:22:2c:be:c8" - assert module_id in async_account.modules - module = async_account.modules[module_id] - assert module.device_type == DeviceType.NAMain - assert module.name == "Zuhause (Kinderzimmer)" - assert module.modules == ["00:11:22:2c:ce:b6"] - assert module.features == { - "temperature", - "humidity", - "co2", - "noise", - "pressure", - "absolute_pressure", - "temp_trend", - "pressure_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "reachable", - "wifi_strength", - "place", - } - assert module.pressure == 1015.6 - assert module.absolute_pressure == 1000.4 - assert module.place == Place( - { - "altitude": 127, - "city": "Wiesbaden", - "country": "DE", - "location": Location( - longitude=8.238054275512695, - latitude=50.07585525512695, - ), - "timezone": "Europe/Berlin", - }, - ) - - module_id = "00:11:22:2c:ce:b6" - assert module_id in async_account.modules - module = async_account.modules[module_id] - assert module.device_type == DeviceType.NAModule1 - assert module.name == "Unknown" - assert module.modules is None - assert module.features == { - "temperature", - "humidity", - "temp_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "reachable", - "rf_strength", - "battery", - "place", - } - assert module.temperature == 7.8 - assert module.humidity == 87 - - -@pytest.mark.asyncio -async def test_async_air_care_update(async_account): - """Test basic air care update.""" - await async_account.async_update_air_care() - - module_id = "12:34:56:26:68:92" - assert module_id in async_account.modules - module = async_account.modules[module_id] - - assert module.device_type == DeviceType.NHC - assert module.name == "Baby Bedroom" - assert module.features == { - "temperature", - "humidity", - "co2", - "noise", - "pressure", - "absolute_pressure", - "temp_trend", - "pressure_trend", - "min_temp", - "max_temp", - "temp_max", - "temp_min", - "health_idx", - "reachable", - "wifi_strength", - "place", - } - - assert module.modules is None - assert module.firmware_revision == 45 - assert module.wifi_strength == 68 - assert module.temperature == 21.6 - assert module.humidity == 66 - assert module.co2 == 1053 - assert module.pressure == 1021.4 - assert module.noise == 45 - assert module.absolute_pressure == 1011 - assert module.health_idx == 1 - - -@pytest.mark.asyncio -async def test_async_set_persons_home(async_account): - """Test marking a person being at home.""" - home_id = "91763b24c43d3e344f424e8b" - home = async_account.homes[home_id] - - person_ids = [ - "91827374-7e04-5298-83ad-a0cb8372dff1", - "91827375-7e04-5298-83ae-a0cb8372dff2", - ] - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_resp: - await home.async_set_persons_home(person_ids) - - mock_resp.assert_awaited_with( - params={"home_id": home_id, "person_ids[]": person_ids}, - endpoint="api/setpersonshome", - ) - - -@pytest.mark.asyncio -async def test_async_set_persons_away(async_account): - """Test marking a set of persons being away.""" - home_id = "91763b24c43d3e344f424e8b" - home = async_account.homes[home_id] - - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - response = json.load(json_file) - - with patch( - "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", - AsyncMock(return_value=MockResponse(response, 200)), - ) as mock_resp: - person_id = "91827374-7e04-5298-83ad-a0cb8372dff1" - await home.async_set_persons_away(person_id) - - mock_resp.assert_awaited_with( - params={"home_id": home_id, "person_id": person_id}, - endpoint="api/setpersonsaway", - ) - - await home.async_set_persons_away() - - mock_resp.assert_awaited_with( - params={"home_id": home_id}, - endpoint="api/setpersonsaway", - ) - - -@pytest.mark.asyncio -async def test_async_public_weather_update(async_account): - """Test basic public weather update.""" - lon_ne = "6.221652" - lat_ne = "46.610870" - lon_sw = "6.217828" - lat_sw = "46.596485" - - area_id = async_account.register_public_weather_area(lat_ne, lon_ne, lat_sw, lon_sw) - await async_account.async_update_public_weather(area_id) - - area = async_account.public_weather_areas[area_id] - assert area.location == pyatmo.modules.netatmo.Location( - lat_ne, - lon_ne, - lat_sw, - lon_sw, - ) - assert area.stations_in_area() == 8 - - assert area.get_latest_rain() == { - "70:ee:50:1f:68:9e": 0, - "70:ee:50:27:25:b0": 0, - "70:ee:50:36:94:7c": 0.5, - "70:ee:50:36:a9:fc": 0, - } - - assert area.get_60_min_rain() == { - "70:ee:50:1f:68:9e": 0, - "70:ee:50:27:25:b0": 0, - "70:ee:50:36:94:7c": 0.2, - "70:ee:50:36:a9:fc": 0, - } - - assert area.get_24_h_rain() == { - "70:ee:50:1f:68:9e": 9.999, - "70:ee:50:27:25:b0": 11.716000000000001, - "70:ee:50:36:94:7c": 12.322000000000001, - "70:ee:50:36:a9:fc": 11.009, - } - - assert area.get_latest_pressures() == { - "70:ee:50:1f:68:9e": 1007.3, - "70:ee:50:27:25:b0": 1012.8, - "70:ee:50:36:94:7c": 1010.6, - "70:ee:50:36:a9:fc": 1010, - "70:ee:50:01:20:fa": 1014.4, - "70:ee:50:04:ed:7a": 1005.4, - "70:ee:50:27:9f:2c": 1010.6, - "70:ee:50:3c:02:78": 1011.7, - } - - assert area.get_latest_temperatures() == { - "70:ee:50:1f:68:9e": 21.1, - "70:ee:50:27:25:b0": 23.2, - "70:ee:50:36:94:7c": 21.4, - "70:ee:50:36:a9:fc": 20.1, - "70:ee:50:01:20:fa": 27.4, - "70:ee:50:04:ed:7a": 19.8, - "70:ee:50:27:9f:2c": 25.5, - "70:ee:50:3c:02:78": 23.3, - } - - assert area.get_latest_humidities() == { - "70:ee:50:1f:68:9e": 69, - "70:ee:50:27:25:b0": 60, - "70:ee:50:36:94:7c": 62, - "70:ee:50:36:a9:fc": 67, - "70:ee:50:01:20:fa": 58, - "70:ee:50:04:ed:7a": 76, - "70:ee:50:27:9f:2c": 56, - "70:ee:50:3c:02:78": 58, - } - - assert area.get_latest_wind_strengths() == {"70:ee:50:36:a9:fc": 15} - - assert area.get_latest_wind_angles() == {"70:ee:50:36:a9:fc": 17} - - assert area.get_latest_gust_strengths() == {"70:ee:50:36:a9:fc": 31} - - assert area.get_latest_gust_angles() == {"70:ee:50:36:a9:fc": 217} - - -@pytest.mark.asyncio -async def test_home_event_update(async_account): - """Test basic event update.""" - home_id = "91763b24c43d3e344f424e8b" - await async_account.async_update_events(home_id=home_id) - home = async_account.homes[home_id] - - events = home.events - assert len(events) == 8 - - module_id = "12:34:56:10:b9:0e" - assert module_id in home.modules - module = home.modules[module_id] - - events = module.events - assert len(events) == 5 - assert events[0].event_type == "outdoor" - assert events[0].video_id == "11111111-2222-3333-4444-b42f0fc4cfad" - assert events[1].event_type == "connection" - - -@time_machine.travel(dt.datetime(2022, 2, 12, 7, 59, 49)) -@pytest.mark.asyncio -async def test_historical_data_retrieval(async_account): - """Test retrieval of historical measurements.""" - home_id = "91763b24c43d3e344f424e8b" - await async_account.async_update_events(home_id=home_id) - home = async_account.homes[home_id] - - module_id = "12:34:56:00:00:a1:4c:da" - assert module_id in home.modules - module = home.modules[module_id] - assert module.device_type == DeviceType.NLPC - - await async_account.async_update_measures(home_id=home_id, module_id=module_id) - assert module.historical_data[0] == { - "Wh": 197, - "duration": 60, - "startTime": "2022-02-05T08:29:50Z", - "endTime": "2022-02-05T09:29:49Z", - } - assert module.historical_data[-1] == { - "Wh": 259, - "duration": 60, - "startTime": "2022-02-12T07:29:50Z", - "endTime": "2022-02-12T08:29:49Z", - } - assert len(module.historical_data) == 168 - - -def test_device_types_missing(): - """Test handling of missing device types.""" - - assert DeviceType("NOC") == DeviceType.NOC - assert DeviceType("UNKNOWN") == DeviceType.NLunknown diff --git a/tests/test_pyatmo_thermostat.py b/tests/test_pyatmo_thermostat.py deleted file mode 100644 index 4d9e2651..00000000 --- a/tests/test_pyatmo_thermostat.py +++ /dev/null @@ -1,590 +0,0 @@ -"""Define tests for Thermostat module.""" -# pylint: disable=protected-access -import json - -import pyatmo -import pytest - -from tests.conftest import does_not_raise - - -def test_home_data(home_data): - expected = { - "12:34:56:00:fa:d0": { - "id": "12:34:56:00:fa:d0", - "type": "NAPlug", - "name": "Thermostat", - "setup_date": 1494963356, - "modules_bridged": [ - "12:34:56:00:01:ae", - "12:34:56:03:a0:ac", - "12:34:56:03:a5:54", - ], - }, - "12:34:56:00:01:ae": { - "id": "12:34:56:00:01:ae", - "type": "NATherm1", - "name": "Livingroom", - "setup_date": 1494963356, - "room_id": "2746182631", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:03:a5:54": { - "id": "12:34:56:03:a5:54", - "type": "NRV", - "name": "Valve1", - "setup_date": 1554549767, - "room_id": "2833524037", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:03:a0:ac": { - "id": "12:34:56:03:a0:ac", - "type": "NRV", - "name": "Valve2", - "setup_date": 1554554444, - "room_id": "2940411577", - "bridge": "12:34:56:00:fa:d0", - }, - "12:34:56:00:f1:62": { - "id": "12:34:56:00:f1:62", - "type": "NACamera", - "name": "Hall", - "setup_date": 1544828430, - "room_id": "3688132631", - }, - } - assert home_data.modules["91763b24c43d3e344f424e8b"] == expected - - -def test_home_data_no_data(auth, requests_mock): - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json={}, - headers={"content-type": "application/json"}, - ) - home_data = pyatmo.HomeData(auth) - with pytest.raises(pyatmo.NoDevice): - home_data.update() - - -def test_home_data_no_body(auth, requests_mock): - with open("fixtures/home_data_empty.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_data = pyatmo.HomeData(auth) - with pytest.raises(pyatmo.NoDevice): - home_data.update() - - -def test_home_data_no_homes(auth, requests_mock): - with open("fixtures/home_data_no_homes.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_data = pyatmo.HomeData(auth) - with pytest.raises(pyatmo.NoDevice): - home_data.update() - - -def test_home_data_no_home_name(auth, requests_mock): - with open("fixtures/home_data_nohomename.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_data = pyatmo.HomeData(auth) - home_data.update() - home_id = "91763b24c43d3e344f424e8b" - assert home_data.homes[home_id]["name"] == "Unknown" - - -@pytest.mark.parametrize( - "home_id, expected", - [("91763b24c43d3e344f424e8b", "MYHOME"), ("91763b24c43d3e344f424e8c", "Unknown")], -) -def test_home_data_homes_by_id(home_data, home_id, expected): - assert home_data.homes[home_id]["name"] == expected - - -def test_home_data_get_selected_schedule(home_data): - assert ( - home_data._get_selected_schedule("91763b24c43d3e344f424e8b")["name"] - == "Default" - ) - assert home_data._get_selected_schedule("Unknown") == {} - - -@pytest.mark.parametrize( - "t_home_id, t_sched_id, expected", - [ - ("91763b24c43d3e344f424e8b", "591b54a2764ff4d50d8b5795", does_not_raise()), - ( - "91763b24c43d3e344f424e8b", - "123456789abcdefg12345678", - pytest.raises(pyatmo.NoSchedule), - ), - ], -) -def test_home_data_switch_home_schedule( - home_data, - requests_mock, - t_home_id, - t_sched_id, - expected, -): - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SWITCHHOMESCHEDULE_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with expected: - home_data.switch_home_schedule(home_id=t_home_id, schedule_id=t_sched_id) - - -@pytest.mark.parametrize( - "home_id, expected", - [("91763b24c43d3e344f424e8b", 14), ("00000000000000000000000", None)], -) -def test_home_data_get_away_temp(home_data, home_id, expected): - assert home_data.get_away_temp(home_id) == expected - - -@pytest.mark.parametrize( - "home_id, expected", - [("91763b24c43d3e344f424e8b", 7), ("00000000000000000000000", None)], -) -def test_home_data_get_hg_temp(home_data, home_id, expected): - assert home_data.get_hg_temp(home_id) == expected - - -@pytest.mark.parametrize( - "home_id, module_id, expected", - [ - ("91763b24c43d3e344f424e8b", "2746182631", "NATherm1"), - ("91763b24c43d3e344f424e8b", "2833524037", "NRV"), - ("91763b24c43d3e344f424e8b", "0000000000", None), - ], -) -def test_home_data_thermostat_type(home_data, home_id, module_id, expected): - assert home_data.get_thermostat_type(home_id, module_id) == expected - - -@pytest.mark.parametrize( - "home_id, room_id, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "2746182631", - { - "id": "2746182631", - "reachable": True, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0, - }, - ), - ], -) -def test_home_status(home_status, room_id, expected): - assert len(home_status.rooms) == 3 - assert home_status.rooms[room_id] == expected - - -def test_home_status_error_and_data(auth, requests_mock): - with open( - "fixtures/home_status_error_and_data.json", - encoding="utf-8", - ) as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESTATUS_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - home_status = pyatmo.HomeStatus(auth, home_id="91763b24c43d3e344f424e8b") - home_status.update() - assert len(home_status.rooms) == 3 - - expexted = { - "id": "2746182631", - "reachable": True, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0, - } - assert home_status.rooms["2746182631"] == expexted - - -def test_home_status_error(auth, requests_mock): - with open("fixtures/home_status_empty.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESTATUS_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with open("fixtures/home_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - home_status = pyatmo.HomeStatus(auth, home_id="91763b24c43d3e344f424e8b") - home_status.update() - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_get_room(home_status): - expexted = { - "id": "2746182631", - "reachable": True, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0, - } - assert home_status.get_room("2746182631") == expexted - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.get_room("0000000000") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_get_thermostat(home_status): - expexted = { - "id": "12:34:56:00:01:ae", - "reachable": True, - "type": "NATherm1", - "firmware_revision": 65, - "rf_strength": 58, - "battery_level": 3780, - "boiler_valve_comfort_boost": False, - "boiler_status": True, - "anticipating": False, - "bridge": "12:34:56:00:fa:d0", - "battery_state": "high", - } - assert home_status.get_thermostat("12:34:56:00:01:ae") == expexted - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.get_thermostat("00:00:00:00:00:00") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_get_relay(home_status): - expexted = { - "id": "12:34:56:00:fa:d0", - "type": "NAPlug", - "firmware_revision": 174, - "rf_strength": 107, - "wifi_strength": 42, - } - assert home_status.get_relay("12:34:56:00:fa:d0") == expexted - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.get_relay("00:00:00:00:00:00") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_get_valve(home_status): - expexted = { - "id": "12:34:56:03:a5:54", - "reachable": True, - "type": "NRV", - "firmware_revision": 79, - "rf_strength": 51, - "battery_level": 3025, - "bridge": "12:34:56:00:fa:d0", - "battery_state": "full", - } - assert home_status.get_valve("12:34:56:03:a5:54") == expexted - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.get_valve("00:00:00:00:00:00") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_set_point(home_status): - assert home_status.set_point("2746182631") == 12 - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.set_point("0000000000") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_set_point_mode(home_status): - assert home_status.set_point_mode("2746182631") == "away" - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.set_point_mode("0000000000") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_measured_temperature(home_status): - assert home_status.measured_temperature("2746182631") == 19.8 - with pytest.raises(pyatmo.InvalidRoom): - assert home_status.measured_temperature("0000000000") - - -@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"]) -def test_home_status_boiler_status(home_status): - assert home_status.boiler_status("12:34:56:00:01:ae") is True - - -@pytest.mark.parametrize( - "home_id, mode, end_time, schedule_id, json_fixture, expected", - [ - ( - None, - None, - None, - None, - "home_status_error_mode_is_missing.json", - "mode is missing", - ), - ( - "91763b24c43d3e344f424e8b", - None, - None, - None, - "home_status_error_mode_is_missing.json", - "mode is missing", - ), - ( - "invalidID", - "away", - None, - None, - "home_status_error_invalid_id.json", - "Invalid id", - ), - ("91763b24c43d3e344f424e8b", "away", None, None, "status_ok.json", "ok"), - ("91763b24c43d3e344f424e8b", "away", 1559162650, None, "status_ok.json", "ok"), - ( - "91763b24c43d3e344f424e8b", - "away", - 1559162650, - 0000000, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - None, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - 1559162650, - "591b54a2764ff4d50d8b5795", - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "schedule", - None, - "blahblahblah", - "home_status_error_invalid_schedule_id.json", - "schedule is not therm schedule", - ), - ], -) -def test_home_status_set_thermmode( - home_status, - requests_mock, - mode, - end_time, - schedule_id, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETTHERMMODE_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - res = home_status.set_thermmode( - mode=mode, - end_time=end_time, - schedule_id=schedule_id, - ) - if "error" in res: - assert expected in res["error"]["message"] - else: - assert expected in res["status"] - - -@pytest.mark.parametrize( - "home_id, room_id, mode, temp, end_time, json_fixture, expected", - [ - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - 14, - None, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - 14, - 1559162650, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - None, - None, - "status_ok.json", - "ok", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - None, - 1559162650, - "status_ok.json", - "ok", - ), - ], -) -def test_home_status_set_room_thermpoint( - home_status, - requests_mock, - room_id, - mode, - temp, - end_time, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETROOMTHERMPOINT_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert ( - home_status.set_room_thermpoint( - room_id=room_id, - mode=mode, - temp=temp, - end_time=end_time, - )["status"] - == expected - ) - - -@pytest.mark.parametrize( - "home_id, room_id, mode, temp, json_fixture, expected", - [ - ( - None, - None, - None, - None, - "home_status_error_missing_home_id.json", - "Missing home_id", - ), - ( - None, - None, - "home", - None, - "home_status_error_missing_home_id.json", - "Missing home_id", - ), - ( - "91763b24c43d3e344f424e8b", - None, - "home", - None, - "home_status_error_missing_parameters.json", - "Missing parameters", - ), - ( - "91763b24c43d3e344f424e8b", - "2746182631", - "home", - None, - "home_status_error_missing_parameters.json", - "Missing parameters", - ), - ], -) -def test_home_status_set_room_thermpoint_error( - home_status, - requests_mock, - room_id, - mode, - temp, - json_fixture, - expected, -): - with open(f"fixtures/{json_fixture}", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.SETROOMTHERMPOINT_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert ( - home_status.set_room_thermpoint(room_id=room_id, mode=mode, temp=temp)["error"][ - "message" - ] - == expected - ) - - -def test_home_status_error_disconnected( - auth, - requests_mock, - home_id="91763b24c43d3e344f424e8b", -): - with open( - "fixtures/home_status_error_disconnected.json", - encoding="utf-8", - ) as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETHOMESTATUS_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with open("fixtures/home_data_simple.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.GETHOMESDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - home_status = pyatmo.HomeStatus(auth, home_id) - home_status.update() diff --git a/tests/test_pyatmo_weatherstation.py b/tests/test_pyatmo_weatherstation.py deleted file mode 100644 index 5019a126..00000000 --- a/tests/test_pyatmo_weatherstation.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Define tests for WeatherStation module.""" -# pylint: disable=protected-access -import datetime as dt -import json - -import pyatmo -import pytest -import time_machine - - -def test_weather_station_data(weather_station_data): - assert ( - weather_station_data.stations["12:34:56:37:11:ca"]["station_name"] - == "MyStation" - ) - - -def test_weather_station_data_no_response(auth, requests_mock): - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETSTATIONDATA_ENDPOINT, - json={}, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - wsd = pyatmo.WeatherStationData(auth) - wsd.update() - - -def test_weather_station_data_no_body(auth, requests_mock): - with open("fixtures/status_ok.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETSTATIONDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - wsd = pyatmo.WeatherStationData(auth) - wsd.update() - - -def test_weather_station_data_no_data(auth, requests_mock): - with open("fixtures/home_data_empty.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETSTATIONDATA_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - with pytest.raises(pyatmo.NoDevice): - wsd = pyatmo.WeatherStationData(auth) - wsd.update() - - -@pytest.mark.parametrize( - "station_id, expected", - [ - ( - "12:34:56:37:11:ca", - [ - "Garden", - "Kitchen", - "Livingroom", - "NetatmoIndoor", - "NetatmoOutdoor", - "Yard", - ], - ), - ("12:34:56:36:fd:3c", ["Module", "NAMain", "Rain Gauge"]), - pytest.param( - "NoValidStation", - None, - marks=pytest.mark.xfail( - reason="Invalid station names are not handled yet.", - ), - ), - ], -) -def test_weather_station_get_module_names(weather_station_data, station_id, expected): - assert sorted(weather_station_data.get_module_names(station_id)) == expected - - -@pytest.mark.parametrize( - "station_id, expected", - [ - (None, {}), - ( - "12:34:56:37:11:ca", - { - "12:34:56:03:1b:e4": { - "id": "12:34:56:03:1b:e4", - "module_name": "Garden", - "station_name": "MyStation", - }, - "12:34:56:05:51:20": { - "id": "12:34:56:05:51:20", - "module_name": "Yard", - "station_name": "MyStation", - }, - "12:34:56:07:bb:0e": { - "id": "12:34:56:07:bb:0e", - "module_name": "Livingroom", - "station_name": "MyStation", - }, - "12:34:56:07:bb:3e": { - "id": "12:34:56:07:bb:3e", - "module_name": "Kitchen", - "station_name": "MyStation", - }, - "12:34:56:36:fc:de": { - "id": "12:34:56:36:fc:de", - "module_name": "NetatmoOutdoor", - "station_name": "MyStation", - }, - "12:34:56:37:11:ca": { - "id": "12:34:56:37:11:ca", - "module_name": "NetatmoIndoor", - "station_name": "MyStation", - }, - }, - ), - ( - "12:34:56:1d:68:2e", - { - "12:34:56:1d:68:2e": { - "id": "12:34:56:1d:68:2e", - "module_name": "Basisstation", - "station_name": "NAMain", - }, - }, - ), - ( - "12:34:56:58:c8:54", - { - "12:34:56:58:c8:54": { - "id": "12:34:56:58:c8:54", - "module_name": "NAMain", - "station_name": "Njurunda (Indoor)", - }, - "12:34:56:58:e6:38": { - "id": "12:34:56:58:e6:38", - "module_name": "NAModule1", - "station_name": "Njurunda (Indoor)", - }, - }, - ), - pytest.param( - "NoValidStation", - None, - marks=pytest.mark.xfail( - reason="Invalid station names are not handled yet.", - ), - ), - ], -) -def test_weather_station_get_modules(weather_station_data, station_id, expected): - assert weather_station_data.get_modules(station_id) == expected - - -def test_weather_station_get_station(weather_station_data): - result = weather_station_data.get_station("12:34:56:37:11:ca") - - assert result["_id"] == "12:34:56:37:11:ca" - assert result["station_name"] == "MyStation" - assert result["module_name"] == "NetatmoIndoor" - assert result["type"] == "NAMain" - assert result["data_type"] == [ - "Temperature", - "CO2", - "Humidity", - "Noise", - "Pressure", - ] - - assert weather_station_data.get_station("NoValidStation") == {} - - -@pytest.mark.parametrize( - "mid, expected", - [ - ("12:34:56:07:bb:3e", "12:34:56:07:bb:3e"), - ("12:34:56:07:bb:3e", "12:34:56:07:bb:3e"), - ("", {}), - (None, {}), - ], -) -def test_weather_station_get_module(weather_station_data, mid, expected): - mod = weather_station_data.get_module(mid) - - assert isinstance(mod, dict) - assert mod.get("_id", mod) == expected - - -@pytest.mark.parametrize( - "module_id, expected", - [ - ( - "12:34:56:07:bb:3e", - [ - "CO2", - "Humidity", - "Temperature", - "battery_percent", - "battery_vp", - "reachable", - "rf_status", - "temp_trend", - ], - ), - ( - "12:34:56:07:bb:3e", - [ - "CO2", - "Humidity", - "Temperature", - "battery_percent", - "battery_vp", - "reachable", - "rf_status", - "temp_trend", - ], - ), - ( - "12:34:56:03:1b:e4", - [ - "GustAngle", - "GustStrength", - "WindAngle", - "WindStrength", - "battery_percent", - "battery_vp", - "reachable", - "rf_status", - ], - ), - ( - "12:34:56:05:51:20", - [ - "Rain", - "battery_percent", - "battery_vp", - "reachable", - "rf_status", - "sum_rain_1", - "sum_rain_24", - ], - ), - ( - "12:34:56:37:11:ca", - [ - "CO2", - "Humidity", - "Noise", - "Pressure", - "Temperature", - "pressure_trend", - "reachable", - "temp_trend", - "wifi_status", - ], - ), - ( - "12:34:56:58:c8:54", - [ - "CO2", - "Humidity", - "Noise", - "Pressure", - "Temperature", - "pressure_trend", - "reachable", - "temp_trend", - "wifi_status", - ], - ), - ( - "12:34:56:58:e6:38", - [ - "Humidity", - "Temperature", - "battery_percent", - "battery_vp", - "reachable", - "rf_status", - "temp_trend", - ], - ), - pytest.param( - None, - None, - marks=pytest.mark.xfail(reason="Invalid module names are not handled yet."), - ), - ], -) -def test_weather_station_get_monitored_conditions( - weather_station_data, - module_id, - expected, -): - assert sorted(weather_station_data.get_monitored_conditions(module_id)) == expected - - -@time_machine.travel(dt.datetime(2019, 6, 11)) -@pytest.mark.parametrize( - "station_id, exclude, expected", - [ - ("12:34:56:05:51:20", None, {}), - ( - "12:34:56:37:11:ca", - None, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:0e", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - ("", None, {}), - ("NoValidStation", None, {}), - ( - "12:34:56:37:11:ca", - 1000000, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:0e", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - ( - "12:34:56:37:11:ca", - 798103, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - ], -) -def test_weather_station_get_last_data( - weather_station_data, - station_id, - exclude, - expected, -): - if mod := weather_station_data.get_last_data(station_id, exclude=exclude): - assert sorted(mod) == expected - else: - assert mod == expected - - -@time_machine.travel(dt.datetime(2019, 6, 11)) -@pytest.mark.parametrize( - "station_id, delay, expected", - [ - ( - "12:34:56:37:11:ca", - 3600, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:0e", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - ("12:34:56:37:11:ca", 798500, []), - pytest.param( - "NoValidStation", - 3600, - None, - marks=pytest.mark.xfail(reason="Invalid station name not handled yet"), - ), - ], -) -def test_weather_station_check_not_updated( - weather_station_data, - station_id, - delay, - expected, -): - mod = weather_station_data.check_not_updated(station_id, delay) - assert sorted(mod) == expected - - -@time_machine.travel(dt.datetime(2019, 6, 11)) -@pytest.mark.parametrize( - "station_id, delay, expected", - [ - ( - "12:34:56:37:11:ca", - 798500, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:0e", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - ("12:34:56:37:11:ca", 100, []), - ], -) -def test_weather_station_check_updated( - weather_station_data, - station_id, - delay, - expected, -): - if mod := weather_station_data.check_updated(station_id, delay): - assert sorted(mod) == expected - else: - assert mod == expected - - -@time_machine.travel(dt.datetime(2019, 6, 11)) -@pytest.mark.parametrize( - "device_id, scale, module_type, expected", - [("MyStation", "scale", "type", [28.1])], -) -def test_weather_station_get_data( - weather_station_data, - requests_mock, - device_id, - scale, - module_type, - expected, -): - with open("fixtures/weatherstation_measure.json", encoding="utf-8") as json_file: - json_fixture = json.load(json_file) - requests_mock.post( - pyatmo.const.DEFAULT_BASE_URL + pyatmo.const.GETMEASURE_ENDPOINT, - json=json_fixture, - headers={"content-type": "application/json"}, - ) - assert ( - weather_station_data.get_data(device_id, scale, module_type)["body"][ - "1544558433" - ] - == expected - ) - - -def test_weather_station_get_last_data_measurements(weather_station_data): - station_id = "12:34:56:37:11:ca" - module_id = "12:34:56:03:1b:e4" - - mod = weather_station_data.get_last_data(station_id, None) - - assert mod[station_id]["Temperature"] == 24.6 - assert mod[station_id]["Pressure"] == 1017.3 - assert mod[module_id]["WindAngle"] == 217 - assert mod[module_id]["WindStrength"] == 4 - assert mod[module_id]["GustAngle"] == 206 - assert mod[module_id]["GustStrength"] == 9 - - -@time_machine.travel(dt.datetime(2019, 6, 11)) -@pytest.mark.parametrize( - "station_id, exclude, expected", - [ - ( - "12:34:56:37:11:ca", - None, - [ - "12:34:56:03:1b:e4", - "12:34:56:05:51:20", - "12:34:56:07:bb:0e", - "12:34:56:07:bb:3e", - "12:34:56:36:fc:de", - "12:34:56:37:11:ca", - ], - ), - (None, None, {}), - ("12:34:56:00:aa:01", None, {}), - ], -) -def test_weather_station_get_last_data_bug_97( - weather_station_data, - station_id, - exclude, - expected, -): - if mod := weather_station_data.get_last_data(station_id, exclude): - assert sorted(mod) == expected - else: - assert mod == expected diff --git a/tests/test_shutter.py b/tests/test_shutter.py new file mode 100644 index 00000000..11bbe3bc --- /dev/null +++ b/tests/test_shutter.py @@ -0,0 +1,102 @@ +"""Define tests for climate module.""" +import json +from unittest.mock import AsyncMock, patch + +from pyatmo import DeviceType +import pytest + +from tests.common import MockResponse + +# pylint: disable=F6401 + + +@pytest.mark.asyncio +async def test_async_shutter_NBR(async_home): # pylint: disable=invalid-name + """Test NLP Bubendorf iDiamant roller shutter.""" + module_id = "0009999992" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NBR + assert module.firmware_revision == 16 + assert module.current_position == 0 + + +@pytest.mark.asyncio +async def test_async_shutter_NBO(async_home): # pylint: disable=invalid-name + """Test NBO Bubendorf iDiamant roller shutter.""" + module_id = "0009999993" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NBO + assert module.firmware_revision == 22 + assert module.current_position == 0 + + +@pytest.mark.asyncio +async def test_async_shutters(async_home): + """Test basic shutter functionality.""" + room_id = "3688132631" + assert room_id in async_home.rooms + + module_id = "0009999992" + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NBR + + with open("fixtures/status_ok.json", encoding="utf-8") as json_file: + response = json.load(json_file) + + def gen_json_data(position): + return { + "json": { + "home": { + "id": "91763b24c43d3e344f424e8b", + "modules": [ + { + "bridge": "12:34:56:30:d5:d4", + "id": module_id, + "target_position": position, + }, + ], + }, + }, + } + + with patch( + "pyatmo.auth.AbstractAsyncAuth.async_post_api_request", + AsyncMock(return_value=MockResponse(response, 200)), + ) as mock_resp: + assert await module.async_open() + mock_resp.assert_awaited_with( + params=gen_json_data(100), + endpoint="api/setstate", + ) + + assert await module.async_close() + mock_resp.assert_awaited_with( + params=gen_json_data(0), + endpoint="api/setstate", + ) + + assert await module.async_stop() + mock_resp.assert_awaited_with( + params=gen_json_data(-1), + endpoint="api/setstate", + ) + + assert await module.async_set_target_position(47) + mock_resp.assert_awaited_with( + params=gen_json_data(47), + endpoint="api/setstate", + ) + + assert await module.async_set_target_position(-10) + mock_resp.assert_awaited_with( + params=gen_json_data(-1), + endpoint="api/setstate", + ) + + assert await module.async_set_target_position(101) + mock_resp.assert_awaited_with( + params=gen_json_data(100), + endpoint="api/setstate", + ) diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 00000000..a718d852 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,31 @@ +"""Define tests for climate module.""" + +from pyatmo import DeviceType +import pytest + +# pylint: disable=F6401 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NLP + assert module.firmware_revision == 62 + assert module.on + assert module.power == 0 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NLF + assert module.firmware_revision == 57 + assert module.on is False + assert module.brightness == 63 + assert module.power == 0 diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 00000000..a3237733 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,356 @@ +"""Define tests for climate module.""" + +import pyatmo +from pyatmo import DeviceType +from pyatmo.modules.base_class import Location, Place +import pytest + +# pylint: disable=F6401 + + +@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" + assert module_id in async_home.modules + module = async_home.modules[module_id] + assert module.device_type == DeviceType.NAMain + + +@pytest.mark.asyncio +async def test_async_weather_update(async_account): + """Test basic weather station update.""" + home_id = "91763b24c43d3e344f424e8b" + await async_account.async_update_weather_stations() + home = async_account.homes[home_id] + + module_id = "12:34:56:80:bb:26" + assert module_id in home.modules + module = home.modules[module_id] + assert module.device_type == DeviceType.NAMain + assert module.name == "Villa" + assert module.modules == [ + "12:34:56:80:44:92", + "12:34:56:80:7e:18", + "12:34:56:80:1c:42", + "12:34:56:80:c1:ea", + ] + assert module.features == { + "temperature", + "humidity", + "co2", + "noise", + "pressure", + "absolute_pressure", + "temp_trend", + "pressure_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "reachable", + "wifi_strength", + "place", + } + assert module.firmware_revision == 181 + assert module.wifi_strength == 57 + assert module.temperature == 21.1 + assert module.humidity == 45 + assert module.co2 == 1339 + assert module.pressure == 1026.8 + assert module.noise == 35 + assert module.absolute_pressure == 974.5 + assert module.place == Place( + { + "altitude": 329, + "city": "Someplace", + "country": "FR", + "location": Location(longitude=6.1234567, latitude=46.123456), + "timezone": "Europe/Paris", + }, + ) + + module_id = "12:34:56:80:44:92" + assert module_id in home.modules + module = home.modules[module_id] + assert module.name == "Villa Bedroom" + assert module.features == { + "temperature", + "temp_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "reachable", + "rf_strength", + "co2", + "humidity", + "battery", + "place", + } + assert module.device_type == DeviceType.NAModule4 + assert module.modules is None + assert module.firmware_revision == 51 + assert module.rf_strength == 67 + assert module.temperature == 19.3 + assert module.humidity == 53 + assert module.battery == 28 + + module_id = "12:34:56:80:c1:ea" + assert module_id in home.modules + module = home.modules[module_id] + assert module.name == "Villa Rain" + assert module.features == { + "sum_rain_1", + "sum_rain_24", + "rain", + "reachable", + "rf_strength", + "battery", + "place", + } + assert module.device_type == DeviceType.NAModule3 + assert module.modules is None + assert module.firmware_revision == 12 + assert module.rf_strength == 79 + assert module.rain == 3.7 + + module_id = "12:34:56:80:1c:42" + assert module_id in home.modules + module = home.modules[module_id] + assert module.name == "Villa Outdoor" + assert module.features == { + "temperature", + "humidity", + "temp_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "reachable", + "rf_strength", + "battery", + "place", + } + assert module.device_type == DeviceType.NAModule1 + assert module.modules is None + assert module.firmware_revision == 50 + assert module.rf_strength == 68 + assert module.temperature == 9.4 + assert module.humidity == 57 + + module_id = "12:34:56:03:1b:e4" + assert module_id in home.modules + module = home.modules[module_id] + assert module.name == "Villa Garden" + assert module.features == { + "wind_strength", + "gust_strength", + "gust_angle", + "gust_direction", + "wind_angle", + "wind_direction", + "reachable", + "rf_strength", + "battery", + "place", + } + assert module.device_type == DeviceType.NAModule2 + assert module.modules is None + assert module.firmware_revision == 19 + assert module.rf_strength == 59 + assert module.wind_strength == 4 + assert module.wind_angle == 217 + assert module.gust_strength == 9 + assert module.gust_angle == 206 + + +@pytest.mark.asyncio +async def test_async_weather_favorite(async_account): + """Test favorite weather station.""" + await async_account.async_update_weather_stations() + + module_id = "00:11:22:2c:be:c8" + assert module_id in async_account.modules + module = async_account.modules[module_id] + assert module.device_type == DeviceType.NAMain + assert module.name == "Zuhause (Kinderzimmer)" + assert module.modules == ["00:11:22:2c:ce:b6"] + assert module.features == { + "temperature", + "humidity", + "co2", + "noise", + "pressure", + "absolute_pressure", + "temp_trend", + "pressure_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "reachable", + "wifi_strength", + "place", + } + assert module.pressure == 1015.6 + assert module.absolute_pressure == 1000.4 + assert module.place == Place( + { + "altitude": 127, + "city": "Wiesbaden", + "country": "DE", + "location": Location( + longitude=8.238054275512695, + latitude=50.07585525512695, + ), + "timezone": "Europe/Berlin", + }, + ) + + module_id = "00:11:22:2c:ce:b6" + assert module_id in async_account.modules + module = async_account.modules[module_id] + assert module.device_type == DeviceType.NAModule1 + assert module.name == "Unknown" + assert module.modules is None + assert module.features == { + "temperature", + "humidity", + "temp_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "reachable", + "rf_strength", + "battery", + "place", + } + assert module.temperature == 7.8 + assert module.humidity == 87 + + +@pytest.mark.asyncio +async def test_async_air_care_update(async_account): + """Test basic air care update.""" + await async_account.async_update_air_care() + + module_id = "12:34:56:26:68:92" + assert module_id in async_account.modules + module = async_account.modules[module_id] + + assert module.device_type == DeviceType.NHC + assert module.name == "Baby Bedroom" + assert module.features == { + "temperature", + "humidity", + "co2", + "noise", + "pressure", + "absolute_pressure", + "temp_trend", + "pressure_trend", + "min_temp", + "max_temp", + "temp_max", + "temp_min", + "health_idx", + "reachable", + "wifi_strength", + "place", + } + + assert module.modules is None + assert module.firmware_revision == 45 + assert module.wifi_strength == 68 + assert module.temperature == 21.6 + assert module.humidity == 66 + assert module.co2 == 1053 + assert module.pressure == 1021.4 + assert module.noise == 45 + assert module.absolute_pressure == 1011 + assert module.health_idx == 1 + + +@pytest.mark.asyncio +async def test_async_public_weather_update(async_account): + """Test basic public weather update.""" + lon_ne = "6.221652" + lat_ne = "46.610870" + lon_sw = "6.217828" + lat_sw = "46.596485" + + area_id = async_account.register_public_weather_area(lat_ne, lon_ne, lat_sw, lon_sw) + await async_account.async_update_public_weather(area_id) + + area = async_account.public_weather_areas[area_id] + assert area.location == pyatmo.modules.netatmo.Location( + lat_ne, + lon_ne, + lat_sw, + lon_sw, + ) + assert area.stations_in_area() == 8 + + assert area.get_latest_rain() == { + "70:ee:50:1f:68:9e": 0, + "70:ee:50:27:25:b0": 0, + "70:ee:50:36:94:7c": 0.5, + "70:ee:50:36:a9:fc": 0, + } + + assert area.get_60_min_rain() == { + "70:ee:50:1f:68:9e": 0, + "70:ee:50:27:25:b0": 0, + "70:ee:50:36:94:7c": 0.2, + "70:ee:50:36:a9:fc": 0, + } + + assert area.get_24_h_rain() == { + "70:ee:50:1f:68:9e": 9.999, + "70:ee:50:27:25:b0": 11.716000000000001, + "70:ee:50:36:94:7c": 12.322000000000001, + "70:ee:50:36:a9:fc": 11.009, + } + + assert area.get_latest_pressures() == { + "70:ee:50:1f:68:9e": 1007.3, + "70:ee:50:27:25:b0": 1012.8, + "70:ee:50:36:94:7c": 1010.6, + "70:ee:50:36:a9:fc": 1010, + "70:ee:50:01:20:fa": 1014.4, + "70:ee:50:04:ed:7a": 1005.4, + "70:ee:50:27:9f:2c": 1010.6, + "70:ee:50:3c:02:78": 1011.7, + } + + assert area.get_latest_temperatures() == { + "70:ee:50:1f:68:9e": 21.1, + "70:ee:50:27:25:b0": 23.2, + "70:ee:50:36:94:7c": 21.4, + "70:ee:50:36:a9:fc": 20.1, + "70:ee:50:01:20:fa": 27.4, + "70:ee:50:04:ed:7a": 19.8, + "70:ee:50:27:9f:2c": 25.5, + "70:ee:50:3c:02:78": 23.3, + } + + assert area.get_latest_humidities() == { + "70:ee:50:1f:68:9e": 69, + "70:ee:50:27:25:b0": 60, + "70:ee:50:36:94:7c": 62, + "70:ee:50:36:a9:fc": 67, + "70:ee:50:01:20:fa": 58, + "70:ee:50:04:ed:7a": 76, + "70:ee:50:27:9f:2c": 56, + "70:ee:50:3c:02:78": 58, + } + + assert area.get_latest_wind_strengths() == {"70:ee:50:36:a9:fc": 15} + + assert area.get_latest_wind_angles() == {"70:ee:50:36:a9:fc": 17} + + assert area.get_latest_gust_strengths() == {"70:ee:50:36:a9:fc": 31} + + assert area.get_latest_gust_angles() == {"70:ee:50:36:a9:fc": 217} diff --git a/usage.md b/usage.md deleted file mode 100644 index cdeaddb1..00000000 --- a/usage.md +++ /dev/null @@ -1,414 +0,0 @@ -## Python Netatmo API programmers guide - -> 2013-01-21, philippelt@users.sourceforge.net - -> 2014-01-13, Revision to include new modules additionnal information - -> 2016-06-25 Update documentation for Netatmo Welcome - -> 2016-12-09 Update documentation for all Netatmo cameras - -No additional library other than standard Python 3 library is required. - -More information about the Netatmo REST API can be obtained from http://dev.netatmo.com/doc/ - -This package support only user based authentication. - -### 1 Set up your environment from Netatmo Web interface - -Before being able to use the module you will need : - -- A Netatmo user account having access to, at least, one station -- An application registered from the user account (see http://dev.netatmo.com/dev/createapp) to obtain application credentials. - -In the netatmo philosophy, both the application itself and the user have to be registered thus have authentication credentials to be able to access any station. Registration is free for both. - -### 2 Setup your library - -Install `pyatmo` as described in the `README.md`. - -If you provide your credentials, you can test if everything is working properly by simply running the package as a standalone program. - -This will run a full access test to the account and stations and return 0 as return code if everything works well. If run interactively, it will also display an OK message. - -```bash -$ export CLIENT_ID="" -$ export CLIENT_SECRET="" -$ export USERNAME="" -$ export PASSWORD="" -$ python3 pyatmo.py -pyatmo.py : OK -$ echo $? -0 -``` - -### 3 Package guide - -Most of the time, the sequence of operations will be : - -1. Authenticate your program against Netatmo web server -2. Get the device list accessible to the user -3. Request data on one of these devices or directly access last data sent by the station - -Example : - -```python -import pyatmo - -# 1 : Authenticate -CLIENT_ID = '123456789abcd1234' -CLIENT_SECRET = '123456789abcd1234' -USERNAME = 'your@account.com' -PASSWORD = 'abcdef-123456-ghijkl' -authorization = pyatmo.ClientAuth( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - username=USERNAME, - password=PASSWORD, -) - -# 2 : Get devices list -weather_data = pyatmo.WeatherStationData(authorization) -weather_data.update() - -# 3 : Access most fresh data directly -print( - "Current temperature (inside/outside): %s / %s °C" - % ( - weather_data.last_data()["indoor"]["Temperature"], - weather_data.last_data()["outdoor"]["Temperature"], - ) -) -``` - -The user must have named the sensors indoor and outdoor through the Web interface (or any other name as long as the program is requesting the same name). - -The Netatmo design is based on stations (usually the in-house module) and modules (radio sensors reporting to a station, usually an outdoor sensor). - -Sensor design is not exactly the same for station and external modules, and they are not addressed the same way wether in the station or an external module. This is a design issue of the API that restrict the ability to write generic code that could work for station sensor the same way as other modules sensors. The station role (the reporting device) and module role (getting environmental data) should not have been mixed. The fact that a sensor is physically built in the station should not interfere with this two distincts objects. - -The consequence is that, for the API, we will use terms of station data (for the sensors inside the station) and module data (for external(s) module). Lookup methods like module_by_name look for external modules and **NOT station -modules**. - -Having two roles, the station has a 'station_name' property as well as a 'module_name' for its internal sensor. - -> Exception : to reflect again the API structure, the last data uploaded by the station is indexed by module_name (wether it is a station module or an external module). - -Sensors (stations and modules) are managed in the API using ID's (network hardware adresses). The Netatmo web account management gives you the capability to associate names to station sensor and module (and to the station itself). This is by far more comfortable and the interface provides service to locate a station or a module by name or by ID depending on your taste. Module lookup by name includes the optional station name in case -multiple stations would have similar module names (if you monitor multiple stations/locations, it would not be a surprise that each of them would have an 'outdoor' module). This is a benefit in the sense it gives you the ability to write generic code (for example, collect all 'outdoor' temperatures for all your stations). - -The results are Python data structures, mostly dictionaries as they mirror easily the JSON returned data. All supplied classes provides simple properties to use as well as access to full data returned by the netatmo web services (rawData property for most classes). - -### 4 Package classes and functions - -#### 4-1 Global variables - -`_DEFAULT_BASE_URL` and `_*_REQ`: Various URL to access Netatmo web services. -They are documented in https://dev.netatmo.com/doc/. -They should not be changed unless Netatmo API changes. - -#### 4-2 ClientAuth class - -Constructor - -```python -authorization = pyatmo.ClientAuth( - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - username=USERNAME, - password=PASSWORD, - scope="read_station", - base_url="https://example.com/api", #optional - user_prefix="xmpl", #optional -) -``` - -Requires : Application and User credentials to access Netatmo API. - -Return : an authorization object that will supply the access token required by other web services. This class will handle the renewal of the access token if expiration is reached. - -Properties, all properties are read-only unless specified : - -- **accessToken** : Retrieve a valid access token (renewed if necessary) -- **refreshToken** : The token used to renew the access token (normally should not be used) -- **expiration** : The expiration time (epoch) of the current token -- **base_url** : If targeting a third-party Netatmo-compatible API, the custom base URL to reach it -- **user_prefix** : If targeting a third-part Netatmo-compatible API, the custom user prefix for this API -- **scope** : The scope of the required access token (what will it be used for) default to read_station to provide backward compatibility. - -Possible values for scope are : - -- read_station: to retrieve weather station data (Getstationsdata, Getmeasure) -- read_camera: to retrieve Welcome camera data (Gethomedata, Getcamerapicture) -- access_camera: to access the camera, the videos and the live stream. -- read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata) -- write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint) -- read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture) -- access_presence: to access the camera, the videos and the live stream. - -Several value can be used at the same time, ie: 'read_station read_camera' - -#### 4-3 WeatherStationData class - -Constructor - -```python -weather_data = pyatmo.WeatherStationData(authorization) -``` - -Requires : an authorization object (ClientAuth instance) - -Return : a WeatherStationData object. This object contains most administration properties of stations and modules accessible to the user and the last data pushed by the station to the Netatmo servers. - -Raise a pyatmo.NoDevice exception if no weather station is available for the given account. - -Properties, all properties are read-only unless specified: - -- **rawData** : Full dictionary of the returned JSON DEVICELIST Netatmo API service -- **default_station** : Name of the first station returned by the web service (warning, this is mainly for the ease of use of peoples having only 1 station). -- **stations** : Dictionary of stations (indexed by ID) accessible to this user account -- **modules** : Dictionary of modules (indexed by ID) accessible to the user account (whatever station there are plugged in) - -Methods : - -- **station_by_name** (station=None) : Find a station by it is station name - - - Input : Station name to lookup (str) - - Output : station dictionary or None - -- **station_by_id** (sid) : Find a station by it is Netatmo ID (mac address) - - - Input : Station ID - - Output : station dictionary or None - -- **module_by_name** (module, station=None) : Find a module by it is module name - - - Input : module name and optional station name - - Output : module dictionary or None - - The station name parameter, if provided, is used to check wether the module belongs to the appropriate station (in case multiple stations would have same module name). - -- **module_by_id** (mid, sid=None) : Find a module by it is ID and belonging station's ID - - - Input : module ID and optional Station ID - - Output : module dictionary or None - -- **modules_names_list** (station=None) : Get the list of modules names, including the station module name. Each of them should have a corresponding entry in last_data. It is an equivalent (at lower cost) for last_data.keys() - -- **last_data** (station=None, exclude=0) : Get the last data uploaded by the station, exclude sensors with measurement older than given value (default return all) - - - Input : station name OR id. If not provided default_station is used. Exclude is the delay in seconds from now to filter sensor readings. - - Output : Sensors data dictionary (Key is sensor name) - - AT the time of this document, Available measures types are : - - - a full or subset of Temperature, Pressure, Noise, Co2, Humidity, Rain (mm of precipitation during the last 5 minutes, or since the previous data upload), When (measurement timestamp) for modules including station module - - battery_vp : likely to be total battery voltage for external sensors (running on batteries) in mV (undocumented) - - rf_status : likely to be the % of radio signal between the station and a module (undocumented) - - See Netatmo API documentation for units of regular measurements - - If you named the internal sensor 'indoor' and the outdoor one 'outdoor' (simple is'n it ?) for your station in the user Web account station properties, you will access the data by : - -```python -# Last data access example - -the_data = weather_data.last_data() -print('Available modules : ', the_data.keys()) -print('In-house CO2 level : ', the_data['indoor']['Co2']) -print('Outside temperature : ', the_data['outdoor']['Temperature']) -print('External module battery : ', "OK" if int(the_data['outdoor']['battery_vp']) > 5000 \ - else "NEEDS TO BE REPLACED") -``` - -- **check_not_updated** (station=None, delay=3600) : - - - Input : optional station name (else default_station is used) - - Output : list of modules name for which last data update is older than specified delay (default 1 hour). If the station itself is lost, the module_name of the station will be returned (the key item of last_data information). - - For example (following the previous one) - -```python -# Ensure data sanity - -for m in weather_data.check_not_updated(""): - print("Warning, sensor %s information is obsolete" % m) - if module_by_name(m) == None : # Sensor is not an external module - print("The station is lost") -``` - -- **check_updated** (station=None, delay=3600) : - - - Input : optional station name (else default_station is used) - - Output : list of modules name for which last data update is newer than specified delay (default 1 hour). - - Complement of the previous service - -- **get_measure** (device_id, scale, mtype, module_id=None, date_begin=None, date_end=None, limit=None, optimize=False) : - - Input : All parameters specified in the Netatmo API service GETMEASURE (type being a python reserved word as been replaced by mtype). - - Output : A python dictionary reflecting the full service response. No transformation is applied. -- **min_max_th** (station=None, module=None, frame="last24") : Return min and max temperature and humidity for the given station/module in the given timeframe - _ Input : - _ An optional station Name or ID, default\*station is used if not supplied, - - - An optional module name or ID, default : station sensor data is used - _ A time frame that can be : - _ "last24" : For a shifting window of the last 24 hours - _ "day" : For all available data in the current day - _ Output : - \_ 4 values tuple (Temp mini, Temp maxi, Humid mini, Humid maxi) - - >Note : I have been obliged to determine the min and max manually, the built-in service in the API doesn't always provide the actual min and max. The double parameter (scale) and aggregation request (min, max) is not satisfying - - at all if you slip over two days as required in a shifting 24 hours window. - -#### 4-4 CameraData class - -Constructor - -```python -camera_data = pyatmo.CameraData(authorization) -camera_data.update() -``` - -Requires : an authorization object (ClientAuth instance) - -Return : a CameraData object. This object contains most administration properties of Netatmo cameras accessible to the user and the last data pushed by the cameras to the Netatmo servers. - -Raise a pyatmo.NoDevice exception if no camera is available for the given account. - -Properties, all properties are read-only unless specified: - -- **rawData** : Full dictionary of the returned JSON DEVICELIST Netatmo API service -- **default_home** : Name of the first home returned by the web service (warning, this is mainly for the ease of use of peoples having cameras in only 1 house). -- **default_camera** : Data of the first camera in the default home returned by the web service (warning, this is mainly for the ease of use of peoples having only 1 camera). -- **homes** : Dictionary of homes (indexed by ID) accessible to this user account -- **cameras** : Dictionary of cameras (indexed by home name and cameraID) accessible to this user -- **persons** : Dictionary of persons (indexed by ID) accessible to the user account -- **events** : Dictionary of events (indexed by cameraID and timestamp) seen by cameras -- **outdoor_events** : Dictionary of Outdoor events (indexed by cameraID and timestamp) seen by cameras - -Methods : - -- **home_by_id** (hid) : Find a home by its Netatmo ID - - - Input : Home ID - - Output : home dictionary or None - -- **home_by_name** (home=None) : Find a home by its home name - - - Input : home name to lookup (str) - - Output : home dictionary or None - -- **camera_by_id** (hid) : Find a camera by its Netatmo ID - - - Input : camera ID - - Output : camera dictionary or None - -- **camera_by_name** (camera=None, home=None) : Find a camera by its camera name - - - Input : camera name and home name to lookup (str) - - Output : camera dictionary or None - -- **camera_type** (camera=None, home=None, cid=None) : Return the type of a given camera. - - - Input : camera name and home name or cameraID to lookup (str) - - Output : Return the type of a given camera - -- **camera_urls_by_name** (camera=None, home=None, cid=None) : return Urls to access camera live feed - - - Input : camera name and home name or cameraID to lookup (str) - - Output : tuple with the vpn_url (for remote access) and local url to access the camera live feed - -- **persons_at_home_by_name** (home=None) : return the list of known persons who are at home - - - Input : home name to lookup (str) - - Output : list of persons seen - -- **get_camera_picture** (image_id, key): Download a specific image (of an event or user face) from the camera - - - Input : image_id and key of an events or person face - - Output: Tuple with image data (to be stored in a file) and image type (jpg, png...) - -- **get_profile_image** (name) : Retrieve the face of a given person - - - Input : person name (str) - - Output: **get_camera_picture** data - -- **update_event** (event=None, home=None, cameratype=None): Update the list of events - - - Input: Id of the latest event, home name and cameratype to update event list - -- **person_seen_by_camera** (name, home=None, camera=None): Return true is a specific person has been seen by the camera in the last event - -- **someone_known_seen** (home=None, camera=None) : Return true is a known person has been in the last event - -- **someone_unknown_seen** (home=None, camera=None) : Return true is an unknown person has been seen in the last event - -- **motion_detected** (home=None, camera=None) : Return true is a movement has been detected in the last event - -- **outdoormotion_detected** (home=None, camera=None) : Return true is a outdoor movement has been detected in the last event - -- **humanDetected** (home=None, camera=None) : Return True if a human has been detected in the last outdoor events - -- **animalDetected** (home=None, camera=None) : Return True if an animal has been detected in the last outdoor events - -- **carDetected** (home=None, camera=None) : Return True if a car has been detected in the last outdoor events - -#### 4-5 ThermostatData class - -Constructor - -```python -thermostat_data = pyatmo.ThermostatData(authorization) -thermostat_data.update() -``` - -Requires : an authorization object (ClientAuth instance) - -Return : a ThermostatData object. This object contains most administration properties of Netatmo thermostats accessible to the user and the last data pushed by the thermostats to the Netatmo servers. - -Raise a pyatmo.NoDevice exception if no thermostat is available for the given account. - -Properties, all properties are read-only unless specified: - -- **rawData** : Full dictionary of the returned JSON Netatmo API service -- **devList** : Full dictionary of the returned JSON DEVICELIST Netatmo API service -- **default_device** : Name of the first device returned by the web service (warning, this is mainly for the ease of use of peoples having multiple thermostats in only 1 house). -- **default_module** : Data of the first module in the default device returned by the web service (warning, this is mainly for the ease of use of peoples having only 1 thermostat). -- **devices** : Dictionary of devices (indexed by ID) accessible to this user account -- **modules** : Dictionary of modules (indexed by device name and moduleID) accessible to this user -- **therm_program_list** : Dictionary of therm programs (indexed by ID) accessible to the user account -- **zones** : Dictionary of zones (indexed by ID) -- **timetable** : Dictionary of timetable (indexed by m_offset) - -Methods : - -- **deviceById** (hid) : Find a device by its Netatmo ID - - - Input : Device ID - - Output : device dictionary or None - -- **deviceByName** (device=None) : Find a device by it's device name - - - Input : device name to lookup (str) - - Output : device dictionary or None - -- **module_by_id** (hid) : Find a module by its Netatmo ID - - - Input : module ID - - Output : module dictionary or None - -- **module_by_name** (module=None, device=None) : Find a module by its module name - - - Input : module name and device name to lookup (str) - - Output : module dictionary or None - -- **setthermpoint** (mode, temp, endTimeOffsetmode, temp, endTimeOffset) : set thermpoint - - Input : device_id and module_id and setpoint_mode - -#### 4-6 Utilities functions - -- **to_time_string** (timestamp) : Convert a Netatmo time stamp to a readable date/time format. -- **to_epoch**( dateString) : Convert a date string (form YYYY-MM-DD_HH:MM:SS) to timestamp -- **today_stamps**() : Return a couple of epoch time (start, end) for the current day