From fe47550428b9f04992a1af23357d44f24ec2a715 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Fri, 6 Mar 2026 06:45:17 -0800 Subject: [PATCH] feat: Add auto-lock timer sensor with dashboard badge (#509) Add a timestamp sensor that tracks the auto-lock countdown timer, with timer-emulating attributes (duration, remaining, finishes_at, is_running). The sensor shows a timestamp when the timer is running and 'unknown' when idle. Dashboard integration: - Add conditional badge to generated Lovelace dashboard - Badge only visible when timer is running (not unknown/unavailable) Timer responsiveness: - Push coordinator data updates immediately when timer starts/cancels - Previously entities only saw timer changes on ~30s refresh cycle Timer state consistency: - Refactor KeymasterTimer to use shared _cleanup_expired() method - All property accessors now consistently clear duration on expiry - Snapshot timer values atomically in sensor to avoid race conditions Fix float-to-int conversion: - Convert autolock minute values from float to int in number entity - HA NumberEntity passes float values that caused ValueError in _seconds_to_hhmmss format string when using :02d specifier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Andrew Grimberg --- custom_components/keymaster/coordinator.py | 2 + custom_components/keymaster/helpers.py | 49 ++-- custom_components/keymaster/lovelace.py | 28 ++- custom_components/keymaster/number.py | 3 + custom_components/keymaster/sensor.py | 89 ++++++- tests/test_coordinator.py | 32 +++ tests/test_helpers.py | 15 ++ tests/test_init.py | 8 +- tests/test_lovelace.py | 42 ++++ tests/test_number.py | 49 ++++ tests/test_sensor.py | 271 ++++++++++++++++++++- 11 files changed, 553 insertions(+), 35 deletions(-) diff --git a/custom_components/keymaster/coordinator.py b/custom_components/keymaster/coordinator.py index 7d5a03fc..a5e8ab7a 100644 --- a/custom_components/keymaster/coordinator.py +++ b/custom_components/keymaster/coordinator.py @@ -621,6 +621,7 @@ async def _lock_unlocked( if kmlock.autolock_enabled and kmlock.autolock_timer: await kmlock.autolock_timer.start() + self.async_set_updated_data(dict(self.kmlocks)) if kmlock.lock_notifications: message = event_label @@ -740,6 +741,7 @@ async def _lock_locked( ) if kmlock.autolock_timer: await kmlock.autolock_timer.cancel() + self.async_set_updated_data(dict(self.kmlocks)) if kmlock.lock_notifications: await send_manual_notification( diff --git a/custom_components/keymaster/helpers.py b/custom_components/keymaster/helpers.py index a805e037..e27fcfd0 100644 --- a/custom_components/keymaster/helpers.py +++ b/custom_components/keymaster/helpers.py @@ -55,6 +55,7 @@ def __init__(self) -> None: self._kmlock: KeymasterLock | None = None self._call_action: Callable | None = None self._end_time: dt | None = None + self._duration: int | None = None async def setup( self, hass: HomeAssistant, kmlock: KeymasterLock, call_action: Callable @@ -80,6 +81,7 @@ async def start(self) -> bool: delay: int = (self._kmlock.autolock_min_day or DEFAULT_AUTOLOCK_MIN_DAY) * 60 else: delay = (self._kmlock.autolock_min_night or DEFAULT_AUTOLOCK_MIN_NIGHT) * 60 + self._duration = int(delay) self._end_time = dt.now().astimezone() + timedelta(seconds=delay) _LOGGER.debug( "[KeymasterTimer] Starting auto-lock timer for %s seconds. Ending %s", @@ -98,35 +100,37 @@ async def cancel(self, timer_elapsed: dt | None = None) -> None: _LOGGER.debug("[KeymasterTimer] Timer elapsed") else: _LOGGER.debug("[KeymasterTimer] Cancelling auto-lock timer") + self._cleanup_expired() + + def _cleanup_expired(self) -> None: + """Clean up all timer state (unsub events, end_time, duration).""" if isinstance(self._unsub_events, list): for unsub in self._unsub_events: unsub() self._unsub_events = [] self._end_time = None + self._duration = None + + def _check_expired(self) -> bool: + """Check if the timer has expired and clean up if so. Returns True if expired.""" + if isinstance(self._end_time, dt) and self._end_time <= dt.now().astimezone(): + self._cleanup_expired() + return True + return False @property def is_running(self) -> bool: """Return if the timer is running.""" if not self._end_time: return False - if isinstance(self._end_time, dt) and self._end_time <= dt.now().astimezone(): - if isinstance(self._unsub_events, list): - for unsub in self._unsub_events: - unsub() - self._unsub_events = [] - self._end_time = None + if self._check_expired(): return False return True @property def is_setup(self) -> bool: """Return if the timer has been initially setup.""" - if isinstance(self._end_time, dt) and self._end_time <= dt.now().astimezone(): - if isinstance(self._unsub_events, list): - for unsub in self._unsub_events: - unsub() - self._unsub_events = [] - self._end_time = None + self._check_expired() return bool(self.hass and self._kmlock and self._call_action) @property @@ -134,12 +138,7 @@ def end_time(self) -> dt | None: """Returns when the timer will end.""" if not self._end_time: return None - if isinstance(self._end_time, dt) and self._end_time <= dt.now().astimezone(): - if isinstance(self._unsub_events, list): - for unsub in self._unsub_events: - unsub() - self._unsub_events = [] - self._end_time = None + if self._check_expired(): return None return self._end_time @@ -148,15 +147,17 @@ def remaining_seconds(self) -> int | None: """Return the seconds until the timer ends.""" if not self._end_time: return None - if isinstance(self._end_time, dt) and self._end_time <= dt.now().astimezone(): - if isinstance(self._unsub_events, list): - for unsub in self._unsub_events: - unsub() - self._unsub_events = [] - self._end_time = None + if self._check_expired(): return None return round((self._end_time - dt.now().astimezone()).total_seconds()) + @property + def duration(self) -> int | None: + """Return the total timer duration in seconds.""" + if self._duration is None or not self.is_running: + return None + return self._duration + @callback def async_has_supported_provider( diff --git a/custom_components/keymaster/lovelace.py b/custom_components/keymaster/lovelace.py index fc19926e..5a2a1022 100644 --- a/custom_components/keymaster/lovelace.py +++ b/custom_components/keymaster/lovelace.py @@ -382,6 +382,7 @@ def _generate_badge_ll_config( visibility: bool = False, tap_action: str | None = "none", show_name: bool = False, + visibility_conditions: list[MutableMapping[str, Any]] | None = None, ) -> MutableMapping[str, Any]: """Generate Lovelace config for a badge.""" data: MutableMapping[str, Any] = { @@ -395,7 +396,9 @@ def _generate_badge_ll_config( data["name"] = name if entity: data["entity"] = entity - if visibility: + if visibility_conditions: + data["visibility"] = visibility_conditions + elif visibility: data["visibility"] = [ { "condition": "state", @@ -528,7 +531,7 @@ def _generate_lock_badges( """Generate the Lovelace badges configuration for a keymaster lock.""" door = door_sensor is not None battery = battery_entity is not None - return [ + badges = [ _generate_badge_ll_config( entity, name, visibility=visibility, show_name=show_name, tap_action=tap_action ) @@ -548,6 +551,27 @@ def _generate_lock_badges( ) if condition ] + badges.append( + _generate_badge_ll_config( + entity="sensor.autolock_timer", + name="Auto Lock Timer", + show_name=True, + tap_action="none", + visibility_conditions=[ + { + "condition": "state", + "entity": "sensor.autolock_timer", + "state_not": "unknown", + }, + { + "condition": "state", + "entity": "sensor.autolock_timer", + "state_not": "unavailable", + }, + ], + ) + ) + return badges def _generate_dow_entities( diff --git a/custom_components/keymaster/number.py b/custom_components/keymaster/number.py index 3587778a..e32115c1 100644 --- a/custom_components/keymaster/number.py +++ b/custom_components/keymaster/number.py @@ -193,6 +193,9 @@ async def async_set_native_value(self, value: float) -> None: # Convert to int for accesslimit_count (NumberEntity returns float) if self._is_accesslimit_count: value = int(value) + # Convert to int for autolock minutes (NumberEntity returns float) + if self._property in ("number.autolock_min_day", "number.autolock_min_night"): + value = int(value) if self._set_property_value(value): self._attr_native_value = value self.async_write_ha_state() # Immediate UI update diff --git a/custom_components/keymaster/sensor.py b/custom_components/keymaster/sensor.py index 561d294e..df5562b1 100644 --- a/custom_components/keymaster/sensor.py +++ b/custom_components/keymaster/sensor.py @@ -1,10 +1,11 @@ """Sensor for keymaster.""" from dataclasses import dataclass +from datetime import datetime as dt import logging from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,6 +57,20 @@ async def async_setup_entry( ) ) + entities.append( + KeymasterAutoLockSensor( + entity_description=KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=config_entry, + coordinator=coordinator, + ), + ) + ) + entities.extend( [ KeymasterSensor( @@ -117,3 +132,75 @@ def _handle_coordinator_update(self) -> None: self._attr_available = True self._attr_native_value = self._get_property_value() self.async_write_ha_state() + + +class KeymasterAutoLockSensor(KeymasterEntity, SensorEntity): + """Sensor for the auto-lock timer countdown.""" + + entity_description: KeymasterSensorEntityDescription + + def __init__( + self, + entity_description: KeymasterSensorEntityDescription, + ) -> None: + """Initialize auto-lock timer sensor.""" + super().__init__( + entity_description=entity_description, + ) + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_native_value: dt | None = None + + @staticmethod + def _seconds_to_hhmmss(seconds: float | None) -> str | None: + """Format seconds as HH:MM:SS string.""" + if seconds is None or seconds < 0: + return None + seconds = int(seconds) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" + + @callback + def _handle_coordinator_update(self) -> None: + if not self._kmlock or not self._kmlock.connected: + self._attr_available = False + self.async_write_ha_state() + return + + if not self._kmlock.autolock_enabled: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + timer = self._kmlock.autolock_timer + + # Snapshot all timer values at once to avoid race if timer expires mid-read + if timer: + is_running = timer.is_running + end_time = timer.end_time + duration = timer.duration + remaining = timer.remaining_seconds + else: + is_running = False + end_time = None + duration = None + remaining = None + + if is_running and end_time: + self._attr_native_value = end_time + self._attr_extra_state_attributes = { + "duration": self._seconds_to_hhmmss(duration), + "remaining": self._seconds_to_hhmmss(remaining), + "finishes_at": end_time.isoformat(), + "is_running": True, + } + else: + self._attr_native_value = None + self._attr_extra_state_attributes = { + "duration": None, + "remaining": None, + "finishes_at": None, + "is_running": False, + } + self.async_write_ha_state() diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index edfdc3a6..00949d44 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -103,6 +103,7 @@ def mock_coordinator(mock_hass): coordinator.kmlocks = {} # Use setattr to safely add the mock method setattr(coordinator, "delete_lock_by_config_entry_id", AsyncMock()) + coordinator.async_set_updated_data = Mock() return coordinator @@ -987,6 +988,7 @@ async def test_lock_locked_cancels_autolock_timer(self, mock_coordinator, mock_k await mock_coordinator._lock_locked(mock_kmlock, source="manual") mock_kmlock.autolock_timer.cancel.assert_called_once() + mock_coordinator.async_set_updated_data.assert_called_once() async def test_lock_locked_with_notifications(self, mock_coordinator, mock_kmlock): """Test _lock_locked sends notification when enabled.""" @@ -1008,6 +1010,36 @@ async def test_lock_locked_with_notifications(self, mock_coordinator, mock_kmloc assert call_kwargs["title"] == "Front Door" assert call_kwargs["message"] == "Locked by User 1" + async def test_lock_unlocked_starts_autolock_timer(self, mock_coordinator, mock_kmlock): + """Test _lock_unlocked starts autolock timer and pushes data update.""" + mock_kmlock.lock_state = LockState.LOCKED + mock_kmlock.autolock_enabled = True + mock_kmlock.autolock_timer = AsyncMock() + mock_kmlock.autolock_timer.start = AsyncMock() + mock_kmlock.lock_notifications = False + mock_kmlock.code_slots = {} + mock_coordinator._throttle = Mock() + mock_coordinator._throttle.is_allowed = Mock(return_value=True) + + await mock_coordinator._lock_unlocked(mock_kmlock, source="manual") + + mock_kmlock.autolock_timer.start.assert_called_once() + mock_coordinator.async_set_updated_data.assert_called_once() + + async def test_lock_unlocked_no_autolock_no_data_update(self, mock_coordinator, mock_kmlock): + """Test _lock_unlocked does not push data update when autolock is disabled.""" + mock_kmlock.lock_state = LockState.LOCKED + mock_kmlock.autolock_enabled = False + mock_kmlock.autolock_timer = None + mock_kmlock.lock_notifications = False + mock_kmlock.code_slots = {} + mock_coordinator._throttle = Mock() + mock_coordinator._throttle.is_allowed = Mock(return_value=True) + + await mock_coordinator._lock_unlocked(mock_kmlock, source="manual") + + mock_coordinator.async_set_updated_data.assert_not_called() + async def test_door_opened_basic_state_change(self, mock_coordinator, mock_kmlock): """Test _door_opened updates door state to open.""" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 4cc6435b..c325efb5 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -167,6 +167,7 @@ async def test_keymaster_timer_init(): assert timer._kmlock is None assert timer._call_action is None assert timer._end_time is None + assert timer._duration is None assert not timer.is_setup assert not timer.is_running @@ -231,6 +232,7 @@ async def mock_callback(*args): assert result is True assert timer._end_time is not None + assert timer._duration == 5 * 60 assert len(timer._unsub_events) == 2 # Timer callback + cancel callback assert timer.is_running assert timer._end_time is not None # Should still be set after checking is_running @@ -261,6 +263,7 @@ async def mock_callback(*args): assert result is True assert timer._end_time is not None + assert timer._duration == 10 * 60 assert len(timer._unsub_events) == 2 # Timer callback + cancel callback assert timer.is_running assert timer._end_time is not None # Should still be set after checking is_running @@ -322,6 +325,7 @@ async def mock_callback(*args): await timer.start() assert timer._end_time is not None + assert timer._duration is not None assert timer.is_running # Cancel timer @@ -329,6 +333,7 @@ async def mock_callback(*args): assert not timer.is_running assert timer._end_time is None + assert timer._duration is None assert timer._unsub_events == [] @@ -355,6 +360,7 @@ async def mock_callback(*args): assert not timer.is_running assert timer.end_time is None assert timer.remaining_seconds is None + assert timer.duration is None # Start timer with patch("custom_components.keymaster.helpers.sun.is_up", return_value=True): @@ -365,6 +371,7 @@ async def mock_callback(*args): assert timer.end_time is not None assert timer.remaining_seconds is not None assert timer.remaining_seconds > 0 # Time remaining (positive because end_time is in future) + assert timer.duration == 5 * 60 # 5 minutes in seconds async def test_delete_code_slot_entities_removes_all(hass): @@ -462,11 +469,13 @@ async def mock_callback(*args): # Manually set end_time to the past to simulate expired timer timer._end_time = dt.now().astimezone() - timedelta(seconds=10) + timer._duration = 300 timer._unsub_events = [MagicMock()] # Add a mock unsub function # Checking is_running should clean up the expired timer assert timer.is_running is False assert timer._end_time is None + assert timer._duration is None assert timer._unsub_events == [] @@ -487,12 +496,14 @@ async def mock_callback(*args): # Manually set end_time to the past timer._end_time = dt.now().astimezone() - timedelta(seconds=10) + timer._duration = 300 timer._unsub_events = [MagicMock()] # Checking is_setup should clean up the expired timer result = timer.is_setup assert result is True # Still setup, just expired assert timer._end_time is None + assert timer._duration is None assert timer._unsub_events == [] @@ -513,12 +524,14 @@ async def mock_callback(*args): # Manually set end_time to the past timer._end_time = dt.now().astimezone() - timedelta(seconds=10) + timer._duration = 300 timer._unsub_events = [MagicMock()] # Getting end_time should clean up and return None result = timer.end_time assert result is None assert timer._end_time is None + assert timer._duration is None assert timer._unsub_events == [] @@ -539,12 +552,14 @@ async def mock_callback(*args): # Manually set end_time to the past timer._end_time = dt.now().astimezone() - timedelta(seconds=10) + timer._duration = 300 timer._unsub_events = [MagicMock()] # Getting remaining_seconds should clean up and return None result = timer.remaining_seconds assert result is None assert timer._end_time is None + assert timer._duration is None assert timer._unsub_events == [] diff --git a/tests/test_init.py b/tests/test_init.py index bf1049d5..16ee8b43 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -33,7 +33,7 @@ async def test_setup_entry( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -58,7 +58,7 @@ async def test_setup_entry_core_state( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -76,13 +76,13 @@ async def test_unload_entry( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 assert len(hass.states.async_entity_ids(DOMAIN)) == 0 assert await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/test_lovelace.py b/tests/test_lovelace.py index e6fbf51d..b1266ccb 100644 --- a/tests/test_lovelace.py +++ b/tests/test_lovelace.py @@ -941,3 +941,45 @@ def test_delete_lovelace_handles_permission_error(hass: HomeAssistant, tmp_path: # Should log the error assert "Unable to delete lovelace YAML" in caplog.text + + +async def test_generate_view_config_badges_autolock_timer(hass: HomeAssistant): + """Test that auto-lock timer badge is generated with correct visibility conditions.""" + mock_registry = _create_mock_registry() + + with patch( + "custom_components.keymaster.lovelace.er.async_get", + return_value=mock_registry, + ): + view = generate_view_config( + hass=hass, + kmlock_name="frontdoor", + keymaster_config_entry_id="test_entry_id", + code_slot_start=1, + code_slots=1, + lock_entity="lock.frontdoor", + advanced_date_range=False, + advanced_day_of_week=False, + door_sensor="binary_sensor.frontdoor", + ) + + badges = view["badges"] + + # Find the autolock timer badge + timer_badges = [b for b in badges if "autolock_timer" in str(b.get("entity", ""))] + assert len(timer_badges) == 1 + timer_badge = timer_badges[0] + + # Should have name shown + assert timer_badge.get("show_name") is True + assert timer_badge.get("name") == "Auto Lock Timer" + + # Should have visibility conditions: not unknown and not unavailable + visibility = timer_badge.get("visibility", []) + assert len(visibility) == 2 + assert any( + v.get("condition") == "state" and v.get("state_not") == "unknown" for v in visibility + ) + assert any( + v.get("condition") == "state" and v.get("state_not") == "unavailable" for v in visibility + ) diff --git a/tests/test_number.py b/tests/test_number.py index 0d01bab2..38d3962e 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -559,3 +559,52 @@ async def test_number_entity_converts_float_to_int_for_accesslimit_count( # Also verify the code slot was updated with an int assert kmlock.code_slots[1].accesslimit_count == 5 assert isinstance(kmlock.code_slots[1].accesslimit_count, int) + + +@pytest.mark.parametrize( + ("key", "attr_name"), + [ + ("number.autolock_min_day", "autolock_min_day"), + ("number.autolock_min_night", "autolock_min_night"), + ], +) +async def test_number_entity_autolock_float_to_int( + hass: HomeAssistant, number_config_entry, coordinator, key, attr_name +): + """Test autolock minute values are converted from float to int.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=number_config_entry.entry_id, + ) + kmlock.connected = True + kmlock.autolock_enabled = True + coordinator.kmlocks[number_config_entry.entry_id] = kmlock + + entity_description = KeymasterNumberEntityDescription( + key=key, + name="Auto Lock", + icon="mdi:timer-lock", + mode=NumberMode.BOX, + native_min_value=1, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_registry_enabled_default=True, + hass=hass, + config_entry=number_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterNumber(entity_description=entity_description) + + with ( + patch.object(coordinator, "async_refresh", new=AsyncMock()), + patch.object(entity, "async_write_ha_state"), + ): + await entity.async_set_native_value(5.0) + + assert entity._attr_native_value == 5 + assert isinstance(entity._attr_native_value, int) + assert getattr(kmlock, attr_name) == 5 + assert isinstance(getattr(kmlock, attr_name), int) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 1d6cd606..ee790d47 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,5 +1,6 @@ """Tests for keymaster Sensor platform.""" +from datetime import UTC, datetime as dt, timedelta from unittest.mock import AsyncMock, Mock, patch import pytest @@ -19,10 +20,12 @@ from custom_components.keymaster.coordinator import KeymasterCoordinator from custom_components.keymaster.lock import KeymasterCodeSlot, KeymasterLock from custom_components.keymaster.sensor import ( + KeymasterAutoLockSensor, KeymasterSensor, KeymasterSensorEntityDescription, async_setup_entry, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.core import HomeAssistant CONFIG_DATA_SENSOR = { @@ -251,11 +254,271 @@ def mock_add_entities(new_entities, update_before_add=False): # Call setup await async_setup_entry(hass, config_entry, mock_add_entities) - # Should have created 4 entities: lock_name, parent_name, and 2 code slot sync sensors - assert len(added_entities) == 4 + # Should have created 5 entities: lock_name, parent_name, autolock_timer, and 2 code slot sync sensors + assert len(added_entities) == 5 assert added_entities[0].entity_description.key == "sensor.lock_name" assert added_entities[1].entity_description.key == "sensor.parent_name" assert added_entities[1].entity_description.name == "Parent Lock" + assert added_entities[2].entity_description.key == "sensor.autolock_timer" + assert added_entities[2].entity_description.name == "Auto Lock Timer" # Code slot sensors for slots 1 and 2 - assert added_entities[2].entity_description.key == "sensor.code_slots:1.synced" - assert added_entities[3].entity_description.key == "sensor.code_slots:2.synced" + assert added_entities[3].entity_description.key == "sensor.code_slots:1.synced" + assert added_entities[4].entity_description.key == "sensor.code_slots:2.synced" + + +async def test_autolock_sensor_initialization( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor initialization.""" + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + assert entity._attr_native_value is None + assert entity._attr_device_class == SensorDeviceClass.TIMESTAMP + assert entity.entity_description.key == "sensor.autolock_timer" + assert entity.entity_description.name == "Auto Lock Timer" + + +async def test_autolock_sensor_unavailable_when_not_connected( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor becomes unavailable when lock is not connected.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=sensor_config_entry.entry_id, + ) + kmlock.connected = False + kmlock.autolock_enabled = True + coordinator.kmlocks[sensor_config_entry.entry_id] = kmlock + + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert not entity._attr_available + + +async def test_autolock_sensor_unavailable_when_autolock_disabled( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor becomes unavailable when autolock is not enabled.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=sensor_config_entry.entry_id, + ) + kmlock.connected = True + kmlock.autolock_enabled = False + coordinator.kmlocks[sensor_config_entry.entry_id] = kmlock + + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert not entity._attr_available + + +async def test_autolock_sensor_idle_when_timer_not_running( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor shows idle state when timer is not running.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=sensor_config_entry.entry_id, + ) + kmlock.connected = True + kmlock.autolock_enabled = True + kmlock.autolock_timer = Mock() + kmlock.autolock_timer.is_running = False + coordinator.kmlocks[sensor_config_entry.entry_id] = kmlock + + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert entity._attr_available + assert entity._attr_native_value is None + assert entity._attr_extra_state_attributes["duration"] is None + assert entity._attr_extra_state_attributes["remaining"] is None + assert entity._attr_extra_state_attributes["finishes_at"] is None + assert entity._attr_extra_state_attributes["is_running"] is False + + +async def test_autolock_sensor_active_when_timer_running( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor shows end time when timer is running.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=sensor_config_entry.entry_id, + ) + kmlock.connected = True + kmlock.autolock_enabled = True + + end_time = dt.now(tz=UTC) + timedelta(minutes=5) + mock_timer = Mock() + mock_timer.is_running = True + mock_timer.end_time = end_time + mock_timer.remaining_seconds = 300 + mock_timer.duration = 600 + kmlock.autolock_timer = mock_timer + coordinator.kmlocks[sensor_config_entry.entry_id] = kmlock + + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert entity._attr_available + assert entity._attr_native_value == end_time + assert entity._attr_extra_state_attributes["duration"] == "0:10:00" + assert entity._attr_extra_state_attributes["remaining"] == "0:05:00" + assert entity._attr_extra_state_attributes["finishes_at"] == end_time.isoformat() + assert entity._attr_extra_state_attributes["is_running"] is True + + +async def test_autolock_sensor_no_timer_object( + hass: HomeAssistant, sensor_config_entry, coordinator +): + """Test auto-lock timer sensor handles None autolock_timer gracefully.""" + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=sensor_config_entry.entry_id, + ) + kmlock.connected = True + kmlock.autolock_enabled = True + kmlock.autolock_timer = None + coordinator.kmlocks[sensor_config_entry.entry_id] = kmlock + + entity_description = KeymasterSensorEntityDescription( + key="sensor.autolock_timer", + name="Auto Lock Timer", + icon="mdi:lock-clock", + entity_registry_enabled_default=True, + hass=hass, + config_entry=sensor_config_entry, + coordinator=coordinator, + ) + + entity = KeymasterAutoLockSensor(entity_description=entity_description) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert entity._attr_available + assert entity._attr_native_value is None + assert entity._attr_extra_state_attributes["duration"] is None + assert entity._attr_extra_state_attributes["remaining"] is None + assert entity._attr_extra_state_attributes["finishes_at"] is None + assert entity._attr_extra_state_attributes["is_running"] is False + + +async def test_autolock_sensor_created_in_setup(hass: HomeAssistant): + """Test that async_setup_entry creates the auto-lock timer sensor.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="frontdoor", + data=CONFIG_DATA_SENSOR, + version=3, + ) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + setattr(coordinator, "get_lock_by_config_entry_id", AsyncMock(return_value=None)) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][COORDINATOR] = coordinator + + added_entities = [] + + def mock_add_entities(new_entities, update_before_add=False): + del update_before_add + added_entities.extend(new_entities) + + await async_setup_entry(hass, config_entry, mock_add_entities) + + # Should have: lock_name, autolock_timer, and 2 code slot sync sensors = 4 + assert len(added_entities) == 4 + autolock_entities = [ + e for e in added_entities if e.entity_description.key == "sensor.autolock_timer" + ] + assert len(autolock_entities) == 1 + assert isinstance(autolock_entities[0], KeymasterAutoLockSensor) + + +@pytest.mark.parametrize( + ("seconds", "expected"), + [ + (0, "0:00:00"), + (59, "0:00:59"), + (60, "0:01:00"), + (300, "0:05:00"), + (3600, "1:00:00"), + (3661, "1:01:01"), + (7200, "2:00:00"), + (None, None), + (-1, None), + (60.0, "0:01:00"), + (900.0, "0:15:00"), + (3661.5, "1:01:01"), + ], +) +def test_autolock_sensor_seconds_to_hhmmss(seconds, expected): + """Test _seconds_to_hhmmss formats correctly.""" + assert KeymasterAutoLockSensor._seconds_to_hhmmss(seconds) == expected