commit c06d286b3ca1b83cda27467212f7974fb55ce237 Author: magico13 Date: Sat Mar 14 18:24:27 2020 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a714f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vscode +**/__pycache__ +/dist +/build +*.egg-info \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..61d235a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Michael Marvin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d3219a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# emporia_vue Home Assistant Integration + +Reads data from the Emporia Vue energy monitor. Creates a sensor for each device channel showing average usage over each minute. diff --git a/custom_components/emporia_vue/.translations/en.json b/custom_components/emporia_vue/.translations/en.json new file mode 100644 index 0000000..0fd2b94 --- /dev/null +++ b/custom_components/emporia_vue/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Connect to the device" + } + }, + "title": "Emporia Vue" + } +} \ No newline at end of file diff --git a/custom_components/emporia_vue/__init__.py b/custom_components/emporia_vue/__init__.py new file mode 100644 index 0000000..83ebe1f --- /dev/null +++ b/custom_components/emporia_vue/__init__.py @@ -0,0 +1,101 @@ +"""The Emporia Vue integration.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from pyemvue import PyEmVue +from pyemvue.device import VueDevice, VueDeviceChannel + +from .const import DOMAIN, VUE_DATA + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Emporia Vue component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_EMAIL: conf[CONF_EMAIL], + CONF_PASSWORD: conf[CONF_PASSWORD], + }, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Emporia Vue from a config entry.""" + entry_data = entry.data + email = entry_data[CONF_EMAIL] + password = entry_data[CONF_PASSWORD] + _LOGGER.info(entry_data) + vue = PyEmVue() + try: + result = vue.login(username=email, password=password) + if not result: + raise Exception("Could not authenticate with Emporia API") + return False + except Exception: + _LOGGER.error("Could not authenticate with Emporia API") + return False + + # Get device data from Emporia API + #discovered_devices = vue.get_devices() + + hass.data[DOMAIN][entry.entry_id] = { + VUE_DATA: vue, + #VUE_DEVICES_DATA: VueDeviceData(), + #VUE_DISCOVERED_DEVICES_DATA: discovered_devices, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/emporia_vue/config_flow.py b/custom_components/emporia_vue/config_flow.py new file mode 100644 index 0000000..c3d81d6 --- /dev/null +++ b/custom_components/emporia_vue/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for Emporia Vue integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN # pylint:disable=unused-import + +from pyemvue import PyEmVue + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str}) + + +class VueHub: + """Hub for the Emporia Vue Integration.""" + + def __init__(self): + """Initialize.""" + self.vue = PyEmVue() + pass + + async def authenticate(self, username, password) -> bool: + """Test if we can authenticate with the host.""" + return self.vue.login(username=username, password=password) + + +async def validate_input(hass: core.HomeAssistant, data): + """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 + + # If you cannot connect: + # throw CannotConnect + # If the authentication is wrong: + # InvalidAuth + + # Return info that you want to store in the config entry. + return { + "title": f"Customer {hub.vue.customer.customer_gid}", + "gid": f"{hub.vue.customer.customer_gid}" + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Emporia Vue.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """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 + 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) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/emporia_vue/const.py b/custom_components/emporia_vue/const.py new file mode 100644 index 0000000..8c22bf7 --- /dev/null +++ b/custom_components/emporia_vue/const.py @@ -0,0 +1,4 @@ +"""Constants for the Emporia Vue integration.""" + +DOMAIN = "emporia_vue" +VUE_DATA = "vue_data" \ No newline at end of file diff --git a/custom_components/emporia_vue/manifest.json b/custom_components/emporia_vue/manifest.json new file mode 100644 index 0000000..e892014 --- /dev/null +++ b/custom_components/emporia_vue/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "emporia_vue", + "name": "Emporia Vue", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/emporia_vue", + "requirements": [ + "pyemvue==0.9.3" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@magico13" + ] +} \ No newline at end of file diff --git a/custom_components/emporia_vue/sensor.py b/custom_components/emporia_vue/sensor.py new file mode 100644 index 0000000..337c1ef --- /dev/null +++ b/custom_components/emporia_vue/sensor.py @@ -0,0 +1,77 @@ +"""Platform for sensor integration.""" +from homeassistant.const import DEVICE_CLASS_POWER, POWER_WATT, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, VUE_DATA + +from pyemvue import pyemvue +from pyemvue.enums import Scale +from pyemvue.device import VueDevice, VueDeviceChannel, VuewDeviceChannelUsage + +#def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, add_entities): + """Set up the sensor platform.""" + vue = hass.data[DOMAIN][config_entry.entry_id][VUE_DATA] + vue_devices = vue.get_devices() + + # Add a sensor for each device channel + devices = [] + for device in vue_devices: + for channel in device.channels: + devices.append(CurrentVuePowerSensor(vue, channel)) + + add_entities(devices) + + +class CurrentVuePowerSensor(Entity): + """Representation of a Vue Sensor's current power.""" + + def __init__(self, vue, channel): + """Initialize the sensor.""" + self._state = None + self._vue = vue + self._channel = channel + + @property + def name(self): + """Return the name of the sensor.""" + return f'Power {self._channel.device_gid} {self._channel.channel_num}' + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return POWER_WATT + + @property + def device_class(self): + """The type of sensor""" + return DEVICE_CLASS_POWER + + @property + def unique_id(self): + """Unique ID for the sensor""" + return f"sensor.emporia_vue.instant.{self._channel.device_gid}-{self._channel.channel_num}" + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + gid = self._channel.device_gid + num = self._channel.channel_num + + # TODO: each sensor shouldn't do this separately + channels = self._vue.get_recent_usage(scale=Scale.MINUTE.value) + if channels: + for channel in channels: + if channel.device_gid == gid and channel.channel_num == num: + self._state = round(channel.usage) + return + + self._state = None + return diff --git a/custom_components/emporia_vue/strings.json b/custom_components/emporia_vue/strings.json new file mode 100644 index 0000000..527e640 --- /dev/null +++ b/custom_components/emporia_vue/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Emporia Vue", + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Host" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file