diff --git a/.coverage b/.coverage index 926ab5b..f372c82 100644 Binary files a/.coverage and b/.coverage differ diff --git a/BLUETOOTH_PROTOCOL.md b/BLUETOOTH_PROTOCOL.md index 085e16d..154622f 100644 --- a/BLUETOOTH_PROTOCOL.md +++ b/BLUETOOTH_PROTOCOL.md @@ -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": }` - 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": , "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 | ### 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": }, "set_point_heating": {"val": 65}}` | 4-10°C | Set target cooling temperature (heating is always 65°C) | +| Water hardness | `{"wtr_hardness": {"val": }}` | 1-9 | Set water hardness level | +| Still water calibration | `{"calib_still_wtr": {"val": }}` | 1-10 | Calibrate still water flow | +| Soda water calibration | `{"calib_soda_wtr": {"val": }}` | 1-10 | Calibrate carbonated water flow | + +## Event Types (Blanco Choice.all) + +Yet to be defined. ## Request/Response Examples diff --git a/README.md b/README.md index 4418d31..b11ebf7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/blanco_unit/client.py b/custom_components/blanco_unit/client.py index 0030cc1..774831e 100644 --- a/custom_components/blanco_unit/client.py +++ b/custom_components/blanco_unit/client.py @@ -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"), @@ -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), @@ -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), @@ -562,9 +559,8 @@ 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"), @@ -572,9 +568,8 @@ async def get_device_identity(self) -> BlancoUnitIdentity: 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", ""), @@ -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 diff --git a/custom_components/blanco_unit/config_flow.py b/custom_components/blanco_unit/config_flow.py index 7cea60f..adadb73 100644 --- a/custom_components/blanco_unit/config_flow.py +++ b/custom_components/blanco_unit/config_flow.py @@ -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, diff --git a/custom_components/blanco_unit/const.py b/custom_components/blanco_unit/const.py index dc31bdf..d1f0d74 100644 --- a/custom_components/blanco_unit/const.py +++ b/custom_components/blanco_unit/const.py @@ -18,6 +18,7 @@ # 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" @@ -25,3 +26,4 @@ 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" diff --git a/custom_components/blanco_unit/coordinator.py b/custom_components/blanco_unit/coordinator.py index 5085099..2a1d2ab 100644 --- a/custom_components/blanco_unit/coordinator.py +++ b/custom_components/blanco_unit/coordinator.py @@ -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 # ------------------------------- diff --git a/custom_components/blanco_unit/services.py b/custom_components/blanco_unit/services.py index 76a7caa..05250c4 100644 --- a/custom_components/blanco_unit/services.py +++ b/custom_components/blanco_unit/services.py @@ -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 @@ -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 @@ -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.""" @@ -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, @@ -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.""" diff --git a/custom_components/blanco_unit/services.yaml b/custom_components/blanco_unit/services.yaml index e4f18d7..2e88b27 100644 --- a/custom_components/blanco_unit/services.yaml +++ b/custom_components/blanco_unit/services.yaml @@ -65,3 +65,32 @@ change_pin: default: false selector: boolean: + +scan_protocol_parameters: + name: Scan Protocol Parameters + description: Test protocol parameters by sending custom BLE commands. Results are returned and logged to debug. Use evt_type (required), ctrl (optional), and pars (optional) to construct the request. + fields: + device_id: + name: Device + description: The Blanco Unit device to test. + required: true + example: "a1b2c3d4e5f6" + selector: + device: + integration: blanco_unit + data: + name: Data + description: JSON object with evt_type (required), ctrl (optional), and pars (optional) fields. Example to get system info - {"evt_type":7, "ctrl":3, "pars":{"evt_type":2}}. + required: true + default: + evt_type: 7 + ctrl: 3 + pars: + evt_type: 2 + example: + evt_type: 7 + ctrl: 3 + pars: + evt_type: 6 + selector: + object: diff --git a/test_cli.py b/test_cli.py index ba078d1..6742a20 100755 --- a/test_cli.py +++ b/test_cli.py @@ -4,18 +4,18 @@ from __future__ import annotations import asyncio + +# Import client with workaround for relative imports +import importlib.util +import os import sys -from typing import Any +import traceback +import types import bleak from bleak import BleakScanner -from bleak.backends.device import BLEDevice from bleak.backends import get_default_backend - - -# Import client with workaround for relative imports -import importlib.util -import os +from bleak.backends.device import BLEDevice # Create a proper package structure in sys.modules to support relative imports sys.path.insert(0, "custom_components") @@ -35,7 +35,8 @@ data_spec.loader.exec_module(data_module) # Create a fake package module for blanco_unit -import types + + blanco_unit_package = types.ModuleType("blanco_unit") blanco_unit_package.__path__ = [os.path.join("custom_components", "blanco_unit")] blanco_unit_package.__package__ = "blanco_unit" @@ -43,8 +44,11 @@ # Load client module with package context client_path = os.path.join("custom_components", "blanco_unit", "client.py") -client_spec = importlib.util.spec_from_file_location("blanco_unit.client", client_path, - submodule_search_locations=[os.path.join("custom_components", "blanco_unit")]) +client_spec = importlib.util.spec_from_file_location( + "blanco_unit.client", + client_path, + submodule_search_locations=[os.path.join("custom_components", "blanco_unit")], +) client_module = importlib.util.module_from_spec(client_spec) client_module.__package__ = "blanco_unit" sys.modules["blanco_unit.client"] = client_module @@ -75,6 +79,7 @@ def print_header(self) -> None: except AttributeError: try: import importlib.metadata + bleak_version = importlib.metadata.version("bleak") except Exception: # noqa: BLE001 bleak_version = "Unknown" @@ -139,7 +144,10 @@ def display_devices(self, devices: list[BLEDevice]) -> None: print(f" Address: {device.address}") # Get RSSI from advertisement data if available - if hasattr(self, 'devices_with_advdata') and device.address in self.devices_with_advdata: + if ( + hasattr(self, "devices_with_advdata") + and device.address in self.devices_with_advdata + ): _, adv_data = self.devices_with_advdata[device.address] rssi = adv_data.rssi print(f" RSSI: {rssi} dBm") @@ -151,7 +159,7 @@ def select_device(self, devices: list[BLEDevice]) -> BLEDevice | None: while True: try: choice = input("Select device number (or 'q' to quit): ").strip() - if choice.lower() == 'q': + if choice.lower() == "q": return None idx = int(choice) - 1 @@ -184,7 +192,9 @@ async def connect_to_device(self) -> bool: print("Error: Device or PIN not set") return False - print(f"\nConnecting to {self.device.name or 'Unknown'} ({self.device.address})...") + print( + f"\nConnecting to {self.device.name or 'Unknown'} ({self.device.address})..." + ) try: self.client = BlancoUnitBluetoothClient( @@ -245,7 +255,7 @@ async def show_menu(self) -> None: try: if choice == "0": break - elif choice == "1": + if choice == "1": await self.test_get_system_info() elif choice == "2": await self.test_get_settings() @@ -272,6 +282,7 @@ async def show_menu(self) -> None: except Exception as e: print(f"\n✗ Error: {e}") import traceback + traceback.print_exc() # Test methods for each function @@ -348,8 +359,7 @@ async def test_set_temperature(self) -> None: else: print("✗ Failed to set temperature") break - else: - print("Temperature must be between 4 and 10°C") + print("Temperature must be between 4 and 10°C") except ValueError: print("Please enter a valid number") @@ -370,8 +380,7 @@ async def test_set_water_hardness(self) -> None: else: print("✗ Failed to set water hardness") break - else: - print("Hardness level must be between 1 and 9") + print("Hardness level must be between 1 and 9") except ValueError: print("Please enter a valid number") @@ -397,11 +406,15 @@ async def test_dispense_water(self) -> None: print("CO2 intensity must be 1, 2, or 3") continue - confirm = input(f"Dispense {amount_int}ml with CO2 level {co2_int}? (y/n): ").strip().lower() - if confirm == 'y': + confirm = ( + input(f"Dispense {amount_int}ml with CO2 level {co2_int}? (y/n): ") + .strip() + .lower() + ) + if confirm == "y": result = await self.client.dispense_water(amount_int, co2_int) if result: - print(f"✓ Dispensing started successfully") + print("✓ Dispensing started successfully") else: print("✗ Failed to start dispensing") break @@ -447,7 +460,9 @@ async def test_change_pin(self) -> None: print("⚠️ WARNING: This will change the device PIN!") print("⚠️ Make sure you remember the new PIN!") - confirm = input("Are you sure you want to change the PIN? (yes/no): ").strip().lower() + confirm = ( + input("Are you sure you want to change the PIN? (yes/no): ").strip().lower() + ) if confirm != "yes": print("PIN change cancelled") return @@ -465,8 +480,7 @@ async def test_change_pin(self) -> None: else: print("✗ Failed to change PIN") break - else: - print("PINs don't match. Please try again.") + print("PINs don't match. Please try again.") else: print("PIN must be exactly 5 digits. Please try again.") @@ -497,7 +511,9 @@ async def run(self) -> None: print("No device selected. Exiting.") return - print(f"\nSelected: {self.device.name or 'Unknown'} ({self.device.address})") + print( + f"\nSelected: {self.device.name or 'Unknown'} ({self.device.address})" + ) # Get PIN self.pin = self.get_pin() @@ -509,9 +525,9 @@ async def run(self) -> None: except KeyboardInterrupt: print("\n\nInterrupted by user") - except Exception as e: + except Exception as e: # noqa: BLE001 print(f"\n✗ Unexpected error: {e}") - import traceback + traceback.print_exc() finally: await self.cleanup() @@ -524,4 +540,4 @@ async def main() -> None: if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main())