Skip to content

Commit c7276ac

Browse files
authored
Merge pull request #195 from traktore-org/claude/prepare-release-pr-U4QAB
Release v1.5.2-beta.2 (prerelease): per-charger EV support
2 parents 79d79fe + ab43e7d commit c7276ac

19 files changed

Lines changed: 1761 additions & 273 deletions

config_flow.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,34 @@ async def async_step_ev_charger_add(
949949
): selector.NumberSelector(
950950
selector.NumberSelectorConfig(min=1, max=10, step=1, mode="slider")
951951
),
952+
# Per-charger night charging settings (#193)
953+
vol.Optional(
954+
"daily_ev_target",
955+
default=self._data.get("daily_ev_target", 10),
956+
): selector.NumberSelector(
957+
selector.NumberSelectorConfig(
958+
min=0, max=100, step=0.5,
959+
unit_of_measurement="kWh", mode="slider",
960+
)
961+
),
962+
vol.Optional(
963+
"ev_night_initial_current",
964+
default=self._data.get("ev_night_initial_current", 10),
965+
): selector.NumberSelector(
966+
selector.NumberSelectorConfig(
967+
min=6, max=32, step=1,
968+
unit_of_measurement="A", mode="slider",
969+
)
970+
),
971+
vol.Optional(
972+
"ev_min_current",
973+
default=self._data.get("ev_min_current", 6),
974+
): selector.NumberSelector(
975+
selector.NumberSelectorConfig(
976+
min=6, max=16, step=1,
977+
unit_of_measurement="A", mode="slider",
978+
)
979+
),
952980
}),
953981
errors=errors,
954982
)
@@ -1021,6 +1049,34 @@ async def async_step_ev_charger_edit(
10211049
): selector.NumberSelector(
10221050
selector.NumberSelectorConfig(min=1, max=10, step=1, mode="slider")
10231051
),
1052+
# Per-charger night charging settings (#193)
1053+
vol.Optional(
1054+
"daily_ev_target",
1055+
default=charger.get("daily_ev_target", self._data.get("daily_ev_target", 10)),
1056+
): selector.NumberSelector(
1057+
selector.NumberSelectorConfig(
1058+
min=0, max=100, step=0.5,
1059+
unit_of_measurement="kWh", mode="slider",
1060+
)
1061+
),
1062+
vol.Optional(
1063+
"ev_night_initial_current",
1064+
default=charger.get("ev_night_initial_current", self._data.get("ev_night_initial_current", 10)),
1065+
): selector.NumberSelector(
1066+
selector.NumberSelectorConfig(
1067+
min=6, max=32, step=1,
1068+
unit_of_measurement="A", mode="slider",
1069+
)
1070+
),
1071+
vol.Optional(
1072+
"ev_min_current",
1073+
default=charger.get("ev_min_current", self._data.get("ev_min_current", 6)),
1074+
): selector.NumberSelector(
1075+
selector.NumberSelectorConfig(
1076+
min=6, max=16, step=1,
1077+
unit_of_measurement="A", mode="slider",
1078+
)
1079+
),
10241080
}),
10251081
errors=errors,
10261082
)

coordinator/coordinator.py

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
119119
self._ev_enable_surplus_per_charger: Dict[str, Optional[float]] = {}
120120
self._ev_charge_started_per_charger: Dict[str, Optional[float]] = {}
121121
self._ev_last_change_per_charger: Dict[str, Any] = {}
122+
self._daily_ev_per_charger: Dict[str, float] = {} # Per-charger daily energy (#193)
123+
self._daily_ev_per_charger_date: Optional[str] = None
122124
self._notification_manager = NotificationManager(hass, config)
123125

124126
# Storage will be initialized with entry_id later
@@ -488,19 +490,53 @@ async def _async_update_data(self) -> Dict[str, Any]:
488490
# Step 4.5: Update session tracking (before charging decisions)
489491
# Multi-charger (#112): track sessions for each charger
490492
if self._ev_devices:
493+
# Collect per-charger power to proportionally attribute flows (#15)
494+
charger_powers: Dict[str, float] = {}
495+
for cid, ev_dev in self._ev_devices.items():
496+
cp = 0.0
497+
if ev_dev.power_entity_id:
498+
pstate = self.hass.states.get(ev_dev.power_entity_id)
499+
if pstate and pstate.state not in ("unknown", "unavailable"):
500+
try:
501+
cp = float(pstate.state)
502+
unit = pstate.attributes.get("unit_of_measurement", "W")
503+
if unit == "kW":
504+
cp *= 1000
505+
except (ValueError, TypeError):
506+
pass
507+
charger_powers[cid] = cp
508+
total_charger_power = sum(charger_powers.values())
509+
491510
for cid, ev_dev in self._ev_devices.items():
492511
if cid not in self._session_data_per_charger:
493512
self._session_data_per_charger[cid] = SessionData()
494513
if cid not in self._last_ev_connected_per_charger:
495514
self._last_ev_connected_per_charger[cid] = False
515+
# Scale power flows proportionally to each charger's share (#15)
516+
if total_charger_power > 0:
517+
frac = charger_powers[cid] / total_charger_power
518+
from .types import PowerFlows as _PF
519+
charger_flows = _PF(
520+
solar_to_ev=power_flows.solar_to_ev * frac,
521+
grid_to_ev=power_flows.grid_to_ev * frac,
522+
battery_to_ev=power_flows.battery_to_ev * frac,
523+
solar_to_home=power_flows.solar_to_home,
524+
solar_to_battery=power_flows.solar_to_battery,
525+
solar_to_grid=power_flows.solar_to_grid,
526+
grid_to_home=power_flows.grid_to_home,
527+
grid_to_battery=power_flows.grid_to_battery,
528+
battery_to_home=power_flows.battery_to_home,
529+
)
530+
else:
531+
charger_flows = power_flows
496532
# Swap context for per-charger session tracking
497533
saved_dev, saved_sess, saved_conn = (
498534
self._ev_device, self._session_data, self._last_ev_connected
499535
)
500536
self._ev_device = ev_dev
501537
self._session_data = self._session_data_per_charger[cid]
502538
self._last_ev_connected = self._last_ev_connected_per_charger[cid]
503-
self._update_session_tracking(power, power_flows)
539+
self._update_session_tracking(power, charger_flows)
504540
# Save back per-charger state
505541
self._session_data_per_charger[cid] = self._session_data
506542
self._last_ev_connected_per_charger[cid] = self._last_ev_connected
@@ -544,22 +580,26 @@ async def _async_update_data(self) -> Dict[str, Any]:
544580
ev_budget_per_charger = {}
545581
num_chargers = len(self._ev_devices)
546582

547-
# Night target: split equally across connected chargers
548-
if num_chargers > 1 and charging_state == ChargingState.NIGHT_CHARGING_ACTIVE:
549-
connected_count = sum(
550-
1 for d in self._ev_devices.values()
551-
if getattr(d, '_session_active', False) or power.ev_connected
552-
)
553-
if connected_count > 1:
554-
per_charger_night_kwh = charging_context.night_target_kwh / connected_count
555-
self._night_target_per_charger = per_charger_night_kwh
556-
else:
557-
self._night_target_per_charger = None
558-
else:
583+
# Night target: use per-charger targets if configured (#193)
584+
self._night_target_per_charger_map = {}
585+
if num_chargers >= 1 and charging_state == ChargingState.NIGHT_CHARGING_ACTIVE:
586+
ev_chargers_cfg = self.config.get("ev_chargers", [])
587+
charger_cfg_by_id = {c.get("id"): c for c in ev_chargers_cfg}
588+
global_target = charging_context.night_target_kwh
589+
590+
for cid in self._ev_devices:
591+
cfg = charger_cfg_by_id.get(cid, {})
592+
# Per-charger target from config, fallback to global
593+
target = cfg.get("daily_ev_target", global_target)
594+
# Remaining = target - daily energy delivered by this charger
595+
daily = self._daily_ev_per_charger.get(cid, 0.0)
596+
self._night_target_per_charger_map[cid] = max(0, target - daily)
597+
598+
# Backward compat: set the old scalar for single-value reads
559599
self._night_target_per_charger = None
560600

561601
# Solar budget: distribute by priority
562-
if num_chargers > 1 and charging_state in (
602+
if num_chargers >= 1 and charging_state in (
563603
ChargingState.SOLAR_CHARGING_ACTIVE,
564604
ChargingState.SOLAR_SUPER_CHARGING,
565605
ChargingState.SOLAR_CHARGING_ALLOWED,
@@ -577,6 +617,14 @@ async def _async_update_data(self) -> Dict[str, Any]:
577617
key=lambda x: x[1].priority,
578618
)
579619
for cid, ev_dev in sorted_chargers:
620+
# Check per-charger night charging switch (#193)
621+
if charging_state == ChargingState.NIGHT_CHARGING_ACTIVE:
622+
night_switch = self.hass.states.get(
623+
f"switch.sem_charger_{cid}_night_charging"
624+
)
625+
if night_switch and night_switch.state == "off":
626+
continue # Skip this charger for night charging
627+
580628
# Save coordinator-level state, swap in per-charger state
581629
saved = {
582630
"dev": self._ev_device,
@@ -591,6 +639,10 @@ async def _async_update_data(self) -> Dict[str, Any]:
591639
self._ev_charge_started_at = self._ev_charge_started_per_charger.get(cid)
592640
self._ev_last_change_time = self._ev_last_change_per_charger.get(cid)
593641
self._current_charger_budget = ev_budget_per_charger.get(cid)
642+
# Set per-charger night target (#193)
643+
per_charger_target = getattr(self, '_night_target_per_charger_map', {}).get(cid)
644+
if per_charger_target is not None:
645+
self._night_target_per_charger = per_charger_target
594646
try:
595647
await self._execute_ev_control(
596648
charging_state, power, energy, charging_context
@@ -737,6 +789,8 @@ async def _async_update_data(self) -> Dict[str, Any]:
737789
ev_charger_count=len(self._ev_devices),
738790
ev_charger_ids=list(self._ev_devices.keys()),
739791
ev_intelligence=ev_intelligence,
792+
per_charger_intelligence=self._build_per_charger_intelligence(),
793+
per_charger_daily_energy=dict(self._daily_ev_per_charger),
740794
last_update=dt_util.now(),
741795
)
742796

@@ -1787,7 +1841,7 @@ def _update_ev_intelligence(
17871841
interval_hours = self.update_interval.total_seconds() / 3600
17881842

17891843
# Multi-charger (#112): run per-charger taper detection
1790-
if self._ev_devices and len(self._ev_devices) > 1:
1844+
if self._ev_devices and len(self._ev_devices) >= 1:
17911845
for cid, ev_dev in self._ev_devices.items():
17921846
if cid not in self._ev_taper_detectors:
17931847
self._ev_taper_detectors[cid] = EVTaperDetector(self.config)
@@ -1813,7 +1867,25 @@ def _update_ev_intelligence(
18131867
pass
18141868

18151869
charger_setpoint = getattr(ev_dev, "_current_setpoint", 0.0)
1816-
charger_connected = getattr(ev_dev, "_session_active", False) or power.ev_connected
1870+
# Use per-charger session state; fall back to power threshold
1871+
# as proxy — do NOT OR with global ev_connected which belongs
1872+
# to the primary charger only.
1873+
charger_connected = getattr(ev_dev, "_session_active", False)
1874+
if not charger_connected and ev_dev.power_entity_id:
1875+
charger_connected = charger_power > 50
1876+
1877+
# Accumulate per-charger daily energy (#193)
1878+
# Use sunrise-based day (offset by 7h) so midnight doesn't
1879+
# split a night-charging session across two "days".
1880+
if charger_power > 0:
1881+
ev_day = self.time_manager.get_current_meter_day_sunrise_based().isoformat()
1882+
if self._daily_ev_per_charger_date != ev_day:
1883+
self._daily_ev_per_charger = {}
1884+
self._daily_ev_per_charger_date = ev_day
1885+
increment = charger_power * interval_hours / 1000 # W → kWh
1886+
self._daily_ev_per_charger[cid] = (
1887+
self._daily_ev_per_charger.get(cid, 0.0) + increment
1888+
)
18171889

18181890
if charger_power > 0 or charger_connected:
18191891
self._ev_taper_detectors[cid].update(
@@ -1900,7 +1972,7 @@ def _update_ev_intelligence(
19001972
# Self-healing: if SOC is at 0% but car just charged, something is wrong
19011973
# Reset to a reasonable estimate based on recent session energy
19021974
if estimated_soc <= 0 and self._session_data.energy_kwh > 1.0 and power.ev_connected:
1903-
capacity = self._config.get("ev_battery_capacity_kwh", 40)
1975+
capacity = self.config.get("ev_battery_capacity_kwh", 40)
19041976
session_soc = min(95.0, self._session_data.energy_kwh / capacity * 100 * 0.92)
19051977
self._ev_taper_detector._energy_since_full = (100 - session_soc) / 100 * capacity
19061978
self._ev_taper_detector._estimated_soc = session_soc
@@ -1942,6 +2014,32 @@ def _update_ev_intelligence(
19422014
charge_skip_reason=skip_reason,
19432015
)
19442016

2017+
def _build_per_charger_intelligence(self) -> dict:
2018+
"""Build per-charger intelligence data from per-charger taper detectors (#193)."""
2019+
if not self._ev_taper_detectors:
2020+
return {}
2021+
2022+
result = {}
2023+
for cid, detector in self._ev_taper_detectors.items():
2024+
soc = detector.get_virtual_soc()
2025+
predicted = getattr(self, '_predictor', None)
2026+
predicted_daily = predicted.predict_ev_consumption_tomorrow(dt_util.now()) if predicted else 0
2027+
nights, charge_needed, skip_reason = detector.calculate_nights_until_charge(
2028+
predicted_daily, None,
2029+
)
2030+
taper_data = detector.get_taper_data() if hasattr(detector, 'get_taper_data') else None
2031+
2032+
result[cid] = {
2033+
"estimated_soc": round(soc, 1),
2034+
"nights_until_charge": nights,
2035+
"charge_needed": charge_needed,
2036+
"charge_skip_reason": skip_reason,
2037+
"minutes_to_full": taper_data.minutes_to_full if taper_data else None,
2038+
"battery_health": detector.battery_health_pct,
2039+
}
2040+
2041+
return result
2042+
19452043
def _read_outdoor_temperature(self) -> float:
19462044
"""Read outdoor temperature from weather entity or configured sensor.
19472045

coordinator/ev_control.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ class EVControlMixin:
3939
- config, hass
4040
"""
4141

42+
def _get_active_charger_config(self) -> dict:
43+
"""Get config dict for the currently active charger (#193).
44+
45+
Returns the per-charger config from ev_chargers[] if available,
46+
otherwise an empty dict (caller falls back to global config).
47+
"""
48+
ev_device = getattr(self, '_ev_device', None)
49+
if ev_device is None:
50+
return {}
51+
device_id = getattr(ev_device, 'device_id', None)
52+
if device_id is None:
53+
return {}
54+
ev_chargers = self.config.get("ev_chargers", [])
55+
for cfg in ev_chargers:
56+
if cfg.get("id") == device_id:
57+
return cfg
58+
return {}
59+
4260
SOLAR_CHARGING_STATES = {
4361
ChargingState.SOLAR_CHARGING_ACTIVE,
4462
ChargingState.SOLAR_SUPER_CHARGING,
@@ -97,9 +115,16 @@ async def _execute_ev_control(
97115
ev._session_active = True
98116
_LOGGER.info("Night: KEBA already active (%.0fW), resuming", power.ev_power)
99117

100-
# Read configurable EV parameters
101-
initial_amps = int(self.config.get("ev_night_initial_current", 10))
102-
min_amps = int(self.config.get("ev_min_current", 6))
118+
# Read configurable EV parameters — per-charger overrides (#193)
119+
charger_cfg = self._get_active_charger_config()
120+
initial_amps = int(charger_cfg.get(
121+
"ev_night_initial_current",
122+
self.config.get("ev_night_initial_current", 10),
123+
))
124+
min_amps = int(charger_cfg.get(
125+
"ev_min_current",
126+
self.config.get("ev_min_current", 6),
127+
))
103128
stall_cooldown = int(self.config.get("ev_stall_cooldown", 120))
104129

105130
if not ev._session_active:

coordinator/sensor_reader.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,16 @@ def _read_from_energy_dashboard(self) -> PowerReadings:
370370
readings.battery_soc = self._last_valid_soc
371371
readings.battery_soc_unavailable = True
372372

373-
# EV power — Energy Dashboard first, then config fallback
374-
if ed.ev_power:
373+
# EV power — sum all chargers if multi-charger (#193), else single sensor
374+
ev_chargers = self._raw_config.get("ev_chargers", [])
375+
if len(ev_chargers) > 1:
376+
total_ev = 0.0
377+
for charger_cfg in ev_chargers:
378+
cps = charger_cfg.get("ev_charging_power_sensor")
379+
if cps:
380+
total_ev += self._read_sensor(cps, "ev")
381+
readings.ev_power = total_ev
382+
elif ed.ev_power:
375383
readings.ev_power = self._read_sensor(ed.ev_power, "ev")
376384
elif self.config.ev_power_sensor:
377385
readings.ev_power = self._read_sensor(self.config.ev_power_sensor, "ev")
@@ -547,8 +555,16 @@ def _read_from_legacy_config(self) -> PowerReadings:
547555
self.config.battery_temperature_sensor, "battery_temp"
548556
)
549557

550-
# EV power
551-
if self.config.ev_power_sensor:
558+
# EV power — sum all chargers if multi-charger (#193)
559+
ev_chargers = self._raw_config.get("ev_chargers", [])
560+
if len(ev_chargers) > 1:
561+
total_ev = 0.0
562+
for charger_cfg in ev_chargers:
563+
cps = charger_cfg.get("ev_charging_power_sensor")
564+
if cps:
565+
total_ev += self._read_sensor(cps, "ev")
566+
readings.ev_power = total_ev
567+
elif self.config.ev_power_sensor:
552568
readings.ev_power = self._read_sensor(
553569
self.config.ev_power_sensor, "ev"
554570
)

0 commit comments

Comments
 (0)