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

EnergyID integration #95006

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Expand Up @@ -116,6 +116,7 @@ homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
homeassistant.components.energy.*
homeassistant.components.energyid.*
homeassistant.components.esphome.*
homeassistant.components.event.*
homeassistant.components.evil_genius_labs.*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -342,6 +342,7 @@ build.json @home-assistant/supervisor
/tests/components/emulated_kasa/ @kbickar
/homeassistant/components/energy/ @home-assistant/core
/tests/components/energy/ @home-assistant/core
/homeassistant/components/energyid/ @JrtPec
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, the tests should also be added to your name. I think hassfest might be having a bad day with it (I also saw another case of this recently, so maybe its something we have to fix in hassfest)

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/homeassistant/components/energyid/ @JrtPec
/homeassistant/components/energyid/ @JrtPec
/tests/components/energyid/ @JrtPec

/homeassistant/components/energyzero/ @klaasnicolaas
/tests/components/energyzero/ @klaasnicolaas
/homeassistant/components/enigma2/ @fbradyirl
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/brands/energyid.json
@@ -0,0 +1,5 @@
{
"domain": "energyid",
"name": "EnergyID",
"integrations": ["energyid"]
}
Comment on lines +1 to +5
Copy link
Member

Choose a reason for hiding this comment

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

A brand is only created if there are more integrations under 1 company brand. For example Google has everything from Google Agenda to YouTube and Philips has Philips hue and Philips TV. So this integration is not a brand.

Copy link
Author

Choose a reason for hiding this comment

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

I understand. So this json file can simply be removed?

Copy link
Member

Choose a reason for hiding this comment

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

Yes

Copy link
Member

Choose a reason for hiding this comment

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

Still not removed btw

142 changes: 142 additions & 0 deletions homeassistant/components/energyid/__init__.py
@@ -0,0 +1,142 @@
"""The EnergyID integration."""
from __future__ import annotations

import asyncio
import datetime as dt
import logging

import aiohttp
from energyid_webhooks import WebhookClientAsync, WebhookPayload

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_state_change_event

from .const import (
CONF_ENTITY_ID,
CONF_METRIC,
CONF_METRIC_KIND,
CONF_UNIT,
CONF_WEBHOOK_URL,
DEFAULT_DATA_INTERVAL,
DEFAULT_UPLOAD_INTERVAL,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up EnergyID from a config entry."""

hass.data.setdefault(DOMAIN, {})

# Create the webhook dispatcher
dispatcher = WebhookDispatcher(hass, entry)
hass.data[DOMAIN][entry.entry_id] = dispatcher

# Validate the webhook client
try:
await dispatcher.client.get_policy()
except aiohttp.ClientResponseError as error:
_LOGGER.error("Could not validate webhook client")
raise ConfigEntryAuthFailed from error
Copy link
Member

Choose a reason for hiding this comment

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

ConfigEntryAuthFailed should only be raised when the integration has a reauth flow and you dont have one yet. So please raise ConfigEntryError instead


# Register the webhook dispatcher
async_track_state_change_event(
hass=hass,
entity_ids=dispatcher.entity_id,
action=dispatcher.async_handle_state_change,
)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
return True


class WebhookDispatcher:
"""Webhook dispatcher."""

def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the dispatcher."""
self.hass = hass
self.client = WebhookClientAsync(
webhook_url=entry.data[CONF_WEBHOOK_URL],
session=async_get_clientsession(hass),
)
self.entity_id = entry.data[CONF_ENTITY_ID]
self.metric = entry.data[CONF_METRIC]
self.metric_kind = entry.data[CONF_METRIC_KIND]
self.unit = entry.data[CONF_UNIT]
self.data_interval = DEFAULT_DATA_INTERVAL
self.upload_interval = dt.timedelta(seconds=DEFAULT_UPLOAD_INTERVAL)

self.last_upload: dt.datetime | None = None

self._upload_lock = asyncio.Lock()

async def async_handle_state_change(self, event: Event) -> bool:
"""Handle a state change."""
await self._upload_lock.acquire()
Copy link
Member

Choose a reason for hiding this comment

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

Would it maybe work if you did:

async with self._upload_lock:

This way you don't have to manually acquire and release locks. (and if you dislike the extra indent, you could move this function to _async_handle_state_change and call it like

async def async_handle_state_change() -> bool:
    async with self._upload_lock:
        await self._async_handle_state_change()

_LOGGER.debug("Handling state change event %s", event)
new_state = event.data["new_state"]

# Check if enough time has passed since the last upload
if not self.upload_allowed(new_state.last_changed):
_LOGGER.debug(
"Not uploading state %s because of last upload %s",
new_state,
self.last_upload,
)
self._upload_lock.release()
return False

# Check if the new state is a valid float
try:
value = float(new_state.state)
except ValueError:
_LOGGER.error(
"Error converting state %s to float for entity %s",
new_state.state,
self.entity_id,
)
self._upload_lock.release()
return False

# Upload the new state
try:
data: list[list] = [[new_state.last_changed.isoformat(), value]]
payload = WebhookPayload(
remote_id=self.entity_id,
remote_name=new_state.attributes.get("friendly_name", self.entity_id),
metric=self.metric,
metric_kind=self.metric_kind,
unit=self.unit,
interval=self.data_interval,
data=data,
)
_LOGGER.debug("Uploading data %s", payload)
await self.client.post_payload(payload)
except Exception: # pylint: disable=broad-except
_LOGGER.error("Error saving data %s", payload)
self._upload_lock.release()
return False

# Update the last upload time
self.last_upload = new_state.last_changed
_LOGGER.debug("Updated last upload time to %s", self.last_upload)
self._upload_lock.release()
return True

def upload_allowed(self, state_change_time: dt.datetime) -> bool:
"""Check if an upload is allowed."""
if self.last_upload is None:
return True

return state_change_time - self.last_upload > self.upload_interval
84 changes: 84 additions & 0 deletions homeassistant/components/energyid/config_flow.py
@@ -0,0 +1,84 @@
"""Config flow for EnergyID integration."""
from __future__ import annotations

import logging
from typing import Any

import aiohttp
from energyid_webhooks import WebhookClientAsync
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
CONF_ENTITY_ID,
CONF_METRIC,
CONF_METRIC_KIND,
CONF_UNIT,
CONF_WEBHOOK_URL,
DOMAIN,
ENERGYID_METRIC_KINDS,
)

_LOGGER = logging.getLogger(__name__)


def hass_entity_ids(hass: HomeAssistant) -> list[str]:
"""Return all entity IDs in Home Assistant."""
return list(hass.states.async_entity_ids())


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

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step."""
errors: dict[str, str] = {}

# Get the meter catalog
http_session = async_get_clientsession(self.hass)
# Temporary client without webhook URL (not yet known, but not needed for catalog)
_client = WebhookClientAsync(webhook_url=None, session=http_session)
meter_catalog = await _client.get_meter_catalog()

# Handle the user input
if user_input is not None:
client = WebhookClientAsync(
webhook_url=user_input[CONF_WEBHOOK_URL], session=http_session
)
try:
await client.get_policy()
except aiohttp.ClientResponseError:
errors["base"] = "cannot_connect"
except aiohttp.InvalidURL:
errors[CONF_WEBHOOK_URL] = "invalid_url"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=f"Send {user_input[CONF_ENTITY_ID]} to EnergyID",
data=user_input,
)

# Show the form
data_schema = vol.Schema(
{
vol.Required(CONF_WEBHOOK_URL): str,
joostlek marked this conversation as resolved.
Show resolved Hide resolved
vol.Required(CONF_ENTITY_ID): vol.In(hass_entity_ids(self.hass)),
vol.Required(CONF_METRIC): vol.In(sorted(meter_catalog.all_metrics)),
vol.Required(CONF_METRIC_KIND): vol.In(ENERGYID_METRIC_KINDS),
vol.Required(CONF_UNIT): vol.In(sorted(meter_catalog.all_units)),
}
)

return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
15 changes: 15 additions & 0 deletions homeassistant/components/energyid/const.py
@@ -0,0 +1,15 @@
"""Constants for the EnergyID integration."""

from typing import Final

DOMAIN: Final[str] = "energyid"

CONF_WEBHOOK_URL: Final["str"] = "webhook_url"
CONF_ENTITY_ID: Final["str"] = "entity_id"
CONF_METRIC: Final["str"] = "metric"
CONF_METRIC_KIND: Final["str"] = "metric_kind"
CONF_UNIT: Final["str"] = "unit"
DEFAULT_DATA_INTERVAL: Final["str"] = "P1D"
DEFAULT_UPLOAD_INTERVAL: Final[int] = 300

ENERGYID_METRIC_KINDS = ["cumulative", "total", "delta", "gauge"]
9 changes: 9 additions & 0 deletions homeassistant/components/energyid/manifest.json
@@ -0,0 +1,9 @@
{
"domain": "energyid",
"name": "EnergyID",
"codeowners": ["@JrtPec"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/energyid",
"iot_class": "cloud_push",
"requirements": ["energyid-webhooks==0.0.6"]
}
33 changes: 33 additions & 0 deletions homeassistant/components/energyid/strings.json
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"data": {
JrtPec marked this conversation as resolved.
Show resolved Hide resolved
"webhook_url": "EnergyID webhook url",
"entity_id": "Home Assistant entity id",
"metric": "EnergyID metric",
"metric_kind": "EnergyID metric kind",
"unit": "Unit of measurement"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Invalid Webhook URL",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"options": {
"step": {
"init": {
"data": {
"data_interval": "EnergyID data interval",
"upload_interval": "Upload interval (seconds)"
}
}
},
"error": {
"invalid_interval": "Invalid interval for this webhook policy."
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Expand Up @@ -129,6 +129,7 @@
"elmax",
"emonitor",
"emulated_roku",
"energyid",
"energyzero",
"enocean",
"enphase_envoy",
Expand Down
11 changes: 11 additions & 0 deletions homeassistant/generated/integrations.json
Expand Up @@ -1485,6 +1485,17 @@
"integration_type": "virtual",
"supported_by": "energyzero"
},
"energyid": {
"name": "EnergyID",
"integrations": {
"energyid": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push",
"name": "EnergyID"
}
}
},
"energyzero": {
"name": "EnergyZero",
"integration_type": "hub",
Expand Down
10 changes: 10 additions & 0 deletions mypy.ini
Expand Up @@ -922,6 +922,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.energyid.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.esphome.*]
check_untyped_defs = true
disallow_incomplete_defs = true
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Expand Up @@ -739,6 +739,9 @@ emulated-roku==0.2.1
# homeassistant.components.huisbaasje
energyflip-client==0.2.2

# homeassistant.components.energyid
energyid-webhooks==0.0.6

# homeassistant.components.energyzero
energyzero==0.5.0

Expand Down