Skip to content

Commit 844d52f

Browse files
authored
Auto discovery and wallbox api (#78)
* Auto discovery and refactoring wallbox api
1 parent 0129327 commit 844d52f

15 files changed

Lines changed: 1171 additions & 146 deletions

File tree

.github/copilot-instructions.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Enpal Solar Integration - AI Coding Agent Instructions
2+
3+
## Project Overview
4+
This is a Home Assistant custom integration that parses data from local Enpal solar system web interfaces (e.g., `http://<enpal-box-ip>/deviceMessages`) and creates sensors dynamically. It supports optional wallbox control via an external add-on API.
5+
6+
**Target**: First-generation Enpal boxes that expose HTML tables on local network (NOT all Enpal systems are supported). Enpal boxes get their IP address via DHCP from the router.
7+
8+
## Architecture & Key Components
9+
10+
### Data Flow
11+
1. **HTML Scraping** (`utils.py`): Fetches HTML from Enpal box → BeautifulSoup parsing → extracts sensor data from `<div class="card">` elements
12+
2. **Dynamic Entity Creation** (`sensor.py`, `entity_factory.py`): Parsed data → DataUpdateCoordinator → Auto-generated HA sensor entities
13+
3. **Optional Wallbox Control** (`button.py`, `switch.py`, `select.py`): Only loaded when `use_wallbox_addon=True` → HTTP POST to `localhost:36725/wallbox/*`
14+
15+
### Critical Files
16+
- **`utils.py`**: Core parsing logic (`parse_enpal_html_sensors`, `expand_inverter_system_state`). All sensor extraction happens here.
17+
- **`const.py`**: All constants including `DEFAULT_GROUPS` (sensor categories), unit mappings, device class overrides, icon mappings
18+
- **`entity_factory.py`**: Factory pattern for creating sensor entities with proper device_class/state_class assignments
19+
- **`sensor.py`**: Platform setup with DataUpdateCoordinator, fallback to last known data on errors, cumulative energy sensors
20+
- **`config_flow.py`**: Multi-step UI configuration with auto-discovery and manual setup options, URL validation, group selection, wallbox toggle
21+
- **`discovery.py`**: Network scanning utilities for auto-discovering Enpal boxes on local subnets
22+
- **`wallbox_api.py`**: Centralized API client for all wallbox HTTP communication (added to eliminate code duplication)
23+
24+
### Special Parsing Logic: Inverter System State
25+
The inverter "System State" sensor contains a 200+ character bitfield string like `"State Decimal: 1234 Bits: 0101010101..."`.
26+
27+
**Problem**: String too long (>255 chars) for HA sensor states
28+
**Solution**: `expand_inverter_system_state()` in `utils.py` splits it into multiple short sensors:
29+
- `sensor.inverter_system_state_decimal` (numeric value)
30+
- `sensor.inverter_system_state_flags` (comma-separated active flags)
31+
- Individual binary state sensors for each bit (e.g., `sensor.inverter_system_state_standby`)
32+
33+
**Detection**: Uses regex `INV_STATE_RE` OR length check `len(value_raw) > 200 and "Bits" in value_raw`
34+
35+
## Development Workflows
36+
37+
### Running Tests
38+
```bash
39+
# Set PYTHONPATH to project root (critical for imports)
40+
$env:PYTHONPATH = "e:\Github\enpal"
41+
42+
# Run all tests
43+
pytest
44+
45+
# Run specific test file
46+
pytest custom_components/enpal_webparser/tests/test_utils.py
47+
48+
# Run with verbose output
49+
pytest -v
50+
```
51+
52+
**Test Structure**:
53+
- `conftest.py`: Fixtures like `real_html` (loads `fixtures/deviceMessages.html`)
54+
- Tests use real HTML fixtures to validate parsing against actual Enpal output
55+
- No mocking of BeautifulSoup - tests parse actual HTML structure
56+
57+
### Adding New Sensors
58+
1. **If sensor appears in HTML but not in HA**: Check if group is enabled in `DEFAULT_GROUPS` (const.py)
59+
2. **Custom unit/device_class**: Add to `DEVICE_CLASS_OVERRIDES` or `STATE_CLASS_OVERRIDES` in `const.py`
60+
3. **Custom icon**: Add mapping to `ICON_MAP` using sensor's `unique_id` format (e.g., `"iotedgedevice_cpu_load": "mdi:cpu-64-bit"`)
61+
62+
### Debugging Sensor Issues
63+
- Check logs with prefix `[Enpal]` - all modules use this
64+
- Sensor unique_ids generated via `make_id()`: lowercase, non-alphanumeric → `_`
65+
- Friendly names created via `friendly_name(group, sensor_name)` - handles special formatting like unit letters in parentheses
66+
67+
## Project-Specific Conventions
68+
69+
### Wallbox API Communication Pattern
70+
All wallbox API calls use the centralized `WallboxApiClient` class (`wallbox_api.py`):
71+
```python
72+
from .wallbox_api import WallboxApiClient
73+
74+
api_client = WallboxApiClient(hass)
75+
success = await api_client.start_charging() # Returns True/False
76+
data = await api_client.get_status() # Returns dict or None
77+
78+
# For actions that need sensor refresh:
79+
await api_client.call_and_refresh_sensors(
80+
"/start",
81+
sensor_entities=["sensor.wallbox_status"]
82+
)
83+
```
84+
85+
**Key benefits**: Centralized error handling, logging, timeout management, and sensor refresh logic.
86+
87+
### Logging Pattern
88+
```python
89+
_LOGGER.info("[Enpal] Your message: %s", variable) # Always prefix with [Enpal]
90+
```
91+
92+
### Entity Naming
93+
- **Unique ID**: `make_id("Inverter Power DC Total")``"inverter_power_dc_total"`
94+
- **Friendly Name**: `friendly_name("Inverter", "Power.DC.L1")``"Inverter: Power DC (L1)"`
95+
96+
### Coordinator Pattern
97+
- Sensors use `DataUpdateCoordinator` with fallback: If fetch fails but `last_successful_data` exists, reuse old values (prevents all sensors going unavailable during transient network issues)
98+
- Wallbox has separate coordinator because it polls different endpoint (`localhost:36725/wallbox/status`)
99+
100+
### Platform Loading
101+
- Base platforms: Always `["sensor"]`
102+
- Optional platforms: `["button", "switch", "select"]` only when `use_wallbox_addon=True`
103+
- Dynamic loading in `__init__.py`: `async_forward_entry_setups(entry, platforms)`
104+
105+
## Integration Points
106+
107+
### External Dependencies
108+
- **BeautifulSoup** (`bs4`): HTML parsing - assumes specific structure with `<div class="card"><h2>GroupName</h2><table><tr><td>Sensor</td><td>Value</td><td>Timestamp</td></tr>`
109+
- **Wallbox Add-on**: Separate Home Assistant add-on (not part of this repo) exposes HTTP API on port 36725
110+
- Endpoints: `/start`, `/stop`, `/set_eco`, `/set_solar`, `/set_full`, `/status`
111+
- This integration only consumes the API, doesn't implement it
112+
113+
### Home Assistant Integration
114+
- **Config Flow**: Multi-step UI-only configuration (no YAML)
115+
- Step 1: Choose between auto-discovery and manual setup
116+
- Step 2 (discovery): Scan local network subnets for Enpal boxes, present found devices
117+
- Step 2 (manual): Enter URL manually
118+
- Step 3: Configure interval, groups, and wallbox addon
119+
- **Network Discovery**: Uses platform-specific detection (ip/ipconfig) to detect actual subnet masks, scans all hosts for `/deviceMessages` endpoint, max 1024 hosts safety limit
120+
- **Restore State**: `DailyResetFromEntitySensor` uses `RestoreEntity` to persist daily energy totals across HA restarts
121+
- **Entity Registry**: Disabled entities for unselected groups still appear in HA (can be manually enabled later)
122+
123+
## Common Pitfalls
124+
125+
1. **255 Character Limit**: HA sensors have state string length limits. Always truncate long values or split them (see inverter system state)
126+
2. **Timestamp Parsing**: Use `ENPAL_TIMESTAMP_FORMAT = "%m/%d/%Y %H:%M:%S"` - Enpal uses MM/DD/YYYY format
127+
3. **Unit Conversion**: Wh → kWh conversion happens in `normalize_value_and_unit()` to match HA energy dashboard expectations
128+
4. **Wallbox Status Dependency**: Switch/select entities listen to `sensor.wallbox_status` state changes - ensure sensor exists before enabling controls
129+
5. **Test Isolation**: Tests don't use pytest parametrize - each test explicitly constructs scenarios for clarity
130+
131+
## Branch Context
132+
Current branch: `72-bug-sensorinverter_system_state-is-longer-than-255`
133+
Working on: Fixing the inverter system state string length issue (this is why `expand_inverter_system_state()` exists)

custom_components/enpal_webparser/button.py

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,15 @@
1717
# See README.md for setup and usage instructions.
1818
#
1919

20-
import asyncio
2120
from functools import cached_property
2221
import logging
2322

2423
from homeassistant.components.button import ButtonEntity
25-
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2624
from homeassistant.helpers.device_registry import DeviceInfo
2725
from homeassistant.helpers.entity import EntityCategory
2826

29-
from .const import DOMAIN, DEFAULT_WALLBOX_API_ENDPOINT
27+
from .const import DOMAIN
28+
from .wallbox_api import WallboxApiClient
3029

3130

3231
_LOGGER = logging.getLogger(__name__)
@@ -45,30 +44,40 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
4544

4645
_LOGGER.info("[Enpal] Setting up Wallbox buttons")
4746

48-
base_url = DEFAULT_WALLBOX_API_ENDPOINT
47+
api_client = WallboxApiClient(hass)
4948

5049
buttons = [
51-
EnpalWallboxButton(hass, "Ladevorgang starten", f"{base_url}/start", "start"),
52-
EnpalWallboxButton(hass, "Ladevorgang stoppen", f"{base_url}/stop", "stop"),
50+
EnpalWallboxButton(hass, api_client, "Ladevorgang starten", "start", "start"),
51+
EnpalWallboxButton(hass, api_client, "Ladevorgang stoppen", "stop", "stop"),
5352
]
5453

5554
for key, label in MODES.items():
5655
button_name = f"Modus {label} aktivieren"
57-
buttons.append(EnpalWallboxButton(hass, button_name, f"{base_url}/set_{key}", f"set_{key}"))
56+
buttons.append(EnpalWallboxButton(hass, api_client, button_name, f"set_{key}", f"set_{key}"))
5857
_LOGGER.debug("[Enpal] Added button for mode: %s", button_name)
5958

6059
async_add_entities(buttons)
6160
_LOGGER.info("[Enpal] Wallbox buttons successfully added")
6261

6362

6463
class EnpalWallboxButton(ButtonEntity):
65-
def __init__(self, hass, name, url, unique_id):
64+
def __init__(self, hass, api_client: WallboxApiClient, name: str, action: str, unique_id: str):
65+
"""Initialize the wallbox button.
66+
67+
Args:
68+
hass: Home Assistant instance
69+
api_client: Wallbox API client instance
70+
name: Display name for the button
71+
action: Action to perform (start, stop, set_eco, set_solar, set_full)
72+
unique_id: Unique identifier for the entity
73+
"""
6674
self._hass = hass
75+
self._api_client = api_client
76+
self._action = action
6777
self._attr_name = f"Wallbox {name}"
68-
self._url = url
6978
self._attr_unique_id = f"enpal_wallbox_button_{unique_id}"
7079
self._attr_entity_category = EntityCategory.CONFIG
71-
_LOGGER.debug("[Enpal] Created button entity: %s (URL: %s)", self._attr_name, self._url)
80+
_LOGGER.debug("[Enpal] Created button entity: %s (action: %s)", self._attr_name, self._action)
7281

7382
@cached_property
7483
def device_info(self) -> DeviceInfo:
@@ -80,29 +89,29 @@ def device_info(self) -> DeviceInfo:
8089
)
8190

8291
async def async_press(self):
92+
"""Handle button press."""
8393
_LOGGER.info("[Enpal] Button pressed: %s", self._attr_name)
84-
try:
85-
session = async_get_clientsession(self._hass)
86-
async with session.post(self._url, timeout=30) as response:
87-
if response.status != 200:
88-
text = await response.text()
89-
_LOGGER.warning("[Enpal] Wallbox command failed (%s): %s", response.status, text)
90-
else:
91-
_LOGGER.info("[Enpal] Wallbox command successful: %s", self._url)
92-
except Exception as e:
93-
_LOGGER.exception("[Enpal] Wallbox request failed: %s", e)
94-
95-
# Wait a moment to allow the wallbox to process the change
96-
await asyncio.sleep(2)
97-
98-
await self._hass.services.async_call(
99-
"homeassistant",
100-
"update_entity",
101-
{
102-
"entity_id": [
103-
"sensor.wallbox_lademodus",
104-
"sensor.wallbox_status",
105-
]
106-
},
107-
blocking=True
94+
95+
# Map action to API client method
96+
action_map = {
97+
"start": self._api_client.start_charging,
98+
"stop": self._api_client.stop_charging,
99+
"set_eco": self._api_client.set_mode_eco,
100+
"set_solar": self._api_client.set_mode_solar,
101+
"set_full": self._api_client.set_mode_full,
102+
}
103+
104+
api_method = action_map.get(self._action)
105+
if not api_method:
106+
_LOGGER.error("[Enpal] Unknown action: %s", self._action)
107+
return
108+
109+
# Call API and refresh sensors
110+
endpoint = f"/{self._action}"
111+
await self._api_client.call_and_refresh_sensors(
112+
endpoint,
113+
sensor_entities=[
114+
"sensor.wallbox_lademodus",
115+
"sensor.wallbox_status",
116+
]
108117
)

0 commit comments

Comments
 (0)