Add PECO smart meter binary_sensor (#71034)

* Add support for PECO smart meter

* Add support for PECO smart meter

* Conform to black

* Fix tests and additional clean-up

* Return init file to original state

* Move to FlowResultType

* Catch up to upstream

* Remove commented code

* isort

* Merge smart meter and outage count into one entry

* Test coverage

* Remove logging exceptions from config flow verification

* Fix comments from @emontnemery

* Revert "Add support for PECO smart meter"

This reverts commit 36ca90856684f328e71bc3778fa7aa52a6bde5ca.

* More fixes
This commit is contained in:
IceBotYT 2023-11-29 03:08:27 -05:00 committed by GitHub
parent 3aa9066a50
commit 526180a8af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 525 additions and 43 deletions

View File

@ -5,7 +5,14 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Final
from peco import AlertResults, BadJSONError, HttpError, OutageResults, PecoOutageApi
from peco import (
AlertResults,
BadJSONError,
HttpError,
OutageResults,
PecoOutageApi,
UnresponsiveMeterError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@ -13,9 +20,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL
from .const import (
CONF_COUNTY,
CONF_PHONE_NUMBER,
DOMAIN,
LOGGER,
OUTAGE_SCAN_INTERVAL,
SMART_METER_SCAN_INTERVAL,
)
PLATFORMS: Final = [Platform.SENSOR]
PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR]
@dataclass
@ -31,9 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
websession = async_get_clientsession(hass)
api = PecoOutageApi()
# Outage Counter Setup
county: str = entry.data[CONF_COUNTY]
async def async_update_data() -> PECOCoordinatorData:
async def async_update_outage_data() -> OutageResults:
"""Fetch data from API."""
try:
outages: OutageResults = (
@ -53,15 +68,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
LOGGER,
name="PECO Outage Count",
update_method=async_update_data,
update_interval=timedelta(minutes=SCAN_INTERVAL),
update_method=async_update_outage_data,
update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator}
if phone_number := entry.data.get(CONF_PHONE_NUMBER):
# Smart Meter Setup]
async def async_update_meter_data() -> bool:
"""Fetch data from API."""
try:
data: bool = await api.meter_check(phone_number, websession)
except UnresponsiveMeterError as err:
raise UpdateFailed("Unresponsive meter") from err
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
return data
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="PECO Smart Meter",
update_method=async_update_meter_data,
update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -0,0 +1,59 @@
"""Binary sensor for PECO outage counter."""
from __future__ import annotations
from typing import Final
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
PARALLEL_UPDATES: Final = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensor for PECO."""
if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]:
return
coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][
"smart_meter"
]
async_add_entities(
[PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])]
)
class PecoBinarySensor(
CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity
):
"""Binary sensor for PECO outage counter."""
_attr_icon = "mdi:gauge"
_attr_device_class = BinarySensorDeviceClass.POWER
_attr_name = "Meter Status"
def __init__(
self, coordinator: DataUpdateCoordinator[bool], phone_number: str
) -> None:
"""Initialize binary sensor for PECO."""
super().__init__(coordinator)
self._attr_unique_id = f"{phone_number}"
@property
def is_on(self) -> bool:
"""Return if the meter has power."""
return self.coordinator.data

View File

@ -1,41 +1,122 @@
"""Config flow for PECO Outage Counter integration."""
from __future__ import annotations
import logging
from typing import Any
from peco import (
HttpError,
IncompatibleMeterError,
PecoOutageApi,
UnresponsiveMeterError,
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN
from .const import CONF_COUNTY, CONF_PHONE_NUMBER, COUNTY_LIST, DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST),
vol.Optional(CONF_PHONE_NUMBER): cv.string,
}
)
_LOGGER = logging.getLogger(__name__)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PECO Outage Counter."""
VERSION = 1
meter_verification: bool = False
meter_data: dict[str, str] = {}
meter_error: dict[str, str] = {}
async def _verify_meter(self, phone_number: str) -> None:
"""Verify if the meter is compatible."""
api = PecoOutageApi()
try:
await api.meter_check(phone_number)
except ValueError:
self.meter_error = {"phone_number": "invalid_phone_number", "type": "error"}
except IncompatibleMeterError:
self.meter_error = {"phone_number": "incompatible_meter", "type": "abort"}
except UnresponsiveMeterError:
self.meter_error = {"phone_number": "unresponsive_meter", "type": "error"}
except HttpError:
self.meter_error = {"phone_number": "http_error", "type": "error"}
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if self.meter_verification is True:
return self.async_show_progress_done(next_step_id="finish_smart_meter")
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
)
county = user_input[CONF_COUNTY]
await self.async_set_unique_id(county)
if CONF_PHONE_NUMBER not in user_input:
await self.async_set_unique_id(county)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_COUNTY].capitalize()} Outage Count",
data=user_input,
)
phone_number = user_input[CONF_PHONE_NUMBER]
await self.async_set_unique_id(f"{county}-{phone_number}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{county.capitalize()} Outage Count", data=user_input
self.meter_verification = True
if self.meter_error is not None:
# Clear any previous errors, since the user may have corrected them
self.meter_error = {}
self.hass.async_create_task(self._verify_meter(phone_number))
self.meter_data = user_input
return self.async_show_progress(
step_id="user",
progress_action="verifying_meter",
)
async def async_step_finish_smart_meter(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the finish smart meter step."""
if "phone_number" in self.meter_error:
if self.meter_error["type"] == "error":
self.meter_verification = False
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors={"phone_number": self.meter_error["phone_number"]},
)
return self.async_abort(reason=self.meter_error["phone_number"])
return self.async_create_entry(
title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}",
data=self.meter_data,
)

View File

@ -14,6 +14,8 @@ COUNTY_LIST: Final = [
"TOTAL",
]
CONFIG_FLOW_COUNTIES: Final = [{county: county.capitalize()} for county in COUNTY_LIST]
SCAN_INTERVAL: Final = 9
OUTAGE_SCAN_INTERVAL: Final = 9 # minutes
SMART_METER_SCAN_INTERVAL: Final = 15 # minutes
CONF_COUNTY: Final = "county"
ATTR_CONTENT: Final = "content"
CONF_PHONE_NUMBER: Final = "phone_number"

View File

@ -91,7 +91,7 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
county: str = config_entry.data[CONF_COUNTY]
coordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"]
async_add_entities(
PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST

View File

@ -3,12 +3,26 @@
"step": {
"user": {
"data": {
"county": "County"
"county": "County",
"phone_number": "Phone Number"
},
"data_description": {
"county": "County used for outage number retrieval",
"phone_number": "Phone number associated with the PECO account (optional). Adding a phone number adds a binary sensor confirming if your power is out or not, and not an issue with a breaker or an issue on your end."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"incompatible_meter": "Your meter is not compatible with smart meter checking."
},
"progress": {
"verifying_meter": "One moment. Verifying that your meter is compatible. This may take a minute or two."
},
"error": {
"invalid_phone_number": "Please enter a valid phone number.",
"unresponsive_meter": "Your meter is not responding. Please try again later.",
"http_error": "There was an error communicating with PECO. The issue that is most likely is that you entered an invalid phone number. Please check the phone number or try again later."
}
},
"entity": {

View File

@ -1,6 +1,7 @@
"""Test the PECO Outage Counter config flow."""
from unittest.mock import patch
from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError
import pytest
from voluptuous.error import MultipleInvalid
@ -17,6 +18,7 @@ async def test_form(hass: HomeAssistant) -> None:
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] is None
assert result["step_id"] == "user"
with patch(
"homeassistant.components.peco.async_setup_entry",
@ -35,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["data"] == {
"county": "PHILADELPHIA",
}
assert result2["context"]["unique_id"] == "PHILADELPHIA"
async def test_invalid_county(hass: HomeAssistant) -> None:
@ -43,37 +46,160 @@ async def test_invalid_county(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] is None
with pytest.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()
second_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert second_result["type"] == FlowResultType.FORM
assert second_result["errors"] is None
assert result["step_id"] == "user"
with patch(
"homeassistant.components.peco.async_setup_entry",
return_value=True,
):
second_result2 = await hass.config_entries.flow.async_configure(
second_result["flow_id"],
), pytest.raises(MultipleInvalid):
await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST",
},
)
await hass.async_block_till_done()
assert second_result2["type"] == FlowResultType.CREATE_ENTRY
assert second_result2["title"] == "Philadelphia Outage Count"
assert second_result2["data"] == {
"county": "PHILADELPHIA",
}
async def test_meter_value_error(hass: HomeAssistant) -> None:
"""Test if the MeterValueError error works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"phone_number": "INVALID_SMART_METER_THAT_SHOULD_NOT_EXIST",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["progress_action"] == "verifying_meter"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"phone_number": "invalid_phone_number"}
async def test_incompatible_meter_error(hass: HomeAssistant) -> None:
"""Test if the IncompatibleMeter error works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch("peco.PecoOutageApi.meter_check", side_effect=IncompatibleMeterError()):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"phone_number": "1234567890",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["progress_action"] == "verifying_meter"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "incompatible_meter"
async def test_unresponsive_meter_error(hass: HomeAssistant) -> None:
"""Test if the UnresponsiveMeter error works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch("peco.PecoOutageApi.meter_check", side_effect=UnresponsiveMeterError()):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"phone_number": "1234567890",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["progress_action"] == "verifying_meter"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"phone_number": "unresponsive_meter"}
async def test_meter_http_error(hass: HomeAssistant) -> None:
"""Test if the InvalidMeter error works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch("peco.PecoOutageApi.meter_check", side_effect=HttpError):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"phone_number": "1234567890",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["progress_action"] == "verifying_meter"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"phone_number": "http_error"}
async def test_smart_meter(hass: HomeAssistant) -> None:
"""Test if the Smart Meter step works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch("peco.PecoOutageApi.meter_check", return_value=True):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"county": "PHILADELPHIA",
"phone_number": "1234567890",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["progress_action"] == "verifying_meter"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Philadelphia - 1234567890"
assert result["data"]["phone_number"] == "1234567890"
assert result["context"]["unique_id"] == "PHILADELPHIA-1234567890"

View File

@ -2,7 +2,13 @@
import asyncio
from unittest.mock import patch
from peco import AlertResults, BadJSONError, HttpError, OutageResults
from peco import (
AlertResults,
BadJSONError,
HttpError,
OutageResults,
UnresponsiveMeterError,
)
import pytest
from homeassistant.components.peco.const import DOMAIN
@ -14,6 +20,7 @@ from tests.common import MockConfigEntry
MOCK_ENTRY_DATA = {"county": "TOTAL"}
COUNTY_ENTRY_DATA = {"county": "BUCKS"}
INVALID_COUNTY_DATA = {"county": "INVALID"}
METER_DATA = {"county": "BUCKS", "phone_number": "1234567890"}
async def test_unload_entry(hass: HomeAssistant) -> None:
@ -149,3 +156,154 @@ async def test_bad_json(hass: HomeAssistant, sensor: str) -> None:
assert hass.states.get(f"sensor.{sensor}") is None
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_unresponsive_meter_error(hass: HomeAssistant) -> None:
"""Test if it raises an error when the meter will not respond."""
config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA)
config_entry.add_to_hass(hass)
with patch(
"peco.PecoOutageApi.meter_check",
side_effect=UnresponsiveMeterError(),
), patch(
"peco.PecoOutageApi.get_outage_count",
return_value=OutageResults(
customers_out=0,
percent_customers_out=0,
outage_count=0,
customers_served=350394,
),
), patch(
"peco.PecoOutageApi.get_map_alerts",
return_value=AlertResults(
alert_content="Testing 1234", alert_title="Testing 4321"
),
):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.meter_status") is None
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_meter_http_error(hass: HomeAssistant) -> None:
"""Test if it raises an error when there is an HTTP error."""
config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA)
config_entry.add_to_hass(hass)
with patch(
"peco.PecoOutageApi.meter_check",
side_effect=HttpError(),
), patch(
"peco.PecoOutageApi.get_outage_count",
return_value=OutageResults(
customers_out=0,
percent_customers_out=0,
outage_count=0,
customers_served=350394,
),
), patch(
"peco.PecoOutageApi.get_map_alerts",
return_value=AlertResults(
alert_content="Testing 1234", alert_title="Testing 4321"
),
):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.meter_status") is None
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_meter_bad_json(hass: HomeAssistant) -> None:
"""Test if it raises an error when there is bad JSON."""
config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA)
config_entry.add_to_hass(hass)
with patch(
"peco.PecoOutageApi.meter_check",
side_effect=BadJSONError(),
), patch(
"peco.PecoOutageApi.get_outage_count",
return_value=OutageResults(
customers_out=0,
percent_customers_out=0,
outage_count=0,
customers_served=350394,
),
), patch(
"peco.PecoOutageApi.get_map_alerts",
return_value=AlertResults(
alert_content="Testing 1234", alert_title="Testing 4321"
),
):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.meter_status") is None
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_meter_timeout(hass: HomeAssistant) -> None:
"""Test if it raises an error when there is a timeout."""
config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA)
config_entry.add_to_hass(hass)
with patch(
"peco.PecoOutageApi.meter_check",
side_effect=asyncio.TimeoutError(),
), patch(
"peco.PecoOutageApi.get_outage_count",
return_value=OutageResults(
customers_out=0,
percent_customers_out=0,
outage_count=0,
customers_served=350394,
),
), patch(
"peco.PecoOutageApi.get_map_alerts",
return_value=AlertResults(
alert_content="Testing 1234", alert_title="Testing 4321"
),
):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.meter_status") is None
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_meter_data(hass: HomeAssistant) -> None:
"""Test if the meter returns the value successfully."""
config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA)
config_entry.add_to_hass(hass)
with patch(
"peco.PecoOutageApi.meter_check",
return_value=True,
), patch(
"peco.PecoOutageApi.get_outage_count",
return_value=OutageResults(
customers_out=0,
percent_customers_out=0,
outage_count=0,
customers_served=350394,
),
), patch(
"peco.PecoOutageApi.get_map_alerts",
return_value=AlertResults(
alert_content="Testing 1234", alert_title="Testing 4321"
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("binary_sensor.meter_status") is not None
assert hass.states.get("binary_sensor.meter_status").state == "on"
assert config_entry.state == ConfigEntryState.LOADED