8000 Add Gaposa integration by mwatson2 · Pull Request #138754 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Add Gaposa integration #138754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 17 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
< 8000 /file-tree-toggle>
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ homeassistant.components.frontend.*
homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fyta.*
homeassistant.components.gaposa.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions homeassistant/components/gaposa/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""The Gaposa integration."""

from __future__ import annotations

from datetime import timedelta
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant

from .const import CONF_PASSWORD, CONF_USERNAME, DOMAIN, UPDATE_INTERVAL # noqa: F401
from .coordinator import DataUpdateCoordinatorGaposa

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.COVER]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Gaposa from a config entry."""

api_key = entry.data[CONF_API_KEY]
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]

coordinator = DataUpdateCoordinatorGaposa(
hass,
_LOGGER,
api_key=api_key,
username=username,
password=password,
name=entry.title,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)

# Store runtime data that should persist between restarts
entry.async_on_unload(entry.add_update_listener(update_listener))
Comment on lines +37 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be removed


# Initialize runtime data with coordinator reference
entry.runtime_data = {"coordinator": coordinator}

# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()

# Call async_setup_entry for each of the platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
# Add any code needed to handle configuration updates


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):
coordinator: DataUpdateCoordinatorGaposa = entry.runtime_data["coordinator"]
if coordinator.gaposa is not None:
await coordinator.gaposa.close()

return unload_ok
151 changes: 151 additions & 0 deletions homeassistant/components/gaposa/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Config flow for Gaposa integration."""

from __future__ import annotations

from asyncio import timeout
from collections.abc import Mapping
import logging
from typing import Any

from aiohttp import ClientConnectionError
from pygaposa import FirebaseAuthException, Gaposa, GaposaAuthException
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant. 6D4E core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import CONF_PASSWORD, CONF_USERNAME, DEFAULT_GATEWAY_NAME, DOMAIN

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)


async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.

Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""

try:
async with timeout(10): # Add timeout handling
websession = async_get_clientsession(hass)
gaposa = Gaposa(data[CONF_API_KEY], loop=hass.loop, websession=websession)
await gaposa.login(data[CONF_USERNAME], data[CONF_PASSWORD])
Comment on lines +40 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only have things in the try block that can raise

except ClientConnectionError as exp:
_LOGGER.error(exp)
raise CannotConnect from exp
except GaposaAuthException as exp:
_LOGGER.error(exp)
raise InvalidAuth from exp
except FirebaseAuthException as exp:
_LOGGER.error(exp)
raise InvalidAuth from exp

await gaposa.close()

# Return info that you want to store in the config entry.
return {"title": DEFAULT_GATEWAY_NAME}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class GaposaConfigFlow(ConfigFlow, domain=DOMAIN):

"""Handle a config flow for Gaposa."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
Comment on lines +82 to +83
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we set the domain as unique id?


return self.async_create_entry(title=info["title"], data=user_input)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthorization request."""
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthorization flow."""
errors = {}

if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry is not None

try:
# Validate the new password
await validate_input(
self.hass,
{
"api_key": entry.data["api_key"],
"username": entry.data["username"],
"password": user_input["password"],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except # noqa: BLE001
errors["base"] = "unknown"
else:
# Update the config entry with the new password
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
"password": user_input["password"],
},
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required("password"): str,
}
),
errors=errors,
)


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
27 changes: 27 additions & 0 deletions homeassistant/components/gaposa/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Constants for the Gaposa integration."""

DOMAIN = "gaposa"

DEFAULT_GATEWAY_NAME = "Gaposa Gateway"

CONF_USERNAME = "username"
CONF_PASSWORD = "password"

KEY_GAPOSA_API = "gaposa"

DOMAIN_DATA_GAPOSA = "gaposa"
DOMAIN_DATA_COORDINATOR = "coordinator"

ATTR_AVAILABLE = "available"

COMMAND_UP = "UP"
COMMAND_DOWN = "DOWN"
COMMAND_STOP = "STOP"

STATE_UP = "UP"
STATE_DOWN = "DOWN"

UPDATE_INTERVAL = 600
UPDATE_INTERVAL_FAST = 60

MOTION_DELAY = 60
133 changes: 133 additions & 0 deletions homeassistant/components/gaposa/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Data update coordinator for the Gaposa integration."""

from asyncio import timeout
from collections.abc import Callable
from datetime import timedelta
import logging

from pygaposa import Device, FirebaseAuthException, Gaposa, GaposaAuthException, Motor

from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import UPDATE_INTERVAL, UPDATE_INTERVAL_FAST


class DataUpdateCoordinatorGaposa(DataUpdateCoordinator):
"""Class to manage fetching data from single endpoint."""

def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
*,
api_key: str,
username: str,
password: str,
name: str,
update_interval: timedelta,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
name=name,
update_interval=update_interval,
)

self._api_key = api_key
self._username = username
self._password = password
self.gaposa: Gaposa | None = None
self.devices: list[Device] = []
self.listener: Callable[[], None] | None = None

async def update_gateway(self) -> bool:
"""Fetch data from gateway."""
# Initialize the API if it's not ready
if self.gaposa is None:
websession = async_get_clientsession(self.hass)
try:
self.gaposa = Gaposa(self._api_key, websession=websession)
await self.gaposa.login(self._username, self._password)
except GaposaAuthException as exp:
raise ConfigEntryAuthFailed from exp
except FirebaseAuthException as exp:
raise ConfigEntryAuthFailed from exp

try:
await self.gaposa.update()
except GaposaAuthException as exp:
raise ConfigEntryAuthFailed from exp
except FirebaseAuthException as exp:
raise ConfigEntryAuthFailed from exp

current_devices: list[Device] = []
new_devices: list[Device] = []
if self.listener is None and self.gaposa is not None:
self.listener = self.on_document_updated
# mypy doesn't understand that we've already checked self.gaposa is not None
assert self.gaposa is not None
for client, _user in self.gaposa.clients:
for device in client.devices:
current_devices.append(device)
if device not in self.devices:
device.addListener(self.listener)
new_devices.append(device)

for device in self.devices:
if device not in current_devices:
device.removeListener(self.listener)

self.devices = current_devices

return True

async def _async_update_data(self) -> dict[str, Motor]:
self.logger.debug(
"Gaposa coordinator _async_update_data, interval: %s",
str(self.update_interval),
)

try:
async with timeout(10):
await self.update_gateway()
except ConfigEntryAuthFailed:
raise
except TimeoutError:
self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST)
raise
except Exception as exp:
self.logger.exception("Error updating Gaposa data")
self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST)
raise UpdateFailed from exp

self.update_interval = timedelta(seconds=UPDATE_INTERVAL)

data = self._get_data_from_devices()

self.logger.debug("Finished _async_update_data")

return data

def _get_data_from_devices(self) -> dict[str, Motor]:
# Coordinator data consists of a Dictionary of the controllable motors, with
# the dictionalry key being a unique id for the motor of the form
# <device serial number>.motors.<channel number>
data: dict[str, Motor] = {}

if self.gaposa is not None:
for client, _user in self.gaposa.clients:
for device in client.devices:
for motor in device.motors:
data[f"{device.serial}.motors.{motor.id}"] = motor

return data

def on_document_updated(self) -> None:
"""Handle document updated."""
self.logger.debug("Gaposa coordinator on_document_updated")
data = self._get_data_from_devices()
self.async_set_updated_data(data)
Loading
0