diff --git a/custom_components/easee_solar_charging/config_flow.py b/custom_components/easee_solar_charging/config_flow.py index 66e8779..a17d3f2 100644 --- a/custom_components/easee_solar_charging/config_flow.py +++ b/custom_components/easee_solar_charging/config_flow.py @@ -8,11 +8,19 @@ from homeassistant.helpers import device_registry as dr, selector from .const import ( CONF_CHARGER_ID, + CHARGING_PROFILE_ALL_SURPLUS, + CHARGING_PROFILE_CONSERVATIVE, + CONF_CHARGING_PROFILE, + CONF_DEAD_BAND_RESYNC_MINUTES, CONF_DEVICE_ID, + CONF_EV_CHARGING_SENSOR, + CONF_HOUSE_LOAD_SENSOR, CONF_NOTIFY_TARGET, CONF_SITE_ID, - CONF_SOLAR_EXCESS_SENSOR, + CONF_SOLAR_PRODUCTION_SENSOR, + DEFAULT_CHARGING_PROFILE, CONF_STOP_GRACE_MINUTES, + DEFAULT_DEAD_BAND_RESYNC_MINUTES, DEFAULT_STOP_GRACE_MINUTES, DOMAIN, EASEE_DOMAIN, @@ -24,11 +32,31 @@ _STOP_GRACE_SELECTOR = selector.NumberSelector( ) ) +_DEAD_BAND_RESYNC_SELECTOR = selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=60, step=1, unit_of_measurement="min", mode=selector.NumberSelectorMode.BOX + ) +) + +_CHARGING_PROFILE_SELECTOR = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value=CHARGING_PROFILE_CONSERVATIVE, label="Conservative"), + selector.SelectOptionDict(value=CHARGING_PROFILE_ALL_SURPLUS, label="All Surplus"), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) +) + +_POWER_SENSOR_SELECTOR = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class="power") +) + _STATIC_USER_FIELDS = { vol.Required(CONF_SITE_ID): str, - vol.Required(CONF_SOLAR_EXCESS_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ), + vol.Required(CONF_SOLAR_PRODUCTION_SENSOR): _POWER_SENSOR_SELECTOR, + vol.Required(CONF_HOUSE_LOAD_SENSOR): _POWER_SENSOR_SELECTOR, + vol.Required(CONF_EV_CHARGING_SENSOR): _POWER_SENSOR_SELECTOR, vol.Required(CONF_DEVICE_ID): selector.DeviceSelector( selector.DeviceSelectorConfig(integration="easee") ), @@ -63,10 +91,18 @@ def _user_schema(hass: HomeAssistant) -> vol.Schema: def _options_schema(hass: HomeAssistant, current: dict) -> vol.Schema: return vol.Schema({ + vol.Required( + CONF_CHARGING_PROFILE, + default=current.get(CONF_CHARGING_PROFILE, DEFAULT_CHARGING_PROFILE), + ): _CHARGING_PROFILE_SELECTOR, vol.Required( CONF_STOP_GRACE_MINUTES, default=current.get(CONF_STOP_GRACE_MINUTES, DEFAULT_STOP_GRACE_MINUTES), ): _STOP_GRACE_SELECTOR, + vol.Required( + CONF_DEAD_BAND_RESYNC_MINUTES, + default=current.get(CONF_DEAD_BAND_RESYNC_MINUTES, DEFAULT_DEAD_BAND_RESYNC_MINUTES), + ): _DEAD_BAND_RESYNC_SELECTOR, vol.Optional(CONF_NOTIFY_TARGET): _notify_selector(_notify_services(hass)), }) diff --git a/custom_components/easee_solar_charging/const.py b/custom_components/easee_solar_charging/const.py index f236e93..a47ec34 100644 --- a/custom_components/easee_solar_charging/const.py +++ b/custom_components/easee_solar_charging/const.py @@ -3,7 +3,11 @@ DOMAIN = "easee_solar_charging" CONF_CHARGER_ID = "charger_id" CONF_DEVICE_ID = "device_id" CONF_SITE_ID = "site_id" -CONF_SOLAR_EXCESS_SENSOR = "solar_excess_sensor" +CONF_SOLAR_PRODUCTION_SENSOR = "solar_production_sensor" +CONF_HOUSE_LOAD_SENSOR = "house_load_sensor" +CONF_EV_CHARGING_SENSOR = "ev_charging_sensor" + +ROLLING_AVERAGE_SECONDS = 30 EASEE_DOMAIN = "easee" EASEE_SERVICE_SET_DYNAMIC_LIMIT = "set_charger_dynamic_limit" @@ -25,6 +29,14 @@ HYSTERESIS_A = 2 # dead-band within the charging range (fixed) CONF_STOP_GRACE_MINUTES = "stop_grace_minutes" DEFAULT_STOP_GRACE_MINUTES = 5 +CONF_CHARGING_PROFILE = "charging_profile" +CHARGING_PROFILE_CONSERVATIVE = "conservative" +CHARGING_PROFILE_ALL_SURPLUS = "all_surplus" +DEFAULT_CHARGING_PROFILE = CHARGING_PROFILE_CONSERVATIVE + +CONF_DEAD_BAND_RESYNC_MINUTES = "dead_band_resync_minutes" +DEFAULT_DEAD_BAND_RESYNC_MINUTES = 1 + CONF_NOTIFY_TARGET = "notify_target" # Watts available at each valid current step on a 3-phase 230V system (P = 3 × V × I) diff --git a/custom_components/easee_solar_charging/coordinator.py b/custom_components/easee_solar_charging/coordinator.py index aab6b3b..67274a2 100644 --- a/custom_components/easee_solar_charging/coordinator.py +++ b/custom_components/easee_solar_charging/coordinator.py @@ -1,18 +1,29 @@ from __future__ import annotations +from collections import deque from datetime import datetime, timedelta, timezone import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + CHARGING_PROFILE_ALL_SURPLUS, + CHARGING_PROFILE_CONSERVATIVE, + CONF_CHARGING_PROFILE, CONF_CHARGER_ID, + CONF_DEAD_BAND_RESYNC_MINUTES, CONF_DEVICE_ID, + CONF_EV_CHARGING_SENSOR, + CONF_HOUSE_LOAD_SENSOR, CONF_NOTIFY_TARGET, - CONF_SOLAR_EXCESS_SENSOR, + CONF_SOLAR_PRODUCTION_SENSOR, CONF_STOP_GRACE_MINUTES, + DEFAULT_CHARGING_PROFILE, + DEFAULT_DEAD_BAND_RESYNC_MINUTES, DEFAULT_SCAN_INTERVAL, DEFAULT_STOP_GRACE_MINUTES, DOMAIN, @@ -21,12 +32,12 @@ from .const import ( EASEE_DOMAIN, EASEE_SERVICE_SET_DYNAMIC_LIMIT, HYSTERESIS_A, + ROLLING_AVERAGE_SECONDS, solar_excess_to_charger_current, ) _LOGGER = logging.getLogger(__name__) -_DEAD_BAND_RESYNC = timedelta(minutes=1) class EaseeSolarCoordinator(DataUpdateCoordinator): @@ -36,8 +47,15 @@ class EaseeSolarCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._entry = entry self.charger_id = entry.data[CONF_CHARGER_ID] - self._solar_excess_sensor = entry.data[CONF_SOLAR_EXCESS_SENSOR] self._device_id: str = entry.data[CONF_DEVICE_ID] + self._production_sensor = entry.data[CONF_SOLAR_PRODUCTION_SENSOR] + self._house_load_sensor = entry.data[CONF_HOUSE_LOAD_SENSOR] + self._ev_charging_sensor = entry.data[CONF_EV_CHARGING_SENSOR] + self._samples: dict[str, deque[tuple[datetime, float]]] = { + self._production_sensor: deque(), + self._house_load_sensor: deque(), + self._ev_charging_sensor: deque(), + } self._last_sent_current: int | None = None self._last_sent_at: datetime | None = None self._below_threshold_since: datetime | None = None @@ -47,25 +65,71 @@ class EaseeSolarCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) + self._setup_state_listeners() + + def _setup_state_listeners(self) -> None: + @callback + def _on_state_change(event) -> None: + entity_id: str = event.data["entity_id"] + new_state = event.data.get("new_state") + if new_state is None or new_state.state in ("unknown", "unavailable"): + return + try: + value = float(new_state.state) + except ValueError: + return + samples = self._samples[entity_id] + samples.append((datetime.now(timezone.utc), value)) + + self._entry.async_on_unload( + async_track_state_change_event(self.hass, list(self._samples), _on_state_change) + ) + + def _rolling_average(self, entity_id: str) -> float | None: + now = datetime.now(timezone.utc) + cutoff = now - timedelta(seconds=ROLLING_AVERAGE_SECONDS) + samples = self._samples[entity_id] + while samples and samples[0][0] < cutoff: + samples.popleft() + if samples: + return sum(v for _, v in samples) / len(samples) + # No samples collected yet — fall back to current state + state = self.hass.states.get(entity_id) + if state is None or state.state in ("unknown", "unavailable"): + return None + try: + return float(state.state) + except ValueError: + return None @property def _stop_grace(self) -> timedelta: minutes = self._entry.options.get(CONF_STOP_GRACE_MINUTES, DEFAULT_STOP_GRACE_MINUTES) return timedelta(minutes=minutes) - def _get_solar_excess_w(self) -> float | None: - state = self.hass.states.get(self._solar_excess_sensor) - if state is None or state.state in ("unknown", "unavailable"): - return None - try: - return float(state.state) - except ValueError: - _LOGGER.warning( - "Solar excess sensor %s has non-numeric state: %s", - self._solar_excess_sensor, - state.state, - ) - return None + @property + def _dead_band_resync(self) -> timedelta: + minutes = self._entry.options.get(CONF_DEAD_BAND_RESYNC_MINUTES, DEFAULT_DEAD_BAND_RESYNC_MINUTES) + return timedelta(minutes=minutes) + + def _get_solar_excess_w(self) -> tuple[float | None, float | None, float | None, float | None]: + profile = self._entry.options.get(CONF_CHARGING_PROFILE, DEFAULT_CHARGING_PROFILE) + production = self._rolling_average(self._production_sensor) + house_load = self._rolling_average(self._house_load_sensor) or 0.0 + ev_charging = self._rolling_average(self._ev_charging_sensor) or 0.0 + + if production is None: + return None, None, house_load, ev_charging + + if profile == CHARGING_PROFILE_ALL_SURPLUS: + # Allocate all solar production to EV; house load draws from grid. + surplus = production + else: + assert profile == CHARGING_PROFILE_CONSERVATIVE + surplus = production - house_load - ev_charging + + return surplus, production, house_load, ev_charging + def _get_charger_current_a(self) -> int | None: entity_id = f"sensor.{self.charger_id.lower()}_current" @@ -143,7 +207,7 @@ class EaseeSolarCoordinator(DataUpdateCoordinator): # Within dead-band or same value: re-align once per minute. return ( self._last_sent_at is None - or datetime.now(timezone.utc) - self._last_sent_at >= _DEAD_BAND_RESYNC + or datetime.now(timezone.utc) - self._last_sent_at >= self._dead_band_resync ) async def _async_notify(self, title: str, message: str) -> None: @@ -168,7 +232,7 @@ class EaseeSolarCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: try: - solar_excess_w = self._get_solar_excess_w() + solar_excess_w, production_w, house_load_w, ev_charging_w = self._get_solar_excess_w() raw_target = ( solar_excess_to_charger_current(solar_excess_w) if self.solar_charging_enabled and solar_excess_w is not None @@ -202,6 +266,9 @@ class EaseeSolarCoordinator(DataUpdateCoordinator): return { "charger_id": self.charger_id, + "solar_production_w": production_w, + "house_load_w": house_load_w, + "ev_charging_w": ev_charging_w, "solar_excess_w": solar_excess_w, "target_current_a": target_current_a, "charger_current_a": self._get_charger_current_a(), diff --git a/custom_components/easee_solar_charging/sensor.py b/custom_components/easee_solar_charging/sensor.py index a8a5a18..0ba3241 100644 --- a/custom_components/easee_solar_charging/sensor.py +++ b/custom_components/easee_solar_charging/sensor.py @@ -24,6 +24,30 @@ class EaseeSolarSensorDescription(SensorEntityDescription): SENSOR_DESCRIPTIONS: tuple[EaseeSolarSensorDescription, ...] = ( + EaseeSolarSensorDescription( + key="solar_production", + data_key="solar_production_w", + name="Solar Production", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), + EaseeSolarSensorDescription( + key="house_load", + data_key="house_load_w", + name="House Load", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), + EaseeSolarSensorDescription( + key="ev_charging", + data_key="ev_charging_w", + name="EV Charging Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), EaseeSolarSensorDescription( key="solar_excess", data_key="solar_excess_w", diff --git a/custom_components/easee_solar_charging/translations/en.json b/custom_components/easee_solar_charging/translations/en.json index 98749c0..4db9bb9 100644 --- a/custom_components/easee_solar_charging/translations/en.json +++ b/custom_components/easee_solar_charging/translations/en.json @@ -14,11 +14,15 @@ "init": { "title": "Easee Solar Charging options", "data": { + "charging_profile": "Charging profile", "stop_grace_minutes": "Stop grace period", + "dead_band_resync_minutes": "Dead-band resync interval", "notify_target": "Notification target" }, "data_description": { + "charging_profile": "Conservative: charge only on true solar surplus (production − house load − current EV draw). All Surplus: allocate all solar production to the EV; house load draws from the grid.", "stop_grace_minutes": "How many minutes to keep charging after solar excess drops below the minimum threshold, before stopping the charger.", + "dead_band_resync_minutes": "How often to re-send the current charging level when within the dead-band, to track gradual solar drift.", "notify_target": "Name of a notify service to receive start/stop alerts (e.g. mobile_app_my_phone). Leave empty to disable notifications." } } @@ -30,12 +34,16 @@ "title": "Set up Easee Solar Charging", "data": { "site_id": "Site ID", - "solar_excess_sensor": "Solar Excess Power Sensor", + "solar_production_sensor": "Solar Production Sensor", + "house_load_sensor": "House Load Sensor", + "ev_charging_sensor": "EV Charging Power Sensor", "device_id": "Easee Charger", "notify_target": "Notification target" }, "data_description": { - "solar_excess_sensor": "Sensor reporting the momentarily available solar power surplus in Watts.", + "solar_production_sensor": "Sensor reporting total solar panel output in Watts.", + "house_load_sensor": "Sensor reporting total household consumption (excluding EV charging) in Watts.", + "ev_charging_sensor": "Sensor reporting current EV charging power in Watts.", "notify_target": "Name of a notify service to receive start/stop alerts (e.g. mobile_app_my_phone). Leave empty to disable notifications." } } diff --git a/lovelace_card.yaml b/lovelace_card.yaml index 1949a9d..4f4b420 100644 --- a/lovelace_card.yaml +++ b/lovelace_card.yaml @@ -15,14 +15,23 @@ cards: show_icon: true show_state: true entities: - - entity: sensor.esc_CHARGER_ID_solar_excess - name: Solar Excess + - entity: sensor.esc_CHARGER_ID_solar_production + name: Production icon: mdi:solar-power + - entity: sensor.esc_CHARGER_ID_house_load + name: House Load + icon: mdi:home-lightning-bolt + - entity: sensor.esc_CHARGER_ID_ev_charging + name: EV Power + icon: mdi:car-electric + - entity: sensor.esc_CHARGER_ID_solar_excess + name: Surplus + icon: mdi:lightning-bolt-circle - entity: sensor.esc_CHARGER_ID_target_current - name: Target Current + name: Target icon: mdi:lightning-bolt - entity: sensor.esc_CHARGER_ID_charger_current - name: Charger Current + name: Actual icon: mdi:current-ac - entity: sensor.esc_CHARGER_ID_charger_status name: Status