New integration for Gaposa blinds#161442
New integration for Gaposa blinds#161442mwatson2 wants to merge 45 commits intohome-assistant:devfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new integration for Gaposa blinds and shades, which uses a cloud-based API to control motors via their LinkIt hub. The integration targets Bronze quality scale and includes configuration flow, data coordinator, cover entity implementation, and comprehensive test coverage.
Changes:
- New Gaposa integration with config flow, coordinator, and cover entity implementation
- Test suite covering config flow, coordinator, and cover entity functionality
- Quality scale configuration and strict typing enabled
Reviewed changes
Copilot reviewed 19 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| homeassistant/components/gaposa/init.py | Integration setup with coordinator initialization and platform forwarding |
| homeassistant/components/gaposa/config_flow.py | User and reauth configuration flows with validation |
| homeassistant/components/gaposa/coordinator.py | Data update coordinator managing API communication and device updates |
| homeassistant/components/gaposa/cover.py | Cover entity implementation with motion tracking and state management |
| homeassistant/components/gaposa/const.py | Constants for commands, states, and intervals |
| homeassistant/components/gaposa/strings.json | Localization strings for config flow |
| homeassistant/components/gaposa/manifest.json | Integration manifest with bronze quality scale |
| homeassistant/components/gaposa/quality_scale.yaml | Quality scale rule compliance status |
| tests/components/gaposa/*.py | Test suite for config flow, coordinator, and cover entity |
| requirements_all.txt | Added pygaposa dependency |
| requirements_test_all.txt | Added pygaposa test dependency |
| mypy.ini | Strict typing configuration for gaposa component |
| .strict-typing | Added gaposa to strict typing list |
| CODEOWNERS | Added maintainer for gaposa integration |
| tests/testing_config/.storage/core.config_entries | Test configuration entry |
erwindouna
left a comment
There was a problem hiding this comment.
Thanks for contributing, here's an initial review!
Fixes an issue where the UI state of Gaposa covers wasn't properly updating after actions: - When stop button is pressed, immediately request a refresh from the API and update UI - After open/close actions, refresh API state before updating UI when motion completes - Add tests to verify this behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
- Fix context parameter in GaposaCover initialization - Change log levels from INFO to DEBUG for routine operations - Improve test handling of coroutines to avoid warnings
- Remove loop parameter from Gaposa API initialization - Move API initialization to coordinator - Use entry.runtime_data instead of hass.data for storing data - Update quality_scale.yaml file - Remove device_actions property as it's not needed - Remove diagnostics.py for future PR - Cleanup code by removing excessive comments
The existing test suite constructed GaposaCover and
DataUpdateCoordinatorGaposa directly with mocks, poking at private
attributes and overriding the upstream `hass` fixture. The cover
tests broke when HA tightened EntityPlatform's internals; the
coordinator tests broke when DataUpdateCoordinator.__init__ started
calling frame.report_usage(). Both of those breakages are the result
of the tests reaching around the public interface rather than the
code under test being wrong.
Replace the whole suite with behavior tests that route through a
proper setup:
conftest.py
- Drop the custom `hass` and `verify_cleanup` fixtures that were
overriding HA core's standard ones.
- Build realistic pygaposa mocks: a Motor MagicMock spec'd from
pygaposa.Motor (so Motor-typed tests still get isinstance hits)
with AsyncMock up/down/stop; a device containing two test motors
(Living Room = UP, Bedroom = DOWN); a Gaposa instance with an
AsyncMock login/update/close and a clients list matching the
`list[tuple[Client, User]]` shape the coordinator iterates.
- `mock_gaposa` patches Gaposa in both the coordinator AND config_flow
modules, so the test never hits the real pygaposa Firebase init.
- `mock_setup_entry` now also mocks async_unload_entry so teardown
doesn't blow up on runtime_data access when async_setup_entry was
short-circuited.
- `init_integration` runs real async_setup(entry_id), exercising the
actual coordinator + cover platform path.
test_cover.py
- Every test now asks hass.states.get("cover.living_room") or calls
hass.services.async_call(...) rather than constructing GaposaCover.
- test_cover_entities_created / test_cover_initial_state_from_motor
verify the two motors show up with the right initial states.
- test_cover_supported_features asserts OPEN|CLOSE|STOP and absence
of position control.
- test_open/close/stop_cover_calls_motor_* verify the mocked Motor
coroutines get called when services fire.
- test_cover_reports_opening_during_motion_window /
test_cover_reports_closing_during_motion_window assert the
entity goes into STATE_OPENING / STATE_CLOSING during the motion
window. The old tests checked an is_opening attribute that cover
entities never expose in attributes — it goes into .state.
- test_cover_state_mapping parametrizes motor.state → HA state for
UP / DOWN / STOP / UNKNOWN.
- test_cover_device_registry_entry verifies each motor is registered
as its own device.
- test_motion_window_collapses_after_delay asserts the cover returns
to a steady state after MOTION_DELAY via async_fire_time_changed.
test_coordinator.py
- Tests now access the coordinator via
`config_entry.runtime_data` after init_integration runs. A small
`_get_coordinator` helper understands both the current dict shape
and the direct-coordinator shape that Stage 3 will introduce, so
these tests survive the refactor.
- Parametrized the auth-failure test on GaposaAuthException /
FirebaseAuthException.
- Added test_coordinator_populates_data and
test_on_document_updated_pushes_data for the happy paths.
- Kept the fast-interval and recovery-interval tests, now exercising
the coordinator that was really set up rather than a hand-
constructed one.
test_init.py (new)
- test_setup_and_unload: LOADED → unload → NOT_LOADED.
- test_unload_closes_gaposa_client: verifies the mocked Gaposa.close
is called on unload.
- test_network_failure_during_setup_retries: OSError on first refresh
→ SETUP_RETRY.
- test_auth_failure_during_setup_triggers_reauth (parametrized on
both auth exception types): confirms the entry lands in
SETUP_ERROR and a reauth flow is queued.
test_config_flow.py
- Strip the "HERE" docstring typo on line 1.
- Replace the narrow `patch("pygaposa.Gaposa.login", ...)` patches
with the shared `mock_gaposa` fixture. The old approach left the
Gaposa class's __init__ to run for real, which leaked an aiohttp
ClientSession every test and then blew up at teardown.
- Parametrize the validation-error test over the four error modes
(GaposaAuthException, FirebaseAuthException, ClientConnectionError,
generic Exception).
- Add test_reauth_flow_invalid_auth_shows_form_error for the
"still-bad-credentials" path.
Test count: 5 → 33 (all passing). The new tests exercise real
platform setup, so subsequent stages (runtime_data unwrap, cover.py
overhaul, config_flow cleanup) can rely on them as a regression net.
After rebasing onto current upstream/dev the integration started emitting: WARNING: Detected that integration 'gaposa' relies on ContextVar, but should pass the config entry explicitly. at homeassistant/components/gaposa/coordinator.py, line 33: super().__init__(. This will stop working in Home Assistant 2026.8 DataUpdateCoordinator now expects subclasses to pass the ConfigEntry explicitly via the `config_entry` keyword. Add it to DataUpdateCoordinatorGaposa.__init__ and plumb the entry through from async_setup_entry. No behaviour change; just deletes the deprecation warning and makes the integration forward-compatible with HA 2026.8.
Address the __init__.py feedback from @erwindouna + Copilot: - Drop the `{"coordinator": coordinator}` dict wrapper on runtime_data. The coordinator is the only thing stored; put it directly on runtime_data. - Introduce a typed `GaposaConfigEntry = ConfigEntry[DataUpdateCoordinatorGaposa]` alias (modern HA pattern) and use it on both async_setup_entry and async_unload_entry. This both removes the manual `: DataUpdateCoordinatorGaposa` annotation and lets type checkers understand `entry.runtime_data` without casts. - Read entry.data[...] directly in the coordinator constructor instead of materialising intermediate api_key/username/password variables. - Strip the what-not-why comments ("Store runtime data that should persist between restarts", "Fetch initial data so we have data when entities subscribe", "Call async_setup_entry for each of the platforms"). The code is self-explanatory. - Delete the empty `update_listener` function and its `entry.add_update_listener` subscription. There are no options configured for this integration, and the no-op function was flagged by Copilot. - Drop the `noqa: F401` re-export of CONF_PASSWORD / CONF_USERNAME from `.const`. Callers now import from `homeassistant.const` (Stage 7 will delete the `.const` copies entirely). cover.py:46 - Update the runtime_data access to match: `config_entry.runtime_data` is now the coordinator, not a dict. Tests - Simplify the helpers in test_coordinator.py and test_cover.py that previously handled both the dict and the direct shapes. The dict shape no longer exists. All 33 tests still pass.
Rewrite the config flow to address @erwindouna's PR feedback: - Drop the deprecated `loop=hass.loop` kwarg on Gaposa(...). Modern async libraries use asyncio.get_running_loop() internally and the pygaposa constructor accepts websession without it. - Move validate_input into the flow class as `_async_validate_credentials`. Return a (client_id, error) tuple instead of raising custom exceptions that the flow then catches — this flattens four try/except blocks into a single error string. - Drop the `{"title": ...}` return-value pattern; the title is a constant (DEFAULT_GATEWAY_NAME) so pass it directly to async_create_entry. - Drop `VERSION = 1` (it's the default). - **Fix unique_id**: the old code used `DOMAIN` as the unique id, which meant only one Gaposa account could ever be configured per HA instance. Switch to `gaposa.clients[0][0].id` — the stable account-scoped Gaposa client id returned after login. Also add a test that asserts the created entry's unique_id. - Use `CONF_API_KEY` / `CONF_USERNAME` / `CONF_PASSWORD` from homeassistant.const everywhere, replacing the previous literal "api_key" / "username" / "password" string usage in the reauth path. - Replace the manual async_update_entry + async_reload + async_abort sequence in reauth_confirm with self.async_update_reload_and_abort. - Add a `wrong_account` abort so a reauth flow can't silently re-bind the entry to a different Gaposa account. Add the matching strings.json entry and a test for it. - Rename the class GaposaConfigFlow (instead of ConfigFlow) so the base class doesn't need a qualified `config_entries.ConfigFlow` reference. Drop the now-unused CannotConnect and InvalidAuth custom exceptions. Tests - conftest: mock client gets an explicit `client.id` (TEST_CLIENT_ID = "gaposa-client-123"), mock_config_entry now uses that as its unique_id so init_integration matches the new key. - test_form_creates_entry now asserts `result2["result"].unique_id == TEST_CLIENT_ID`. - New test_form_aborts_when_already_configured covers the duplicate prevention. - New test_reauth_flow_wrong_account_aborts covers the unique-id-mismatch abort. All 35 tests pass.
- Use a module-level `_LOGGER` constant instead of taking a `logger` argument through the constructor (@erwindouna). - Move the one-time Gaposa login into `_async_setup`, the dedicated hook `DataUpdateCoordinator` calls before the first refresh (@erwindouna). Removes the conditional "if self.gaposa is None" branch that used to gate the login inside `update_gateway`. - Drop the `update_gateway` helper entirely — its body is now inlined into `_async_update_data`. The old method returned a `bool` that was always `True` and never used; that dead return is gone. - Fix the `dictionalry` typo in the _get_data_from_devices docstring (Copilot). - Drop the `new_devices` list that was collected but never used. - Narrow the update-exception catch from bare `Exception` to `(ClientError, TimeoutError, OSError)` — bare catches mask programming errors as network flakes. - Type the coordinator subclass as `DataUpdateCoordinator[dict[str, Motor]]` so callers get a real data type without annotating. - Make the listener attribute private (`self._listener`). - Drop the mypy assert-hack comment; now that `_async_setup` unconditionally assigns `self.gaposa`, the type checker can follow along without the manual assertion. __init__.py no longer needs to import logging or pass a logger to the coordinator — drop both. All 35 tests still pass.
HA does not call async_unload_entry on SETUP_RETRY — only on a transition from LOADED to NOT_LOADED. So if async_config_entry_first_refresh raises (ConfigEntryNotReady, ConfigEntryAuthFailed, UpdateFailed, etc.), the coordinator's Gaposa client and any push-notification listeners it registered would leak across retries. Wrap the first_refresh call in try/except that calls coordinator.async_shutdown() before re-raising. This closes the aiohttp session, detaches pygaposa device listeners, and stops the coordinator timer — matching what async_unload_entry does on the normal unload path. Move entry.runtime_data assignment back to after the first refresh (it's no longer needed before, since we handle cleanup inline). 44 tests pass.
|
@mwatson2 Please add links to the pygaposa repo to the PR description and mark the PR "ready for review" when done |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Done! |
This file doesn't exist on upstream/dev — it was created by an earlier commit on this branch as a test-run artifact. Commit 6fd771e blanked the entries but left the file. Remove it entirely so the PR diff doesn't carry a file that shouldn't exist.
| coordinator = DataUpdateCoordinatorGaposa( | ||
| hass, | ||
| entry, | ||
| api_key=entry.data[CONF_API_KEY], | ||
| username=entry.data[CONF_USERNAME], | ||
| password=entry.data[CONF_PASSWORD], | ||
| name=entry.title, | ||
| update_interval=timedelta(seconds=UPDATE_INTERVAL), | ||
| ) |
There was a problem hiding this comment.
why don't we just get all the data from the entry inside of the coordinator?
| async def async_step_reauth( | ||
| self, _entry_data: Mapping[str, Any] | ||
| ) -> ConfigFlowResult: | ||
| """Start reauth when the stored credentials stop working.""" | ||
| return await self.async_step_reauth_confirm() | ||
|
|
||
| async def async_step_reauth_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Ask the user for a new password and validate it.""" | ||
| errors: dict[str, str] = {} |
There was a problem hiding this comment.
Let's keep reauth for a follow up PR
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: ConfigEntry, |
| # Attach a listener to every new device so document-level pushes | ||
| # from pygaposa trigger async_set_updated_data. | ||
| if self._listener is None: | ||
| self._listener = self.on_document_updated | ||
|
|
||
| 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 | ||
|
|
||
| # Recovered from a transient failure — restore the normal interval. | ||
| self.update_interval = timedelta(seconds=UPDATE_INTERVAL) | ||
|
|
||
| return self._get_data_from_devices() |
There was a problem hiding this comment.
So can you please elaborate on what we poll for and what we use listeners for? As in, I am wondering if it makes sense to keep using the coordinator or that we add listeners to every entity, so we don't have to keep a centralized state (as apparently we get updates on device level)
There was a problem hiding this comment.
The listeners are callbacks that are called after pygaposa does a document update. Pygaposa polls after a command is sent to ensure we get the updates that occur as a result of the command. I believe the server is waiting until it confirms delivery of the command to the hub before updating the document and this can be a few seconds.
Otherwise, pygaposa doesn't poll, so we need to trigger background updates from here to pick up changes caused by other users (e.g. official Gaposa mobile app).
| @callback | ||
| def _async_add_remove_entities() -> None: | ||
| """Add new motors and drop covers for motors that have disappeared.""" | ||
| latest_ids = set(coordinator.data) | ||
| new_entities: list[GaposaCover] = [] | ||
|
|
||
| for motor_id, motor in coordinator.data.items(): | ||
| if motor_id not in known_entities: | ||
| entity = GaposaCover(coordinator, motor_id, motor) | ||
| new_entities.append(entity) | ||
| known_entities[motor_id] = entity | ||
|
|
||
| if new_entities: | ||
| async_add_entities(new_entities) | ||
|
|
||
| entity_registry = er.async_get(hass) | ||
| device_registry = dr.async_get(hass) | ||
| for motor_id in list(known_entities): | ||
| if motor_id not in latest_ids: |
There was a problem hiding this comment.
Should we keep dynamic devices for a follow up PR?
| def motor(self) -> Motor | None: | ||
| """Return the current Motor object, or ``None`` if it has been removed.""" | ||
| return self.coordinator.data.get(self._motor_id) | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Entity is available while the motor is still known to the coordinator.""" | ||
| return super().available and self.motor is not None |
There was a problem hiding this comment.
instead change self.motor is not None to self._motor_id in self.coordinator.data and change the .get to [], this way you don't have to handle None states
| await motor.up(False) | ||
| self._begin_motion(COMMAND_UP) | ||
| self.async_write_ha_state() | ||
| self._schedule_refresh_after_motion() |
There was a problem hiding this comment.
I want to make a mental note because things will get clear when I know what role the coordinator has
| discovery: | ||
| status: exempt | ||
| comment: | | ||
| Gaposa is a cloud service; there is no discoverable hub on the | ||
| local network to find. |
There was a problem hiding this comment.
There's no discoverable hub? The devices are connected somehow right? Can we discover the devices and propose the users to set up their cloud account?
There was a problem hiding this comment.
The setup for the hub involves configuring the WiFi (through a hub-hosted temporary AP/web page) and then registering through the mobile app using the serial number / QR code. I didn't see any case where the mobile app attempts local network discovery of the hub. It's just server-side rendez-vous. The hub does have an open port that responds to HTTP but I could only get a single error code from this whatever I sent. I have not looked for mDNS or SSDP, but I could try that when I am next at the install location and make a follow-up PR for discovery if I find anything.
Address @joostlek's code review on PR home-assistant#161442: config_flow.py — drop reauth flow Remove async_step_reauth, async_step_reauth_confirm, and the STEP_REAUTH_DATA_SCHEMA. Reauth will be added in a follow-up PR per reviewer request to keep the initial integration minimal. strings.json — remove reauth strings Drop reauth_confirm step, reauth_successful and wrong_account abort strings. coordinator.py — read credentials from config_entry.data Remove api_key/username/password constructor kwargs. The coordinator now reads self.config_entry.data[CONF_*] directly in _async_setup, as suggested. Type config_entry as GaposaConfigEntry (via TYPE_CHECKING to avoid circular import). Auth errors on refresh now raise UpdateFailed instead of ConfigEntryAuthFailed (no reauth flow to trigger). Fix listener comments to accurately describe pygaposa's post-command polling mechanism (not Firebase push). Rename on_document_updated → _on_device_polled. __init__.py — simplify coordinator construction No more credential kwargs; just pass entry + name + interval. cover.py — drop dynamic device add/remove Replace the _async_add_remove_entities listener pattern with a simple one-shot async_add_entities at setup time. Removes entity_registry and device_registry cleanup code. Dynamic device support will be added in a follow-up PR. Motor property uses [] instead of .get(); available checks _motor_id membership in coordinator.data. quality_scale.yaml — flip deferred items to todo reauthentication-flow, dynamic-devices, stale-devices → todo. Tests — align with reduced scope Remove reauth flow tests (test_reauth_flow_success, test_reauth_flow_wrong_account_aborts, test_reauth_flow_invalid_auth_shows_form_error). Remove dynamic device tests (test_stale_motor_cleans_up_entity_and_device, test_entity_removed_when_motor_gone, test_entity_unavailable_when_motor_gone). Update auth error tests to expect UpdateFailed / SETUP_RETRY instead of ConfigEntryAuthFailed / SETUP_ERROR. Rename test_on_document_updated → test_device_polled. 37 tests pass (was 44; 7 removed for deferred features).
Tighten the three motor command assertions to verify the actual arguments passed to pygaposa: - motor.up(False) — don't wait for update (we handle refresh ourselves) - motor.down(False) — same - motor.stop(True) — wait for backend to confirm stop state
New PR for #138754
Proposed change
This change introduces a new component for Gaposa blinds and shades (https://www.gaposa.it/eng). The component uses the cloud pull model, accessing the same server and API as the Gaposa RollApp mobile application (https://www.gaposa.it/eng/news/rollapp/). This cloud service communicates with the shades through their LinkIt hub (https://www.gaposa.it/eng/prod/?residential/electronics/control-units/home-automation/linkit).
The Gaposa cloud API does not expose motor position or battery level. The integration provides open, close, and stop commands. Motion status (opening/closing) is inferred from elapsed time since the last command and an assumed motion duration; no numeric position is reported.
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: