diff --git a/custom_components/keymaster/coordinator.py b/custom_components/keymaster/coordinator.py index 7d4479b9..0062cce8 100644 --- a/custom_components/keymaster/coordinator.py +++ b/custom_components/keymaster/coordinator.py @@ -2382,10 +2382,16 @@ async def _connect_and_update_lock(self, kmlock: KeymasterLock) -> bool: kmlock.connected = await kmlock.provider.async_connect() if not kmlock.connected: - _LOGGER.error( - "[Coordinator] %s: Provider failed to connect", - kmlock.lock_name, - ) + if self.hass.is_running: + _LOGGER.error( + "[Coordinator] %s: Provider failed to connect", + kmlock.lock_name, + ) + else: + _LOGGER.debug( + "[Coordinator] %s: Provider not connected yet during startup", + kmlock.lock_name, + ) return False if kmlock.provider.lock_config_entry_id: @@ -2634,8 +2640,13 @@ async def _update_lock_data(self, keymaster_config_entry_id: str) -> None: failures, backoff_secs, ) - else: + elif self.hass.is_running: _LOGGER.error("[Coordinator] %s: Not Connected", kmlock.lock_name) + else: + _LOGGER.debug( + "[Coordinator] %s: Not connected yet during startup", + kmlock.lock_name, + ) return if not kmlock.provider: diff --git a/custom_components/keymaster/providers/schlage.py b/custom_components/keymaster/providers/schlage.py index 48643be0..0e8af89b 100644 --- a/custom_components/keymaster/providers/schlage.py +++ b/custom_components/keymaster/providers/schlage.py @@ -19,6 +19,7 @@ from typing import Any, cast from custom_components.keymaster.const import CONF_SLOTS, CONF_START +from homeassistant.config_entries import ConfigEntryState from homeassistant.exceptions import HomeAssistantError from ._base import BaseLockProvider, CodeSlot @@ -107,13 +108,19 @@ async def async_connect(self) -> bool: ) return False - try: - coordinator = schlage_entry.runtime_data - except (AttributeError, TypeError) as e: - _LOGGER.error( - "[SchlageProvider] Can't access Schlage coordinator: %s: %s", - e.__class__.__qualname__, - e, + if getattr(schlage_entry, "state", None) != ConfigEntryState.LOADED: + _LOGGER.debug( + "[SchlageProvider] Schlage config entry %s is not loaded yet (state: %s)", + self.lock_config_entry_id, + getattr(schlage_entry, "state", None), + ) + return False + + coordinator = getattr(schlage_entry, "runtime_data", None) + if coordinator is None: + _LOGGER.debug( + "[SchlageProvider] Schlage coordinator runtime_data not yet available: %s", + self.lock_config_entry_id, ) return False @@ -176,21 +183,19 @@ async def async_is_connected(self) -> bool: return False schlage_entry = self.hass.config_entries.async_get_entry(lock_entry.config_entry_id) - if not schlage_entry: + if not schlage_entry or getattr(schlage_entry, "state", None) != ConfigEntryState.LOADED: self._connected = False return False - try: - coordinator = schlage_entry.runtime_data - connected = self._schlage_device_id in coordinator.data.locks - except ( - AttributeError, - TypeError, - ): - connected = False - - self._connected = connected - return connected + coordinator = getattr(schlage_entry, "runtime_data", None) + if coordinator is None or not hasattr(coordinator, "data"): + self._connected = False + return False + + self._connected = bool( + coordinator.data.locks and self._schlage_device_id in coordinator.data.locks + ) + return self._connected async def _async_get_codes(self) -> dict[str, dict[str, str]]: """Call ``schlage.get_codes`` and return the response dict. diff --git a/custom_components/keymaster/providers/zwave_js.py b/custom_components/keymaster/providers/zwave_js.py index 3888d22f..dc3aa65c 100644 --- a/custom_components/keymaster/providers/zwave_js.py +++ b/custom_components/keymaster/providers/zwave_js.py @@ -32,6 +32,7 @@ from homeassistant.components.lock import LockState from homeassistant.components.zwave_js import ZWAVE_JS_NOTIFICATION_EVENT from homeassistant.components.zwave_js.const import ATTR_PARAMETERS, DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Event, EventStateChangedData from homeassistant.helpers.device_registry import DeviceEntry @@ -400,21 +401,35 @@ async def async_connect(self) -> bool: ) return False - try: - zwave_entry = self.hass.config_entries.async_get_entry(self.lock_config_entry_id) - if not zwave_entry: - _LOGGER.error( - "[ZWaveJSProvider] Can't find Z-Wave JS config entry: %s", - self.lock_config_entry_id, - ) - return False - - self._client = zwave_entry.runtime_data.client - except (KeyError, TypeError, AttributeError) as e: + zwave_entry = self.hass.config_entries.async_get_entry(self.lock_config_entry_id) + if not zwave_entry: _LOGGER.error( - "[ZWaveJSProvider] Can't access Z-Wave JS client: %s: %s", - e.__class__.__qualname__, - e, + "[ZWaveJSProvider] Can't find Z-Wave JS config entry: %s", + self.lock_config_entry_id, + ) + return False + + if getattr(zwave_entry, "state", None) != ConfigEntryState.LOADED: + _LOGGER.debug( + "[ZWaveJSProvider] Z-Wave JS config entry %s is not loaded yet (state: %s)", + self.lock_config_entry_id, + getattr(zwave_entry, "state", None), + ) + return False + + runtime_data = getattr(zwave_entry, "runtime_data", None) + if runtime_data is None: + _LOGGER.debug( + "[ZWaveJSProvider] Z-Wave JS config entry %s runtime_data not yet available", + self.lock_config_entry_id, + ) + return False + + self._client = getattr(runtime_data, "client", None) + if self._client is None: + _LOGGER.debug( + "[ZWaveJSProvider] Z-Wave JS client not yet available on runtime_data: %s", + self.lock_config_entry_id, ) return False diff --git a/tests/providers/test_schlage.py b/tests/providers/test_schlage.py index 94274555..f20c6fb9 100644 --- a/tests/providers/test_schlage.py +++ b/tests/providers/test_schlage.py @@ -12,6 +12,7 @@ _make_tagged_name, _parse_tag, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -151,6 +152,7 @@ async def test_connect_success(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED coordinator = MagicMock() coordinator.data.locks = {"schlage_device_123": MagicMock()} schlage_entry.runtime_data = coordinator @@ -187,13 +189,26 @@ async def test_connect_schlage_entry_not_found(self, schlage_provider): schlage_provider.hass.config_entries.async_get_entry.return_value = None assert await schlage_provider.async_connect() is False + async def test_connect_schlage_entry_not_loaded(self, schlage_provider): + """Test connection fails when schlage config entry is not loaded.""" + lock_entry = MagicMock() + lock_entry.config_entry_id = "schlage_entry" + schlage_provider.entity_registry.async_get.return_value = lock_entry + + schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.SETUP_IN_PROGRESS + schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry + assert await schlage_provider.async_connect() is False + async def test_connect_coordinator_not_available(self, schlage_provider): """Test connection fails when coordinator unavailable.""" lock_entry = MagicMock() lock_entry.config_entry_id = "schlage_entry" schlage_provider.entity_registry.async_get.return_value = lock_entry - schlage_entry = MagicMock(spec_set=[]) + schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED + schlage_entry.runtime_data = None schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry assert await schlage_provider.async_connect() is False @@ -205,6 +220,7 @@ async def test_connect_no_device_entry(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED schlage_entry.runtime_data = MagicMock() schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry schlage_provider.device_registry.async_get.return_value = None @@ -218,6 +234,7 @@ async def test_connect_no_schlage_identifier(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED schlage_entry.runtime_data = MagicMock() schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry @@ -234,6 +251,7 @@ async def test_connect_lock_not_in_coordinator(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED coordinator = MagicMock() coordinator.data.locks = {} # Empty schlage_entry.runtime_data = coordinator @@ -252,6 +270,7 @@ async def test_connect_coordinator_data_missing(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED coordinator = type("Coordinator", (), {})() schlage_entry.runtime_data = coordinator schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry @@ -284,6 +303,7 @@ async def test_connected_lock_in_coordinator(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED coordinator = MagicMock() coordinator.data.locks = {"dev123": MagicMock()} schlage_entry.runtime_data = coordinator @@ -300,6 +320,7 @@ async def test_not_connected_lock_removed(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED coordinator = MagicMock() coordinator.data.locks = {} schlage_entry.runtime_data = coordinator @@ -307,6 +328,21 @@ async def test_not_connected_lock_removed(self, schlage_provider): assert await schlage_provider.async_is_connected() is False + async def test_not_connected_schlage_entry_not_loaded(self, schlage_provider): + """Test returns False when Schlage config entry is not loaded.""" + schlage_provider._schlage_device_id = "dev123" + + lock_entry = MagicMock() + lock_entry.config_entry_id = "schlage_entry" + schlage_provider.entity_registry.async_get.return_value = lock_entry + + schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.SETUP_IN_PROGRESS + schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry + + assert await schlage_provider.async_is_connected() is False + assert schlage_provider._connected is False + async def test_not_connected_lock_entry_missing(self, schlage_provider): """Test returns False when entity registry entry is missing.""" schlage_provider._schlage_device_id = "dev123" @@ -347,6 +383,7 @@ async def test_not_connected_coordinator_error(self, schlage_provider): schlage_provider.entity_registry.async_get.return_value = lock_entry schlage_entry = MagicMock() + schlage_entry.state = ConfigEntryState.LOADED schlage_entry.runtime_data = None # causes AttributeError on .data.locks schlage_provider.hass.config_entries.async_get_entry.return_value = schlage_entry diff --git a/tests/providers/test_zwave_js.py b/tests/providers/test_zwave_js.py index d040bc13..90042430 100644 --- a/tests/providers/test_zwave_js.py +++ b/tests/providers/test_zwave_js.py @@ -22,6 +22,7 @@ ) from custom_components.keymaster.providers.zwave_js import ZWaveJSLockProvider from homeassistant.components.lock.const import LockState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from tests.common import async_capture_events @@ -88,6 +89,7 @@ def setup_successful_connect( zwave_provider.entity_registry.async_get.return_value = mock_entity mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED mock_zwave_entry.runtime_data = MagicMock() mock_zwave_entry.runtime_data.client = mock_zwave_client zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry @@ -216,6 +218,49 @@ async def test_connect_zwave_entry_not_found(self, zwave_provider): assert result is False + async def test_connect_zwave_entry_not_loaded(self, zwave_provider): + """Test connect fails when Z-Wave config entry is not loaded.""" + mock_entity = MagicMock() + mock_entity.config_entry_id = "zwave_entry_id" + zwave_provider.entity_registry.async_get.return_value = mock_entity + + mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.SETUP_IN_PROGRESS + zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry + + result = await zwave_provider.async_connect() + + assert result is False + + async def test_connect_runtime_data_missing(self, zwave_provider): + """Test connect fails when Z-Wave runtime_data is missing.""" + mock_entity = MagicMock() + mock_entity.config_entry_id = "zwave_entry_id" + zwave_provider.entity_registry.async_get.return_value = mock_entity + + mock_zwave_entry = MagicMock(spec=["state"]) + mock_zwave_entry.state = ConfigEntryState.LOADED + zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry + + result = await zwave_provider.async_connect() + + assert result is False + + async def test_connect_client_missing_on_runtime_data(self, zwave_provider): + """Test connect fails when Z-Wave client attribute is None on runtime_data.""" + mock_entity = MagicMock() + mock_entity.config_entry_id = "zwave_entry_id" + zwave_provider.entity_registry.async_get.return_value = mock_entity + + mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED + mock_zwave_entry.runtime_data = MagicMock(spec=[]) + zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry + + result = await zwave_provider.async_connect() + + assert result is False + async def test_connect_client_not_connected(self, zwave_provider): """Test connect fails when Z-Wave client not connected.""" mock_entity = MagicMock() @@ -224,6 +269,7 @@ async def test_connect_client_not_connected(self, zwave_provider): zwave_provider.entity_registry.async_get.return_value = mock_entity mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED mock_zwave_entry.runtime_data = MagicMock() mock_zwave_entry.runtime_data.client = MagicMock() mock_zwave_entry.runtime_data.client.connected = False @@ -241,6 +287,7 @@ async def test_connect_device_not_found(self, zwave_provider, mock_zwave_client) zwave_provider.entity_registry.async_get.return_value = mock_entity mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED mock_zwave_entry.runtime_data = MagicMock() mock_zwave_entry.runtime_data.client = mock_zwave_client zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry @@ -258,6 +305,7 @@ async def test_connect_node_id_not_found(self, zwave_provider, mock_zwave_client zwave_provider.entity_registry.async_get.return_value = mock_entity mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED mock_zwave_entry.runtime_data = MagicMock() mock_zwave_entry.runtime_data.client = mock_zwave_client zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry @@ -1447,6 +1495,7 @@ async def test_connect_warns_when_dead_but_proceeds( zwave_provider.entity_registry.async_get.return_value = mock_entity mock_zwave_entry = MagicMock() + mock_zwave_entry.state = ConfigEntryState.LOADED mock_zwave_entry.runtime_data = MagicMock() mock_zwave_entry.runtime_data.client = mock_zwave_client zwave_provider.hass.config_entries.async_get_entry.return_value = mock_zwave_entry diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 784d0206..bf5c7435 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -4263,3 +4263,113 @@ def _listen_bus(event: Event) -> None: await hass.async_block_till_done() assert mock_notify.call_count == 0 assert len(bus_events) == 1 + + +class TestConnectAndUpdateLockStartup: + """Test startup vs runtime connection behavior in KeymasterCoordinator.""" + + async def test_connect_provider_fails_during_startup(self, mock_hass): + """Test _connect_and_update_lock logs debug (not error) when provider fails during startup.""" + mock_hass.is_running = False + with patch.object(KeymasterCoordinator, "__init__", return_value=None): + coordinator = KeymasterCoordinator(mock_hass) + coordinator.hass = mock_hass + kmlock = KeymasterLock( + lock_name="Front Door", + lock_entity_id="lock.front_door", + keymaster_config_entry_id="entry_1", + ) + kmlock.provider = AsyncMock() + kmlock.provider.async_connect = AsyncMock(return_value=False) + + with ( + patch("custom_components.keymaster.coordinator._LOGGER.error") as mock_error, + patch("custom_components.keymaster.coordinator._LOGGER.debug") as mock_debug, + ): + result = await coordinator._connect_and_update_lock(kmlock) + + assert result is False + assert kmlock.connected is False + mock_error.assert_not_called() + mock_debug.assert_any_call( + "[Coordinator] %s: Provider not connected yet during startup", + "Front Door", + ) + + async def test_connect_provider_fails_when_running(self, mock_hass): + """Test _connect_and_update_lock logs error when provider fails after startup.""" + mock_hass.is_running = True + with patch.object(KeymasterCoordinator, "__init__", return_value=None): + coordinator = KeymasterCoordinator(mock_hass) + coordinator.hass = mock_hass + kmlock = KeymasterLock( + lock_name="Front Door", + lock_entity_id="lock.front_door", + keymaster_config_entry_id="entry_1", + ) + kmlock.provider = AsyncMock() + kmlock.provider.async_connect = AsyncMock(return_value=False) + + with patch("custom_components.keymaster.coordinator._LOGGER.error") as mock_error: + result = await coordinator._connect_and_update_lock(kmlock) + + assert result is False + assert kmlock.connected is False + mock_error.assert_called_once_with( + "[Coordinator] %s: Provider failed to connect", + "Front Door", + ) + + async def test_update_lock_data_not_connected_during_startup(self, mock_hass): + """Test _update_lock_data logs debug (not error) when not connected during startup.""" + mock_hass.is_running = False + with patch.object(KeymasterCoordinator, "__init__", return_value=None): + coordinator = KeymasterCoordinator(mock_hass) + coordinator.hass = mock_hass + coordinator._next_retry_time = {} + coordinator._consecutive_failures = {} + kmlock = KeymasterLock( + lock_name="Front Door", + lock_entity_id="lock.front_door", + keymaster_config_entry_id="entry_1", + connected=False, + ) + coordinator.get_lock_by_config_entry_id = AsyncMock(return_value=kmlock) + coordinator._connect_and_update_lock = AsyncMock(return_value=False) + + with ( + patch("custom_components.keymaster.coordinator._LOGGER.error") as mock_error, + patch("custom_components.keymaster.coordinator._LOGGER.debug") as mock_debug, + ): + await coordinator._update_lock_data("entry_1") + + mock_error.assert_not_called() + mock_debug.assert_any_call( + "[Coordinator] %s: Not connected yet during startup", + "Front Door", + ) + + async def test_update_lock_data_not_connected_when_running(self, mock_hass): + """Test _update_lock_data logs error when not connected after startup.""" + mock_hass.is_running = True + with patch.object(KeymasterCoordinator, "__init__", return_value=None): + coordinator = KeymasterCoordinator(mock_hass) + coordinator.hass = mock_hass + coordinator._next_retry_time = {} + coordinator._consecutive_failures = {} + kmlock = KeymasterLock( + lock_name="Front Door", + lock_entity_id="lock.front_door", + keymaster_config_entry_id="entry_1", + connected=False, + ) + coordinator.get_lock_by_config_entry_id = AsyncMock(return_value=kmlock) + coordinator._connect_and_update_lock = AsyncMock(return_value=False) + + with patch("custom_components.keymaster.coordinator._LOGGER.error") as mock_error: + await coordinator._update_lock_data("entry_1") + + mock_error.assert_any_call( + "[Coordinator] %s: Not Connected", + "Front Door", + )