mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add WattTime integration (#56149)
This commit is contained in:
parent
9059ee6604
commit
aabc8cd2d5
@ -1178,6 +1178,8 @@ omit =
|
|||||||
homeassistant/components/waterfurnace/*
|
homeassistant/components/waterfurnace/*
|
||||||
homeassistant/components/watson_iot/*
|
homeassistant/components/watson_iot/*
|
||||||
homeassistant/components/watson_tts/tts.py
|
homeassistant/components/watson_tts/tts.py
|
||||||
|
homeassistant/components/watttime/__init__.py
|
||||||
|
homeassistant/components/watttime/sensor.py
|
||||||
homeassistant/components/waze_travel_time/__init__.py
|
homeassistant/components/waze_travel_time/__init__.py
|
||||||
homeassistant/components/waze_travel_time/helpers.py
|
homeassistant/components/waze_travel_time/helpers.py
|
||||||
homeassistant/components/waze_travel_time/sensor.py
|
homeassistant/components/waze_travel_time/sensor.py
|
||||||
|
@ -574,6 +574,7 @@ homeassistant/components/wake_on_lan/* @ntilley905
|
|||||||
homeassistant/components/wallbox/* @hesselonline
|
homeassistant/components/wallbox/* @hesselonline
|
||||||
homeassistant/components/waqi/* @andrey-git
|
homeassistant/components/waqi/* @andrey-git
|
||||||
homeassistant/components/watson_tts/* @rutkai
|
homeassistant/components/watson_tts/* @rutkai
|
||||||
|
homeassistant/components/watttime/* @bachya
|
||||||
homeassistant/components/weather/* @fabaff
|
homeassistant/components/weather/* @fabaff
|
||||||
homeassistant/components/webostv/* @bendavid @thecode
|
homeassistant/components/webostv/* @bendavid @thecode
|
||||||
homeassistant/components/websocket_api/* @home-assistant/core
|
homeassistant/components/websocket_api/* @home-assistant/core
|
||||||
|
78
homeassistant/components/watttime/__init__.py
Normal file
78
homeassistant/components/watttime/__init__.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""The WattTime integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from aiowatttime import Client
|
||||||
|
from aiowatttime.emissions import RealTimeEmissionsResponseType
|
||||||
|
from aiowatttime.errors import WattTimeError
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_LATITUDE,
|
||||||
|
CONF_LONGITUDE,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DATA_COORDINATOR, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
PLATFORMS: list[str] = ["sensor"]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up WattTime from a config entry."""
|
||||||
|
hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}})
|
||||||
|
|
||||||
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await Client.async_login(
|
||||||
|
entry.data[CONF_USERNAME],
|
||||||
|
entry.data[CONF_PASSWORD],
|
||||||
|
session=session,
|
||||||
|
logger=LOGGER,
|
||||||
|
)
|
||||||
|
except WattTimeError as err:
|
||||||
|
LOGGER.error("Error while authenticating with WattTime: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_update_data() -> RealTimeEmissionsResponseType:
|
||||||
|
"""Get the latest realtime emissions data."""
|
||||||
|
try:
|
||||||
|
return await client.emissions.async_get_realtime_emissions(
|
||||||
|
entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE]
|
||||||
|
)
|
||||||
|
except WattTimeError as err:
|
||||||
|
raise UpdateFailed(
|
||||||
|
f"Error while requesting data from WattTime: {err}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
coordinator = DataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
LOGGER,
|
||||||
|
name=entry.title,
|
||||||
|
update_interval=DEFAULT_UPDATE_INTERVAL,
|
||||||
|
update_method=async_update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
165
homeassistant/components/watttime/config_flow.py
Normal file
165
homeassistant/components/watttime/config_flow.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""Config flow for WattTime integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from aiowatttime import Client
|
||||||
|
from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_LATITUDE,
|
||||||
|
CONF_LONGITUDE,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
)
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_BALANCING_AUTHORITY,
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONF_LOCATION_TYPE = "location_type"
|
||||||
|
|
||||||
|
LOCATION_TYPE_COORDINATES = "Specify coordinates"
|
||||||
|
LOCATION_TYPE_HOME = "Use home location"
|
||||||
|
|
||||||
|
STEP_COORDINATES_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_LATITUDE): cv.latitude,
|
||||||
|
vol.Required(CONF_LONGITUDE): cv.longitude,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STEP_LOCATION_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_LOCATION_TYPE): vol.In(
|
||||||
|
[LOCATION_TYPE_HOME, LOCATION_TYPE_COORDINATES]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for WattTime."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self._client: Client | None = None
|
||||||
|
self._password: str | None = None
|
||||||
|
self._username: str | None = None
|
||||||
|
|
||||||
|
async def async_step_coordinates(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the coordinates step."""
|
||||||
|
if not user_input:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert self._client
|
||||||
|
|
||||||
|
unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}"
|
||||||
|
await self.async_set_unique_id(unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
try:
|
||||||
|
grid_region = await self._client.emissions.async_get_grid_region(
|
||||||
|
user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE]
|
||||||
|
)
|
||||||
|
except CoordinatesNotFoundError:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="coordinates",
|
||||||
|
data_schema=STEP_COORDINATES_DATA_SCHEMA,
|
||||||
|
errors={CONF_LATITUDE: "unknown_coordinates"},
|
||||||
|
)
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception while getting region: %s", err)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="coordinates",
|
||||||
|
data_schema=STEP_COORDINATES_DATA_SCHEMA,
|
||||||
|
errors={"base": "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=unique_id,
|
||||||
|
data={
|
||||||
|
CONF_USERNAME: self._username,
|
||||||
|
CONF_PASSWORD: self._password,
|
||||||
|
CONF_LATITUDE: user_input[CONF_LATITUDE],
|
||||||
|
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
|
||||||
|
CONF_BALANCING_AUTHORITY: grid_region["name"],
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV: grid_region["abbrev"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_location(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the "pick a location" step."""
|
||||||
|
if not user_input:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="location", data_schema=STEP_LOCATION_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_COORDINATES:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
return await self.async_step_coordinates(
|
||||||
|
{
|
||||||
|
CONF_LATITUDE: self.hass.config.latitude,
|
||||||
|
CONF_LONGITUDE: self.hass.config.longitude,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if not user_input:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = await Client.async_login(
|
||||||
|
user_input[CONF_USERNAME],
|
||||||
|
user_input[CONF_PASSWORD],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors={CONF_USERNAME: "invalid_auth"},
|
||||||
|
)
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception while logging in: %s", err)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors={"base": "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._username = user_input[CONF_USERNAME]
|
||||||
|
self._password = user_input[CONF_PASSWORD]
|
||||||
|
return await self.async_step_location()
|
11
homeassistant/components/watttime/const.py
Normal file
11
homeassistant/components/watttime/const.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""Constants for the WattTime integration."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DOMAIN = "watttime"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
CONF_BALANCING_AUTHORITY = "balancing_authority"
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation"
|
||||||
|
|
||||||
|
DATA_COORDINATOR = "coordinator"
|
17
homeassistant/components/watttime/manifest.json
Normal file
17
homeassistant/components/watttime/manifest.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"domain": "watttime",
|
||||||
|
"name": "WattTime",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/watttime",
|
||||||
|
"requirements": [
|
||||||
|
"aiowatttime==0.1.1"
|
||||||
|
],
|
||||||
|
"ssdp": [],
|
||||||
|
"zeroconf": [],
|
||||||
|
"homekit": {},
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": [
|
||||||
|
"@bachya"
|
||||||
|
],
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
}
|
134
homeassistant/components/watttime/sensor.py
Normal file
134
homeassistant/components/watttime/sensor.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Support for WattTime sensors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
STATE_CLASS_MEASUREMENT,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_ATTRIBUTION,
|
||||||
|
ATTR_LATITUDE,
|
||||||
|
ATTR_LONGITUDE,
|
||||||
|
MASS_POUNDS,
|
||||||
|
PERCENTAGE,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_BALANCING_AUTHORITY,
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV,
|
||||||
|
DATA_COORDINATOR,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
ATTR_BALANCING_AUTHORITY = "balancing_authority"
|
||||||
|
|
||||||
|
DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime"
|
||||||
|
|
||||||
|
SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "realtime_emissions_moer"
|
||||||
|
SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "realtime_emissions_percent"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RealtimeEmissionsSensorDescriptionMixin:
|
||||||
|
"""Define an entity description mixin for realtime emissions sensors."""
|
||||||
|
|
||||||
|
data_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RealtimeEmissionsSensorEntityDescription(
|
||||||
|
SensorEntityDescription, RealtimeEmissionsSensorDescriptionMixin
|
||||||
|
):
|
||||||
|
"""Describe a realtime emissions sensor."""
|
||||||
|
|
||||||
|
|
||||||
|
REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = (
|
||||||
|
RealtimeEmissionsSensorEntityDescription(
|
||||||
|
key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER,
|
||||||
|
name="Marginal Operating Emissions Rate",
|
||||||
|
icon="mdi:blur",
|
||||||
|
native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh",
|
||||||
|
state_class=STATE_CLASS_MEASUREMENT,
|
||||||
|
data_key="moer",
|
||||||
|
),
|
||||||
|
RealtimeEmissionsSensorEntityDescription(
|
||||||
|
key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT,
|
||||||
|
name="Relative Marginal Emissions Intensity",
|
||||||
|
icon="mdi:blur",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=STATE_CLASS_MEASUREMENT,
|
||||||
|
data_key="percent",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
"""Set up WattTime sensors based on a config entry."""
|
||||||
|
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id]
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
RealtimeEmissionsSensor(coordinator, description)
|
||||||
|
for description in REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS
|
||||||
|
if description.data_key in coordinator.data
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Define a realtime emissions sensor."""
|
||||||
|
|
||||||
|
entity_description: RealtimeEmissionsSensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DataUpdateCoordinator,
|
||||||
|
description: RealtimeEmissionsSensorEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert coordinator.config_entry
|
||||||
|
|
||||||
|
self._attr_extra_state_attributes = {
|
||||||
|
ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION,
|
||||||
|
ATTR_BALANCING_AUTHORITY: coordinator.config_entry.data[
|
||||||
|
CONF_BALANCING_AUTHORITY
|
||||||
|
],
|
||||||
|
ATTR_LATITUDE: coordinator.config_entry.data[ATTR_LATITUDE],
|
||||||
|
ATTR_LONGITUDE: coordinator.config_entry.data[ATTR_LONGITUDE],
|
||||||
|
}
|
||||||
|
self._attr_name = f"{description.name} ({coordinator.config_entry.data[CONF_BALANCING_AUTHORITY_ABBREV]})"
|
||||||
|
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||||
|
self.entity_description = description
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Respond to a DataUpdateCoordinator update."""
|
||||||
|
self.update_from_latest_data()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Handle entity which will be added."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self.update_from_latest_data()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def update_from_latest_data(self) -> None:
|
||||||
|
"""Update the state."""
|
||||||
|
self._attr_native_value = self.coordinator.data[
|
||||||
|
self.entity_description.data_key
|
||||||
|
]
|
34
homeassistant/components/watttime/strings.json
Normal file
34
homeassistant/components/watttime/strings.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"coordinates": {
|
||||||
|
"description": "Input the latitude and longitude to monitor:",
|
||||||
|
"data": {
|
||||||
|
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||||
|
"longitude": "[%key:common::config_flow::data::longitude%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"description": "Pick a location to monitor:",
|
||||||
|
"data": {
|
||||||
|
"location_type": "[%key:common::config_flow::data::location%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"description": "Input your username and password:",
|
||||||
|
"data": {
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
|
"unknown_coordinates": "No data for latitude/longitude"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
homeassistant/components/watttime/translations/en.json
Normal file
34
homeassistant/components/watttime/translations/en.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error",
|
||||||
|
"unknown_coordinates": "No data for latitude/longitude"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"coordinates": {
|
||||||
|
"data": {
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude"
|
||||||
|
},
|
||||||
|
"description": "Input the latitude and longitude to monitor:"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"data": {
|
||||||
|
"location_type": "Location"
|
||||||
|
},
|
||||||
|
"description": "Pick a location to monitor:"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Username"
|
||||||
|
},
|
||||||
|
"description": "Input your username and password:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -303,6 +303,7 @@ FLOWS = [
|
|||||||
"vizio",
|
"vizio",
|
||||||
"volumio",
|
"volumio",
|
||||||
"wallbox",
|
"wallbox",
|
||||||
|
"watttime",
|
||||||
"waze_travel_time",
|
"waze_travel_time",
|
||||||
"wemo",
|
"wemo",
|
||||||
"whirlpool",
|
"whirlpool",
|
||||||
|
@ -254,6 +254,9 @@ aiotractive==0.5.2
|
|||||||
# homeassistant.components.unifi
|
# homeassistant.components.unifi
|
||||||
aiounifi==26
|
aiounifi==26
|
||||||
|
|
||||||
|
# homeassistant.components.watttime
|
||||||
|
aiowatttime==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.yandex_transport
|
# homeassistant.components.yandex_transport
|
||||||
aioymaps==1.1.0
|
aioymaps==1.1.0
|
||||||
|
|
||||||
|
@ -178,6 +178,9 @@ aiotractive==0.5.2
|
|||||||
# homeassistant.components.unifi
|
# homeassistant.components.unifi
|
||||||
aiounifi==26
|
aiounifi==26
|
||||||
|
|
||||||
|
# homeassistant.components.watttime
|
||||||
|
aiowatttime==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.yandex_transport
|
# homeassistant.components.yandex_transport
|
||||||
aioymaps==1.1.0
|
aioymaps==1.1.0
|
||||||
|
|
||||||
|
1
tests/components/watttime/__init__.py
Normal file
1
tests/components/watttime/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the WattTime integration."""
|
263
tests/components/watttime/test_config_flow.py
Normal file
263
tests/components/watttime/test_config_flow.py
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"""Test the WattTime config flow."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries, setup
|
||||||
|
from homeassistant.components.watttime.config_flow import (
|
||||||
|
CONF_LOCATION_TYPE,
|
||||||
|
LOCATION_TYPE_COORDINATES,
|
||||||
|
LOCATION_TYPE_HOME,
|
||||||
|
)
|
||||||
|
from homeassistant.components.watttime.const import (
|
||||||
|
CONF_BALANCING_AUTHORITY,
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_LATITUDE,
|
||||||
|
CONF_LONGITUDE,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import (
|
||||||
|
RESULT_TYPE_ABORT,
|
||||||
|
RESULT_TYPE_CREATE_ENTRY,
|
||||||
|
RESULT_TYPE_FORM,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="client")
|
||||||
|
def client_fixture(get_grid_region):
|
||||||
|
"""Define a fixture for an aiowatttime client."""
|
||||||
|
client = AsyncMock(return_value=None)
|
||||||
|
client.emissions.async_get_grid_region = get_grid_region
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="client_login")
|
||||||
|
def client_login_fixture(client):
|
||||||
|
"""Define a fixture for patching the aiowatttime coroutine to get a client."""
|
||||||
|
with patch("homeassistant.components.watttime.config_flow.Client.async_login") as m:
|
||||||
|
m.return_value = client
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="get_grid_region")
|
||||||
|
def get_grid_region_fixture():
|
||||||
|
"""Define a fixture for getting grid region data."""
|
||||||
|
return AsyncMock(return_value={"abbrev": "AUTH_1", "id": 1, "name": "Authority 1"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_error(hass: HomeAssistant, client_login):
|
||||||
|
"""Test that errors are shown when duplicate entries are added."""
|
||||||
|
MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id="32.87336, -117.22743",
|
||||||
|
data={
|
||||||
|
CONF_USERNAME: "user",
|
||||||
|
CONF_PASSWORD: "password",
|
||||||
|
CONF_LATITUDE: 32.87336,
|
||||||
|
CONF_LONGITUDE: -117.22743,
|
||||||
|
},
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_show_form_coordinates(hass: HomeAssistant) -> None:
|
||||||
|
"""Test showing the form to input custom latitude/longitude."""
|
||||||
|
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"],
|
||||||
|
user_input={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "coordinates"
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_show_form_user(hass: HomeAssistant) -> None:
|
||||||
|
"""Test showing the form to select the authentication type."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"get_grid_region", [AsyncMock(side_effect=CoordinatesNotFoundError)]
|
||||||
|
)
|
||||||
|
async def test_step_coordinates_unknown_coordinates(
|
||||||
|
hass: HomeAssistant, client_login
|
||||||
|
) -> None:
|
||||||
|
"""Test that providing coordinates with no data is handled."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LATITUDE: "0", CONF_LONGITUDE: "0"},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"latitude": "unknown_coordinates"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)])
|
||||||
|
async def test_step_coordinates_unknown_error(
|
||||||
|
hass: HomeAssistant, client_login
|
||||||
|
) -> None:
|
||||||
|
"""Test that providing coordinates with no data is handled."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_login_coordinates(hass: HomeAssistant, client_login) -> None:
|
||||||
|
"""Test a full login flow (inputting custom coordinates)."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.watttime.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == "51.528308, -0.3817765"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_USERNAME: "user",
|
||||||
|
CONF_PASSWORD: "password",
|
||||||
|
CONF_LATITUDE: 51.528308,
|
||||||
|
CONF_LONGITUDE: -0.3817765,
|
||||||
|
CONF_BALANCING_AUTHORITY: "Authority 1",
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_user_home(hass: HomeAssistant, client_login) -> None:
|
||||||
|
"""Test a full login flow (selecting the home location)."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.watttime.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == "32.87336, -117.22743"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_USERNAME: "user",
|
||||||
|
CONF_PASSWORD: "password",
|
||||||
|
CONF_LATITUDE: 32.87336,
|
||||||
|
CONF_LONGITUDE: -117.22743,
|
||||||
|
CONF_BALANCING_AUTHORITY: "Authority 1",
|
||||||
|
CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_user_invalid_credentials(hass: HomeAssistant) -> None:
|
||||||
|
"""Test that invalid credentials are handled."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.watttime.config_flow.Client.async_login",
|
||||||
|
AsyncMock(side_effect=InvalidCredentialsError),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"username": "invalid_auth"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)])
|
||||||
|
async def test_step_user_unknown_error(hass: HomeAssistant, client_login) -> None:
|
||||||
|
"""Test that an unknown error during the login step is handled."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.watttime.config_flow.Client.async_login",
|
||||||
|
AsyncMock(side_effect=Exception),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "unknown"}
|
Loading…
x
Reference in New Issue
Block a user