@@ -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
0 commit comments