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 IoTMeter integration #120087

Draft
wants to merge 31 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
219bf29
Added IoTMeter component
lipic Jun 21, 2024
101a412
bugfixes
lipic Jun 21, 2024
619bdab
bugfixes
lipic Jun 22, 2024
3b92baf
Merge branch 'dev' into dev
lipic Jun 22, 2024
2104ae7
Fixed senzor bug
lipic Jun 22, 2024
f4f3ae7
Merge branch 'dev' of github.com:lipic/core into dev
lipic Jun 22, 2024
5bc25fd
Merge branch 'dev' into dev
lipic Jun 26, 2024
bc913b9
Merge branch 'dev' into dev
lipic Jul 10, 2024
d8d1ba0
Limit integration to SENSOR platform for easier review
lipic Jul 10, 2024
b486a82
Merge branch 'dev' of github.com:lipic/core into dev
lipic Jul 10, 2024
fc2c055
Merge branch 'dev' into dev
lipic Jul 10, 2024
918b690
Merge branch 'dev' into dev
lipic Jul 10, 2024
cb990bf
Merge branch 'dev' into dev
lipic Jul 13, 2024
6a64052
Add unit tests for IoTMeter component
lipic Jul 17, 2024
66d2d84
Merge branch 'dev' of github.com:lipic/core into dev
lipic Jul 17, 2024
b58ce55
Merge branch 'dev' into dev
lipic Jul 17, 2024
c138629
Merge branch 'dev' into dev
lipic Jul 18, 2024
b4c7d3c
Merge branch 'dev' into dev
lipic Jul 24, 2024
a21fad8
Add only one platform
lipic Jul 24, 2024
8356120
Merge branch 'dev' of github.com:lipic/core into dev
lipic Jul 24, 2024
6a6735f
Remove number platform
lipic Jul 24, 2024
a6cd18d
Add iotmeter_api
lipic Aug 14, 2024
061ed62
Merge branch 'dev' into dev
lipic Aug 14, 2024
8e57087
Merge branch 'dev' into dev
lipic Aug 14, 2024
2095861
Merge branch 'dev' into dev
lipic Aug 15, 2024
4f2a31a
Bugfix
lipic Aug 15, 2024
832a7f4
Merge branch 'dev' of github.com:lipic/core into dev
lipic Aug 15, 2024
49fe1f0
Merge branch 'dev' into dev
lipic Aug 15, 2024
918435e
Bugfix
lipic Aug 15, 2024
942c1b3
Merge branch 'dev' of github.com:lipic/core into dev
lipic Aug 15, 2024
25196a5
Merge branch 'dev' into dev
lipic Aug 21, 2024
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 @@ -696,6 +696,7 @@ build.json @home-assistant/supervisor
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
/tests/components/iotawatt/ @gtdiehl @jyavenard
/homeassistant/components/iotmeter/ @lipic
/homeassistant/components/iotty/ @pburgio
/tests/components/iotty/ @pburgio
/homeassistant/components/iperf3/ @rohankapoorcom
Expand Down
55 changes: 55 additions & 0 deletions homeassistant/components/iotmeter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""IoTMeter integration for Home Assistant."""

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .const import DOMAIN
from .coordinator import IotMeterDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)
lipic marked this conversation as resolved.
Show resolved Hide resolved

PLATFORMS = [Platform.SENSOR] # Platform.NUMBER
Copy link
Member

Choose a reason for hiding this comment

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

Why the comment



async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up IoTMeter from a config entry."""
_LOGGER.debug("Setting up IoTMeter integration")
Copy link
Member

Choose a reason for hiding this comment

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

Not needed

if DOMAIN not in hass.data:
hass.data[DOMAIN] = {} # Ensure that the domain is a dictionary
Comment on lines +21 to +22
Copy link
Member

Choose a reason for hiding this comment

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

Please use entry.runtime_data. Check airgradient for an example. You should extend the ConfigEntry type with the type for the runtime_data.


ip_address = entry.data.get("ip_address")
port = entry.data.get("port", 8000) # Default to port 8000 if not set
Comment on lines +24 to +25
Copy link
Member

Choose a reason for hiding this comment

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

Use constants


_coordinator = IotMeterDataUpdateCoordinator(hass, ip_address, port)
await _coordinator.async_config_entry_first_refresh()

hass.data[DOMAIN] = {
"coordinator": _coordinator,
"ip_address": ip_address,
"port": port,
}

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True


async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
ip_address = entry.data.get("ip_address", entry.data.get("ip_address"))
port = entry.data.get("port", entry.data.get("port", 8000))
_coordinator = hass.data[DOMAIN]["coordinator"]
_coordinator.update_ip_port(ip_address, port)
await _coordinator.async_request_refresh()
Comment on lines +41 to +47
Copy link
Member

Choose a reason for hiding this comment

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

Not needed for now



async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle unloading of an entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data.pop(DOMAIN)
return unload_ok
63 changes: 63 additions & 0 deletions homeassistant/components/iotmeter/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Config flow for IoTMeter integration."""

import voluptuous as vol

from homeassistant import config_entries

from .const import DOMAIN


class EVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Copy link
Member

Choose a reason for hiding this comment

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

EV?

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
class EVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class EVConfigFlow(ConfigFlow, domain=DOMAIN):

"""Handle a config flow for IoTMeter."""

async def async_step_user(self, user_input=None):
Copy link
Member

Choose a reason for hiding this comment

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

Please add typing

"""Handle the initial step."""
errors = {}
if user_input is not None:
ip_address = user_input.get("ip_address")
port = user_input.get("port")

if ip_address and port:
return self.async_create_entry(title="IoTMeter", data=user_input)
Comment on lines +17 to +21
Copy link
Member

Choose a reason for hiding this comment

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

We should check if the data is valid. The config flow is the best way to tell the user they did something wrong, so we should try to connect with the device to verify the data

errors["base"] = "invalid_input"

data_schema = vol.Schema(
{vol.Required("ip_address"): str, vol.Optional("port", default=8000): int}
Copy link
Member

Choose a reason for hiding this comment

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

Use constants instead

)
Comment on lines +24 to +26
Copy link
Member

Choose a reason for hiding this comment

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

if both are required, why do you always use .get()?


return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
Comment on lines +24 to +30
Copy link
Member

Choose a reason for hiding this comment

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

can be combined


async def async_step_reconfigure(self, user_input=None):
Copy link
Member

Choose a reason for hiding this comment

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

Please keep this for a follow up PR

"""Handle the reconfiguration step."""
errors = {}
# Get the current configuration from config_entry
config_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
current_ip = config_entry.data.get("ip_address", "")
current_port = config_entry.data.get("port", 8000)

if user_input is not None:
ip_address = user_input.get("ip_address")
port = user_input.get("port")

if ip_address and port:
# Update the config_entry with new data
self.hass.config_entries.async_update_entry(
config_entry, data=user_input
)
return self.async_abort(reason="reconfigured")
errors["base"] = "invalid_input"

data_schema = vol.Schema(
{
vol.Required("ip_address", default=current_ip): str,
vol.Optional("port", default=current_port): int,
}
)

return self.async_show_form(
step_id="reconfigure", data_schema=data_schema, errors=errors
)
3 changes: 3 additions & 0 deletions homeassistant/components/iotmeter/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the wallbox integration."""

DOMAIN = "iotmeter"
152 changes: 152 additions & 0 deletions homeassistant/components/iotmeter/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Module for IoTMeter integration in Home Assistant."""

import asyncio
from datetime import timedelta
import logging

import aiohttp

from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN
from .number import ChargingCurrentNumber
from .sensor import (
ConsumptionEnergySensor,
EvseSensor,
GenerationEnergySensor,
TotalPowerSensor,
)
Comment on lines +15 to +20
Copy link
Member

Choose a reason for hiding this comment

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

The coordinator should not be in charge of creating and removing entities. That is the task of async_setup_entry in the platform files. Check Withings for an example, the best example in there are the measurement sensors


SCAN_INTERVAL = 5
_LOGGER = logging.getLogger(__name__)


async def fetch_data(session, url):
"""Fetch data from a URL."""
async with session.get(url) as response:
return await response.json()


class IotMeterDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the IotMeter API."""

def __init__(self, hass, ip_address, port):
"""Initialize the data update coordinator."""
self.ip_address = ip_address
self.port = port
self.setting_read: bool = False
self.async_add_sensor_entities = None
self.async_add_number_entities = None
self.entities = []
self.number_of_evse: int = 0
self.is_smartmodul = None
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL)
)

def update_ip_port(self, ip_address, port):
"""Update IP address and port."""
self.ip_address = ip_address
self.port = port

async def _async_update_data(self):
"""Fetch data from API."""
try:
Copy link
Member

Choose a reason for hiding this comment

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

Only have stuff in the try block that can raise

urls = [
f"http://{self.ip_address}:{self.port}/updateSetting",
f"http://{self.ip_address}:{self.port}/updateData",
]

if self.is_smartmodul is not None:
if self.is_smartmodul:
urls.append(
f"http://{self.ip_address}:{self.port}/updateRamSetting"
)
else:
urls.append(f"http://{self.ip_address}:{self.port}/updateEvse")

async with aiohttp.ClientSession() as session:
tasks = [fetch_data(session, url) for url in urls]
results = await asyncio.gather(*tasks)
data = results[0]
data.update(results[1])
if self.is_smartmodul is not None:
data.update(results[2])

number_of_evse = data.get("NUMBER_OF_EVSE", 0)

if "TYPE" in data:
if data["TYPE"] == "2":
self.is_smartmodul = True
elif "inp,EVSE1" in data:
self.is_smartmodul = False

if not self.setting_read or (
self.number_of_evse != number_of_evse and self.setting_read
):
self.number_of_evse = number_of_evse
await self.remove_entities()
if self.async_add_sensor_entities:
self.setting_read = True
await self.add_sensor_entities()
if self.async_add_number_entities and self.is_smartmodul:
await self.add_number_entities(data["txt,ACTUAL SW VERSION"])

return data

except aiohttp.ClientError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err

async def add_sensor_entities(self):
"""Add sensor entities to Home Assistant."""
translations = await async_get_translations(
self.hass, self.hass.config.language, "entity"
)
self.entities = [
ConsumptionEnergySensor(
self, "Total Consumption Energy", translations, "kWh"
),
TotalPowerSensor(self, "Total Power", translations, "kW"),
]
if not self.is_smartmodul:
self.entities.append(
GenerationEnergySensor(
self, "Total Generation Energy", translations, "kWh"
)
)
if self.number_of_evse > 0 or self.is_smartmodul:
self.entities.append(
EvseSensor(
self, "Evse", translations, "", smartmodule=self.is_smartmodul
)
)
self.async_add_sensor_entities(self.entities)

async def add_number_entities(self, fw_version):
"""Add number entities to Home Assistant."""
translations = await async_get_translations(
self.hass, self.hass.config.language, "entity"
)
self.entities = [
ChargingCurrentNumber(
self,
"Charging current",
translations,
"A",
0,
32,
1,
fw_version=fw_version,
smartmodule=self.is_smartmodul,
)
]
self.async_add_number_entities(self.entities)

async def remove_entities(self):
"""Remove entities."""
if self.entities:
_LOGGER.debug("Removing entities: %s", self.entities)
for entity in self.entities:
await entity.async_remove()
self.entities = []
10 changes: 10 additions & 0 deletions homeassistant/components/iotmeter/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "iotmeter",
"name": "IoTMeter",
"codeowners": ["@lipic"],
"config_flow": true,
"dependencies": [],
Copy link
Member

Choose a reason for hiding this comment

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

Remove empty fields

"documentation": "https://www.home-assistant.io/integrations/iotmeter",
"iot_class": "local_polling",
"requirements": ["aiohttp==3.9.5"]
Copy link
Member

Choose a reason for hiding this comment

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

The logic to connect to the device should be encapsulated in a package published on PyPi.

Copy link
Author

Choose a reason for hiding this comment

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

The logic to connect to the device should be encapsulated in a package published on PyPi.

Can I simply remove the requirements from manifest.json and use the aiohttp==3.10.0b1 version that is already a dependency in Home Assistant?

Copy link
Member

Choose a reason for hiding this comment

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

No, you need to create your own library with all the calls to the device/service and publish that to Pypi and use that

}
Loading
Loading