From d49ee0ae83128f4ebc58080eeaa14bed4f5f84cf Mon Sep 17 00:00:00 2001 From: Alexey 'Cluster' Avdyukhin Date: Sat, 13 Aug 2022 10:30:50 +0300 Subject: Migrated to new Home Assistant Bluetooth API --- custom_components/skykettle/__init__.py | 2 +- custom_components/skykettle/config_flow.py | 68 ++------- custom_components/skykettle/const.py | 2 - custom_components/skykettle/kettle_connection.py | 175 ++++++++--------------- custom_components/skykettle/manifest.json | 6 +- hacs.json | 2 +- 6 files changed, 75 insertions(+), 180 deletions(-) diff --git a/custom_components/skykettle/__init__.py b/custom_components/skykettle/__init__.py index 257ad98..0506862 100644 --- a/custom_components/skykettle/__init__.py +++ b/custom_components/skykettle/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): config[CONF_FRIENDLY_NAME] = config[CONF_FRIENDLY_NAME] + 'S' hass.config_entries.async_update_entry(entry, data=config) _LOGGER.info(f"Fixed invalid model name: {config[CONF_FRIENDLY_NAME][:-1]} -> {config[CONF_FRIENDLY_NAME]}") - + kettle = KettleConnection( mac=entry.data[CONF_MAC], key=entry.data[CONF_PASSWORD], diff --git a/custom_components/skykettle/config_flow.py b/custom_components/skykettle/config_flow.py index 1d6b660..32e6bc1 100644 --- a/custom_components/skykettle/config_flow.py +++ b/custom_components/skykettle/config_flow.py @@ -4,7 +4,9 @@ import re import secrets import traceback import sys +import asyncio import subprocess +from homeassistant.components import bluetooth import voluptuous as vol from homeassistant.const import * import homeassistant.helpers.config_validation as cv @@ -50,56 +52,8 @@ class SkyKettleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the user step.""" - # Check OS - if sys.platform != "linux": - return self.async_abort(reason='linux_not_found') - # Test binaries - try: - subprocess.Popen(["timeout"], shell=False).kill() - except FileNotFoundError: - _LOGGER.error(traceback.format_exc()) - return self.async_abort(reason='timeout_not_found') - try: - subprocess.Popen(["gatttool"], shell=False).kill() - except FileNotFoundError: - _LOGGER.error(traceback.format_exc()) - return self.async_abort(reason='gatttool_not_found') - try: - subprocess.Popen(["hcitool"], shell=False).kill() - except FileNotFoundError: - _LOGGER.error(traceback.format_exc()) - return self.async_abort(reason='hcitool_not_found') - return await self.async_step_select_adapter() - - async def async_step_select_adapter(self, user_input=None): - """Handle the select_adapter step.""" - errors = {} - if user_input is not None: - spl = user_input[CONF_DEVICE].split(' ', maxsplit=1) - name = None - if spl[0] != "auto": name = spl[0] - self.config[CONF_DEVICE] = name - # Continue to scan - return await self.async_step_scan_message() - - try: - adapters = await ble_get_adapters() - _LOGGER.debug(f"Adapters: {adapters}") - adapters_list = [f"{r.name} ({r.mac})" for r in adapters] - adapters_list = ["auto"] + adapters_list # Auto - schema = vol.Schema( - { - vol.Required(CONF_DEVICE): vol.In(adapters_list) - }) - except Exception: - _LOGGER.error(traceback.format_exc()) - return self.async_abort(reason='unknown') - return self.async_show_form( - step_id="select_adapter", - errors=errors, - data_schema=schema - ) - + return await self.async_step_scan_message() + async def async_step_scan_message(self, user_input=None): """Handle the scan_message step.""" if user_input is not None: @@ -127,12 +81,16 @@ class SkyKettleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_connect() try: - macs = await ble_scan(self.config.get(CONF_DEVICE, None), scan_time=BLE_SCAN_TIME) - _LOGGER.debug(f"Scan result: {macs}") - macs_filtered = [mac for mac in macs if mac.name and (mac.name.startswith("RK-") or mac.name.startswith("RFS-"))] - if len(macs_filtered) == 0: + scanner = bluetooth.async_get_scanner(self.hass) + await scanner.start() + await asyncio.sleep(5) + await scanner.stop() + for device in scanner.discovered_devices: + _LOGGER.debug(f"Device found: {device.address} - {device.name}") + devices_filtered = [device for device in scanner.discovered_devices if device.name and (device.name.startswith("RK-") or device.name.startswith("RFS-"))] + if len(devices_filtered) == 0: return self.async_abort(reason='kettle_not_found') - mac_list = [f"{r.mac} ({r.name})" for r in macs_filtered] + mac_list = [f"{r.address} ({r.name})" for r in devices_filtered] schema = vol.Schema( { vol.Required(CONF_MAC): vol.In(mac_list) diff --git a/custom_components/skykettle/const.py b/custom_components/skykettle/const.py index 8482cad..9f8d475 100644 --- a/custom_components/skykettle/const.py +++ b/custom_components/skykettle/const.py @@ -5,8 +5,6 @@ FRIENDLY_NAME = "SkyKettle" MANUFACTORER = "Redmond" SUGGESTED_AREA = "kitchen" -REGEX_MAC = r"^(([0-9a-fA-F]){2}[:-]?){5}[0-9a-fA-F]{2}$" - CONF_PERSISTENT_CONNECTION = "persistent_connection" DEFAULT_SCAN_INTERVAL = 5 diff --git a/custom_components/skykettle/kettle_connection.py b/custom_components/skykettle/kettle_connection.py index cc7d7c5..d95f6a7 100644 --- a/custom_components/skykettle/kettle_connection.py +++ b/custom_components/skykettle/kettle_connection.py @@ -1,36 +1,34 @@ import logging -import pexpect import asyncio -from time import monotonic, sleep -try: - from const import * -except ModuleNotFoundError: - from .const import * -try: - from skykettle import SkyKettle -except ModuleNotFoundError: - from .skykettle import SkyKettle +from time import monotonic +from homeassistant.components import bluetooth +from bleak import BleakScanner, BleakClient +from .const import * +from .skykettle import SkyKettle import traceback _LOGGER = logging.getLogger(__name__) class KettleConnection(SkyKettle): - BLE_TIMEOUT = 1.5 - MAX_TRIES = 5 + UUID_SERVICE = "6e400001-b5a3-f393e-0a9e-50e24dcca9e" + UUID_TX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" + UUID_RX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" + BLE_RECV_TIMEOUT = 1.5 + MAX_TRIES = 3 TRIES_INTERVAL = 0.5 STATS_INTERVAL = 15 TARGET_TTL = 30 def __init__(self, mac, key, persistent=True, adapter=None, hass=None, model=None): super().__init__(model) - self._child = None + self._device = None + self._client = None self._mac = mac self._key = key self.persistent = persistent self.adapter = adapter self.hass = hass - self._connected = False self._auth_ok = False self._sw_version = None self._iter = 0 @@ -50,111 +48,76 @@ class KettleConnection(SkyKettle): self._fresh_water = None self._colors = {} self._disposed = False - - async def _sendline(self, data): - if self.hass == None: - self._child.sendline(data) - else: - await self.hass.async_add_executor_job(self._child.sendline, data) - - async def _sendcontrol(self, data): - if self.hass == None: - self._child.sendcontrol(data) - else: - await self.hass.async_add_executor_job(self._child.sendcontrol, data) + self._last_data = None async def command(self, command, params=[]): if self._disposed: raise DisposedError() - if not self._connected or not self._child: + if not self._client or not self._client.is_connected: raise IOError("not connected") self._iter = (self._iter + 1) % 256 _LOGGER.debug(f"Writing command {command:02x}, data: [{' '.join([f'{c:02x}' for c in params])}]") - data = f"char-write-req 0x000e 55{self._iter:02x}{''.join([f'{c:02x}' for c in [command] + list(params)])}aa" - #_LOGGER.debug(f"Writing {data}") - await self._sendline(data) + data = bytes([0x55, self._iter, command] + list(params) + [0xAA]) + # _LOGGER.debug(f"Writing {data}") + await self._client.write_gatt_char(KettleConnection.UUID_TX, data) + timeout_time = monotonic() + KettleConnection.BLE_RECV_TIMEOUT + self._last_data = None while True: - r = await self._child.expect([ - r"value:([ 0-9a-f]*)\r\n.*?\[LE\]> ", - r"Disconnected\r\n.*?\[LE\]> ", - ], async_=True) - if r == 1: - _LOGGER.debug("'Disconnected' message received") - raise IOError("Disconnected") - hex_response = self._child.match.group(1).decode().strip() - r = bytes.fromhex(hex_response.replace(' ','')) - if r[0] != 0x55 or r[-1] != 0xAA: - raise IOError("Invalid response magic") - if r[1] == self._iter: break + await asyncio.sleep(0.05) + if self._last_data: + r = self._last_data + if r[0] != 0x55 or r[-1] != 0xAA: + raise IOError("Invalid response magic") + if r[1] == self._iter: + break + else: + self._last_data = None + if monotonic() >= timeout_time: raise IOError("Receive timeout") if r[2] != command: raise IOError("Invalid response command") clean = bytes(r[3:-1]) _LOGGER.debug(f"Received: {' '.join([f'{c:02x}' for c in clean])}") return clean + def _rx_callback(self, sender, data): + # _LOGGER.debug(f"Received (full): {' '.join([f'{c:02x}' for c in data])}") + self._last_data = data + async def _connect(self): if self._disposed: raise DisposedError() - if self._connected and self._child and self._child.isalive(): return - if not self._child or not self._child.isalive(): - _LOGGER.debug("Starting \"gatttool\"...") - self._child = await self.hass.async_add_executor_job(pexpect.spawn, "gatttool", (['-i', self.adapter] if self.adapter else []) + ['-I', '-t', 'random', '-b', self._mac], KettleConnection.BLE_TIMEOUT) - await self._child.expect(r"\[LE\]> ", async_=True) - _LOGGER.debug("\"gatttool\" started") - await self._sendline(f"connect") - await self._child.expect(r"Attempting to connect.*?\[LE\]> ", async_=True) - _LOGGER.debug("Attempting to connect...") - await self._child.expect(r"Connection successful.*?\[LE\]> ", async_=True) - await self._sendline("char-write-cmd 0x000c 0100") - await self._child.expect(r"\[LE\]> ", async_=True) + if self._client and self._client.is_connected: return + self._device = bluetooth.async_ble_device_from_address(self.hass, self._mac) + self._client = BleakClient(self._device) + _LOGGER.debug("Connecting to the Kettle...") + await self._client.connect() _LOGGER.debug("Connected to the Kettle") + await self._client.start_notify(KettleConnection.UUID_RX, self._rx_callback) + _LOGGER.debug("Subscribed to RX") auth = lambda self: super().auth(self._key) async def _disconnect(self): try: - if self._child and self._child.isalive(): - await self._sendline(f"disconnect") - await self._child.expect(r"\[LE\]> ", async_=True) - if self._connected: - _LOGGER.debug("Disconnected") + if self._client and self._client.is_connected: + self._client.disconnect() + _LOGGER.debug("Disconnected") finally: - self._connected = False self._auth_ok = False - - async def _terminate(self): - try: - if self._child and self._child.isalive(): - try: - await self._sendcontrol('d') - timeout = 1 - while self._child.isalive(): - await asyncio.sleep(0.025) - timeout = timeout - 0.25 - if timeout <= 0: - if self.hass == None: - self._child.terminate() - else: - await self.hass.async_add_executor_job(self._child.terminate) - break - _LOGGER.debug("Terminated") - except Exception as ex: - _LOGGER.error(f"Can't terminate, error ({type(ex).__name__}): {str(ex)}") - finally: - self._child = None + self._device = None + self._client = None async def disconnect(self): try: await self._disconnect() except: pass - await self._terminate() async def _connect_if_need(self): - if not self._connected or not self._child or not self._child.isalive(): + if not self._client or not self._client.is_connected: try: await self._connect() - self._last_connect_ok = self._connected = True + self._last_connect_ok = True except Exception as ex: self._last_connect_ok = False raise ex @@ -275,26 +238,18 @@ class KettleConnection(SkyKettle): return True except Exception as ex: - if type(ex) == DisposedError: return await self.disconnect() if self._target_state != None and self._last_set_target + KettleConnection.TARGET_TTL < monotonic(): _LOGGER.warning(f"Can't set mode to {self._target_state} for {KettleConnection.TARGET_TTL} seconds, stop trying") self._target_state = None if type(ex) == AuthError: return self.add_stat(False) - if type(ex) == pexpect.exceptions.TIMEOUT: - msg = "Timeout" # too many debug info - else: - msg = f"{type(ex).__name__}: {str(ex)}" if tries > 1 and extra_action == None: - _LOGGER.debug(f"{msg}, retry #{KettleConnection.MAX_TRIES - tries + 1}") + _LOGGER.debug(f"{type(ex).__name__}: {str(ex)}, retry #{KettleConnection.MAX_TRIES - tries + 1}") await asyncio.sleep(KettleConnection.TRIES_INTERVAL) - return await self.update(tries=tries-1, force_stats=force_stats, commit=commit) - elif type(ex) != pexpect.exceptions.TIMEOUT: - _LOGGER.error(traceback.format_exc()) + return await self.update(tries=tries-1, force_stats=force_stats, extra_action=extra_action, commit=commit) else: - _LOGGER.debug(f"Timeout") - #_LOGGER.warning(f"{type(ex).__name__}: {str(ex)}") + _LOGGER.debug(f"Can't update status") return False def add_stat(self, value): @@ -330,25 +285,9 @@ class KettleConnection(SkyKettle): def stop(self): if self._disposed: return + self._disconnect() self._disposed = True - if self._child and self._child.isalive(): - if self.hass == None: - self._child.sendcontrol('d') - else: - self.hass.async_add_executor_job(self._child.sendcontrol, 'd') - timeout = 1 - while self._child.isalive(): - sleep(0.025) - timeout = timeout - 0.25 - if timeout <= 0: - if self.hass == None: - self._child.terminate() - else: - self.hass.async_add_executor_job(self._child.terminate) - break - self._child = None - self._connected = False - _LOGGER.info("Disposed.") + _LOGGER.info("Stopped.") @property def available(self): @@ -447,7 +386,7 @@ class KettleConnection(SkyKettle): @property def connected(self): - return self._connected + return True if self._client and self._client.is_connected else False @property def auth_ok(self): @@ -491,7 +430,7 @@ class KettleConnection(SkyKettle): @property def colors_lamp(self): return self._colors.get(SkyKettle.LIGHT_LAMP, None) - + @property def parental_control(self): if not self._status: return None @@ -513,7 +452,7 @@ class KettleConnection(SkyKettle): if light_type not in self._colors: return None colors = self._colors[light_type] return colors.brightness - + def get_temperature(self, light_type, n): if light_type not in self._colors: return None colors = self._colors[light_type] @@ -551,7 +490,7 @@ class KettleConnection(SkyKettle): _LOGGER.info(f"Setting boil time to {value}") self._target_boil_time = value await self.update(commit=True) - + async def impulse_color(self, r, g, b, brightness): await self.update(extra_action=super().impulse_color(r, g, b, brightness)) @@ -595,7 +534,7 @@ class KettleConnection(SkyKettle): async def set_temperature(self, light_type, n, temp): temp = int(temp) - if light_type not in self._colors: return + if light_type not in self._colors: return self._last_get_stats = monotonic() # To avoid race condition colors = self._colors[light_type] temp = int(temp) diff --git a/custom_components/skykettle/manifest.json b/custom_components/skykettle/manifest.json index 85ee739..4fc570c 100644 --- a/custom_components/skykettle/manifest.json +++ b/custom_components/skykettle/manifest.json @@ -1,9 +1,9 @@ { "domain": "skykettle", "name": "SkyKettle", - "version": "1.7", - "dependencies": [], - "requirements": ["pexpect==4.8.0"], + "version": "2.0", + "dependencies": ["bluetooth"], + "requirements": [], "codeowners": ["@clusterm"], "config_flow": true, "iot_class": "local_polling", diff --git a/hacs.json b/hacs.json index dc65bba..f8ff6fb 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "SkyKettle", - "homeassistant": "2022.7.1", + "homeassistant": "2022.8.1", "render_readme": true, "country": ["RU"] } -- cgit v1.2.3