Add PECO power outage counter integration (#65194)

* Create a new NWS Alerts integration

* Create a new NWS Alerts integration

* Create new PECO integration

* Remove empty keys

* Revert "Create a new NWS Alerts integration"

This reverts commit 38309c5a878d78f26df56a62e56cb602d9dc9a9e.

* Revert "Create a new NWS Alerts integration"

This reverts commit aeabdd37b86c370bdb8009e885806bdac6e464d8.

* Fix test with new mock data

* Add init and sensor to .coveragerc and more tests for config flow

* Small fixes and replacing patch with pytest.raises in testing invalid county

* Add type defs and fix test_config_flow to use MultipleValid instead

* Fix  issues with 'typing.Dict'

* Move API communication to seperate PyPI library

* Switch PyPI library from httpx to aiohttp to allow for passing in websessions

* Commit file changes requested by farmio as listed here: d267e4300a

* Add suggestions requested by farmio as listed here: 586d8ffa42

* Move native_unit_of_measurement from prop to attr

* Update PLATFORMS constant type annotation

Co-authored-by: Matthias Alphart <farmio@alphart.net>

* Add peco to .strict-typing

I am from school so I can't run mypy atm

* Forgot to import Final

* Do as requested [here](https://github.com/home-assistant/core/runs/5070634928?check_suite_focus=true)

* Updated mypy.ini, checks should pass now

* Fix to conform to mypy restrictions https://github.com/home-assistant/core/runs/5072861837\?check_suite_focus\=true

* Fix type annotations

* Fix tests

* Use cast in async_update_data

* Add data type to CoordinatorEntity and DataUpdateCoordinator

* More cleanup from suggestions here: https://github.com/home-assistant/core/pull/65194\#pullrequestreview-908183493

* Fix tests for new code

* Cleaning up a speck of dust

* Remove unused variable from the peco sensor

* Add rounding to percentage, and extra clean-up

* Final suggestions from @farmio

* Update SCAN_INTERVAL to be a little bit faster

* Change the SCAN_INTERVAL to be somewhat near the update interval of the outage map, as noted by farmio

* New UpdateCoordinator typing
This commit is contained in:
IceBotYT 2022-03-21 18:56:53 -04:00 committed by GitHub
parent 247af2e74f
commit a43505a0a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 622 additions and 0 deletions

View File

@ -151,6 +151,7 @@ homeassistant.components.oncue.*
homeassistant.components.onewire.*
homeassistant.components.open_meteo.*
homeassistant.components.openuv.*
homeassistant.components.peco.*
homeassistant.components.overkiz.*
homeassistant.components.persistent_notification.*
homeassistant.components.pi_hole.*

View File

@ -741,6 +741,8 @@ homeassistant/components/panel_custom/* @home-assistant/frontend
tests/components/panel_custom/* @home-assistant/frontend
homeassistant/components/panel_iframe/* @home-assistant/frontend
tests/components/panel_iframe/* @home-assistant/frontend
homeassistant/components/peco/* @IceBotYT
tests/components/peco/* @IceBotYT
homeassistant/components/persistent_notification/* @home-assistant/core
tests/components/persistent_notification/* @home-assistant/core
homeassistant/components/philips_js/* @elupus

View File

@ -0,0 +1,31 @@
"""The PECO Outage Counter integration."""
from __future__ import annotations
from typing import Final
import peco
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
PLATFORMS: Final = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up PECO Outage Counter from a config entry."""
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = peco.PecoOutageApi()
hass.config_entries.async_setup_platforms(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,43 @@
"""Config flow for PECO Outage Counter integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST),
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PECO Outage Counter."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
county = user_input[
CONF_COUNTY
] # Voluptuous automatically detects if the county is invalid
await self.async_set_unique_id(county)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{county.capitalize()} Outage Count", data=user_input
)

View File

@ -0,0 +1,17 @@
"""Constants for the PECO Outage Counter integration."""
import logging
from typing import Final
DOMAIN: Final = "peco"
LOGGER: Final = logging.getLogger(__package__)
COUNTY_LIST: Final = [
"BUCKS",
"CHESTER",
"DELAWARE",
"MONTGOMERY",
"PHILADELPHIA",
"YORK",
"TOTAL",
]
SCAN_INTERVAL: Final = 9
CONF_COUNTY: Final = "county"

View File

@ -0,0 +1,13 @@
{
"domain": "peco",
"name": "PECO Outage Counter",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/peco",
"codeowners": [
"@IceBotYT"
],
"iot_class": "cloud_polling",
"requirements": [
"peco==0.0.21"
]
}

View File

@ -0,0 +1,113 @@
"""Sensor component for PECO outage counter."""
import asyncio
from datetime import timedelta
from typing import Final, cast
from peco import BadJSONError, HttpError
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL
PARALLEL_UPDATES: Final = 0
SENSOR_LIST = (
SensorEntityDescription(key="customers_out", name="Customers Out"),
SensorEntityDescription(
key="percent_customers_out",
name="Percent Customers Out",
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(key="outage_count", name="Outage Count"),
SensorEntityDescription(key="customers_served", name="Customers Served"),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
api = hass.data[DOMAIN][config_entry.entry_id]
websession = async_get_clientsession(hass)
county: str = config_entry.data[CONF_COUNTY]
async def async_update_data() -> dict[str, float]:
"""Fetch data from API."""
try:
data = (
cast(dict[str, float], await api.get_outage_totals(websession))
if county == "TOTAL"
else cast(
dict[str, float],
await api.get_outage_count(county, websession),
)
)
except HttpError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
except BadJSONError as err:
raise UpdateFailed(f"Error parsing data: {err}") from err
except asyncio.TimeoutError as err:
raise UpdateFailed(f"Timeout fetching data: {err}") from err
if data["percent_customers_out"] < 5:
percent_out = round(
data["customers_out"] / data["customers_served"] * 100, 3
)
data["percent_customers_out"] = percent_out
return data
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="PECO Outage Count",
update_method=async_update_data,
update_interval=timedelta(minutes=SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
async_add_entities(
[PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST],
True,
)
return
class PecoSensor(
CoordinatorEntity[DataUpdateCoordinator[dict[str, float]]], SensorEntity
):
"""PECO outage counter sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon: str = "mdi:power-plug-off"
def __init__(
self,
description: SensorEntityDescription,
county: str,
coordinator: DataUpdateCoordinator[dict[str, float]],
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_name = f"{county.capitalize()} {description.name}"
self._attr_unique_id = f"{county}-{description.key}"
self.entity_description = description
@property
def native_value(self) -> float:
"""Return the value of the sensor."""
return self.coordinator.data[self.entity_description.key]

View File

@ -0,0 +1,16 @@
{
"config": {
"step": {
"user": {
"title": "PECO Outage Counter",
"description": "Please choose your county below.",
"data": {
"county": "County"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
}
}

View File

@ -0,0 +1,16 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured"
},
"step": {
"user": {
"data": {
"county": "County"
},
"description": "Please choose your county below.",
"title": "PECO Outage Counter"
}
}
}
}

View File

@ -249,6 +249,7 @@ FLOWS = {
"owntracks",
"p1_monitor",
"panasonic_viera",
"peco",
"philips_js",
"pi_hole",
"picnic",

View File

@ -1463,6 +1463,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.peco.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.overkiz.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -1177,6 +1177,9 @@ panasonic_viera==0.3.6
# homeassistant.components.dunehd
pdunehd==1.3.2
# homeassistant.components.peco
peco==0.0.21
# homeassistant.components.pencom
pencompy==0.0.3

View File

@ -768,6 +768,9 @@ panasonic_viera==0.3.6
# homeassistant.components.dunehd
pdunehd==1.3.2
# homeassistant.components.peco
peco==0.0.21
# homeassistant.components.aruba
# homeassistant.components.cisco_ios
# homeassistant.components.pandora

View File

@ -0,0 +1 @@
"""Tests for the PECO Outage Counter integration."""

View File

@ -0,0 +1,57 @@
"""Test the PECO Outage Counter config flow."""
from unittest.mock import patch
from pytest import raises
from voluptuous.error import MultipleInvalid
from homeassistant import config_entries
from homeassistant.components.peco.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with patch(
"homeassistant.components.peco.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "Philadelphia Outage Count"
assert result2["data"] == {
"county": "PHILADELPHIA",
}
async def test_invalid_county(hass: HomeAssistant) -> None:
"""Test if the InvalidCounty error works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with raises(MultipleInvalid):
await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST",
},
)
await hass.async_block_till_done()
# it should have errored, instead of returning an errors dict, since this error should never happen

View File

@ -0,0 +1,38 @@
"""Test the PECO Outage Counter init file."""
from peco import PecoOutageApi
from homeassistant.components.peco.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_ENTRY_DATA = {"county": "TOTAL"}
INVALID_COUNTY_DATA = {"county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", "test": True}
async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test the unload entry."""
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert hass.data[DOMAIN]
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state == ConfigEntryState.LOADED
await hass.config_entries.async_unload(entries[0].entry_id)
await hass.async_block_till_done()
assert entries[0].state == ConfigEntryState.NOT_LOADED
async def test_data(hass: HomeAssistant) -> None:
"""Test the data."""
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert hass.data[DOMAIN]
assert isinstance(hass.data[DOMAIN][config_entry.entry_id], PecoOutageApi)

View File

@ -0,0 +1,256 @@
"""Test the PECO Outage Counter sensors."""
import asyncio
from homeassistant.components.peco.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
MOCK_ENTRY_DATA = {"county": "TOTAL"}
COUNTY_ENTRY_DATA = {"county": "BUCKS"}
INVALID_COUNTY_DATA = {"county": "INVALID"}
async def test_sensor_available(
aioclient_mock: AiohttpClientMocker, hass: HomeAssistant
) -> None:
"""Test that the sensors are working."""
# Totals Test
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {"interval_generation_data": "data/TEST"}},
)
aioclient_mock.get(
"https://kubra.io/data/TEST/public/reports/a36a6292-1c55-44de-a6a9-44fedf9482ee_report.json",
json={
"file_data": {
"totals": {
"cust_a": {
"val": 123,
},
"percent_cust_a": {
"val": 1.23,
},
"n_out": 456,
"cust_s": 789,
}
}
},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert config_entry.state == ConfigEntryState.LOADED
sensors_to_get = [
"total_customers_out",
"total_percent_customers_out",
"total_outage_count",
"total_customers_served",
]
for sensor in sensors_to_get:
sensor_entity = hass.states.get(f"sensor.{sensor}")
assert sensor_entity is not None
assert sensor_entity.state != "unavailable"
if sensor == "total_customers_out":
assert sensor_entity.state == "123"
elif sensor == "total_percent_customers_out":
assert sensor_entity.state == "15.589"
elif sensor == "total_outage_count":
assert sensor_entity.state == "456"
elif sensor == "total_customers_served":
assert sensor_entity.state == "789"
# County Test
aioclient_mock.clear_requests()
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {"interval_generation_data": "data/TEST"}},
)
aioclient_mock.get(
"https://kubra.io/data/TEST/public/reports/a36a6292-1c55-44de-a6a9-44fedf9482ee_report.json",
json={
"file_data": {
"areas": [
{
"name": "BUCKS",
"cust_a": {
"val": 123,
},
"percent_cust_a": {
"val": 1.23,
},
"n_out": 456,
"cust_s": 789,
}
]
}
},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=COUNTY_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 2
assert config_entry.state == ConfigEntryState.LOADED
sensors_to_get = [
"bucks_customers_out",
"bucks_percent_customers_out",
"bucks_outage_count",
"bucks_customers_served",
]
for sensor in sensors_to_get:
sensor_entity = hass.states.get(f"sensor.{sensor}")
assert sensor_entity is not None
assert sensor_entity.state != "unavailable"
if sensor == "bucks_customers_out":
assert sensor_entity.state == "123"
elif sensor == "bucks_percent_customers_out":
assert sensor_entity.state == "15.589"
elif sensor == "bucks_outage_count":
assert sensor_entity.state == "456"
elif sensor == "bucks_customers_served":
assert sensor_entity.state == "789"
async def test_http_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is an HTTP error."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {"interval_generation_data": "data/TEST"}},
)
aioclient_mock.get(
"https://kubra.io/data/TEST/public/reports/a36a6292-1c55-44de-a6a9-44fedf9482ee_report.json",
status=500,
json={"error": "Internal Server Error"},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=COUNTY_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "Error getting PECO outage counter data" in caplog.text
async def test_bad_json(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is bad JSON."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {}},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=COUNTY_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "ConfigEntryNotReady" in caplog.text
async def test_total_http_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is an HTTP error."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {"interval_generation_data": "data/TEST"}},
)
aioclient_mock.get(
"https://kubra.io/data/TEST/public/reports/a36a6292-1c55-44de-a6a9-44fedf9482ee_report.json",
status=500,
json={"error": "Internal Server Error"},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "Error getting PECO outage counter data" in caplog.text
async def test_total_bad_json(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is bad JSON."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
json={"data": {}},
)
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "ConfigEntryNotReady" in caplog.text
async def test_update_timeout(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is a timeout."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
exc=asyncio.TimeoutError(),
)
config_entry = MockConfigEntry(domain=DOMAIN, data=COUNTY_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "Timeout fetching data" in caplog.text
async def test_total_update_timeout(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
):
"""Test if it raises an error when there is a timeout."""
aioclient_mock.get(
"https://kubra.io/stormcenter/api/v1/stormcenters/39e6d9f3-fdea-4539-848f-b8631945da6f/views/74de8a50-3f45-4f6a-9483-fd618bb9165d/currentState?preview=false",
exc=asyncio.TimeoutError(),
)
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.data[DOMAIN]
assert "Timeout fetching data" in caplog.text