Skip to content
Merged
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
2 changes: 2 additions & 0 deletions custom_components/keymaster/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 25 additions & 24 deletions custom_components/keymaster/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -98,48 +100,45 @@ 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
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

Expand All @@ -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(
Expand Down
28 changes: 26 additions & 2 deletions custom_components/keymaster/lovelace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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",
Expand Down Expand Up @@ -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
)
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions custom_components/keymaster/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 88 additions & 1 deletion custom_components/keymaster/sensor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
32 changes: 32 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."""
Expand All @@ -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."""

Expand Down
Loading
Loading