diff --git a/BLUETOOTH_PROTOCOL.md b/BLUETOOTH_PROTOCOL.md index 1de76e0..5827ffe 100644 --- a/BLUETOOTH_PROTOCOL.md +++ b/BLUETOOTH_PROTOCOL.md @@ -267,6 +267,7 @@ Example: | 7 | 12 | Scan available WiFi networks | `{}` - Returns list of access points with SSID, signal, auth_mode | | 7 | 14 | Allow cloud services | `{"rca_id":""}` - Empty string to allow all | | 7 | 15 | Factory reset | `{}` - Performs full software reset | +| 7 | 18 | Away mode | `{"active": , "timestamp": }` - Values are not wrapped in `{"val": ...}`, see [Away Mode](#away-mode) | | 7 | 1000 | Dispense water | `{"disp_amt": , "co2_int": }` - Amount: 100-1500ml (multiples of 100), Intensity: 1=still, 2=medium, 3=high | | 10 | N/A | Initial pairing/authentication | `{}` - Returns device ID on successful authentication | @@ -344,6 +345,38 @@ The CHOICE.All settings response includes these additional fields: | ------------------- | ---------------------------------------- | ------------ | ------------------------------ | | Heating temperature | `{"set_point_heating": {"val": }}` | 60-100°C | Set target heating temperature | +### Away Mode + +The away mode is switched with event type 7, control code 18. Unlike settings writes, the values are sent as plain values. Writing `absence_mode_active` with control code 5 is acknowledged but ignored. + +Switching off: + +```json +{ "active": false, "timestamp": 1793198677460 } +``` + +Switching on, as sent by the BLANCO UNIT app (reduced setpoints and child lock): + +```json +{ + "active": true, + "timestamp": 1793198677460, + "set_point_heating": 65, + "set_point_cooling": 10, + "child_protect": true +} +``` + +While active, the device keeps the previous values in the settings (`evt_type=5`) and restores them when the mode is switched off: + +| Parameter | Type | Description | +| ------------------------ | ---- | --------------------------------------------- | +| `absence_mode_active` | bool | Away mode is active | +| `absence_mode_timestamp` | int | Activation time in milliseconds, 0 when off | +| `set_point_cooling_bkp` | int | Cooling setpoint to restore | +| `set_point_heating_bkp` | int | Heating setpoint to restore | +| `child_protect_bkp` | bool | Child lock state to restore | + ## Request/Response Examples ### 1. Initial Pairing diff --git a/README.md b/README.md index 5f82943..b373637 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,10 @@ Once configured, the integration creates the following entities: - Level 8: 26-28°dH - Level 9: >28°dH +### Switch Entities (1 total) + +- **Away Mode** - Lowers cooling and heating while you are away and restores the previous settings when switched off. Only created when the device reports an away mode. + ## Services ### blanco_unit.dispense_water diff --git a/custom_components/blanco_unit/__init__.py b/custom_components/blanco_unit/__init__.py index 154bf8b..a6b43b1 100644 --- a/custom_components/blanco_unit/__init__.py +++ b/custom_components/blanco_unit/__init__.py @@ -53,6 +53,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] type BlancoUnitConfigEntry = ConfigEntry[BlancoUnitCoordinator] diff --git a/custom_components/blanco_unit/client.py b/custom_components/blanco_unit/client.py index a2fc964..31469d5 100644 --- a/custom_components/blanco_unit/client.py +++ b/custom_components/blanco_unit/client.py @@ -194,6 +194,30 @@ def to_pars(self) -> dict[str, Any]: } +@dataclass +class _SetAwayModePars: + """Internal: Parameters for switching the away mode. + + Unlike settings, the away mode values are sent without a "val" wrapper. + Switching on also sends the reduced setpoints the BLANCO app uses; the + device keeps the previous ones and restores them when switched off. + """ + + active: bool + timestamp_ms: int + + def to_pars(self) -> dict[str, Any]: + """Convert to parameters dictionary.""" + pars: dict[str, Any] = {"active": self.active, "timestamp": self.timestamp_ms} + if self.active: + pars |= { + "set_point_heating": 65, + "set_point_cooling": 10, + "child_protect": True, + } + return pars + + @dataclass class _SetWaterHardnessPars: """Internal: Parameters for setting water hardness.""" @@ -710,6 +734,7 @@ async def get_settings(self) -> BlancoUnitSettings: calib_hot_wtr=pars.get("calib_hot_wtr", {}).get("val", 0), gbl_medium_wtr_ratio=pars.get("gbl_medium_wtr_ratio", {}).get("val", 0.0), gbl_classic_wtr_ratio=pars.get("gbl_classic_wtr_ratio", {}).get("val", 0.0), + absence_mode_active=pars.get("absence_mode_active", {}).get("val"), ) async def get_status(self) -> BlancoUnitStatus: @@ -802,6 +827,20 @@ async def set_heating_temperature(self, heating_celsius: int) -> bool: resp = await self._execute_transaction(evt_type=7, ctrl=5, pars=req.to_pars()) return resp.get("type") == 2 + async def set_away_mode(self, active: bool) -> bool: + """Switch the away mode on or off. + + Args: + active: True to switch the away mode on, False to switch it off. + + Returns: + True if successful. + """ + _LOGGER.info("Setting away mode to %s", active) + req = _SetAwayModePars(active=active, timestamp_ms=int(time.time() * 1000)) + resp = await self._execute_transaction(evt_type=7, ctrl=18, pars=req.to_pars()) + return resp.get("type") == 2 + async def set_water_hardness(self, level: int) -> bool: """Set water hardness level (1-9). diff --git a/custom_components/blanco_unit/coordinator.py b/custom_components/blanco_unit/coordinator.py index 55238f6..b001d0e 100644 --- a/custom_components/blanco_unit/coordinator.py +++ b/custom_components/blanco_unit/coordinator.py @@ -155,6 +155,23 @@ async def set_heating_temperature(self, heating_celsius: int) -> None: }, ) + async def set_away_mode(self, active: bool) -> None: + """Switch the away mode on or off.""" + await self._call(self._client.set_away_mode, active) + # Refresh settings to verify the change + settings = await self._call(self._client.get_settings) + if self.data is not None: + self.async_set_updated_data(replace(self.data, settings=settings)) + if settings.absence_mode_active != active: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_saved_away_mode", + translation_placeholders={ + "expected": str(active), + "actual": str(settings.absence_mode_active), + }, + ) + async def set_water_hardness(self, level: int) -> None: """Set water hardness level (1-9).""" await self._call(self._client.set_water_hardness, level) diff --git a/custom_components/blanco_unit/data.py b/custom_components/blanco_unit/data.py index 10c3a32..97a249e 100644 --- a/custom_components/blanco_unit/data.py +++ b/custom_components/blanco_unit/data.py @@ -31,6 +31,8 @@ class BlancoUnitSettings: calib_hot_wtr: int = 0 gbl_medium_wtr_ratio: float = 0.0 gbl_classic_wtr_ratio: float = 0.0 + # None when the device does not report an away mode + absence_mode_active: bool | None = None @dataclass diff --git a/custom_components/blanco_unit/switch.py b/custom_components/blanco_unit/switch.py new file mode 100644 index 0000000..9a6b9c2 --- /dev/null +++ b/custom_components/blanco_unit/switch.py @@ -0,0 +1,56 @@ +"""Switch entities for Blanco Unit BLE entities.""" + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BlancoUnitConfigEntry +from .base import BlancoUnitBaseEntity +from .coordinator import BlancoUnitCoordinator + + +async def async_setup_entry( + _: HomeAssistant, + config_entry: BlancoUnitConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the away mode switch if the device reports one.""" + coordinator: BlancoUnitCoordinator = config_entry.runtime_data + + settings = coordinator.data.settings + if settings is not None and settings.absence_mode_active is not None: + async_add_entities([AwayModeSwitch(coordinator)]) + + +class AwayModeSwitch(BlancoUnitBaseEntity, SwitchEntity): + """Implementation of the away mode switch.""" + + _attr_unique_id = "away_mode" + _attr_translation_key = _attr_unique_id + _attr_icon = "mdi:bag-suitcase" + + @property + def available(self) -> bool: + """Set availability if settings are available.""" + return ( + super().available + and self.coordinator.data.settings is not None + and self.coordinator.data.settings.absence_mode_active is not None + ) + + @property + def is_on(self) -> bool | None: + """Return whether the away mode is active.""" + if self.coordinator.data.settings is None: + return None + return self.coordinator.data.settings.absence_mode_active + + async def async_turn_on(self, **kwargs: Any) -> None: + """Switch the away mode on.""" + await self.coordinator.set_away_mode(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Switch the away mode off.""" + await self.coordinator.set_away_mode(False) diff --git a/custom_components/blanco_unit/translations/de.json b/custom_components/blanco_unit/translations/de.json index 298ad33..7d26365 100644 --- a/custom_components/blanco_unit/translations/de.json +++ b/custom_components/blanco_unit/translations/de.json @@ -24,6 +24,9 @@ "not_saved_heating_temperature": { "message": "Die Änderung der Heiztemperatur wurde nicht auf dem Gerät gespeichert. Erwartet wurden {expected}°C, das Gerät zeigt jedoch {actual}°C an." }, + "not_saved_away_mode": { + "message": "Die Änderung des Abwesenheitsmodus wurde nicht auf dem Gerät gespeichert. Erwartet wurde {expected}, das Gerät zeigt jedoch {actual} an." + }, "not_saved_water_hardness": { "message": "Die Änderung der Wasserhärte wurde nicht auf dem Gerät gespeichert. Erwartet wurde Stufe {expected}, das Gerät zeigt jedoch Stufe {actual} an." } @@ -273,6 +276,11 @@ "subnet": { "name": "Subnetzmaske" } + }, + "switch": { + "away_mode": { + "name": "Abwesenheitsmodus" + } } }, "services": { diff --git a/custom_components/blanco_unit/translations/en.json b/custom_components/blanco_unit/translations/en.json index bcd8217..b5b7d1d 100644 --- a/custom_components/blanco_unit/translations/en.json +++ b/custom_components/blanco_unit/translations/en.json @@ -24,6 +24,9 @@ "not_saved_heating_temperature": { "message": "Heating temperature change was not saved on the device. Expected {expected}°C but device shows {actual}°C." }, + "not_saved_away_mode": { + "message": "Away mode change was not saved on the device. Expected {expected} but device shows {actual}." + }, "not_saved_water_hardness": { "message": "Water hardness change was not saved on the device. Expected level {expected} but device shows level {actual}." } @@ -265,6 +268,11 @@ "subnet": { "name": "Subnet Mask" } + }, + "switch": { + "away_mode": { + "name": "Away Mode" + } } }, "services": { diff --git a/tests/snapshots/test_switch.ambr b/tests/snapshots/test_switch.ambr new file mode 100644 index 0000000..409a76c --- /dev/null +++ b/tests/snapshots/test_switch.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_all_entities[switch.test_blanco_unit_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_blanco_unit_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Away Mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bag-suitcase', + 'original_name': 'Away Mode', + 'platform': 'blanco_unit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'away_mode', + 'unique_id': 'away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_blanco_unit_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blanco Unit Away Mode', + 'icon': 'mdi:bag-suitcase', + }), + 'context': , + 'entity_id': 'switch.test_blanco_unit_away_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/test_client.py b/tests/test_client.py index c39c115..82b2e64 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -29,6 +29,7 @@ _RequestBody, _RequestEnvelope, _RequestMeta, + _SetAwayModePars, _SetCalibrationPars, _SetHeatingTemperaturePars, _SetTemperaturePars, @@ -154,6 +155,24 @@ def test_set_heating_temperature_pars_to_pars(): assert result["set_point_heating"]["val"] == 65 +def test_set_away_mode_pars_to_pars_off(): + """Test _SetAwayModePars.to_pars() when switching off.""" + pars = _SetAwayModePars(active=False, timestamp_ms=1793198677460) + assert pars.to_pars() == {"active": False, "timestamp": 1793198677460} + + +def test_set_away_mode_pars_to_pars_on(): + """Test _SetAwayModePars.to_pars() sends the app's reduced setpoints when on.""" + pars = _SetAwayModePars(active=True, timestamp_ms=1793198677460) + assert pars.to_pars() == { + "active": True, + "timestamp": 1793198677460, + "set_point_heating": 65, + "set_point_cooling": 10, + "child_protect": True, + } + + def test_set_water_hardness_pars_to_pars_valid(): """Test _SetWaterHardnessPars.to_pars() with valid level.""" pars = _SetWaterHardnessPars(level=5) @@ -1330,6 +1349,7 @@ async def mock_read(*args, **kwargs): assert settings.post_flush_quantity == 100 assert settings.set_point_cooling == 7 assert settings.wtr_hardness == 4 + assert settings.absence_mode_active is None @pytest.mark.asyncio @@ -1589,6 +1609,92 @@ async def test_bluetooth_client_set_temperature_invalid_high(): await client.set_temperature(cooling_celsius=11) +@pytest.mark.asyncio +@patch("custom_components.blanco_unit.client.establish_connection") +async def test_bluetooth_client_get_settings_away_mode(mock_establish): + """Test get_settings reads the away mode state.""" + device = BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Test Device", details={}) + client = BlancoUnitBluetoothClient( + pin="12345", device=device, connection_callback=MagicMock() + ) + + mock_ble_client = AsyncMock() + mock_ble_client.is_connected = True + mock_establish.return_value = mock_ble_client + + pairing_json = json.dumps( + {"body": {"meta": {"dev_id": "device123", "dev_type": 2}}} + ) + pairing_packet = ( + bytes([0xFF, 0x00, 1, 10, 0x00]) + pairing_json.encode("utf-8") + b"\x00\xff" + ) + # Recorded from a CHOICE.All in away mode + settings_json = json.dumps( + { + "body": { + "pars": { + "set_point_cooling": {"val": 10}, + "set_point_heating": {"val": 65}, + "child_protect": {"val": True}, + "absence_mode_active": {"val": True}, + "absence_mode_timestamp": {"val": 1792213090806}, + "set_point_cooling_bkp": {"val": 4}, + "set_point_heating_bkp": {"val": 100}, + "child_protect_bkp": {"val": False}, + } + } + } + ) + settings_packet = ( + bytes([0xFF, 0x00, 1, 11, 0x00]) + settings_json.encode("utf-8") + b"\x00\xff" + ) + + read_count = 0 + + async def mock_read(*args, **kwargs): + nonlocal read_count + read_count += 1 + if read_count == 1: + return pairing_packet + return settings_packet + + mock_ble_client.write_gatt_char = AsyncMock() + mock_ble_client.read_gatt_char = mock_read + + settings = await client.get_settings() + + assert settings.absence_mode_active is True + assert settings.set_point_heating == 65 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("active", [True, False]) +async def test_bluetooth_client_set_away_mode(active): + """Test set_away_mode sends control code 18 with a millisecond timestamp.""" + device = BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Test Device", details={}) + client = BlancoUnitBluetoothClient( + pin="12345", device=device, connection_callback=MagicMock() + ) + + with ( + patch.object( + client, "_execute_transaction", AsyncMock(return_value={"type": 2}) + ) as mock_transaction, + patch( + "custom_components.blanco_unit.client.time.time", + return_value=1793198677.46, + ), + ): + result = await client.set_away_mode(active) + + assert result is True + mock_transaction.assert_awaited_once_with( + evt_type=7, + ctrl=18, + pars=_SetAwayModePars(active=active, timestamp_ms=1793198677460).to_pars(), + ) + + @pytest.mark.asyncio @patch("custom_components.blanco_unit.client.establish_connection") async def test_bluetooth_client_set_water_hardness_success(mock_establish): diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index c78f558..44e6fd4 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -521,6 +521,100 @@ async def test_coordinator_set_water_hardness_verification_failed( await coordinator.set_water_hardness(7) +@pytest.mark.parametrize("active", [True, False]) +async def test_coordinator_set_away_mode( + hass: HomeAssistant, mock_device, mock_config_entry, mock_client, active +) -> None: + """Test set_away_mode switches and reads the state back.""" + unsub_listener = MagicMock() + + mock_client.set_away_mode = AsyncMock() + mock_client.get_settings = AsyncMock( + return_value=BlancoUnitSettings( + calib_still_wtr=5, + calib_soda_wtr=5, + filter_life_tm=365, + post_flush_quantity=100, + set_point_cooling=7, + wtr_hardness=5, + absence_mode_active=active, + ) + ) + + with ( + patch( + "custom_components.blanco_unit.coordinator.BlancoUnitBluetoothClient", + return_value=mock_client, + ), + patch( + "custom_components.blanco_unit.coordinator.bluetooth.async_track_unavailable" + ), + patch( + "custom_components.blanco_unit.coordinator.bluetooth.async_register_callback" + ), + ): + coordinator = BlancoUnitCoordinator( + hass, mock_config_entry, mock_device, unsub_listener + ) + + coordinator.data = BlancoUnitData( + connected=True, + available=True, + device_id="test_device_id", + ) + + await coordinator.set_away_mode(active) + + mock_client.set_away_mode.assert_called_once_with(active) + mock_client.get_settings.assert_called_once() + assert coordinator.data.settings.absence_mode_active is active + + +async def test_coordinator_set_away_mode_verification_failed( + hass: HomeAssistant, mock_device, mock_config_entry, mock_client +) -> None: + """Test set_away_mode when the device keeps the away mode on.""" + unsub_listener = MagicMock() + + mock_client.set_away_mode = AsyncMock() + mock_client.get_settings = AsyncMock( + return_value=BlancoUnitSettings( + calib_still_wtr=5, + calib_soda_wtr=5, + filter_life_tm=365, + post_flush_quantity=100, + set_point_cooling=7, + wtr_hardness=5, + absence_mode_active=True, + ) + ) + + with ( + patch( + "custom_components.blanco_unit.coordinator.BlancoUnitBluetoothClient", + return_value=mock_client, + ), + patch( + "custom_components.blanco_unit.coordinator.bluetooth.async_track_unavailable" + ), + patch( + "custom_components.blanco_unit.coordinator.bluetooth.async_register_callback" + ), + ): + coordinator = BlancoUnitCoordinator( + hass, mock_config_entry, mock_device, unsub_listener + ) + + coordinator.data = BlancoUnitData( + connected=True, + available=True, + device_id="test_device_id", + ) + + with pytest.raises(ServiceValidationError, match="not_saved_away_mode"): + await coordinator.set_away_mode(False) + + async def test_coordinator_dispense_water( hass: HomeAssistant, mock_device, mock_config_entry, mock_client ) -> None: diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 0000000..4a5181f --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,149 @@ +"""Tests for the Blanco Unit switch entities.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + snapshot_platform, +) +from syrupy.assertion import SnapshotAssertion + +from custom_components.blanco_unit.const import CONF_MAC, CONF_NAME, CONF_PIN, DOMAIN +from custom_components.blanco_unit.data import BlancoUnitData, BlancoUnitSettings +from custom_components.blanco_unit.switch import AwayModeSwitch, async_setup_entry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration # noqa: TID251 + + +@pytest.fixture +def mock_coordinator(): + """Create a mock coordinator.""" + coordinator = MagicMock() + coordinator.data = BlancoUnitData( + connected=True, + available=True, + device_id="test_device_id", + settings=BlancoUnitSettings( + calib_still_wtr=5, + calib_soda_wtr=6, + filter_life_tm=365, + post_flush_quantity=100, + set_point_cooling=7, + wtr_hardness=5, + absence_mode_active=False, + ), + ) + coordinator.set_away_mode = AsyncMock() + return coordinator + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_blanco_unit_data, + mock_bluetooth_device, +) -> None: + """Test all entities.""" + mock_blanco_unit_data.settings = replace( + mock_blanco_unit_data.settings, absence_mode_active=False + ) + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "Test Blanco Unit", + CONF_PIN: 12345, + }, + unique_id="AA:BB:CC:DD:EE:FF", + ) + + with ( + patch("custom_components.blanco_unit.PLATFORMS", [Platform.SWITCH]), + patch( + "custom_components.blanco_unit.bluetooth.async_ble_device_from_address", + return_value=mock_bluetooth_device, + ), + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("absence_mode_active", "expected_count"), + [ + (None, 0), + (False, 1), + (True, 1), + ], +) +async def test_async_setup_entry( + hass: HomeAssistant, + mock_coordinator, + absence_mode_active, + expected_count, +) -> None: + """Test the switch is only created when the device reports an away mode.""" + mock_coordinator.data.settings.absence_mode_active = absence_mode_active + config_entry = MagicMock() + config_entry.runtime_data = mock_coordinator + entities_added = [] + + await async_setup_entry(hass, config_entry, entities_added.extend) + + assert len(entities_added) == expected_count + + +async def test_async_setup_entry_without_settings( + hass: HomeAssistant, mock_coordinator +) -> None: + """Test no switch is created before settings were read.""" + mock_coordinator.data.settings = None + config_entry = MagicMock() + config_entry.runtime_data = mock_coordinator + entities_added = [] + + await async_setup_entry(hass, config_entry, entities_added.extend) + + assert entities_added == [] + + +async def test_away_mode_switch(mock_coordinator) -> None: + """Test AwayModeSwitch state.""" + switch = AwayModeSwitch(mock_coordinator) + + assert switch.unique_id == "away_mode" + assert switch.available is True + assert switch.is_on is False + + mock_coordinator.data.settings.absence_mode_active = True + assert switch.is_on is True + + +async def test_away_mode_switch_turn_on_off(mock_coordinator) -> None: + """Test AwayModeSwitch turn on and off.""" + switch = AwayModeSwitch(mock_coordinator) + + await switch.async_turn_on() + mock_coordinator.set_away_mode.assert_awaited_once_with(True) + + mock_coordinator.set_away_mode.reset_mock() + await switch.async_turn_off() + mock_coordinator.set_away_mode.assert_awaited_once_with(False) + + +async def test_away_mode_switch_unavailable(mock_coordinator) -> None: + """Test AwayModeSwitch when settings are missing.""" + mock_coordinator.data.settings = None + switch = AwayModeSwitch(mock_coordinator) + + assert switch.available is False + assert switch.is_on is None