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..c1f794e 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 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..ec9b15f 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.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..aa118b8 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_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 diff --git a/tests/test_services.py b/tests/test_services.py index 7ca79dd..8bb7a38 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,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