Skip to content

Commit c1c5cff

Browse files
pburgioedenhausshapournemati-iottyemontnemery
authored
Add integration for iotty Smart Home (home-assistant#103073)
* Initial import 0.0.2 * Fixes to URL, and removed commits * Initial import 0.0.2 * Fixes to URL, and removed commits * Added first test for iotty * First release * Reviewers request home-assistant#1 - Removed clutter - Added support for new naming convention for IottySmartSwitch entity * Removed commmented code * Some modifications * Modified REST EP for iotty CloudApi * Initial import 0.0.2 * Fixes to URL, and removed commits * Added first test for iotty * First release * Rebased and resolved conflicts * Reviewers request home-assistant#1 - Removed clutter - Added support for new naming convention for IottySmartSwitch entity * Removed commmented code * Some modifications * Modified REST EP for iotty CloudApi * Removed empty entries in manifest.json * Added test_config_flow * Fix as requested by @edenhaus * Added test_init * Removed comments, added one assert * Added TEST_CONFIG_FLOW * Added test for STORE_ENTITY * Increased code coverage * Full coverage for api.py * Added tests for switch component * Converted INFO logs onto DEBUG logs * Removed .gitignore from commits * Modifications to SWITCH.PY * Initial import 0.0.2 * Fixes to URL, and removed commits * Added first test for iotty * First release * Rebased and resolved conflicts * Fixed conflicts * Reviewers request home-assistant#1 - Removed clutter - Added support for new naming convention for IottySmartSwitch entity * Removed commmented code * Some modifications * Modified REST EP for iotty CloudApi * Removed empty entries in manifest.json * Added test_config_flow * Some modifications * Fix as requested by @edenhaus * Added test_init * Removed comments, added one assert * Added TEST_CONFIG_FLOW * Added test for STORE_ENTITY * Increased code coverage * Full coverage for api.py * Added tests for switch component * Converted INFO logs onto DEBUG logs * Removed .gitignore from commits * Modifications to SWITCH.PY * Fixed tests for SWITCH * First working implementation of Coordinator * Increased code coverage * Full code coverage * Missing a line in testing * Update homeassistant/components/iotty/__init__.py Co-authored-by: Robert Resch <[email protected]> * Update homeassistant/components/iotty/__init__.py Co-authored-by: Robert Resch <[email protected]> * Modified coordinator as per request by edenhaus * use coordinator entities for switches * move platforms to constants * fix whitespace with ruff-format * correct iotty entry in application_credentials list * minor style improvements * refactor function name * handle new and deleted devices * improve code for adding devices after first initialization * use typed config entry instead of adding known devices to hass.data * improve iotty entity removal * test listeners update cycle * handle iotty as devices and not only as entities * fix test typing for mock config entry * test with fewer mocks for an integration test style opposed to the previous unit test style * remove useless tests and add more integration style tests * check if device_to_remove is None * integration style tests for turning switches on and off * remove redundant coordinator tests * check device status after issuing command in tests * remove unused fixtures * add strict typing for iotty * additional asserts and named snapshots in tests * fix mypy issues after enabling strict typing * upgrade iottycloud version to 0.1.3 * move coordinator to runtime_data * remove entity name * fix typing issues * coding style fixes * improve tests coding style and assertion targets * test edge cases when apis are not working * improve tests comments and assertions --------- Co-authored-by: Robert Resch <[email protected]> Co-authored-by: Shapour Nemati <[email protected]> Co-authored-by: Erik Montnemery <[email protected]> Co-authored-by: shapournemati-iotty <[email protected]>
1 parent 4620a54 commit c1c5cff

24 files changed

+1336
-0
lines changed

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ homeassistant.components.integration.*
255255
homeassistant.components.intent.*
256256
homeassistant.components.intent_script.*
257257
homeassistant.components.ios.*
258+
homeassistant.components.iotty.*
258259
homeassistant.components.ipp.*
259260
homeassistant.components.iqvia.*
260261
homeassistant.components.islamic_prayer_times.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,8 @@ build.json @home-assistant/supervisor
695695
/tests/components/ios/ @robbiet480
696696
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
697697
/tests/components/iotawatt/ @gtdiehl @jyavenard
698+
/homeassistant/components/iotty/ @pburgio
699+
/tests/components/iotty/ @pburgio
698700
/homeassistant/components/iperf3/ @rohankapoorcom
699701
/homeassistant/components/ipma/ @dgomes
700702
/tests/components/ipma/ @dgomes
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""The iotty integration."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import logging
7+
8+
from iottycloud.device import Device
9+
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import Platform
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.config_entry_oauth2_flow import (
14+
OAuth2Session,
15+
async_get_config_entry_implementation,
16+
)
17+
18+
from . import coordinator
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
PLATFORMS: list[Platform] = [Platform.SWITCH]
23+
24+
type IottyConfigEntry = ConfigEntry[IottyConfigEntryData]
25+
26+
27+
@dataclass
28+
class IottyConfigEntryData:
29+
"""Contains config entry data for iotty."""
30+
31+
known_devices: set[Device]
32+
coordinator: coordinator.IottyDataUpdateCoordinator
33+
34+
35+
async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> bool:
36+
"""Set up iotty from a config entry."""
37+
_LOGGER.debug("async_setup_entry entry_id=%s", entry.entry_id)
38+
39+
implementation = await async_get_config_entry_implementation(hass, entry)
40+
session = OAuth2Session(hass, entry, implementation)
41+
42+
data_update_coordinator = coordinator.IottyDataUpdateCoordinator(
43+
hass, entry, session
44+
)
45+
46+
entry.runtime_data = IottyConfigEntryData(set(), data_update_coordinator)
47+
48+
await data_update_coordinator.async_config_entry_first_refresh()
49+
50+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
51+
return True
52+
53+
54+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
55+
"""Unload a config entry."""
56+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""API for iotty bound to Home Assistant OAuth."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from aiohttp import ClientSession
8+
from iottycloud.cloudapi import CloudApi
9+
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers import config_entry_oauth2_flow
12+
13+
OAUTH2_CLIENT_ID = "hass-iotty"
14+
IOTTYAPI_BASE = "https://homeassistant.iotty.com/"
15+
16+
17+
class IottyProxy(CloudApi):
18+
"""Provide iotty authentication tied to an OAuth2 based config entry."""
19+
20+
def __init__(
21+
self,
22+
hass: HomeAssistant,
23+
websession: ClientSession,
24+
oauth_session: config_entry_oauth2_flow.OAuth2Session,
25+
) -> None:
26+
"""Initialize iotty auth."""
27+
28+
super().__init__(websession, IOTTYAPI_BASE, OAUTH2_CLIENT_ID)
29+
if oauth_session is None:
30+
raise ValueError("oauth_session")
31+
self._oauth_session = oauth_session
32+
self._hass = hass
33+
34+
async def async_get_access_token(self) -> Any:
35+
"""Return a valid access token."""
36+
37+
if not self._oauth_session.valid_token:
38+
await self._oauth_session.async_ensure_token_valid()
39+
40+
return self._oauth_session.token["access_token"]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Application credentials platform for iotty."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.components.application_credentials import AuthorizationServer
6+
from homeassistant.core import HomeAssistant
7+
8+
OAUTH2_AUTHORIZE = "https://auth.iotty.com/.auth/oauth2/login"
9+
OAUTH2_TOKEN = "https://auth.iotty.com/.auth/oauth2/token"
10+
11+
12+
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
13+
"""Return authorization server."""
14+
return AuthorizationServer(
15+
authorize_url=OAUTH2_AUTHORIZE,
16+
token_url=OAUTH2_TOKEN,
17+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Config flow for iotty."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from homeassistant.helpers import config_entry_oauth2_flow
8+
9+
from .const import DOMAIN
10+
11+
12+
class OAuth2FlowHandler(
13+
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
14+
):
15+
"""Config flow to handle iotty OAuth2 authentication."""
16+
17+
DOMAIN = DOMAIN
18+
19+
@property
20+
def logger(self) -> logging.Logger:
21+
"""Return logger."""
22+
return logging.getLogger(__name__)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Constants for the iotty integration."""
2+
3+
from __future__ import annotations
4+
5+
DOMAIN = "iotty"
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""DataUpdateCoordinator for iotty."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from datetime import timedelta
7+
import logging
8+
9+
from iottycloud.device import Device
10+
from iottycloud.verbs import RESULT, STATUS
11+
12+
from homeassistant.config_entries import ConfigEntry
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.helpers import aiohttp_client, device_registry as dr
15+
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
16+
from homeassistant.helpers.entity import Entity
17+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
18+
19+
from . import api
20+
from .const import DOMAIN
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
UPDATE_INTERVAL = timedelta(seconds=30)
25+
26+
27+
@dataclass
28+
class IottyData:
29+
"""iotty data stored in the DataUpdateCoordinator."""
30+
31+
devices: list[Device]
32+
33+
34+
class IottyDataUpdateCoordinator(DataUpdateCoordinator[IottyData]):
35+
"""Class to manage fetching Iotty data."""
36+
37+
config_entry: ConfigEntry
38+
_entities: dict[str, Entity]
39+
_devices: list[Device]
40+
_device_registry: dr.DeviceRegistry
41+
42+
def __init__(
43+
self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session
44+
) -> None:
45+
"""Initialize the coordinator."""
46+
_LOGGER.debug("Initializing iotty data update coordinator")
47+
48+
super().__init__(
49+
hass,
50+
_LOGGER,
51+
name=f"{DOMAIN}_coordinator",
52+
update_interval=UPDATE_INTERVAL,
53+
)
54+
55+
self.config_entry = entry
56+
self._entities = {}
57+
self._devices = []
58+
self.iotty = api.IottyProxy(
59+
hass, aiohttp_client.async_get_clientsession(hass), session
60+
)
61+
self._device_registry = dr.async_get(hass)
62+
63+
async def async_config_entry_first_refresh(self) -> None:
64+
"""Override the first refresh to also fetch iotty devices list."""
65+
_LOGGER.debug("Fetching devices list from iottyCloud")
66+
self._devices = await self.iotty.get_devices()
67+
_LOGGER.debug("There are %d devices", len(self._devices))
68+
69+
await super().async_config_entry_first_refresh()
70+
71+
async def _async_update_data(self) -> IottyData:
72+
"""Fetch data from iottyCloud device."""
73+
_LOGGER.debug("Fetching devices status from iottyCloud")
74+
75+
current_devices = await self.iotty.get_devices()
76+
77+
removed_devices = [
78+
d
79+
for d in self._devices
80+
if not any(x.device_id == d.device_id for x in current_devices)
81+
]
82+
83+
for removed_device in removed_devices:
84+
device_to_remove = self._device_registry.async_get_device(
85+
{(DOMAIN, removed_device.device_id)}
86+
)
87+
if device_to_remove is not None:
88+
self._device_registry.async_remove_device(device_to_remove.id)
89+
90+
self._devices = current_devices
91+
92+
for device in self._devices:
93+
res = await self.iotty.get_status(device.device_id)
94+
json = res.get(RESULT, {})
95+
if (
96+
not isinstance(res, dict)
97+
or RESULT not in res
98+
or not isinstance(json := res[RESULT], dict)
99+
or not (status := json.get(STATUS))
100+
):
101+
_LOGGER.warning("Unable to read status for device %s", device.device_id)
102+
else:
103+
_LOGGER.debug(
104+
"Retrieved status: '%s' for device %s", status, device.device_id
105+
)
106+
device.update_status(status)
107+
108+
return IottyData(self._devices)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "iotty",
3+
"name": "iotty",
4+
"codeowners": ["@pburgio"],
5+
"config_flow": true,
6+
"dependencies": ["application_credentials"],
7+
"documentation": "https://www.home-assistant.io/integrations/iotty",
8+
"integration_type": "device",
9+
"iot_class": "cloud_polling",
10+
"requirements": ["iottycloud==0.1.3"]
11+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"config": {
3+
"step": {
4+
"pick_implementation": {
5+
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
6+
}
7+
},
8+
"abort": {
9+
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
10+
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
11+
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
12+
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
13+
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
14+
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
15+
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
16+
},
17+
"create_entry": {
18+
"default": "[%key:common::config_flow::create_entry::authenticated%]"
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)