Skip to content
Merged
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
Binary file modified .coverage
Binary file not shown.
54 changes: 36 additions & 18 deletions BLUETOOTH_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,27 +252,45 @@ Example:
4. Split on `0x00` to extract JSON
5. Parse JSON string

## Event Types

| Event Type | Control Code | Description |
| ---------- | ------------ | ---------------------------------------------------- |
| 7 | 1 | Subscribe to events |
| 7 | 2 | Get device identity (serial number, service code) |
| 7 | 3 | Get device information (requires `evt_type` in pars) |
| 7 | 5 | Set device settings |
| 7 | 10 | Get WiFi information |
| 7 | 13 | Change PIN |
| 7 | 1000 | Dispense water |
| 10 | N/A | Initial pairing/authentication |
## Event Types (Blanco Drink.soda)

### Main Event Types

| Event Type | Control Code | Description | Parameters (`pars`) |
| ---------- | ------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| 7 | 2 | Get device identity | `{}` - Returns serial number and service code |
| 7 | 3 | Get device information | `{"evt_type": <sub_evt_type>}` - See sub-event types below |
| 7 | 5 | Set device settings | See settings parameters below |
| 7 | 10 | Get WiFi information | `{}` - Returns WiFi SSID, signal, IP, MAC addresses, gateway, subnet |
| 7 | 13 | Change PIN | `{"new_pass": "<5_digit_pin>"}` - Changes device PIN (string) |
| 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 |

### Sub-Event Types (evt_type in pars for ctrl=3)

| evt_type | Description |
| -------- | --------------------------------------------------------------------- |
| 2 | System information (firmware versions, device name, reset count) |
| 4 | Error information |
| 5 | Device settings (calibration, filter lifetime, temperature, hardness) |
| 6 | Device status (filter/CO2 remaining, tap state, errors) |
When using event type 7 with control code 3, you must specify a sub-event type in the `pars` field:

| evt_type | Description | Returns |
| -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| 2 | System information | Firmware versions (`sw_ver_comm_con`, `sw_ver_elec_con`, `sw_ver_main_con`), device name, reset count |
| 4 | Error information | Array of errors with `err_code` and `err_msg` (empty array if no errors) |
| 5 | Device settings | Calibration values, filter lifetime, post-flush quantity, temperature setpoint, water hardness |
| 6 | Device status | Tap state, filter/CO2 remaining percentage, water dispensing active, firmware update available, cleaning mode, error bits |

### Settings Parameters (evt_type=7, ctrl=5)

When setting device configuration, use event type 7 with control code 5 and one of these parameters:

| Setting | Parameter Format | Valid Values | Description |
| ----------------------- | -------------------------------------------------------------------------- | ------------ | ------------------------------------------------------- |
| Cooling temperature | `{"set_point_cooling": {"val": <temp>}, "set_point_heating": {"val": 65}}` | 4-10°C | Set target cooling temperature (heating is always 65°C) |
| Water hardness | `{"wtr_hardness": {"val": <level>}}` | 1-9 | Set water hardness level |
| Still water calibration | `{"calib_still_wtr": {"val": <amount>}}` | 1-10 | Calibrate still water flow |
| Soda water calibration | `{"calib_soda_wtr": {"val": <amount>}}` | 1-10 | Calibrate carbonated water flow |

## Event Types (Blanco Choice.all)

Yet to be defined.

## Request/Response Examples

Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,60 @@ data:
update_config: true
```

### blanco_unit.scan_protocol_parameters

Test protocol parameters by sending custom BLE commands to the device. This is a diagnostic tool for developers and advanced users to discover supported device commands or test protocol behavior. Results are returned in the service response and logged to the debug log.

**Parameters:**

- `device_id` (required): The Blanco Unit device to test
- `data` (required): JSON object containing the protocol parameters
- `evt_type` (required): Event type value (0-255)
- `ctrl` (optional): Control code value (0-255)
- `pars` (optional): Parameters dictionary to send with the request

**Example - Get System Information for Blanco Drink.soda:**

See [Event Types](BLUETOOTH_PROTOCOL.md#event-types-blanco-drinksoda) in the protocol documentation for complete details.

```yaml
service: blanco_unit.scan_protocol_parameters
data:
device_id: abc123def456
data:
evt_type: 7
ctrl: 3
pars:
evt_type: 2
response_variable: result
```

**Example - Get Device Status:**

```yaml
service: blanco_unit.scan_protocol_parameters
data:
device_id: abc123def456
data:
evt_type: 7
ctrl: 3
pars:
evt_type: 6
response_variable: status
```

The service returns a response with the following structure:

```yaml
evt_type: 7
ctrl: 3
pars:
evt_type: 2
success: true
response:
# Device response data
```

## Example Automations

### Low Filter Notification
Expand Down
67 changes: 57 additions & 10 deletions custom_components/blanco_unit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,8 @@ async def _execute_transaction(

async def get_system_info(self) -> BlancoUnitSystemInfo:
"""Read and return system information (firmware versions, device name, reset count)."""
session_data = await self._connect()
resp = await self._execute_transaction(evt_type=7, ctrl=3, pars={"evt_type": 2})
pars = session_data.protocol.extract_pars(resp)
pars = self._session_data.protocol.extract_pars(resp)
return BlancoUnitSystemInfo(
sw_ver_comm_con=pars.get("sw_ver_comm_con", {}).get("val", "Unknown"),
sw_ver_elec_con=pars.get("sw_ver_elec_con", {}).get("val", "Unknown"),
Expand All @@ -532,9 +531,8 @@ async def get_system_info(self) -> BlancoUnitSystemInfo:

async def get_settings(self) -> BlancoUnitSettings:
"""Read and return device configuration settings."""
session_data = await self._connect()
resp = await self._execute_transaction(evt_type=7, ctrl=3, pars={"evt_type": 5})
pars = session_data.protocol.extract_pars(resp)
pars = self._session_data.protocol.extract_pars(resp)
return BlancoUnitSettings(
calib_still_wtr=pars.get("calib_still_wtr", {}).get("val", 0),
calib_soda_wtr=pars.get("calib_soda_wtr", {}).get("val", 0),
Expand All @@ -546,9 +544,8 @@ async def get_settings(self) -> BlancoUnitSettings:

async def get_status(self) -> BlancoUnitStatus:
"""Read and return real-time device status."""
session_data = await self._connect()
resp = await self._execute_transaction(evt_type=7, ctrl=3, pars={"evt_type": 6})
pars = session_data.protocol.extract_pars(resp)
pars = self._session_data.protocol.extract_pars(resp)
return BlancoUnitStatus(
tap_state=pars.get("tap_state", {}).get("val", 0),
filter_rest=pars.get("filter_rest", {}).get("val", 0),
Expand All @@ -562,19 +559,17 @@ async def get_status(self) -> BlancoUnitStatus:

async def get_device_identity(self) -> BlancoUnitIdentity:
"""Read and return device identity (serial number, service code)."""
session_data = await self._connect()
resp = await self._execute_transaction(evt_type=7, ctrl=2, pars={})
pars = session_data.protocol.extract_pars(resp)
pars = self._session_data.protocol.extract_pars(resp)
return BlancoUnitIdentity(
serial_no=pars.get("ser_no", "Unknown"),
service_code=pars.get("serv_code", "Unknown"),
)

async def get_wifi_info(self) -> BlancoUnitWifiInfo:
"""Read and return WiFi and network information."""
session_data = await self._connect()
resp = await self._execute_transaction(evt_type=7, ctrl=10, pars={})
pars = session_data.protocol.extract_pars(resp)
pars = self._session_data.protocol.extract_pars(resp)
return BlancoUnitWifiInfo(
cloud_connect=pars.get("cloud_connect", {}).get("val", False),
ssid=pars.get("ssid", {}).get("val", ""),
Expand Down Expand Up @@ -703,6 +698,58 @@ async def set_calibration_soda(self, amount: int) -> bool:
resp = await self._execute_transaction(evt_type=7, ctrl=5, pars=req.to_pars())
return resp.get("type") == 2

# -------------------------------
# region Protocol Discovery
# -------------------------------

async def test_protocol_parameters(
self, evt_type: int, ctrl: int | None = None, pars: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""Test protocol parameters and return response if it contains meaningful data.

Args:
evt_type: Event type to test.
ctrl: Control parameter to test (optional).
pars: Parameters dictionary to test (optional).

Returns:
Response dictionary if it contains meaningful data, None otherwise.
"""
try:
response = await self._execute_transaction(
evt_type=evt_type, ctrl=ctrl, pars=pars
)

# Check if response contains meaningful data
if self._is_response_empty(response):
return None

return response # noqa: TRY300
except Exception as e: # noqa: BLE001
_LOGGER.debug(
"Test failed for evt_type=%s, ctrl=%s, pars=%s: %s",
evt_type,
ctrl,
pars,
e,
)
return None

def _is_response_empty(self, response: dict[str, Any]) -> bool:
"""Check if a response contains meaningful data.

Args:
response: Response dictionary to check.

Returns:
True if response is empty or contains only empty structures.
"""
if not response:
return True

# Check if body exists
body = response.get("body", {})
return not body

# -------------------------------
# region Standalone Functions
Expand Down
3 changes: 0 additions & 3 deletions custom_components/blanco_unit/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
Expand Down
2 changes: 2 additions & 0 deletions custom_components/blanco_unit/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
# Service Names
HA_SERVICE_DISPENSE_WATER = "dispense_water"
HA_SERVICE_CHANGE_PIN = "change_pin"
HA_SERVICE_SCAN_PROTOCOL = "scan_protocol_parameters"

# Service Attributes
HA_SERVICE_ATTR_DEVICE_ID = "device_id"
HA_SERVICE_ATTR_AMOUNT_ML = "amount_ml"
HA_SERVICE_ATTR_CO2_INTENSITY = "co2_intensity"
HA_SERVICE_ATTR_NEW_PIN = "new_pin"
HA_SERVICE_ATTR_UPDATE_CONFIG = "update_config"
HA_SERVICE_ATTR_DATA = "data"
12 changes: 12 additions & 0 deletions custom_components/blanco_unit/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@ def _connection_changed(self, connected: bool) -> None:
if self.data is not None:
self.async_set_updated_data(replace(self.data, connected=connected))

# -------------------------------
# region Testing
# -------------------------------

async def test_protocol_parameters(
self, evt_type: int, ctrl: int | None = None, pars: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""Test protocol parameters by sending a custom event."""
return await self._call(
self._client.test_protocol_parameters, evt_type, ctrl, pars
)

# -------------------------------
# region internal
# -------------------------------
Expand Down
70 changes: 69 additions & 1 deletion custom_components/blanco_unit/services.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Home Assistant services provided by the Blanco Unit integration."""

import json
import logging

import voluptuous as vol

from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr

Expand All @@ -13,11 +14,13 @@
DOMAIN,
HA_SERVICE_ATTR_AMOUNT_ML,
HA_SERVICE_ATTR_CO2_INTENSITY,
HA_SERVICE_ATTR_DATA,
HA_SERVICE_ATTR_DEVICE_ID,
HA_SERVICE_ATTR_NEW_PIN,
HA_SERVICE_ATTR_UPDATE_CONFIG,
HA_SERVICE_CHANGE_PIN,
HA_SERVICE_DISPENSE_WATER,
HA_SERVICE_SCAN_PROTOCOL,
)
from .coordinator import BlancoUnitCoordinator

Expand Down Expand Up @@ -57,6 +60,13 @@ def _validate_amount_ml(value: int) -> int:
}
)

SERVICE_SCAN_PROTOCOL_SCHEMA = vol.Schema(
{
vol.Required(HA_SERVICE_ATTR_DEVICE_ID): cv.string,
vol.Required(HA_SERVICE_ATTR_DATA): dict,
}
)


def async_setup_services(hass: HomeAssistant) -> None:
"""Set up Blanco Unit integration services."""
Expand Down Expand Up @@ -104,6 +114,56 @@ async def handle_change_pin(call: ServiceCall) -> None:
# Reload the config entry to reconnect with new PIN
await hass.config_entries.async_reload(entry_id)

async def handle_scan_protocol(call: ServiceCall) -> dict:
"""Handle the scan_protocol_parameters service call."""

_LOGGER.debug("Scan protocol service called with data: %s", call.data)
coordinator = _get_coordinator(hass, call)

# Extract parameters from the data object
data = call.data[HA_SERVICE_ATTR_DATA]
evt_type = data.get("evt_type", 7)
ctrl = data.get("ctrl")
pars = data.get("pars")

_LOGGER.info(
"Testing protocol parameters: evt_type=%d, ctrl=%s, pars=%s",
evt_type,
ctrl,
pars,
)

# Test the specific parameter combination
response = await coordinator.test_protocol_parameters(
evt_type, ctrl, pars
)

result = {
"evt_type": evt_type,
"ctrl": ctrl,
"pars": pars,
"success": response is not None,
"response": response if response else None,
}

if response:
_LOGGER.info(
"Found data: evt_type=%d, ctrl=%s, pars=%s",
evt_type,
ctrl,
pars,
)
_LOGGER.info("Response: %s", json.dumps(response, indent=2))
else:
_LOGGER.warning(
"No data: evt_type=%d, ctrl=%s, pars=%s",
evt_type,
ctrl,
pars,
)

return result

hass.services.async_register(
DOMAIN,
HA_SERVICE_DISPENSE_WATER,
Expand All @@ -118,6 +178,14 @@ async def handle_change_pin(call: ServiceCall) -> None:
schema=SERVICE_CHANGE_PIN_SCHEMA,
)

hass.services.async_register(
DOMAIN,
HA_SERVICE_SCAN_PROTOCOL,
handle_scan_protocol,
schema=SERVICE_SCAN_PROTOCOL_SCHEMA,
supports_response=SupportsResponse.ONLY,
)


def _get_coordinator(hass: HomeAssistant, call: ServiceCall) -> BlancoUnitCoordinator:
"""Extract device_id from service call and return the coordinator."""
Expand Down
Loading
Loading