فهرست منبع

Add last_updated sensor; demote last_refresh to attribute

Replaces LastRefreshSensor with LastUpdatedSensor. The new sensor tracks
when the *displayed* reservation state actually changed, not just when the
backend was polled. This gives battery-powered consumers a stable signal
to compare against: wake up, check last_updated, sleep again if unchanged.

last_refresh (poll timestamp) is preserved as an extra_state_attribute on
the new sensor for human troubleshooting but no longer drives HA state
changes.

Override start and override end both update last_updated because the
applied state changes at those boundaries. Backend fetch, thin updates,
promote-next, and reconcile all update last_updated only when content
actually differs. Timestamps are persisted in the store so no false
"changed now" fires on HA restart when data is unchanged.
ZiB 4 ماه پیش
والد
کامیت
9c707f8da4

+ 81 - 9
custom_components/room_cal_ha/coordinator.py

@@ -38,6 +38,8 @@ class CoordinatorData:
   next: ReservationData
   next: ReservationData
   current_last_refresh: datetime | None
   current_last_refresh: datetime | None
   next_last_refresh: datetime | None
   next_last_refresh: datetime | None
+  current_last_updated: datetime | None = None
+  next_last_updated: datetime | None = None
   override_active: bool = False
   override_active: bool = False
   api_status: ApiStatusData | None = None
   api_status: ApiStatusData | None = None
 
 
@@ -58,14 +60,45 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self._room_state_switch: Any = None  # injected by switch.py after platform setup
     self._room_state_switch: Any = None  # injected by switch.py after platform setup
     self._current_last_refresh: datetime | None = None
     self._current_last_refresh: datetime | None = None
     self._next_last_refresh: datetime | None = None
     self._next_last_refresh: datetime | None = None
+    self._current_last_updated: datetime | None = None
+    self._next_last_updated: datetime | None = None
     self._override: OverrideData | None = None
     self._override: OverrideData | None = None
 
 
   async def async_setup(self) -> None:
   async def async_setup(self) -> None:
     await self.store.async_load()
     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)
 
 
   def inject_room_state_switch(self, switch: Any) -> None:
   def inject_room_state_switch(self, switch: Any) -> None:
     self._room_state_switch = switch
     self._room_state_switch = switch
 
 
+  async def _async_note_displayed_change(
+    self,
+    new_current: ReservationData,
+    new_next: ReservationData,
+    now: datetime,
+  ) -> None:
+    """Update last_updated timestamps when the displayed (applied) state changes.
+
+    On first call (self.data is None), uses _empty_reservation() as the baseline
+    unless a persisted timestamp already exists — in that case the persisted value
+    is trusted and no update is issued, avoiding a false 'changed now' on startup.
+    """
+    empty = self._empty_reservation()
+    if self.data is not None:
+      old_current: ReservationData = self.data.current
+      old_next: ReservationData = self.data.next
+    else:
+      old_current = empty if self._current_last_updated is None else new_current
+      old_next = empty if self._next_last_updated is None else new_next
+
+    if old_current != new_current:
+      self._current_last_updated = now
+      await self.store.async_set_last_updated(STORE_CURRENT, now)
+    if old_next != new_next:
+      self._next_last_updated = now
+      await self.store.async_set_last_updated(STORE_NEXT, now)
+
   async def _async_update_data(self) -> CoordinatorData:
   async def _async_update_data(self) -> CoordinatorData:
     backend_current: ReservationData | None = None
     backend_current: ReservationData | None = None
     backend_next: ReservationData | None = None
     backend_next: ReservationData | None = None
@@ -122,18 +155,23 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     store_next = self._with_defaults(self.store.get(STORE_NEXT))
     store_next = self._with_defaults(self.store.get(STORE_NEXT))
 
 
     if self._override is not None:
     if self._override is not None:
+      new_current = self._with_defaults(self._override.current)
       displayed_next = (
       displayed_next = (
         self._with_defaults(self._override.next) if self._override.next is not None else store_next
         self._with_defaults(self._override.next) if self._override.next is not None else store_next
       )
       )
+      await self._async_note_displayed_change(new_current, displayed_next, now)
       return CoordinatorData(
       return CoordinatorData(
-        current=self._with_defaults(self._override.current),
+        current=new_current,
         next=displayed_next,
         next=displayed_next,
         current_last_refresh=self._current_last_refresh,
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         next_last_refresh=self._next_last_refresh,
+        current_last_updated=self._current_last_updated,
+        next_last_updated=self._next_last_updated,
         override_active=True,
         override_active=True,
         api_status=api_status,
         api_status=api_status,
       )
       )
 
 
+    await self._async_note_displayed_change(store_current, store_next, now)
     self._reschedule_mode_sync(store_current, store_next)
     self._reschedule_mode_sync(store_current, store_next)
 
 
     return CoordinatorData(
     return CoordinatorData(
@@ -141,6 +179,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=store_next,
       next=store_next,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      current_last_updated=self._current_last_updated,
+      next_last_updated=self._next_last_updated,
       api_status=api_status,
       api_status=api_status,
     )
     )
 
 
@@ -323,12 +363,16 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
 
     real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
     real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
     real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
     real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+    now = dt_util.utcnow()
+    await self._async_note_displayed_change(real_current, real_next, now)
     self._reschedule_mode_sync(real_current, real_next)
     self._reschedule_mode_sync(real_current, real_next)
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
       current=real_current,
       current=real_current,
       next=real_next,
       next=real_next,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      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,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
 
 
@@ -375,47 +419,61 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
   async def _async_do_check_out(self) -> None:
   async def _async_do_check_out(self) -> None:
     departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
     departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, None)
     await self.store.async_set(STORE_CURRENT, None)
-    self._current_last_refresh = dt_util.utcnow()
+    now = dt_util.utcnow()
+    self._current_last_refresh = now
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
+    empty = self._empty_reservation()
+    await self._async_note_displayed_change(empty, nxt, now)
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
-      current=self._empty_reservation(),
+      current=empty,
       next=nxt,
       next=nxt,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      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,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
     await self._async_run_check_out_actions(departing)
     await self._async_run_check_out_actions(departing)
-    self._reschedule_mode_sync(self._empty_reservation(), nxt)
+    self._reschedule_mode_sync(empty, nxt)
 
 
   async def async_clear_current(self) -> None:
   async def async_clear_current(self) -> None:
     """Clear the Current slot in the store and notify listeners."""
     """Clear the Current slot in the store and notify listeners."""
     departing = self.store.get(STORE_CURRENT)
     departing = self.store.get(STORE_CURRENT)
     await self.store.async_set(STORE_CURRENT, None)
     await self.store.async_set(STORE_CURRENT, None)
-    self._current_last_refresh = dt_util.utcnow()
+    now = dt_util.utcnow()
+    self._current_last_refresh = now
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
+    empty = self._empty_reservation()
+    await self._async_note_displayed_change(empty, nxt, now)
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
-      current=self._empty_reservation(),
+      current=empty,
       next=nxt,
       next=nxt,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      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,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
     if departing is not None:
     if departing is not None:
       await self._async_run_check_out_actions(departing)
       await self._async_run_check_out_actions(departing)
-    self._reschedule_mode_sync(self._empty_reservation(), nxt)
+    self._reschedule_mode_sync(empty, nxt)
 
 
   async def async_promote_next_to_current(self) -> None:
   async def async_promote_next_to_current(self) -> None:
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
     old_current = self.store.get(STORE_CURRENT)
     old_current = self.store.get(STORE_CURRENT)
     next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, next_data)
     await self.store.async_set(STORE_CURRENT, next_data)
-    self._current_last_refresh = dt_util.utcnow()
+    now = dt_util.utcnow()
+    self._current_last_refresh = now
     new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
     new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+    await self._async_note_displayed_change(next_data, new_next, now)
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
       current=next_data,
       current=next_data,
       next=new_next,
       next=new_next,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      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,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
     self._reschedule_mode_sync(next_data, new_next)
     self._reschedule_mode_sync(next_data, new_next)
@@ -426,15 +484,20 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
     """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
     self._override = override
     self._override = override
     self.cancel_mode_sync()
     self.cancel_mode_sync()
+    new_current = self._with_defaults(override.current)
     displayed_next = (
     displayed_next = (
       self._with_defaults(override.next) if override.next is not None
       self._with_defaults(override.next) if override.next is not None
       else self._with_defaults(self.store.get(STORE_NEXT) or self._empty_reservation())
       else self._with_defaults(self.store.get(STORE_NEXT) or self._empty_reservation())
     )
     )
+    now = dt_util.utcnow()
+    await self._async_note_displayed_change(new_current, displayed_next, now)
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
-      current=self._with_defaults(override.current),
+      current=new_current,
       next=displayed_next,
       next=displayed_next,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      current_last_updated=self._current_last_updated,
+      next_last_updated=self._next_last_updated,
       override_active=True,
       override_active=True,
       api_status=self.data.api_status if self.data else None,
       api_status=self.data.api_status if self.data else None,
     ))
     ))
@@ -482,11 +545,14 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         self._override.next if self._override.next is not None
         self._override.next if self._override.next is not None
         else self.store.get(STORE_NEXT) or self._empty_reservation()
         else self.store.get(STORE_NEXT) or self._empty_reservation()
       )
       )
+      await self._async_note_displayed_change(self._override.current, displayed_next, now)
       self.async_set_updated_data(CoordinatorData(
       self.async_set_updated_data(CoordinatorData(
         current=self._override.current,
         current=self._override.current,
         next=displayed_next,
         next=displayed_next,
         current_last_refresh=self._current_last_refresh,
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         next_last_refresh=self._next_last_refresh,
+        current_last_updated=self._current_last_updated,
+        next_last_updated=self._next_last_updated,
         override_active=True,
         override_active=True,
         api_status=self.data.api_status if self.data else None,
         api_status=self.data.api_status if self.data else None,
       ))
       ))
@@ -506,22 +572,28 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         self._override.next if self._override.next is not None
         self._override.next if self._override.next is not None
         else self.store.get(STORE_NEXT) or self._empty_reservation()
         else self.store.get(STORE_NEXT) or self._empty_reservation()
       )
       )
+      await self._async_note_displayed_change(self._override.current, displayed_next, now)
       self.async_set_updated_data(CoordinatorData(
       self.async_set_updated_data(CoordinatorData(
         current=self._override.current,
         current=self._override.current,
         next=displayed_next,
         next=displayed_next,
         current_last_refresh=self._current_last_refresh,
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         next_last_refresh=self._next_last_refresh,
+        current_last_updated=self._current_last_updated,
+        next_last_updated=self._next_last_updated,
         override_active=True,
         override_active=True,
         api_status=self.data.api_status if self.data else None,
         api_status=self.data.api_status if self.data else None,
       ))
       ))
     else:
     else:
       store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
       store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
       store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
       store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+      await self._async_note_displayed_change(store_current, store_next, now)
       self.async_set_updated_data(CoordinatorData(
       self.async_set_updated_data(CoordinatorData(
         current=store_current,
         current=store_current,
         next=store_next,
         next=store_next,
         current_last_refresh=self._current_last_refresh,
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         next_last_refresh=self._next_last_refresh,
+        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,
         api_status=self.data.api_status if self.data else None,
       ))
       ))
       self._reschedule_mode_sync(store_current, store_next)
       self._reschedule_mode_sync(store_current, store_next)

+ 15 - 5
custom_components/room_cal_ha/sensor.py

@@ -65,7 +65,7 @@ async def async_setup_entry(
       ReservationStrSensor(coordinator, entry, slot, device_info, "WiFi SSID", "wifi_ssid", "mdi:wifi"),
       ReservationStrSensor(coordinator, entry, slot, device_info, "WiFi SSID", "wifi_ssid", "mdi:wifi"),
       WiFiKeySensor(coordinator, entry, slot, device_info),
       WiFiKeySensor(coordinator, entry, slot, device_info),
       ReservationStrSensor(coordinator, entry, slot, device_info, "Door Code", "door_code", "mdi:door-closed-lock"),
       ReservationStrSensor(coordinator, entry, slot, device_info, "Door Code", "door_code", "mdi:door-closed-lock"),
-      LastRefreshSensor(coordinator, entry, slot, device_info),
+      LastUpdatedSensor(coordinator, entry, slot, device_info),
     ]
     ]
 
 
   async_add_entities(entities)
   async_add_entities(entities)
@@ -227,11 +227,11 @@ class ReservationDateTimeSensor(_ReservationSensor):
     return _dt_attrs(datetime.combine(d, t))
     return _dt_attrs(datetime.combine(d, t))
 
 
 
 
-class LastRefreshSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
+class LastUpdatedSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
   _attr_has_entity_name = True
   _attr_has_entity_name = True
-  _attr_name = "Last Refresh"
+  _attr_name = "Last Updated"
   _attr_device_class = SensorDeviceClass.TIMESTAMP
   _attr_device_class = SensorDeviceClass.TIMESTAMP
-  _attr_icon = "mdi:timer-sync"
+  _attr_icon = "mdi:clock-edit-outline"
 
 
   def __init__(
   def __init__(
     self,
     self,
@@ -242,17 +242,27 @@ class LastRefreshSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
   ) -> None:
   ) -> None:
     super().__init__(coordinator)
     super().__init__(coordinator)
     self._slot = slot
     self._slot = slot
-    self._attr_unique_id = f"{entry.entry_id}_{slot}_last_refresh"
+    self._attr_unique_id = f"{entry.entry_id}_{slot}_last_updated"
     self._attr_device_info = device_info
     self._attr_device_info = device_info
 
 
   @property
   @property
   def native_value(self) -> datetime | None:
   def native_value(self) -> datetime | None:
     data: CoordinatorData = self.coordinator.data
     data: CoordinatorData = self.coordinator.data
     return (
     return (
+      data.current_last_updated
+      if self._slot == STORE_CURRENT
+      else data.next_last_updated
+    )
+
+  @property
+  def extra_state_attributes(self) -> dict:
+    data: CoordinatorData = self.coordinator.data
+    refresh = (
       data.current_last_refresh
       data.current_last_refresh
       if self._slot == STORE_CURRENT
       if self._slot == STORE_CURRENT
       else data.next_last_refresh
       else data.next_last_refresh
     )
     )
+    return {"last_refresh": refresh}
 
 
 
 
 class ApiHealthSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
 class ApiHealthSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):

+ 11 - 0
custom_components/room_cal_ha/store.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+from datetime import datetime
 from typing import Any
 from typing import Any
 
 
 from homeassistant.core import HomeAssistant
 from homeassistant.core import HomeAssistant
@@ -36,6 +37,16 @@ class ReservationStore:
       return None
       return None
     return ReservationData.from_dict(raw)
     return ReservationData.from_dict(raw)
 
 
+  def get_last_updated(self, key: str) -> datetime | None:
+    raw = self._data.get(f"{key}_last_updated")
+    if raw is None:
+      return None
+    return datetime.fromisoformat(raw)
+
   async def async_set(self, key: str, data: ReservationData | None) -> None:
   async def async_set(self, key: str, data: ReservationData | None) -> None:
     self._data[key] = data.to_dict() if data is not None else None
     self._data[key] = data.to_dict() if data is not None else None
     await self.async_save()
     await self.async_save()
+
+  async def async_set_last_updated(self, key: str, ts: datetime) -> None:
+    self._data[f"{key}_last_updated"] = ts.isoformat()
+    await self.async_save()

+ 92 - 0
tests/test_coordinator.py

@@ -206,6 +206,98 @@ async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator)
   assert coordinator.data.current_last_refresh is not None
   assert coordinator.data.current_last_refresh is not None
 
 
 
 
+# ---------------------------------------------------------------------------
+# last_updated
+# ---------------------------------------------------------------------------
+
+async def test_last_updated_set_when_backend_data_changes(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+
+  next_sample = ReservationData(guest_name="Next")
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
+    MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, next_sample))
+    await coord.async_refresh()
+
+  assert coord.data.current_last_updated is not None
+  assert coord.data.next_last_updated is not None
+
+
+async def test_last_updated_not_set_when_backend_data_unchanged(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  await coord.store.async_set(STORE_CURRENT, SAMPLE)
+
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
+    MockBackend.return_value.async_fetch = AsyncMock(return_value=(SAMPLE, None))
+    # First refresh populates self.data
+    await coord.async_refresh()
+    ts_after_first = coord.data.current_last_updated
+    # Second refresh returns same data — last_updated must not change
+    await coord.async_refresh()
+
+  assert coord.data.current_last_updated == ts_after_first
+
+
+async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  await coordinator.async_refresh()
+
+  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+  assert coordinator.data.current_last_updated is not None
+
+
+async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  await coordinator.async_refresh()
+
+  # First update sets last_updated
+  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+  ts = coordinator.data.current_last_updated
+
+  # Second update with the same value must not advance last_updated
+  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+  assert coordinator.data.current_last_updated == ts
+
+
+async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator):
+  await coordinator.async_refresh()
+  coordinator._room_state_switch = None
+  assert coordinator.data.current_last_updated is None
+
+  await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
+  assert coordinator.data.current_last_updated is not None
+
+
+async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator):
+  coordinator._room_state_switch = None
+  await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
+  ts_start = coordinator.data.current_last_updated
+
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  coordinator._override = OverrideData(current=ReservationData(guest_name="Override"))
+  await coordinator.async_end_override()
+
+  assert coordinator.data.current_last_updated != ts_start
+
+
+async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry):
+  mock_entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, mock_entry)
+  await coord.async_setup()
+  await coord.store.async_set(STORE_CURRENT, SAMPLE)
+  await coord.store.async_set_last_updated(STORE_CURRENT, datetime(2026, 1, 1, 12, 0, tzinfo=None))
+
+  coord2 = RoomCalCoordinator(hass, mock_entry)
+  await coord2.async_setup()
+
+  loaded = coord2.store.get_last_updated(STORE_CURRENT)
+  assert loaded is not None
+  assert loaded.year == 2026
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Store persistence
 # Store persistence
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 30 - 10
tests/test_sensors.py

@@ -13,7 +13,7 @@ from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
 from custom_components.room_cal_ha.sensor import (
 from custom_components.room_cal_ha.sensor import (
   ApiHealthSensor,
   ApiHealthSensor,
   GuestNameSensor,
   GuestNameSensor,
-  LastRefreshSensor,
+  LastUpdatedSensor,
   ReservationDateTimeSensor,
   ReservationDateTimeSensor,
   ReservationStrSensor,
   ReservationStrSensor,
   ReservationTimeSensor,
   ReservationTimeSensor,
@@ -31,6 +31,8 @@ def _coordinator(
   nxt=None,
   nxt=None,
   current_ts=None,
   current_ts=None,
   next_ts=None,
   next_ts=None,
+  current_updated=None,
+  next_updated=None,
   api_status=None,
   api_status=None,
 ):
 ):
   coord = MagicMock()
   coord = MagicMock()
@@ -39,6 +41,8 @@ def _coordinator(
     next=nxt or ReservationData(),
     next=nxt or ReservationData(),
     current_last_refresh=current_ts,
     current_last_refresh=current_ts,
     next_last_refresh=next_ts,
     next_last_refresh=next_ts,
+    current_last_updated=current_updated,
+    next_last_updated=next_updated,
     api_status=api_status,
     api_status=api_status,
   )
   )
   return coord
   return coord
@@ -220,32 +224,48 @@ def test_wifi_key_sensor_no_attrs_when_key_missing():
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
-# LastRefreshSensor
+# LastUpdatedSensor
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
-def test_last_refresh_sensor_current():
+def test_last_updated_sensor_current():
   ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
   ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
-  coord = _coordinator(current_ts=ts)
-  s = _sensor(LastRefreshSensor, coord)
+  coord = _coordinator(current_updated=ts)
+  s = _sensor(LastUpdatedSensor, coord)
   s._slot = STORE_CURRENT
   s._slot = STORE_CURRENT
   assert s.native_value == ts
   assert s.native_value == ts
 
 
 
 
-def test_last_refresh_sensor_next():
+def test_last_updated_sensor_next():
   ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
   ts = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
-  coord = _coordinator(next_ts=ts)
-  s = _sensor(LastRefreshSensor, coord)
+  coord = _coordinator(next_updated=ts)
+  s = _sensor(LastUpdatedSensor, coord)
   s._slot = STORE_NEXT
   s._slot = STORE_NEXT
   assert s.native_value == ts
   assert s.native_value == ts
 
 
 
 
-def test_last_refresh_sensor_none_before_any_fetch():
+def test_last_updated_sensor_none_before_any_change():
   coord = _coordinator()
   coord = _coordinator()
-  s = _sensor(LastRefreshSensor, coord)
+  s = _sensor(LastUpdatedSensor, coord)
   s._slot = STORE_CURRENT
   s._slot = STORE_CURRENT
   assert s.native_value is None
   assert s.native_value is None
 
 
 
 
+def test_last_updated_sensor_exposes_last_refresh_as_attribute():
+  refresh_ts = datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc)
+  updated_ts = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc)
+  coord = _coordinator(current_ts=refresh_ts, current_updated=updated_ts)
+  s = _sensor(LastUpdatedSensor, coord)
+  s._slot = STORE_CURRENT
+  assert s.extra_state_attributes["last_refresh"] == refresh_ts
+
+
+def test_last_updated_sensor_last_refresh_attribute_none_when_not_yet_fetched():
+  coord = _coordinator()
+  s = _sensor(LastUpdatedSensor, coord)
+  s._slot = STORE_CURRENT
+  assert s.extra_state_attributes["last_refresh"] is None
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # ApiHealthSensor
 # ApiHealthSensor
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------