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>pull/107925/head
parent
c1faafc6a0
commit
2508b55b0f
|
@ -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
|
||||
|
|
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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"])
|
|
@ -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,
|
||||
)
|
|
@ -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)}
|
|
@ -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"]
|
|
@ -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()
|
||||
)
|
|
@ -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)})
|
|
@ -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"]
|
||||
}
|
|
@ -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]
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [
|
|||
"home_connect",
|
||||
"lametric",
|
||||
"lyric",
|
||||
"myuplink",
|
||||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
|
|
|
@ -315,6 +315,7 @@ FLOWS = {
|
|||
"mutesync",
|
||||
"mysensors",
|
||||
"mystrom",
|
||||
"myuplink",
|
||||
"nam",
|
||||
"nanoleaf",
|
||||
"neato",
|
||||
|
|
|
@ -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",
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the myUplink integration."""
|
|
@ -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
|
Loading…
Reference in New Issue