diff --git a/custom_components/lock_code_manager/domain/credentials.py b/custom_components/lock_code_manager/domain/credentials.py index be5def70..0de0e9e4 100644 --- a/custom_components/lock_code_manager/domain/credentials.py +++ b/custom_components/lock_code_manager/domain/credentials.py @@ -269,6 +269,13 @@ class CredentialTypeCapability: ``supports_learn`` is True when the lock can enroll the credential at the device (for example a fingerprint learn flow) rather than being told the value. + + Length convention shared by every provider: a non-positive ``max_length`` + means "no advertised maximum / unknown" -- never a literal zero-length + limit, which would be meaningless -- so providers map an absent or + unreadable maximum to ``0`` (Matter's ``max_pin_length or 0`` idiom). A + non-positive ``min_length`` means "no minimum". ``length_bounds`` applies + this normalization; do not emit a literal ``0`` to express a real limit. """ num_slots: int @@ -335,6 +342,26 @@ def bounded_slot_count(self, credential_type: CredentialType) -> int | None: return None return capability.num_slots + def length_bounds( + self, credential_type: CredentialType + ) -> tuple[int, int | None] | None: + """ + Return the effective ``(min, max)`` value length for a credential type. + + ``None`` when the type is unsupported. A non-positive advertised + bound means "unbounded" rather than a literal limit: Matter reports + ``max_pin_length`` as ``... or 0``, where ``0`` is "unknown", so it + normalizes to no upper bound (``max`` of ``None``). A non-positive + minimum normalizes to ``0`` (no minimum). + """ + cap = self.capability_for(credential_type) + if cap is None: + return None + return ( + max(cap.min_length, 0), + cap.max_length if cap.max_length > 0 else None, + ) + def credential_from_slot(slot: int, state: SlotCredential) -> Credential: """ diff --git a/custom_components/lock_code_manager/domain/slot_coordinator.py b/custom_components/lock_code_manager/domain/slot_coordinator.py index 32719118..9c2ec23f 100644 --- a/custom_components/lock_code_manager/domain/slot_coordinator.py +++ b/custom_components/lock_code_manager/domain/slot_coordinator.py @@ -33,7 +33,7 @@ HomeAssistant, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( IssueSeverity, @@ -43,6 +43,7 @@ from ..const import ATTR_IN_SYNC, DOMAIN, EVENT_CREDENTIAL_USED from .config import EntryConfig +from .credentials import CredentialType from .names import name_error, normalize_name from .queries import get_entry_config @@ -245,10 +246,22 @@ async def async_request_pin_update(self, value: str) -> None: Normalizing whitespace and the empty-PIN side effect (disabling the slot on an active slot whose PIN was cleared) live here so entities do not have to coordinate sibling state themselves. + + A non-empty PIN is validated against every bound lock's advertised + length range before it is written; an empty PIN clears the slot and + is exempt. This is the authoritative gate for BOTH ends: the text + entity keeps ``native_min`` and ``native_max`` permissive so Home + Assistant's ``text.set_value`` service neither rejects the empty clear + nor pre-empts the per-lock error built here -- and so a lock + advertising a limit tighter than it really accepts cannot silently + stop the keystrokes with no message at all. """ if not value.strip(): value = "" + if value: + self._validate_credential_length(value, CredentialType.PIN) + updates: dict[str, Any] = {CONF_PIN: value} if not value and self.is_enabled: _LOGGER.debug( @@ -259,6 +272,46 @@ async def async_request_pin_update(self, value: str) -> None: self._write_config_fields(updates) + def _validate_credential_length( + self, value: str, credential_type: CredentialType + ) -> None: + """ + Reject ``value`` if it violates any bound lock's length range. + + Authoritative gate for credential length. Iterates every bound lock so + the error names each offending lock with its required range. The lock + set is the entry-wide ``runtime_data.locks`` -- the same set the text + entity mirrors in ``self.locks`` to size its surfaced bounds, since LCM + binds every lock to every slot; a future per-slot binding must update + both sites together. Locks whose capabilities are not cached + (disconnected or not yet probed) and locks that do not advertise + ``credential_type`` are skipped -- the write proceeds rather than + blocking on unknown limits, and the sync layer surfaces any later + device rejection. + """ + length = len(value) + violations: list[str] = [] + for lock in self._config_entry.runtime_data.locks.values(): + caps = lock.cached_capabilities + if caps is None: + continue + bounds = caps.length_bounds(credential_type) + if bounds is None: + continue + lo, hi = bounds + if length < lo or (hi is not None and length > hi): + required = ( + f"at least {lo} characters" + if hi is None + else f"{lo}-{hi} characters" + ) + violations.append(f"{required} for {lock.display_name}") + if violations: + raise ServiceValidationError( + f"{credential_type.value.upper()} length {length} is not accepted " + f"by all locks: {'; '.join(violations)}" + ) + async def async_request_active_toggle(self, enabled: bool) -> None: """ Apply an enabled/disabled toggle requested by the switch entity. diff --git a/custom_components/lock_code_manager/providers/_base.py b/custom_components/lock_code_manager/providers/_base.py index 3c64a54f..04ceeabd 100644 --- a/custom_components/lock_code_manager/providers/_base.py +++ b/custom_components/lock_code_manager/providers/_base.py @@ -1409,6 +1409,19 @@ async def async_get_usercodes( """ return await self._project_users_to_slots(CredentialType.PIN, slots) + @final + @property + def cached_capabilities(self) -> LockCapabilities | None: + """ + Return the already-probed capabilities, or ``None``. Never performs I/O. + + Synchronous read of the same cache ``_get_cached_capabilities`` + populates. Lets synchronous callers (e.g. the PIN text entity sizing + its length bounds) consult capabilities without awaiting; an unprobed + or disconnected lock reads ``None`` and contributes no constraint. + """ + return self._capabilities_cache + @final async def _get_cached_capabilities(self) -> LockCapabilities: """ diff --git a/custom_components/lock_code_manager/text.py b/custom_components/lock_code_manager/text.py index 2d1d5237..fc0e181d 100644 --- a/custom_components/lock_code_manager/text.py +++ b/custom_components/lock_code_manager/text.py @@ -49,8 +49,10 @@ def add_standard_text_entities(slot_num: int, ent_reg: er.EntityRegistry) -> Non class LockCodeManagerText(BaseLockCodeManagerEntity, TextEntity): """Text entity for lock code manager.""" - _attr_native_min = 0 - _attr_native_max = 9999 + # Defaults for keys with no length constraint (the slot name) and the + # fallback when bound locks advertise nothing or an unsatisfiable range. + _DEFAULT_MIN = 0 + _DEFAULT_MAX = 9999 def __init__( self, @@ -67,6 +69,37 @@ def __init__( ) self._attr_mode = text_mode + @property + def native_min(self) -> int: + """ + Return the minimum value length -- always the permissive default. + + The advertised per-lock minimum is deliberately NOT surfaced here. + Home Assistant's ``text.set_value`` service rejects + ``len(value) < native_min`` before the value reaches the coordinator, + which would block the empty string that clears a slot and would replace + the coordinator's per-lock error with a generic one. The coordinator + (``SlotEntityCoordinator._validate_credential_length``) is the + authoritative minimum gate; an empty PIN is exempt because it clears + the slot. + """ + return self._DEFAULT_MIN + + @property + def native_max(self) -> int: + """ + Return the maximum value length -- always the permissive default. + + The advertised maximum is deliberately NOT surfaced here, for the same + reason as the minimum. Home Assistant turns ``native_max`` into the + field's ``maxlength``, so a lock advertising a limit lower than it + really accepts would stop the keystrokes with no message at all: the + field simply refuses to grow and nothing says why. The coordinator + refuses the write instead, naming the lock and the range it claims, + which is what somebody needs to see to recognise a bad advertisement. + """ + return self._DEFAULT_MAX + @property def native_value(self) -> str | None: """Return native value.""" diff --git a/tests/providers/test_base.py b/tests/providers/test_base.py index 1ccba7bd..f0ee5a3e 100644 --- a/tests/providers/test_base.py +++ b/tests/providers/test_base.py @@ -71,6 +71,39 @@ def teardown_push_subscription(self) -> None: self.unsubscribe_calls += 1 +class _CapsLock(MockLCMLock): + """Mock lock that advertises PIN capabilities.""" + + async def async_get_capabilities(self) -> LockCapabilities: + """Report a single PIN credential type with a 4-8 length range.""" + return LockCapabilities( + supports_user_management=True, + max_users=30, + credential_types={ + CredentialType.PIN: CredentialTypeCapability( + num_slots=30, min_length=4, max_length=8, supports_learn=False + ) + }, + ) + + +async def test_cached_capabilities_exposes_warmed_cache(hass: HomeAssistant): + """cached_capabilities is None until probed, then returns the cached snapshot.""" + entity_reg = er.async_get(hass) + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + lock_entity = entity_reg.async_get_or_create( + "lock", "test", "caps_lock", config_entry=config_entry + ) + lock = _CapsLock(hass, dr.async_get(hass), entity_reg, config_entry, lock_entity) + + assert lock.cached_capabilities is None + + caps = await lock._get_cached_capabilities() + assert lock.cached_capabilities is caps + assert lock.cached_capabilities.length_bounds(CredentialType.PIN) == (4, 8) + + async def test_base(hass: HomeAssistant): """Test base class.""" entity_reg = er.async_get(hass) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 2b21de4b..c24549a9 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -270,6 +270,40 @@ def test_credential_types_is_snapshotted(self) -> None: caps.credential_types[CredentialType.RFID] = pin_cap # type: ignore[index] +def _caps(min_length: int, max_length: int) -> LockCapabilities: + """Build a single-PIN-type LockCapabilities with the given length bounds.""" + return LockCapabilities( + supports_user_management=True, + max_users=30, + credential_types={ + CredentialType.PIN: CredentialTypeCapability( + num_slots=30, + min_length=min_length, + max_length=max_length, + supports_learn=False, + ) + }, + ) + + +class TestLengthBounds: + """LockCapabilities.length_bounds normalizes per-type length limits.""" + + def test_returns_min_and_max_for_supported_type(self) -> None: + assert _caps(4, 8).length_bounds(CredentialType.PIN) == (4, 8) + + def test_unsupported_type_returns_none(self) -> None: + assert _caps(4, 8).length_bounds(CredentialType.RFID) is None + + def test_non_positive_max_means_unbounded(self) -> None: + # Matter reports max_pin_length as `... or 0` -- 0 is "unknown", not + # "zero characters", so it must normalize to no upper bound. + assert _caps(4, 0).length_bounds(CredentialType.PIN) == (4, None) + + def test_negative_min_clamps_to_zero(self) -> None: + assert _caps(-1, 8).length_bounds(CredentialType.PIN) == (0, 8) + + class TestProjectionHelpers: """Pure 1:1:1 projection between a managed slot and the User/Credential model.""" diff --git a/tests/test_slot_coordinator.py b/tests/test_slot_coordinator.py index e88008b6..43f17bb5 100644 --- a/tests/test_slot_coordinator.py +++ b/tests/test_slot_coordinator.py @@ -29,7 +29,7 @@ STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry from custom_components.lock_code_manager.binary_sensor import ( @@ -41,6 +41,11 @@ DOMAIN, ) from custom_components.lock_code_manager.domain.config import EntryConfig +from custom_components.lock_code_manager.domain.credentials import ( + CredentialType, + CredentialTypeCapability, + LockCapabilities, +) from custom_components.lock_code_manager.domain.queries import get_entry_config from custom_components.lock_code_manager.domain.slot_coordinator import ( PinRequiredError, @@ -49,6 +54,7 @@ from .common import ( LOCK_1_ENTITY_ID, + LOCK_2_ENTITY_ID, SLOT_1_ACTIVE_ENTITY, SLOT_1_ENABLED_ENTITY, SLOT_1_PIN_ENTITY, @@ -60,6 +66,196 @@ _LOGGER = logging.getLogger(__name__) +def _pin_caps(min_length: int, max_length: int) -> LockCapabilities: + """Build LockCapabilities advertising a PIN type with the given bounds.""" + return LockCapabilities( + supports_user_management=True, + max_users=30, + credential_types={ + CredentialType.PIN: CredentialTypeCapability( + num_slots=30, + min_length=min_length, + max_length=max_length, + supports_learn=False, + ) + }, + ) + + +async def test_request_pin_update_rejects_out_of_range_pin( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """A PIN shorter than the lock minimum is rejected and never written.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + coordinator = runtime_data.slot_coordinators[1] + + with pytest.raises(ServiceValidationError): + await coordinator.async_request_pin_update("12") + + # The rejected PIN must not reach config. + assert get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == ( + "1234" + ) + + +async def test_request_pin_update_rejects_too_long_pin( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """A PIN longer than the lock maximum is rejected.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + coordinator = runtime_data.slot_coordinators[1] + + with pytest.raises(ServiceValidationError): + await coordinator.async_request_pin_update("123456789") + + +async def test_request_pin_update_allows_in_range_pin( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """A PIN within the advertised range is written normally.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + coordinator = runtime_data.slot_coordinators[1] + + await coordinator.async_request_pin_update("567890") + await hass.async_block_till_done() + + assert get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == ( + "567890" + ) + + +async def test_request_pin_update_skips_lock_advertising_no_pin_type( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """ + A lock with known capabilities but no PIN type constrains nothing. + + Distinct from the uncached case: here the capabilities ARE known, they + just say nothing about PIN length. Treating that silence as a rejection + would let one non-PIN lock in the entry block every write. + """ + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = LockCapabilities( + supports_user_management=True, + max_users=30, + credential_types={}, + ) + coordinator = runtime_data.slot_coordinators[1] + + # A single character would fail any real bound, so acceptance proves the skip. + await coordinator.async_request_pin_update("1") + await hass.async_block_till_done() + + assert get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == "1" + + +async def test_request_pin_update_empty_pin_exempt_from_length( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """Clearing the PIN is allowed even when the lock requires a minimum length.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + coordinator = runtime_data.slot_coordinators[1] + + await coordinator.async_request_pin_update("") + await hass.async_block_till_done() + + assert get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == "" + + +async def test_request_pin_update_fails_open_without_capabilities( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """Locks with unknown capabilities do not block a write.""" + coordinator = lock_code_manager_config_entry.runtime_data.slot_coordinators[1] + + await coordinator.async_request_pin_update("12") + await hass.async_block_till_done() + + assert ( + get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == "12" + ) + + +async def test_validation_names_each_offending_lock( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """The rejection message names every lock the PIN violates.""" + runtime_data = lock_code_manager_config_entry.runtime_data + runtime_data.locks[LOCK_1_ENTITY_ID]._capabilities_cache = _pin_caps(6, 8) + runtime_data.locks[LOCK_2_ENTITY_ID]._capabilities_cache = _pin_caps(6, 8) + coordinator = runtime_data.slot_coordinators[1] + + with pytest.raises(ServiceValidationError) as exc: + await coordinator.async_request_pin_update("12") + + message = str(exc.value) + assert runtime_data.locks[LOCK_1_ENTITY_ID].display_name in message + assert runtime_data.locks[LOCK_2_ENTITY_ID].display_name in message + + +async def test_request_pin_update_accepts_boundary_lengths( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """A PIN exactly at the min or max is accepted; one past either end is rejected.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + coordinator = runtime_data.slot_coordinators[1] + + for ok in ("1234", "12345678"): # exactly the min (4) and the max (8) + await coordinator.async_request_pin_update(ok) + await hass.async_block_till_done() + assert ( + get_entry_config(lock_code_manager_config_entry).slot(1).get(CONF_PIN) == ok + ) + + for bad in ("123", "123456789"): # one under the min and one over the max + with pytest.raises(ServiceValidationError): + await coordinator.async_request_pin_update(bad) + + +async def test_validation_message_unbounded_max_says_at_least( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """A lock advertising a minimum but no maximum yields an 'at least N' message.""" + runtime_data = lock_code_manager_config_entry.runtime_data + for lock in runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(6, 0) # max 0 == unbounded + coordinator = runtime_data.slot_coordinators[1] + + with pytest.raises(ServiceValidationError) as exc: + await coordinator.async_request_pin_update("12") + + assert "at least 6 characters" in str(exc.value) + + async def test_coordinator_registered_for_each_slot( hass: HomeAssistant, mock_lock_config_entry, diff --git a/tests/test_text.py b/tests/test_text.py index 712c462b..ad250528 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1,17 +1,36 @@ """Test text platform.""" import logging +from types import SimpleNamespace import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.components.text import ( + ATTR_MAX, + ATTR_MIN, ATTR_VALUE, DOMAIN as TEXT_DOMAIN, SERVICE_SET_VALUE, + TextMode, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PIN, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from custom_components.lock_code_manager.const import DOMAIN +from custom_components.lock_code_manager.domain.credentials import ( + CredentialType, + CredentialTypeCapability, + LockCapabilities, +) +from custom_components.lock_code_manager.domain.models import ( + LockCodeManagerConfigEntryRuntimeData, +) +from custom_components.lock_code_manager.text import ( + LockCodeManagerText, +) from .common import ( SLOT_1_NAME_ENTITY, @@ -23,6 +42,183 @@ _LOGGER = logging.getLogger(__name__) +def _pin_caps(min_length: int, max_length: int) -> LockCapabilities: + """Build LockCapabilities advertising a PIN type with the given bounds.""" + return LockCapabilities( + supports_user_management=True, + max_users=30, + credential_types={ + CredentialType.PIN: CredentialTypeCapability( + num_slots=30, + min_length=min_length, + max_length=max_length, + supports_learn=False, + ) + }, + ) + + +def _fake_lock(entity_id: str, caps: LockCapabilities | None): + """A stand-in lock exposing only what the text entity reads.""" + + async def _get_cached_capabilities() -> LockCapabilities | None: + """Stand in for the async probe the add hook runs in the background.""" + return caps + + return SimpleNamespace( + cached_capabilities=caps, + lock=SimpleNamespace(entity_id=entity_id), + _get_cached_capabilities=_get_cached_capabilities, + ) + + +def _make_text_entity( + hass: HomeAssistant, key: str, locks: list +) -> LockCodeManagerText: + """Construct a text entity with a controlled lock list for bounds tests.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="Test") + config_entry.add_to_hass(hass) + config_entry.runtime_data = LockCodeManagerConfigEntryRuntimeData() + + entity = LockCodeManagerText( + hass, + er.async_get(hass), + config_entry, + 1, + key, + TextMode.PASSWORD if key == CONF_PIN else TextMode.TEXT, + ) + entity.locks = locks + return entity + + +def test_pin_bounds_default_without_capabilities(hass: HomeAssistant) -> None: + """An uncached lock contributes no constraint; bounds stay 0/9999.""" + entity = _make_text_entity(hass, CONF_PIN, [_fake_lock("lock.a", None)]) + assert (entity.native_min, entity.native_max) == (0, 9999) + + +def test_bounds_stay_permissive_whatever_the_locks_advertise( + hass: HomeAssistant, +) -> None: + """ + Neither end of the advertised range is surfaced to Home Assistant. + + Surfacing the minimum would make ``text.set_value`` reject the empty + string that clears a slot. Surfacing the maximum would become the field's + ``maxlength``, so a lock claiming a tighter limit than it really accepts + would stop the keystrokes with no message at all -- nothing on screen + would say why the field refused to grow. + + The coordinator gates both ends instead, and names the lock and the range + it claims, which is what somebody needs to recognise a bad advertisement. + """ + for advertised in ((4, 8), (6, 6), (8, 12)): + entity = _make_text_entity( + hass, CONF_PIN, [_fake_lock("lock.a", _pin_caps(*advertised))] + ) + assert (entity.native_min, entity.native_max) == (0, 9999) + + # Including locks that cannot agree on any length at all. + entity = _make_text_entity( + hass, + CONF_PIN, + [_fake_lock("lock.a", _pin_caps(8, 12)), _fake_lock("lock.b", _pin_caps(4, 6))], + ) + assert (entity.native_min, entity.native_max) == (0, 9999) + + +def test_native_min_stays_zero_despite_advertised_minimum(hass: HomeAssistant) -> None: + """The advertised minimum is never surfaced as a hard floor. + + Surfacing it would make HA's text service reject the empty string that + clears a slot; the coordinator owns the minimum instead. + """ + entity = _make_text_entity(hass, CONF_PIN, [_fake_lock("lock.a", _pin_caps(6, 8))]) + assert entity.native_min == 0 + + +def test_name_entity_ignores_capabilities(hass: HomeAssistant) -> None: + """The name entity is not a credential key; bounds stay 0/9999.""" + entity = _make_text_entity(hass, CONF_NAME, [_fake_lock("lock.a", _pin_caps(4, 8))]) + assert (entity.native_min, entity.native_max) == (0, 9999) + + +async def test_pin_entity_surfaces_lock_bounds( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """The PIN entity leaves its length range open whatever the lock advertises.""" + # Without advertised capabilities the entity uses the default range. + # Home Assistant clamps the reported max to its 255-char state ceiling. + state = hass.states.get(SLOT_2_PIN_ENTITY) + assert state + assert state.attributes[ATTR_MIN] == 0 + assert state.attributes[ATTR_MAX] == 255 + + # Warm the lock's capability cache, then a write re-reads the bounds. + for lock in lock_code_manager_config_entry.runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(4, 8) + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: "1234"}, + target={ATTR_ENTITY_ID: SLOT_2_PIN_ENTITY}, + blocking=True, + ) + + state = hass.states.get(SLOT_2_PIN_ENTITY) + assert state + # Both ends are owned by the coordinator, not surfaced as hard limits. + assert state.attributes[ATTR_MIN] == 0 + assert state.attributes[ATTR_MAX] == 255 + + +async def test_pin_clear_through_service_with_minimum_advertised( + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, +): + """Clearing a PIN via text.set_value works even when locks advertise a minimum. + + Regression: surfacing the advertised minimum as ``native_min`` made HA's + text service reject the empty string (``len 0 < min``) before the + coordinator's empty-PIN exemption ran, so a slot could not be cleared. + """ + for lock in lock_code_manager_config_entry.runtime_data.locks.values(): + lock._capabilities_cache = _pin_caps(6, 8) + + # An in-range PIN goes through the service normally. + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: "654321"}, + target={ATTR_ENTITY_ID: SLOT_2_PIN_ENTITY}, + blocking=True, + ) + state = hass.states.get(SLOT_2_PIN_ENTITY) + assert state + assert state.state == "654321" + + # Clearing must reach the coordinator (empty is exempt) rather than being + # rejected by HA's service-level minimum check. + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: ""}, + target={ATTR_ENTITY_ID: SLOT_2_PIN_ENTITY}, + blocking=True, + ) + state = hass.states.get(SLOT_2_PIN_ENTITY) + assert state + assert state.state == "" + state = hass.states.get(SLOT_2_ENABLED_ENTITY) + assert state + assert state.state == STATE_OFF + + async def test_text_entities( hass: HomeAssistant, mock_lock_config_entry,