Add myUplink integration (#86522)

* First checkin for myUplink

* Refactored coordinator and sensor state classe

* Updated .coveragerc

* Update test_config_flow

* Fix test_config_flow for myuplink

* Only set state class for temperature sensor

* PR comment updates

* Type strong dict

* use asyncio.timeouts

* PR updates (part 1)

* Updated to myuplink 0.0.9

* Add strict typing

* Fix typing

* Inherit CoordinatorEntity

* Clean up coordinator and sensors

* Use common base entity

* Improve device point sensor

* Exclude entity from coverage

* Set device point entity name if there's no entity description

* Update homeassistant/components/myuplink/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/myuplink/entity.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/myuplink/entity.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remvoed firmware + connstate sensors

* Always add device point parameter name

* Removed MyUplinkDeviceSensor

* Removed unused class

* key="celsius",

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Peter Winkler 2024-01-12 12:17:07 +01:00 committed by GitHub
parent c1faafc6a0
commit 2508b55b0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 480 additions and 0 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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,
)

View File

@ -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"])

View File

@ -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,
)

View File

@ -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)}

View File

@ -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"]

View File

@ -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()
)

View File

@ -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)})

View File

@ -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"]
}

View File

@ -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]

View File

@ -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%]"
}
}
}

View File

@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [
"home_connect",
"lametric",
"lyric",
"myuplink",
"neato",
"nest",
"netatmo",

View File

@ -315,6 +315,7 @@ FLOWS = {
"mutesync",
"mysensors",
"mystrom",
"myuplink",
"nam",
"nanoleaf",
"neato",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the myUplink integration."""

View File

@ -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