Procházet zdrojové kódy

Add configurable reservation defaults via options flow

Defaults for guest_name, check_in_time, check_out_time, wifi_ssid,
wifi_key, and door_code are now stored in entry.options and applied
by the coordinator when a slot has no stored data. ReservationData
dataclass fields are all-None; the options flow gains a final
defaults step across all sync modes.
ZiB před 4 měsíci
rodič
revize
58d8faff39

+ 52 - 6
custom_components/room_cal_ha/config_flow.py

@@ -28,6 +28,7 @@ from .const import (
   CONF_CALENDAR_NAME,
   CONF_PASSWORD,
   CONF_REFRESH_INTERVAL,
+  CONF_RESERVATION_DEFAULTS,
   CONF_ROOM_NAME,
   CONF_SYNC_MODE,
   CONF_USERNAME,
@@ -142,10 +143,15 @@ class RoomCalConfigFlow(ConfigFlow, domain=DOMAIN):
     return RoomCalOptionsFlow()
 
 
+_DEFAULTS_STRING_KEYS = ["guest_name", "wifi_ssid", "wifi_key", "door_code"]
+_DEFAULTS_TIME_KEYS = ["check_in_time", "check_out_time"]
+
+
 class RoomCalOptionsFlow(OptionsFlow):
 
   def __init__(self) -> None:
     self._selected_mode: str = SYNC_MODE_MANUAL
+    self._pending_options: dict[str, Any] = {}
 
   async def async_step_init(
     self, user_input: dict[str, Any] | None = None
@@ -161,8 +167,8 @@ class RoomCalOptionsFlow(OptionsFlow):
         return await self.async_step_caldav()
       if self._selected_mode == SYNC_MODE_API:
         return await self.async_step_api()
-      merged = {**self.config_entry.options, CONF_SYNC_MODE: self._selected_mode}
-      return self.async_create_entry(data=merged)
+      self._pending_options = {**self.config_entry.options, CONF_SYNC_MODE: self._selected_mode}
+      return await self.async_step_defaults()
 
     opts = self.config_entry.options
     return self.async_show_form(
@@ -206,8 +212,8 @@ class RoomCalOptionsFlow(OptionsFlow):
       if not errors:
         new_data = {**self.config_entry.data, CONF_USERNAME: username, CONF_PASSWORD: password}
         self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
-        merged = {**self.config_entry.options, CONF_SYNC_MODE: self._selected_mode, **user_input}
-        return self.async_create_entry(data=merged)
+        self._pending_options = {**self.config_entry.options, CONF_SYNC_MODE: self._selected_mode, **user_input}
+        return await self.async_step_defaults()
 
     opts = self.config_entry.options
     return self.async_show_form(
@@ -251,12 +257,12 @@ class RoomCalOptionsFlow(OptionsFlow):
       if not errors:
         new_data = {**self.config_entry.data, CONF_API_TOKEN: api_token}
         self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
-        merged = {
+        self._pending_options = {
           **self.config_entry.options,
           CONF_SYNC_MODE: self._selected_mode,
           **user_input,
         }
-        return self.async_create_entry(data=merged)
+        return await self.async_step_defaults()
 
     opts = self.config_entry.options
     return self.async_show_form(
@@ -277,6 +283,46 @@ class RoomCalOptionsFlow(OptionsFlow):
     )
 
 
+  async def async_step_defaults(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    errors: dict[str, str] = {}
+
+    if user_input is not None:
+      defaults: dict[str, Any] = {}
+      for key in _DEFAULTS_STRING_KEYS:
+        val = (user_input.get(key) or "").strip()
+        defaults[key] = val if val else None
+      for key in _DEFAULTS_TIME_KEYS:
+        val = (user_input.get(key) or "").strip()
+        if val:
+          try:
+            from datetime import time as _time
+            _time.fromisoformat(val)
+          except ValueError:
+            errors[key] = "invalid_time"
+          else:
+            defaults[key] = val
+        else:
+          defaults[key] = None
+      if not errors:
+        return self.async_create_entry(data={**self._pending_options, CONF_RESERVATION_DEFAULTS: defaults})
+
+    existing = self.config_entry.options.get(CONF_RESERVATION_DEFAULTS, {})
+    return self.async_show_form(
+      step_id="defaults",
+      data_schema=vol.Schema({
+        vol.Optional("guest_name", default=existing.get("guest_name") or ""): str,
+        vol.Optional("check_in_time", default=existing.get("check_in_time") or ""): str,
+        vol.Optional("check_out_time", default=existing.get("check_out_time") or ""): str,
+        vol.Optional("wifi_ssid", default=existing.get("wifi_ssid") or ""): str,
+        vol.Optional("wifi_key", default=existing.get("wifi_key") or ""): str,
+        vol.Optional("door_code", default=existing.get("door_code") or ""): str,
+      }),
+      errors=errors,
+    )
+
+
 class _ApiAuthError(Exception):
   pass
 

+ 1 - 0
custom_components/room_cal_ha/const.py

@@ -13,6 +13,7 @@ CONF_CALDAV_URL = "caldav_url"
 CONF_CALENDAR_NAME = "calendar_name"
 CONF_REFRESH_INTERVAL = "refresh_interval"
 CONF_API_BASE_URL = "api_base_url"
+CONF_RESERVATION_DEFAULTS = "reservation_defaults"
 
 # entry.data keys (sensitive)
 CONF_API_TOKEN = "api_token"

+ 24 - 14
custom_components/room_cal_ha/coordinator.py

@@ -15,6 +15,7 @@ from homeassistant.util import dt as dt_util
 from .backends import BackendAuthError, BackendConnectError, CalDavBackend, RoomCalApiBackend
 from .const import (
   CONF_REFRESH_INTERVAL,
+  CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
@@ -24,6 +25,7 @@ from .const import (
   SYNC_MODE_CALDAV,
 )
 from .models import ApiStatusData, ReservationData
+from dataclasses import fields as dataclass_fields
 from .store import ReservationStore
 
 _LOGGER = logging.getLogger(__name__)
@@ -105,8 +107,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._current_last_refresh = now
       self._next_last_refresh = now
 
-    new_current = self.store.get(STORE_CURRENT) or ReservationData()
-    new_next = self.store.get(STORE_NEXT) or ReservationData()
+    new_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+    new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
 
     self._reschedule_mode_sync(new_current, new_next)
 
@@ -176,8 +178,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     stored reservation data implies a different state than what was restored, then
     commits a single HA state write via GuestModeSwitch.commit_state().
     """
-    current = self.store.get(STORE_CURRENT) or ReservationData()
-    nxt = self.store.get(STORE_NEXT) or ReservationData()
+    current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+    nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     data_changed = False
 
     if self._is_mode_sync_on():
@@ -193,7 +195,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       if state == "elapsed":
         await self.store.async_set(STORE_CURRENT, None)
         self._current_last_refresh = dt_util.utcnow()
-        current = ReservationData()
+        current = self._empty_reservation()
         if self._guest_mode_switch is not None:
           self._guest_mode_switch._state = False
         data_changed = True
@@ -202,8 +204,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
           self._guest_mode_switch._state = True
         data_changed = True
 
-      new_current = self.store.get(STORE_CURRENT) or ReservationData()
-      new_next = self.store.get(STORE_NEXT) or ReservationData()
+      new_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+      new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
       self._reschedule_mode_sync(new_current, new_next)
 
       if data_changed:
@@ -241,8 +243,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     await self.store.async_set(STORE_CURRENT, None)
     self._current_last_refresh = dt_util.utcnow()
     self.async_set_updated_data(CoordinatorData(
-      current=ReservationData(),
-      next=self.store.get(STORE_NEXT) or ReservationData(),
+      current=self._empty_reservation(),
+      next=self.store.get(STORE_NEXT) or self._empty_reservation(),
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
     ))
@@ -251,10 +253,10 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
   async def async_promote_next_to_current(self) -> None:
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
-    next_data = self.store.get(STORE_NEXT) or ReservationData()
+    next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
     await self.store.async_set(STORE_CURRENT, next_data)
     self._current_last_refresh = dt_util.utcnow()
-    new_next = self.store.get(STORE_NEXT) or ReservationData()
+    new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
     self.async_set_updated_data(CoordinatorData(
       current=next_data,
       next=new_next,
@@ -270,7 +272,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
   async def async_apply_update(self, target: str, partial: dict[str, Any]) -> None:
     """Apply a thin update (service call) to current or next."""
-    stored = self.store.get(target) or ReservationData()
+    stored = self.store.get(target) or self._empty_reservation()
     updated = stored.apply_partial(partial)
     await self.store.async_set(target, updated)
 
@@ -281,8 +283,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._next_last_refresh = now
 
     self.async_set_updated_data(CoordinatorData(
-      current=self.store.get(STORE_CURRENT) or ReservationData(),
-      next=self.store.get(STORE_NEXT) or ReservationData(),
+      current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
+      next=self.store.get(STORE_NEXT) or self._empty_reservation(),
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
     ))
@@ -291,3 +293,11 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     for cancel in self._mode_sync_handles:
       cancel()
     self._mode_sync_handles.clear()
+
+  def _empty_reservation(self) -> ReservationData:
+    """Return a ReservationData with configured defaults applied (for empty slots)."""
+    defaults = self.entry.options.get(CONF_RESERVATION_DEFAULTS, {})
+    if not defaults:
+      return ReservationData()
+    raw = {f.name: defaults.get(f.name) for f in dataclass_fields(ReservationData)}
+    return ReservationData.from_dict(raw)

+ 4 - 4
custom_components/room_cal_ha/models.py

@@ -16,12 +16,12 @@ class ApiStatusData:
 
 @dataclass
 class ReservationData:
-  guest_name: str | None = "<No Guest>"
+  guest_name: str | None = None
   check_in_date: date | None = None
-  check_in_time: time | None = time(14, 0)
+  check_in_time: time | None = None
   check_out_date: date | None = None
-  check_out_time: time | None = time(12, 0)
-  wifi_ssid: str | None = "4114"
+  check_out_time: time | None = None
+  wifi_ssid: str | None = None
   wifi_key: str | None = None
   door_code: str | None = None
 

+ 14 - 1
custom_components/room_cal_ha/strings.json

@@ -57,11 +57,24 @@
           "api_token": "API Token",
           "refresh_interval": "Refresh Interval"
         }
+      },
+      "defaults": {
+        "title": "Reservation Defaults",
+        "description": "Default values shown for empty reservation slots. Leave a field blank to show nothing.",
+        "data": {
+          "guest_name": "Guest Name",
+          "check_in_time": "Check-In Time (HH:MM)",
+          "check_out_time": "Check-Out Time (HH:MM)",
+          "wifi_ssid": "WiFi SSID",
+          "wifi_key": "WiFi Key",
+          "door_code": "Door Code"
+        }
       }
     },
     "error": {
       "invalid_auth": "Invalid credentials.",
-      "cannot_connect": "Cannot connect to the server."
+      "cannot_connect": "Cannot connect to the server.",
+      "invalid_time": "Enter a valid time in HH:MM format, or leave blank."
     }
   },
   "services": {

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

@@ -57,11 +57,24 @@
           "api_token": "API Token",
           "refresh_interval": "Refresh Interval"
         }
+      },
+      "defaults": {
+        "title": "Reservation Defaults",
+        "description": "Default values shown for empty reservation slots. Leave a field blank to show nothing.",
+        "data": {
+          "guest_name": "Guest Name",
+          "check_in_time": "Check-In Time (HH:MM)",
+          "check_out_time": "Check-Out Time (HH:MM)",
+          "wifi_ssid": "WiFi SSID",
+          "wifi_key": "WiFi Key",
+          "door_code": "Door Code"
+        }
       }
     },
     "error": {
       "invalid_auth": "Invalid credentials.",
-      "cannot_connect": "Cannot connect to the server."
+      "cannot_connect": "Cannot connect to the server.",
+      "invalid_time": "Enter a valid time in HH:MM format, or leave blank."
     }
   },
   "services": {

+ 95 - 0
tests/test_config_flow.py

@@ -12,6 +12,7 @@ from custom_components.room_cal_ha.const import (
   CONF_CALENDAR_NAME,
   CONF_PASSWORD,
   CONF_REFRESH_INTERVAL,
+  CONF_RESERVATION_DEFAULTS,
   CONF_ROOM_NAME,
   CONF_SYNC_MODE,
   CONF_USERNAME,
@@ -22,6 +23,15 @@ from custom_components.room_cal_ha.const import (
   SYNC_MODE_MANUAL,
 )
 
+_EMPTY_DEFAULTS = {
+  "guest_name": "",
+  "check_in_time": "",
+  "check_out_time": "",
+  "wifi_ssid": "",
+  "wifi_key": "",
+  "door_code": "",
+}
+
 
 # ---------------------------------------------------------------------------
 # Config flow — user step
@@ -62,6 +72,13 @@ async def test_options_sync_mode_manual(hass: HomeAssistant, mock_entry):
     result["flow_id"],
     user_input={CONF_SYNC_MODE: SYNC_MODE_MANUAL},
   )
+  assert result["type"] == "form"
+  assert result["step_id"] == "defaults"
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input=_EMPTY_DEFAULTS,
+  )
   assert result["type"] == "create_entry"
   assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_MANUAL
 
@@ -111,6 +128,13 @@ async def test_options_caldav_saves_on_success(hass: HomeAssistant, mock_entry):
       },
     )
 
+  assert result["type"] == "form"
+  assert result["step_id"] == "defaults"
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input=_EMPTY_DEFAULTS,
+  )
   assert result["type"] == "create_entry"
   assert result["data"][CONF_SYNC_MODE] == SYNC_MODE_CALDAV
   assert result["data"][CONF_CALDAV_URL] == "https://cal.example.com"
@@ -193,6 +217,13 @@ async def test_options_api_saves_on_success(hass: HomeAssistant, mock_entry):
       },
     )
 
+  assert result["type"] == "form"
+  assert result["step_id"] == "defaults"
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input=_EMPTY_DEFAULTS,
+  )
   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"
@@ -247,3 +278,67 @@ async def test_options_api_connect_error_shows_error(hass: HomeAssistant, mock_e
 
   assert result["type"] == "form"
   assert result["errors"]["base"] == "cannot_connect"
+
+
+# ---------------------------------------------------------------------------
+# Options flow — defaults step
+# ---------------------------------------------------------------------------
+
+async def _reach_defaults_step(hass: HomeAssistant, mock_entry):
+  """Navigate to the defaults step via manual sync mode."""
+  mock_entry.add_to_hass(hass)
+  result = await hass.config_entries.options.async_init(mock_entry.entry_id)
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"], user_input={CONF_SYNC_MODE: SYNC_MODE_MANUAL}
+  )
+  assert result["step_id"] == "defaults"
+  return result
+
+
+async def test_options_defaults_saves_values(hass: HomeAssistant, mock_entry):
+  result = await _reach_defaults_step(hass, mock_entry)
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input={
+      "guest_name": "Default Guest",
+      "check_in_time": "15:00",
+      "check_out_time": "11:00",
+      "wifi_ssid": "HomeNet",
+      "wifi_key": "secret",
+      "door_code": "1234",
+    },
+  )
+  assert result["type"] == "create_entry"
+  defaults = result["data"][CONF_RESERVATION_DEFAULTS]
+  assert defaults["guest_name"] == "Default Guest"
+  assert defaults["check_in_time"] == "15:00"
+  assert defaults["check_out_time"] == "11:00"
+  assert defaults["wifi_ssid"] == "HomeNet"
+  assert defaults["wifi_key"] == "secret"
+  assert defaults["door_code"] == "1234"
+
+
+async def test_options_defaults_empty_strings_become_none(hass: HomeAssistant, mock_entry):
+  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["type"] == "create_entry"
+  defaults = result["data"][CONF_RESERVATION_DEFAULTS]
+  assert defaults["guest_name"] is None
+  assert defaults["check_in_time"] is None
+  assert defaults["wifi_ssid"] is None
+
+
+async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mock_entry):
+  result = await _reach_defaults_step(hass, mock_entry)
+
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    user_input={**_EMPTY_DEFAULTS, "check_in_time": "not-a-time"},
+  )
+  assert result["type"] == "form"
+  assert "check_in_time" in result["errors"]

+ 4 - 4
tests/test_models.py

@@ -139,12 +139,12 @@ def test_apply_partial_empty_dict_returns_equivalent():
 
 def test_default_values():
   res = ReservationData()
-  assert res.guest_name == "<No Guest>"
-  assert res.check_in_time == time(14, 0)
-  assert res.check_out_time == time(12, 0)
-  assert res.wifi_ssid == "4114"
+  assert res.guest_name is None
   assert res.check_in_date is None
+  assert res.check_in_time is None
   assert res.check_out_date is None
+  assert res.check_out_time is None
+  assert res.wifi_ssid is None
   assert res.wifi_key is None
   assert res.door_code is None