-
-
Notifications
You must be signed in to change notification settings - Fork 34.4k
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
base: dev
Are you sure you want to change the base?
Add Gaposa integration #138754
Changes from all commits
a78c31d
79e7b65
c987242
50db3c0
72f4143
9fd5f88
c9ee35f
f6c99e9
0dd89c6
1effeec
be01895
c062975
833e7f9
6368756
b4917f9
0487f85
a5824be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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)) | ||
|
||
# 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 |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
"""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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.""" |
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 |
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can be removed