Jelajahi Sumber

Allow empty-string WiFi key default for open networks

Preserve wifi_key="" through the Reservation Defaults options step
instead of collapsing blank input to None, so open (no-password)
networks can be configured. Skip pushing the key to UniFi when it's
empty, and emit a correct nopass QR code for the WiFi Key sensor.
Also escape special characters (\;,: and ") in SSID/key fields
when building the WiFi QR string.
ZiB 3 bulan lalu
induk
melakukan
02cce1da88

+ 3 - 1
custom_components/room_cal_ha/config_flow.py

@@ -303,7 +303,9 @@ class RoomCalOptionsFlow(OptionsFlow):
       defaults: dict[str, Any] = {}
       defaults: dict[str, Any] = {}
       for key in _DEFAULTS_STRING_KEYS:
       for key in _DEFAULTS_STRING_KEYS:
         val = (user_input.get(key) or "").strip()
         val = (user_input.get(key) or "").strip()
-        defaults[key] = val if val else None
+        # wifi_key alone preserves "" (open networks have no key) instead of
+        # collapsing blank input to None like the other string defaults.
+        defaults[key] = val if (val or key == "wifi_key") else None
       for key in _DEFAULTS_TIME_KEYS:
       for key in _DEFAULTS_TIME_KEYS:
         val = (user_input.get(key) or "").strip()
         val = (user_input.get(key) or "").strip()
         if val:
         if val:

+ 1 - 1
custom_components/room_cal_ha/coordinator.py

@@ -332,7 +332,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         _LOGGER.info("WiFi enabled: ssid=%s", res.wifi_ssid)
         _LOGGER.info("WiFi enabled: ssid=%s", res.wifi_ssid)
       except Exception as err:
       except Exception as err:
         _LOGGER.warning("Failed to enable SSID '%s': %s", res.wifi_ssid, err)
         _LOGGER.warning("Failed to enable SSID '%s': %s", res.wifi_ssid, err)
-      if isinstance(res.wifi_key, str):
+      if res.wifi_key:
         try:
         try:
           await async_set_wifi_key(self.hass, res.wifi_ssid, res.wifi_key)
           await async_set_wifi_key(self.hass, res.wifi_ssid, res.wifi_key)
           _LOGGER.info("WiFi key set: ssid=%s", res.wifi_ssid)
           _LOGGER.info("WiFi key set: ssid=%s", res.wifi_ssid)

+ 13 - 1
custom_components/room_cal_ha/sensor.py

@@ -152,6 +152,14 @@ class GuestNameSensor(ReservationStrSensor):
     }
     }
 
 
 
 
+_WIFI_QR_ESCAPE_RE = re.compile(r'([\\;,:"])')
+
+
+def _wifi_qr_escape(value: str) -> str:
+  """Escape special characters in a WiFi QR field per the de-facto MECARD-style spec."""
+  return _WIFI_QR_ESCAPE_RE.sub(r"\\\1", value)
+
+
 class WiFiKeySensor(_ReservationSensor):
 class WiFiKeySensor(_ReservationSensor):
   def __init__(self, coordinator, entry, slot, device_info) -> None:
   def __init__(self, coordinator, entry, slot, device_info) -> None:
     super().__init__(coordinator, entry, slot, device_info, "wifi_key", "wifi_key", "mdi:wifi-lock")
     super().__init__(coordinator, entry, slot, device_info, "wifi_key", "wifi_key", "mdi:wifi-lock")
@@ -165,7 +173,11 @@ class WiFiKeySensor(_ReservationSensor):
     res = self._reservation()
     res = self._reservation()
     if res.wifi_ssid is None or res.wifi_key is None:
     if res.wifi_ssid is None or res.wifi_key is None:
       return None
       return None
-    return {"qr": f"WIFI:S:{res.wifi_ssid};T:WPA;P:{res.wifi_key};;"}
+    ssid = _wifi_qr_escape(res.wifi_ssid)
+    if res.wifi_key == "":
+      return {"qr": f"WIFI:S:{ssid};T:nopass;;"}
+    key = _wifi_qr_escape(res.wifi_key)
+    return {"qr": f"WIFI:S:{ssid};T:WPA;P:{key};;"}
 
 
 
 
 class ReservationTimeSensor(_ReservationSensor):
 class ReservationTimeSensor(_ReservationSensor):

+ 20 - 0
tests/test_config_flow.py

@@ -359,6 +359,26 @@ async def test_options_defaults_empty_strings_become_none(hass: HomeAssistant, m
   assert defaults["guest_name"] is None
   assert defaults["guest_name"] is None
   assert defaults["check_in_time"] is None
   assert defaults["check_in_time"] is None
   assert defaults["wifi_ssid"] is None
   assert defaults["wifi_ssid"] is None
+  assert defaults["door_code"] is None
+
+
+async def test_options_defaults_blank_wifi_key_preserved_as_empty_string(
+  hass: HomeAssistant, mock_entry
+):
+  """A blank wifi_key should save as "" (open network), not collapse to None,
+  while other blank string fields still collapse to None."""
+  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["wifi_key"] == ""
+  assert defaults["guest_name"] is None
+  assert defaults["wifi_ssid"] is None
+  assert defaults["door_code"] is None
 
 
 
 
 async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mock_entry):
 async def test_options_defaults_invalid_time_shows_error(hass: HomeAssistant, mock_entry):

+ 11 - 0
tests/test_coordinator.py

@@ -425,6 +425,17 @@ async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator
   mock_wifi.assert_not_awaited()
   mock_wifi.assert_not_awaited()
 
 
 
 
+async def test_check_in_skips_wifi_when_key_empty_string(coordinator: RoomCalCoordinator):
+  """An open-network default (wifi_key="") should not push a WPA password to UniFi."""
+  await coordinator.store.async_set(STORE_NEXT, ReservationData(wifi_ssid="GuestNet", wifi_key=""))
+  coordinator._room_state_switch = None
+
+  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
+    await coordinator._async_do_check_in()
+
+  mock_wifi.assert_not_awaited()
+
+
 async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
 async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
   mock_switch = MagicMock()
   mock_switch = MagicMock()

+ 19 - 0
tests/test_sensors.py

@@ -209,6 +209,25 @@ def test_wifi_key_sensor_qr_attribute():
   assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
   assert attrs["qr"] == "WIFI:S:MyNet;T:WPA;P:hunter2;;"
 
 
 
 
+def test_wifi_key_sensor_qr_attribute_open_network():
+  """An empty-string wifi_key (open network default) should produce a nopass QR string."""
+  res = ReservationData(wifi_ssid="MyNet", wifi_key="")
+  coord = _coordinator(current=res)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  attrs = s.extra_state_attributes
+  assert attrs is not None
+  assert attrs["qr"] == "WIFI:S:MyNet;T:nopass;;"
+
+
+def test_wifi_key_sensor_qr_attribute_escapes_special_chars():
+  res = ReservationData(wifi_ssid="Guest;Net,\"A\"", wifi_key="p:a\\ss;w,ord")
+  coord = _coordinator(current=res)
+  s = _sensor(WiFiKeySensor, coord, field="wifi_key")
+  attrs = s.extra_state_attributes
+  assert attrs is not None
+  assert attrs["qr"] == 'WIFI:S:Guest\\;Net\\,\\"A\\";T:WPA;P:p\\:a\\\\ss\\;w\\,ord;;'
+
+
 def test_wifi_key_sensor_no_attrs_when_ssid_missing():
 def test_wifi_key_sensor_no_attrs_when_ssid_missing():
   res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
   res = ReservationData(wifi_ssid=None, wifi_key="hunter2")
   coord = _coordinator(current=res)
   coord = _coordinator(current=res)