Kaynağa Gözat

Add Arrived/Departed binary sensors, buttons, persistent flags, and script dispatch

- ArrivedSensor and DepartedSensor binary sensors on the Current device track
  whether the guest has physically arrived or departed (independent of the
  reservation window start/end)
- GuestArrivedButton and GuestDepartedButton set the respective flag and
  optionally call a configured HA script with event_type="arrived"|"departed"
- Flags are persisted in the HA Store and survive restarts; they reset to false
  whenever the current reservation identity (check-in date+time) changes
- New Scripts options flow step (CONF_GUEST_SCRIPT) configures the script entity
  to call on arrived/departed events
- 20 new tests covering sensors, buttons, flag lifecycle, reset conditions,
  persistence, and script dispatch
ZiB 3 ay önce
ebeveyn
işleme
418e168e2a

+ 52 - 5
custom_components/room_cal_ha/binary_sensor.py

@@ -17,6 +17,7 @@ from .const import (
   CONF_SYNC_MODE,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
+  STORE_CURRENT,
   SYNC_MODE_MANUAL,
 )
 from .coordinator import CoordinatorData, RoomCalCoordinator
@@ -30,14 +31,20 @@ async def async_setup_entry(
   async_add_entities: AddEntitiesCallback,
 ) -> None:
   coordinator: RoomCalCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
-  device_name = f"{entry.data.get(CONF_ROOM_NAME, entry.title)} Room State"
-  device_info = DeviceInfo(
+  room_name = entry.data.get(CONF_ROOM_NAME, entry.title)
+  room_state_device = DeviceInfo(
     identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
-    name=device_name,
+    name=f"{room_name} Room State",
+  )
+  current_device = DeviceInfo(
+    identifiers={(DOMAIN, f"{entry.entry_id}_{STORE_CURRENT}")},
+    name=f"{room_name} Current",
   )
   async_add_entities([
-    StaleSensor(coordinator, entry, device_info),
-    UnifiAvailableSensor(coordinator, entry, device_info),
+    StaleSensor(coordinator, entry, room_state_device),
+    UnifiAvailableSensor(coordinator, entry, room_state_device),
+    ArrivedSensor(coordinator, entry, current_device),
+    DepartedSensor(coordinator, entry, current_device),
   ])
 
 
@@ -110,3 +117,43 @@ class UnifiAvailableSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEn
     return False
 
 
+class ArrivedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Arrived"
+  _attr_icon = "mdi:account-check"
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_info: DeviceInfo,
+  ) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_current_arrived"
+    self._attr_device_info = device_info
+
+  @property
+  def is_on(self) -> bool:
+    return self.coordinator.data.current_arrived
+
+
+class DepartedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Departed"
+  _attr_icon = "mdi:account-arrow-left"
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_info: DeviceInfo,
+  ) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_current_departed"
+    self._attr_device_info = device_info
+
+  @property
+  def is_on(self) -> bool:
+    return self.coordinator.data.current_departed
+
+

+ 24 - 1
custom_components/room_cal_ha/button.py

@@ -28,6 +28,7 @@ async def async_setup_entry(
     ClearCurrentButton(coordinator, entry, device_name),
     TestNextGuestButton(coordinator, entry, device_name),
     GuestArrivedButton(coordinator, entry, device_name),
+    GuestDepartedButton(coordinator, entry, device_name),
   ])
 
 
@@ -144,4 +145,26 @@ class GuestArrivedButton(ButtonEntity):
     )
 
   async def async_press(self) -> None:
-    pass
+    await self._coordinator.async_set_arrived(True)
+
+
+class GuestDepartedButton(ButtonEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Guest Departed"
+  _attr_icon = "mdi:account-arrow-left"
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_name: str,
+  ) -> None:
+    self._coordinator = coordinator
+    self._attr_unique_id = f"{entry.entry_id}_guest_departed"
+    self._attr_device_info = DeviceInfo(
+      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+      name=device_name,
+    )
+
+  async def async_press(self) -> None:
+    await self._coordinator.async_set_departed(True)

+ 19 - 1
custom_components/room_cal_ha/config_flow.py

@@ -26,6 +26,7 @@ from .const import (
   CONF_API_TOKEN,
   CONF_CALDAV_URL,
   CONF_CALENDAR_NAME,
+  CONF_GUEST_SCRIPT,
   CONF_PASSWORD,
   CONF_REFRESH_INTERVAL,
   CONF_RESERVATION_DEFAULTS,
@@ -165,7 +166,7 @@ class RoomCalOptionsFlow(OptionsFlow):
   ) -> ConfigFlowResult:
     return self.async_show_menu(
       step_id="configure",
-      menu_options=["sync_mode", "defaults", "locks"],
+      menu_options=["sync_mode", "defaults", "locks", "scripts"],
     )
 
   async def async_step_sync_mode(
@@ -364,6 +365,23 @@ class RoomCalOptionsFlow(OptionsFlow):
     )
 
 
+  async def async_step_scripts(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    if user_input is not None:
+      self._pending_options[CONF_GUEST_SCRIPT] = (user_input.get(CONF_GUEST_SCRIPT) or "").strip() or None
+      return self.async_create_entry(data=self._pending_options)
+
+    self._pending_options = {**self.config_entry.options}
+    opts = self.config_entry.options
+    return self.async_show_form(
+      step_id="scripts",
+      data_schema=vol.Schema({
+        vol.Optional(CONF_GUEST_SCRIPT, default=opts.get(CONF_GUEST_SCRIPT) or ""): str,
+      }),
+    )
+
+
 class _ApiAuthError(Exception):
   pass
 

+ 5 - 0
custom_components/room_cal_ha/const.py

@@ -49,3 +49,8 @@ DEFAULT_REFRESH_INTERVAL = 15
 # Store keys
 STORE_CURRENT = "current"
 STORE_NEXT = "next"
+STORE_ARRIVED = "current_arrived"
+STORE_DEPARTED = "current_departed"
+
+# Script config
+CONF_GUEST_SCRIPT = "guest_script"

+ 83 - 2
custom_components/room_cal_ha/coordinator.py

@@ -13,6 +13,7 @@ from homeassistant.util import dt as dt_util
 
 from .backends import BackendAuthError, BackendConnectError, CalDavBackend, RoomCalApiBackend
 from .const import (
+  CONF_GUEST_SCRIPT,
   CONF_REFRESH_INTERVAL,
   CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
@@ -26,7 +27,7 @@ from .const import (
   SYNC_MODE_CALDAV,
 )
 from .models import ApiStatusData, OverrideData, ReservationData, is_window_active, reservation_diff
-from dataclasses import fields as dataclass_fields
+from dataclasses import fields as dataclass_fields, replace as dataclass_replace
 from .store import ReservationStore
 
 _LOGGER = logging.getLogger(__name__)
@@ -42,6 +43,8 @@ class CoordinatorData:
   next_last_updated: datetime | None = None
   override_active: bool = False
   api_status: ApiStatusData | None = None
+  current_arrived: bool = False
+  current_departed: bool = False
 
 
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
@@ -63,11 +66,15 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self._current_last_updated: datetime | None = None
     self._next_last_updated: datetime | None = None
     self._override: OverrideData | None = None
+    self._current_arrived: bool = False
+    self._current_departed: bool = False
 
   async def async_setup(self) -> None:
     await self.store.async_load()
     self._current_last_updated = self.store.get_last_updated(STORE_CURRENT)
     self._next_last_updated = self.store.get_last_updated(STORE_NEXT)
+    self._current_arrived = self.store.get_arrived()
+    self._current_departed = self.store.get_departed()
 
   def inject_room_state_switch(self, switch: Any) -> None:
     self._room_state_switch = switch
@@ -141,6 +148,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       old_current = self.store.get(STORE_CURRENT)
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_NEXT, backend_next)
+      await self._async_maybe_reset_arrival_flags(old_current, backend_current)
       self._current_last_refresh = now
       self._next_last_refresh = now
       if self._override is None:
@@ -169,6 +177,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next_last_updated=self._next_last_updated,
         override_active=True,
         api_status=api_status,
+        current_arrived=self._current_arrived,
+        current_departed=self._current_departed,
       )
 
     await self._async_note_displayed_change(store_current, store_next, now)
@@ -182,6 +192,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_updated=self._current_last_updated,
       next_last_updated=self._next_last_updated,
       api_status=api_status,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     )
 
   def _reschedule_mode_sync(self, current: ReservationData, nxt: ReservationData) -> None:
@@ -352,18 +364,22 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     _startup=True uses direct state mutation on the room state switch instead of
     async_turn_on/off, because HA state writes are deferred until commit_state().
     """
-    current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+    old_current = self.store.get(STORE_CURRENT)
+    current = old_current or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
 
     if self._is_mode_sync_on():
       if self._reservation_window_state(nxt) in ("active", "elapsed"):
         await self.store.async_set(STORE_CURRENT, nxt)
+        await self._async_maybe_reset_arrival_flags(old_current, nxt)
         self._current_last_refresh = dt_util.utcnow()
         current = nxt
+        old_current = nxt
 
       state = self._reservation_window_state(current)
       if state == "elapsed":
         await self.store.async_set(STORE_CURRENT, None)
+        await self._async_maybe_reset_arrival_flags(old_current, None)
         self._current_last_refresh = dt_util.utcnow()
         current = self._empty_reservation()
 
@@ -382,6 +398,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_updated=self._current_last_updated,
       next_last_updated=self._next_last_updated,
       api_status=self.data.api_status if self.data else None,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     ))
 
   async def async_post_setup_reconcile(self) -> None:
@@ -427,6 +445,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
   async def _async_do_check_out(self) -> None:
     departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, None)
+    await self._async_maybe_reset_arrival_flags(departing, None)
     now = dt_util.utcnow()
     self._current_last_refresh = now
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
@@ -440,6 +459,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_updated=self._current_last_updated,
       next_last_updated=self._next_last_updated,
       api_status=self.data.api_status if self.data else None,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     ))
     await self._async_run_check_out_actions(departing)
     self._reschedule_mode_sync(empty, nxt)
@@ -448,6 +469,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     """Clear the Current slot in the store and notify listeners."""
     departing = self.store.get(STORE_CURRENT)
     await self.store.async_set(STORE_CURRENT, None)
+    await self._async_maybe_reset_arrival_flags(departing, None)
     now = dt_util.utcnow()
     self._current_last_refresh = now
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
@@ -461,6 +483,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_updated=self._current_last_updated,
       next_last_updated=self._next_last_updated,
       api_status=self.data.api_status if self.data else None,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     ))
     if departing is not None:
       await self._async_run_check_out_actions(departing)
@@ -471,6 +495,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     old_current = self.store.get(STORE_CURRENT)
     next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, next_data)
+    await self._async_maybe_reset_arrival_flags(old_current, next_data)
     now = dt_util.utcnow()
     self._current_last_refresh = now
     new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
@@ -483,6 +508,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_updated=self._current_last_updated,
       next_last_updated=self._next_last_updated,
       api_status=self.data.api_status if self.data else None,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     ))
     self._reschedule_mode_sync(next_data, new_next)
     if self._reservation_window_state(next_data) == "active":
@@ -508,6 +535,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next_last_updated=self._next_last_updated,
       override_active=True,
       api_status=self.data.api_status if self.data else None,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
     ))
     await self._async_run_check_in_actions(override.current)
 
@@ -563,6 +592,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next_last_updated=self._next_last_updated,
         override_active=True,
         api_status=self.data.api_status if self.data else None,
+        current_arrived=self._current_arrived,
+        current_departed=self._current_departed,
       ))
       return
 
@@ -590,6 +621,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next_last_updated=self._next_last_updated,
         override_active=True,
         api_status=self.data.api_status if self.data else None,
+        current_arrived=self._current_arrived,
+        current_departed=self._current_departed,
       ))
     else:
       store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
@@ -603,6 +636,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         current_last_updated=self._current_last_updated,
         next_last_updated=self._next_last_updated,
         api_status=self.data.api_status if self.data else None,
+        current_arrived=self._current_arrived,
+        current_departed=self._current_departed,
       ))
       self._reschedule_mode_sync(store_current, store_next)
       if target == STORE_CURRENT:
@@ -659,6 +694,52 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       departing=diff.departing,
     )
 
+  @staticmethod
+  def _arrival_identity(res: ReservationData | None) -> tuple:
+    if res is None:
+      return (None, None)
+    return (res.check_in_date, res.check_in_time)
+
+  async def _async_maybe_reset_arrival_flags(
+    self,
+    old: ReservationData | None,
+    new: ReservationData | None,
+  ) -> None:
+    if self._arrival_identity(old) != self._arrival_identity(new):
+      self._current_arrived = False
+      self._current_departed = False
+      await self.store.async_set_arrived(False)
+      await self.store.async_set_departed(False)
+
+  async def async_set_arrived(self, arrived: bool) -> None:
+    self._current_arrived = arrived
+    await self.store.async_set_arrived(arrived)
+    if arrived:
+      await self._async_run_guest_script("arrived")
+    if self.data is not None:
+      self.async_set_updated_data(dataclass_replace(self.data, current_arrived=arrived))
+
+  async def async_set_departed(self, departed: bool) -> None:
+    self._current_departed = departed
+    await self.store.async_set_departed(departed)
+    if departed:
+      await self._async_run_guest_script("departed")
+    if self.data is not None:
+      self.async_set_updated_data(dataclass_replace(self.data, current_departed=departed))
+
+  async def _async_run_guest_script(self, event_type: str) -> None:
+    script_id = self.entry.options.get(CONF_GUEST_SCRIPT)
+    if not script_id:
+      return
+    try:
+      await self.hass.services.async_call(
+        "script", "turn_on",
+        {"entity_id": script_id, "variables": {"event_type": event_type}},
+        blocking=False,
+      )
+    except Exception as err:
+      _LOGGER.warning("Failed to run guest script '%s': %s", script_id, err)
+
   def cancel_mode_sync(self) -> None:
     for cancel in self._mode_sync_handles:
       cancel()

+ 21 - 2
custom_components/room_cal_ha/store.py

@@ -6,7 +6,7 @@ from typing import Any
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.storage import Store
 
-from .const import DOMAIN, STORE_CURRENT, STORE_NEXT
+from .const import DOMAIN, STORE_ARRIVED, STORE_CURRENT, STORE_DEPARTED, STORE_NEXT
 from .models import ReservationData
 
 STORAGE_VERSION = 1
@@ -21,7 +21,12 @@ class ReservationStore:
       STORAGE_VERSION,
       f"{DOMAIN}.{entry_id}",
     )
-    self._data: dict[str, Any] = {STORE_CURRENT: None, STORE_NEXT: None}
+    self._data: dict[str, Any] = {
+      STORE_CURRENT: None,
+      STORE_NEXT: None,
+      STORE_ARRIVED: False,
+      STORE_DEPARTED: False,
+    }
 
   async def async_load(self) -> None:
     loaded = await self._store.async_load()
@@ -50,3 +55,17 @@ class ReservationStore:
   async def async_set_last_updated(self, key: str, ts: datetime) -> None:
     self._data[f"{key}_last_updated"] = ts.isoformat()
     await self.async_save()
+
+  def get_arrived(self) -> bool:
+    return bool(self._data.get(STORE_ARRIVED, False))
+
+  async def async_set_arrived(self, value: bool) -> None:
+    self._data[STORE_ARRIVED] = value
+    await self.async_save()
+
+  def get_departed(self) -> bool:
+    return bool(self._data.get(STORE_DEPARTED, False))
+
+  async def async_set_departed(self, value: bool) -> None:
+    self._data[STORE_DEPARTED] = value
+    await self.async_save()

+ 9 - 1
custom_components/room_cal_ha/strings.json

@@ -38,7 +38,8 @@
         "menu_options": {
           "sync_mode": "Sync Mode & Connection",
           "defaults": "Reservation Defaults",
-          "locks": "zLock Slots"
+          "locks": "zLock Slots",
+          "scripts": "Scripts"
         }
       },
       "sync_mode": {
@@ -84,6 +85,13 @@
           "zlock_entries": "zLock Slots Integrations",
           "zwave_slot": "Code Slot"
         }
+      },
+      "scripts": {
+        "title": "Scripts",
+        "description": "Configure a script to call when a guest arrives or departs. The script is called with a variable 'event_type' set to 'arrived' or 'departed'. Leave blank to disable.",
+        "data": {
+          "guest_script": "Guest Event Script"
+        }
       }
     },
     "error": {

+ 9 - 1
custom_components/room_cal_ha/translations/en.json

@@ -38,7 +38,8 @@
         "menu_options": {
           "sync_mode": "Sync Mode & Connection",
           "defaults": "Reservation Defaults",
-          "locks": "zLock Slots"
+          "locks": "zLock Slots",
+          "scripts": "Scripts"
         }
       },
       "sync_mode": {
@@ -84,6 +85,13 @@
           "zlock_entries": "zLock Slots Integrations",
           "zwave_slot": "Code Slot"
         }
+      },
+      "scripts": {
+        "title": "Scripts",
+        "description": "Configure a script to call when a guest arrives or departs. The script is called with a variable 'event_type' set to 'arrived' or 'departed'. Leave blank to disable.",
+        "data": {
+          "guest_script": "Guest Event Script"
+        }
       }
     },
     "error": {

+ 298 - 0
tests/test_arrived_departed.py

@@ -0,0 +1,298 @@
+"""Tests for Arrived/Departed binary sensors, Guest Arrived/Departed buttons,
+and coordinator arrived/departed flag lifecycle.
+
+Uses object.__new__ for pure-unit tests and hass fixtures for coordinator integration.
+"""
+from datetime import date, time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.core import HomeAssistant
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.room_cal_ha.binary_sensor import ArrivedSensor, DepartedSensor
+from custom_components.room_cal_ha.button import GuestArrivedButton, GuestDepartedButton
+from custom_components.room_cal_ha.const import (
+  CONF_GUEST_SCRIPT,
+  CONF_SYNC_MODE,
+  DOMAIN,
+  STORE_CURRENT,
+  STORE_NEXT,
+  SYNC_MODE_CALDAV,
+  SYNC_MODE_MANUAL,
+)
+from custom_components.room_cal_ha.coordinator import CoordinatorData, RoomCalCoordinator
+from custom_components.room_cal_ha.models import ReservationData
+
+
+_ALICE = ReservationData(
+  guest_name="Alice",
+  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),
+)
+
+_BOB = ReservationData(
+  guest_name="Bob",
+  check_in_date=date(2026, 7, 6),
+  check_in_time=time(15, 0),
+  check_out_date=date(2026, 7, 10),
+  check_out_time=time(11, 0),
+)
+
+_BASE_DATA = {"room_state_name": "Room State"}
+
+
+def _make_entry(hass, options: dict) -> MockConfigEntry:
+  entry = MockConfigEntry(domain=DOMAIN, data=_BASE_DATA, options=options)
+  entry.add_to_hass(hass)
+  return entry
+
+
+# ---------------------------------------------------------------------------
+# ArrivedSensor / DepartedSensor unit tests
+# ---------------------------------------------------------------------------
+
+def _data(arrived=False, departed=False) -> CoordinatorData:
+  return CoordinatorData(
+    current=ReservationData(),
+    next=ReservationData(),
+    current_last_refresh=None,
+    next_last_refresh=None,
+    current_arrived=arrived,
+    current_departed=departed,
+  )
+
+
+def _arrived_sensor(arrived=False) -> ArrivedSensor:
+  s = object.__new__(ArrivedSensor)
+  s.coordinator = MagicMock()
+  s.coordinator.data = _data(arrived=arrived)
+  return s
+
+
+def _departed_sensor(departed=False) -> DepartedSensor:
+  s = object.__new__(DepartedSensor)
+  s.coordinator = MagicMock()
+  s.coordinator.data = _data(departed=departed)
+  return s
+
+
+def test_arrived_sensor_default_false():
+  assert _arrived_sensor(arrived=False).is_on is False
+
+
+def test_arrived_sensor_true_when_set():
+  assert _arrived_sensor(arrived=True).is_on is True
+
+
+def test_departed_sensor_default_false():
+  assert _departed_sensor(departed=False).is_on is False
+
+
+def test_departed_sensor_true_when_set():
+  assert _departed_sensor(departed=True).is_on is True
+
+
+# ---------------------------------------------------------------------------
+# GuestArrivedButton / GuestDepartedButton unit tests
+# ---------------------------------------------------------------------------
+
+def _arrived_button() -> GuestArrivedButton:
+  btn = object.__new__(GuestArrivedButton)
+  btn._coordinator = MagicMock()
+  btn._coordinator.async_set_arrived = AsyncMock()
+  return btn
+
+
+def _departed_button() -> GuestDepartedButton:
+  btn = object.__new__(GuestDepartedButton)
+  btn._coordinator = MagicMock()
+  btn._coordinator.async_set_departed = AsyncMock()
+  return btn
+
+
+async def test_arrived_button_calls_set_arrived_true():
+  btn = _arrived_button()
+  await btn.async_press()
+  btn._coordinator.async_set_arrived.assert_awaited_once_with(True)
+
+
+async def test_departed_button_calls_set_departed_true():
+  btn = _departed_button()
+  await btn.async_press()
+  btn._coordinator.async_set_departed.assert_awaited_once_with(True)
+
+
+# ---------------------------------------------------------------------------
+# Coordinator: async_set_arrived / async_set_departed
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+async def coord(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_MANUAL})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+  await c.async_refresh()
+  return c
+
+
+async def test_set_arrived_sets_flag(coord: RoomCalCoordinator):
+  await coord.async_set_arrived(True)
+  assert coord._current_arrived is True
+  assert coord.data.current_arrived is True
+
+
+async def test_set_arrived_persists_to_store(coord: RoomCalCoordinator):
+  await coord.async_set_arrived(True)
+  assert coord.store.get_arrived() is True
+
+
+async def test_set_departed_sets_flag(coord: RoomCalCoordinator):
+  await coord.async_set_departed(True)
+  assert coord._current_departed is True
+  assert coord.data.current_departed is True
+
+
+async def test_set_departed_persists_to_store(coord: RoomCalCoordinator):
+  await coord.async_set_departed(True)
+  assert coord.store.get_departed() is True
+
+
+async def test_flags_load_from_store_on_startup(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_MANUAL})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+  await c.store.async_set_arrived(True)
+  await c.store.async_set_departed(True)
+
+  c2 = RoomCalCoordinator(hass, entry)
+  await c2.async_setup()
+  assert c2._current_arrived is True
+  assert c2._current_departed is True
+
+
+# ---------------------------------------------------------------------------
+# Coordinator: flag reset on reservation identity change
+# ---------------------------------------------------------------------------
+
+async def test_flags_reset_when_reservation_changes(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, "caldav_url": "https://x", "calendar_name": "R"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+
+  # Load Alice as current
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(_ALICE, None))
+    await c.async_refresh()
+
+  await c.async_set_arrived(True)
+  assert c._current_arrived is True
+
+  # Bob becomes current — different check-in identity
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(_BOB, None))
+    await c.async_refresh()
+
+  assert c._current_arrived is False
+  assert c.data.current_arrived is False
+  assert c.store.get_arrived() is False
+
+
+async def test_flags_not_reset_when_same_reservation(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, "caldav_url": "https://x", "calendar_name": "R"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(_ALICE, None))
+    await c.async_refresh()
+
+  await c.async_set_arrived(True)
+
+  # Same Alice again — no identity change
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(_ALICE, None))
+    await c.async_refresh()
+
+  assert c._current_arrived is True
+  assert c.data.current_arrived is True
+
+
+async def test_flags_reset_on_promote_from_next(coord: RoomCalCoordinator):
+  await coord.store.async_set(STORE_CURRENT, _ALICE)
+  await coord.store.async_set(STORE_NEXT, _BOB)
+  await coord.async_refresh()
+  await coord.async_set_arrived(True)
+
+  await coord.async_promote_next_to_current()
+
+  assert coord._current_arrived is False
+  assert coord.data.current_arrived is False
+
+
+async def test_flags_reset_on_clear_current(coord: RoomCalCoordinator):
+  await coord.store.async_set(STORE_CURRENT, _ALICE)
+  await coord.async_refresh()
+  await coord.async_set_arrived(True)
+
+  await coord.async_clear_current()
+
+  assert coord._current_arrived is False
+  assert coord.data.current_arrived is False
+
+
+async def test_flags_reset_on_checkout(coord: RoomCalCoordinator):
+  await coord.store.async_set(STORE_CURRENT, _ALICE)
+  await coord.async_refresh()
+  await coord.async_set_arrived(True)
+
+  await coord._async_do_check_out()
+
+  assert coord._current_arrived is False
+  assert coord.data.current_arrived is False
+
+
+# ---------------------------------------------------------------------------
+# Script dispatch
+# ---------------------------------------------------------------------------
+
+async def test_set_arrived_dispatches_script(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_GUEST_SCRIPT: "script.guest_event"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+  await c.async_refresh()
+
+  with patch.object(c, "_async_run_guest_script", new=AsyncMock()) as mock_script:
+    await c.async_set_arrived(True)
+
+  mock_script.assert_awaited_once_with("arrived")
+
+
+async def test_set_departed_dispatches_script(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_GUEST_SCRIPT: "script.guest_event"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+  await c.async_refresh()
+
+  with patch.object(c, "_async_run_guest_script", new=AsyncMock()) as mock_script:
+    await c.async_set_departed(True)
+
+  mock_script.assert_awaited_once_with("departed")
+
+
+async def test_no_script_dispatched_when_setting_false(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_MANUAL, CONF_GUEST_SCRIPT: "script.guest_event"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+  await c.async_refresh()
+
+  with patch.object(c, "_async_run_guest_script", new=AsyncMock()) as mock_script:
+    await c.async_set_arrived(False)
+  mock_script.assert_not_awaited()
+
+
+async def test_async_run_guest_script_no_op_when_not_configured(coord: RoomCalCoordinator):
+  # coord has no CONF_GUEST_SCRIPT — should return early with no error
+  await coord._async_run_guest_script("arrived")