This commit is contained in:
Björn Stormwall
2026-06-06 18:42:30 +02:00
commit 108d154b3d
15 changed files with 1203 additions and 0 deletions
@@ -0,0 +1,32 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import EaseeSolarCoordinator
PLATFORMS = ["sensor", "switch"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = EaseeSolarCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
return True
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
coordinator: EaseeSolarCoordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.async_request_refresh()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -0,0 +1,108 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr, selector
from .const import (
CONF_CHARGER_ID,
CONF_DEVICE_ID,
CONF_NOTIFY_TARGET,
CONF_SITE_ID,
CONF_SOLAR_EXCESS_SENSOR,
CONF_STOP_GRACE_MINUTES,
DEFAULT_STOP_GRACE_MINUTES,
DOMAIN,
EASEE_DOMAIN,
)
_NOTIFY_TARGET_SELECTOR = selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SITE_ID): str,
vol.Required(CONF_SOLAR_EXCESS_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor")
),
vol.Required(CONF_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(integration="easee")
),
vol.Optional(CONF_NOTIFY_TARGET): _NOTIFY_TARGET_SELECTOR,
}
)
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_STOP_GRACE_MINUTES, default=DEFAULT_STOP_GRACE_MINUTES): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=60, step=1, unit_of_measurement="min", mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(CONF_NOTIFY_TARGET): _NOTIFY_TARGET_SELECTOR,
}
)
def _charger_serial_from_device(hass, device_id: str) -> str | None:
"""Extract the Easee charger serial number from the device registry entry."""
registry = dr.async_get(hass)
device = registry.async_get(device_id)
if device is None:
return None
for domain, identifier in device.identifiers:
if domain == EASEE_DOMAIN:
return identifier
return None
class EaseeSolarChargingConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> EaseeSolarChargingOptionsFlow:
return EaseeSolarChargingOptionsFlow()
async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
device_id = user_input[CONF_DEVICE_ID]
charger_id = _charger_serial_from_device(self.hass, device_id)
if charger_id is None:
errors[CONF_DEVICE_ID] = "cannot_resolve_serial"
else:
await self.async_set_unique_id(charger_id)
self._abort_if_unique_id_configured()
notify_target = user_input.pop(CONF_NOTIFY_TARGET, None)
initial_options = {CONF_NOTIFY_TARGET: notify_target} if notify_target else {}
return self.async_create_entry(
title=f"Easee {charger_id}",
data={**user_input, CONF_CHARGER_ID: charger_id},
options=initial_options,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
class EaseeSolarChargingOptionsFlow(OptionsFlow):
async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
OPTIONS_SCHEMA,
self.config_entry.options,
),
)
@@ -0,0 +1,46 @@
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"
EASEE_DOMAIN = "easee"
EASEE_SERVICE_SET_DYNAMIC_LIMIT = "set_charger_dynamic_limit"
EASEE_CONTROLLABLE_STATES = {"awaiting_start", "ready_to_charge", "charging"}
# States where the session is finished/sleeping: set current to 0 once, then idle.
EASEE_SLEEPING_STATES = {"completed", "awaiting_authorization", "awaiting_schedule"}
DEFAULT_SCAN_INTERVAL = 30 # seconds
# Charging current constraints
VOLTAGE_V = 230
PHASES = 3
MIN_CHARGER_CURRENT_A = 6
MAX_CHARGER_CURRENT_A = 16
# Hysteresis
HYSTERESIS_A = 2 # dead-band within the charging range (fixed)
CONF_STOP_GRACE_MINUTES = "stop_grace_minutes"
DEFAULT_STOP_GRACE_MINUTES = 5
CONF_NOTIFY_TARGET = "notify_target"
# Watts available at each valid current step on a 3-phase 230V system (P = 3 × V × I)
AMPS_TO_POWER_W: dict[int, int] = {
a: PHASES * VOLTAGE_V * a
for a in range(MIN_CHARGER_CURRENT_A, MAX_CHARGER_CURRENT_A + 1)
}
def solar_excess_to_charger_current(excess_w: float) -> int:
"""Return the highest valid charger current (A) that fits within excess_w.
Returns 0 if excess is below the minimum charging threshold (6 A).
Steps are 1 A increments between 6 A and 16 A on a 3-phase 230 V system.
"""
amps = int(excess_w / (PHASES * VOLTAGE_V))
if amps < MIN_CHARGER_CURRENT_A:
return 0
return min(amps, MAX_CHARGER_CURRENT_A)
@@ -0,0 +1,210 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_CHARGER_ID,
CONF_DEVICE_ID,
CONF_NOTIFY_TARGET,
CONF_SOLAR_EXCESS_SENSOR,
CONF_STOP_GRACE_MINUTES,
DEFAULT_SCAN_INTERVAL,
DEFAULT_STOP_GRACE_MINUTES,
DOMAIN,
EASEE_CONTROLLABLE_STATES,
EASEE_SLEEPING_STATES,
EASEE_DOMAIN,
EASEE_SERVICE_SET_DYNAMIC_LIMIT,
HYSTERESIS_A,
solar_excess_to_charger_current,
)
_LOGGER = logging.getLogger(__name__)
_DEAD_BAND_RESYNC = timedelta(minutes=1)
class EaseeSolarCoordinator(DataUpdateCoordinator):
solar_charging_enabled: bool = True
stop_grace_enabled: bool = True
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._last_sent_current: int | None = None
self._last_sent_at: datetime | None = None
self._below_threshold_since: datetime | None = None
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
@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
def _get_charger_current_a(self) -> float | None:
entity_id = f"sensor.{self.charger_id.lower()}_current"
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
def _charger_is_sleeping(self) -> bool:
entity_id = f"sensor.{self.charger_id.lower()}_status"
state = self.hass.states.get(entity_id)
return state is not None and state.state in EASEE_SLEEPING_STATES
def _charger_allows_control(self) -> bool:
entity_id = f"sensor.{self.charger_id.lower()}_status"
state = self.hass.states.get(entity_id)
if state is None:
_LOGGER.warning("Charger status entity %s not found", entity_id)
return False
return state.state in EASEE_CONTROLLABLE_STATES
def cancel_stop_grace(self) -> None:
self._below_threshold_since = None
def _apply_stop_hysteresis(self, raw_target: int) -> int:
"""Hold current charging level for up to stop_grace_minutes before stopping.
Once excess drops below the 6 A threshold, start a timer. Only send 0 A
(stop charging) after the grace period expires without recovery.
"""
if not self.stop_grace_enabled:
self._below_threshold_since = None
return raw_target
now = datetime.now(timezone.utc)
if raw_target == 0:
if self._below_threshold_since is None:
self._below_threshold_since = now
_LOGGER.debug(
"Solar excess below threshold — grace period started (%s)",
self._stop_grace,
)
elapsed = now - self._below_threshold_since
if elapsed < self._stop_grace:
return self._last_sent_current or 0
_LOGGER.debug("Grace period expired — stopping charger")
return 0
if self._below_threshold_since is not None:
_LOGGER.debug("Solar excess recovered — grace period cancelled")
self._below_threshold_since = None
return raw_target
def _should_send(self, target: int) -> bool:
"""Return True only when the target warrants a new service call.
- First run: always send.
- Transitions to/from 0: always send (start/stop events).
- Outside dead-band: send immediately.
- Inside dead-band: send once per minute so gradual solar drift is applied.
"""
if self._last_sent_current is None:
return True
if target == self._last_sent_current:
return False
if target == 0 or self._last_sent_current == 0:
return True
if abs(target - self._last_sent_current) >= HYSTERESIS_A:
return True
# Within dead-band: resync once per minute
return (
self._last_sent_at is None
or datetime.now(timezone.utc) - self._last_sent_at >= _DEAD_BAND_RESYNC
)
async def _async_notify(self, title: str, message: str) -> None:
target = self._entry.options.get(CONF_NOTIFY_TARGET)
if not target:
return
await self.hass.services.async_call(
"notify",
target,
{"title": title, "message": message},
blocking=False,
)
async def _async_set_charger_current(self, amps: int) -> None:
await self.hass.services.async_call(
EASEE_DOMAIN,
EASEE_SERVICE_SET_DYNAMIC_LIMIT,
{"device_id": self._device_id, "current": amps},
blocking=True,
)
_LOGGER.debug("Set Easee dynamic limit to %d A", amps)
async def _async_update_data(self) -> dict:
try:
solar_excess_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
else 0
)
target_current_a = self._apply_stop_hysteresis(raw_target)
if self._charger_is_sleeping():
# Session finished/waiting: zero out once then stop adjusting.
if self._last_sent_current != 0:
await self._async_set_charger_current(0)
self._last_sent_current = 0
self._last_sent_at = datetime.now(timezone.utc)
target_current_a = 0
elif self._should_send(target_current_a) and self._charger_allows_control():
prev = self._last_sent_current
await self._async_set_charger_current(target_current_a)
self._last_sent_current = target_current_a
self._last_sent_at = datetime.now(timezone.utc)
if prev in (None, 0) and target_current_a > 0:
await self._async_notify(
"Solar charging started",
f"Solar excess is sufficient — charging at {target_current_a} A.",
)
elif prev and prev > 0 and target_current_a == 0:
await self._async_notify(
"Solar charging stopped",
"Solar excess dropped below the minimum threshold.",
)
return {
"charger_id": self.charger_id,
"solar_excess_w": solar_excess_w,
"target_current_a": target_current_a,
"charger_current_a": self._get_charger_current_a(),
"charger_status": (s := self.hass.states.get(f"sensor.{self.charger_id.lower()}_status")) and s.state,
}
except Exception as err:
raise UpdateFailed(f"Error fetching Easee data: {err}") from err
@@ -0,0 +1,12 @@
{
"domain": "easee_solar_charging",
"name": "Easee Solar Charging",
"version": "0.1.0",
"config_flow": true,
"documentation": "https://github.com/bjorn/easee-solar-charging-custom-component",
"issue_tracker": "https://github.com/bjorn/easee-solar-charging-custom-component/issues",
"requirements": [],
"dependencies": [],
"codeowners": ["@bjorn"],
"iot_class": "local_polling"
}
@@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfElectricCurrent, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import EaseeSolarCoordinator
@dataclass(frozen=True, kw_only=True)
class EaseeSolarSensorDescription(SensorEntityDescription):
data_key: str
SENSOR_DESCRIPTIONS: tuple[EaseeSolarSensorDescription, ...] = (
EaseeSolarSensorDescription(
key="solar_excess",
data_key="solar_excess_w",
name="Solar Excess Power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
EaseeSolarSensorDescription(
key="target_current",
data_key="target_current_a",
name="Target Charging Current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
EaseeSolarSensorDescription(
key="charger_current",
data_key="charger_current_a",
name="Charger Current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
EaseeSolarSensorDescription(
key="charger_status",
data_key="charger_status",
name="Charger Status",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: EaseeSolarCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
EaseeSolarSensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
)
class EaseeSolarSensor(CoordinatorEntity[EaseeSolarCoordinator], SensorEntity):
entity_description: EaseeSolarSensorDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: EaseeSolarCoordinator,
description: EaseeSolarSensorDescription,
) -> None:
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.charger_id}_{description.key}"
self.entity_id = f"sensor.esc_{coordinator.charger_id.lower()}_{description.key}"
@property
def native_value(self):
return self.coordinator.data.get(self.entity_description.data_key)
@@ -0,0 +1,85 @@
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .coordinator import EaseeSolarCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: EaseeSolarCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([
SolarChargingSwitch(coordinator),
StopGraceSwitch(coordinator),
])
class SolarChargingSwitch(RestoreEntity, SwitchEntity):
_attr_has_entity_name = True
_attr_name = "Solar Charging"
_attr_icon = "mdi:solar-power"
def __init__(self, coordinator: EaseeSolarCoordinator) -> None:
self._coordinator = coordinator
self._attr_unique_id = f"{coordinator.charger_id}_solar_charging_enabled"
self.entity_id = f"switch.esc_{coordinator.charger_id.lower()}_solar_charging"
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is not None:
self._coordinator.solar_charging_enabled = last_state.state == "on"
@property
def is_on(self) -> bool:
return self._coordinator.solar_charging_enabled
async def async_turn_on(self, **kwargs) -> None:
self._coordinator.solar_charging_enabled = True
await self._coordinator.async_request_refresh()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs) -> None:
self._coordinator.solar_charging_enabled = False
await self._coordinator.async_request_refresh()
self.async_write_ha_state()
class StopGraceSwitch(RestoreEntity, SwitchEntity):
_attr_has_entity_name = True
_attr_name = "Stop Grace"
_attr_icon = "mdi:timer-pause"
def __init__(self, coordinator: EaseeSolarCoordinator) -> None:
self._coordinator = coordinator
self._attr_unique_id = f"{coordinator.charger_id}_stop_grace_enabled"
self.entity_id = f"switch.esc_{coordinator.charger_id.lower()}_stop_grace"
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is not None:
self._coordinator.stop_grace_enabled = last_state.state == "on"
@property
def is_on(self) -> bool:
return self._coordinator.stop_grace_enabled
async def async_turn_on(self, **kwargs) -> None:
self._coordinator.stop_grace_enabled = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs) -> None:
self._coordinator.stop_grace_enabled = False
# Reset any running timer so the charger stops on the next poll
self._coordinator.cancel_stop_grace()
await self._coordinator.async_request_refresh()
self.async_write_ha_state()
@@ -0,0 +1,48 @@
{
"entity": {
"switch": {
"solar_charging_enabled": {
"name": "Solar Charging"
},
"stop_grace_enabled": {
"name": "Stop Grace"
}
}
},
"options": {
"step": {
"init": {
"title": "Easee Solar Charging options",
"data": {
"stop_grace_minutes": "Stop grace period",
"notify_target": "Notification target"
},
"data_description": {
"stop_grace_minutes": "How many minutes to keep charging after solar excess drops below the minimum threshold, before stopping the charger.",
"notify_target": "Name of a notify service to receive start/stop alerts (e.g. mobile_app_my_phone). Leave empty to disable notifications."
}
}
}
},
"config": {
"step": {
"user": {
"title": "Set up Easee Solar Charging",
"data": {
"site_id": "Site ID",
"solar_excess_sensor": "Solar Excess 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.",
"notify_target": "Name of a notify service to receive start/stop alerts (e.g. mobile_app_my_phone). Leave empty to disable notifications."
}
}
},
"error": {},
"abort": {
"already_configured": "Charger is already configured."
}
}
}