Bläddra i källkod

Add Z-Wave lock support: set/clear door codes at check-in and check-out

Adds zwave_lock.py helpers that call zwave_js.set_lock_usercode /
clear_lock_usercode. UseZwaveLockSwitch (off by default, unavailable
until locks are configured) gates the behavior. Options flow gains a
final locks step with EntitySelector + shared slot number. Coordinator
check-in/out actions push or clear codes on all configured locks,
paralleling the existing UniFi SSID path.
ZiB 4 månader sedan
förälder
incheckning
0fe3199ad9

+ 29 - 1
custom_components/room_cal_ha/config_flow.py

@@ -10,6 +10,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResu
 from homeassistant.core import callback
 from homeassistant.helpers.aiohttp_client import async_get_clientsession
 from homeassistant.helpers.selector import (
+  EntitySelector,
+  EntitySelectorConfig,
   NumberSelector,
   NumberSelectorConfig,
   NumberSelectorMode,
@@ -32,6 +34,8 @@ from .const import (
   CONF_ROOM_NAME,
   CONF_SYNC_MODE,
   CONF_USERNAME,
+  CONF_ZWAVE_LOCKS,
+  CONF_ZWAVE_SLOT,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
   SYNC_MODE_API,
@@ -306,7 +310,8 @@ class RoomCalOptionsFlow(OptionsFlow):
         else:
           defaults[key] = None
       if not errors:
-        return self.async_create_entry(data={**self._pending_options, CONF_RESERVATION_DEFAULTS: defaults})
+        self._pending_options[CONF_RESERVATION_DEFAULTS] = defaults
+        return await self.async_step_locks()
 
     existing = self.config_entry.options.get(CONF_RESERVATION_DEFAULTS, {})
     return self.async_show_form(
@@ -323,6 +328,29 @@ class RoomCalOptionsFlow(OptionsFlow):
     )
 
 
+  async def async_step_locks(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    if user_input is not None:
+      self._pending_options[CONF_ZWAVE_LOCKS] = user_input.get(CONF_ZWAVE_LOCKS, [])
+      slot = user_input.get(CONF_ZWAVE_SLOT)
+      self._pending_options[CONF_ZWAVE_SLOT] = int(slot) if slot is not None else None
+      return self.async_create_entry(data=self._pending_options)
+
+    opts = self.config_entry.options
+    return self.async_show_form(
+      step_id="locks",
+      data_schema=vol.Schema({
+        vol.Optional(CONF_ZWAVE_LOCKS, default=opts.get(CONF_ZWAVE_LOCKS, [])): EntitySelector(
+          EntitySelectorConfig(domain="lock", multiple=True)
+        ),
+        vol.Optional(CONF_ZWAVE_SLOT, default=opts.get(CONF_ZWAVE_SLOT, 1)): NumberSelector(
+          NumberSelectorConfig(min=1, max=250, mode=NumberSelectorMode.BOX)
+        ),
+      }),
+    )
+
+
 class _ApiAuthError(Exception):
   pass
 

+ 4 - 0
custom_components/room_cal_ha/const.py

@@ -18,6 +18,10 @@ CONF_RESERVATION_DEFAULTS = "reservation_defaults"
 # entry.data keys (sensitive)
 CONF_API_TOKEN = "api_token"
 
+# Z-Wave lock options
+CONF_ZWAVE_LOCKS = "zwave_locks"   # list[str] of lock entity_ids
+CONF_ZWAVE_SLOT = "zwave_slot"     # int — shared code slot for all locks
+
 # Sync mode values
 SYNC_MODE_CALDAV = "CalDAV"
 SYNC_MODE_API = "API"

+ 41 - 2
custom_components/room_cal_ha/coordinator.py

@@ -16,6 +16,8 @@ from .const import (
   CONF_REFRESH_INTERVAL,
   CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
+  CONF_ZWAVE_LOCKS,
+  CONF_ZWAVE_SLOT,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
   STORE_CURRENT,
@@ -203,7 +205,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       await self._room_state_switch.async_turn_off()
 
   async def _async_run_check_in_actions(self, res: ReservationData) -> None:
-    """Run WiFi updates and guest mode activation. No-op when mode_sync is off."""
+    """Run WiFi + Z-Wave updates and guest mode activation. No-op when mode_sync is off."""
     if not self._is_mode_sync_on():
       return
     if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
@@ -217,10 +219,12 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
           await async_set_wifi_key(self.hass, res.wifi_ssid, res.wifi_key)
         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)
     await self._async_set_room_state(True)
 
   async def _async_run_check_out_actions(self, res: ReservationData) -> None:
-    """Run WiFi SSID disable and guest mode deactivation. No-op when mode_sync is off."""
+    """Run WiFi SSID disable, Z-Wave code clear, and guest mode deactivation. No-op when mode_sync is off."""
     if not self._is_mode_sync_on():
       return
     if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
@@ -229,8 +233,36 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         await async_set_ssid_enabled(self.hass, res.wifi_ssid, False)
       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()
     await self._async_set_room_state(False)
 
+  async def _async_push_lock_codes(self, code: str) -> None:
+    from .zwave_lock import async_set_lock_usercode
+    locks: list[str] = self.entry.options.get(CONF_ZWAVE_LOCKS, [])
+    slot = self.entry.options.get(CONF_ZWAVE_SLOT)
+    if not locks or slot is None:
+      return
+    slot = int(slot)
+    for entity_id in locks:
+      try:
+        await async_set_lock_usercode(self.hass, entity_id, slot, code)
+      except Exception as err:
+        _LOGGER.warning("Failed to set lock usercode on '%s': %s", entity_id, err)
+
+  async def _async_clear_lock_codes(self) -> None:
+    from .zwave_lock import async_clear_lock_usercode
+    locks: list[str] = self.entry.options.get(CONF_ZWAVE_LOCKS, [])
+    slot = self.entry.options.get(CONF_ZWAVE_SLOT)
+    if not locks or slot is None:
+      return
+    slot = int(slot)
+    for entity_id in locks:
+      try:
+        await async_clear_lock_usercode(self.hass, entity_id, slot)
+      except Exception as err:
+        _LOGGER.warning("Failed to clear lock usercode 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.
 
@@ -293,6 +325,13 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         return entity.is_on
     return True  # safe default: don't silently skip WiFi if switch isn't found yet
 
+  def _is_use_zwave_on(self) -> bool:
+    from .switch import UseZwaveLockSwitch  # avoid circular at module level
+    for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
+      if isinstance(entity, UseZwaveLockSwitch):
+        return entity.is_on
+    return False  # safe default: don't push codes if switch isn't found yet
+
   @callback
   def _async_handle_check_in(self, _now: datetime) -> None:
     self.hass.async_create_task(self._async_do_check_in())

+ 8 - 0
custom_components/room_cal_ha/strings.json

@@ -69,6 +69,14 @@
           "wifi_key": "WiFi Key",
           "door_code": "Door Code"
         }
+      },
+      "locks": {
+        "title": "Z-Wave Locks",
+        "description": "Select lock entities and the code slot to use for guest door codes. All selected locks share the same slot number. Leave empty to skip Z-Wave lock management.",
+        "data": {
+          "zwave_locks": "Lock Entities",
+          "zwave_slot": "Code Slot"
+        }
       }
     },
     "error": {

+ 17 - 2
custom_components/room_cal_ha/switch.py

@@ -8,7 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.restore_state import RestoreEntity
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
-from .const import CONF_ROOM_NAME, DOMAIN
+from .const import CONF_ROOM_NAME, DOMAIN, CONF_ZWAVE_LOCKS
 from .coordinator import RoomCalCoordinator
 
 
@@ -24,10 +24,11 @@ async def async_setup_entry(
   mode_sync = ModeSyncSwitch(coordinator, entry, device_name)
   override = OverrideReservationSwitch(coordinator, entry, device_name)
   use_unifi = UseUnifiSwitch(coordinator, entry, device_name)
+  use_zwave = UseZwaveLockSwitch(coordinator, entry, device_name)
 
   coordinator.inject_room_state_switch(guest_mode)
 
-  switches = [guest_mode, mode_sync, override, use_unifi]
+  switches = [guest_mode, mode_sync, override, use_unifi, use_zwave]
   hass.data[DOMAIN][entry.entry_id]["switches"] = switches
   async_add_entities(switches)
 
@@ -109,6 +110,20 @@ class UseUnifiSwitch(_SwitchBase):
     self._attr_unique_id = f"{entry.entry_id}_use_unifi"
 
 
+class UseZwaveLockSwitch(_SwitchBase):
+  _attr_name = "Use Z-Wave Locks"
+  _attr_icon = "mdi:lock-smart"
+  _state: bool = False  # default off until locks are configured
+
+  def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
+    super().__init__(coordinator, entry, device_name)
+    self._attr_unique_id = f"{entry.entry_id}_use_zwave_locks"
+
+  @property
+  def available(self) -> bool:
+    return bool(self._entry.options.get(CONF_ZWAVE_LOCKS))
+
+
 class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEntity):
   _attr_has_entity_name = True
   _attr_name = "Override Reservation"

+ 8 - 0
custom_components/room_cal_ha/translations/en.json

@@ -69,6 +69,14 @@
           "wifi_key": "WiFi Key",
           "door_code": "Door Code"
         }
+      },
+      "locks": {
+        "title": "Z-Wave Locks",
+        "description": "Select lock entities and the code slot to use for guest door codes. All selected locks share the same slot number. Leave empty to skip Z-Wave lock management.",
+        "data": {
+          "zwave_locks": "Lock Entities",
+          "zwave_slot": "Code Slot"
+        }
       }
     },
     "error": {

+ 41 - 0
custom_components/room_cal_ha/zwave_lock.py

@@ -0,0 +1,41 @@
+"""Z-Wave JS lock helpers — set and clear user codes by slot number."""
+from __future__ import annotations
+
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+ZWAVE_JS_DOMAIN = "zwave_js"
+
+
+async def async_set_lock_usercode(
+  hass: HomeAssistant, entity_id: str, slot: int, code: str
+) -> None:
+  """Set a user code on a Z-Wave lock entity."""
+  try:
+    await hass.services.async_call(
+      ZWAVE_JS_DOMAIN,
+      "set_lock_usercode",
+      {"entity_id": entity_id, "code_slot": slot, "usercode": code},
+      blocking=True,
+    )
+  except Exception as err:
+    raise HomeAssistantError(
+      f"Failed to set lock usercode on '{entity_id}' slot {slot}: {err}"
+    ) from err
+
+
+async def async_clear_lock_usercode(
+  hass: HomeAssistant, entity_id: str, slot: int
+) -> None:
+  """Clear a user code slot on a Z-Wave lock entity."""
+  try:
+    await hass.services.async_call(
+      ZWAVE_JS_DOMAIN,
+      "clear_lock_usercode",
+      {"entity_id": entity_id, "code_slot": slot},
+      blocking=True,
+    )
+  except Exception as err:
+    raise HomeAssistantError(
+      f"Failed to clear lock usercode on '{entity_id}' slot {slot}: {err}"
+    ) from err

+ 71 - 0
tests/test_config_flow.py

@@ -16,6 +16,8 @@ from custom_components.room_cal_ha.const import (
   CONF_ROOM_NAME,
   CONF_SYNC_MODE,
   CONF_USERNAME,
+  CONF_ZWAVE_LOCKS,
+  CONF_ZWAVE_SLOT,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
   SYNC_MODE_API,
@@ -32,6 +34,15 @@ _EMPTY_DEFAULTS = {
   "door_code": "",
 }
 
+_EMPTY_LOCKS = {CONF_ZWAVE_LOCKS: [], CONF_ZWAVE_SLOT: 1}
+
+
+async def _submit_locks_step(hass: HomeAssistant, flow_id: str, user_input: dict | None = None):
+  """Submit the locks step (last step in options flow) and return the result."""
+  return await hass.config_entries.options.async_configure(
+    flow_id, user_input=user_input if user_input is not None else _EMPTY_LOCKS
+  )
+
 
 # ---------------------------------------------------------------------------
 # Config flow — user step
@@ -79,6 +90,10 @@ async def test_options_sync_mode_manual(hass: HomeAssistant, mock_entry):
     result["flow_id"],
     user_input=_EMPTY_DEFAULTS,
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+  result = await _submit_locks_step(hass, result["flow_id"])
   assert result["type"] == "create_entry"
   assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_MANUAL
 
@@ -135,6 +150,10 @@ async def test_options_caldav_saves_on_success(hass: HomeAssistant, mock_entry):
     result["flow_id"],
     user_input=_EMPTY_DEFAULTS,
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+  result = await _submit_locks_step(hass, result["flow_id"])
   assert result["type"] == "create_entry"
   assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV
   assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com"
@@ -224,6 +243,10 @@ async def test_options_api_saves_on_success(hass: HomeAssistant, mock_entry):
     result["flow_id"],
     user_input=_EMPTY_DEFAULTS,
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+  result = await _submit_locks_step(hass, result["flow_id"])
   assert result["type"] == "create_entry"
   assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_API
   assert result["data"][CONF_API_BASE_URL] == "https://api.example.com"
@@ -295,6 +318,16 @@ async def _reach_defaults_step(hass: HomeAssistant, mock_entry):
   return result
 
 
+async def _reach_locks_step(hass: HomeAssistant, mock_entry):
+  """Navigate to the locks step via manual sync mode + empty defaults."""
+  result = await _reach_defaults_step(hass, mock_entry)
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"], user_input=_EMPTY_DEFAULTS
+  )
+  assert result["step_id"] == "locks"
+  return result
+
+
 async def test_options_defaults_saves_values(hass: HomeAssistant, mock_entry):
   result = await _reach_defaults_step(hass, mock_entry)
 
@@ -309,6 +342,10 @@ async def test_options_defaults_saves_values(hass: HomeAssistant, mock_entry):
       "door_code": "1234",
     },
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+  result = await _submit_locks_step(hass, result["flow_id"])
   assert result["type"] == "create_entry"
   defaults = result["data"][CONF_RESERVATION_DEFAULTS]
   assert defaults["guest_name"] == "Default Guest"
@@ -326,6 +363,10 @@ async def test_options_defaults_empty_strings_become_none(hass: HomeAssistant, m
     result["flow_id"],
     user_input=_EMPTY_DEFAULTS,
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+  result = await _submit_locks_step(hass, result["flow_id"])
   assert result["type"] == "create_entry"
   defaults = result["data"][CONF_RESERVATION_DEFAULTS]
   assert defaults["guest_name"] is None
@@ -342,3 +383,33 @@ async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mo
   )
   assert result["type"] == "form"
   assert "check_in_time" in result["errors"]
+
+
+# ---------------------------------------------------------------------------
+# Options flow — locks step
+# ---------------------------------------------------------------------------
+
+async def test_options_locks_step_shows_form(hass: HomeAssistant, mock_entry):
+  result = await _reach_locks_step(hass, mock_entry)
+  assert result["type"] == "form"
+  assert result["step_id"] == "locks"
+
+
+async def test_options_locks_saves_entities_and_slot(hass: HomeAssistant, mock_entry):
+  result = await _reach_locks_step(hass, mock_entry)
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input={CONF_ZWAVE_LOCKS: ["lock.front_door", "lock.back_door"], CONF_ZWAVE_SLOT: 5},
+  )
+  assert result["type"] == "create_entry"
+  assert result["data"][CONF_ZWAVE_LOCKS] == ["lock.front_door", "lock.back_door"]
+  assert result["data"][CONF_ZWAVE_SLOT] == 5
+
+
+async def test_options_locks_empty_saves_empty_list(hass: HomeAssistant, mock_entry):
+  result = await _reach_locks_step(hass, mock_entry)
+
+  result = await _submit_locks_step(hass, result["flow_id"])
+  assert result["type"] == "create_entry"
+  assert result["data"][CONF_ZWAVE_LOCKS] == []

+ 198 - 0
tests/test_coordinator.py

@@ -13,6 +13,8 @@ from custom_components.room_cal_ha.const import (
   CONF_CALENDAR_NAME,
   CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
+  CONF_ZWAVE_LOCKS,
+  CONF_ZWAVE_SLOT,
   DOMAIN,
   STORE_CURRENT,
   STORE_NEXT,
@@ -845,8 +847,204 @@ async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
   assert not diff.has_changes
 
 
+@pytest.fixture
+async def coordinator_with_locks(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_ZWAVE_LOCKS: ["lock.front_door"], 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("custom_components.room_cal_ha.zwave_lock.async_set_lock_usercode", new_callable=AsyncMock) as mock_set,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_set.assert_awaited_once_with(hass, "lock.front_door", 5, "7890")
+  mock_switch.async_turn_on.assert_awaited_once()
+
+
+async def test_check_in_sets_codes_on_all_locks(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_ZWAVE_LOCKS: ["lock.front", "lock.back"], 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.zwave_lock.async_set_lock_usercode", new_callable=AsyncMock) as mock_set,
+  ):
+    await coord._async_do_check_in()
+  coord.cancel_mode_sync()
+
+  assert mock_set.await_count == 2
+  mock_set.assert_any_await(hass, "lock.front", 5, "7890")
+  mock_set.assert_any_await(hass, "lock.back", 5, "7890")
+
+
+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.zwave_lock.async_set_lock_usercode", new_callable=AsyncMock) as mock_set,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_set.assert_not_awaited()
+
+
+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.zwave_lock.async_set_lock_usercode", new_callable=AsyncMock) as mock_set,
+  ):
+    await coordinator_with_locks._async_do_check_in()
+  coordinator_with_locks.cancel_mode_sync()
+
+  mock_set.assert_not_awaited()
+
+
+async def test_check_in_skips_lock_codes_when_no_locks_configured(hass: HomeAssistant, coordinator: RoomCalCoordinator):
+  # coordinator fixture has no CONF_ZWAVE_LOCKS 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.zwave_lock.async_set_lock_usercode", new_callable=AsyncMock) as mock_set,
+  ):
+    await coordinator._async_do_check_in()
+  coordinator.cancel_mode_sync()
+
+  mock_set.assert_not_awaited()
+
+
+async def test_check_in_lock_error_still_activates_guest_mode(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.zwave_lock.async_set_lock_usercode",
+      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_clears_lock_codes(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.zwave_lock.async_clear_lock_usercode", new_callable=AsyncMock) as mock_clear,
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_clear.assert_awaited_once_with(hass, "lock.front_door", 5)
+  mock_switch.async_turn_off.assert_awaited_once()
+
+
+async def test_check_out_skips_lock_codes_when_use_zwave_off(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.zwave_lock.async_clear_lock_usercode", new_callable=AsyncMock) as mock_clear,
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_clear.assert_not_awaited()
+
+
+async def test_check_out_lock_error_still_deactivates_guest_mode(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.zwave_lock.async_clear_lock_usercode",
+      side_effect=HomeAssistantError("lock offline"),
+    ),
+  ):
+    await coordinator_with_locks._async_do_check_out()
+
+  mock_switch.async_turn_off.assert_awaited_once()

+ 46 - 1
tests/test_switches.py

@@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
-from custom_components.room_cal_ha.switch import RoomStateSwitch, ModeSyncSwitch, OverrideReservationSwitch, UseUnifiSwitch
+from custom_components.room_cal_ha.const import CONF_ZWAVE_LOCKS
+from custom_components.room_cal_ha.switch import RoomStateSwitch, ModeSyncSwitch, OverrideReservationSwitch, UseUnifiSwitch, UseZwaveLockSwitch
 
 
 def _room_state():
@@ -173,3 +174,47 @@ async def test_use_unifi_turn_on_restores_true():
   s._state = False
   await s.async_turn_on()
   assert s.is_on is True
+
+
+# ---------------------------------------------------------------------------
+# UseZwaveLockSwitch
+# ---------------------------------------------------------------------------
+
+def _use_zwave(locks: list | None = None):
+  s = object.__new__(UseZwaveLockSwitch)
+  s._coordinator = MagicMock()
+  s.async_write_ha_state = MagicMock()
+  entry = MagicMock()
+  entry.options = {CONF_ZWAVE_LOCKS: locks} if locks is not None else {}
+  s._entry = entry
+  return s
+
+
+def test_use_zwave_default_state_is_false():
+  s = _use_zwave()
+  assert s.is_on is False
+
+
+def test_use_zwave_available_false_when_no_locks_configured():
+  assert _use_zwave(locks=[]).available is False
+
+
+def test_use_zwave_available_false_when_options_missing():
+  assert _use_zwave().available is False
+
+
+def test_use_zwave_available_true_when_locks_configured():
+  assert _use_zwave(locks=["lock.front_door"]).available is True
+
+
+async def test_use_zwave_turn_on_sets_true():
+  s = _use_zwave(locks=["lock.front_door"])
+  await s.async_turn_on()
+  assert s.is_on is True
+
+
+async def test_use_zwave_turn_off_sets_false():
+  s = _use_zwave(locks=["lock.front_door"])
+  s._state = True
+  await s.async_turn_off()
+  assert s.is_on is False