From 6f06d2c3e261ce8b39c0167f66b4c834917cc3e1 Mon Sep 17 00:00:00 2001 From: Mike Marvin Date: Thu, 19 Sep 2024 20:34:47 -0400 Subject: [PATCH] v0.10.0 Updates (#311) - Bumped version to 0.10.0. - Updated sensor names to more closely follow Home Assistant's recommended naming conventions. Only changes them if they're still set to the default, you can still rename them. - Addressed Home Assistant warnings about deprecated functionality and also numerous linter recommendations.\ - Bumped PyEmVue to 0.18.6, which includes improvements to the authentication flow and supports bidirectional circuits. --- custom_components/emporia_vue/__init__.py | 124 ++++++++++-------- .../emporia_vue/charger_entity.py | 32 +++-- custom_components/emporia_vue/config_flow.py | 15 +-- custom_components/emporia_vue/manifest.json | 4 +- custom_components/emporia_vue/sensor.py | 114 ++++++++-------- custom_components/emporia_vue/switch.py | 75 +++++------ .../emporia_vue/translations/en.json | 12 +- 7 files changed, 196 insertions(+), 180 deletions(-) diff --git a/custom_components/emporia_vue/__init__.py b/custom_components/emporia_vue/__init__.py index f35acf8..7ef02fd 100644 --- a/custom_components/emporia_vue/__init__.py +++ b/custom_components/emporia_vue/__init__.py @@ -1,9 +1,10 @@ """The Emporia Vue integration.""" + import asyncio -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging import re -from typing import Any, Optional +from typing import Any import dateutil.relativedelta import dateutil.tz @@ -24,6 +25,7 @@ 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 @@ -47,13 +49,15 @@ PLATFORMS = ["sensor", "switch"] -DEVICE_GIDS: list[int] = [] +DEVICE_GIDS: list[str] = [] DEVICE_INFORMATION: dict[int, VueDevice] = {} +DEVICES_ONLINE: list[str] = [] LAST_MINUTE_DATA: dict[str, Any] = {} LAST_DAY_DATA: dict[str, Any] = {} -LAST_DAY_UPDATE: Optional[datetime] = None +LAST_DAY_UPDATE: datetime | None = None + -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Emporia Vue component.""" hass.data.setdefault(DOMAIN, {}) conf = config.get(DOMAIN) @@ -76,7 +80,7 @@ async def async_setup(hass: HomeAssistant, config: dict): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Emporia Vue from a config entry.""" global DEVICE_GIDS global DEVICE_INFORMATION @@ -90,18 +94,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): loop = 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 = await loop.run_in_executor(None, vue.login_simulator, "http://localhost:8000", email, password) if not result: - raise Exception("Could not authenticate with Emporia API") - except Exception: - _LOGGER.error("Could not authenticate with Emporia API") + _LOGGER.error("Failed to login to Emporia Vue") + return False + except Exception as err: + _LOGGER.error("Failed to login to Emporia Vue: %s", err) return False try: devices = await loop.run_in_executor(None, vue.get_devices) for device in devices: - if device.device_gid not in DEVICE_GIDS: - DEVICE_GIDS.append(device.device_gid) + 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 @@ -109,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): DEVICE_INFORMATION[device.device_gid].channels += device.channels total_channels = 0 - for _, device in DEVICE_INFORMATION.items(): + for device in DEVICE_INFORMATION.values(): total_channels += len(device.channels) _LOGGER.info( "Found %s Emporia devices with %s total channels", @@ -142,7 +147,7 @@ async def async_update_data_1mon(): async def async_update_day_sensors(): global LAST_DAY_UPDATE global LAST_DAY_DATA - now = datetime.now(timezone.utc) + now = 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 @@ -164,7 +169,7 @@ async def async_update_day_sensors(): ): # if we just passed midnight, then reset back to zero timestamp: datetime = data["timestamp"] - check_for_midnight(timestamp, int(device_gid), day_id) + await check_for_midnight(timestamp, int(device_gid), day_id) LAST_DAY_DATA[day_id]["usage"] += data[ "usage" @@ -284,7 +289,11 @@ async def handle_set_charger_current(call): try: updated_charger = await loop.run_in_executor( - None, vue.update_charger, charger_info.ev_charger, state.state == "on", current + None, + vue.update_charger, + charger_info.ev_charger, + state.state == "on", + current, ) DEVICE_INFORMATION[charger_gid].ev_charger = updated_charger # update the state of the charger entity using the updated data @@ -294,7 +303,9 @@ async def handle_set_charger_current(call): newAttributes = state.attributes.copy() newAttributes["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) + hass.states.async_set( + charger_entity.entity_id, newState, newAttributes + ) except requests.exceptions.HTTPError as err: _LOGGER.error( @@ -312,7 +323,7 @@ async def handle_set_charger_current(call): _LOGGER.warning("Exception while setting up Emporia Vue. Will retry. %s", err) raise ConfigEntryNotReady( f"Exception while setting up Emporia Vue. Will retry. {err}" - ) + ) from err hass.data[DOMAIN][entry.entry_id] = { VUE_DATA: vue, @@ -322,18 +333,15 @@ async def handle_set_charger_current(call): } try: - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except Exception as err: _LOGGER.warning("Error setting up platforms: %s", err) - raise ConfigEntryNotReady(f"Error setting up platforms: {err}") + raise ConfigEntryNotReady(f"Error setting up platforms: {err}") from err return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = all( await asyncio.gather( @@ -357,7 +365,7 @@ async def update_sensors(vue: PyEmVue, scales: list[str]): data = {} loop = asyncio.get_event_loop() for scale in scales: - utcnow = datetime.now(timezone.utc) + utcnow = datetime.now(UTC) usage_dict = await loop.run_in_executor( None, vue.get_device_list_usage, DEVICE_GIDS, utcnow, scale ) @@ -383,7 +391,7 @@ async def update_sensors(vue: PyEmVue, scales: list[str]): return data except Exception as err: _LOGGER.error("Error communicating with Emporia API: %s", err) - raise UpdateFailed(f"Error communicating with Emporia API: {err}") + raise UpdateFailed(f"Error communicating with Emporia API: {err}") from err def flatten_usage_data( @@ -392,11 +400,11 @@ def flatten_usage_data( ) -> tuple[dict[str, VueDeviceChannelUsage], datetime]: """Flattens the raw usage data into a dictionary of channel ids and usage info.""" flattened: dict[str, VueDeviceChannelUsage] = {} - data_time: datetime = datetime.now(timezone.utc) - for _, usage in usage_devices.items(): + data_time: datetime = datetime.now(UTC) + for usage in usage_devices.values(): data_time = usage.timestamp or data_time if usage.channels: - for _, channel in usage.channels.items(): + for channel in usage.channels.values(): identifier = make_channel_id(channel, scale) flattened[identifier] = channel if channel.nested_devices: @@ -417,8 +425,10 @@ async def parse_flattened_usage_data( """Loop through the device list and find the corresponding update data.""" unused_data = flattened_data.copy() for gid, info in DEVICE_INFORMATION.items(): - local_time = change_time_to_local(data_time, info.time_zone) - requested_time_local = change_time_to_local(requested_time, info.time_zone) + local_time = await change_time_to_local(data_time, info.time_zone) + requested_time_local = 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", @@ -428,9 +438,7 @@ async def parse_flattened_usage_data( for info_channel in info.channels: identifier = make_channel_id(info_channel, scale) channel_num = info_channel.channel_num - channel = ( - flattened_data[identifier] if identifier in flattened_data else None - ) + channel = flattened_data.get(identifier) if not channel: _LOGGER.info( "Could not find usage info for device %s channel %s", @@ -464,7 +472,9 @@ async def parse_flattened_usage_data( fixed_usage, ) - fixed_usage = fix_usage_sign(channel_num, fixed_usage) + fixed_usage = fix_usage_sign( + channel_num, fixed_usage, "bidirectional" in info_channel.type.lower() + ) data[identifier] = { "device_gid": gid, @@ -478,18 +488,20 @@ async def parse_flattened_usage_data( if unused_data: # unused_data is not json serializable because VueDeviceChannelUsage is not JSON serializable # instead print out dictionary as a string - _LOGGER.warning( + _LOGGER.info( "Unused data found during update. Unused data: %s", str(unused_data), ) channels_were_added = False - for _, channel in unused_data.items(): + 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 if channels_were_added: _LOGGER.info("Rerunning update due to added channels") - await parse_flattened_usage_data(flattened_data, scale, data, requested_time, data_time) + await parse_flattened_usage_data( + flattened_data, scale, data, requested_time, data_time + ) async def handle_special_channels_for_device(channel: VueDeviceChannel) -> bool: @@ -533,16 +545,16 @@ 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, - # ) + # 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 @@ -552,27 +564,29 @@ def make_channel_id(channel: VueDeviceChannel, scale: str): return f"{channel.device_gid}-{channel.channel_num}-{scale}" -def fix_usage_sign(channel_num: str, usage: float): +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).""" - if usage and channel_num not in ["1,2,3", "Balance"]: + 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. return abs(usage) return usage -def change_time_to_local(time: datetime, tz_string: str): +async def change_time_to_local(time: datetime, tz_string: str): """Change the datetime to the provided timezone, if not already.""" - tz_info = dateutil.tz.gettz(tz_string) + loop = asyncio.get_event_loop() + tz_info = 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=timezone.utc) + time = time.replace(tzinfo=UTC) return time.astimezone(tz_info) -def check_for_midnight(timestamp: datetime, device_gid: int, day_id: 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 = change_time_to_local(timestamp, device_info.time_zone) + 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) last_reset = LAST_DAY_DATA[day_id]["reset"] if local_midnight > last_reset: diff --git a/custom_components/emporia_vue/charger_entity.py b/custom_components/emporia_vue/charger_entity.py index a75a7c3..c947d9c 100644 --- a/custom_components/emporia_vue/charger_entity.py +++ b/custom_components/emporia_vue/charger_entity.py @@ -4,6 +4,7 @@ from pyemvue import pyemvue from pyemvue.device import ChargerDevice, VueDevice +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -30,10 +31,16 @@ def __init__( self._attr_unit_of_measurement = units self._attr_device_class = device_class - self._attr_name = device.device_name + self._attr_has_entity_name = True + self._attr_name = None @property - def entity_registry_enabled_default(self): + def available(self) -> bool: + """Return True if entity is available.""" + return self._device + + @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 @@ -60,17 +67,14 @@ def unique_id(self) -> str: return f"charger.emporia_vue.{self._device.device_gid}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information.""" - return { - "identifiers": {(DOMAIN, f"{self._device.device_gid}-1,2,3")}, - "name": self._device.device_name + "-1,2,3", - "model": self._device.model, - "sw_version": self._device.firmware, - "manufacturer": "Emporia", - } + return DeviceInfo( + identifiers={(DOMAIN, f"{self._device.device_gid}-1,2,3")}, + name=self._device.device_name, + model=self._device.model, + sw_version=self._device.firmware, + manufacturer="Emporia", + ) + - @property - def available(self): - """Return True if entity is available.""" - return self._device diff --git a/custom_components/emporia_vue/config_flow.py b/custom_components/emporia_vue/config_flow.py index b9251e0..ce5c9cf 100644 --- a/custom_components/emporia_vue/config_flow.py +++ b/custom_components/emporia_vue/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Emporia Vue integration.""" + import asyncio import logging @@ -18,7 +19,7 @@ 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 + vol.Optional(ENABLE_1MON, default=True): bool, } ) @@ -26,16 +27,14 @@ class VueHub: """Hub for the Emporia Vue Integration.""" - def __init__(self): + def __init__(self) -> None: """Initialize.""" self.vue = PyEmVue() - pass async def authenticate(self, username, password) -> bool: """Test if we can authenticate with the host.""" loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, self.vue.login, username, password) - return result + return await loop.run_in_executor(None, self.vue.login, username, password) async def validate_input(hass: core.HomeAssistant, data): @@ -58,7 +57,7 @@ async def validate_input(hass: core.HomeAssistant, data): "gid": f"{hub.vue.customer.customer_gid}", ENABLE_1M: data[ENABLE_1M], ENABLE_1D: data[ENABLE_1D], - ENABLE_1MON: data[ENABLE_1MON] + ENABLE_1MON: data[ENABLE_1MON], } @@ -68,13 +67,13 @@ 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): + async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult: """Handle the initial step.""" errors = {} if user_input is not None: try: info = await validate_input(self.hass, user_input) - #prevent setting up the same account twice + # prevent setting up the same account twice await self.async_set_unique_id(info["gid"]) self._abort_if_unique_id_configured() diff --git a/custom_components/emporia_vue/manifest.json b/custom_components/emporia_vue/manifest.json index eaba9e1..08654bf 100644 --- a/custom_components/emporia_vue/manifest.json +++ b/custom_components/emporia_vue/manifest.json @@ -9,8 +9,8 @@ "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/magico13/ha-emporia-vue/issues", - "requirements": ["pyemvue==0.18.4"], + "requirements": ["pyemvue==0.18.6"], "ssdp": [], - "version": "0.9.3", + "version": "0.10.0", "zeroconf": [] } diff --git a/custom_components/emporia_vue/sensor.py b/custom_components/emporia_vue/sensor.py index 28569f8..5da4d61 100644 --- a/custom_components/emporia_vue/sensor.py +++ b/custom_components/emporia_vue/sensor.py @@ -1,6 +1,7 @@ """Platform for sensor integration.""" + +from datetime import datetime import logging -from typing import Optional from pyemvue.device import VueDevice, VueDeviceChannel from pyemvue.enums import Scale @@ -10,8 +11,11 @@ SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower 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 .const import DOMAIN @@ -20,7 +24,11 @@ # def setup_platform(hass, config, add_entities, discovery_info=None): -async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the sensor platform.""" coordinator_1min = hass.data[DOMAIN][config_entry.entry_id]["coordinator_1min"] coordinator_1mon = hass.data[DOMAIN][config_entry.entry_id]["coordinator_1mon"] @@ -32,20 +40,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie if coordinator_1min: async_add_entities( - CurrentVuePowerSensor(coordinator_1min, id) - for _, id in enumerate(coordinator_1min.data) + CurrentVuePowerSensor(coordinator_1min, identifier) + for _, identifier in enumerate(coordinator_1min.data) ) if coordinator_1mon: async_add_entities( - CurrentVuePowerSensor(coordinator_1mon, id) - for _, id in enumerate(coordinator_1mon.data) + CurrentVuePowerSensor(coordinator_1mon, identifier) + for _, identifier in enumerate(coordinator_1mon.data) ) if coordinator_day_sensor: async_add_entities( - CurrentVuePowerSensor(coordinator_day_sensor, id) - for _, id in enumerate(coordinator_day_sensor.data) + CurrentVuePowerSensor(coordinator_day_sensor, identifier) + for _, identifier in enumerate(coordinator_day_sensor.data) ) @@ -60,13 +68,13 @@ def __init__(self, coordinator, identifier) -> None: device_gid: int = coordinator.data[identifier]["device_gid"] channel_num: str = coordinator.data[identifier]["channel_num"] self._device: VueDevice = coordinator.data[identifier]["info"] - self._channel: Optional[VueDeviceChannel] = None + final_channel: VueDeviceChannel | None = None if self._device is not None: for channel in self._device.channels: if channel.channel_num == channel_num: - self._channel = channel + final_channel = channel break - if self._channel is None: + if final_channel is None: _LOGGER.warning( "No channel found for device_gid %s and channel_num %s", device_gid, @@ -75,81 +83,69 @@ def __init__(self, coordinator, identifier) -> None: raise RuntimeError( f"No channel found for device_gid {device_gid} and channel_num {channel_num}" ) - device_name = self._device.device_name - if self._channel.name and self._channel.name not in [ - "Main", - "Balance", - "TotalUsage", - "MainsToGrid", - "MainsFromGrid", - ]: - device_name = self._channel.name - self._name = f"{device_name} {channel_num} {self._scale}" + self._channel: VueDeviceChannel = final_channel self._iskwh = self.scale_is_energy() - self._attr_name = self._name + self._attr_has_entity_name = True if self._iskwh: self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR self._attr_device_class = SensorDeviceClass.ENERGY self._attr_state_class = SensorStateClass.TOTAL + self._attr_suggested_display_precision = 3 + self._attr_name = f"Energy {self.scale_readable()}" else: self._attr_native_unit_of_measurement = UnitOfPower.WATT self._attr_device_class = SensorDeviceClass.POWER self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_suggested_display_precision = 1 + self._attr_name = f"Power {self.scale_readable()}" @property - def native_value(self): - """Return the state of the sensor.""" - if self._id in self.coordinator.data: - usage = self.coordinator.data[self._id]["usage"] - return self.scale_usage(usage) if usage is not None else None - return None + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device_name = self._channel.name or self._device.device_name + return DeviceInfo( + identifiers={ + (DOMAIN, f"{self._device.device_gid}-{self._channel.channel_num}") + }, + name=device_name, + model=self._device.model, + sw_version=self._device.firmware, + manufacturer="Emporia", + ) @property - def last_reset(self): + 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 - def unique_id(self): + def native_value(self) -> float | None: + """Return the state of the sensor.""" + if self._id in self.coordinator.data: + usage = self.coordinator.data[self._id]["usage"] + return self.scale_usage(usage) if usage is not None else None + return None + + @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}" - @property - def device_info(self): - """Return the device info.""" - device_name = self._channel.name or self._device.device_name - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - ( - DOMAIN, - f"{self._device.device_gid}-{self._channel.channel_num}", - ) - }, - "name": device_name, - "model": self._device.model, - "sw_version": self._device.firmware, - "manufacturer": "Emporia" - # "via_device": self._device.device_gid # might be able to map the extender, nested outlets - } - def scale_usage(self, usage): """Scales the usage to the correct timescale and magnitude.""" if self._scale == Scale.MINUTE.value: - usage = round(60 * 1000 * usage) # convert from kwh to w rate + usage = 60 * 1000 * usage # convert from kwh to w rate elif self._scale == Scale.SECOND.value: - usage = round(3600 * 1000 * usage) # convert to rate + usage = 3600 * 1000 * usage # convert to rate elif self._scale == Scale.MINUTES_15.value: - usage = round( + usage = ( 4 * 1000 * usage ) # this might never be used but for safety, convert to rate - else: - usage = round(usage, 3) return usage def scale_is_energy(self): @@ -159,3 +155,13 @@ def scale_is_energy(self): Scale.SECOND.value, Scale.MINUTES_15.value, ) + + def scale_readable(self): + """Return a human readable scale.""" + if self._scale == Scale.MINUTE.value: + return "Minute Average" + if self._scale == Scale.DAY.value: + return "Today" + if self._scale == Scale.MONTH.value: + return "This Month" + return self._scale diff --git a/custom_components/emporia_vue/switch.py b/custom_components/emporia_vue/switch.py index cffeb65..9c89c97 100644 --- a/custom_components/emporia_vue/switch.py +++ b/custom_components/emporia_vue/switch.py @@ -2,19 +2,21 @@ import asyncio from datetime import timedelta import logging +from typing import Any -from pyemvue.device import ChargerDevice, OutletDevice, VueDevice -import requests +from requests import exceptions from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry 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, DataUpdateCoordinator, UpdateFailed, ) +from pyemvue.device import ChargerDevice, OutletDevice, VueDevice from .charger_entity import EmporiaChargerEntity from .const import DOMAIN, VUE_DATA @@ -28,7 +30,7 @@ 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] @@ -63,7 +65,7 @@ async def async_update_data(): data[charger.device_gid] = charger return data except Exception as err: - raise UpdateFailed(f"Error communicating with Emporia API: {err}") + raise UpdateFailed(f"Error communicating with Emporia API: {err}") from err coordinator = DataUpdateCoordinator( hass, @@ -107,20 +109,11 @@ def __init__(self, coordinator, vue, gid) -> None: self._vue = vue self._device_gid = gid self._device = device_information[gid] - self._name = f"Switch {self._device.device_name}" + self._attr_has_entity_name = True + self._attr_name = None self._attr_device_class = SwitchDeviceClass.OUTLET - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return the state of the switch.""" - return self.coordinator.data[self._device_gid].outlet_on - - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -128,7 +121,7 @@ async def async_turn_on(self, **kwargs): ) await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -140,42 +133,42 @@ async def async_turn_off(self, **kwargs): await self.coordinator.async_request_refresh() @property - def unique_id(self): - """Unique ID for the switch.""" - return f"switch.emporia_vue.{self._device_gid}" + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + identifiers={(DOMAIN, f"{self._device_gid}-1,2,3")}, + name=self._device.device_name, + model=self._device.model, + sw_version=self._device.firmware, + manufacturer="Emporia", + ) @property - def device_info(self): - """Return the device information.""" - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, f"{self._device_gid}-1,2,3") - }, - "name": self._device.device_name + "-1,2,3", - "model": self._device.model, - "sw_version": self._device.firmware, - "manufacturer": "Emporia" - # "via_device": self._device.device_gid # might be able to map the extender, nested outlets - } + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.coordinator.data[self._device_gid].outlet_on + @property + def unique_id(self) -> str: + """Unique ID for the switch.""" + return f"switch.emporia_vue.{self._device_gid}" class EmporiaChargerSwitch(EmporiaChargerEntity, SwitchEntity): """Representation of an Emporia Charger switch state.""" - @property - def is_on(self): - """Return the state of the switch.""" - return self.coordinator.data[self._device.device_gid].charger_on - - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the charger on.""" await self._update_switch(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the charger off.""" await self._update_switch(False) + @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): """Update the switch.""" loop = asyncio.get_event_loop() @@ -186,7 +179,7 @@ async def _update_switch(self, on: bool): self._coordinator.data[self._device.device_gid], on, ) - except requests.exceptions.HTTPError as err: + except exceptions.HTTPError as err: _LOGGER.error( "Error updating charger status: %s \nResponse body: %s", err, diff --git a/custom_components/emporia_vue/translations/en.json b/custom_components/emporia_vue/translations/en.json index bd6199a..b27c950 100644 --- a/custom_components/emporia_vue/translations/en.json +++ b/custom_components/emporia_vue/translations/en.json @@ -4,7 +4,7 @@ "already_configured": "Device is already configured" }, "error": { - "cannot_connect": "Failed to connect, please try again", + "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, @@ -12,11 +12,11 @@ "user": { "data": { "email": "Email", - "password": "Password", - "enable_1s": "One Second Sensor", - "enable_1m": "One Minute Sensor", - "enable_1d": "One Day Sensor", - "enable_1mon": "One Month Sensor" + "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" } } }