Conversation
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
There was a problem hiding this comment.
Pull request overview
Adds a new Home Assistant integration for the gridX (GridBox) cloud platform, including config flow, coordinators, sensors, diagnostics, and a dedicated test suite.
Changes:
- Introduces the
gridxintegration with config entry setup/unload, config flow (incl. reauth/reconfigure), and data update coordinators. - Adds a comprehensive sensor platform covering live and historical (daily total) metrics with translations/icons.
- Adds diagnostics support and a full set of component tests (config flow, coordinators, sensors, diagnostics).
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
homeassistant/components/gridx/__init__.py |
Sets up/unloads the integration, creates the connector + coordinators, forwards platform setups. |
homeassistant/components/gridx/config_flow.py |
Implements user setup, reauth, and reconfigure flows plus credential validation. |
homeassistant/components/gridx/const.py |
Defines domain constants, OEM list, and polling intervals. |
homeassistant/components/gridx/coordinator.py |
Adds live + historical DataUpdateCoordinators and fetch helpers with error handling. |
homeassistant/components/gridx/sensor.py |
Defines all sensor entity descriptions and the coordinator-backed sensor entity implementation. |
homeassistant/components/gridx/diagnostics.py |
Adds config entry diagnostics with redaction. |
homeassistant/components/gridx/types.py |
Adds typed config entry + runtime data container. |
homeassistant/components/gridx/manifest.json |
Declares the integration metadata and PyPI requirement (gridx-connector==3.0.0). |
homeassistant/components/gridx/strings.json |
Adds config flow, entity, and exception translation strings. |
homeassistant/components/gridx/icons.json |
Adds icon translations for entities. |
homeassistant/components/gridx/quality_scale.yaml |
Declares integration quality scale status (Bronze→Platinum rules). |
tests/components/gridx/conftest.py |
Adds shared fixtures and mocks for integration tests. |
tests/components/gridx/test_config_flow.py |
Tests config flow user setup, duplicate abort, reauth, and reconfigure. |
tests/components/gridx/test_coordinator.py |
Tests coordinator success paths and error handling. |
tests/components/gridx/test_sensor.py |
Tests entity unique IDs, live/historical values, and missing-battery behavior. |
tests/components/gridx/test_diagnostics.py |
Tests diagnostics payload and redaction behavior. |
tests/components/gridx/__init__.py |
Marks the test package. |
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key=key, | ||
| ) from err |
There was a problem hiding this comment.
Raise UpdateFailed (not ConfigEntryAuthFailed) for non-auth HTTP status codes so transient server/network errors don’t incorrectly trigger a reauth flow.
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key=key, | ||
| ) from err |
There was a problem hiding this comment.
Raise UpdateFailed (not ConfigEntryAuthFailed) for non-auth HTTP status codes so transient server/network errors don’t incorrectly trigger a reauth flow.
| async def _fetch_historical(connector: AsyncGridboxConnector) -> GridxHistoricalData: | ||
| """Fetch today's historical totals.""" | ||
| now = datetime.now().astimezone() | ||
| midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) | ||
| tomorrow = midnight + timedelta(days=1) | ||
|
|
There was a problem hiding this comment.
Use Home Assistant’s dt_util helpers (e.g., dt_util.start_of_local_day()/dt_util.now()) to compute local midnight to avoid DST/timezone edge cases from manual datetime.now().astimezone().replace().
| except PermissionError as err: | ||
| LOGGER.error("GridX authentication failed: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key="invalid_auth", | ||
| ) from err | ||
| except httpx.HTTPStatusError as err: |
There was a problem hiding this comment.
Raise ConfigEntryAuthFailed (not ConfigEntryNotReady) on authentication errors during setup so Home Assistant can start the reauth flow instead of repeatedly retrying the entry.
| def _load_oem_config(oem: str, username: str, password: str) -> dict[str, Any]: | ||
| """Load the OEM config file bundled with gridx-connector and inject credentials.""" | ||
| config_path = files("gridx_connector").joinpath("config", f"{oem}.config.json") | ||
| config: dict[str, Any] = json.loads(config_path.read_text()) | ||
| config["login"]["username"] = username | ||
| config["login"]["password"] = password | ||
| return config |
There was a problem hiding this comment.
Avoid duplicating the OEM config-loading logic by moving _load_oem_config to a shared module (or importing it from the integration package) so future changes don’t need to be made in two places.
|
|
||
|
|
||
| @pytest.fixture | ||
| async def setup_integration(hass: HomeAssistant, mock_gridx_connector: MagicMock): |
There was a problem hiding this comment.
Add an explicit return type to this fixture (it returns a MockConfigEntry) to align with the repository’s typed-test conventions.
| async def setup_integration(hass: HomeAssistant, mock_gridx_connector: MagicMock): | |
| async def setup_integration( | |
| hass: HomeAssistant, mock_gridx_connector: MagicMock | |
| ) -> MockConfigEntry: |
| @pytest.fixture | ||
| def config_entry(hass): | ||
| """Return a mock GridX config entry.""" |
There was a problem hiding this comment.
Add type annotations for test parameters (e.g., hass: HomeAssistant, config_entry: MockConfigEntry) so the new tests follow the repository’s typed-test guideline.
| async def test_live_coordinator_success(hass, config_entry) -> None: | ||
| """Test that live coordinator returns processed data.""" |
There was a problem hiding this comment.
Add type annotations for the hass/config_entry parameters in these async tests to keep new tests consistent with typed-test expectations.
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_gridx_connector() -> Generator[MagicMock]: |
There was a problem hiding this comment.
Specify the full Generator type parameters (Generator[MagicMock, None, None]) for this fixture’s return type so type checkers correctly understand the yielded value.
| def mock_gridx_connector() -> Generator[MagicMock]: | |
| def mock_gridx_connector() -> Generator[MagicMock, None, None]: |
| appropriate-polling: done | ||
| brands: done | ||
| common-modules: done |
There was a problem hiding this comment.
Mark the brands quality scale rule as not-yet-done (or exempt) until the brands-repo PR is merged, since the PR description indicates the brand assets are still pending.
| except PermissionError as err: | ||
| LOGGER.error("GridX authentication failed: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key="invalid_auth", | ||
| ) from err | ||
| except httpx.HTTPStatusError as err: |
There was a problem hiding this comment.
Raise ConfigEntryAuthFailed for authentication errors (not ConfigEntryNotReady) so Home Assistant can initiate the reauth flow instead of endlessly retrying setup.
| live_coordinator = GridxLiveCoordinator(hass, entry, connector) | ||
| hist_coordinator = GridxHistoricalCoordinator(hass, entry, connector) | ||
|
|
||
| await live_coordinator.async_config_entry_first_refresh() | ||
| await hist_coordinator.async_config_entry_first_refresh() | ||
|
|
There was a problem hiding this comment.
Ensure the httpx client/connector is closed if setup fails after creation (e.g., during the first coordinator refresh) to avoid leaking network resources on retries.
| connector = await async_create_connector(config, httpx_client) | ||
| try: | ||
| data = await connector.retrieve_live_data() | ||
| finally: | ||
| await connector.close() |
There was a problem hiding this comment.
Close the created httpx client if connector creation fails (since owns_httpx_client/connector.close won’t run) to prevent leaking an AsyncClient during config flow validation.
| connector = await async_create_connector(config, httpx_client) | |
| try: | |
| data = await connector.retrieve_live_data() | |
| finally: | |
| await connector.close() | |
| try: | |
| connector = await async_create_connector(config, httpx_client) | |
| try: | |
| data = await connector.retrieve_live_data() | |
| finally: | |
| await connector.close() | |
| finally: | |
| await httpx_client.aclose() |
| schema = vol.Schema( | ||
| { | ||
| vol.Required( | ||
| CONF_USERNAME, default=entry.data.get(CONF_USERNAME, "") | ||
| ): str, | ||
| vol.Required(CONF_PASSWORD): str, | ||
| } |
There was a problem hiding this comment.
Avoid allowing username changes during reauth (or update the config entry unique_id accordingly), since the entry’s unique_id is based on the original username and changing it here can lead to confusing identity/duplicate-account handling.
| class GridxData: | ||
| """Runtime data stored on the config entry.""" | ||
|
|
||
| connector: Any | ||
| live_coordinator: GridxLiveCoordinator |
There was a problem hiding this comment.
Use a typed connector protocol for runtime_data (e.g., GridxConnector) instead of Any to satisfy strict typing and improve type safety across the integration.
| self.entity_description: GridxSensorEntityDescription = description | ||
| self._attr_unique_id = f"{entry.entry_id}_{description.key}" | ||
| self._attr_device_info = DeviceInfo( | ||
| identifiers={(DOMAIN, entry.entry_id)}, | ||
| name="GridX GridBox", |
There was a problem hiding this comment.
Base entity unique_id/device identifiers on a stable identifier (e.g., entry.unique_id) instead of entry.entry_id so entity/device registry entries survive remove/re-add and keep user customizations.
| self.entity_description: GridxSensorEntityDescription = description | ||
| self._attr_unique_id = f"{entry.entry_id}_{description.key}" | ||
| self._attr_device_info = DeviceInfo( | ||
| identifiers={(DOMAIN, entry.entry_id)}, | ||
| name="GridX GridBox", |
There was a problem hiding this comment.
Base entity unique_id/device identifiers on a stable identifier (e.g., entry.unique_id) instead of entry.entry_id so entity/device registry entries survive remove/re-add and keep user customizations.
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key=key, | ||
| ) from err |
There was a problem hiding this comment.
Apply the same HTTPStatusError handling for historical fetches so non-auth HTTP failures don’t raise ConfigEntryAuthFailed.
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | |
| raise ConfigEntryAuthFailed( | |
| translation_domain=DOMAIN, | |
| translation_key=key, | |
| ) from err | |
| if status in (401, 403): | |
| raise ConfigEntryAuthFailed( | |
| translation_domain=DOMAIN, | |
| translation_key="invalid_auth", | |
| ) from err | |
| raise UpdateFailed(f"Error fetching GridX historical data: {err}") from err |
| config = _load_oem_config(oem, username, password) | ||
| httpx_client = create_async_httpx_client( | ||
| hass, | ||
| auto_cleanup=False, | ||
| base_url=API_BASE_URL, | ||
| ) | ||
|
|
There was a problem hiding this comment.
Ensure the created httpx client is closed when connector creation/setup fails; currently httpx_client is leaked on exceptions before a connector exists to close it.
| if user_input is not None: | ||
| try: | ||
| await _validate_credentials( | ||
| self.hass, | ||
| entry.data[CONF_OEM], | ||
| user_input[CONF_USERNAME], | ||
| user_input[CONF_PASSWORD], | ||
| ) | ||
| except PermissionError: | ||
| errors["base"] = "invalid_auth" | ||
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| errors["base"] = ( | ||
| "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| ) | ||
| except httpx.HTTPError: | ||
| errors["base"] = "cannot_connect" | ||
| except ConnectionError, TimeoutError, OSError: | ||
| errors["base"] = "cannot_connect" | ||
| except UNEXPECTED_AUTH_ERRORS: | ||
| LOGGER.exception("Unexpected error during GridX re-authentication") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| return self.async_update_reload_and_abort( | ||
| entry, | ||
| data_updates={ | ||
| CONF_USERNAME: user_input[CONF_USERNAME], | ||
| CONF_PASSWORD: user_input[CONF_PASSWORD], | ||
| }, | ||
| ) |
There was a problem hiding this comment.
Don’t allow username changes in the reauth step (or update the config entry unique_id and re-check duplicates) because the unique_id is derived from username but is never updated here.
| if user_input is not None: | ||
| try: | ||
| await _validate_credentials( | ||
| self.hass, | ||
| user_input[CONF_OEM], | ||
| user_input[CONF_USERNAME], | ||
| user_input[CONF_PASSWORD], | ||
| ) | ||
| except PermissionError: | ||
| errors["base"] = "invalid_auth" | ||
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| errors["base"] = ( | ||
| "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| ) | ||
| except httpx.HTTPError: | ||
| errors["base"] = "cannot_connect" | ||
| except ConnectionError, TimeoutError, OSError: | ||
| errors["base"] = "cannot_connect" | ||
| except UNEXPECTED_AUTH_ERRORS: | ||
| LOGGER.exception("Unexpected error during GridX reconfiguration") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| return self.async_update_reload_and_abort( | ||
| entry, | ||
| data_updates={ | ||
| CONF_USERNAME: user_input[CONF_USERNAME], | ||
| CONF_PASSWORD: user_input[CONF_PASSWORD], | ||
| CONF_OEM: user_input[CONF_OEM], | ||
| }, | ||
| ) |
There was a problem hiding this comment.
Reconfigure currently accepts a new username without validating/updating the entry unique_id, which can desync duplicate detection from the configured account; either keep username fixed or update unique_id and abort on duplicates/mismatch.
| @dataclass | ||
| class GridxData: | ||
| """Runtime data stored on the config entry.""" | ||
|
|
||
| connector: Any | ||
| live_coordinator: GridxLiveCoordinator | ||
| hist_coordinator: GridxHistoricalCoordinator |
There was a problem hiding this comment.
Type the stored connector as GridxConnector instead of Any to keep strict typing meaningful for runtime_data.
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| LOGGER.error("Error connecting to GridX: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key=key, | ||
| ) from err |
There was a problem hiding this comment.
Raise ConfigEntryAuthFailed for 401/403 HTTPStatusError cases so authentication errors trigger reauth instead of setup retries (use ConfigEntryNotReady only for transient connectivity issues).
| key="directConsumptionRate", | ||
| translation_key="direct_consumption_rate", | ||
| native_unit_of_measurement=PERCENTAGE, | ||
| device_class=SensorDeviceClass.POWER_FACTOR, | ||
| state_class=SensorStateClass.MEASUREMENT, |
There was a problem hiding this comment.
Remove SensorDeviceClass.POWER_FACTOR for these percentage rate sensors (keep unit as %), since POWER_FACTOR is intended for unitless -1..1 power factor values and can misclassify the entity.
| key="hist_selfConsumptionRate", | ||
| translation_key="hist_self_consumption_rate", | ||
| native_unit_of_measurement=PERCENTAGE, | ||
| device_class=SensorDeviceClass.POWER_FACTOR, | ||
| state_class=SensorStateClass.MEASUREMENT, |
There was a problem hiding this comment.
Remove SensorDeviceClass.POWER_FACTOR for historical percentage rate sensors (keep unit as %), since POWER_FACTOR is intended for unitless -1..1 power factor values.
| config = _load_oem_config(oem, username, password) | ||
| httpx_client = create_async_httpx_client( | ||
| hass, | ||
| auto_cleanup=False, | ||
| base_url=API_BASE_URL, | ||
| ) | ||
|
|
||
| try: | ||
| connector = await async_create_connector(config, httpx_client) | ||
| except PermissionError as err: | ||
| LOGGER.error("GridX authentication failed: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key="invalid_auth", | ||
| ) from err | ||
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| LOGGER.error("Error connecting to GridX: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key=key, | ||
| ) from err | ||
| except httpx.HTTPError as err: | ||
| LOGGER.error("Error connecting to GridX: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key="cannot_connect", | ||
| ) from err | ||
| except (RuntimeError, TypeError, ValueError) as err: | ||
| LOGGER.error("Error connecting to GridX: %s", err) | ||
| raise ConfigEntryNotReady( | ||
| translation_domain=DOMAIN, | ||
| translation_key="cannot_connect", | ||
| ) from err |
There was a problem hiding this comment.
Ensure the httpx client is closed if async_create_connector fails, since auto_cleanup=False means the client will otherwise leak on setup errors.
| class GridxData: | ||
| """Runtime data stored on the config entry.""" | ||
|
|
||
| connector: Any | ||
| live_coordinator: GridxLiveCoordinator | ||
| hist_coordinator: GridxHistoricalCoordinator |
There was a problem hiding this comment.
Replace the connector field type from Any to the integration’s GridxConnector Protocol (or a more specific type) to keep strict typing meaningful.
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, |
There was a problem hiding this comment.
Raise UpdateFailed (not ConfigEntryAuthFailed) for non-401/403 HTTPStatusError responses so temporary connectivity/server errors don’t trigger a reauth flow.
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| key = "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| raise ConfigEntryAuthFailed( | ||
| translation_domain=DOMAIN, |
There was a problem hiding this comment.
Raise UpdateFailed (not ConfigEntryAuthFailed) for non-401/403 HTTPStatusError responses so historical polling failures don’t incorrectly force reauthentication.
| now = datetime.now().astimezone() | ||
| midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) | ||
| tomorrow = midnight + timedelta(days=1) |
There was a problem hiding this comment.
Compute the local midnight using Home Assistant’s dt_util helpers (respecting the configured time zone) instead of datetime.now().astimezone().
| ) | ||
|
|
||
| try: | ||
| connector = await async_create_connector(config, httpx_client) |
There was a problem hiding this comment.
Ensure the created httpx client is closed if async_create_connector fails (e.g., wrap connector creation in a try/except that calls await httpx_client.aclose() before re-raising) to avoid leaking sessions when setup errors occur.
| connector = await async_create_connector(config, httpx_client) | |
| try: | |
| connector = await async_create_connector(config, httpx_client) | |
| except BaseException: | |
| await httpx_client.aclose() | |
| raise |
| "loggers": ["gridx_connector"], | ||
| "quality_scale": "gold", | ||
| "requirements": ["gridx-connector==3.0.2"] |
There was a problem hiding this comment.
Align the PR description with the pinned dependency version (code pins gridx-connector==3.0.2 while the description mentions 3.0.0).
Adds support for the gridX energy management platform. gridX provides a cloud-based platform for monitoring and managing energy systems including solar, batteries, EV chargers, heat pumps, and smart heaters. - Config flow with OEM + username/password authentication - Live data coordinator (solar, consumption, grid, battery, EV, heatpump, heater) - Historical data coordinator (self-sufficiency, autarchy) - 40+ sensors across all categories - Diagnostics support - Async implementation with injectable httpx session (Platinum quality scale) - Full test coverage (20 tests) Depends on gridx-connector==3.0.0 (PyPI)
- Updated manifest.json for better formatting and clarity. - Refactored sensor.py to enhance type hints and improve value extraction logic. - Cleaned up string.json for consistency and added error handling messages. - Adjusted types.py to import AsyncGridboxConnector from the correct module. - Updated requirements files to specify gridx-connector version 3.0.0. - Enhanced test files for better readability and consistency in type hints. - Improved diagnostics and sensor tests for better coverage and clarity.
Version 3.0.2 fixes the missing gridx_connector_api module in the PyPI package (the 3.0.1 wheel omitted it due to a CI build issue).
- coordinator: use dt_util.start_of_local_day() instead of manual datetime; raise UpdateFailed for non-auth HTTP errors, ConfigEntryAuthFailed for 401/403 - __init__: raise ConfigEntryAuthFailed for auth errors; close httpx_client on all error paths during connector creation - config_flow: close httpx_client on connector creation failure; reauth only asks for password (username locked to existing entry); reconfigure updates unique_id when username changes - types: type connector as GridxConnector instead of Any - sensor: remove SensorDeviceClass.POWER_FACTOR from percentage rate sensors; use entry.unique_id for stable entity/device identifiers - tests: add unique_id to MockConfigEntry fixtures; update entity lookups to use entry.unique_id; fix reauth tests to only submit password
- client: add load_oem_config() as shared public helper for OEM config injection - __init__: use load_oem_config from client, remove duplicated private function - config_flow: use load_oem_config from client, remove duplicated private function - test_init: add setup error path tests (PermissionError, HTTPStatus 401/500, HTTPError, RuntimeError) with proper ignore_missing_translations markers - test_coordinator: add HTTPStatusError/HTTPError/RuntimeError coverage for both live and historical coordinators (8 new tests) - test_config_flow: add Generator return type; add httpx error / unexpected error / reauth http error tests (5 new tests) - test_sensor: fix battery docstring; add value_fn TypeError/ValueError and last_reset edge case tests (4 new tests)
…nd inject-websession Upstream hassfest now requires a comment when using dict notation (status: done). Switch to shorthand notation to avoid the validation error.
- __init__: close connector if coordinator first_refresh fails to avoid leaking the httpx client when setup errors occur after connector creation - config_flow: update unique_id and title in async_update_reload_and_abort during reconfigure so username changes are fully persisted on the entry - conftest: fix Generator return type (UP043: remove redundant None defaults) - test_config_flow: fix Generator return type; add test verifying that reconfigure with username change updates unique_id and title
- config_flow: introduce _NoSystemsFoundError and a no_systems error key so users with valid credentials but no assigned GridBox get a clear message instead of the misleading 'cannot_connect' error; handled in all three flow steps (user, reauth, reconfigure) - strings.json: add no_systems error string - test_sensor: assert last_reset attribute is None in both last_reset edge-case tests to verify the actual property behaviour
The config_entry_reauth translations now exist in upstream, so the ignore_missing_translations fixtures in test_setup_permission_error and test_setup_http_status_401 are no longer needed and were causing test teardown errors.
dc26e5c to
1d2ed13
Compare
| if not results: | ||
| raise UpdateFailed("GridX returned no live data") | ||
| return results[0] |
There was a problem hiding this comment.
Handle multiple returned systems instead of always selecting results[0], e.g., by creating one device/coordinator per system (with a stable system identifier) or explicitly validating that only one system is present and raising an error otherwise.
| if not results: | ||
| raise UpdateFailed("GridX returned no historical data") | ||
|
|
||
| total = results[0].get("total", {}) |
There was a problem hiding this comment.
Handle multiple returned systems instead of always selecting results[0], otherwise accounts with more than one system will only ever expose the first system’s historical totals.
| total = results[0].get("total", {}) | |
| total: dict[str, Any] = {} | |
| for result in results: | |
| result_total = result.get("total", {}) | |
| if not isinstance(result_total, dict): | |
| continue | |
| for key, value in result_total.items(): | |
| current_value = total.get(key) | |
| if isinstance(value, int | float) and isinstance(current_value, int | float): | |
| total[key] = current_value + value | |
| elif isinstance(value, int | float): | |
| total[key] = value | |
| elif key not in total: | |
| total[key] = value |
| native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, | ||
| device_class=SensorDeviceClass.ENERGY, | ||
| state_class=SensorStateClass.TOTAL_INCREASING, | ||
| value_fn=lambda d: ( | ||
| round(d["gridMeterReadingPositive"] / 3600, 2) | ||
| if d.get("gridMeterReadingPositive") is not None | ||
| else None | ||
| ), |
There was a problem hiding this comment.
Avoid rounding cumulative energy readings before storing state, since rounding can introduce apparent non-monotonic values and reduce accuracy for TOTAL_INCREASING sensors; prefer returning the full-precision conversion and control display via suggested precision instead.
| native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, | ||
| device_class=SensorDeviceClass.ENERGY, | ||
| state_class=SensorStateClass.TOTAL_INCREASING, | ||
| value_fn=lambda d: ( | ||
| round(d["gridMeterReadingNegative"] / 3600, 2) | ||
| if d.get("gridMeterReadingNegative") is not None | ||
| else None | ||
| ), |
There was a problem hiding this comment.
Avoid rounding cumulative energy readings before storing state, since rounding can introduce apparent non-monotonic values and reduce accuracy for TOTAL_INCREASING sensors; prefer returning the full-precision conversion and control display via suggested precision instead.
…historical totals - sensor.py: remove round() from gridMeterReadingPositive/Negative value_fn and add suggested_display_precision=2 to avoid non-monotonic TOTAL_INCREASING state values - coordinator.py: aggregate _fetch_historical totals across all returned systems instead of always using results[0]
| async def async_step_reauth( | ||
| self, entry_data: Mapping[str, Any] | ||
| ) -> ConfigFlowResult: | ||
| """Handle re-authentication.""" | ||
| return await self.async_step_reauth_confirm() | ||
|
|
There was a problem hiding this comment.
Rename the unused entry_data parameter (e.g., to _ or _entry_data) to avoid Ruff's unused-argument violation.
| self._attr_unique_id = f"{entry.unique_id}_{description.key}" | ||
| self._attr_device_info = DeviceInfo( | ||
| identifiers={(DOMAIN, str(entry.unique_id))}, | ||
| name="GridX GridBox", | ||
| manufacturer="gridX / Viessmann", |
There was a problem hiding this comment.
Base entity unique_ids/device identifiers on a stable value (e.g., entry.entry_id or an immutable system/device id) so a username change during reconfigure doesn’t create a whole new set of entities/devices in the registry.
| self._attr_unique_id = f"{entry.unique_id}_{description.key}" | ||
| self._attr_device_info = DeviceInfo( | ||
| identifiers={(DOMAIN, str(entry.unique_id))}, | ||
| name="GridX GridBox", | ||
| manufacturer="gridX / Viessmann", |
There was a problem hiding this comment.
Apply the same stable-id approach for historical sensors as well; otherwise reconfiguring the username will orphan the existing historical entities and create new ones.
| async def _fetch_historical(connector: GridxConnector) -> GridxHistoricalData: | ||
| """Fetch today's historical totals.""" | ||
| midnight = dt_util.start_of_local_day() | ||
| tomorrow = midnight + timedelta(days=1) |
There was a problem hiding this comment.
Compute the end timestamp for the historical query using a calendar-day boundary (e.g., next local midnight via start_of_local_day) instead of midnight + timedelta(days=1) to avoid DST off-by-one-hour ranges.
| tomorrow = midnight + timedelta(days=1) | |
| tomorrow = dt_util.start_of_local_day(dt_util.now() + timedelta(days=1)) |
| from homeassistant.const import CONF_PASSWORD | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .types import GridxConfigEntry | ||
|
|
||
| TO_REDACT: set[str] = {CONF_PASSWORD} |
There was a problem hiding this comment.
Redact the configured username (likely an email address) in diagnostics output in addition to the password to avoid leaking PII in support bundles.
| from homeassistant.const import CONF_PASSWORD | |
| from homeassistant.core import HomeAssistant | |
| from .types import GridxConfigEntry | |
| TO_REDACT: set[str] = {CONF_PASSWORD} | |
| from homeassistant.const import CONF_PASSWORD, CONF_USERNAME | |
| from homeassistant.core import HomeAssistant | |
| from .types import GridxConfigEntry | |
| TO_REDACT: set[str] = {CONF_PASSWORD, CONF_USERNAME} |
| new_unique_id = new_username.lower() | ||
| if new_unique_id != entry.unique_id: | ||
| await self.async_set_unique_id(new_unique_id) | ||
| self._abort_if_unique_id_configured() | ||
| return self.async_update_reload_and_abort( | ||
| entry, | ||
| unique_id=new_unique_id, |
There was a problem hiding this comment.
Avoid changing the config entry unique_id on reconfigure (or migrate entity unique_ids accordingly), since entity unique_ids are derived from entry.unique_id and a change will create new entities and orphan existing ones.
| new_unique_id = new_username.lower() | |
| if new_unique_id != entry.unique_id: | |
| await self.async_set_unique_id(new_unique_id) | |
| self._abort_if_unique_id_configured() | |
| return self.async_update_reload_and_abort( | |
| entry, | |
| unique_id=new_unique_id, | |
| return self.async_update_reload_and_abort( | |
| entry, |
| try: | ||
| await _validate_credentials(self.hass, oem, username, password) | ||
| except PermissionError: | ||
| errors["base"] = "invalid_auth" | ||
| except httpx.HTTPStatusError as err: | ||
| status = err.response.status_code if err.response else None | ||
| errors["base"] = ( | ||
| "invalid_auth" if status in (401, 403) else "cannot_connect" | ||
| ) | ||
| except httpx.HTTPError: | ||
| errors["base"] = "cannot_connect" | ||
| except ConnectionError, TimeoutError, OSError: | ||
| errors["base"] = "cannot_connect" | ||
| except _NoSystemsFoundError: | ||
| errors["base"] = "no_systems" | ||
| except UNEXPECTED_AUTH_ERRORS: | ||
| LOGGER.exception("Unexpected error during GridX credential validation") | ||
| errors["base"] = "unknown" | ||
| else: |
There was a problem hiding this comment.
Factor the repeated credential-validation exception-to-error mapping into a shared helper to prevent the three steps from drifting and to simplify future changes.
Breaking change
Proposed change
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: