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
EnergyID integration #95006
Changes from all commits
0b9a668
cda5987
e0f8e7a
f07f9fe
8c89918
338fbdf
b32c9e2
cc83e9a
ae7660e
680279b
cc06578
9782b09
03e9f45
cd7020e
fb4cb15
cbccce0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -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 | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
/homeassistant/components/energyzero/ @klaasnicolaas | ||||||||
/tests/components/energyzero/ @klaasnicolaas | ||||||||
/homeassistant/components/enigma2/ @fbradyirl | ||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"domain": "energyid", | ||
"name": "EnergyID", | ||
"integrations": ["energyid"] | ||
} | ||
Comment on lines
+1
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I understand. So this json file can simply be removed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still not removed btw |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
# 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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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." | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -129,6 +129,7 @@ | |
"elmax", | ||
"emonitor", | ||
"emulated_roku", | ||
"energyid", | ||
"energyzero", | ||
"enocean", | ||
"enphase_envoy", | ||
|
There was a problem hiding this comment.
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)