Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IoTaWatt integration #55364

Merged
merged 64 commits into from
Aug 30, 2021
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
edc208b
Initial checkin for new integration
gtdiehl Aug 4, 2021
90c97f2
Bumping API version to 0.0.6
gtdiehl Aug 4, 2021
7d99b91
Fixed merge issue
gtdiehl Aug 4, 2021
d98b7ee
Remove unused code
gtdiehl Aug 4, 2021
10be9aa
Added tests for config_flow
gtdiehl Aug 7, 2021
5b919b6
Bump API version to 0.0.7
gtdiehl Aug 7, 2021
f7e5538
Fixed merge issue
gtdiehl Aug 8, 2021
35a2190
Remove entities when removed/renamed from IoTaWatt
gtdiehl Aug 27, 2021
854f43a
Merging update change
gtdiehl Aug 27, 2021
67f41f7
Correcting product name
gtdiehl Aug 27, 2021
a92acf2
Initial checkin for new integration
gtdiehl Aug 4, 2021
36c578a
Bumping API version to 0.0.6
gtdiehl Aug 4, 2021
26f80a0
Fixed merge issue
gtdiehl Aug 4, 2021
9bcbfe2
Remove unused code
gtdiehl Aug 4, 2021
ead1f65
Added tests for config_flow
gtdiehl Aug 7, 2021
8b75706
Bump API version to 0.0.7
gtdiehl Aug 7, 2021
2a0dc9e
Fixed merge issue
gtdiehl Aug 8, 2021
d0331f1
Remove entities when removed/renamed from IoTaWatt
gtdiehl Aug 27, 2021
762f615
Merging update change
gtdiehl Aug 27, 2021
a17639f
Correcting product name
gtdiehl Aug 27, 2021
e055ce4
Merge branch 'iotawatt_new' of https://github.com/gtdiehl/core into i…
gtdiehl Aug 27, 2021
3c42caf
Remove last_reset attribute use increasing prop
gtdiehl Aug 27, 2021
9e9d155
Remove debug logging
gtdiehl Aug 27, 2021
acf7d9b
Change connection class to local poll
gtdiehl Aug 27, 2021
158039b
Remove info logging
gtdiehl Aug 27, 2021
0decd75
Fixed pylint errors
gtdiehl Aug 27, 2021
972e05b
Add tests
balloob Aug 28, 2021
dfe7169
Isort
balloob Aug 28, 2021
3b7258b
Review suggestions
gtdiehl Aug 29, 2021
1d2f808
Review suggestions
gtdiehl Aug 29, 2021
c7a8d72
Removing unused consts
gtdiehl Aug 29, 2021
a95b6a3
Remove defaults in manifest
gtdiehl Aug 29, 2021
c2a4715
Setting default scan in coordinator
gtdiehl Aug 29, 2021
b96845c
Suggestions for sensor file
gtdiehl Aug 29, 2021
e2b77d9
Fixed state attribute issue
gtdiehl Aug 29, 2021
940c3da
Test suggestion changes
gtdiehl Aug 29, 2021
8807717
Fixed comment
gtdiehl Aug 29, 2021
cd53f82
Added sensor test for unit types
gtdiehl Aug 29, 2021
24f6476
Split files into entity and coordinator
gtdiehl Aug 29, 2021
bc15ae9
Added config_flow test for general exception
gtdiehl Aug 29, 2021
c047914
Fixed order of quotes
gtdiehl Aug 29, 2021
2f18fa6
Fixed typo
gtdiehl Aug 29, 2021
5c95c69
Another typo fix
gtdiehl Aug 29, 2021
f34a7a0
Using SensorEntityDescription
gtdiehl Aug 30, 2021
407d4b0
Changed sensor test
gtdiehl Aug 30, 2021
c8bd946
Fixed typo in test
gtdiehl Aug 30, 2021
5931a1b
Fixed unit and device_class for Amps
gtdiehl Aug 30, 2021
be59547
Bump API version to 0.0.8
gtdiehl Aug 30, 2021
1b37ed1
Remove methods
gtdiehl Aug 30, 2021
da2679e
Cleanup
balloob Aug 30, 2021
02abc66
Update stale comment
balloob Aug 30, 2021
a74abf4
Pylint
balloob Aug 30, 2021
8e52106
Remove unused var
balloob Aug 30, 2021
e10bff8
Drop last usage of name
balloob Aug 30, 2021
9977fc2
Review suggestions
gtdiehl Aug 30, 2021
6fe787f
Review suggestions
gtdiehl Aug 30, 2021
f35f92e
Address comments
balloob Aug 30, 2021
eb0f32b
Set unique ID only for input sensors
balloob Aug 30, 2021
c1a0d32
using lambda
gtdiehl Aug 30, 2021
0e7af9b
unique input with unit type
gtdiehl Aug 30, 2021
b259bb0
Verify auth/fetch mac info on first connect
balloob Aug 30, 2021
7aa90f1
remove when no unique id
gtdiehl Aug 30, 2021
646508c
Remove title from strings
balloob Aug 30, 2021
11cd5b6
Add test for removing output
balloob Aug 30, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ homeassistant/components/integration/* @dgomes
homeassistant/components/intent/* @home-assistant/core
homeassistant/components/intesishome/* @jnimmo
homeassistant/components/ios/* @robbiet480
homeassistant/components/iotawatt/* @gtdiehl
homeassistant/components/iperf3/* @rohankapoorcom
homeassistant/components/ipma/* @dgomes @abmantis
homeassistant/components/ipp/* @ctalkington
Expand Down
24 changes: 24 additions & 0 deletions homeassistant/components/iotawatt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""The iotawatt integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .const import DOMAIN
from .coordinator import IotawattUpdater

PLATFORMS = ("sensor",)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up iotawatt from a config entry."""
coordinator = IotawattUpdater(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
hass.data[DOMAIN].pop(entry.entry_id)
balloob marked this conversation as resolved.
Show resolved Hide resolved
return unload_ok
107 changes: 107 additions & 0 deletions homeassistant/components/iotawatt/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Config flow for iotawatt integration."""
import json
gtdiehl marked this conversation as resolved.
Show resolved Hide resolved
import logging

import httpx
from iotawattpy.iotawatt import Iotawatt
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import httpx_client

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


async def validate_input(
hass: core.HomeAssistant, data: dict[str, str]
) -> dict[str, str]:
"""Validate the user input allows us to connect."""
iotawatt = Iotawatt(
"",
data[CONF_HOST],
httpx_client.get_async_client(hass),
data.get(CONF_USERNAME),
data.get(CONF_PASSWORD),
)
try:
is_connected = await iotawatt.connect()
except (KeyError, json.JSONDecodeError, httpx.HTTPError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the future, these types of errors should really be caught by the library and then the library should raise library specific exceptions. How should the user of the library know to catch these errors currently?

return {"base": "cannot_connect"}
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return {"base": "unknown"}

if not is_connected:
return {"base": "invalid_auth"}
gtdiehl marked this conversation as resolved.
Show resolved Hide resolved

return {}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for iotawatt."""

VERSION = 1

def __init__(self):
"""Initialize."""
self._data = {}

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if user_input is None:
user_input = {}

schema = vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
}
)
if not user_input:
return self.async_show_form(step_id="user", data_schema=schema)

if not (errors := await validate_input(self.hass, user_input)):
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)

if errors == {"base": "invalid_auth"}:
self._data.update(user_input)
return await self.async_step_auth()

return self.async_show_form(step_id="user", data_schema=schema, errors=errors)

async def async_step_auth(self, user_input=None):
"""Authenticate user if authentication is enabled on the IoTaWatt device."""
if user_input is None:
user_input = {}

data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
): str,
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
}
)
if not user_input:
return self.async_show_form(step_id="auth", data_schema=data_schema)

data = {**self._data, **user_input}

if errors := await validate_input(self.hass, data):
return self.async_show_form(
step_id="auth", data_schema=data_schema, errors=errors
)

return self.async_create_entry(title=data[CONF_HOST], data=data)


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
6 changes: 6 additions & 0 deletions homeassistant/components/iotawatt/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the IoTaWatt integration."""
from __future__ import annotations

DOMAIN = "iotawatt"
VOLT_AMPERE_REACTIVE = "VAR"
VOLT_AMPERE_REACTIVE_HOURS = "VARh"
41 changes: 41 additions & 0 deletions homeassistant/components/iotawatt/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""IoTaWatt DataUpdateCoordinator."""
from __future__ import annotations

from datetime import timedelta
import logging

from iotawattpy.iotawatt import Iotawatt

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import httpx_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)


class IotawattUpdater(DataUpdateCoordinator):
"""Class to manage fetching update data from the IoTaWatt Energy Device."""

def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize IotaWattUpdater object."""
self.api = Iotawatt(
entry.title,
entry.data[CONF_HOST],
httpx_client.get_async_client(hass),
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)

super().__init__(
hass=hass,
logger=_LOGGER,
name=entry.title,
update_interval=timedelta(seconds=30),
)

async def _async_update_data(self):
"""Fetch sensors from IoTaWatt device."""
await self.api.update()
return self.api.getSensors()
13 changes: 13 additions & 0 deletions homeassistant/components/iotawatt/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "iotawatt",
"name": "IoTaWatt",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
"requirements": [
"iotawattpy==0.0.8"
],
"codeowners": [
"@gtdiehl"
],
"iot_class": "local_polling"
}
168 changes: 168 additions & 0 deletions homeassistant/components/iotawatt/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Support for IoTaWatt Energy monitor."""
from __future__ import annotations

from iotawattpy.sensor import Sensor

from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import (
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_VOLTAGE,
ELECTRIC_CURRENT_AMPERE,
ELECTRIC_POTENTIAL_VOLT,
ENERGY_WATT_HOUR,
FREQUENCY_HERTZ,
PERCENTAGE,
POWER_VOLT_AMPERE,
POWER_WATT,
)
from homeassistant.core import callback
from homeassistant.helpers import entity_registry, update_coordinator

from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS
from .coordinator import IotawattUpdater

ENTITY_DESCRIPTION_KEY_MAP: dict[str, SensorEntityDescription] = {
"Amps": SensorEntityDescription(
"Amps",
native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_CURRENT,
),
"Hz": SensorEntityDescription(
"Hz",
native_unit_of_measurement=FREQUENCY_HERTZ,
state_class=STATE_CLASS_MEASUREMENT,
),
"PF": SensorEntityDescription(
"PF",
native_unit_of_measurement=PERCENTAGE,
balloob marked this conversation as resolved.
Show resolved Hide resolved
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_POWER_FACTOR,
),
"Watts": SensorEntityDescription(
"Watts",
native_unit_of_measurement=POWER_WATT,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_POWER,
),
"WattHours": SensorEntityDescription(
"WattHours",
native_unit_of_measurement=ENERGY_WATT_HOUR,
state_class=STATE_CLASS_TOTAL_INCREASING,
device_class=DEVICE_CLASS_ENERGY,
),
"VA": SensorEntityDescription(
"VA",
native_unit_of_measurement=POWER_VOLT_AMPERE,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_CURRENT,
gtdiehl marked this conversation as resolved.
Show resolved Hide resolved
),
"VAR": SensorEntityDescription(
"VAR",
native_unit_of_measurement=VOLT_AMPERE_REACTIVE,
state_class=STATE_CLASS_MEASUREMENT,
gtdiehl marked this conversation as resolved.
Show resolved Hide resolved
),
"VARh": SensorEntityDescription(
"VARh",
native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS,
state_class=STATE_CLASS_MEASUREMENT,
),
"Volts": SensorEntityDescription(
"Volts",
native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_VOLTAGE,
),
}


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add sensors for passed config_entry in HA."""
coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id]
created = set()

@callback
def _create_entity(key: str) -> IotaWattSensor:
"""Create a sensor entity."""
created.add(key)
return IotaWattSensor(
coordinator=coordinator,
key=key,
mac_address=coordinator.data["sensors"][key].hub_mac_address,
name=coordinator.data["sensors"][key].getName(),
entity_description=ENTITY_DESCRIPTION_KEY_MAP.get(
coordinator.data["sensors"][key].getUnit(),
SensorEntityDescription("base_sensor"),
),
)

async_add_entities(_create_entity(key) for key in coordinator.data["sensors"])

@callback
def new_data_received():
"""Check for new sensors."""
entities = [
_create_entity(key)
for key in coordinator.data["sensors"]
if key not in created
]
if entities:
async_add_entities(entities)

coordinator.async_add_listener(new_data_received)


class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity):
"""Defines a IoTaWatt Energy Sensor."""

_attr_force_update = True

def __init__(self, coordinator, key, mac_address, name, entity_description):
"""Initialize the sensor."""
super().__init__(coordinator=coordinator)

self._key = key
self._attr_unique_id = self._sensor_data.getSensorID()
balloob marked this conversation as resolved.
Show resolved Hide resolved
self.entity_description = entity_description

@property
def _sensor_data(self) -> Sensor:
"""Return sensor data."""
return self.coordinator.data["sensors"][self._key]

@property
def name(self) -> str | None:
"""Return name of the entity."""
return self._sensor_data.getName()

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._key not in self.coordinator.data["sensors"]:
entity_registry.async_get(self.hass).async_remove(self.entity_id)
return

super()._handle_coordinator_update()

@property
def extra_state_attributes(self):
"""Return the extra state attributes of the entity."""
data = self._sensor_data
attrs = {"type": data.getType()}
if attrs["type"] == "Input":
attrs["channel"] = data.getChannel()

return attrs

@property
def native_value(self):
"""Return the state of the sensor."""
return self._sensor_data.getValue()
24 changes: 24 additions & 0 deletions homeassistant/components/iotawatt/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"title": "iotawatt",
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"auth": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
Loading