Skip to content

Commit 1be2e4f

Browse files
Add anglian_water integration (home-assistant#156225)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 839f647 commit 1be2e4f

File tree

20 files changed

+1098
-0
lines changed

20 files changed

+1098
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""The Anglian Water integration."""
2+
3+
from __future__ import annotations
4+
5+
from pyanglianwater import AnglianWater
6+
from pyanglianwater.auth import MSOB2CAuth
7+
from pyanglianwater.exceptions import (
8+
ExpiredAccessTokenError,
9+
SelfAssertedError,
10+
SmartMeterUnavailableError,
11+
)
12+
13+
from homeassistant.const import (
14+
CONF_ACCESS_TOKEN,
15+
CONF_PASSWORD,
16+
CONF_USERNAME,
17+
Platform,
18+
)
19+
from homeassistant.core import HomeAssistant
20+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
21+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
22+
23+
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
24+
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
25+
26+
_PLATFORMS: list[Platform] = [Platform.SENSOR]
27+
28+
29+
async def async_setup_entry(
30+
hass: HomeAssistant, entry: AnglianWaterConfigEntry
31+
) -> bool:
32+
"""Set up Anglian Water from a config entry."""
33+
auth = MSOB2CAuth(
34+
username=entry.data[CONF_USERNAME],
35+
password=entry.data[CONF_PASSWORD],
36+
session=async_get_clientsession(hass),
37+
refresh_token=entry.data[CONF_ACCESS_TOKEN],
38+
account_number=entry.data[CONF_ACCOUNT_NUMBER],
39+
)
40+
try:
41+
await auth.send_refresh_request()
42+
except (ExpiredAccessTokenError, SelfAssertedError) as err:
43+
raise ConfigEntryAuthFailed from err
44+
45+
_aw = AnglianWater(authenticator=auth)
46+
47+
try:
48+
await _aw.validate_smart_meter()
49+
except SmartMeterUnavailableError as err:
50+
raise ConfigEntryError(
51+
translation_domain=DOMAIN, translation_key="smart_meter_unavailable"
52+
) from err
53+
54+
hass.config_entries.async_update_entry(
55+
entry, data={**entry.data, CONF_ACCESS_TOKEN: auth.refresh_token}
56+
)
57+
entry.runtime_data = coordinator = AnglianWaterUpdateCoordinator(
58+
hass=hass, api=_aw, config_entry=entry
59+
)
60+
61+
await coordinator.async_config_entry_first_refresh()
62+
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
63+
return True
64+
65+
66+
async def async_unload_entry(
67+
hass: HomeAssistant, entry: AnglianWaterConfigEntry
68+
) -> bool:
69+
"""Unload a config entry."""
70+
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Config flow for the Anglian Water integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from aiohttp import CookieJar
9+
from pyanglianwater import AnglianWater
10+
from pyanglianwater.auth import BaseAuth, MSOB2CAuth
11+
from pyanglianwater.exceptions import (
12+
InvalidAccountIdError,
13+
SelfAssertedError,
14+
SmartMeterUnavailableError,
15+
)
16+
import voluptuous as vol
17+
18+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
19+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME
20+
from homeassistant.helpers import selector
21+
from homeassistant.helpers.aiohttp_client import async_create_clientsession
22+
23+
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
24+
25+
_LOGGER = logging.getLogger(__name__)
26+
27+
STEP_USER_DATA_SCHEMA = vol.Schema(
28+
{
29+
vol.Required(CONF_USERNAME): selector.TextSelector(),
30+
vol.Required(CONF_PASSWORD): selector.TextSelector(
31+
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
32+
),
33+
}
34+
)
35+
36+
37+
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
38+
"""Validate the provided credentials."""
39+
try:
40+
await auth.send_login_request()
41+
except SelfAssertedError:
42+
return "invalid_auth"
43+
except Exception:
44+
_LOGGER.exception("Unexpected exception")
45+
return "unknown"
46+
_aw = AnglianWater(authenticator=auth)
47+
try:
48+
await _aw.validate_smart_meter()
49+
except (InvalidAccountIdError, SmartMeterUnavailableError):
50+
return "smart_meter_unavailable"
51+
return auth
52+
53+
54+
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
55+
"""Handle a config flow for Anglian Water."""
56+
57+
async def async_step_user(
58+
self, user_input: dict[str, Any] | None = None
59+
) -> ConfigFlowResult:
60+
"""Handle the initial step."""
61+
errors: dict[str, str] = {}
62+
if user_input is not None:
63+
validation_response = await validate_credentials(
64+
MSOB2CAuth(
65+
username=user_input[CONF_USERNAME],
66+
password=user_input[CONF_PASSWORD],
67+
session=async_create_clientsession(
68+
self.hass,
69+
cookie_jar=CookieJar(quote_cookie=False),
70+
),
71+
account_number=user_input.get(CONF_ACCOUNT_NUMBER),
72+
)
73+
)
74+
if isinstance(validation_response, BaseAuth):
75+
account_number = (
76+
user_input.get(CONF_ACCOUNT_NUMBER)
77+
or validation_response.account_number
78+
)
79+
await self.async_set_unique_id(account_number)
80+
self._abort_if_unique_id_configured()
81+
return self.async_create_entry(
82+
title=account_number,
83+
data={
84+
**user_input,
85+
CONF_ACCESS_TOKEN: validation_response.refresh_token,
86+
CONF_ACCOUNT_NUMBER: account_number,
87+
},
88+
)
89+
if validation_response == "smart_meter_unavailable":
90+
return self.async_show_form(
91+
step_id="user",
92+
data_schema=STEP_USER_DATA_SCHEMA.extend(
93+
{
94+
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
95+
}
96+
),
97+
errors={"base": validation_response},
98+
)
99+
errors["base"] = validation_response
100+
101+
return self.async_show_form(
102+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
103+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants for the Anglian Water integration."""
2+
3+
DOMAIN = "anglian_water"
4+
CONF_ACCOUNT_NUMBER = "account_number"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Anglian Water data coordinator."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
8+
from pyanglianwater import AnglianWater
9+
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
10+
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
14+
15+
from .const import DOMAIN
16+
17+
type AnglianWaterConfigEntry = ConfigEntry[AnglianWaterUpdateCoordinator]
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
UPDATE_INTERVAL = timedelta(minutes=60)
21+
22+
23+
class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
24+
"""Anglian Water data update coordinator."""
25+
26+
config_entry: AnglianWaterConfigEntry
27+
28+
def __init__(
29+
self,
30+
hass: HomeAssistant,
31+
api: AnglianWater,
32+
config_entry: AnglianWaterConfigEntry,
33+
) -> None:
34+
"""Initialize update coordinator."""
35+
super().__init__(
36+
hass=hass,
37+
logger=_LOGGER,
38+
name=DOMAIN,
39+
update_interval=UPDATE_INTERVAL,
40+
config_entry=config_entry,
41+
)
42+
self.api = api
43+
44+
async def _async_update_data(self) -> None:
45+
"""Update data from Anglian Water's API."""
46+
try:
47+
return await self.api.update()
48+
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
49+
raise UpdateFailed from err
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Anglian Water entity."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from pyanglianwater.meter import SmartMeter
8+
9+
from homeassistant.helpers.device_registry import DeviceInfo
10+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
11+
12+
from .const import DOMAIN
13+
from .coordinator import AnglianWaterUpdateCoordinator
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
18+
class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]):
19+
"""Defines a Anglian Water entity."""
20+
21+
def __init__(
22+
self,
23+
coordinator: AnglianWaterUpdateCoordinator,
24+
smart_meter: SmartMeter,
25+
) -> None:
26+
"""Initialize Anglian Water entity."""
27+
super().__init__(coordinator)
28+
self.smart_meter = smart_meter
29+
self._attr_device_info = DeviceInfo(
30+
identifiers={(DOMAIN, smart_meter.serial_number)},
31+
name="Smart Water Meter",
32+
manufacturer="Anglian Water",
33+
serial_number=smart_meter.serial_number,
34+
)
35+
36+
async def async_added_to_hass(self) -> None:
37+
"""When entity is loaded."""
38+
self.coordinator.api.updated_data_callbacks.append(self.async_write_ha_state)
39+
await super().async_added_to_hass()
40+
41+
async def async_will_remove_from_hass(self) -> None:
42+
"""When will be removed from HASS."""
43+
self.coordinator.api.updated_data_callbacks.remove(self.async_write_ha_state)
44+
await super().async_will_remove_from_hass()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"domain": "anglian_water",
3+
"name": "Anglian Water",
4+
"codeowners": ["@pantherale0"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
7+
"iot_class": "cloud_polling",
8+
"quality_scale": "bronze",
9+
"requirements": ["pyanglianwater==2.1.0"]
10+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
rules:
2+
# Bronze
3+
action-setup:
4+
status: exempt
5+
comment: |
6+
No custom actions are defined.
7+
appropriate-polling: done
8+
brands: done
9+
common-modules: done
10+
config-flow-test-coverage: done
11+
config-flow: done
12+
dependency-transparency: done
13+
docs-actions:
14+
status: exempt
15+
comment: |
16+
No custom actions are defined.
17+
docs-high-level-description: done
18+
docs-installation-instructions: done
19+
docs-removal-instructions: done
20+
entity-event-setup: done
21+
entity-unique-id: done
22+
has-entity-name: done
23+
runtime-data: done
24+
test-before-configure: done
25+
test-before-setup: done
26+
unique-config-entry: done
27+
28+
# Silver
29+
action-exceptions:
30+
status: exempt
31+
comment: |
32+
No custom actions are defined.
33+
config-entry-unloading: done
34+
docs-configuration-parameters: done
35+
docs-installation-parameters: done
36+
entity-unavailable: done
37+
integration-owner: done
38+
log-when-unavailable: done
39+
parallel-updates: done
40+
reauthentication-flow: todo
41+
test-coverage: todo
42+
43+
# Gold
44+
devices: done
45+
diagnostics: todo
46+
discovery-update-info:
47+
status: exempt
48+
comment: |
49+
Unable to discover meters.
50+
discovery:
51+
status: exempt
52+
comment: |
53+
Unable to discover meters.
54+
docs-data-update: done
55+
docs-examples: todo
56+
docs-known-limitations: done
57+
docs-supported-devices: done
58+
docs-supported-functions: done
59+
docs-troubleshooting: done
60+
docs-use-cases: todo
61+
dynamic-devices: todo
62+
entity-category: done
63+
entity-device-class: done
64+
entity-disabled-by-default:
65+
status: exempt
66+
comment: |
67+
No entities are disabled by default.
68+
entity-translations: done
69+
exception-translations: done
70+
icon-translations:
71+
status: exempt
72+
comment: |
73+
Entities do not require different icons.
74+
reconfiguration-flow: todo
75+
repair-issues:
76+
status: exempt
77+
comment: |
78+
Read-only integration and no repairs are possible.
79+
stale-devices: todo
80+
# Platinum
81+
async-dependency: done
82+
inject-websession: done
83+
strict-typing: todo

0 commit comments

Comments
 (0)