Added OptionFlow and fixed formatting and typing issues (#316)

* Added OptionsFlow support
* Formatted code
* Corrected and added typing
* Removed linting errors where possible
* Added VSCode configuration files
This commit is contained in:
Tim Laing
2025-01-19 17:45:45 +00:00
committed by GitHub
parent 86804e1301
commit 44673931db
11 changed files with 328 additions and 205 deletions

10
.gitignore vendored
View File

@@ -1,6 +1,12 @@
.vscode
/.vscode/*
.vs
**/__pycache__
/dist
/build
*.egg-info
*.egg-info
/config
/.venv
!/.vscode/settings.json
!/.vscode/launch.json

19
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: HomeAssistant",
"type": "debugpy",
"request": "launch",
"module": "homeassistant",
"args": [
"-v",
"-c",
"config"
]
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"cSpell.words": [
"hass"
]
}

View File

@@ -1,53 +1,36 @@
"""The Emporia Vue integration."""
import asyncio
from datetime import UTC, datetime, timedelta
import logging
import re
from datetime import UTC, datetime, timedelta, tzinfo
from typing import Any
import dateutil.relativedelta
import dateutil.tz
import requests
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from pyemvue import PyEmVue
from pyemvue.device import (
ChargerDevice,
VueDevice,
VueDeviceChannel,
VueDeviceChannelUsage,
VueUsageDevice,
)
from pyemvue.enums import Scale
import requests
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, ENABLE_1D, ENABLE_1M, ENABLE_1MON, VUE_DATA
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(ENABLE_1M, default=True): cv.boolean,
vol.Optional(ENABLE_1D, default=True): cv.boolean,
vol.Optional(ENABLE_1MON, default=True): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor", "switch"]
PLATFORMS: list[str] = ["sensor", "switch"]
DEVICE_GIDS: list[str] = []
DEVICE_INFORMATION: dict[int, VueDevice] = {}
@@ -88,27 +71,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DEVICE_INFORMATION = {}
entry_data = entry.data
email = entry_data[CONF_EMAIL]
password = entry_data[CONF_PASSWORD]
email: str = entry_data[CONF_EMAIL]
password: str = entry_data[CONF_PASSWORD]
vue = PyEmVue()
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, vue.login, email, password)
# result = await loop.run_in_executor(None, vue.login_simulator, "http://localhost:8000", email, password)
result: bool = await loop.run_in_executor(None, vue.login, email, password)
if not result:
_LOGGER.error("Failed to login to Emporia Vue")
return False
except Exception as err:
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error("Failed to login to Emporia Vue: %s", err)
return False
try:
devices = await loop.run_in_executor(None, vue.get_devices)
devices: list[VueDevice] = await loop.run_in_executor(None, vue.get_devices)
for device in devices:
if str(device.device_gid) not in DEVICE_GIDS:
DEVICE_GIDS.append(str(device.device_gid))
_LOGGER.info("Adding gid %s to DEVICE_GIDS list", device.device_gid)
# await loop.run_in_executor(None, vue.populate_device_properties, device)
DEVICE_INFORMATION[device.device_gid] = device
else:
DEVICE_INFORMATION[device.device_gid].channels += device.channels
@@ -122,13 +103,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
total_channels,
)
async def async_update_data_1min():
async def async_update_data_1min() -> dict:
"""Fetch data from API endpoint at a 1 minute interval.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
data = await update_sensors(vue, [Scale.MINUTE.value])
data: dict = await update_sensors(vue, [Scale.MINUTE.value])
# store this, then have the daily sensors pull from it and integrate
# then the daily can "true up" hourly (or more frequent) in case it's incorrect
if data:
@@ -136,7 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LAST_MINUTE_DATA = data
return data
async def async_update_data_1mon():
async def async_update_data_1mon() -> dict:
"""Fetch data from API endpoint at a 1 hour interval.
This is the place to pre-process the data to lookup tables
@@ -144,10 +125,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
return await update_sensors(vue, [Scale.MONTH.value])
async def async_update_day_sensors():
async def async_update_day_sensors() -> dict:
global LAST_DAY_UPDATE
global LAST_DAY_DATA
now = datetime.now(UTC)
now: datetime = datetime.now(UTC)
if not LAST_DAY_UPDATE or (now - LAST_DAY_UPDATE) > timedelta(minutes=15):
_LOGGER.info("Updating day sensors")
LAST_DAY_UPDATE = now
@@ -158,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if LAST_MINUTE_DATA:
for identifier, data in LAST_MINUTE_DATA.items():
device_gid, channel_gid, _ = identifier.split("-")
day_id = f"{device_gid}-{channel_gid}-{Scale.DAY.value}"
day_id: str = f"{device_gid}-{channel_gid}-{Scale.DAY.value}"
if (
data
and LAST_DAY_DATA
@@ -217,7 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator_day_sensor.async_config_entry_first_refresh()
# Setup custom services
async def handle_set_charger_current(call):
async def handle_set_charger_current(call) -> None:
"""Handle setting the EV Charger current."""
_LOGGER.debug(
"executing set_charger_current: %s %s",
@@ -226,8 +207,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
current = call.data.get("current")
current = int(current)
device_id = call.data.get("device_id", None)
entity_id = call.data.get("entity_id", None)
device_id: str | list[str] | None = call.data.get("device_id", None)
entity_id: str | list[str] | None = call.data.get("entity_id", None)
# if device or entity ids are strings, convert to list
if isinstance(device_id, str):
@@ -237,10 +218,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# technically we should loop through all the passed device and entities and update all
# but for now we'll just use the first one
charger_entity = None
entity_registry = er.async_get(hass)
charger_entity: er.RegistryEntry | None = None
entity_registry: er.EntityRegistry = er.async_get(hass)
if device_id:
entities = er.async_entries_for_device(entity_registry, device_id[0])
entities: list[er.RegistryEntry] = er.async_entries_for_device(
entity_registry, device_id[0]
)
for entity in entities:
_LOGGER.info("Entity is %s", str(entity))
if entity.entity_id.startswith("switch"):
@@ -253,8 +236,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not charger_entity:
raise HomeAssistantError("Target device or Entity required.")
unique_entity_id = charger_entity.unique_id
gid_match = re.search(r"\d+", unique_entity_id)
unique_entity_id: str = charger_entity.unique_id
gid_match: re.Match[str] | None = re.search(r"\d+", unique_entity_id)
if not gid_match:
raise HomeAssistantError(
f"Could not find device gid from unique id {unique_entity_id}"
@@ -266,7 +249,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
or not DEVICE_INFORMATION[charger_gid].ev_charger
):
raise HomeAssistantError(
f"Set Charging Current called on invalid device with entity id {charger_entity.entity_id} (unique id {unique_entity_id})"
"Set Charging Current called on invalid device with entity id"
f" {charger_entity.entity_id} (unique id {unique_entity_id})"
)
state = hass.states.get(charger_entity.entity_id)
@@ -275,20 +259,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise HomeAssistantError(
f"Could not find state for entity {charger_entity.entity_id}"
)
charger_info = DEVICE_INFORMATION[charger_gid]
charger_info: VueDevice = DEVICE_INFORMATION[charger_gid]
if charger_info.ev_charger is None:
raise HomeAssistantError(
f"Could not find charger info for device {charger_gid}"
)
# Scale the current to a minimum of 6 amps and max of the circuit max
current = max(6, current)
current: int = max(6, current)
current = min(current, charger_info.ev_charger.max_charging_rate)
_LOGGER.info(
"Setting charger %s to current of %d amps", charger_gid, current
)
try:
updated_charger = await loop.run_in_executor(
updated_charger: ChargerDevice = await loop.run_in_executor(
None,
vue.update_charger,
charger_info.ev_charger,
@@ -297,14 +281,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
DEVICE_INFORMATION[charger_gid].ev_charger = updated_charger
# update the state of the charger entity using the updated data
state = hass.states.get(charger_entity.entity_id)
state: State | None = hass.states.get(charger_entity.entity_id)
if state:
newState = "on" if updated_charger.charger_on else "off"
newAttributes = state.attributes.copy()
newAttributes["charging_rate"] = updated_charger.charging_rate
new_state: str = "on" if updated_charger.charger_on else "off"
new_attributes: dict = state.attributes.copy()
new_attributes["charging_rate"] = updated_charger.charging_rate
# good enough for now, update the state in the registry
hass.states.async_set(
charger_entity.entity_id, newState, newAttributes
charger_entity.entity_id, new_state, new_attributes
)
except requests.exceptions.HTTPError as err:
@@ -343,7 +327,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = all(
unload_ok: bool = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
@@ -357,16 +341,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def update_sensors(vue: PyEmVue, scales: list[str]):
async def update_sensors(vue: PyEmVue, scales: list[str]) -> dict:
"""Fetch data from API endpoint."""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
data = {}
loop = asyncio.get_event_loop()
data: dict = {}
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
for scale in scales:
utcnow = datetime.now(UTC)
usage_dict = await loop.run_in_executor(
utcnow: datetime = datetime.now(UTC)
usage_dict: dict[int, VueUsageDevice] = await loop.run_in_executor(
None, vue.get_device_list_usage, DEVICE_GIDS, utcnow, scale
)
if not usage_dict:
@@ -405,7 +389,7 @@ def flatten_usage_data(
data_time = usage.timestamp or data_time
if usage.channels:
for channel in usage.channels.values():
identifier = make_channel_id(channel, scale)
identifier: str = make_channel_id(channel, scale)
flattened[identifier] = channel
if channel.nested_devices:
nested_flattened, _ = flatten_usage_data(
@@ -421,24 +405,25 @@ async def parse_flattened_usage_data(
data: dict[str, Any],
requested_time: datetime,
data_time: datetime,
):
) -> None:
"""Loop through the device list and find the corresponding update data."""
unused_data = flattened_data.copy()
unused_data: dict[str, VueDeviceChannelUsage] = flattened_data.copy()
for gid, info in DEVICE_INFORMATION.items():
local_time = await change_time_to_local(data_time, info.time_zone)
requested_time_local = await change_time_to_local(
local_time: datetime = await change_time_to_local(data_time, info.time_zone)
requested_time_local: datetime = await change_time_to_local(
requested_time, info.time_zone
)
if abs((local_time - requested_time_local).total_seconds()) > 30:
_LOGGER.warning(
"More than 30 seconds have passed between the requested datetime and the returned datetime. Requested: %s Returned: %s",
"More than 30 seconds have passed between the requested datetime"
" and the returned datetime. Requested: %s Returned: %s",
requested_time,
data_time,
)
for info_channel in info.channels:
identifier = make_channel_id(info_channel, scale)
identifier: str = make_channel_id(info_channel, scale)
channel_num = info_channel.channel_num
channel = flattened_data.get(identifier)
channel: VueDeviceChannelUsage | None = flattened_data.get(identifier)
if not channel:
_LOGGER.info(
"Could not find usage info for device %s channel %s",
@@ -446,12 +431,13 @@ async def parse_flattened_usage_data(
channel_num,
)
unused_data.pop(identifier, None)
reset_datetime = None
reset_datetime: datetime | None = None
if scale in [Scale.DAY.value, Scale.MONTH.value]:
# We need to know when the value reset
# For day, that should be midnight local time, but we need to use the timestamp returned to us
# for month, that should be midnight of the reset day they specify in the app
# For day, that should be midnight local time, but we need to use the timestamp
# returnedto us for month, that should be midnight of the reset day they specify
# in the app
reset_datetime = determine_reset_datetime(
local_time,
info.billing_cycle_start_day,
@@ -460,11 +446,12 @@ async def parse_flattened_usage_data(
# Fix the usage if we got None
# Use the last value if we have it, otherwise use zero
fixed_usage = channel.usage if channel else 0
fixed_usage: float = channel.usage if channel else 0.0
if fixed_usage is None:
fixed_usage = handle_none_usage(scale, identifier)
_LOGGER.info(
"Got None usage for device %s channel %s scale %s and timestamp %s. Instead using a value of %s",
"Got None usage for device %s channel %s scale %s and timestamp %s. "
"Instead using a value of %s",
gid,
channel_num,
scale,
@@ -487,8 +474,8 @@ async def parse_flattened_usage_data(
"timestamp": local_time,
}
if unused_data:
# unused_data is not json serializable because VueDeviceChannelUsage is not JSON serializable
# instead print out dictionary as a string
# unused_data is not json serializable because VueDeviceChannelUsage
# is not JSON serializable instead print out dictionary as a string
_LOGGER.info(
"Unused data found during update. Unused data: %s",
str(unused_data),
@@ -496,8 +483,9 @@ async def parse_flattened_usage_data(
channels_were_added = False
for channel in unused_data.values():
channels_were_added |= await handle_special_channels_for_device(channel)
# we'll also need to register these entities I think. They might show up automatically on the first run
# When we're done handling the unused data we need to rerun the update
# we'll also need to register these entities I think. They might show up
# automatically on the first run When we're done handling the unused data
# we need to rerun the update
if channels_were_added:
_LOGGER.info("Rerunning update due to added channels")
await parse_flattened_usage_data(
@@ -507,9 +495,8 @@ async def parse_flattened_usage_data(
async def handle_special_channels_for_device(channel: VueDeviceChannel) -> bool:
"""Handle the special channels for a device, if they exist."""
device_info = None
if channel.device_gid in DEVICE_INFORMATION:
device_info = DEVICE_INFORMATION[channel.device_gid]
device_info: VueDevice = DEVICE_INFORMATION[channel.device_gid]
# if channel.channel_num in [
# "MainsFromGrid",
# "MainsToGrid",
@@ -517,7 +504,7 @@ async def handle_special_channels_for_device(channel: VueDeviceChannel) -> bool:
# "TotalUsage",
# ]:
found = False
channel_123 = None
channel_123: VueDeviceChannel | None = None
for device_channel in device_info.channels:
if device_channel.channel_num == channel.channel_num:
found = True
@@ -546,37 +533,33 @@ async def handle_special_channels_for_device(channel: VueDeviceChannel) -> bool:
)
)
# register the entity
# registry = await async_get_registry(hass)
# registry.async_get_or_create(
# domain='your_domain',
# platform='your_platform',
# unique_id=entity_id,
# name=entity_name,
# config_entry=config_entry,
# device_id=device_id,
# )
return True
return False
def make_channel_id(channel: VueDeviceChannel, scale: str):
def make_channel_id(channel: VueDeviceChannel, scale: str) -> str:
"""Format the channel id for a channel and scale."""
return f"{channel.device_gid}-{channel.channel_num}-{scale}"
def fix_usage_sign(channel_num: str, usage: float, bidirectional: bool):
"""If the channel is not '1,2,3' or 'Balance' we need it to be positive (see https://github.com/magico13/ha-emporia-vue/issues/57)."""
def fix_usage_sign(channel_num: str, usage: float, bidirectional: bool) -> float:
"""
If the channel is not '1,2,3' or 'Balance' we need it to be positive
(see https://github.com/magico13/ha-emporia-vue/issues/57).
"""
if usage and not bidirectional and channel_num not in ["1,2,3", "Balance"]:
# With bidirectionality, we need to also check if bidirectional. If yes, we either don't abs, or we flip the sign.
# With bidirectionality, we need to also check if bidirectional. If yes,
# we either don't abs, or we flip the sign.
return abs(usage)
return usage
async def change_time_to_local(time: datetime, tz_string: str):
async def change_time_to_local(time: datetime, tz_string: str) -> datetime:
"""Change the datetime to the provided timezone, if not already."""
loop = asyncio.get_event_loop()
tz_info = await loop.run_in_executor(None, dateutil.tz.gettz, tz_string)
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
tz_info: tzinfo | None = await loop.run_in_executor(
None, dateutil.tz.gettz, tz_string
)
if not time.tzinfo or time.tzinfo.utcoffset(time) is None:
# unaware, assume it's already utc
time = time.replace(tzinfo=UTC)
@@ -586,14 +569,19 @@ async def change_time_to_local(time: datetime, tz_string: str):
async def check_for_midnight(timestamp: datetime, device_gid: int, day_id: str):
"""If midnight has recently passed, reset the LAST_DAY_DATA for Day sensors to zero."""
if device_gid in DEVICE_INFORMATION:
device_info = DEVICE_INFORMATION[device_gid]
local_time = await change_time_to_local(timestamp, device_info.time_zone)
local_midnight = local_time.replace(hour=0, minute=0, second=0, microsecond=0)
device_info: VueDevice = DEVICE_INFORMATION[device_gid]
local_time: datetime = await change_time_to_local(
timestamp, device_info.time_zone
)
local_midnight: datetime = local_time.replace(
hour=0, minute=0, second=0, microsecond=0
)
last_reset = LAST_DAY_DATA[day_id]["reset"]
if local_midnight > last_reset:
# New reset time found
_LOGGER.info(
"Midnight happened recently for id %s! Timestamp is %s, midnight is %s, previous reset was %s",
"Midnight happened recently for id %s! Timestamp is %s, midnight is %s, "
"previous reset was %s",
day_id,
local_time,
local_midnight,
@@ -605,9 +593,11 @@ async def check_for_midnight(timestamp: datetime, device_gid: int, day_id: str):
def determine_reset_datetime(
local_time: datetime, monthly_cycle_start: int, is_month: bool
):
) -> datetime:
"""Determine the last reset datetime (aware) based on the passed time and cycle start date."""
reset_datetime = local_time.replace(hour=0, minute=0, second=0, microsecond=0)
reset_datetime: datetime = local_time.replace(
hour=0, minute=0, second=0, microsecond=0
)
if is_month:
# Month should use the last billing_cycle_start_day of either this or last month
reset_datetime = reset_datetime.replace(day=monthly_cycle_start)

View File

@@ -1,11 +1,12 @@
"""Emporia Charger Entity."""
from typing import Any, Optional
from pyemvue import pyemvue
from pyemvue.device import ChargerDevice, VueDevice
from functools import cached_property
from typing import Any, Optional
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyemvue import pyemvue
from pyemvue.device import ChargerDevice, VueDevice
from .const import DOMAIN
@@ -25,9 +26,9 @@ class EmporiaChargerEntity(CoordinatorEntity):
"""Initialize the sensor."""
super().__init__(coordinator)
self._coordinator = coordinator
self._device = device
self._vue = vue
self._enabled_default = enabled_default
self._device: VueDevice = device
self._vue: pyemvue.PyEmVue = vue
self._enabled_default: bool = enabled_default
self._attr_unit_of_measurement = units
self._attr_device_class = device_class
@@ -37,14 +38,14 @@ class EmporiaChargerEntity(CoordinatorEntity):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._device
return self._device is not None
@property
@cached_property
def entity_registry_enabled_default(self) -> bool:
"""Return whether the entity should be enabled when first added to the entity registry."""
return self._enabled_default
@property
@cached_property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the device."""
data: ChargerDevice = self._coordinator.data[self._device.device_gid]
@@ -61,12 +62,12 @@ class EmporiaChargerEntity(CoordinatorEntity):
}
return {}
@property
@cached_property
def unique_id(self) -> str:
"""Unique ID for the charger."""
return f"charger.emporia_vue.{self._device.device_gid}"
@property
@cached_property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return DeviceInfo(
@@ -76,5 +77,3 @@ class EmporiaChargerEntity(CoordinatorEntity):
sw_version=self._device.firmware,
manufacturer="Emporia",
)

View File

@@ -3,25 +3,14 @@
import asyncio
import logging
from pyemvue import PyEmVue
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from pyemvue import PyEmVue
from .const import DOMAIN, ENABLE_1D, ENABLE_1M, ENABLE_1MON
from .const import DOMAIN, DOMAIN_SCHEMA, ENABLE_1D, ENABLE_1M, ENABLE_1MON
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(ENABLE_1M, default=True): bool,
vol.Optional(ENABLE_1D, default=True): bool,
vol.Optional(ENABLE_1MON, default=True): bool,
}
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
class VueHub:
@@ -33,24 +22,27 @@ class VueHub:
async def authenticate(self, username, password) -> bool:
"""Test if we can authenticate with the host."""
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.vue.login, username, password)
async def validate_input(hass: core.HomeAssistant, data):
async def validate_input(data: dict):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
hub = VueHub()
if not await hub.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]):
raise InvalidAuth
raise InvalidAuth()
# If you cannot connect:
# throw CannotConnect
# If the authentication is wrong:
# InvalidAuth
if not hub.vue.customer:
raise InvalidAuth()
# Return info that you want to store in the config entry.
return {
"title": f"Customer {hub.vue.customer.customer_gid}",
@@ -67,17 +59,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult:
async def async_step_user(self, user_input=None) -> config_entries.FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
info = await validate_input(user_input)
# prevent setting up the same account twice
await self.async_set_unique_id(info["gid"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_create_entry(
title=info["title"], data=user_input, options=user_input
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -87,13 +81,54 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user", data_schema=DOMAIN_SCHEMA, errors=errors
)
@staticmethod
@core.callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return OptionsFlowHandler(config_entry)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a options flow for Emporia Vue."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry: config_entries.ConfigEntry = config_entry
async def async_step_init(self, user_input=None) -> config_entries.FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
ENABLE_1M,
default=self.config_entry.options.get(ENABLE_1M, True),
): bool,
vol.Optional(
ENABLE_1D,
default=self.config_entry.options.get(ENABLE_1D, True),
): bool,
vol.Optional(
ENABLE_1MON,
default=self.config_entry.options.get(ENABLE_1MON, True),
): bool,
}
),
)
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@@ -1,8 +1,29 @@
"""Constants for the Emporia Vue integration."""
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
DOMAIN = "emporia_vue"
VUE_DATA = "vue_data"
ENABLE_1S = "enable_1s"
ENABLE_1M = "enable_1m"
ENABLE_1D = "enable_1d"
ENABLE_1MON = "enable_1mon"
DOMAIN_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(ENABLE_1M, default=True): cv.boolean, # type: ignore
vol.Optional(ENABLE_1D, default=True): cv.boolean, # type: ignore
vol.Optional(ENABLE_1MON, default=True): cv.boolean, # type: ignore
}
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: DOMAIN_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)

View File

@@ -1,10 +1,8 @@
"""Platform for sensor integration."""
from datetime import datetime
import logging
from pyemvue.device import VueDevice, VueDeviceChannel
from pyemvue.enums import Scale
from datetime import datetime
from functools import cached_property
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -17,10 +15,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyemvue.device import VueDevice, VueDeviceChannel
from pyemvue.enums import Scale
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_LOGGER: logging.Logger = logging.getLogger(__name__)
# def setup_platform(hass, config, add_entities, discovery_info=None):
@@ -57,7 +57,7 @@ async def async_setup_entry(
)
class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity):
class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): # type: ignore
"""Representation of a Vue Sensor's current power."""
def __init__(self, coordinator, identifier) -> None:
@@ -100,7 +100,7 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity):
self._attr_suggested_display_precision = 1
self._attr_name = f"Power {self.scale_readable()}"
@property
@cached_property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
device_name = self._channel.name or self._device.device_name
@@ -114,14 +114,14 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity):
manufacturer="Emporia",
)
@property
@cached_property
def last_reset(self) -> datetime | None:
"""The time when the daily/monthly sensor was reset. Midnight local time."""
if self._id in self.coordinator.data:
return self.coordinator.data[self._id]["reset"]
return None
@property
@cached_property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if self._id in self.coordinator.data:
@@ -129,12 +129,18 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity):
return self.scale_usage(usage) if usage is not None else None
return None
@property
@cached_property
def unique_id(self) -> str:
"""Unique ID for the sensor."""
if self._scale == Scale.MINUTE.value:
return f"sensor.emporia_vue.instant.{self._channel.device_gid}-{self._channel.channel_num}"
return f"sensor.emporia_vue.{self._scale}.{self._channel.device_gid}-{self._channel.channel_num}"
return (
"sensor.emporia_vue.instant."
f"{self._channel.device_gid}-{self._channel.channel_num}"
)
return (
"sensor.emporia_vue.{self._scale}."
f"{self._channel.device_gid}-{self._channel.channel_num}"
)
def scale_usage(self, usage):
"""Scales the usage to the correct timescale and magnitude."""
@@ -155,7 +161,7 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity):
Scale.SECOND.value,
Scale.MINUTES_15.value,
)
def scale_readable(self):
"""Return a human readable scale."""
if self._scale == Scale.MINUTE.value:

View File

@@ -5,7 +5,6 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
"enable_1s": "One Second Sensor",
"enable_1m": "One Minute Sensor",
"enable_1d": "One Day Sensor",
"enable_1mon": "One Month Sensor"
@@ -20,5 +19,18 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
"enable_1m": "One Minute Sensor",
"enable_1d": "One Day Sensor",
"enable_1mon": "One Month Sensor"
}
}
}
}
}

View File

@@ -1,10 +1,10 @@
"""Platform for switch integration."""
import asyncio
from datetime import timedelta
import logging
from typing import Any
from requests import exceptions
import asyncio
import logging
from datetime import timedelta
from functools import cached_property
from typing import Any
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
@@ -16,25 +16,52 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from pyemvue import PyEmVue
from pyemvue.device import ChargerDevice, OutletDevice, VueDevice
from requests import exceptions
from .charger_entity import EmporiaChargerEntity
from .const import DOMAIN, VUE_DATA
_LOGGER = logging.getLogger(__name__)
_LOGGER: logging.Logger = logging.getLogger(__name__)
device_information: dict[int, VueDevice] = {} # data is the populated device objects
async def __async_update_data(vue: PyEmVue):
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
data = {}
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
outlets: list[OutletDevice]
chargers: list[ChargerDevice]
(outlets, chargers) = await loop.run_in_executor(None, vue.get_devices_status)
if outlets:
for outlet in outlets:
data[outlet.device_gid] = outlet
if chargers:
for charger in chargers:
data[charger.device_gid] = charger
return data
except Exception as err:
raise UpdateFailed(f"Error communicating with Emporia API: {err}") from err
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
vue = hass.data[DOMAIN][config_entry.entry_id][VUE_DATA]
vue: PyEmVue = hass.data[DOMAIN][config_entry.entry_id][VUE_DATA]
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
devices: list[VueDevice] = await loop.run_in_executor(None, vue.get_devices)
for device in devices:
if device.outlet or device.ev_charger:
@@ -42,30 +69,9 @@ async def async_setup_entry(
device_information[device.device_gid] = device
async def async_update_data():
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
data = {}
loop = asyncio.get_event_loop()
outlets: list[OutletDevice]
chargers: list[ChargerDevice]
(outlets, chargers) = await loop.run_in_executor(
None, vue.get_devices_status
)
if outlets:
for outlet in outlets:
data[outlet.device_gid] = outlet
if chargers:
for charger in chargers:
data[charger.device_gid] = charger
return data
except Exception as err:
raise UpdateFailed(f"Error communicating with Emporia API: {err}") from err
return await __async_update_data(
vue=vue,
)
coordinator = DataUpdateCoordinator(
hass,
@@ -85,7 +91,7 @@ async def async_setup_entry(
continue
if device_information[gid].outlet:
switches.append(EmporiaOutletSwitch(coordinator, vue, gid))
elif device_information[gid].ev_charger:
elif device_information[gid].ev_charger:
switches.append(
EmporiaChargerSwitch(
coordinator,
@@ -99,23 +105,22 @@ async def async_setup_entry(
async_add_entities(switches)
class EmporiaOutletSwitch(CoordinatorEntity, SwitchEntity):
class EmporiaOutletSwitch(CoordinatorEntity, SwitchEntity): # type: ignore
"""Representation of an Emporia Smart Outlet state."""
def __init__(self, coordinator, vue, gid) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
# self._state = coordinator.data[index]['usage']
self._vue = vue
self._device_gid = gid
self._device = device_information[gid]
self._device: VueDevice = device_information[gid]
self._attr_has_entity_name = True
self._attr_name = None
self._attr_device_class = SwitchDeviceClass.OUTLET
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
await loop.run_in_executor(
None, self._vue.update_outlet, self.coordinator.data[self._device_gid], True
)
@@ -123,7 +128,7 @@ class EmporiaOutletSwitch(CoordinatorEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self._vue.update_outlet,
@@ -132,7 +137,7 @@ class EmporiaOutletSwitch(CoordinatorEntity, SwitchEntity):
)
await self.coordinator.async_request_refresh()
@property
@cached_property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
return DeviceInfo(
@@ -143,17 +148,24 @@ class EmporiaOutletSwitch(CoordinatorEntity, SwitchEntity):
manufacturer="Emporia",
)
@property
@cached_property
def is_on(self) -> bool:
"""Return the state of the switch."""
return self.coordinator.data[self._device_gid].outlet_on
@property
@cached_property
def unique_id(self) -> str:
"""Unique ID for the switch."""
return f"switch.emporia_vue.{self._device_gid}"
class EmporiaChargerSwitch(EmporiaChargerEntity, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
raise NotImplementedError()
def turn_off(self, **kwargs: Any) -> None:
raise NotImplementedError()
class EmporiaChargerSwitch(EmporiaChargerEntity, SwitchEntity): # type: ignore
"""Representation of an Emporia Charger switch state."""
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -164,14 +176,14 @@ class EmporiaChargerSwitch(EmporiaChargerEntity, SwitchEntity):
"""Turn the charger off."""
await self._update_switch(False)
@property
@cached_property
def is_on(self) -> bool:
"""Return the state of the switch."""
return self.coordinator.data[self._device.device_gid].charger_on
async def _update_switch(self, on: bool):
async def _update_switch(self, on: bool) -> None:
"""Update the switch."""
loop = asyncio.get_event_loop()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
@@ -187,3 +199,9 @@ class EmporiaChargerSwitch(EmporiaChargerEntity, SwitchEntity):
)
raise
await self._coordinator.async_request_refresh()
def turn_on(self, **kwargs: Any) -> None:
raise NotImplementedError()
def turn_off(self, **kwargs: Any) -> None:
raise NotImplementedError()

View File

@@ -15,7 +15,19 @@
"enable_1d": "Energy Today Sensor",
"enable_1m": "Power Minute Average Sensor",
"enable_1mon": "Energy This Month Sensor",
"enable_1s": "Power Second Average Sensor",
"password": "Password"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"email": "Email",
"enable_1d": "Energy Today Sensor",
"enable_1m": "Power Minute Average Sensor",
"enable_1mon": "Energy This Month Sensor",
"password": "Password"
}
}