Skip to content

Commit 8b18d07

Browse files
authored
Added calculated current sensors (#83)
1 parent 4141fa4 commit 8b18d07

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Test calculated current sensors for PowerSensor."""
2+
import pytest
3+
from custom_components.enpal_webparser.utils import add_calculated_current_sensors
4+
5+
6+
def test_calculate_current_sensors():
7+
"""Test that current is calculated correctly from power and voltage."""
8+
# Test data based on user's screenshot:
9+
# Phase A: -61W / 231.1V = -0.26A
10+
# Phase B: -19W / 230.1V = -0.08A
11+
# Phase C: 77W / 230.3V = 0.33A
12+
13+
sensors = [
14+
{
15+
"name": "PowerSensor: Power AC Phase (A)",
16+
"value": "-61",
17+
"unit": "W",
18+
"group": "PowerSensor",
19+
"enabled": True,
20+
"enpal_last_update": "2025-10-31T09:57:14.635Z",
21+
},
22+
{
23+
"name": "PowerSensor: Voltage Phase (A)",
24+
"value": "231.1",
25+
"unit": "V",
26+
"group": "PowerSensor",
27+
"enabled": True,
28+
"enpal_last_update": "2025-10-31T09:57:14.634Z",
29+
},
30+
{
31+
"name": "PowerSensor: Power AC Phase (B)",
32+
"value": "-19",
33+
"unit": "W",
34+
"group": "PowerSensor",
35+
"enabled": True,
36+
"enpal_last_update": "2025-10-31T09:57:14.635Z",
37+
},
38+
{
39+
"name": "PowerSensor: Voltage Phase (B)",
40+
"value": "230.1",
41+
"unit": "V",
42+
"group": "PowerSensor",
43+
"enabled": True,
44+
"enpal_last_update": "2025-10-31T09:57:14.634Z",
45+
},
46+
{
47+
"name": "PowerSensor: Power AC Phase (C)",
48+
"value": "77",
49+
"unit": "W",
50+
"group": "PowerSensor",
51+
"enabled": True,
52+
"enpal_last_update": "2025-10-31T09:57:14.636Z",
53+
},
54+
{
55+
"name": "PowerSensor: Voltage Phase (C)",
56+
"value": "230.3",
57+
"unit": "V",
58+
"group": "PowerSensor",
59+
"enabled": True,
60+
"enpal_last_update": "2025-10-31T09:57:14.634Z",
61+
},
62+
]
63+
64+
result = add_calculated_current_sensors(sensors)
65+
66+
# Should have original 6 sensors + 3 calculated current sensors
67+
assert len(result) == 9
68+
69+
# Find calculated current sensors by name
70+
current_a = next((s for s in result if "Current Phase (A)" in s["name"]), None)
71+
current_b = next((s for s in result if "Current Phase (B)" in s["name"]), None)
72+
current_c = next((s for s in result if "Current Phase (C)" in s["name"]), None)
73+
74+
assert current_a is not None, "Current sensor for phase A not found"
75+
assert current_b is not None, "Current sensor for phase B not found"
76+
assert current_c is not None, "Current sensor for phase C not found"
77+
78+
# Check calculated values (I = P / U)
79+
# Phase A: -61 / 231.1 = -0.26A
80+
assert float(current_a["value"]) == pytest.approx(-0.26, abs=0.01)
81+
assert current_a["unit"] == "A"
82+
assert current_a["device_class"] == "current"
83+
assert current_a["group"] == "PowerSensor"
84+
85+
# Phase B: -19 / 230.1 = -0.08A
86+
assert float(current_b["value"]) == pytest.approx(-0.08, abs=0.01)
87+
assert current_b["unit"] == "A"
88+
89+
# Phase C: 77 / 230.3 = 0.33A
90+
assert float(current_c["value"]) == pytest.approx(0.33, abs=0.01)
91+
assert current_c["unit"] == "A"
92+
93+
94+
def test_calculate_current_sensors_missing_voltage():
95+
"""Test that calculation handles missing voltage gracefully."""
96+
sensors = [
97+
{
98+
"name": "PowerSensor: Power AC Phase (A)",
99+
"value": "-61",
100+
"unit": "W",
101+
"group": "PowerSensor",
102+
"enabled": True,
103+
"enpal_last_update": "2025-10-31T09:57:14.635Z",
104+
},
105+
# Missing voltage sensor for phase A
106+
]
107+
108+
result = add_calculated_current_sensors(sensors)
109+
110+
# Should have only the original sensor (no current calculated)
111+
assert len(result) == 1
112+
assert "Power AC Phase (A)" in result[0]["name"]
113+
114+
115+
def test_calculate_current_sensors_zero_voltage():
116+
"""Test that calculation handles zero voltage (division by zero)."""
117+
sensors = [
118+
{
119+
"name": "PowerSensor: Power AC Phase (A)",
120+
"value": "-61",
121+
"unit": "W",
122+
"group": "PowerSensor",
123+
"enabled": True,
124+
"enpal_last_update": "2025-10-31T09:57:14.635Z",
125+
},
126+
{
127+
"name": "PowerSensor: Voltage Phase (A)",
128+
"value": "0",
129+
"unit": "V",
130+
"group": "PowerSensor",
131+
"enabled": True,
132+
"enpal_last_update": "2025-10-31T09:57:14.634Z",
133+
},
134+
]
135+
136+
result = add_calculated_current_sensors(sensors)
137+
138+
# Should have only the original sensors (no current calculated due to zero voltage)
139+
assert len(result) == 2
140+
assert not any("Current Phase (A)" in s["name"] for s in result)
141+
142+
143+
def test_calculate_current_sensors_non_powersensor_group():
144+
"""Test that calculation only applies to PowerSensor group."""
145+
sensors = [
146+
{
147+
"name": "Inverter: Power AC Phase (A)",
148+
"value": "100",
149+
"unit": "W",
150+
"group": "Inverter", # Not PowerSensor
151+
"enabled": True,
152+
"enpal_last_update": "2025-10-31T09:57:14.635Z",
153+
},
154+
{
155+
"name": "Inverter: Voltage Phase (A)",
156+
"value": "230",
157+
"unit": "V",
158+
"group": "Inverter",
159+
"enabled": True,
160+
"enpal_last_update": "2025-10-31T09:57:14.634Z",
161+
},
162+
]
163+
164+
result = add_calculated_current_sensors(sensors)
165+
166+
# Should have only the original sensors (no calculation for non-PowerSensor group)
167+
assert len(result) == 2
168+
assert not any("Current Phase" in s["name"] for s in result)

custom_components/enpal_webparser/utils.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ def parse_enpal_html_sensors(
229229

230230
sensors.extend(parse_card_rows(card, group, groups))
231231

232+
# Calculate missing current sensors from power and voltage (I = P / U)
233+
sensors = add_calculated_current_sensors(sensors)
234+
232235
return sensors
233236

234237

@@ -266,6 +269,7 @@ def parse_card_rows(card: Tag, group: str, groups: List[str]) -> List[Dict[str,
266269
"device_class": device_class,
267270
"enabled": group in groups,
268271
"enpal_last_update": timestamp_iso,
272+
"group": group, # Add group for later filtering
269273
}
270274

271275
sensor_id = make_id(sensor["name"])
@@ -316,3 +320,101 @@ def parse_timestamp(raw: Optional[str]) -> Optional[str]:
316320
return dt.isoformat()
317321
except ValueError:
318322
return raw
323+
324+
325+
def add_calculated_current_sensors(sensors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
326+
"""Calculate missing PowerSensor current sensors from power and voltage.
327+
328+
Enpal boxes no longer provide Current.Phase.A/B/C sensors directly.
329+
This function calculates them using Ohm's law: I = P / U
330+
331+
Args:
332+
sensors: List of parsed sensors
333+
334+
Returns:
335+
Updated sensor list with calculated current sensors added
336+
"""
337+
# Find power and voltage values for each phase
338+
power_sensors = {}
339+
voltage_sensors = {}
340+
341+
for sensor in sensors:
342+
name = sensor.get("name", "")
343+
group = sensor.get("group", "")
344+
345+
# Only process PowerSensor group
346+
if group != "PowerSensor":
347+
continue
348+
349+
# Generate a normalized ID from the name for matching
350+
sensor_id = make_id(name)
351+
352+
# Collect power values (Power.AC.Phase.A/B/C)
353+
if "power_ac_phase_a" in sensor_id:
354+
power_sensors["a"] = sensor
355+
elif "power_ac_phase_b" in sensor_id:
356+
power_sensors["b"] = sensor
357+
elif "power_ac_phase_c" in sensor_id:
358+
power_sensors["c"] = sensor
359+
# Collect voltage values (Voltage.Phase.A/B/C)
360+
elif "voltage_phase_a" in sensor_id:
361+
voltage_sensors["a"] = sensor
362+
elif "voltage_phase_b" in sensor_id:
363+
voltage_sensors["b"] = sensor
364+
elif "voltage_phase_c" in sensor_id:
365+
voltage_sensors["c"] = sensor
366+
367+
# Calculate current for each phase where both power and voltage are available
368+
calculated_count = 0
369+
for phase in ["a", "b", "c"]:
370+
power_sensor = power_sensors.get(phase)
371+
voltage_sensor = voltage_sensors.get(phase)
372+
373+
if not power_sensor or not voltage_sensor:
374+
continue
375+
376+
try:
377+
# Get numeric values as strings, then convert to float
378+
power_str = get_numeric_value(power_sensor.get("value", ""))
379+
voltage_str = get_numeric_value(voltage_sensor.get("value", ""))
380+
381+
if not power_str or not voltage_str:
382+
continue
383+
384+
power_value = float(power_str)
385+
voltage_value = float(voltage_str)
386+
387+
if voltage_value == 0:
388+
continue
389+
390+
# Calculate current: I = P / U
391+
current_value = power_value / voltage_value
392+
393+
# Create calculated current sensor
394+
phase_upper = phase.upper()
395+
current_sensor = {
396+
"name": f"PowerSensor: Current Phase ({phase_upper})",
397+
"value": str(round(current_value, 2)),
398+
"unit": "A",
399+
"device_class": "current",
400+
"enabled": True,
401+
"enpal_last_update": power_sensor.get("enpal_last_update") or voltage_sensor.get("enpal_last_update"),
402+
"group": "PowerSensor",
403+
}
404+
405+
sensors.append(current_sensor)
406+
calculated_count += 1
407+
408+
except (ValueError, ZeroDivisionError, TypeError) as e:
409+
_LOGGER.debug(
410+
"[Enpal] Could not calculate current for phase %s: %s",
411+
phase.upper(), e
412+
)
413+
414+
if calculated_count > 0:
415+
_LOGGER.debug(
416+
"[Enpal] Calculated %d missing PowerSensor current sensor(s)",
417+
calculated_count
418+
)
419+
420+
return sensors

0 commit comments

Comments
 (0)