diff --git a/CODEOWNERS b/CODEOWNERS index 821c3b99bd7718..1aba7ba25036e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -592,6 +592,8 @@ CLAUDE.md @home-assistant/core /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli +/homeassistant/components/gaposa/ @mwatson2 +/tests/components/gaposa/ @mwatson2 /homeassistant/components/garage_door/ @home-assistant/core /tests/components/garage_door/ @home-assistant/core /homeassistant/components/garages_amsterdam/ @klaasnicolaas diff --git a/homeassistant/components/gaposa/__init__.py b/homeassistant/components/gaposa/__init__.py new file mode 100644 index 00000000000000..641dcb4c0f4290 --- /dev/null +++ b/homeassistant/components/gaposa/__init__.py @@ -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 diff --git a/homeassistant/components/gaposa/config_flow.py b/homeassistant/components/gaposa/config_flow.py new file mode 100644 index 00000000000000..ae581917214749 --- /dev/null +++ b/homeassistant/components/gaposa/config_flow.py @@ -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" + 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 + ) diff --git a/homeassistant/components/gaposa/const.py b/homeassistant/components/gaposa/const.py new file mode 100644 index 00000000000000..32011fc17a1589 --- /dev/null +++ b/homeassistant/components/gaposa/const.py @@ -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 diff --git a/homeassistant/components/gaposa/coordinator.py b/homeassistant/components/gaposa/coordinator.py new file mode 100644 index 00000000000000..c94e4a69e7b996 --- /dev/null +++ b/homeassistant/components/gaposa/coordinator.py @@ -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 + 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 ( + 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 + ``.motors.``. + """ + 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 diff --git a/homeassistant/components/gaposa/cover.py b/homeassistant/components/gaposa/cover.py new file mode 100644 index 00000000000000..db2236fe4aa6a4 --- /dev/null +++ b/homeassistant/components/gaposa/cover.py @@ -0,0 +1,177 @@ +"""Gaposa cover entity.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +from pygaposa import Motor + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import GaposaConfigEntry +from .const import ( + COMMAND_DOWN, + COMMAND_STOP, + COMMAND_UP, + DOMAIN, + MOTION_DELAY, + STATE_DOWN, + STATE_UP, +) +from .coordinator import DataUpdateCoordinatorGaposa + +_LOGGER = logging.getLogger(__name__) + +_SUPPORTED_FEATURES = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: GaposaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add a cover entity for every motor the coordinator knows about.""" + coordinator = config_entry.runtime_data + async_add_entities( + GaposaCover(coordinator, motor_id, motor) + for motor_id, motor in coordinator.data.items() + ) + + +class GaposaCover(CoordinatorEntity[DataUpdateCoordinatorGaposa], CoverEntity): + """A single Gaposa motor exposed as a cover entity.""" + + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = _SUPPORTED_FEATURES + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: DataUpdateCoordinatorGaposa, + motor_id: str, + motor: Motor, + ) -> None: + """Initialize the cover.""" + super().__init__(coordinator, context=motor_id) + self._motor_id = motor_id + self._last_command: str | None = None + self._last_command_time: datetime | None = None + self._attr_unique_id = motor_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, motor_id)}, + name=motor.name, + manufacturer="Gaposa", + ) + self._cancel_motion_refresh: CALLBACK_TYPE | None = None + + @property + def motor(self) -> Motor: + """Return the current Motor object from coordinator data.""" + return self.coordinator.data[self._motor_id] + + @property + def available(self) -> bool: + """Entity is available while the motor is known to the coordinator.""" + return super().available and self._motor_id in self.coordinator.data + + async def async_will_remove_from_hass(self) -> None: + """Cancel any pending motion-window refresh on removal.""" + await super().async_will_remove_from_hass() + if self._cancel_motion_refresh is not None: + self._cancel_motion_refresh() + self._cancel_motion_refresh = None + + @property + def is_open(self) -> bool | None: + """Return whether the cover is fully open.""" + if self.motor.state == STATE_UP: + return True + if self.motor.state == STATE_DOWN: + return False + return None + + @property + def is_closed(self) -> bool | None: + """Return whether the cover is fully closed.""" + if self.motor.state == STATE_DOWN: + return True + if self.motor.state == STATE_UP: + return False + return None + + @property + def is_opening(self) -> bool: + """Return whether the cover is opening right now.""" + return self._is_moving() and self._last_command == COMMAND_UP + + @property + def is_closing(self) -> bool: + """Return whether the cover is closing right now.""" + return self._is_moving() and self._last_command == COMMAND_DOWN + + def _is_moving(self) -> bool: + """True while we're still inside the motion window of the last command.""" + if self._last_command_time is None or self._last_command == COMMAND_STOP: + return False + deadline = self._last_command_time + timedelta(seconds=MOTION_DELAY) + return dt_util.utcnow() < deadline + + def _begin_motion(self, command: str) -> None: + """Record an open/close command and arm the motion-window timer.""" + self._last_command = command + self._last_command_time = dt_util.utcnow() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.motor.up(False) + self._begin_motion(COMMAND_UP) + self.async_write_ha_state() + self._schedule_refresh_after_motion() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.motor.down(False) + self._begin_motion(COMMAND_DOWN) + self.async_write_ha_state() + self._schedule_refresh_after_motion() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover and collapse the motion window immediately.""" + self._last_command = COMMAND_STOP + self._last_command_time = None + if self._cancel_motion_refresh is not None: + self._cancel_motion_refresh() + self._cancel_motion_refresh = None + await self.motor.stop(True) + await self.coordinator.async_request_refresh() + self.async_write_ha_state() + + @callback + def _schedule_refresh_after_motion(self) -> None: + """Schedule a coordinator refresh once the motion window expires.""" + if self._cancel_motion_refresh is not None: + self._cancel_motion_refresh() + self._cancel_motion_refresh = async_call_later( + self.hass, MOTION_DELAY, self._on_motion_complete + ) + + async def _on_motion_complete(self, _now: datetime) -> None: + """Coordinator refresh after the cover has finished moving.""" + self._cancel_motion_refresh = None + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/gaposa/manifest.json b/homeassistant/components/gaposa/manifest.json new file mode 100644 index 00000000000000..06577e3ecf083a --- /dev/null +++ b/homeassistant/components/gaposa/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gaposa", + "name": "Gaposa", + "codeowners": ["@mwatson2"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gaposa", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["gaposa"], + "quality_scale": "bronze", + "requirements": ["pygaposa==0.2.4"] +} diff --git a/homeassistant/components/gaposa/quality_scale.yaml b/homeassistant/components/gaposa/quality_scale.yaml new file mode 100644 index 00000000000000..df89a5fd5a3c0e --- /dev/null +++ b/homeassistant/components/gaposa/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options flow. + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Gaposa is a cloud service; there is no discoverable hub on the + local network to find. + discovery-update-info: + status: exempt + comment: | + Gaposa is a cloud service; there is no discoverable hub on the + local network to find. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: exempt + comment: The cover entities do not belong to a specific category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not create any entities that should be disabled. + entity-translations: + status: exempt + comment: | + The only entity on each device is the cover itself and its name + matches the device name (set via _attr_has_entity_name + name=None). + exception-translations: todo + icon-translations: + status: exempt + comment: This integration does not use custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration has no known failure modes worth surfacing as repair issues. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: | + pygaposa does not yet ship a py.typed marker file (PEP 561), + so it cannot be used by a strictly-typed HA integration. + Blocked on an upstream change to the pygaposa package. diff --git a/homeassistant/components/gaposa/strings.json b/homeassistant/components/gaposa/strings.json new file mode 100644 index 00000000000000..79dc06eb14ab88 --- /dev/null +++ b/homeassistant/components/gaposa/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "api_key": "The API key for the Gaposa cloud service", + "password": "The password for your Gaposa account", + "username": "The username for your Gaposa account" + }, + "description": "Enter your Gaposa cloud account credentials", + "title": "Connect to Gaposa" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7f42a179a49ded..de9124595ae101 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -246,6 +246,7 @@ "fujitsu_fglair", "fully_kiosk", "fyta", + "gaposa", "garages_amsterdam", "gardena_bluetooth", "gdacs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 28600a5f462780..f194c2c1505208 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2343,6 +2343,12 @@ "integration_type": "virtual", "supported_by": "home_connect" }, + "gaposa": { + "name": "Gaposa", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "garadget": { "name": "Garadget", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 430fcd2924f2c8..6b006228994912 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2145,6 +2145,9 @@ pyfritzhome==0.6.20 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.gaposa +pygaposa==0.2.4 + # homeassistant.components.skybeacon pygatt[GATTTOOL]==4.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0636f8b7683246..216afaf677f114 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1843,6 +1843,9 @@ pyfritzhome==0.6.20 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.gaposa +pygaposa==0.2.4 + # homeassistant.components.hvv_departures pygti==0.9.4 diff --git a/tests/components/gaposa/__init__.py b/tests/components/gaposa/__init__.py new file mode 100644 index 00000000000000..fe151e67610a32 --- /dev/null +++ b/tests/components/gaposa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Gaposa integration.""" diff --git a/tests/components/gaposa/conftest.py b/tests/components/gaposa/conftest.py new file mode 100644 index 00000000000000..7ab33bfe43477d --- /dev/null +++ b/tests/components/gaposa/conftest.py @@ -0,0 +1,145 @@ +"""Common fixtures for the Gaposa tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pygaposa import Motor +import pytest + +from homeassistant.components.gaposa.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_EMAIL = "test@example.com" +TEST_PASSWORD = "test-password" +TEST_API_KEY = "test-apikey" +TEST_DEVICE_SERIAL = "DEVICE123" +TEST_CLIENT_ID = "gaposa-client-123" + + +def _make_mock_motor(motor_id: str, name: str, state: str = "UP") -> MagicMock: + """Return a MagicMock shaped like a pygaposa Motor.""" + motor = MagicMock(spec=Motor) + motor.id = motor_id + motor.name = name + motor.state = state + motor.up = AsyncMock() + motor.down = AsyncMock() + motor.stop = AsyncMock() + motor.update = AsyncMock() + return motor + + +def _make_mock_device(serial: str, motors: list[MagicMock]) -> MagicMock: + """Return a MagicMock shaped like a pygaposa Device.""" + device = MagicMock() + device.serial = serial + device.motors = motors + device.addListener = MagicMock() + device.removeListener = MagicMock() + return device + + +def _make_mock_client(devices: list[MagicMock], client_id: str) -> MagicMock: + """Return a MagicMock shaped like a pygaposa Client.""" + client = MagicMock() + client.id = client_id + client.devices = devices + return client + + +@pytest.fixture +def mock_motors() -> list[MagicMock]: + """Two motors: Living Room (open) and Bedroom (closed).""" + return [ + _make_mock_motor("motor-1", "Living Room", state="UP"), + _make_mock_motor("motor-2", "Bedroom", state="DOWN"), + ] + + +@pytest.fixture +def mock_gaposa_instance(mock_motors: list[MagicMock]) -> MagicMock: + """Return a mocked Gaposa client instance populated with test data.""" + device = _make_mock_device(TEST_DEVICE_SERIAL, mock_motors) + client = _make_mock_client([device], TEST_CLIENT_ID) + user = MagicMock() + + instance = MagicMock() + instance.login = AsyncMock() + instance.update = AsyncMock() + instance.close = AsyncMock() + instance.clients = [(client, user)] + return instance + + +@pytest.fixture +def mock_gaposa( + mock_gaposa_instance: MagicMock, +) -> Generator[MagicMock]: + """Patch pygaposa.Gaposa in the coordinator and config flow modules. + + Both locations import Gaposa at module load time, so both have to be + patched. Tests consume the shared ``mock_gaposa_instance`` via the + fixture above — they don't need to drill through the class mock. + """ + with ( + patch( + "homeassistant.components.gaposa.coordinator.Gaposa", + return_value=mock_gaposa_instance, + ) as mock_class, + patch( + "homeassistant.components.gaposa.config_flow.Gaposa", + return_value=mock_gaposa_instance, + ), + ): + yield mock_class + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry and async_unload_entry for config-flow tests. + + Config flow tests care about the flow result and the data that ends up + on the created entry — they should not exercise the real coordinator + setup. Mocking unload too keeps HA's teardown path happy (the real + async_unload_entry assumes runtime_data is populated). + """ + with ( + patch( + "homeassistant.components.gaposa.async_setup_entry", return_value=True + ) as mock_setup, + patch("homeassistant.components.gaposa.async_unload_entry", return_value=True), + ): + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: TEST_API_KEY, + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + title="Gaposa Gateway", + unique_id=TEST_CLIENT_ID, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gaposa: MagicMock, +) -> MockConfigEntry: + """Add the config entry to hass and run through a real setup.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/gaposa/test_config_flow.py b/tests/components/gaposa/test_config_flow.py new file mode 100644 index 00000000000000..5b4eeea346620b --- /dev/null +++ b/tests/components/gaposa/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the Gaposa config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientConnectionError +from pygaposa import FirebaseAuthException, GaposaAuthException +import pytest + +from homeassistant import config_entries +from homeassistant.components.gaposa.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_CLIENT_ID + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_API_KEY: "test-apikey", + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", +} + + +async def test_form_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gaposa: MagicMock, +) -> None: + """Happy path: the form creates a config entry with the submitted data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Gaposa Gateway" + assert result2["data"] == USER_INPUT + assert result2["result"].unique_id == TEST_CLIENT_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_aborts_when_already_configured( + hass: HomeAssistant, + mock_gaposa: MagicMock, +) -> None: + """A second setup flow for the same account aborts.""" + MockConfigEntry( + domain=DOMAIN, data=USER_INPUT, unique_id=TEST_CLIENT_ID + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exc", "expected_error"), + [ + (GaposaAuthException("bad creds"), "invalid_auth"), + (FirebaseAuthException("bad firebase token"), "invalid_auth"), + (ConnectionError(), "cannot_connect"), + (Exception("boom"), "unknown"), + ], +) +async def test_form_validation_errors( + hass: HomeAssistant, + mock_gaposa_instance: MagicMock, + mock_gaposa: MagicMock, + exc: Exception, + expected_error: str, +) -> None: + """Each login failure mode surfaces as the right form error.""" + if isinstance(exc, ConnectionError): + exc = ClientConnectionError("boom") + + mock_gaposa_instance.login.side_effect = exc + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} + + +async def test_form_no_clients_returns_unknown( + hass: HomeAssistant, + mock_gaposa_instance: MagicMock, + mock_gaposa: MagicMock, +) -> None: + """Login succeeds but account has no clients — treated as unknown error.""" + mock_gaposa_instance.clients = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/gaposa/test_coordinator.py b/tests/components/gaposa/test_coordinator.py new file mode 100644 index 00000000000000..b0a6239aefc2c8 --- /dev/null +++ b/tests/components/gaposa/test_coordinator.py @@ -0,0 +1,106 @@ +"""Tests for the Gaposa data update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +from pygaposa import FirebaseAuthException, GaposaAuthException +import pytest + +from homeassistant.components.gaposa.const import UPDATE_INTERVAL, UPDATE_INTERVAL_FAST +from homeassistant.components.gaposa.coordinator import DataUpdateCoordinatorGaposa +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + + +def _get_coordinator(entry: MockConfigEntry) -> DataUpdateCoordinatorGaposa: + """Return the coordinator stored on ``entry.runtime_data``.""" + assert isinstance(entry.runtime_data, DataUpdateCoordinatorGaposa) + return entry.runtime_data + + +async def test_coordinator_populates_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """After setup the coordinator should expose a dict of motors keyed by id.""" + coordinator = _get_coordinator(init_integration) + + assert coordinator.data is not None + assert len(coordinator.data) == 2 + keys = set(coordinator.data.keys()) + assert keys == {"DEVICE123.motors.motor-1", "DEVICE123.motors.motor-2"} + + +async def test_coordinator_normal_refresh_interval( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """After a successful first refresh the interval should be UPDATE_INTERVAL.""" + coordinator = _get_coordinator(init_integration) + assert coordinator.update_interval == timedelta(seconds=UPDATE_INTERVAL) + + +async def test_update_failure_shortens_interval( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gaposa_instance: MagicMock, +) -> None: + """A failed refresh should flip the coordinator to the fast interval.""" + coordinator = _get_coordinator(init_integration) + mock_gaposa_instance.update.side_effect = OSError("boom") + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + assert coordinator.update_interval == timedelta(seconds=UPDATE_INTERVAL_FAST) + + +async def test_recovery_restores_normal_interval( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gaposa_instance: MagicMock, +) -> None: + """After a recovered refresh the interval returns to UPDATE_INTERVAL.""" + coordinator = _get_coordinator(init_integration) + + mock_gaposa_instance.update.side_effect = OSError("boom") + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(seconds=UPDATE_INTERVAL_FAST) + + mock_gaposa_instance.update.side_effect = None + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(seconds=UPDATE_INTERVAL) + + +@pytest.mark.parametrize( + "exc", + [GaposaAuthException, FirebaseAuthException], +) +async def test_auth_errors_raise_update_failed( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gaposa_instance: MagicMock, + exc: type[Exception], +) -> None: + """Auth errors on refresh surface as UpdateFailed (no reauth flow yet).""" + coordinator = _get_coordinator(init_integration) + mock_gaposa_instance.update.side_effect = exc("credentials rejected") + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_device_polled_pushes_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """_on_device_polled should synchronously push new data to subscribers.""" + coordinator = _get_coordinator(init_integration) + + initial = coordinator.data.copy() + coordinator._on_device_polled() + assert coordinator.data == initial diff --git a/tests/components/gaposa/test_cover.py b/tests/components/gaposa/test_cover.py new file mode 100644 index 00000000000000..481b3bbb861ff6 --- /dev/null +++ b/tests/components/gaposa/test_cover.py @@ -0,0 +1,296 @@ +"""Tests for the Gaposa cover platform.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun import freeze_time +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + CoverEntityFeature, +) +from homeassistant.components.gaposa.const import MOTION_DELAY +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +LIVING_ROOM_ENTITY = "cover.living_room" +BEDROOM_ENTITY = "cover.bedroom" + + +async def test_cover_entities_created( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Both mock motors should produce cover entities at setup.""" + assert hass.states.get(LIVING_ROOM_ENTITY) is not None + assert hass.states.get(BEDROOM_ENTITY) is not None + + +async def test_cover_initial_state_from_motor( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """The Living Room motor is UP, the Bedroom motor is DOWN.""" + assert hass.states.get(LIVING_ROOM_ENTITY).state == STATE_OPEN + assert hass.states.get(BEDROOM_ENTITY).state == STATE_CLOSED + + +async def test_cover_supported_features( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Open, close and stop are all supported; no position control.""" + state = hass.states.get(LIVING_ROOM_ENTITY) + expected = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + assert state.attributes[ATTR_SUPPORTED_FEATURES] == expected + assert ATTR_CURRENT_POSITION not in state.attributes + + +async def test_open_cover_calls_motor_up( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """Calling open_cover invokes the mocked Motor.up().""" + living_room_motor = mock_motors[0] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: LIVING_ROOM_ENTITY}, + blocking=True, + ) + living_room_motor.up.assert_called_once_with(False) + + +async def test_close_cover_calls_motor_down( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """Calling close_cover invokes the mocked Motor.down().""" + bedroom_motor = mock_motors[1] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + bedroom_motor.down.assert_called_once_with(False) + + +async def test_stop_cover_calls_motor_stop( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """Calling stop_cover invokes the mocked Motor.stop().""" + living_room_motor = mock_motors[0] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: LIVING_ROOM_ENTITY}, + blocking=True, + ) + living_room_motor.stop.assert_called_once_with(True) + + +async def test_stop_cancels_pending_motion_refresh( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """A stop command mid-motion cancels the pending motion refresh.""" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + + entity = hass.data["entity_components"]["cover"].get_entity(BEDROOM_ENTITY) + assert entity._cancel_motion_refresh is not None + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert entity._cancel_motion_refresh is None + + +async def test_cover_reports_opening_during_motion_window( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """After an open command the cover state is `opening` until MOTION_DELAY elapses.""" + now = dt_util.utcnow() + + with freeze_time(now): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + + assert hass.states.get(BEDROOM_ENTITY).state == STATE_OPENING + + +async def test_cover_reports_closing_during_motion_window( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """After a close command the cover state is `closing` until MOTION_DELAY elapses.""" + now = dt_util.utcnow() + + with freeze_time(now): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: LIVING_ROOM_ENTITY}, + blocking=True, + ) + + assert hass.states.get(LIVING_ROOM_ENTITY).state == STATE_CLOSING + + +@pytest.mark.parametrize( + ("motor_state", "expected"), + [ + ("UP", STATE_OPEN), + ("DOWN", STATE_CLOSED), + ("STOP", STATE_UNKNOWN), + ("UNKNOWN", STATE_UNKNOWN), + ], +) +async def test_cover_state_mapping( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], + motor_state: str, + expected: str, +) -> None: + """Verify the motor.state → HA state mapping across known + unknown values.""" + motor = mock_motors[0] + motor.state = motor_state + + await init_integration.runtime_data.async_refresh() + await hass.async_block_till_done() + + assert hass.states.get(LIVING_ROOM_ENTITY).state == expected + + +async def test_cover_device_registry_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Each motor ends up as a distinct device in the registry.""" + device_registry = dr.async_get(hass) + gaposa_devices = [ + d + for d in device_registry.devices.values() + if any(i[0] == "gaposa" for i in d.identifiers) + ] + assert len(gaposa_devices) == 2 + names = {d.name for d in gaposa_devices} + assert names == {"Living Room", "Bedroom"} + + +async def test_entity_reads_state_from_current_coordinator_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """Entity state should come from coordinator.data, not a cached Motor. + + This test replaces the Motor object in coordinator.data with a + brand-new mock and verifies the entity reports the replacement's + state. + """ + assert hass.states.get(LIVING_ROOM_ENTITY).state == STATE_OPEN + + coordinator = init_integration.runtime_data + original_key = "DEVICE123.motors.motor-1" + assert original_key in coordinator.data + + replacement = MagicMock() + replacement.id = "motor-1" + replacement.name = "Living Room" + replacement.state = "DOWN" + coordinator.data[original_key] = replacement + coordinator.async_set_updated_data(coordinator.data) + await hass.async_block_till_done() + + assert hass.states.get(LIVING_ROOM_ENTITY).state == STATE_CLOSED + + +async def test_rapid_open_close_replaces_motion_refresh( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """A second open/close cancels the first motion refresh callback.""" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + entity = hass.data["entity_components"]["cover"].get_entity(BEDROOM_ENTITY) + first_cancel = entity._cancel_motion_refresh + assert first_cancel is not None + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + assert entity._cancel_motion_refresh is not first_cancel + + +async def test_motion_window_collapses_after_delay( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_motors: list[MagicMock], +) -> None: + """Past MOTION_DELAY, the cover should return to a steady state.""" + now = dt_util.utcnow() + + with freeze_time(now): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: BEDROOM_ENTITY}, + blocking=True, + ) + + later = now + timedelta(seconds=MOTION_DELAY + 5) + with freeze_time(later): + async_fire_time_changed(hass, later) + await hass.async_block_till_done() + + state = hass.states.get(BEDROOM_ENTITY).state + assert state not in (STATE_OPENING, STATE_CLOSING) diff --git a/tests/components/gaposa/test_init.py b/tests/components/gaposa/test_init.py new file mode 100644 index 00000000000000..d4a155945b797b --- /dev/null +++ b/tests/components/gaposa/test_init.py @@ -0,0 +1,92 @@ +"""Test setup and teardown of the Gaposa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from pygaposa import FirebaseAuthException, GaposaAuthException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Happy path: integration sets up, entry is LOADED, unload works cleanly.""" + assert init_integration.state is ConfigEntryState.LOADED + assert init_integration.runtime_data is not None + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_unload_closes_gaposa_client( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gaposa_instance: MagicMock, +) -> None: + """Unloading the entry should close the Gaposa session.""" + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + mock_gaposa_instance.close.assert_called_once() + + +async def test_network_failure_during_setup_retries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gaposa_instance: MagicMock, + mock_gaposa: MagicMock, +) -> None: + """If the first refresh fails with a network error the entry enters SETUP_RETRY.""" + mock_gaposa_instance.update.side_effect = OSError("boom") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_gaposa_instance.close.assert_called_once() + + +async def test_network_failure_during_login_retries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gaposa_instance: MagicMock, + mock_gaposa: MagicMock, +) -> None: + """A network error during login surfaces as SETUP_RETRY.""" + mock_gaposa_instance.login.side_effect = OSError("cloud unreachable") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_gaposa_instance.close.assert_called_once() + + +@pytest.mark.parametrize( + "exc", + [GaposaAuthException, FirebaseAuthException], +) +async def test_auth_failure_during_setup_retries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gaposa_instance: MagicMock, + mock_gaposa: MagicMock, + exc: type[Exception], +) -> None: + """An auth failure during setup enters SETUP_RETRY (no reauth flow yet).""" + mock_gaposa_instance.update.side_effect = exc("credentials rejected") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY