From e925fd2228be092db4392cf4fcccc221aedf2cc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Apr 2021 22:11:23 -1000 Subject: [PATCH] Add emonitor integration (#48310) Co-authored-by: Paulus Schoutsen --- .coveragerc | 2 + CODEOWNERS | 1 + homeassistant/components/emonitor/__init__.py | 67 +++++++ .../components/emonitor/config_flow.py | 104 ++++++++++ homeassistant/components/emonitor/const.py | 3 + .../components/emonitor/manifest.json | 13 ++ homeassistant/components/emonitor/sensor.py | 108 ++++++++++ .../components/emonitor/strings.json | 23 +++ .../components/emonitor/translations/en.json | 23 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/emonitor/__init__.py | 1 + tests/components/emonitor/test_config_flow.py | 187 ++++++++++++++++++ 15 files changed, 544 insertions(+) create mode 100644 homeassistant/components/emonitor/__init__.py create mode 100644 homeassistant/components/emonitor/config_flow.py create mode 100644 homeassistant/components/emonitor/const.py create mode 100644 homeassistant/components/emonitor/manifest.json create mode 100644 homeassistant/components/emonitor/sensor.py create mode 100644 homeassistant/components/emonitor/strings.json create mode 100644 homeassistant/components/emonitor/translations/en.json create mode 100644 tests/components/emonitor/__init__.py create mode 100644 tests/components/emonitor/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 519e3a80fd9..f3cdf62ff73 100644 --- a/.coveragerc +++ b/.coveragerc @@ -238,6 +238,8 @@ omit = homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* + homeassistant/components/emonitor/__init__.py + homeassistant/components/emonitor/sensor.py homeassistant/components/enigma2/media_player.py homeassistant/components/enocean/__init__.py homeassistant/components/enocean/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 62e1871192c..a863b469cdf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -134,6 +134,7 @@ homeassistant/components/elkm1/* @gwww @bdraco homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/emoncms/* @borpin +homeassistant/components/emonitor/* @bdraco homeassistant/components/emulated_kasa/* @kbickar homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py new file mode 100644 index 00000000000..74630a193a4 --- /dev/null +++ b/homeassistant/components/emonitor/__init__.py @@ -0,0 +1,67 @@ +"""The SiteSage Emonitor integration.""" +import asyncio +from datetime import timedelta +import logging + +from aioemonitor import Emonitor + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_UPDATE_RATE = 60 + +PLATFORMS = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up SiteSage Emonitor from a config entry.""" + + session = aiohttp_client.async_get_clientsession(hass) + emonitor = Emonitor(entry.data[CONF_HOST], session) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=entry.title, + update_method=emonitor.async_get_status, + update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + 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, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def name_short_mac(short_mac): + """Name from short mac.""" + return f"Emonitor {short_mac}" diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py new file mode 100644 index 00000000000..bb18f03e3af --- /dev/null +++ b/homeassistant/components/emonitor/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for SiteSage Emonitor integration.""" +import logging + +from aioemonitor import Emonitor +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import format_mac + +from . import name_short_mac +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +async def fetch_mac_and_title(hass: core.HomeAssistant, host): + """Validate the user input allows us to connect.""" + session = aiohttp_client.async_get_clientsession(hass) + emonitor = Emonitor(host, session) + status = await emonitor.async_get_status() + mac_address = status.network.mac_address + return {"title": name_short_mac(mac_address[-6:]), "mac_address": mac_address} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SiteSage Emonitor.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize Emonitor ConfigFlow.""" + self.discovered_ip = None + self.discovered_info = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await fetch_mac_and_title(self.hass, user_input[CONF_HOST]) + except aiohttp.ClientError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + format_mac(info["mac_address"]), raise_on_progress=False + ) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required("host", default=self.discovered_ip): str} + ), + errors=errors, + ) + + async def async_step_dhcp(self, dhcp_discovery): + """Handle dhcp discovery.""" + self.discovered_ip = dhcp_discovery[IP_ADDRESS] + await self.async_set_unique_id(format_mac(dhcp_discovery[MAC_ADDRESS])) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.discovered_ip}) + name = name_short_mac(short_mac(dhcp_discovery[MAC_ADDRESS])) + self.context["title_placeholders"] = {"name": name} + try: + self.discovered_info = await fetch_mac_and_title( + self.hass, self.discovered_ip + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.debug( + "Unable to fetch status, falling back to manual entry", exc_info=ex + ) + return await self.async_step_user() + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Attempt to confim.""" + if user_input is not None: + return self.async_create_entry( + title=self.discovered_info["title"], + data={CONF_HOST: self.discovered_ip}, + ) + + self._set_confirm_only() + self.context["title_placeholders"] = {"name": self.discovered_info["title"]} + return self.async_show_form( + step_id="confirm", + description_placeholders={ + CONF_HOST: self.discovered_ip, + CONF_NAME: self.discovered_info["title"], + }, + ) + + +def short_mac(mac): + """Short version of the mac.""" + return "".join(mac.split(":")[3:]).upper() diff --git a/homeassistant/components/emonitor/const.py b/homeassistant/components/emonitor/const.py new file mode 100644 index 00000000000..e39aea46284 --- /dev/null +++ b/homeassistant/components/emonitor/const.py @@ -0,0 +1,3 @@ +"""Constants for the SiteSage Emonitor integration.""" + +DOMAIN = "emonitor" diff --git a/homeassistant/components/emonitor/manifest.json b/homeassistant/components/emonitor/manifest.json new file mode 100644 index 00000000000..b6cf3526bd8 --- /dev/null +++ b/homeassistant/components/emonitor/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "emonitor", + "name": "SiteSage Emonitor", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/emonitor", + "requirements": [ + "aioemonitor==1.0.5" + ], + "dhcp": [{"hostname":"emonitor*","macaddress":"0090C2*"}], + "codeowners": [ + "@bdraco" + ] +} \ No newline at end of file diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py new file mode 100644 index 00000000000..3b075f7cbaa --- /dev/null +++ b/homeassistant/components/emonitor/sensor.py @@ -0,0 +1,108 @@ +"""Support for a Emonitor channel sensor.""" + +from aioemonitor.monitor import EmonitorChannel + +from homeassistant.components.sensor import DEVICE_CLASS_POWER, SensorEntity +from homeassistant.const import POWER_WATT +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import name_short_mac +from .const import DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + channels = coordinator.data.channels + entities = [] + seen_channels = set() + for channel_number, channel in channels.items(): + seen_channels.add(channel_number) + if not channel.active: + continue + if channel.paired_with_channel in seen_channels: + continue + + entities.append(EmonitorPowerSensor(coordinator, channel_number)) + + async_add_entities(entities) + + +class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): + """Representation of an Emonitor power sensor entity.""" + + def __init__(self, coordinator: DataUpdateCoordinator, channel_number: int): + """Initialize the channel sensor.""" + self.channel_number = channel_number + super().__init__(coordinator) + + @property + def unique_id(self) -> str: + """Channel unique id.""" + return f"{self.mac_address}_{self.channel_number}" + + @property + def channel_data(self) -> EmonitorChannel: + """Channel data.""" + return self.coordinator.data.channels[self.channel_number] + + @property + def paired_channel_data(self) -> EmonitorChannel: + """Channel data.""" + return self.coordinator.data.channels[self.channel_data.paired_with_channel] + + @property + def name(self) -> str: + """Name of the sensor.""" + return self.channel_data.label + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return POWER_WATT + + @property + def device_class(self) -> str: + """Device class of the sensor.""" + return DEVICE_CLASS_POWER + + def _paired_attr(self, attr_name: str) -> float: + """Cumulative attributes for channel and paired channel.""" + attr_val = getattr(self.channel_data, attr_name) + if self.channel_data.paired_with_channel: + attr_val += getattr(self.paired_channel_data, attr_name) + return attr_val + + @property + def state(self) -> StateType: + """State of the sensor.""" + return self._paired_attr("inst_power") + + @property + def extra_state_attributes(self) -> dict: + """Return the device specific state attributes.""" + return { + "channel": self.channel_number, + "avg_power": self._paired_attr("avg_power"), + "max_power": self._paired_attr("max_power"), + } + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self.coordinator.data.network.mac_address + + @property + def device_info(self) -> dict: + """Return info about the emonitor device.""" + return { + "name": name_short_mac(self.mac_address[-6:]), + "connections": {(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, + "manufacturer": "Powerhouse Dynamics, Inc.", + "sw_version": self.coordinator.data.hardware.firmware_version, + } diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json new file mode 100644 index 00000000000..aac15dfaae2 --- /dev/null +++ b/homeassistant/components/emonitor/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "SiteSage {name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "title": "Setup SiteSage Emonitor", + "description": "Do you want to setup {name} ({host})?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/emonitor/translations/en.json b/homeassistant/components/emonitor/translations/en.json new file mode 100644 index 00000000000..6e24bbac7a3 --- /dev/null +++ b/homeassistant/components/emonitor/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "SiteSage {name}", + "step": { + "confirm": { + "description": "Do you want to setup {name} ({host})?", + "title": "Setup SiteSage Emonitor" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fd385b21ca0..4095993346e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -60,6 +60,7 @@ FLOWS = [ "econet", "elgato", "elkm1", + "emonitor", "emulated_roku", "enocean", "enphase_envoy", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 83622545551..4d4e3688c1b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -57,6 +57,11 @@ DHCP = [ "domain": "broadlink", "macaddress": "B4430D*" }, + { + "domain": "emonitor", + "hostname": "emonitor*", + "macaddress": "0090C2*" + }, { "domain": "flume", "hostname": "flume-gw-*", diff --git a/requirements_all.txt b/requirements_all.txt index df497eb889a..22e136f255a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,6 +156,9 @@ aiodns==2.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.emonitor +aioemonitor==1.0.5 + # homeassistant.components.esphome aioesphomeapi==2.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3594e316958..d573457b0f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -93,6 +93,9 @@ aiodns==2.0.0 # homeassistant.components.eafm aioeafm==0.1.2 +# homeassistant.components.emonitor +aioemonitor==1.0.5 + # homeassistant.components.esphome aioesphomeapi==2.6.6 diff --git a/tests/components/emonitor/__init__.py b/tests/components/emonitor/__init__.py new file mode 100644 index 00000000000..6415078299f --- /dev/null +++ b/tests/components/emonitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the SiteSage Emonitor integration.""" diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py new file mode 100644 index 00000000000..65fc471786f --- /dev/null +++ b/tests/components/emonitor/test_config_flow.py @@ -0,0 +1,187 @@ +"""Test the SiteSage Emonitor config flow.""" +from unittest.mock import MagicMock, patch + +from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus +import aiohttp + +from homeassistant import config_entries, setup +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components.emonitor.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +def _mock_emonitor(): + return EmonitorStatus( + MagicMock(), EmonitorNetwork("AABBCCDDEEFF", "1.2.3.4"), MagicMock() + ) + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + return_value=_mock_emonitor(), + ), patch( + "homeassistant.components.emonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Emonitor DDEEFF" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_dhcp_can_confirm(hass): + """Test DHCP discovery flow can confirm right away.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + return_value=_mock_emonitor(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "dhcp"}, + data={ + HOSTNAME: "emonitor", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "host": "1.2.3.4", + "name": "Emonitor DDEEFF", + } + + with patch( + "homeassistant.components.emonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Emonitor DDEEFF" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_fails_to_connect(hass): + """Test DHCP discovery flow that fails to connect.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "dhcp"}, + data={ + HOSTNAME: "emonitor", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_dhcp_already_exists(hass): + """Test DHCP discovery flow that fails to connect.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.emonitor.config_flow.Emonitor.async_get_status", + return_value=_mock_emonitor(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "dhcp"}, + data={ + HOSTNAME: "emonitor", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort"