Skip to content

Commit

Permalink
WIP: Update device interaction
Browse files Browse the repository at this point in the history
 * Migrate connection backend from hubspace-async to aiohubspace
 * Migrate logic on setting Hubspace states from HA to aiohubspace
 * @todo - Binary Sensors
 * @todo - Grabbining Anonymous data
 * @todo - Unit Tests

Sem-Ver: api-break
  • Loading branch information
Expl0dingBanana committed Jan 1, 2025
1 parent d3c3354 commit 4cee5c8
Show file tree
Hide file tree
Showing 14 changed files with 797 additions and 1,776 deletions.
71 changes: 7 additions & 64 deletions custom_components/hubspace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,84 +1,27 @@
"""Hubspace integration."""

import logging
from dataclasses import dataclass

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
from homeassistant.const import CONF_TIMEOUT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from hubspace_async import HubSpaceConnection

from .const import DEFAULT_TIMEOUT, UPDATE_INTERVAL_OBSERVATION
from .coordinator import HubSpaceDataUpdateCoordinator
from .bridge import HubSpaceBridge
from .const import DEFAULT_TIMEOUT

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
]


@dataclass
class HubSpaceData:
"""Data for HubSpace integration."""

coordinator_hubspace: HubSpaceDataUpdateCoordinator


type HubSpaceConfigEntry = ConfigEntry[HubSpaceData]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HubSpace as config entry."""
websession = async_get_clientsession(hass)
conn = HubSpaceConnection(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], websession=websession
)

coordinator_hubspace = HubSpaceDataUpdateCoordinator(
hass,
conn,
entry.data[CONF_TIMEOUT],
[],
[],
UPDATE_INTERVAL_OBSERVATION,
)

await coordinator_hubspace.async_config_entry_first_refresh()

entry.runtime_data = HubSpaceData(
coordinator_hubspace=coordinator_hubspace,
)
bridge = HubSpaceBridge(hass, entry)
if not await bridge.async_initialize_bridge():
return False

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# @TODO - Actions / Services
return True


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: HubSpaceConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device.
A device can only be removed if it is not present in the current API response
"""
device_id = list(device_entry.identifiers)[0][1]
return not any(
[
x
for x in config_entry.runtime_data.coordinator_hubspace.tracked_devices
if x.device_id == device_id
]
)


async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
_LOGGER.debug(
"Migrating configuration from version %s.%s",
Expand Down
137 changes: 137 additions & 0 deletions custom_components/hubspace/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import asyncio
import logging
from typing import Any, Callable

import aiohttp
from aiohttp import client_exceptions
from aiohttp.web_exceptions import HTTPForbidden
from aiohubspace import HubSpaceBridgeV1
from homeassistant import core
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import aiohttp_client

from .const import DOMAIN, PLATFORMS
from .device import async_setup_devices


class HubSpaceBridge:
"""Manages a single HubSpace account"""

def __init__(self, hass: core.HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the system."""
self.config_entry = config_entry
self.hass = hass
self.authorized = False
# Jobs to be executed when API is reset.
self.reset_jobs: list[core.CALLBACK_TYPE] = []
# self.sensor_manager: SensorManager | None = None
self.logger = logging.getLogger(__name__)
# store actual api connection to bridge as api
self.api = HubSpaceBridgeV1(
self.config_entry.data[CONF_USERNAME],
self.config_entry.data[CONF_PASSWORD],
session=aiohttp_client.async_get_clientsession(hass),
)
# store (this) bridge object in hass data
hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self

async def async_initialize_bridge(self) -> bool:
"""Initialize Connection with the Hue API."""
setup_ok = False
try:
async with asyncio.timeout(self.config_entry.data[CONF_TIMEOUT]):
await self.api.initialize()
setup_ok = True
except HTTPForbidden:
# Credentials have changed. Force a re-login
create_config_flow(self.hass, self.config_entry.data[CONF_USERNAME])
return False
except (
TimeoutError,
client_exceptions.ClientOSError,
client_exceptions.ServerDisconnectedError,
client_exceptions.ContentTypeError,
) as err:
raise ConfigEntryNotReady(
f"Error connecting to the HubSpace API: {err}"
) from err
except Exception:
self.logger.exception("Unknown error connecting to the HubSpace API")
return False
finally:
if not setup_ok:
await self.api.close()

# Init devices
await async_setup_devices(self)
await self.hass.config_entries.async_forward_entry_setups(
self.config_entry, PLATFORMS
)
# add listener for config entry updates.
self.reset_jobs.append(self.config_entry.add_update_listener(_update_listener))
self.authorized = True
return True

async def async_request_call(self, task: Callable, *args, **kwargs) -> Any:
"""Send request to the Hue bridge."""
try:
return await task(*args, **kwargs)
except aiohttp.ClientError as err:
raise HomeAssistantError(
f"Request failed due connection error: {err}"
) from err
except Exception as err:
msg = f"Request failed: {err}"
raise HomeAssistantError(msg) from err

async def async_reset(self) -> bool:
"""Reset this bridge to default state.
Will cancel any scheduled setup retry and will unload
the config entry.
"""

# If the authentication was wrong.
if self.api is None:
return True

while self.reset_jobs:
self.reset_jobs.pop()()

# Unload platforms
unload_success = await self.hass.config_entries.async_unload_platforms(
self.config_entry, PLATFORMS
)

if unload_success:
self.hass.data[DOMAIN].pop(self.config_entry.entry_id)

return unload_success

async def handle_unauthorized_error(self) -> None:
"""Create a new config flow when the authorization is no longer valid."""
if not self.authorized:
return
self.logger.error(
"Unable to authorize to HubSpace, setup the linking again",
)
self.authorized = False
create_config_flow(self.hass, self.config_entry.data[CONF_USERNAME])


async def _update_listener(hass: core.HomeAssistant, entry: ConfigEntry) -> None:
"""Handle ConfigEntry options update."""
await hass.config_entries.async_reload(entry.entry_id)


def create_config_flow(hass: core.HomeAssistant, username: str) -> None:
"""Start a config flow."""
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_USERNAME: username},
)
)
22 changes: 11 additions & 11 deletions custom_components/hubspace/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from typing import Any, Optional

import voluptuous as vol
from aiohubspace import HubSpaceBridgeV1, InvalidAuth
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from hubspace_async import HubSpaceConnection, InvalidAuth, InvalidResponse

from .const import DEFAULT_TIMEOUT, DOMAIN
from .const import VERSION_MAJOR as const_maj
Expand All @@ -36,23 +36,23 @@ class HubSpaceConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
MINOR_VERSION = const_min
username: str
password: str
conn: HubSpaceConnection
conn: HubSpaceBridgeV1

async def validate_auth(
self, user_input: dict[str, Any] | None = None
) -> Optional[str]:
"""Validate and save auth"""
err_type = None
self.bridge = HubSpaceBridgeV1(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
async with timeout(user_input[CONF_TIMEOUT] / 1000):
self.conn = HubSpaceConnection(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
await self.conn.get_account_id()
await self.bridge.get_account_id()
except TimeoutError:
err_type = "cannot_connect"
except (InvalidAuth, InvalidResponse):
except InvalidAuth:
err_type = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
Expand All @@ -71,15 +71,15 @@ async def async_step_user(
errors["base"] = "invalid_timeout"
if not (err_type := await self.validate_auth(user_input)):
await self.async_set_unique_id(
await self.conn.account_id, raise_on_progress=False
self.bridge.account_id, raise_on_progress=False
)
# self._abort_if_unique_id_configured()
await self.conn.client.close()
await self.bridge.close()
return self.async_create_entry(title=DOMAIN, data=user_input)
else:
errors["base"] = err_type
with contextlib.suppress(Exception):
await self.conn.client.close()
await self.bridge.close()
return self.async_show_form(
step_id="user",
data_schema=LOGIN_SCHEMA,
Expand Down
12 changes: 12 additions & 0 deletions custom_components/hubspace/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
Platform,
UnitOfElectricPotential,
UnitOfPower,
)
Expand All @@ -30,6 +31,17 @@
VERSION_MINOR: Final[int] = 0


PLATFORMS: Final[list[Platform]] = [
# Platform.BINARY_SENSOR,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
]


ENTITY_BINARY_SENSOR: Final[str] = "binary_sensor"
ENTITY_CLIMATE: Final[str] = "climate"
ENTITY_FAN: Final[str] = "fan"
Expand Down
Loading

0 comments on commit 4cee5c8

Please sign in to comment.