Skip to content

Commit 648f765

Browse files
authored
Feature/websocket connection (#105)
* First tests * Websocket Connection to device Messages, rework folder structure. * Wallbox control * Improved message handling * Pylint * fix tests * Fix tests * Fix update dropdown for wallbox charge mode
1 parent bc8d1f5 commit 648f765

60 files changed

Lines changed: 10085 additions & 116 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

custom_components/enpal_webparser/__init__.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@
2626
from homeassistant.helpers.typing import ConfigType
2727

2828
from .const import DOMAIN
29+
from .wallbox_api import WallboxApiClient
2930

3031
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
3132

3233
_LOGGER = logging.getLogger(__name__)
3334

3435

36+
def _get_enpal_base_url(options: dict) -> str:
37+
"""Derive the Enpal Box base URL from the configured deviceMessages URL."""
38+
url = options.get("url", "")
39+
return url.replace("/deviceMessages", "").rstrip("/")
40+
41+
3542
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3643
_LOGGER.info("[Enpal] async_setup called during Home Assistant startup")
3744
return True
@@ -41,17 +48,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4148
_LOGGER.info("[Enpal] async_setup_entry started for entry_id: %s", entry.entry_id)
4249
hass.data.setdefault(DOMAIN, {})
4350

44-
use_wallbox_addon = entry.options.get("use_wallbox_addon", False)
51+
# Migration: Update existing config options
52+
new_options = dict(entry.options)
53+
needs_update = False
54+
if "data_source" not in new_options:
55+
_LOGGER.info("[Enpal] Migrating existing config - setting data_source to 'html'")
56+
new_options["data_source"] = "html"
57+
needs_update = True
58+
# Rename use_wallbox_addon -> use_wallbox
59+
if "use_wallbox_addon" in new_options:
60+
new_options["use_wallbox"] = new_options.pop("use_wallbox_addon")
61+
needs_update = True
62+
if needs_update:
63+
hass.config_entries.async_update_entry(entry, options=new_options)
64+
65+
use_wallbox = entry.options.get("use_wallbox", False)
4566

4667
platforms = ["sensor"]
47-
if use_wallbox_addon:
68+
if use_wallbox:
4869
platforms.extend(["button", "switch", "select"])
4970

50-
hass.data[DOMAIN][entry.entry_id] = {
71+
entry_data = {
5172
"config": entry.data,
5273
"platforms": platforms,
5374
}
5475

76+
# Create shared wallbox client for all wallbox platforms
77+
if use_wallbox:
78+
data_source = entry.options.get("data_source", "html")
79+
enpal_base_url = _get_enpal_base_url(entry.options)
80+
81+
if data_source == "websocket":
82+
# Native Blazor mode: connect directly to Enpal Box /wallbox page
83+
wallbox_client = WallboxApiClient(
84+
hass,
85+
enpal_base_url=enpal_base_url,
86+
use_native=True,
87+
)
88+
_LOGGER.info("[Enpal] Created native wallbox client for %s", enpal_base_url)
89+
else:
90+
# Legacy addon mode: HTTP calls to localhost:36725
91+
wallbox_client = WallboxApiClient(hass, use_native=False)
92+
_LOGGER.info("[Enpal] Created legacy addon wallbox client")
93+
94+
entry_data["wallbox_client"] = wallbox_client
95+
96+
hass.data[DOMAIN][entry.entry_id] = entry_data
97+
5598
_LOGGER.debug("[Enpal] Entry options: %s", entry.options)
5699
_LOGGER.info("[Enpal] Setting up platforms for entry: %s", platforms)
57100

@@ -61,14 +104,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
61104
_LOGGER.exception("[Enpal] Failed to set up entry platforms: %s", e)
62105
raise ConfigEntryNotReady(f"Error setting up entry: {e}")
63106

107+
# Reload integration when options change (e.g. wallbox enabled/disabled)
108+
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
109+
64110
_LOGGER.info("[Enpal] async_setup_entry completed successfully for entry_id: %s", entry.entry_id)
65111
return True
66112

67113

114+
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
115+
"""Reload integration when options change."""
116+
_LOGGER.info("[Enpal] Options changed, reloading integration")
117+
await hass.config_entries.async_reload(entry.entry_id)
118+
119+
68120
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
69121
_LOGGER.info("[Enpal] async_unload_entry called for entry_id: %s", entry.entry_id)
70122

71-
platforms = hass.data[DOMAIN].get(entry.entry_id, {}).get("platforms", [])
123+
entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {})
124+
125+
# Close wallbox client if exists
126+
wallbox_client = entry_data.get("wallbox_client")
127+
if wallbox_client:
128+
try:
129+
await wallbox_client.close()
130+
_LOGGER.info("[Enpal] Wallbox client connection closed")
131+
except Exception as e:
132+
_LOGGER.warning("[Enpal] Error closing wallbox client: %s", e)
133+
134+
# Close API client if exists
135+
coordinator = hass.data.get(DOMAIN, {}).get("coordinator")
136+
if coordinator and hasattr(coordinator, 'api_client'):
137+
try:
138+
await coordinator.api_client.close()
139+
_LOGGER.info("[Enpal] API client connection closed")
140+
except Exception as e:
141+
_LOGGER.warning("[Enpal] Error closing API client: %s", e)
142+
143+
platforms = entry_data.get("platforms", [])
72144
_LOGGER.debug("[Enpal] Unloading platforms: %s", platforms)
73145

74146
unloaded = await hass.config_entries.async_unload_platforms(entry, platforms)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Enpal API Client Package"""
2+
from .base import EnpalApiClient
3+
from .websocket_client import EnpalWebSocketClient
4+
from .html_client import EnpalHtmlClient
5+
from .wallbox_client import WallboxBlazorClient
6+
7+
__all__ = ["EnpalApiClient", "EnpalWebSocketClient", "EnpalHtmlClient", "WallboxBlazorClient"]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Abstract Base Class for Enpal API Clients"""
2+
from abc import ABC, abstractmethod
3+
from typing import Dict, List, Any, Optional, Callable, Awaitable
4+
5+
6+
class EnpalApiClient(ABC):
7+
"""Abstract base class for Enpal API clients"""
8+
9+
@abstractmethod
10+
async def connect(self) -> bool:
11+
"""
12+
Establish connection to Enpal Box.
13+
14+
Returns:
15+
True if connection successful, False otherwise
16+
"""
17+
pass
18+
19+
@abstractmethod
20+
async def fetch_data(self) -> Dict:
21+
"""
22+
Fetch data from Enpal Box.
23+
24+
Returns:
25+
Dictionary with structure:
26+
{
27+
'sensors': List[Dict[str, Any]], # Sensor dictionaries
28+
'source': str, # 'html' or 'websocket'
29+
}
30+
31+
Sensor dict format:
32+
{
33+
'name': str, # Friendly name with group prefix
34+
'value': str, # String representation of value
35+
'unit': Optional[str], # Unit (kWh, W, V, etc.)
36+
'device_class': Optional[str], # HA device class
37+
'enabled': bool, # If sensor group is enabled
38+
'enpal_last_update': Optional[str], # ISO timestamp
39+
'group': str, # Group name (Battery, Inverter, etc.)
40+
}
41+
42+
Raises:
43+
RuntimeError: If not connected
44+
TimeoutError: If data fetch times out
45+
"""
46+
pass
47+
48+
@abstractmethod
49+
async def close(self) -> None:
50+
"""Close connection to Enpal Box"""
51+
pass
52+
53+
@abstractmethod
54+
def is_connected(self) -> bool:
55+
"""
56+
Check connection status.
57+
58+
Returns:
59+
True if connected, False otherwise
60+
"""
61+
pass
62+
63+
def set_data_callback(
64+
self, callback: Optional[Callable[[Dict], Awaitable[None]]]
65+
) -> None:
66+
"""Register a push-data callback. Only meaningful for push-capable clients."""
67+
pass
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""HTML client for Enpal Box using existing HTML parser"""
2+
import logging
3+
from typing import Dict, List, Any
4+
import aiohttp
5+
from bs4 import BeautifulSoup
6+
7+
from .base import EnpalApiClient
8+
9+
_LOGGER = logging.getLogger(__name__)
10+
11+
12+
class EnpalHtmlClient(EnpalApiClient):
13+
"""HTML parser client for Enpal Box (legacy/fallback mode)"""
14+
15+
def __init__(self, base_url: str, groups: List[str]):
16+
"""
17+
Initialize HTML client.
18+
19+
Args:
20+
base_url: Base URL of Enpal Box (e.g., http://192.168.1.100)
21+
groups: List of sensor groups to parse
22+
"""
23+
self.base_url = base_url.rstrip('/')
24+
self.groups = groups
25+
self.session: aiohttp.ClientSession = None
26+
self.connected: bool = False
27+
28+
async def connect(self) -> bool:
29+
"""
30+
Establish HTTP session.
31+
32+
Returns:
33+
True (HTTP doesn't need explicit connection)
34+
"""
35+
self.session = aiohttp.ClientSession()
36+
self.connected = True
37+
_LOGGER.info("[Enpal HTML Client] HTTP session ready for %s", self.base_url)
38+
return True
39+
40+
async def fetch_data(self) -> Dict:
41+
"""
42+
Fetch data from Enpal Box via HTTP and parse HTML.
43+
44+
Returns:
45+
Dictionary with 'sensors' key containing List[Dict[str, Any]]
46+
47+
Raises:
48+
RuntimeError: If not connected
49+
Exception: If HTTP request fails
50+
"""
51+
if not self.connected:
52+
raise RuntimeError("Not connected to Enpal Box")
53+
54+
url = f"{self.base_url}/deviceMessages"
55+
56+
try:
57+
_LOGGER.debug("[Enpal HTML Client] Fetching HTML from %s", url)
58+
59+
async with self.session.get(url, timeout=30) as resp:
60+
if resp.status != 200:
61+
raise Exception(f"HTTP {resp.status} from {url}")
62+
63+
html = await resp.text()
64+
65+
_LOGGER.debug("[Enpal HTML Client] HTML fetched, parsing...")
66+
67+
# Parse HTML with BeautifulSoup
68+
from bs4 import BeautifulSoup
69+
70+
# Import parse function - handle both package and standalone mode
71+
try:
72+
from ..utils import parse_enpal_html_sensors
73+
except ImportError:
74+
# Standalone mode - import directly
75+
import sys
76+
from pathlib import Path
77+
parent_dir = Path(__file__).parent.parent
78+
sys.path.insert(0, str(parent_dir))
79+
from utils import parse_enpal_html_sensors
80+
81+
sensors = parse_enpal_html_sensors(html, self.groups)
82+
83+
_LOGGER.info(
84+
"[Enpal HTML Client] Parsed %d sensors from HTML",
85+
len(sensors)
86+
)
87+
88+
return {
89+
'sensors': sensors,
90+
'source': 'html',
91+
}
92+
93+
except Exception as e:
94+
_LOGGER.error("[Enpal HTML Client] Fetch failed: %s", e)
95+
raise
96+
97+
async def close(self) -> None:
98+
"""Close HTTP session"""
99+
self.connected = False
100+
101+
if self.session:
102+
await self.session.close()
103+
104+
_LOGGER.info("[Enpal HTML Client] Session closed")
105+
106+
def is_connected(self) -> bool:
107+
"""Check if session is ready"""
108+
return self.connected

0 commit comments

Comments
 (0)