Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions BLUETOOTH_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<id>"}` - Empty string to allow all |
| 7 | 15 | Factory reset | `{}` - Performs full software reset |
| 7 | 18 | Away mode | `{"active": <bool>, "timestamp": <ms>}` - Values are not wrapped in `{"val": ...}`, see [Away Mode](#away-mode) |
| 7 | 1000 | Dispense water | `{"disp_amt": <amount_ml>, "co2_int": <intensity>}` - 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 |

Expand Down Expand Up @@ -344,6 +345,38 @@ The CHOICE.All settings response includes these additional fields:
| ------------------- | ---------------------------------------- | ------------ | ------------------------------ |
| Heating temperature | `{"set_point_heating": {"val": <temp>}}` | 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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions custom_components/blanco_unit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

type BlancoUnitConfigEntry = ConfigEntry[BlancoUnitCoordinator]
Expand Down
39 changes: 39 additions & 0 deletions custom_components/blanco_unit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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).

Expand Down
17 changes: 17 additions & 0 deletions custom_components/blanco_unit/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/blanco_unit/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions custom_components/blanco_unit/switch.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions custom_components/blanco_unit/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down Expand Up @@ -273,6 +276,11 @@
"subnet": {
"name": "Subnetzmaske"
}
},
"switch": {
"away_mode": {
"name": "Abwesenheitsmodus"
}
}
},
"services": {
Expand Down
8 changes: 8 additions & 0 deletions custom_components/blanco_unit/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
}
Expand Down Expand Up @@ -265,6 +268,11 @@
"subnet": {
"name": "Subnet Mask"
}
},
"switch": {
"away_mode": {
"name": "Away Mode"
}
}
},
"services": {
Expand Down
52 changes: 52 additions & 0 deletions tests/snapshots/test_switch.ambr
Original file line number Diff line number Diff line change
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_blanco_unit_away_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
Loading
Loading