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
2 changes: 2 additions & 0 deletions custom_components/ttlock/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ def get_device_logger(lock_id: int) -> logging.Logger:
CONF_END_TIME = "end_time"
CONF_WEEK_DAYS = "days"
CONF_SECONDS = "seconds"
CONF_AUTO_LOCK_SECONDS = "auto_lock_seconds"

SVC_CONFIG_AUTOLOCK = "configure_autolock"
SVC_SET_CONFIG_OVERRIDE = "set_config_override"
SVC_CONFIG_PASSAGE_MODE = "configure_passage_mode"
SVC_CREATE_PASSCODE = "create_passcode"
SVC_MODIFY_PASSCODE = "modify_passcode"
Expand Down
13 changes: 13 additions & 0 deletions custom_components/ttlock/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ async def _async_update_data(self) -> LockState:
) from err

new_data.auto_lock_seconds = details.autoLockTime
if new_data.auto_lock_seconds is None:
stored = await self._store.async_get(self.lock_id)
new_data.auto_lock_seconds = stored.get("auto_lock_override_seconds")
new_data.lock_sound = bool(details.lockSound)

new_data.passage_mode_config = await self.api.get_lock_passage_mode_config(
Expand Down Expand Up @@ -527,6 +530,16 @@ async def set_auto_lock(self, on: bool) -> None:
self.data.auto_lock_seconds = seconds
self.async_update_listeners()

async def set_auto_lock_override(self, seconds: int | None) -> None:
"""Persist a locally-assumed auto-lock delay, for locks TTLock's API never reports one for.

Never sent to the lock or TTLock's API - see _async_update_data, which
only falls back to this when the API's own autoLockTime is absent.
Pass None to clear a previously-set override.
"""
await self._store.async_update(self.lock_id, auto_lock_override_seconds=seconds)
await self.async_refresh()

async def set_lock_sound(self, on: bool) -> None:
"""Turn on/off lock sound."""
value = 1 if on else 2
Expand Down
25 changes: 25 additions & 0 deletions custom_components/ttlock/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from .const import (
CONF_ALL_DAY,
CONF_AUTO_LOCK_SECONDS,
CONF_AUTO_UNLOCK,
CONF_END_TIME,
CONF_SECONDS,
Expand All @@ -37,6 +38,7 @@
SVC_MODIFY_PASSCODE,
SVC_RENAME_CARD,
SVC_RENAME_FINGERPRINT,
SVC_SET_CONFIG_OVERRIDE,
SVC_UPDATE_STATE,
)
from .coordinator import LockUpdateCoordinator, coordinator_for
Expand Down Expand Up @@ -212,6 +214,20 @@ def _validate_start_and_end_time_together(config):
),
)

self.hass.services.register(
DOMAIN,
SVC_SET_CONFIG_OVERRIDE,
self.handle_set_config_override,
schema=vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_AUTO_LOCK_SECONDS): vol.Any(
None, vol.All(vol.Coerce(int), vol.Range(min=0))
),
}
),
)

self.hass.services.register(
DOMAIN,
SVC_LIST_CARDS,
Expand Down Expand Up @@ -499,6 +515,15 @@ async def handle_update_state(self, call: ServiceCall):
coordinator.data.locked = None
await coordinator.async_refresh()

async def handle_set_config_override(self, call: ServiceCall):
"""Persist local fallback values for config the TTLock API doesn't always report."""
if CONF_AUTO_LOCK_SECONDS not in call.data:
return

seconds = call.data[CONF_AUTO_LOCK_SECONDS]
for coordinator in self._get_coordinators(call).values():
await coordinator.set_auto_lock_override(seconds)

async def handle_list_cards(self, call: ServiceCall) -> ServiceResponse:
"""List all IC cards for the selected locks."""
cards = {}
Expand Down
18 changes: 18 additions & 0 deletions custom_components/ttlock/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,24 @@ update_state:
integration: ttlock
domain: lock

set_config_override:
name: Set Config Override
description: Manually set locally-stored fallback values for lock configuration TTLock's API doesn't always report (e.g. some locks never report an auto-lock delay). These are only used to fill gaps in what the cloud API reports - the physical lock is never reconfigured. Leave a field blank to leave it untouched; pass it as an explicit YAML null to clear a previously-set override.
target:
entity:
integration: ttlock
domain: lock
fields:
auto_lock_seconds:
name: Auto Lock Seconds (override)
description: Assumed number of seconds until the lock relocks itself, used only when TTLock's API doesn't report an auto-lock delay for this lock. Pass an explicit YAML null to clear a previously-set override.
required: false
selector:
number:
min: 0
max: 3600
unit_of_measurement: secs

list_cards:
name: List IC cards
description: Lists all IC cards enrolled on the selected lock, including their names, numbers, and validity periods.
Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from .const import (
BASIC_LOCK_DETAILS,
LOCK_DETAILS_NO_AUTOLOCK,
LOCK_DETAILS_WITH_SENSOR,
LOCK_STATE_LOCKED,
LOCK_STATE_UNLOCKED,
Expand Down Expand Up @@ -206,6 +207,13 @@ def create_mock_data(scenario: str = "default") -> MockApiData:
state=LockState.model_validate(LOCK_STATE_UNLOCKED),
passage_mode=None,
),
"no_autolock": MockApiData(
lock=Lock.model_validate(LOCK_DETAILS_NO_AUTOLOCK),
state=LockState.model_validate(LOCK_STATE_UNLOCKED),
passage_mode=PassageModeConfig.model_validate(
PASSAGE_MODE_6_TO_6_7_DAYS
),
),
}
return scenarios[scenario]

Expand Down
4 changes: 4 additions & 0 deletions tests/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
"featureValue": "F44354CF5F3",
}

LOCK_DETAILS_NO_AUTOLOCK = {
k: v for k, v in BASIC_LOCK_DETAILS.items() if k != "autoLockTime"
}

SENSOR_DETAILS = {
"doorSensorId": 2323,
"name": "Door sensor for front door",
Expand Down
89 changes: 89 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,95 @@ async def test_confirmed_absent_recheck_failure_leaves_state_unchanged(
assert entry["door_sensor_confirmed_absent"] is True
assert entry["door_sensor_count_failed_req"] == 3

class TestAutoLockOverride:
"""See coordinator.py's auto_lock_seconds merge in _async_update_data -
some locks never report autoLockTime at all (issue #67), so a
locally-stored override fills the gap, but never outranks a real
value TTLock's API does report.
"""

LOCK_ID = 7252408

def _make_coordinator(
self, hass, api, store: LockStateStore
) -> LockUpdateCoordinator:
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
summary = LockSummary(
lockId=self.LOCK_ID,
lockAlias="Test Lock",
lockMac="00:00:00:00:00:00",
hasGateway=1,
)
return LockUpdateCoordinator(
hass, config_entry, api, summary, LockTrafficCapture(), store
)

async def test_stays_none_without_an_override(
self, hass, api, mock_api_responses
):
mock_api_responses("no_autolock")
coordinator = self._make_coordinator(hass, api, LockStateStore(hass))

await coordinator.async_refresh()

assert coordinator.data.auto_lock_seconds is None

async def test_override_fills_in_when_api_reports_nothing(
self, hass, api, mock_api_responses
):
mock_api_responses("no_autolock")
store = LockStateStore(hass)
await store.async_update(self.LOCK_ID, auto_lock_override_seconds=5)
coordinator = self._make_coordinator(hass, api, store)

await coordinator.async_refresh()

assert coordinator.data.auto_lock_seconds == 5

async def test_real_api_value_always_wins_over_the_override(
self, hass, api, mock_api_responses
):
mock_api_responses("default")
store = LockStateStore(hass)
await store.async_update(self.LOCK_ID, auto_lock_override_seconds=5)
coordinator = self._make_coordinator(hass, api, store)

await coordinator.async_refresh()

assert (
coordinator.data.auto_lock_seconds == BASIC_LOCK_DETAILS["autoLockTime"]
)

async def test_set_auto_lock_override_persists_and_applies_immediately(
self, hass, api, mock_api_responses
):
mock_api_responses("no_autolock")
store = LockStateStore(hass)
coordinator = self._make_coordinator(hass, api, store)
await coordinator.async_refresh()
assert coordinator.data.auto_lock_seconds is None

await coordinator.set_auto_lock_override(5)

assert coordinator.data.auto_lock_seconds == 5
entry = await store.async_get(self.LOCK_ID)
assert entry["auto_lock_override_seconds"] == 5

async def test_set_auto_lock_override_none_clears_it(
self, hass, api, mock_api_responses
):
mock_api_responses("no_autolock")
store = LockStateStore(hass)
await store.async_update(self.LOCK_ID, auto_lock_override_seconds=5)
coordinator = self._make_coordinator(hass, api, store)
await coordinator.async_refresh()
assert coordinator.data.auto_lock_seconds == 5

await coordinator.set_auto_lock_override(None)

assert coordinator.data.auto_lock_seconds is None

class TestProcessWebhookData:
async def test_lock_works(
self, coordinator: LockUpdateCoordinator, mock_api_responses
Expand Down
62 changes: 62 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
SVC_MODIFY_PASSCODE,
SVC_RENAME_CARD,
SVC_RENAME_FINGERPRINT,
SVC_SET_CONFIG_OVERRIDE,
SVC_UPDATE_STATE,
)
from custom_components.ttlock.models import (
Expand Down Expand Up @@ -812,6 +813,67 @@ async def test_update_state(
assert coordinator.data.locked is None


class Test_set_config_override:
"""See coordinator.py's TestAutoLockOverride - this is the service-layer
wiring for issue #67's fix: some locks never report an auto-lock delay
from TTLock's API at all, so this service lets a user assert one
locally instead of the integration guessing it.
"""

async def test_sets_override_when_api_reports_nothing(
self, hass: HomeAssistant, component_setup, mock_api_responses
):
mock_api_responses("no_autolock")
coordinator = await component_setup()
entity_id = coordinator.entities[0].entity_id
assert coordinator.data.auto_lock_seconds is None

await hass.services.async_call(
DOMAIN,
SVC_SET_CONFIG_OVERRIDE,
{ATTR_ENTITY_ID: entity_id, "auto_lock_seconds": 5},
blocking=True,
)

assert coordinator.data.auto_lock_seconds == 5

async def test_explicit_none_clears_a_previously_set_override(
self, hass: HomeAssistant, component_setup, mock_api_responses
):
mock_api_responses("no_autolock")
coordinator = await component_setup()
entity_id = coordinator.entities[0].entity_id

await coordinator.set_auto_lock_override(5)
assert coordinator.data.auto_lock_seconds == 5

await hass.services.async_call(
DOMAIN,
SVC_SET_CONFIG_OVERRIDE,
{ATTR_ENTITY_ID: entity_id, "auto_lock_seconds": None},
blocking=True,
)

assert coordinator.data.auto_lock_seconds is None

async def test_omitted_field_leaves_override_untouched(
self, hass: HomeAssistant, component_setup, mock_api_responses
):
mock_api_responses("no_autolock")
coordinator = await component_setup()
entity_id = coordinator.entities[0].entity_id

with patch.object(coordinator, "set_auto_lock_override") as mock_set_override:
await hass.services.async_call(
DOMAIN,
SVC_SET_CONFIG_OVERRIDE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)

mock_set_override.assert_not_called()


class Test_list_cards:
async def test_list_cards(
self, hass: HomeAssistant, component_setup, mock_api_responses
Expand Down
Loading