Skip to content

Commit 0a6223e

Browse files
committed
Auto discovery
1 parent 752d5fe commit 0a6223e

6 files changed

Lines changed: 183 additions & 93 deletions

File tree

Lines changed: 83 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,6 @@
1-
# pyright: reportIncompatibleVariableOverride=false
2-
#
3-
# Home Assistant Custom Component: Enpal Webparser
4-
#
5-
# File: config_flow.py
6-
#
7-
# Description:
8-
# Home Assistant config flow for Enpal Webparser integration.
9-
# Provides setup and options dialogs for configuring URL, interval, groups, and wallbox add-on usage.
10-
#
11-
# Author: Oliver Stock (github.com/derolli1976)
12-
# License: MIT
13-
# Repository: https://github.com/derolli1976/enpal
14-
#
15-
# Compatible with Home Assistant Core 2024.x and later.
16-
#
17-
# See README.md for setup and usage instructions.
18-
#
19-
201
import logging
2+
import sys
3+
import asyncio
214
from typing import Any, cast
225
from urllib.parse import urlparse
236

@@ -36,22 +19,23 @@
3619
DEFAULT_WALLBOX_API_ENDPOINT,
3720
DOMAIN,
3821
)
22+
from .ip_discovery import scan_for_enpal_box
3923

4024
_LOGGER = logging.getLogger(__name__)
4125

26+
if sys.platform == "win32":
27+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
28+
4229

4330
def is_valid_enpal_url_format(url: str) -> bool:
4431
parsed = urlparse(url)
45-
valid = parsed.scheme == "http" and bool(parsed.netloc)
46-
_LOGGER.debug("[Enpal] URL format valid: %s -> %s", url, valid)
47-
return valid
32+
return parsed.scheme == "http" and bool(parsed.netloc)
4833

4934

5035
def sanitize_url(raw_url: str) -> tuple[str, str | None]:
5136
url = raw_url.strip()
5237
if not url.endswith("/deviceMessages"):
5338
url = url.rstrip("/") + "/deviceMessages"
54-
_LOGGER.debug("[Enpal] URL adjusted to: %s", url)
5539
if not is_valid_enpal_url_format(url):
5640
_LOGGER.warning("[Enpal] Invalid URL format: %s", url)
5741
return url, "invalid_format"
@@ -61,94 +45,125 @@ def sanitize_url(raw_url: str) -> tuple[str, str | None]:
6145
async def validate_enpal_url(hass, url: str) -> bool:
6246
try:
6347
session = async_get_clientsession(hass)
64-
async with session.get(url, timeout=30) as response:
65-
_LOGGER.info("[Enpal] URL reachable, status: %s", response.status)
66-
return response.status == 200
67-
except Exception as e:
68-
_LOGGER.warning("[Enpal] URL not reachable: %s", e)
48+
async with session.get(url, timeout=30) as resp:
49+
return resp.status == 200
50+
except Exception:
51+
_LOGGER.warning("[Enpal] URL not reachable: %s", url)
6952
return False
7053

54+
7155
async def validate_wallbox_api(hass) -> bool:
7256
url = f"{DEFAULT_WALLBOX_API_ENDPOINT}/status"
7357
try:
7458
session = async_get_clientsession(hass)
75-
async with session.get(url, timeout=45) as response:
76-
if response.status != 200:
77-
_LOGGER.warning("[Enpal] Wallbox API HTTP error: %s", response.status)
59+
async with session.get(url, timeout=45) as resp:
60+
if resp.status != 200:
7861
return False
79-
data = await response.json()
80-
success = data.get("success", False)
81-
_LOGGER.info("[Enpal] Wallbox API success: %s", success)
82-
return success is True
83-
except Exception as e:
84-
_LOGGER.warning("[Enpal] Wallbox API not reachable: %s", e)
62+
data = await resp.json()
63+
return bool(data.get("success"))
64+
except Exception:
65+
_LOGGER.warning("[Enpal] Wallbox API not reachable: %s", url)
8566
return False
8667

8768

8869
def get_default_config(options: dict[str, Any] | None = None) -> dict[str, Any]:
89-
src = dict(options) if options is not None else {}
70+
src = dict(options) if options else {}
9071
return {
9172
"url": src.get("url", DEFAULT_URL),
9273
"interval": src.get("interval", DEFAULT_INTERVAL),
9374
"groups": src.get("groups", DEFAULT_GROUPS),
9475
"use_wallbox_addon": src.get("use_wallbox_addon", DEFAULT_USE_WALLBOX_ADDON),
9576
}
9677

97-
def get_form_schema(config: dict[str, Any]) -> vol.Schema:
98-
return vol.Schema({
99-
vol.Required("url", default=cast(Any, config["url"])): str,
100-
vol.Required("interval", default=cast(Any, config["interval"])): int,
101-
vol.Optional("groups", default=cast(Any, config["groups"])): cv.multi_select(DEFAULT_GROUPS),
102-
vol.Optional("use_wallbox_addon", default=cast(Any, config["use_wallbox_addon"])): bool,
103-
})
104-
105-
async def process_user_input(hass, user_input: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, str]]:
106-
errors = {}
107-
url_input = user_input["url"]
108-
url_checked, error = sanitize_url(url_input)
10978

79+
def get_form_schema(config: dict[str, Any]) -> vol.Schema:
80+
return vol.Schema(
81+
{
82+
vol.Required("url", default=cast(Any, config["url"])): str,
83+
vol.Required("interval", default=cast(Any, config["interval"])): int,
84+
vol.Optional("groups", default=cast(Any, config["groups"])): cv.multi_select(
85+
DEFAULT_GROUPS
86+
),
87+
vol.Optional(
88+
"use_wallbox_addon", default=cast(Any, config["use_wallbox_addon"])
89+
): bool,
90+
}
91+
)
92+
93+
94+
async def process_user_input(
95+
hass, user_input: dict[str, Any]
96+
) -> tuple[dict[str, Any] | None, dict[str, str]]:
97+
errors: dict[str, str] = {}
98+
url_checked, error = sanitize_url(user_input.get("url", ""))
11099
if not error and not await validate_enpal_url(hass, url_checked):
111100
error = "unreachable"
112-
113101
if error:
114102
errors["url"] = error
115103
elif user_input.get("use_wallbox_addon") and not await validate_wallbox_api(hass):
116104
errors["use_wallbox_addon"] = "wallbox_unreachable"
117-
118105
if errors:
119106
return None, errors
120-
121107
return {
122108
"url": url_checked,
123-
"interval": user_input["interval"],
109+
"interval": user_input.get("interval"),
124110
"groups": user_input.get("groups", DEFAULT_GROUPS),
125111
"use_wallbox_addon": user_input.get("use_wallbox_addon", False),
126112
}, {}
127113

128114

129115
class EnpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
116+
"""Config flow for Enpal Webparser."""
117+
130118
VERSION = 1
131119

132120
async def async_step_user(self, user_input=None):
133-
_LOGGER.info("[Enpal] Config flow started")
121+
return await self.async_step_mode(user_input)
122+
123+
async def async_step_mode(self, user_input=None):
124+
description = (
125+
"Automatic discovery fills in the IP; or choose manual entry."
126+
)
127+
if user_input is None:
128+
schema = vol.Schema({
129+
vol.Required("mode", default="scan"): vol.In(["scan", "manual"])
130+
})
131+
return self.async_show_form(
132+
step_id="mode",
133+
data_schema=schema,
134+
description_placeholders={"info": description},
135+
errors={},
136+
)
137+
138+
# Wert ("scan" oder "manual") kommt immer neutral als Value!
139+
if user_input["mode"] == "manual":
140+
return await self.async_step_manual()
141+
return await self.async_step_discovery()
142+
143+
async def async_step_discovery(self, user_input=None):
144+
_LOGGER.debug("[Enpal] Starting automatic discovery")
145+
found = await scan_for_enpal_box()
146+
self.context["discovered_url"] = (
147+
f"http://{found[0]}/deviceMessages" if found else DEFAULT_URL
148+
)
149+
return await self.async_step_manual()
150+
151+
async def async_step_manual(self, user_input=None):
134152
config = get_default_config()
135-
errors = {}
153+
if "discovered_url" in self.context:
154+
config["url"] = self.context["discovered_url"]
136155

156+
errors: dict[str, str] = {}
137157
if user_input is not None:
138-
_LOGGER.debug("[Enpal] User input: %s", user_input)
139158
result, errors = await process_user_input(self.hass, user_input)
140159
if result:
141160
return self.async_create_entry(
142-
title="Enpal Webparser",
143-
data={"use_options_flow": True},
144-
options=result,
161+
title="Enpal Webparser", data={"use_options_flow": True}, options=result
145162
)
146163
config.update(user_input)
147164

148165
return self.async_show_form(
149-
step_id="user",
150-
data_schema=get_form_schema(config),
151-
errors=errors,
166+
step_id="manual", data_schema=get_form_schema(config), errors=errors
152167
)
153168

154169
@staticmethod
@@ -158,23 +173,21 @@ def async_get_options_flow(config_entry):
158173

159174

160175
class EnpalOptionsFlowHandler(config_entries.OptionsFlow):
176+
"""Options flow for Enpal Webparser."""
177+
161178
def __init__(self, config_entry):
162179
self.config_entry = config_entry
163180

164181
async def async_step_init(self, user_input=None):
165-
_LOGGER.info("[Enpal] OptionsFlow started")
166182
config = get_default_config(dict(self.config_entry.options))
167-
errors = {}
183+
errors: dict[str, str] = {}
168184

169-
if user_input:
170-
_LOGGER.debug("[Enpal] OptionsFlow input: %s", user_input)
185+
if user_input is not None:
171186
result, errors = await process_user_input(self.hass, user_input)
172187
if result:
173188
return self.async_create_entry(title="", data=result)
174189
config.update(user_input)
175190

176191
return self.async_show_form(
177-
step_id="init",
178-
data_schema=get_form_schema(config),
179-
errors=errors,
192+
step_id="init", data_schema=get_form_schema(config), errors=errors
180193
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import asyncio
2+
import sys
3+
from ip_discovery import scan_for_enpal_box
4+
5+
if sys.platform == 'win32':
6+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
7+
8+
if __name__ == "__main__":
9+
results = asyncio.run(scan_for_enpal_box())
10+
print("Gefundene Enpal Box IPs:", results)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import asyncio
2+
import aiohttp
3+
import psutil
4+
import ipaddress
5+
from bs4 import BeautifulSoup
6+
7+
DEVICE_MESSAGES_PATH = "/deviceMessages"
8+
IDENTIFY_TEXT = "Device Messages"
9+
IDENTIFY_CLASS = "m-3"
10+
11+
import psutil
12+
import ipaddress
13+
14+
def get_local_subnet() -> ipaddress.IPv4Network:
15+
for iface, snics in psutil.net_if_addrs().items():
16+
for snic in snics:
17+
if snic.family.name == 'AF_INET' and not snic.address.startswith("127."):
18+
local_ip = snic.address
19+
netmask = snic.netmask
20+
return ipaddress.ip_network(f"{local_ip}/{netmask}", strict=False)
21+
raise RuntimeError("Kein gültiges Subnetz gefunden.")
22+
23+
24+
async def check_ip(session: aiohttp.ClientSession, ip: str) -> str | None:
25+
url = f"http://{ip}{DEVICE_MESSAGES_PATH}"
26+
try:
27+
async with session.get(url, timeout=2) as response:
28+
if response.status != 200:
29+
return None
30+
html = await response.text()
31+
soup = BeautifulSoup(html, "html.parser")
32+
h1 = soup.find("h1", class_=IDENTIFY_CLASS)
33+
if h1 and h1.text.strip() == IDENTIFY_TEXT:
34+
return ip
35+
except (aiohttp.ClientError, asyncio.TimeoutError):
36+
return None
37+
return None
38+
39+
async def scan_for_enpal_box() -> list[str]:
40+
subnet = get_local_subnet()
41+
ips = [str(ip) for ip in subnet.hosts()]
42+
found_ips = []
43+
44+
async with aiohttp.ClientSession() as session:
45+
tasks = [check_ip(session, ip) for ip in ips]
46+
results = await asyncio.gather(*tasks)
47+
48+
return [ip for ip in results if ip]

custom_components/enpal_webparser/translations/de.json

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
{
22
"config": {
33
"step": {
4-
"user": {
5-
"title": "Erstkonfiguration",
6-
"description": "Hier kannst du Einstellungen vornehmen",
4+
"mode": {
5+
"title": "Einrichtungsmethode",
6+
"description": "{info}",
7+
"data": {
8+
"mode": "Einrichtungsmethode",
9+
"scan": "Automatische Erkennung – durchsucht das lokale Netzwerk",
10+
"manual": "Manuell konfigurieren – gib die IP-Adresse selbst ein"
11+
}
12+
},
13+
"manual": {
14+
"title": "Manuelle Konfiguration",
15+
"description": "Hier kannst du die Verbindungseinstellungen manuell anpassen.",
716
"data": {
817
"url": "URL / IP-Adresse der Enpal Box",
9-
"interval": "Abfrageintervall (in Sekunden) - Weniger als 60 Sekunden wird nicht empfohlen!",
18+
"interval": "Abfrageintervall (in Sekunden) Weniger als 60 Sekunden wird nicht empfohlen!",
1019
"groups": "Sensorgruppen auswählen",
11-
"use_wallbox_addon": "Wallbox Add-on verwenden (EXPERIMENTELL!) - Das Add-on muss vorher installiert und konfiguriert werden - https://github.com/derolli1976/enpal-wallbox-addon - nur bei Homeassistant OS oder Supevisor Installation möglich!"
20+
"use_wallbox_addon": "Wallbox Add-on verwenden (EXPERIMENTELL!) Das Add-on muss vorher installiert und konfiguriert werden https://github.com/derolli1976/enpal-wallbox-addon nur bei Home Assistant OS oder Supervisor Installation möglich!"
1221
}
1322
}
1423
},
1524
"error": {
16-
"invalid_format": "Ungültige URL - sie muss mit http:// beginnen und eine gültige IP-Adresse oder Host enthalten.",
17-
"unreachable": "Die URL konnte nicht erreicht werden - bitte Verbindung und Adresse prüfen.",
18-
"wallbox_unreachable": "Die Wallbox-API ist nicht erreichbar. Stelle sicher, dass das Add-on läuft und erreichbar ist! Test: http://Homeassistant-IP-ADDRESSE:36725/wallbox/status"
25+
"invalid_format": "Ungültige URL sie muss mit http:// beginnen und eine gültige IP-Adresse oder Host enthalten.",
26+
"unreachable": "Die URL konnte nicht erreicht werden bitte Verbindung und Adresse prüfen.",
27+
"wallbox_unreachable": "Die Wallbox-API ist nicht erreichbar. Stelle sicher, dass das Add-on läuft und erreichbar ist! Test: http://homeassistant-IP-ADDRESSE:36725/wallbox/status"
1928
}
2029
},
2130
"options": {
@@ -25,15 +34,15 @@
2534
"description": "Hier kannst du zusätzliche Einstellungen vornehmen",
2635
"data": {
2736
"url": "URL / IP-Adresse der Enpal Box",
28-
"interval": "Abfrageintervall (in Sekunden) - Weniger als 60 Sekunden wird nicht empfohlen!",
37+
"interval": "Abfrageintervall (in Sekunden) Weniger als 60 Sekunden wird nicht empfohlen!",
2938
"groups": "Sensorgruppen auswählen",
30-
"use_wallbox_addon": "Wallbox Add-on verwenden (EXPERIMENTELL!) - Das Add-on muss vorher installiert und konfiguriert werden - https://github.com/derolli1976/enpal-wallbox-addon - nur bei Homeassistant OS oder Supevisor Installation möglich!"
39+
"use_wallbox_addon": "Wallbox Add-on verwenden (EXPERIMENTELL!) Das Add-on muss vorher installiert und konfiguriert werden https://github.com/derolli1976/enpal-wallbox-addon nur bei Home Assistant OS oder Supervisor Installation möglich!"
3140
}
3241
}
3342
},
3443
"error": {
35-
"invalid_format": "Ungültige URL - sie muss mit http:// beginnen und eine gültige IP-Adresse oder Host enthalten.",
36-
"unreachable": "Die URL konnte nicht erreicht werden - bitte Verbindung und Adresse prüfen.",
44+
"invalid_format": "Ungültige URL sie muss mit http:// beginnen und eine gültige IP-Adresse oder Host enthalten.",
45+
"unreachable": "Die URL konnte nicht erreicht werden bitte Verbindung und Adresse prüfen.",
3746
"wallbox_unreachable": "Die Wallbox-API ist nicht erreichbar. Stelle sicher, dass das Add-on läuft und erreichbar ist! Test: http://homeassistant-IP-ADDRESSE:36725/wallbox/status"
3847
}
3948
}

0 commit comments

Comments
 (0)