-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
New integration for Gaposa blinds #161442
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
Open
mwatson2
wants to merge
45
commits into
home-assistant:dev
Choose a base branch
from
mwatson2:gaposa
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
45 commits
Select commit
Hold shift + click to select a range
dcb3c25
Add support for gaposa component
mwatson2 3221880
Update for async updates from gaposa module
mwatson2 1c389f9
Gaposa Integration: Immediate refresh, improve logging
mwatson2 c4471d8
Gaposa Integration: Use async_set_updated_data for async document update
mwatson2 120db6d
Add support for gaposa component
mwatson2 07f4dbc
Gaposa Integration: Device and Motor updates, Add unit tests
mwatson2 0afae28
Gaposa integration: additional bug and test fixes
mwatson2 8ee8c71
Gaposa Integration: Update for latest requirements
mwatson2 bfbd370
Gaposa integration: Bronze tier requirements
mwatson2 9e8b63b
Gaposa integration: strict typing, pygaposa@0.2.4
mwatson2 1de1a2a
Fix Gaposa cover UI state updates after actions
mwatson2 22bdfa1
Improve Gaposa integration
mwatson2 6297149
Address PR comments for Gaposa integration
mwatson2 bb45430
Improve state management in Gaposa component
mwatson2 cb9bae9
gaposa: rewrite tests to go through the platform setup path
mwatson2 5ef0b78
gaposa: plumb config_entry through the coordinator
mwatson2 b9108dd
gaposa: simplify __init__.py per PR review
mwatson2 bd15945
gaposa: address config_flow.py review comments
mwatson2 fda38cd
gaposa: address coordinator.py review comments
mwatson2 0f82fe5
gaposa: overhaul cover.py
mwatson2 cf5e7bb
gaposa: tidy const.py and manifest.json
mwatson2 37af484
gaposa: expand quality_scale.yaml with the full rule set
mwatson2 e8f54eb
gaposa: remove strict-typing opt-in until pygaposa ships py.typed
mwatson2 5f4cc29
gaposa: wrap login in timeout, add async_shutdown override
mwatson2 c73c837
gaposa: cancel motion task when stop is called
mwatson2 6fd771e
gaposa: drop committed test .storage config entry
mwatson2 3b37b5b
gaposa: fix ruff violations surfaced by CI
mwatson2 f1396d3
gaposa: flip brands/docs-* quality_scale rules to done
mwatson2 5194cc5
gaposa: close Gaposa client if login fails during setup
mwatson2 b10443b
gaposa: fully remove stale entities from the entity registry
mwatson2 fc4c2b8
gaposa: resolve Motor from coordinator.data on each access
mwatson2 6d6c933
gaposa: switch from asyncio.sleep task to async_call_later
mwatson2 17c240b
gaposa: prefix unused entry_data parameter with underscore
mwatson2 8996bf2
Merge remote-tracking branch 'upstream/dev' into gaposa
mwatson2 447b7e8
gaposa: reformat strings.json with prettier sort-json plugin
mwatson2 95fde06
gaposa: regenerate integrations.json via script.hassfest
mwatson2 8281a71
gaposa: address third round of Copilot review comments
mwatson2 30aec29
gaposa: explicitly remove device when a motor disappears
mwatson2 dea932e
gaposa: widen config-flow error catch to ClientError, assert close on…
mwatson2 7149eb2
gaposa: add tests to reach 100% coverage on config_flow.py
mwatson2 4f6727e
gaposa: shut down coordinator when first refresh fails
mwatson2 56346fc
Update tests/components/gaposa/test_init.py
mwatson2 30dc07b
gaposa: delete accidentally committed test .storage file
mwatson2 c644586
gaposa: address joostlek review — drop reauth, simplify scope
mwatson2 fa5efa4
gaposa: assert motor command arguments in cover tests
mwatson2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
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.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """The Gaposa integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import timedelta | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import Platform | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .const import UPDATE_INTERVAL | ||
| from .coordinator import DataUpdateCoordinatorGaposa | ||
|
|
||
| PLATFORMS: list[Platform] = [Platform.COVER] | ||
|
|
||
| type GaposaConfigEntry = ConfigEntry[DataUpdateCoordinatorGaposa] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: GaposaConfigEntry) -> bool: | ||
| """Set up Gaposa from a config entry.""" | ||
| coordinator = DataUpdateCoordinatorGaposa( | ||
| hass, | ||
| entry, | ||
| name=entry.title, | ||
| update_interval=timedelta(seconds=UPDATE_INTERVAL), | ||
| ) | ||
| try: | ||
| await coordinator.async_config_entry_first_refresh() | ||
| except Exception: | ||
| await coordinator.async_shutdown() | ||
| raise | ||
|
|
||
| entry.runtime_data = coordinator | ||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: GaposaConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): | ||
| await entry.runtime_data.async_shutdown() | ||
| return unload_ok | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """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 ClientError | ||
| from pygaposa import FirebaseAuthException, Gaposa, GaposaAuthException | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import 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, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class GaposaConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Gaposa.""" | ||
|
|
||
| async def _async_validate_credentials( | ||
| self, data: Mapping[str, Any] | ||
| ) -> tuple[str | None, str]: | ||
| """Attempt to authenticate against the Gaposa cloud. | ||
|
|
||
| Returns a ``(client_id, error)`` tuple. ``client_id`` is ``None`` | ||
| on any failure and ``error`` is ``""`` on success. | ||
| """ | ||
| gaposa = Gaposa( | ||
| data[CONF_API_KEY], | ||
| websession=async_get_clientsession(self.hass), | ||
| ) | ||
| try: | ||
| async with timeout(10): | ||
| await gaposa.login(data[CONF_USERNAME], data[CONF_PASSWORD]) | ||
| except (GaposaAuthException, FirebaseAuthException) as exc: | ||
| _LOGGER.debug("Gaposa authentication failed: %s", exc) | ||
| return None, "invalid_auth" | ||
| except (ClientError, TimeoutError, OSError) as exc: | ||
| _LOGGER.debug("Gaposa connection failed: %s", exc) | ||
| return None, "cannot_connect" | ||
| except Exception: | ||
| _LOGGER.exception("Unexpected exception during Gaposa login") | ||
| return None, "unknown" | ||
|
mwatson2 marked this conversation as resolved.
|
||
| finally: | ||
| await gaposa.close() | ||
|
|
||
| if not gaposa.clients: | ||
| return None, "unknown" | ||
| return gaposa.clients[0][0].id, "" | ||
|
|
||
| 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: | ||
| client_id, error = await self._async_validate_credentials(user_input) | ||
| if error: | ||
| errors["base"] = error | ||
| else: | ||
| await self.async_set_unique_id(client_id) | ||
| self._abort_if_unique_id_configured() | ||
| return self.async_create_entry( | ||
| title=DEFAULT_GATEWAY_NAME, data=user_input | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| """Constants for the Gaposa integration.""" | ||
|
|
||
| DOMAIN = "gaposa" | ||
| DEFAULT_GATEWAY_NAME = "Gaposa Gateway" | ||
|
|
||
| # Motor state strings returned by pygaposa's Motor.state attribute. | ||
| # These map directly onto the values the Gaposa cloud emits. | ||
| STATE_UP = "UP" | ||
| STATE_DOWN = "DOWN" | ||
|
|
||
| # Command strings recorded on a cover entity to remember what the | ||
| # last user-initiated action was. They are compared in is_opening / | ||
| # is_closing to decide whether the cover should report as moving. | ||
| COMMAND_UP = "UP" | ||
| COMMAND_DOWN = "DOWN" | ||
| COMMAND_STOP = "STOP" | ||
|
|
||
| # Seconds between coordinator refreshes during normal operation and | ||
| # after a transient failure, respectively. The fast interval lets the | ||
| # integration recover quickly from a blip without hammering the API. | ||
| UPDATE_INTERVAL = 600 | ||
| UPDATE_INTERVAL_FAST = 60 | ||
|
|
||
| # Seconds a cover entity reports as "opening"/"closing" after an | ||
| # open/close command is issued. Gaposa's cloud API does not report | ||
| # motion state directly, so we approximate it from the time the | ||
| # command was sent. | ||
| MOTION_DELAY = 60 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| """Data update coordinator for the Gaposa integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from asyncio import timeout | ||
| from collections.abc import Callable | ||
| from datetime import timedelta | ||
| import logging | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from aiohttp import ClientError | ||
| from pygaposa import Device, FirebaseAuthException, Gaposa, GaposaAuthException, Motor | ||
|
|
||
| from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| 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 | ||
|
|
||
| if TYPE_CHECKING: | ||
| from . import GaposaConfigEntry | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class DataUpdateCoordinatorGaposa(DataUpdateCoordinator[dict[str, Motor]]): | ||
| """Fetch state for every Gaposa motor on the account.""" | ||
|
|
||
| config_entry: GaposaConfigEntry | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: GaposaConfigEntry, | ||
| *, | ||
| name: str, | ||
| update_interval: timedelta, | ||
| ) -> None: | ||
| """Initialize the coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| config_entry=config_entry, | ||
| name=name, | ||
| update_interval=update_interval, | ||
| ) | ||
| self.gaposa: Gaposa | None = None | ||
| self.devices: list[Device] = [] | ||
| self._listener: Callable[[], None] | None = None | ||
|
|
||
| async def _async_setup(self) -> None: | ||
| """Log in to the Gaposa API once, before the first refresh.""" | ||
| websession = async_get_clientsession(self.hass) | ||
| gaposa = Gaposa(self.config_entry.data[CONF_API_KEY], websession=websession) | ||
| try: | ||
| async with timeout(10): | ||
| await gaposa.login( | ||
| self.config_entry.data[CONF_USERNAME], | ||
| self.config_entry.data[CONF_PASSWORD], | ||
| ) | ||
| except (GaposaAuthException, FirebaseAuthException) as exc: | ||
| await gaposa.close() | ||
| raise ConfigEntryNotReady("Gaposa authentication failed") from exc | ||
| except (ClientError, TimeoutError, OSError) as exc: | ||
| await gaposa.close() | ||
| raise ConfigEntryNotReady(f"Error connecting to Gaposa: {exc}") from exc | ||
|
mwatson2 marked this conversation as resolved.
|
||
| self.gaposa = gaposa | ||
|
|
||
| async def _async_update_data(self) -> dict[str, Motor]: | ||
| """Refresh motor state from the Gaposa cloud.""" | ||
| assert self.gaposa is not None # set in _async_setup | ||
|
|
||
| try: | ||
| async with timeout(10): | ||
| await self.gaposa.update() | ||
| except ( | ||
|
mwatson2 marked this conversation as resolved.
|
||
| GaposaAuthException, | ||
| FirebaseAuthException, | ||
| ClientError, | ||
| TimeoutError, | ||
| OSError, | ||
| ) as exc: | ||
| self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) | ||
| raise UpdateFailed(f"Error talking to Gaposa: {exc}") from exc | ||
|
|
||
| # pygaposa polls the Firestore REST API internally after commands | ||
| # (every 2 s for ~20 s). Register a listener on each device so | ||
| # those rapid post-command polls push fresh data to our entities | ||
| # via async_set_updated_data, rather than waiting for the next | ||
| # coordinator poll (600 s). | ||
| if self._listener is None: | ||
| self._listener = self._on_device_polled | ||
|
|
||
| current_devices: list[Device] = [] | ||
| 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) | ||
|
|
||
| for device in self.devices: | ||
| if device not in current_devices: | ||
| device.removeListener(self._listener) | ||
|
|
||
| self.devices = current_devices | ||
|
|
||
| self.update_interval = timedelta(seconds=UPDATE_INTERVAL) | ||
|
|
||
| return self._get_data_from_devices() | ||
|
|
||
| def _get_data_from_devices(self) -> dict[str, Motor]: | ||
| """Flatten all motors across all devices into a single dict. | ||
|
|
||
| The dictionary key is a unique id for the motor of the form | ||
| ``<device serial number>.motors.<motor.id>``. | ||
| """ | ||
| data: dict[str, Motor] = {} | ||
| if self.gaposa is None: | ||
| return data | ||
| 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_device_polled(self) -> None: | ||
| """Called by pygaposa after each internal poll of a device document. | ||
|
|
||
| This fires during pygaposa's rapid post-command polling (every ~2 s) | ||
| and pushes the latest motor state to all coordinator subscribers | ||
| without waiting for the next scheduled coordinator refresh. | ||
| """ | ||
| _LOGGER.debug("Gaposa device polled, pushing new data") | ||
| self.async_set_updated_data(self._get_data_from_devices()) | ||
|
|
||
| async def async_shutdown(self) -> None: | ||
| """Detach listeners and close the Gaposa session.""" | ||
| await super().async_shutdown() | ||
| if self._listener is not None: | ||
| for device in self.devices: | ||
| device.removeListener(self._listener) | ||
| self._listener = None | ||
| self.devices = [] | ||
| if self.gaposa is not None: | ||
| await self.gaposa.close() | ||
| self.gaposa = None | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
why don't we just get all the data from the entry inside of the coordinator?