Add config flow to Suez water (#104730)

* Add config flow to Suez water

* fix tests

* Complete coverage

* Change version to 2024.7

* Fix final test

* Add issue when import is successful

* Move hassdata

* Do unique_id

* Remove import issue when entry already exists

* Remove import issue when entry already exists
pull/105508/head
Joost Lekkerkerker 2023-12-11 22:06:16 +01:00 committed by GitHub
parent e890671192
commit a187a39f0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 519 additions and 18 deletions

View File

@ -1233,7 +1233,8 @@ omit =
homeassistant/components/stream/hls.py
homeassistant/components/stream/worker.py
homeassistant/components/streamlabswater/*
homeassistant/components/suez_water/*
homeassistant/components/suez_water/__init__.py
homeassistant/components/suez_water/sensor.py
homeassistant/components/supervisord/sensor.py
homeassistant/components/supla/*
homeassistant/components/surepetcare/__init__.py

View File

@ -1251,6 +1251,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/subaru/ @G-Two
/tests/components/subaru/ @G-Two
/homeassistant/components/suez_water/ @ooii
/tests/components/suez_water/ @ooii
/homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig
/homeassistant/components/sunweg/ @rokam

View File

@ -1 +1,48 @@
"""France Suez Water integration."""
"""The Suez Water integration."""
from __future__ import annotations
from pysuez import SuezClient
from pysuez.client import PySuezError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from .const import CONF_COUNTER_ID, DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Suez Water from a config entry."""
def get_client() -> SuezClient:
try:
client = SuezClient(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry.data[CONF_COUNTER_ID],
provider=None,
)
if not client.check_credentials():
raise ConfigEntryError
return client
except PySuezError:
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = await hass.async_add_executor_job(get_client)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,166 @@
"""Config flow for Suez Water integration."""
from __future__ import annotations
import logging
from typing import Any
from pysuez import SuezClient
from pysuez.client import PySuezError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import CONF_COUNTER_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"}
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_COUNTER_ID): str,
}
)
def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
try:
client = SuezClient(
data[CONF_USERNAME],
data[CONF_PASSWORD],
data[CONF_COUNTER_ID],
provider=None,
)
if not client.check_credentials():
raise InvalidAuth
except PySuezError:
raise CannotConnect
class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Suez Water."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured()
try:
await self.hass.async_add_executor_job(validate_input, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
"""Import the yaml config."""
await self.async_set_unique_id(user_input[CONF_USERNAME])
try:
self._abort_if_unique_id_configured()
except AbortFlow as err:
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Suez Water",
},
)
raise err
try:
await self.hass.async_add_executor_job(validate_input, user_input)
except CannotConnect:
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_cannot_connect",
breaks_in_ha_version="2024.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_cannot_connect",
translation_placeholders=ISSUE_PLACEHOLDER,
)
return self.async_abort(reason="cannot_connect")
except InvalidAuth:
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_invalid_auth",
breaks_in_ha_version="2024.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_invalid_auth",
translation_placeholders=ISSUE_PLACEHOLDER,
)
return self.async_abort(reason="invalid_auth")
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_unknown",
breaks_in_ha_version="2024.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_unknown",
translation_placeholders=ISSUE_PLACEHOLDER,
)
return self.async_abort(reason="unknown")
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Suez Water",
},
)
return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,5 @@
"""Constants for the Suez Water integration."""
DOMAIN = "suez_water"
CONF_COUNTER_ID = "counter_id"

View File

@ -2,6 +2,7 @@
"domain": "suez_water",
"name": "Suez Water",
"codeowners": ["@ooii"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/suez_water",
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],

View File

@ -13,18 +13,19 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_COUNTER_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(hours=12)
CONF_COUNTER_ID = "counter_id"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_USERNAME): cv.string,
@ -41,21 +42,23 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the sensor platform."""
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
counter_id = config[CONF_COUNTER_ID]
try:
client = SuezClient(username, password, counter_id, provider=None)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
)
if not client.check_credentials():
_LOGGER.warning("Wrong username and/or password")
return
except PySuezError:
_LOGGER.warning("Unable to create Suez Client")
return
add_entities([SuezSensor(client)], True)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Suez Water sensor from a config entry."""
client = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SuezSensor(client)], True)
class SuezSensor(SensorEntity):

View File

@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"counter_id": "Counter id"
}
}
},
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml_import_issue_invalid_auth": {
"title": "The Suez water YAML configuration import failed",
"description": "Configuring Suez water using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The Suez water YAML configuration import failed",
"description": "Configuring Suez water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Suez water works and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_unknown": {
"title": "The Suez water YAML configuration import failed",
"description": "Configuring Suez water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}

View File

@ -471,6 +471,7 @@ FLOWS = {
"stookalert",
"stookwijzer",
"subaru",
"suez_water",
"sun",
"sunweg",
"surepetcare",

View File

@ -5537,7 +5537,7 @@
"suez_water": {
"name": "Suez Water",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"sun": {

View File

@ -1602,6 +1602,9 @@ pyspcwebgw==0.7.0
# homeassistant.components.squeezebox
pysqueezebox==0.7.1
# homeassistant.components.suez_water
pysuez==0.2.0
# homeassistant.components.switchbee
pyswitchbee==1.8.0

View File

@ -0,0 +1 @@
"""Tests for the Suez Water integration."""

View File

@ -0,0 +1,14 @@
"""Common fixtures for the Suez Water tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.suez_water.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,223 @@
"""Test the Suez Water config flow."""
from unittest.mock import AsyncMock, patch
from pysuez.client import PySuezError
import pytest
from homeassistant import config_entries
from homeassistant.components.suez_water.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import issue_registry as ir
from tests.common import MockConfigEntry
MOCK_DATA = {
"username": "test-username",
"password": "test-password",
"counter_id": "test-counter",
}
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
with patch("homeassistant.components.suez_water.config_flow.SuezClient"):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["result"].unique_id == "test-username"
assert result["data"] == MOCK_DATA
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.suez_water.config_flow.SuezClient.__init__",
return_value=None,
), patch(
"homeassistant.components.suez_water.config_flow.SuezClient.check_credentials",
return_value=False,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
with patch("homeassistant.components.suez_water.config_flow.SuezClient"):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["result"].unique_id == "test-username"
assert result["data"] == MOCK_DATA
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_already_configured(hass: HomeAssistant) -> None:
"""Test we abort when entry is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test-username",
data=MOCK_DATA,
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")]
)
async def test_form_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str
) -> None:
"""Test we handle errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.suez_water.config_flow.SuezClient",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": error}
with patch(
"homeassistant.components.suez_water.config_flow.SuezClient",
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_DATA,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["data"] == MOCK_DATA
assert len(mock_setup_entry.mock_calls) == 1
async def test_import(
hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry
) -> None:
"""Test import flow."""
with patch("homeassistant.components.suez_water.config_flow.SuezClient"):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["result"].unique_id == "test-username"
assert result["data"] == MOCK_DATA
assert len(mock_setup_entry.mock_calls) == 1
assert len(issue_registry.issues) == 1
@pytest.mark.parametrize(
("exception", "reason"), [(PySuezError, "cannot_connect"), (Exception, "unknown")]
)
async def test_import_error(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
exception: Exception,
reason: str,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test we handle errors while importing."""
with patch(
"homeassistant.components.suez_water.config_flow.SuezClient",
side_effect=exception,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == reason
assert len(issue_registry.issues) == 1
async def test_importing_invalid_auth(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test we handle invalid auth when importing."""
with patch(
"homeassistant.components.suez_water.config_flow.SuezClient.__init__",
return_value=None,
), patch(
"homeassistant.components.suez_water.config_flow.SuezClient.check_credentials",
return_value=False,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "invalid_auth"
assert len(issue_registry.issues) == 1
async def test_import_already_configured(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test we abort import when entry is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test-username",
data=MOCK_DATA,
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(issue_registry.issues) == 1