Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions custom_components/evse_load_balancer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@

COORDINATOR_STATE_AWAITING_CHARGER = "awaiting_charger"
COORDINATOR_STATE_MONITORING_LOAD = "monitoring_loads"
COORDINATOR_STATE_INITIALIZING = "initializing"
COORDINATOR_STATES: tuple[str, ...] = (
COORDINATOR_STATE_AWAITING_CHARGER,
COORDINATOR_STATE_MONITORING_LOAD,
COORDINATOR_STATE_INITIALIZING,
)

# Startup grace period for sensor availability (in seconds)
SENSOR_STARTUP_GRACE_PERIOD = 30

# Repair issue IDs
REPAIR_ISSUE_SENSORS_UNAVAILABLE = "sensors_unavailable"

# Event constants
EVSE_LOAD_BALANCER_COORDINATOR_EVENT = f"{DOMAIN}_coordinator_event"
EVENT_ACTION_NEW_CHARGER_LIMITS = "new_charger_limits"
Expand Down
87 changes: 82 additions & 5 deletions custom_components/evse_load_balancer/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,27 @@
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)

from . import config_flow as cf
from . import options_flow as of
from .balancers.optimised_load_balancer import OptimisedLoadBalancer
from .chargers.charger import Charger
from .const import (
COORDINATOR_STATE_AWAITING_CHARGER,
COORDINATOR_STATE_INITIALIZING,
COORDINATOR_STATE_MONITORING_LOAD,
DOMAIN,
EVENT_ACTION_NEW_CHARGER_LIMITS,
EVENT_ATTR_ACTION,
EVENT_ATTR_NEW_LIMITS,
EVSE_LOAD_BALANCER_COORDINATOR_EVENT,
REPAIR_ISSUE_SENSORS_UNAVAILABLE,
SENSOR_STARTUP_GRACE_PERIOD,
)
from .meters.meter import Meter, Phase
from .power_allocator import PowerAllocator
Expand All @@ -45,6 +53,9 @@ class EVSELoadBalancerCoordinator:
# MODIFIED: Store as datetime object or None
_last_check_timestamp: datetime | None = None
_last_charger_update_time: int | None = None
_setup_timestamp: datetime | None = None
_unavailable_sensors: set[str] | None = None
_repair_issue_created: bool = False

def __init__(
self,
Expand All @@ -61,6 +72,7 @@ def __init__(

self._meter: Meter = meter
self._charger: Charger = charger
self._setup_timestamp = datetime.now().astimezone()

async def async_setup(self) -> None:
"""Set up the coordinator and its managed components."""
Expand Down Expand Up @@ -93,6 +105,9 @@ async def async_unload(self) -> None:
unsub_method()
self._unsub.clear()

# Dismiss any repair issues on unload
self._dismiss_sensor_repair_issue()

@cached_property
def _device(self) -> dr.DeviceEntry:
"""Get the device entry for the coordinator."""
Expand Down Expand Up @@ -155,16 +170,36 @@ def get_available_current_for_phase(self, phase: Phase) -> int | None:
def _get_available_currents(self) -> dict[Phase, int] | None:
"""Check all phases and return the available current for each."""
available_currents = {}
unavailable_phases = []

for phase_obj in self._available_phases:
current = self.get_available_current_for_phase(phase_obj)
if current is None:
_LOGGER.error(
"Available current for phase '%s' is None."
"Cannot proceed with balancing cycle.",
phase_obj.value,
unavailable_phases.append(phase_obj.value)
else:
available_currents[phase_obj] = current

if unavailable_phases:
unavailable_sensors = self._meter.get_unavailable_sensors()

if self._is_in_startup_grace_period():
_LOGGER.debug(
"Sensors unavailable during startup grace period (phases: %s). "
"Waiting for sensors to initialize...",
", ".join(unavailable_phases),
)
return None
available_currents[phase_obj] = current

_LOGGER.warning(
"Available current for phases %s is None. "
"Cannot proceed with balancing cycle. Unavailable sensors: %s",
", ".join(unavailable_phases),
", ".join(unavailable_sensors) if unavailable_sensors else "unknown",
)
self._handle_unavailable_sensors(unavailable_sensors)
return None

self._dismiss_sensor_repair_issue()
return available_currents

@cached_property
Expand All @@ -177,6 +212,8 @@ def _available_phases(self) -> list[Phase]:
@property
def get_load_balancing_state(self) -> str:
"""Get the current load balancing state."""
if self._is_in_startup_grace_period():
return COORDINATOR_STATE_INITIALIZING
if self._should_check_charger():
return COORDINATOR_STATE_MONITORING_LOAD
return COORDINATOR_STATE_AWAITING_CHARGER
Expand Down Expand Up @@ -323,3 +360,43 @@ def _emit_charger_event(self, action: str, new_limits: dict[Phase, int]) -> None
_LOGGER.info(
"Emitted charger event: action=%s, new_limits=%s", action, new_limits
)

def _is_in_startup_grace_period(self) -> bool:
"""Check if we're still in the startup grace period."""
if self._setup_timestamp is None:
return False
elapsed = (datetime.now().astimezone() - self._setup_timestamp).total_seconds()
return elapsed < SENSOR_STARTUP_GRACE_PERIOD

def _handle_unavailable_sensors(self, unavailable_sensors: list[str]) -> None:
"""Handle unavailable sensors by creating a repair issue."""
if self._repair_issue_created:
return

sensor_list = "\n".join(f"- `{sensor}`" for sensor in unavailable_sensors)
async_create_issue(
self.hass,
DOMAIN,
REPAIR_ISSUE_SENSORS_UNAVAILABLE,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="sensors_unavailable",
translation_placeholders={
"sensors": sensor_list,
"grace_period": str(SENSOR_STARTUP_GRACE_PERIOD),
},
)
self._repair_issue_created = True
_LOGGER.warning(
"Created repair issue for unavailable sensors: %s",
", ".join(unavailable_sensors),
)

def _dismiss_sensor_repair_issue(self) -> None:
"""Dismiss the sensor unavailability repair issue if it exists."""
if not self._repair_issue_created:
return

async_delete_issue(self.hass, DOMAIN, REPAIR_ISSUE_SENSORS_UNAVAILABLE)
self._repair_issue_created = False
_LOGGER.info("Dismissed sensor unavailability repair issue")
20 changes: 18 additions & 2 deletions custom_components/evse_load_balancer/ha_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(self, hass: HomeAssistant, device_entry: DeviceEntry) -> None:
self.hass = hass
self.device_entry = device_entry
self.entity_registry = er.async_get(self.hass)
self._unavailable_sensors: set[str] = set()

def refresh_entities(self) -> None:
"""Refresh local list of entity maps for the meter."""
Expand Down Expand Up @@ -88,22 +89,37 @@ def _get_entity_id_by_key(self, entity_key: str) -> float | None:
)
return entity.entity_id

def get_unavailable_sensors(self) -> list[str]:
"""Return a list of currently unavailable sensors."""
return list(self._unavailable_sensors)

def _get_entity_state(
self, entity_id: str, parser_fn: Callable | None = None
) -> Any | None:
"""Get the state of the entity for a given entity. Can be parsed."""
state = self.hass.states.get(entity_id)
if state is None:
_LOGGER.debug("State not found for entity %s", entity_id)
self._unavailable_sensors.add(entity_id)
return None

state_value = state.state

if state_value in ("unavailable", "unknown"):
self._unavailable_sensors.add(entity_id)
return None

try:
return parser_fn(state.state) if parser_fn else state.state
value = parser_fn(state_value) if parser_fn else state_value
except ValueError:
_LOGGER.warning(
"State for entity %s can't be parsed: %s", entity_id, state.state
"State for entity %s can't be parsed: %s", entity_id, state_value
)
self._unavailable_sensors.add(entity_id)
return None
else:
self._unavailable_sensors.discard(entity_id)
return value

def _get_entity_state_attrs(self, entity_id: str) -> dict | None:
"""Get the state attributes for a given entity."""
Expand Down
18 changes: 17 additions & 1 deletion custom_components/evse_load_balancer/meters/custom_meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the Custom Meter instance."""
Meter.__init__(self, hass, config_entry)
self._config_entry_data = config_entry.data
self._unavailable_sensors: set[str] = set()

def get_active_phase_current(self, phase: Phase) -> int | None:
"""Return available current on a given phase."""
Expand Down Expand Up @@ -77,19 +78,34 @@ def get_tracking_entities(self) -> list[str]:
sensors.extend(self._config_entry_data[phase_cf][cf_sensor])
return sensors

def get_unavailable_sensors(self) -> list[str]:
"""Return a list of currently unavailable sensors."""
return list(self._unavailable_sensors)

def _get_state(self, entity_id: str) -> float | None:
state = self.hass.states.get(entity_id)
if state is None:
_LOGGER.debug("State not found for entity %s", entity_id)
self._unavailable_sensors.add(entity_id)
return None

state_value = state.state

if state_value in ("unavailable", "unknown"):
self._unavailable_sensors.add(entity_id)
return None

try:
return float(state_value)
value = float(state_value)
except ValueError as ex:
_LOGGER.exception(
"Failed to parse state %s for entity %s: %s",
state_value,
entity_id,
ex, # noqa: TRY401
)
self._unavailable_sensors.add(entity_id)
return None
else:
self._unavailable_sensors.discard(entity_id)
return value
4 changes: 4 additions & 0 deletions custom_components/evse_load_balancer/meters/meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ def get_active_phase_power(self, phase: Phase) -> float | None:
@abstractmethod
def get_tracking_entities(self) -> list[str]:
"""Return a list of entity IDs that should be tracked for the meter."""

def get_unavailable_sensors(self) -> list[str]:
"""Return a list of currently unavailable sensors."""
return []
7 changes: 7 additions & 0 deletions custom_components/evse_load_balancer/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,20 @@
"already_configured": "This device or service is already configured."
}
},
"issues": {
"sensors_unavailable": {
"title": "Energy Sensors Unavailable",
"description": "The EVSE Load Balancer cannot operate because the following energy sensors are unavailable:\n\n{sensors}\n\nThis can happen if:\n- The sensor integration (e.g., MQTT, DSMR) hasn't started yet\n- The sensors are disabled or have been removed\n- There's a connection issue with your energy meter\n\nThe load balancer waited {grace_period} seconds after startup but the sensors are still unavailable. Please check your sensor configuration and ensure all required sensors are available.\n\nThis issue will automatically dismiss once all sensors become available."
}
},
"entity": {
"sensor": {
"evse_load_balancing_state": {
"name": "Load balancing state",
"state": {
"awaiting_charger": "Awaiting charger",
"monitoring_loads": "Monitoring loads",
"initializing": "Initializing",
"error": "Error"
}
},
Expand Down
Loading
Loading