diff --git a/.gitignore b/.gitignore index 5a714f3..aea0772 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .vscode +.vs **/__pycache__ /dist /build diff --git a/custom_components/emporia_vue/__init__.py b/custom_components/emporia_vue/__init__.py index dd3b5ed..a566667 100644 --- a/custom_components/emporia_vue/__init__.py +++ b/custom_components/emporia_vue/__init__.py @@ -1,9 +1,9 @@ """The Emporia Vue integration.""" import asyncio from datetime import datetime, timedelta, timezone -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - +import dateutil import logging + from pyemvue import PyEmVue from pyemvue.device import VueDeviceChannel from pyemvue.enums import Scale @@ -11,11 +11,11 @@ from pyemvue.enums import Scale import voluptuous as vol from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, VUE_DATA, ENABLE_1M, ENABLE_1D, ENABLE_1MON @@ -141,8 +141,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # integrate the minute data _LOGGER.info("Integrating minute data into day sensors") if last_minute_data: - for id, data in last_minute_data.items(): - day_id = id.rsplit("-", 1)[0] + "-" + Scale.DAY.value + for identifier, data in last_minute_data.items(): + day_id = identifier.rsplit("-", 1)[0] + "-" + Scale.DAY.value if ( data and last_day_data @@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): update_interval=timedelta(minutes=1), ) await coordinator_1min.async_config_entry_first_refresh() - _LOGGER.info(f"1min Update data: {coordinator_1min.data}") + _LOGGER.info("1min Update data: %s", coordinator_1min.data) coordinator_1hr = None if ENABLE_1MON not in entry_data or entry_data[ENABLE_1MON]: coordinator_1hr = DataUpdateCoordinator( @@ -197,7 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await coordinator_day_sensor.async_config_entry_first_refresh() except Exception as err: - _LOGGER.warn(f"Exception while setting up Emporia Vue. Will retry. {err}") + _LOGGER.warning("Exception while setting up Emporia Vue. Will retry. %s", err) raise ConfigEntryNotReady( f"Exception while setting up Emporia Vue. Will retry. {err}" ) @@ -215,7 +215,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) except Exception as err: - _LOGGER.warn(f"Error setting up platforms: {err}") + _LOGGER.warning("Error setting up platforms: %s", err) raise ConfigEntryNotReady(f"Error setting up platforms: {err}") return True @@ -249,8 +249,8 @@ async def update_sensors(vue, scales): None, vue.get_device_list_usage, device_gids, utcnow, scale ) if not usage_dict: - _LOGGER.warn( - f"No channels found during update for scale {scale}. Retrying..." + _LOGGER.warning( + "No channels found during update for scale %s. Retrying", scale ) usage_dict = await loop.run_in_executor( None, vue.get_device_list_usage, device_gids, utcnow, scale @@ -262,7 +262,7 @@ async def update_sensors(vue, scales): return data except Exception as err: - _LOGGER.error(f"Error communicating with Emporia API: {err}") + _LOGGER.error("Error communicating with Emporia API: %s", err) raise UpdateFailed(f"Error communicating with Emporia API: {err}") @@ -272,12 +272,25 @@ def recurse_usage_data(usage_devices, scale, data): if not channel: continue reset_datetime = None - id = make_channel_id(channel, scale) + identifier = make_channel_id(channel, scale) info = find_device_info_for_channel(channel) if scale in [Scale.DAY.value, Scale.MONTH.value]: - reset_datetime = device.timestamp + # 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 (but could we keep resetting daily?) - data[id] = { + # in either case, convert the given timestamp to local time first + + local_time = change_time_to_local(device.timestamp, info.time_zone) + # take the timestamp, convert to midnight + reset_datetime = local_time.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + _LOGGER.info( + "Reset time for %s is %s", identifier, reset_datetime.isoformat() + ) + + data[identifier] = { "device_gid": gid, "channel_num": channel_num, "usage": fix_usage_sign(channel_num, channel.usage), @@ -305,11 +318,13 @@ def find_device_info_for_channel(channel): if device_channel.channel_num == channel.channel_num: found = True break - elif device_channel.channel_num == "1,2,3": + if device_channel.channel_num == "1,2,3": channel_123 = device_channel if not found: _LOGGER.info( - f"Adding channel for channel {channel.device_gid}-{channel.channel_num}" + "Adding channel for channel %s-%s", + channel.device_gid, + channel.channel_num, ) device_info.channels.append( VueDeviceChannel( @@ -332,6 +347,15 @@ def fix_usage_sign(channel_num, usage): """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"]: return abs(usage) - elif not usage: + if not usage: usage = 0 return usage + + +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) + if not time.tzinfo or time.tzinfo.utcoffset(time) is None: + # unaware, assume it's already utc + time = time.replace(tzinfo=datetime.timezone.utc) + return time.astimezone(tz_info) diff --git a/custom_components/emporia_vue/sensor.py b/custom_components/emporia_vue/sensor.py index 7f182a9..20b5c7a 100644 --- a/custom_components/emporia_vue/sensor.py +++ b/custom_components/emporia_vue/sensor.py @@ -1,6 +1,6 @@ """Platform for sensor integration.""" from homeassistant.components.sensor import ( - STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, STATE_CLASS_MEASUREMENT, SensorEntity, ) @@ -56,14 +56,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): """Representation of a Vue Sensor's current power.""" - def __init__(self, coordinator, id): + def __init__(self, coordinator, identifier): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) - self._id = id - self._scale = coordinator.data[id]["scale"] - device_gid = coordinator.data[id]["device_gid"] - channel_num = coordinator.data[id]["channel_num"] - self._device = coordinator.data[id]["info"] + self._id = identifier + self._scale = coordinator.data[identifier]["scale"] + device_gid = coordinator.data[identifier]["device_gid"] + channel_num = coordinator.data[identifier]["channel_num"] + self._device = coordinator.data[identifier]["info"] self._channel = None if self._device is not None: for channel in self._device.channels: @@ -71,53 +71,42 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): self._channel = channel break if self._channel is None: - _LOGGER.warn( - f"No channel found for device_gid {device_gid} and channel_num {channel_num}" + _LOGGER.warning( + "No channel found for device_gid %s and channel_num %s", + device_gid, + channel_num, ) raise RuntimeError( f"No channel found for device_gid {device_gid} and channel_num {channel_num}" ) - dName = self._device.device_name - if self._channel.name and self._channel.name not in ["Main", "Balance"]: - dName = self._channel.name - self._name = f"{dName} {channel_num} {self._scale}" + 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._iskwh = self.scale_is_energy() - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = self._name + if self._iskwh: + self._attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_state_class = STATE_CLASS_TOTAL + else: + self._attr_native_unit_of_measurement = POWER_WATT + self._attr_device_class = DEVICE_CLASS_POWER + self._attr_state_class = STATE_CLASS_MEASUREMENT @property - def state(self): + def native_value(self): """Return the state of the sensor.""" usage = self.coordinator.data[self._id]["usage"] return self.scale_usage(usage) - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - if self._iskwh: - return ENERGY_KILO_WATT_HOUR - else: - return POWER_WATT - - @property - def device_class(self): - """The type of sensor""" - if self._iskwh: - return DEVICE_CLASS_ENERGY - else: - return DEVICE_CLASS_POWER - - @property - def state_class(self): - """Type of state.""" - if self._iskwh: - return STATE_CLASS_TOTAL_INCREASING - else: - return STATE_CLASS_MEASUREMENT - @property def last_reset(self): """The time when the daily/monthly sensor was reset. Midnight local time.""" @@ -128,13 +117,11 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): """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}" - else: - return f"sensor.emporia_vue.{self._scale}.{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): - dName = self._channel.name or self._device.device_name - + device_name = self._channel.name or self._device.device_name return { "identifiers": { # Serial numbers are unique identifiers within a specific domain @@ -145,7 +132,7 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): ), ) }, - "name": dName, + "name": device_name, "model": self._device.model, "sw_version": self._device.firmware, "manufacturer": "Emporia" @@ -168,8 +155,8 @@ class CurrentVuePowerSensor(CoordinatorEntity, SensorEntity): def scale_is_energy(self): """Returns True if the scale is an energy unit instead of power (hour and bigger)""" - return ( - self._scale != Scale.MINUTE.value - and self._scale != Scale.SECOND.value - and self._scale != Scale.MINUTES_15.value + return self._scale not in ( + Scale.MINUTE.value, + Scale.SECOND.value, + Scale.MINUTES_15.value, )