diff --git a/.coveragerc b/.coveragerc index cf2210ec1a05..88a9f96a6088 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1654,6 +1654,13 @@ omit = homeassistant/components/zwave_me/switch.py homeassistant/components/electrasmart/climate.py homeassistant/components/electrasmart/__init__.py + homeassistant/components/myuplink/__init__.py + homeassistant/components/myuplink/api.py + homeassistant/components/myuplink/application_credentials.py + homeassistant/components/myuplink/coordinator.py + homeassistant/components/myuplink/entity.py + homeassistant/components/myuplink/sensor.py + [report] # Regexes for lines to exclude from consideration diff --git a/.strict-typing b/.strict-typing index b79b50fd9cb6..93d603204f07 100644 --- a/.strict-typing +++ b/.strict-typing @@ -282,6 +282,7 @@ homeassistant.components.mopeka.* homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* +homeassistant.components.myuplink.* homeassistant.components.nam.* homeassistant.components.nanoleaf.* homeassistant.components.neato.* diff --git a/CODEOWNERS b/CODEOWNERS index fdbc63324cef..1288ea535911 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -829,6 +829,8 @@ build.json @home-assistant/supervisor /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff /tests/components/mystrom/ @fabaff +/homeassistant/components/myuplink/ @pajzo +/tests/components/myuplink/ @pajzo /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py new file mode 100644 index 000000000000..15ae1eb75c29 --- /dev/null +++ b/homeassistant/components/myuplink/__init__.py @@ -0,0 +1,71 @@ +"""The myUplink integration.""" +from __future__ import annotations + +from myuplink.api import MyUplinkAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up myUplink from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation) + auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + + # Setup MyUplinkAPI and coordinator for data fetch + api = MyUplinkAPI(auth) + coordinator = MyUplinkDataCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + # Update device registry + create_devices(hass, config_entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +@callback +def create_devices( + hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator +) -> None: + """Update all devices.""" + device_registry = dr.async_get(hass) + + for device_id, device in coordinator.data.devices.items(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, device_id)}, + name=device.productName, + manufacturer=device.productName.split(" ")[0], + model=device.productName, + sw_version=device.firmwareCurrent, + ) diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py new file mode 100644 index 000000000000..5d0fcaf521a8 --- /dev/null +++ b/homeassistant/components/myuplink/api.py @@ -0,0 +1,31 @@ +"""API for myUplink bound to Home Assistant OAuth.""" +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientSession +from myuplink.auth_abstract import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import API_ENDPOINT + + +class AsyncConfigEntryAuth(AbstractAuth): # type: ignore[misc] + """Provide myUplink authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize myUplink auth.""" + super().__init__(websession, API_ENDPOINT) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/myuplink/application_credentials.py b/homeassistant/components/myuplink/application_credentials.py new file mode 100644 index 000000000000..fe3cd22f037e --- /dev/null +++ b/homeassistant/components/myuplink/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the myUplink integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py new file mode 100644 index 000000000000..e8377f2682bf --- /dev/null +++ b/homeassistant/components/myuplink/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for myUplink.""" +import logging +from typing import Any + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle myUplink OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH2_SCOPES)} diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py new file mode 100644 index 000000000000..9adb1eb0e30b --- /dev/null +++ b/homeassistant/components/myuplink/const.py @@ -0,0 +1,8 @@ +"""Constants for the myUplink integration.""" + +DOMAIN = "myuplink" + +API_ENDPOINT = "https://api.myuplink.com" +OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" +OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" +OAUTH2_SCOPES = ["READSYSTEM", "offline_access"] diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py new file mode 100644 index 000000000000..4cd66adab2ba --- /dev/null +++ b/homeassistant/components/myuplink/coordinator.py @@ -0,0 +1,65 @@ +"""Coordinator for myUplink.""" +import asyncio.timeouts +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from myuplink.api import MyUplinkAPI +from myuplink.models import Device, DevicePoint, System + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class CoordinatorData: + """Represent coordinator data.""" + + systems: list[System] + devices: dict[str, Device] + points: dict[str, dict[str, DevicePoint]] + time: datetime + + +class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Coordinator for myUplink data.""" + + def __init__(self, hass: HomeAssistant, api: MyUplinkAPI) -> None: + """Initialize myUplink coordinator.""" + super().__init__( + hass, + _LOGGER, + name="myuplink", + update_interval=timedelta(seconds=60), + ) + self.api = api + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from the myUplink API.""" + async with asyncio.timeout(10): + # Get systems + systems = await self.api.async_get_systems() + + devices: dict[str, Device] = {} + points: dict[str, dict[str, DevicePoint]] = {} + device_ids = [ + device.deviceId for system in systems for device in system.devices + ] + for device_id in device_ids: + # Get device info + api_device_info = await self.api.async_get_device(device_id) + devices[device_id] = api_device_info + + # Get device points (data) + api_device_points = await self.api.async_get_device_points(device_id) + point_info: dict[str, DevicePoint] = {} + for point in api_device_points: + point_info[point.parameter_id] = point + + points[device_id] = point_info + + return CoordinatorData( + systems=systems, devices=devices, points=points, time=datetime.now() + ) diff --git a/homeassistant/components/myuplink/entity.py b/homeassistant/components/myuplink/entity.py new file mode 100644 index 000000000000..e3d6184c368c --- /dev/null +++ b/homeassistant/components/myuplink/entity.py @@ -0,0 +1,28 @@ +"""Provide a common entity class for myUplink entities.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + + +class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): + """Representation of a sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + # Internal properties + self.device_id = device_id + + # Basic values + self._attr_unique_id = f"{device_id}-{unique_id_suffix}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json new file mode 100644 index 000000000000..303af5473355 --- /dev/null +++ b/homeassistant/components/myuplink/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "myuplink", + "name": "myUplink", + "codeowners": ["@pajzo"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/myuplink", + "iot_class": "cloud_polling", + "requirements": ["myuplink==0.0.9"] +} diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py new file mode 100644 index 000000000000..31cb6715e0ce --- /dev/null +++ b/homeassistant/components/myuplink/sensor.py @@ -0,0 +1,89 @@ +"""Sensor for myUplink.""" + +from myuplink.models import DevicePoint + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity + +DEVICE_POINT_DESCRIPTIONS = { + "°C": SensorEntityDescription( + key="celsius", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink sensor.""" + entities: list[SensorEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point sensors + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + entities.append( + MyUplinkDevicePointSensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=DEVICE_POINT_DESCRIPTIONS.get( + device_point.parameter_unit + ), + unique_id_suffix=point_id, + ) + ) + + async_add_entities(entities) + + +class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): + """Representation of a myUplink device point sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + + if entity_description is not None: + self.entity_description = entity_description + else: + self._attr_native_unit_of_measurement = device_point.parameter_unit + + @property + def native_value(self) -> StateType: + """Sensor state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return device_point.value # type: ignore[no-any-return] diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json new file mode 100644 index 000000000000..569e148a5a3a --- /dev/null +++ b/homeassistant/components/myuplink/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 060080517bf0..586aa64ce183 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lametric", "lyric", + "myuplink", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 699bdebc61f0..c62203b4d6cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -315,6 +315,7 @@ FLOWS = { "mutesync", "mysensors", "mystrom", + "myuplink", "nam", "nanoleaf", "neato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b70aad119df5..d5f8354574fa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3725,6 +3725,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "myuplink": { + "name": "myUplink", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nad": { "name": "NAD", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 62ad39da8e2a..bdb854183f60 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2581,6 +2581,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.myuplink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nam.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 45b67c3dccb7..f700d860df0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,6 +1305,9 @@ mutesync==0.0.1 # homeassistant.components.permobil mypermobil==0.1.6 +# homeassistant.components.myuplink +myuplink==0.0.9 + # homeassistant.components.nad nad-receiver==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc97d9391666..f1b2dcd3252e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,6 +1035,9 @@ mutesync==0.0.1 # homeassistant.components.permobil mypermobil==0.1.6 +# homeassistant.components.myuplink +myuplink==0.0.9 + # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 diff --git a/tests/components/myuplink/__init__.py b/tests/components/myuplink/__init__.py new file mode 100644 index 000000000000..d5ca745ced0a --- /dev/null +++ b/tests/components/myuplink/__init__.py @@ -0,0 +1 @@ +"""Tests for the myUplink integration.""" diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py new file mode 100644 index 000000000000..ec781af2a1f3 --- /dev/null +++ b/tests/components/myuplink/test_config_flow.py @@ -0,0 +1,83 @@ +"""Test the myUplink config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.myuplink.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "myuplink", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=READSYSTEM+offline_access" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.myuplink.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1