From 97fcca726e9e174afddfd86bc924487508401e4a Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Sat, 25 Jul 2026 20:45:59 +1200 Subject: [PATCH 1/2] Add set_config_override service for locks TTLock never reports autoLockTime for Some locks (missing featureValue bit 4) never return autoLockTime from TTLock's API, so the coordinator has no way to guess when they've auto-relocked and the lock entity sits stuck at "unlocked" (issue #67). Rather than have the integration infer a relock delay from a missing field, this lets a user assert one explicitly via a new local-only override, persisted in the existing LockStateStore and consulted only when the API itself reports nothing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LkqnVxCQzq4E21ncFrGA2g --- custom_components/ttlock/const.py | 2 + custom_components/ttlock/coordinator.py | 13 ++++ custom_components/ttlock/services.py | 25 +++++++ custom_components/ttlock/services.yaml | 18 +++++ tests/conftest.py | 8 +++ tests/const.py | 4 ++ tests/test_coordinator.py | 89 +++++++++++++++++++++++++ tests/test_services.py | 64 ++++++++++++++++++ 8 files changed, 223 insertions(+) diff --git a/custom_components/ttlock/const.py b/custom_components/ttlock/const.py index 932f289..f145b28 100644 --- a/custom_components/ttlock/const.py +++ b/custom_components/ttlock/const.py @@ -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" diff --git a/custom_components/ttlock/coordinator.py b/custom_components/ttlock/coordinator.py index 5efafa8..8755018 100644 --- a/custom_components/ttlock/coordinator.py +++ b/custom_components/ttlock/coordinator.py @@ -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( @@ -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 async_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 diff --git a/custom_components/ttlock/services.py b/custom_components/ttlock/services.py index b4d646d..27cba67 100644 --- a/custom_components/ttlock/services.py +++ b/custom_components/ttlock/services.py @@ -17,6 +17,7 @@ from .const import ( CONF_ALL_DAY, + CONF_AUTO_LOCK_SECONDS, CONF_AUTO_UNLOCK, CONF_END_TIME, CONF_SECONDS, @@ -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 @@ -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, @@ -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.async_set_auto_lock_override(seconds) + async def handle_list_cards(self, call: ServiceCall) -> ServiceResponse: """List all IC cards for the selected locks.""" cards = {} diff --git a/custom_components/ttlock/services.yaml b/custom_components/ttlock/services.yaml index 2ff5fa4..5f779f6 100644 --- a/custom_components/ttlock/services.yaml +++ b/custom_components/ttlock/services.yaml @@ -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. diff --git a/tests/conftest.py b/tests/conftest.py index d5ce6b0..24a5ddb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,7 @@ from .const import ( BASIC_LOCK_DETAILS, + LOCK_DETAILS_NO_AUTOLOCK, LOCK_DETAILS_WITH_SENSOR, LOCK_STATE_LOCKED, LOCK_STATE_UNLOCKED, @@ -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] diff --git a/tests/const.py b/tests/const.py index f5d42b0..353d579 100644 --- a/tests/const.py +++ b/tests/const.py @@ -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", diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index db6da74..ed83d90 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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_async_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.async_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_async_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.async_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 diff --git a/tests/test_services.py b/tests/test_services.py index 7ca79dd..92460e6 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -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 ( @@ -812,6 +813,69 @@ 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.async_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, "async_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 From 884c2aa7a782ad83724ad29ebb6cd229220e6a2d Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Sat, 25 Jul 2026 20:49:01 +1200 Subject: [PATCH 2/2] Rename set_auto_lock_override to match coordinator naming convention Code review flagged the async_ prefix as inconsistent with this coordinator's other public coroutines (lock, unlock, set_auto_lock, set_lock_sound), none of which use it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LkqnVxCQzq4E21ncFrGA2g --- custom_components/ttlock/coordinator.py | 2 +- custom_components/ttlock/services.py | 2 +- tests/test_coordinator.py | 8 ++++---- tests/test_services.py | 6 ++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/custom_components/ttlock/coordinator.py b/custom_components/ttlock/coordinator.py index 8755018..c1f794e 100644 --- a/custom_components/ttlock/coordinator.py +++ b/custom_components/ttlock/coordinator.py @@ -530,7 +530,7 @@ async def set_auto_lock(self, on: bool) -> None: self.data.auto_lock_seconds = seconds self.async_update_listeners() - async def async_set_auto_lock_override(self, seconds: int | None) -> None: + 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 diff --git a/custom_components/ttlock/services.py b/custom_components/ttlock/services.py index 27cba67..ec9b15f 100644 --- a/custom_components/ttlock/services.py +++ b/custom_components/ttlock/services.py @@ -522,7 +522,7 @@ async def handle_set_config_override(self, call: ServiceCall): seconds = call.data[CONF_AUTO_LOCK_SECONDS] for coordinator in self._get_coordinators(call).values(): - await coordinator.async_set_auto_lock_override(seconds) + await coordinator.set_auto_lock_override(seconds) async def handle_list_cards(self, call: ServiceCall) -> ServiceResponse: """List all IC cards for the selected locks.""" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index ed83d90..aa118b8 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -605,7 +605,7 @@ async def test_real_api_value_always_wins_over_the_override( coordinator.data.auto_lock_seconds == BASIC_LOCK_DETAILS["autoLockTime"] ) - async def test_async_set_auto_lock_override_persists_and_applies_immediately( + async def test_set_auto_lock_override_persists_and_applies_immediately( self, hass, api, mock_api_responses ): mock_api_responses("no_autolock") @@ -614,13 +614,13 @@ async def test_async_set_auto_lock_override_persists_and_applies_immediately( await coordinator.async_refresh() assert coordinator.data.auto_lock_seconds is None - await coordinator.async_set_auto_lock_override(5) + 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_async_set_auto_lock_override_none_clears_it( + async def test_set_auto_lock_override_none_clears_it( self, hass, api, mock_api_responses ): mock_api_responses("no_autolock") @@ -630,7 +630,7 @@ async def test_async_set_auto_lock_override_none_clears_it( await coordinator.async_refresh() assert coordinator.data.auto_lock_seconds == 5 - await coordinator.async_set_auto_lock_override(None) + await coordinator.set_auto_lock_override(None) assert coordinator.data.auto_lock_seconds is None diff --git a/tests/test_services.py b/tests/test_services.py index 92460e6..8bb7a38 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -844,7 +844,7 @@ async def test_explicit_none_clears_a_previously_set_override( coordinator = await component_setup() entity_id = coordinator.entities[0].entity_id - await coordinator.async_set_auto_lock_override(5) + await coordinator.set_auto_lock_override(5) assert coordinator.data.auto_lock_seconds == 5 await hass.services.async_call( @@ -863,9 +863,7 @@ async def test_omitted_field_leaves_override_untouched( coordinator = await component_setup() entity_id = coordinator.entities[0].entity_id - with patch.object( - coordinator, "async_set_auto_lock_override" - ) as mock_set_override: + with patch.object(coordinator, "set_auto_lock_override") as mock_set_override: await hass.services.async_call( DOMAIN, SVC_SET_CONFIG_OVERRIDE,