diff --git a/custom_components/evse_load_balancer/const.py b/custom_components/evse_load_balancer/const.py index 9c81d13..613940e 100644 --- a/custom_components/evse_load_balancer/const.py +++ b/custom_components/evse_load_balancer/const.py @@ -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" diff --git a/custom_components/evse_load_balancer/coordinator.py b/custom_components/evse_load_balancer/coordinator.py index 168485b..5bdb43e 100644 --- a/custom_components/evse_load_balancer/coordinator.py +++ b/custom_components/evse_load_balancer/coordinator.py @@ -11,6 +11,11 @@ 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 @@ -18,12 +23,15 @@ 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 @@ -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, @@ -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.""" @@ -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.""" @@ -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 @@ -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 @@ -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") diff --git a/custom_components/evse_load_balancer/ha_device.py b/custom_components/evse_load_balancer/ha_device.py index 9fed1f9..2e40d06 100644 --- a/custom_components/evse_load_balancer/ha_device.py +++ b/custom_components/evse_load_balancer/ha_device.py @@ -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.""" @@ -88,6 +89,10 @@ 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: @@ -95,15 +100,26 @@ def _get_entity_state( 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.""" diff --git a/custom_components/evse_load_balancer/meters/custom_meter.py b/custom_components/evse_load_balancer/meters/custom_meter.py index 46163b7..b9db357 100644 --- a/custom_components/evse_load_balancer/meters/custom_meter.py +++ b/custom_components/evse_load_balancer/meters/custom_meter.py @@ -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.""" @@ -77,14 +78,25 @@ 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", @@ -92,4 +104,8 @@ def _get_state(self, entity_id: str) -> float | None: entity_id, ex, # noqa: TRY401 ) + self._unavailable_sensors.add(entity_id) return None + else: + self._unavailable_sensors.discard(entity_id) + return value diff --git a/custom_components/evse_load_balancer/meters/meter.py b/custom_components/evse_load_balancer/meters/meter.py index e389965..cf8fb12 100644 --- a/custom_components/evse_load_balancer/meters/meter.py +++ b/custom_components/evse_load_balancer/meters/meter.py @@ -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 [] diff --git a/custom_components/evse_load_balancer/translations/en.json b/custom_components/evse_load_balancer/translations/en.json index 14c052d..773ebe7 100644 --- a/custom_components/evse_load_balancer/translations/en.json +++ b/custom_components/evse_load_balancer/translations/en.json @@ -49,6 +49,12 @@ "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": { @@ -56,6 +62,7 @@ "state": { "awaiting_charger": "Awaiting charger", "monitoring_loads": "Monitoring loads", + "initializing": "Initializing", "error": "Error" } }, diff --git a/tests/test_sensor_startup_grace_period.py b/tests/test_sensor_startup_grace_period.py new file mode 100644 index 0000000..c37f165 --- /dev/null +++ b/tests/test_sensor_startup_grace_period.py @@ -0,0 +1,386 @@ +"""Tests for sensor startup grace period and unavailability handling.""" + +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from freezegun import freeze_time +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry + +from custom_components.evse_load_balancer.const import ( + COORDINATOR_STATE_INITIALIZING, + COORDINATOR_STATE_MONITORING_LOAD, + DOMAIN, + REPAIR_ISSUE_SENSORS_UNAVAILABLE, + SENSOR_STARTUP_GRACE_PERIOD, +) +from custom_components.evse_load_balancer.coordinator import ( + EVSELoadBalancerCoordinator, +) +from custom_components.evse_load_balancer.meters.custom_meter import CustomMeter +from custom_components.evse_load_balancer.meters.meter import Phase + +from .helpers.mock_charger import MockCharger + + +@pytest.fixture +def mock_custom_meter_unavailable(hass: HomeAssistant): + """Create a mock custom meter with unavailable sensors.""" + meter = MagicMock(spec=CustomMeter) + meter.hass = hass + meter.get_active_phase_current = MagicMock(return_value=None) + meter.get_unavailable_sensors = MagicMock( + return_value=["sensor.consumption_l1", "sensor.voltage_l1"] + ) + meter.get_tracking_entities = MagicMock(return_value=[]) + return meter + + +@pytest.fixture +def mock_custom_meter_available(hass: HomeAssistant): + """Create a mock custom meter with available sensors.""" + meter = MagicMock(spec=CustomMeter) + meter.hass = hass + meter.get_active_phase_current = MagicMock(return_value=10) + meter.get_unavailable_sensors = MagicMock(return_value=[]) + meter.get_tracking_entities = MagicMock(return_value=[]) + return meter + + +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = { + "fuse_size": 25, + "phase_count": 3, + } + entry.options = {} + entry.add_update_listener = MagicMock(return_value=MagicMock()) + return entry + + +@pytest.mark.asyncio +async def test_coordinator_starts_in_grace_period( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that coordinator starts in grace period state.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + # Should be in grace period immediately after creation + assert coordinator._is_in_startup_grace_period() is True + assert coordinator.get_load_balancing_state == COORDINATOR_STATE_INITIALIZING + + +@pytest.mark.asyncio +async def test_grace_period_expires_after_timeout( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that grace period expires after SENSOR_STARTUP_GRACE_PERIOD seconds.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + + with freeze_time("2025-01-01 12:00:00") as frozen_time: + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + # Initially in grace period + assert coordinator._is_in_startup_grace_period() is True + + # Move time forward but still within grace period + frozen_time.tick(delta=timedelta(seconds=SENSOR_STARTUP_GRACE_PERIOD - 1)) + assert coordinator._is_in_startup_grace_period() is True + + # Move past grace period + frozen_time.tick(delta=timedelta(seconds=2)) + assert coordinator._is_in_startup_grace_period() is False + + +@pytest.mark.asyncio +async def test_no_repair_issue_during_grace_period( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that no repair issue is created during grace period.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + charger.async_unload = AsyncMock() + charger.can_charge = MagicMock(return_value=True) + charger.get_current_limit = MagicMock(return_value={Phase.L1: 16}) + + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + await coordinator.async_setup() + + # Trigger update cycle during grace period + coordinator._execute_update_cycle(datetime.now().astimezone()) + + # Should not create repair issue + issue_registry = async_get_issue_registry(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_SENSORS_UNAVAILABLE) + assert issue is None + + # Cleanup + await coordinator.async_unload() + + +@pytest.mark.asyncio +async def test_repair_issue_created_after_grace_period( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that repair issue is created after grace period expires.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + charger.async_unload = AsyncMock() + charger.can_charge = MagicMock(return_value=True) + charger.get_current_limit = MagicMock(return_value={Phase.L1: 16}) + + with freeze_time("2025-01-01 12:00:00") as frozen_time: + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + await coordinator.async_setup() + + # Move past grace period + frozen_time.tick(delta=timedelta(seconds=SENSOR_STARTUP_GRACE_PERIOD + 1)) + + # Trigger update cycle after grace period + coordinator._execute_update_cycle(datetime.now().astimezone()) + + # Should create repair issue + issue_registry = async_get_issue_registry(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_SENSORS_UNAVAILABLE) + assert issue is not None + assert issue.translation_key == "sensors_unavailable" + + # Cleanup + await coordinator.async_unload() + + +@pytest.mark.asyncio +async def test_custom_meter_tracks_unavailable_sensors(hass: HomeAssistant): + """Test that CustomMeter properly tracks unavailable sensors.""" + mock_entry = MagicMock() + mock_entry.data = { + "l1": { + "consumption": "sensor.consumption_l1", + "production": "sensor.production_l1", + "voltage": "sensor.voltage_l1", + } + } + + # Mock unavailable state + hass.states.async_set("sensor.consumption_l1", "unavailable") + + meter = CustomMeter(hass, mock_entry) + + # Should return None and track the sensor + result = meter._get_state("sensor.consumption_l1") + assert result is None + assert "sensor.consumption_l1" in meter.get_unavailable_sensors() + + +@pytest.mark.asyncio +async def test_custom_meter_removes_sensors_when_available(hass: HomeAssistant): + """Test that CustomMeter removes sensors from unavailable list when they recover.""" + mock_entry = MagicMock() + mock_entry.data = { + "l1": { + "consumption": "sensor.consumption_l1", + "production": "sensor.production_l1", + "voltage": "sensor.voltage_l1", + } + } + + # Start with unavailable state + hass.states.async_set("sensor.consumption_l1", "unavailable") + + meter = CustomMeter(hass, mock_entry) + + # Get state while unavailable + meter._get_state("sensor.consumption_l1") + assert "sensor.consumption_l1" in meter.get_unavailable_sensors() + + # Update to available state + hass.states.async_set("sensor.consumption_l1", "100.5") + + # Get state again + result = meter._get_state("sensor.consumption_l1") + assert result == 100.5 + assert "sensor.consumption_l1" not in meter.get_unavailable_sensors() + + +@pytest.mark.asyncio +async def test_custom_meter_handles_unknown_state(hass: HomeAssistant): + """Test that CustomMeter handles 'unknown' state like 'unavailable'.""" + mock_entry = MagicMock() + mock_entry.data = {} + + hass.states.async_set("sensor.test", "unknown") + + meter = CustomMeter(hass, mock_entry) + + result = meter._get_state("sensor.test") + assert result is None + assert "sensor.test" in meter.get_unavailable_sensors() + + +@pytest.mark.asyncio +async def test_custom_meter_handles_parse_errors(hass: HomeAssistant): + """Test that CustomMeter tracks sensors that fail to parse.""" + mock_entry = MagicMock() + mock_entry.data = {} + + hass.states.async_set("sensor.test", "not_a_number") + + meter = CustomMeter(hass, mock_entry) + + result = meter._get_state("sensor.test") + assert result is None + assert "sensor.test" in meter.get_unavailable_sensors() + + +@pytest.mark.asyncio +async def test_coordinator_state_changes_after_grace_period( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that coordinator state changes from initializing after grace period.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + charger.async_unload = AsyncMock() + charger.can_charge = MagicMock(return_value=False) + + with freeze_time("2025-01-01 12:00:00") as frozen_time: + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + await coordinator.async_setup() + + # Should be initializing during grace period + assert coordinator.get_load_balancing_state == COORDINATOR_STATE_INITIALIZING + + # Move past grace period + frozen_time.tick(delta=timedelta(seconds=SENSOR_STARTUP_GRACE_PERIOD + 1)) + + # Should no longer be initializing (even with unavailable sensors) + # State should be based on charger state + assert coordinator.get_load_balancing_state != COORDINATOR_STATE_INITIALIZING + + # Cleanup + await coordinator.async_unload() + + +@pytest.mark.asyncio +async def test_repair_issue_dismissed_on_unload( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that repair issue is dismissed when coordinator is unloaded.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + charger.async_unload = AsyncMock() + charger.can_charge = MagicMock(return_value=True) + charger.get_current_limit = MagicMock(return_value={Phase.L1: 16}) + + with freeze_time("2025-01-01 12:00:00") as frozen_time: + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + await coordinator.async_setup() + + # Create repair issue + frozen_time.tick(delta=timedelta(seconds=SENSOR_STARTUP_GRACE_PERIOD + 1)) + coordinator._execute_update_cycle(datetime.now().astimezone()) + + issue_registry = async_get_issue_registry(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_SENSORS_UNAVAILABLE) + assert issue is not None + + # Unload coordinator + await coordinator.async_unload() + + # Issue should be dismissed + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_SENSORS_UNAVAILABLE) + assert issue is None + + +@pytest.mark.asyncio +async def test_repair_issue_only_created_once( + hass: HomeAssistant, + mock_config_entry, + mock_custom_meter_unavailable, +): + """Test that repair issue is only created once even with multiple update cycles.""" + charger = MockCharger(hass, "test_charger_1", 1, 32) + charger.async_setup = AsyncMock() + charger.async_unload = AsyncMock() + charger.can_charge = MagicMock(return_value=True) + charger.get_current_limit = MagicMock(return_value={Phase.L1: 16}) + + with freeze_time("2025-01-01 12:00:00") as frozen_time: + coordinator = EVSELoadBalancerCoordinator( + hass=hass, + config_entry=mock_config_entry, + meter=mock_custom_meter_unavailable, + charger=charger, + ) + + await coordinator.async_setup() + + # Move past grace period + frozen_time.tick(delta=timedelta(seconds=SENSOR_STARTUP_GRACE_PERIOD + 1)) + + # Trigger multiple update cycles + with patch( + "custom_components.evse_load_balancer.coordinator.async_create_issue" + ) as mock_create_issue: + coordinator._execute_update_cycle(datetime.now().astimezone()) + coordinator._execute_update_cycle(datetime.now().astimezone()) + coordinator._execute_update_cycle(datetime.now().astimezone()) + + # Should only be called once + assert mock_create_issue.call_count == 1 + + # Cleanup + await coordinator.async_unload()