Selaa lähdekoodia

Extract lock slot helpers into locks.py mirroring wifi.py

ZiB 3 kuukautta sitten
vanhempi
sitoutus
bf09f800a0

+ 4 - 54
custom_components/room_cal_ha/coordinator.py

@@ -16,8 +16,6 @@ from .const import (
   CONF_REFRESH_INTERVAL,
   CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
-  CONF_ZLOCK_ENTRIES,
-  CONF_ZWAVE_SLOT,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
   STORE_CURRENT,
@@ -276,7 +274,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         except Exception as err:
           _LOGGER.warning("Failed to set WiFi key: %s", err)
     if self._is_use_zwave_on() and isinstance(res.door_code, str):
-      await self._async_push_lock_codes(res.door_code)
+      from .locks import async_push_lock_codes
+      await async_push_lock_codes(self.hass, self.entry, res.door_code)
     await self._async_set_room_state(True)
     _LOGGER.info("Check-in complete: guest=%s", res.guest_name)
 
@@ -294,60 +293,11 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       except Exception as err:
         _LOGGER.warning("Failed to disable SSID '%s': %s", res.wifi_ssid, err)
     if self._is_use_zwave_on():
-      await self._async_clear_lock_codes()
+      from .locks import async_clear_lock_codes
+      await async_clear_lock_codes(self.hass, self.entry)
     await self._async_set_room_state(False)
     _LOGGER.info("Check-out complete: guest=%s", res.guest_name)
 
-  async def _async_call_service(self, domain: str, service: str, data: dict) -> None:
-    await self.hass.services.async_call(domain, service, data, blocking=True)
-
-  def _zlock_entity_ids(self) -> list[str]:
-    """Return all lock entity IDs from the configured zlock_slots entries."""
-    entity_ids: list[str] = []
-    for zlock_entry_id in self.entry.options.get(CONF_ZLOCK_ENTRIES, []):
-      zlock_entry = self.hass.config_entries.async_get_entry(zlock_entry_id)
-      if zlock_entry is not None:
-        entity_ids.extend(zlock_entry.data.get("lock_entity_ids", []))
-    return entity_ids
-
-  async def _async_push_lock_codes(self, code: str) -> None:
-    slot = self.entry.options.get(CONF_ZWAVE_SLOT)
-    lock_entity_ids = self._zlock_entity_ids()
-    _LOGGER.info("Lock enable: slot=%s locks=%s", slot, lock_entity_ids)
-    if not lock_entity_ids or slot is None:
-      return
-    slot = int(slot)
-    for entity_id in lock_entity_ids:
-      try:
-        await self._async_call_service(
-          "zlock_slots", "set_code",
-          {"lock_entity_id": entity_id, "slot": slot, "code": code},
-        )
-        await self._async_call_service(
-          "zlock_slots", "set_slot_enabled",
-          {"lock_entity_id": entity_id, "slot": slot, "enabled": True},
-        )
-        _LOGGER.info("Lock enabled: %s slot %s", entity_id, slot)
-      except Exception as err:
-        _LOGGER.warning("Failed to set lock code on '%s': %s", entity_id, err)
-
-  async def _async_clear_lock_codes(self) -> None:
-    slot = self.entry.options.get(CONF_ZWAVE_SLOT)
-    lock_entity_ids = self._zlock_entity_ids()
-    _LOGGER.info("Lock disable: slot=%s locks=%s", slot, lock_entity_ids)
-    if not lock_entity_ids or slot is None:
-      return
-    slot = int(slot)
-    for entity_id in lock_entity_ids:
-      try:
-        await self._async_call_service(
-          "zlock_slots", "set_slot_enabled",
-          {"lock_entity_id": entity_id, "slot": slot, "enabled": False},
-        )
-        _LOGGER.info("Lock disabled: %s slot %s", entity_id, slot)
-      except Exception as err:
-        _LOGGER.warning("Failed to disable lock slot on '%s': %s", entity_id, err)
-
   async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
     """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
 

+ 69 - 0
custom_components/room_cal_ha/locks.py

@@ -0,0 +1,69 @@
+"""zlock_slots lock helpers — set, enable, and disable door code slots on configured locks."""
+from __future__ import annotations
+
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_ZLOCK_ENTRIES, CONF_ZWAVE_SLOT
+
+ZLOCK_DOMAIN = "zlock_slots"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def _async_call_service(hass: HomeAssistant, domain: str, service: str, data: dict) -> None:
+  await hass.services.async_call(domain, service, data, blocking=True)
+
+
+def get_lock_entity_ids(hass: HomeAssistant, entry: ConfigEntry) -> list[str]:
+  """Return all lock entity IDs from the configured zlock_slots entries."""
+  entity_ids: list[str] = []
+  for zlock_entry_id in entry.options.get(CONF_ZLOCK_ENTRIES, []):
+    zlock_entry = hass.config_entries.async_get_entry(zlock_entry_id)
+    if zlock_entry is not None:
+      entity_ids.extend(zlock_entry.data.get("lock_entity_ids", []))
+  return entity_ids
+
+
+async def async_push_lock_codes(hass: HomeAssistant, entry: ConfigEntry, code: str) -> None:
+  """Set the door code and enable the slot on all configured locks."""
+  slot = entry.options.get(CONF_ZWAVE_SLOT)
+  lock_entity_ids = get_lock_entity_ids(hass, entry)
+  _LOGGER.info("Lock enable: slot=%s locks=%s", slot, lock_entity_ids)
+  if not lock_entity_ids or slot is None:
+    return
+  slot = int(slot)
+  for entity_id in lock_entity_ids:
+    try:
+      await _async_call_service(
+        hass, ZLOCK_DOMAIN, "set_code",
+        {"lock_entity_id": entity_id, "slot": slot, "code": code},
+      )
+      await _async_call_service(
+        hass, ZLOCK_DOMAIN, "set_slot_enabled",
+        {"lock_entity_id": entity_id, "slot": slot, "enabled": True},
+      )
+      _LOGGER.info("Lock enabled: %s slot %s", entity_id, slot)
+    except Exception as err:
+      _LOGGER.warning("Failed to set lock code on '%s': %s", entity_id, err)
+
+
+async def async_clear_lock_codes(hass: HomeAssistant, entry: ConfigEntry) -> None:
+  """Disable the slot on all configured locks (code is preserved)."""
+  slot = entry.options.get(CONF_ZWAVE_SLOT)
+  lock_entity_ids = get_lock_entity_ids(hass, entry)
+  _LOGGER.info("Lock disable: slot=%s locks=%s", slot, lock_entity_ids)
+  if not lock_entity_ids or slot is None:
+    return
+  slot = int(slot)
+  for entity_id in lock_entity_ids:
+    try:
+      await _async_call_service(
+        hass, ZLOCK_DOMAIN, "set_slot_enabled",
+        {"lock_entity_id": entity_id, "slot": slot, "enabled": False},
+      )
+      _LOGGER.info("Lock disabled: %s slot %s", entity_id, slot)
+    except Exception as err:
+      _LOGGER.warning("Failed to disable lock slot on '%s': %s", entity_id, err)

+ 0 - 204
tests/test_coordinator.py

@@ -13,8 +13,6 @@ from custom_components.room_cal_ha.const import (
   CONF_CALENDAR_NAME,
   CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
-  CONF_ZLOCK_ENTRIES,
-  CONF_ZWAVE_SLOT,
   DOMAIN,
   STORE_CURRENT,
   STORE_NEXT,
@@ -958,210 +956,8 @@ async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
   assert not diff.has_changes
 
 
-def _make_zlock_entry(hass, lock_entity_ids: list[str]) -> MockConfigEntry:
-  zlock = MockConfigEntry(domain="zlock_slots", data={"lock_entity_ids": lock_entity_ids})
-  zlock.add_to_hass(hass)
-  return zlock
-
-
-@pytest.fixture
-async def coordinator_with_locks(hass: HomeAssistant):
-  zlock = _make_zlock_entry(hass, ["lock.front_door"])
-  entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
-  coord = RoomCalCoordinator(hass, entry)
-  await coord.async_setup()
-  return coord
-
-
 async def test_unifi_diff_preserves_original_diff_flags(coordinator):
   # Even when UniFi state is clean, original checkin_needed from the base diff is kept.
   old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key")  # no dates → not active
   diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
   assert diff.checkin_needed  # from reservation_diff: old not active, new is active
-
-
-# ---------------------------------------------------------------------------
-# Check-in Z-Wave lock behavior
-# ---------------------------------------------------------------------------
-
-_ZWAVE_RES = ReservationData(
-  guest_name="Bob",
-  check_in_date=date(2026, 7, 1),
-  check_in_time=time(14, 0),
-  check_out_date=date(2026, 7, 5),
-  check_out_time=time(12, 0),
-  door_code="7890",
-)
-
-
-async def test_check_in_sets_lock_codes(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
-  mock_switch = MagicMock()
-  mock_switch.async_turn_on = AsyncMock()
-  coordinator_with_locks._room_state_switch = mock_switch
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator_with_locks._async_do_check_in()
-  coordinator_with_locks.cancel_mode_sync()
-
-  mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front_door", "slot": 5, "code": "7890"})
-  mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": True})
-  mock_switch.async_turn_on.assert_awaited_once()
-
-
-async def test_check_in_sets_codes_on_all_locks(hass: HomeAssistant):
-  zlock = _make_zlock_entry(hass, ["lock.front", "lock.back"])
-  entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
-  coord = RoomCalCoordinator(hass, entry)
-  await coord.async_setup()
-  await coord.store.async_set(STORE_NEXT, _ZWAVE_RES)
-  mock_switch = MagicMock()
-  mock_switch.async_turn_on = AsyncMock()
-  coord._room_state_switch = mock_switch
-
-  with (
-    patch.object(coord, "_is_mode_sync_on", return_value=True),
-    patch.object(coord, "_is_use_zwave_on", return_value=True),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coord, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coord._async_do_check_in()
-  coord.cancel_mode_sync()
-
-  mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.front", "slot": 5, "code": "7890"})
-  mock_call.assert_any_await("zlock_slots", "set_code", {"lock_entity_id": "lock.back", "slot": 5, "code": "7890"})
-  mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front", "slot": 5, "enabled": True})
-  mock_call.assert_any_await("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.back", "slot": 5, "enabled": True})
-
-
-async def test_check_in_skips_lock_codes_when_door_code_none(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  no_code = _ZWAVE_RES.__class__(
-    guest_name="Bob",
-    check_in_date=_ZWAVE_RES.check_in_date,
-    check_in_time=_ZWAVE_RES.check_in_time,
-    check_out_date=_ZWAVE_RES.check_out_date,
-    check_out_time=_ZWAVE_RES.check_out_time,
-    door_code=None,
-  )
-  await coordinator_with_locks.store.async_set(STORE_NEXT, no_code)
-  coordinator_with_locks._room_state_switch = None
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator_with_locks._async_do_check_in()
-  coordinator_with_locks.cancel_mode_sync()
-
-  assert not any(
-    c.args[:2] == ("zlock_slots", "set_code") for c in mock_call.await_args_list
-  )
-
-
-async def test_check_in_skips_lock_codes_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
-  coordinator_with_locks._room_state_switch = None
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator_with_locks._async_do_check_in()
-  coordinator_with_locks.cancel_mode_sync()
-
-  mock_call.assert_not_awaited()
-
-
-async def test_check_in_skips_lock_codes_when_no_locks_configured(hass: HomeAssistant, coordinator: RoomCalCoordinator):
-  # coordinator fixture has no CONF_ZLOCK_ENTRIES in options
-  await coordinator.store.async_set(STORE_NEXT, _ZWAVE_RES)
-  coordinator._room_state_switch = None
-
-  with (
-    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator, "_is_use_zwave_on", return_value=True),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coordinator, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator._async_do_check_in()
-  coordinator.cancel_mode_sync()
-
-  mock_call.assert_not_awaited()
-
-
-async def test_check_in_lock_error_still_activates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
-  mock_switch = MagicMock()
-  mock_switch.async_turn_on = AsyncMock()
-  coordinator_with_locks._room_state_switch = mock_switch
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
-    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
-    patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")),
-  ):
-    await coordinator_with_locks._async_do_check_in()
-  coordinator_with_locks.cancel_mode_sync()
-
-  mock_switch.async_turn_on.assert_awaited_once()
-
-
-# ---------------------------------------------------------------------------
-# Check-out Z-Wave lock behavior
-# ---------------------------------------------------------------------------
-
-async def test_check_out_disables_lock_slot(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
-  mock_switch = MagicMock()
-  mock_switch.async_turn_off = AsyncMock()
-  coordinator_with_locks._room_state_switch = mock_switch
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
-    patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator_with_locks._async_do_check_out()
-
-  mock_call.assert_awaited_once_with("zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": False})
-  mock_switch.async_turn_off.assert_awaited_once()
-
-
-async def test_check_out_skips_lock_slot_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
-  coordinator_with_locks._room_state_switch = None
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
-    patch.object(coordinator_with_locks, "_async_call_service", new_callable=AsyncMock) as mock_call,
-  ):
-    await coordinator_with_locks._async_do_check_out()
-
-  mock_call.assert_not_awaited()
-
-
-async def test_check_out_lock_error_still_deactivates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
-  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
-  mock_switch = MagicMock()
-  mock_switch.async_turn_off = AsyncMock()
-  coordinator_with_locks._room_state_switch = mock_switch
-
-  with (
-    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
-    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
-    patch.object(coordinator_with_locks, "_async_call_service", side_effect=HomeAssistantError("lock offline")),
-  ):
-    await coordinator_with_locks._async_do_check_out()
-
-  mock_switch.async_turn_off.assert_awaited_once()

+ 243 - 0
tests/test_locks.py

@@ -0,0 +1,243 @@
+from datetime import date, datetime, time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.room_cal_ha.const import (
+  CONF_ZLOCK_ENTRIES,
+  CONF_ZWAVE_SLOT,
+  DOMAIN,
+  STORE_CURRENT,
+  STORE_NEXT,
+)
+from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
+from custom_components.room_cal_ha.models import ReservationData
+
+
+_BASE_DATA = {
+  "room_state_name": "Room State",
+  "current_name": "Current",
+  "next_name": "Next",
+}
+
+# Freeze time at the check-in moment so the window registers as active during check-in tests.
+_AT_CHECK_IN = MagicMock()
+_AT_CHECK_IN.replace.return_value = datetime(2026, 7, 1, 14, 0, 0)
+
+_ZWAVE_RES = ReservationData(
+  guest_name="Bob",
+  check_in_date=date(2026, 7, 1),
+  check_in_time=time(14, 0),
+  check_out_date=date(2026, 7, 5),
+  check_out_time=time(12, 0),
+  door_code="7890",
+)
+
+
+def _make_entry(hass, options: dict) -> MockConfigEntry:
+  entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
+  entry.add_to_hass(hass)
+  return entry
+
+
+def _make_zlock_entry(hass, lock_entity_ids: list[str]) -> MockConfigEntry:
+  zlock = MockConfigEntry(domain="zlock_slots", data={"lock_entity_ids": lock_entity_ids})
+  zlock.add_to_hass(hass)
+  return zlock
+
+
+@pytest.fixture
+async def coordinator(hass: HomeAssistant, mock_entry):
+  mock_entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, mock_entry)
+  await coord.async_setup()
+  return coord
+
+
+@pytest.fixture
+async def coordinator_with_locks(hass: HomeAssistant):
+  zlock = _make_zlock_entry(hass, ["lock.front_door"])
+  entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  return coord
+
+
+# ---------------------------------------------------------------------------
+# Check-in Z-Wave lock behavior
+# ---------------------------------------------------------------------------
+
+async def test_check_in_sets_lock_codes(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coordinator_with_locks._room_state_switch = mock_switch
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_call.assert_any_await(hass, "zlock_slots", "set_code", {"lock_entity_id": "lock.front_door", "slot": 5, "code": "7890"})
+  mock_call.assert_any_await(hass, "zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": True})
+  mock_switch.async_turn_on.assert_awaited_once()
+
+
+async def test_check_in_sets_codes_on_all_locks(hass: HomeAssistant):
+  zlock = _make_zlock_entry(hass, ["lock.front", "lock.back"])
+  entry = _make_entry(hass, {CONF_ZLOCK_ENTRIES: [zlock.entry_id], CONF_ZWAVE_SLOT: 5})
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  await coord.store.async_set(STORE_NEXT, _ZWAVE_RES)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coord._room_state_switch = mock_switch
+
+  with (
+    patch.object(coord, "_is_mode_sync_on", return_value=True),
+    patch.object(coord, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coord._async_do_check_in()
+  coord.cancel_mode_sync()
+
+  mock_call.assert_any_await(hass, "zlock_slots", "set_code", {"lock_entity_id": "lock.front", "slot": 5, "code": "7890"})
+  mock_call.assert_any_await(hass, "zlock_slots", "set_code", {"lock_entity_id": "lock.back", "slot": 5, "code": "7890"})
+  mock_call.assert_any_await(hass, "zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front", "slot": 5, "enabled": True})
+  mock_call.assert_any_await(hass, "zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.back", "slot": 5, "enabled": True})
+
+
+async def test_check_in_skips_lock_codes_when_door_code_none(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  no_code = _ZWAVE_RES.__class__(
+    guest_name="Bob",
+    check_in_date=_ZWAVE_RES.check_in_date,
+    check_in_time=_ZWAVE_RES.check_in_time,
+    check_out_date=_ZWAVE_RES.check_out_date,
+    check_out_time=_ZWAVE_RES.check_out_time,
+    door_code=None,
+  )
+  await coordinator_with_locks.store.async_set(STORE_NEXT, no_code)
+  coordinator_with_locks._room_state_switch = None
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  assert not any(
+    c.args[1:3] == ("zlock_slots", "set_code") for c in mock_call.await_args_list
+  )
+
+
+async def test_check_in_skips_lock_codes_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
+  coordinator_with_locks._room_state_switch = None
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_call.assert_not_awaited()
+
+
+async def test_check_in_skips_lock_codes_when_no_locks_configured(hass: HomeAssistant, coordinator: RoomCalCoordinator):
+  # coordinator fixture has no CONF_ZLOCK_ENTRIES in options
+  await coordinator.store.async_set(STORE_NEXT, _ZWAVE_RES)
+  coordinator._room_state_switch = None
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator._async_do_check_in()
+  coordinator.cancel_mode_sync()
+
+  mock_call.assert_not_awaited()
+
+
+async def test_check_in_lock_error_still_activates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_NEXT, _ZWAVE_RES)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coordinator_with_locks._room_state_switch = mock_switch
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.locks._async_call_service", side_effect=HomeAssistantError("lock offline")),
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_switch.async_turn_on.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# Check-out Z-Wave lock behavior
+# ---------------------------------------------------------------------------
+
+async def test_check_out_disables_lock_slot(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator_with_locks._room_state_switch = mock_switch
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_call.assert_awaited_once_with(hass, "zlock_slots", "set_slot_enabled", {"lock_entity_id": "lock.front_door", "slot": 5, "enabled": False})
+  mock_switch.async_turn_off.assert_awaited_once()
+
+
+async def test_check_out_skips_lock_slot_when_use_zwave_off(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
+  coordinator_with_locks._room_state_switch = None
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=False),
+    patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_call.assert_not_awaited()
+
+
+async def test_check_out_lock_error_still_deactivates_guest_mode(hass: HomeAssistant, coordinator_with_locks: RoomCalCoordinator):
+  await coordinator_with_locks.store.async_set(STORE_CURRENT, _ZWAVE_RES)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator_with_locks._room_state_switch = mock_switch
+
+  with (
+    patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.locks._async_call_service", side_effect=HomeAssistantError("lock offline")),
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_switch.async_turn_off.assert_awaited_once()