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/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 20ef5022..b5e0935e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,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/__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/helpers.py b/src/pyatmo/helpers.py index d905eab2..ed422f75 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.""" @@ -48,7 +22,7 @@ def fix_id(raw_data: RawData) -> dict[str, Any]: if "_id" not in station: 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/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..deb1f04a 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,7 @@ class DeviceCategory(str, Enum): DeviceType.NLPD: DeviceCategory.switch, DeviceType.NLJ: DeviceCategory.shutter, DeviceType.BNIL: DeviceCategory.switch, + DeviceType.BNLD: DeviceCategory.dimmer, } @@ -264,6 +266,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..648b6a11 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, Module): """NLPD dry contact.""" 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_refactor.py b/tests/test_pyatmo_refactor.py deleted file mode 100644 index 2231b89e..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 MockResponse, fake_post_request -from tests.conftest import 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_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