├── .github └── workflows │ ├── python-publish.yml │ ├── tagged-prerelease.yml │ └── tagged-release.yml ├── .gitignore ├── README.md ├── examples ├── __init__.py └── demo.py ├── home_connect_async ├── __init__.py ├── api.py ├── appliance.py ├── auth.py ├── callback_registery.py ├── common.py ├── const.py └── homeconnect.py ├── requirements.txt ├── setup.cfg └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published, released] 14 | 15 | jobs: 16 | deploy: 17 | environment: pypi 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tagged-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: "Auto Release and Deploy" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v?[0-9]+.[0-9]+.[0-9]+-*" 7 | 8 | jobs: 9 | tagged-release: 10 | name: "Tagged Release" 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: "marvinpinto/action-automatic-releases@latest" 15 | with: 16 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 17 | prerelease: true 18 | 19 | deploy: 20 | name: "Deploy to pypi" 21 | environment: pypi 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: '3.x' 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install build 34 | - name: Build package 35 | run: python -m build 36 | - name: Publish package 37 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 38 | with: 39 | user: __token__ 40 | password: ${{ secrets.PYPI_API_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | name: "Auto Release and Deploy" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v?[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | tagged-release: 10 | name: "Tagged Release" 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: "marvinpinto/action-automatic-releases@latest" 15 | with: 16 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 17 | prerelease: false 18 | 19 | deploy: 20 | name: "Deploy to pypi" 21 | environment: pypi 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: '3.x' 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install build 34 | - name: Build package 35 | run: python -m build 36 | - name: Publish package 37 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 38 | with: 39 | user: __token__ 40 | password: ${{ secrets.PYPI_API_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | *.egg-info 4 | 5 | .vscode 6 | *.code-workspace 7 | 8 | examples/*.txt 9 | examples/*.json 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Description 3 | This is a high-level async Python SDK for BSH Home Connect API. The API allows monitoring and controlling home appliances manufactured by BSH under brnad names such as Bosch, Siemens, Gaggenau and Neff. 4 | 5 | # Functionality 6 | The SDK connects to the Home Connect API and retrieves all the data associated with the logged-in account, which is made available under the SDK's data model. Afterwards, the SDK maintains an up-to-date state by subscribing to receive real time updates from the API. 7 | 8 | # Getting Access 9 | This API provides access to home appliances enabled by Home Connect 10 | (https://home-connect.com). Through the API programs can be started and 11 | stopped, or home appliances configured and monitored. For instance, you can 12 | start a cotton program on a washer and get a notification when the cycle is 13 | complete. 14 | 15 | To get started with this web client, visit https://developer.home-connect.com 16 | and register an account. An application with a client ID for this API client 17 | will be automatically generated for you. 18 | 19 | # Legal Notice 20 | This SDK is was not created by BSH and is not affiliated with it in any way. 21 | 22 | # License 23 | This SDK is licensed under the MIT license. 24 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekutner/home-connect-async/91a707d0d7ce9c154f06f70eccd16cb96162a1e3/examples/__init__.py -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | from aioconsole import ainput 5 | from home_connect_async import HomeConnect, AuthManager, Appliance 6 | 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | REFRESH_TOKEN_FILE = 'examples/refresh_token.txt' 10 | APPLIANCES_DATA_FILE = 'examples/appliances_data.json' 11 | 12 | CLIENT_ID = os.getenv('CLIENT_ID') 13 | CLIENT_SECRET = os.getenv('CLIENT_SECRET') 14 | 15 | async def event_handler(appliance:Appliance, key:str, value) -> None: 16 | print(f"{appliance.name} -> {key}: {value}" ) 17 | 18 | 19 | async def main(): 20 | 21 | refresh_token = None 22 | am = AuthManager(CLIENT_ID, CLIENT_SECRET, simulate=True) 23 | if os.path.exists(REFRESH_TOKEN_FILE): 24 | with open(REFRESH_TOKEN_FILE, 'r') as f: 25 | refresh_token = f.readline() 26 | am.refresh_token = refresh_token 27 | else: 28 | am.login() 29 | refresh_token = am.refresh_token 30 | with open(REFRESH_TOKEN_FILE, 'w+') as f: 31 | f.write(refresh_token) 32 | 33 | js = None 34 | if os.path.exists(APPLIANCES_DATA_FILE): 35 | with open(APPLIANCES_DATA_FILE, 'r') as file: 36 | js = file.read() 37 | 38 | hc = await HomeConnect.create(am, json_data=js) 39 | 40 | 41 | if js is None: 42 | js = hc.to_json(indent=2) 43 | with open(APPLIANCES_DATA_FILE, 'w+') as file: 44 | file.write(js) 45 | for appliance in hc.appliances.values(): 46 | appliance.register_callback(event_handler, 'BSH.Common.Status.DoorState' ) 47 | hc.subscribe_for_updates() 48 | exit = False 49 | while not exit: 50 | line = await ainput() 51 | if line == 'exit': exit=True 52 | elif line == 'active': 53 | p = await hc.get_appliance('SIEMENS-HCS02DWH1-D1349B55F7EC').get_active_program() 54 | if p is not None: 55 | print(p.to_json(indent=2)) 56 | else: 57 | print("No active program") 58 | elif line == 'selected': 59 | p = await hc.get_appliance('SIEMENS-HCS02DWH1-D1349B55F7EC').get_selected_program() 60 | if p is not None: 61 | print(p.to_json(indent=2)) 62 | else: 63 | print("No selected program") 64 | 65 | hc.close() 66 | await am.close() 67 | 68 | 69 | asyncio.get_event_loop().run_until_complete(main()) 70 | -------------------------------------------------------------------------------- /home_connect_async/__init__.py: -------------------------------------------------------------------------------- 1 | from .homeconnect import HomeConnect 2 | from .appliance import Appliance 3 | from .auth import AuthManager, AbstractAuth 4 | from .common import HealthStatus, HomeConnectError, ConditionalLogger 5 | from .const import Events 6 | 7 | -------------------------------------------------------------------------------- /home_connect_async/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | import logging 4 | import asyncio 5 | from collections.abc import Callable 6 | from aiohttp import ClientResponse 7 | 8 | from .auth import AbstractAuth 9 | from .common import ConditionalLogger, HomeConnectError, HealthStatus 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class HomeConnectApi(): 14 | """ A class that provides basic API calling facilities to the Home Connect API """ 15 | @dataclass 16 | class ApiResponse(): 17 | """ Class to encapsulate a service response """ 18 | response:ClientResponse 19 | status:int 20 | json:str 21 | data:any 22 | error:any 23 | 24 | def __init__(self, response:ClientResponse, json_body): 25 | self.response = response 26 | self.status = response.status 27 | self.json_body = json_body 28 | self.data = json_body['data'] if json_body and 'data' in json_body else None 29 | self.error = json_body['error'] if json_body and 'error' in json_body else None 30 | 31 | @property 32 | def error_key(self) -> str | None: 33 | """ Dynamically extract the error key from the response """ 34 | if self.error and "key" in self.error: 35 | return self.error["key"] 36 | return None 37 | 38 | @property 39 | def error_description(self) -> str | None: 40 | """ Dynamically extract the error description from the response """ 41 | if self.error and "description" in self.error: 42 | return self.error["description"] 43 | return None 44 | 45 | 46 | def __init__(self, auth:AbstractAuth, lang:str, health:HealthStatus): 47 | self._auth = auth 48 | self._lang = lang 49 | self._health = health 50 | self._call_counter = 0 51 | 52 | async def _async_request(self, method:str, endpoint:str, data=None) -> ApiResponse: 53 | """ Main function to call the Home Connect API over HTTPS """ 54 | method = method.upper() 55 | retry = 3 56 | response = None 57 | while retry: 58 | try: 59 | self._call_counter += 1 60 | 61 | if ConditionalLogger.ismode(ConditionalLogger.LogMode.REQUESTS): 62 | if data: 63 | _LOGGER.debug("\nHTTP %s %s (try=%d count=%d)\n%s\n", method, endpoint, 4-retry, self._call_counter, data) 64 | else: 65 | _LOGGER.debug("\nHTTP %s %s (try=%d count=%d)\n", method, endpoint, 4-retry, self._call_counter) 66 | 67 | response = await self._auth.request(method, endpoint, self._lang, data=data) 68 | 69 | 70 | # if self._log_mode and (self._log_mode & LogMode.REQUESTS) and (self._log_mode & LogMode.RESPONSES): 71 | # _LOGGER.debug("\nHTTP RESPONSE [%d] (try=%d count=%d) ====>\n%s\n", response.status,4-retry, self._call_counter, await response.text(encoding="UTF-8")) 72 | # if data: 73 | # _LOGGER.debug("\nHTTP %s %s [%d] (try=%d count=%d)\n%s\nResponse ====>\n%s", method, endpoint, response.status, 4-retry, self._call_counter, data, await response.text(encoding="UTF-8")) 74 | # else: 75 | # _LOGGER.debug("\nHTTP %s %s [%d] (try=%d count=%d)\nResponse ====>\n%s", method, endpoint, response.status, 4-retry, self._call_counter, await response.text(encoding="UTF-8")) 76 | # elif self._log_mode and (self._log_mode & LogMode.REQUESTS) and data: 77 | # _LOGGER.debug("\nHTTP %s %s [%d] (try=%d count=%d)\n%s", method, endpoint, response.status, 4-retry, self._call_counter, data) 78 | if ConditionalLogger.ismode(ConditionalLogger.LogMode.RESPONSES): 79 | if response.content_length and response.content_length>0: 80 | _LOGGER.debug("\nHTTP %s %s (try=%d count=%d) [%d %s] ====>\n%s\n", method, endpoint, 4-retry, self._call_counter, response.status, response.reason, await response.text(encoding="UTF-8")) 81 | else: 82 | _LOGGER.debug("\nHTTP %s %s (try=%d count=%d) [%d %s]\n", method, endpoint, 4-retry, self._call_counter, response.status, response.reason) 83 | else: 84 | _LOGGER.debug("HTTP %s %s (try=%d count=%d) [%d]", method, endpoint, 4-retry, self._call_counter, response.status) 85 | if response.status == 429: # Too Many Requests 86 | wait_time = response.headers.get('Retry-After') 87 | _LOGGER.debug('HTTP Error 429 - Too Many Requests. Sleeping for %s seconds and will retry', wait_time) 88 | self._health.set_status(self._health.Status.BLOCKED, int(wait_time)) 89 | await asyncio.sleep(int(wait_time)+1) 90 | self._health.unset_status(self._health.Status.BLOCKED) 91 | elif method in ["PUT", "DELETE"] and response.status == 204: 92 | result = self.ApiResponse(response, None) 93 | return result 94 | else: 95 | result = self.ApiResponse(response, await response.json(encoding='UTF-8')) 96 | if result.status == 401 or result.status >= 500: # Unauthorized or service error 97 | # This is probably caused by an expired token so the next retry will get a new one automatically 98 | _LOGGER.debug("API got error code=%d key=%s - %d retries left", response.status, result.error_key, retry) 99 | else: 100 | if result.error: 101 | _LOGGER.debug("API call failed with code=%d error=%s", response.status, result.error_key) 102 | return result 103 | except Exception as ex: 104 | _LOGGER.debug("HTTP call failed %s %s", method, endpoint, exc_info=ex) 105 | if not retry: 106 | raise HomeConnectError("API call to HomeConnect service failed", code=901, inner_exception=ex) from ex 107 | finally: 108 | if response: 109 | response.close() 110 | response = None 111 | retry -= 1 112 | 113 | # all retries were exhausted without a valid response 114 | raise HomeConnectError("Failed to get a valid response from Home Connect server", 902) 115 | 116 | 117 | async def async_get(self, endpoint) -> ApiResponse: 118 | """ Implements a HTTP GET request """ 119 | return await self._async_request('GET', endpoint) 120 | 121 | async def async_put(self, endpoint:str, data:str) -> ApiResponse: 122 | """ Implements a HTTP PUT request """ 123 | return await self._async_request('PUT', endpoint, data=data) 124 | 125 | async def async_delete(self, endpoint:str) -> ApiResponse: 126 | """ Implements a HTTP DELETE request """ 127 | return await self._async_request('DELETE', endpoint) 128 | 129 | async def async_get_event_stream(self, endpoint:str, timeout:int): 130 | """ Returns a Server Sent Events (SSE) stream to be consumed by the caller """ 131 | return await self._auth.stream(endpoint, self._lang, timeout) 132 | 133 | 134 | # async def async_stream(self, endpoint:str, timeout:int, event_handler:Callable[[str], None]): 135 | # """ Implements a SSE consumer which calls the defined event handler on every new event""" 136 | # backoff = 2 137 | # event_source = None 138 | # while True: 139 | # try: 140 | # event_source = await self._auth.stream(endpoint, self._lang, timeout) 141 | # await event_source.connect() 142 | # async for event in event_source: 143 | # backoff = 1 144 | # try: 145 | # await event_handler(event) 146 | # except Exception as ex: 147 | # _LOGGER.exception('Unhandled exception in stream event handler', exc_info=ex) 148 | # except asyncio.CancelledError: 149 | # break 150 | # except ConnectionRefusedError as ex: 151 | # _LOGGER.exception('ConnectionRefusedError in SSE connection refused. Will try again', exc_info=ex) 152 | # except ConnectionError as ex: 153 | # error_code = self.parse_sse_error(ex.args[0]) 154 | # if error_code == 429: 155 | # backoff *= 2 156 | # if backoff > 3600: backoff = 3600 157 | # elif backoff < 60: backoff = 60 158 | # _LOGGER.info('Got error 429 when opening event stream connection, will sleep for %s seconds and retry', backoff) 159 | # else: 160 | # _LOGGER.exception('ConnectionError in SSE event stream. Will wait for %d seconds and retry ', backoff, exc_info=ex) 161 | # backoff *= 2 162 | # if backoff > 120: backoff = 120 163 | 164 | # await asyncio.sleep(backoff) 165 | 166 | # except asyncio.TimeoutError: 167 | # # it is expected that the connection will time out every hour 168 | # _LOGGER.debug("The SSE connection timeout, will renew and retry") 169 | # except Exception as ex: 170 | # _LOGGER.exception('Exception in SSE event stream. Will wait for %d seconds and retry ', backoff, exc_info=ex) 171 | # await asyncio.sleep(backoff) 172 | # backoff *= 2 173 | # if backoff > 120: backoff = 120 174 | 175 | # finally: 176 | # if event_source: 177 | # await event_source.close() 178 | # event_source = None 179 | 180 | 181 | # def parse_sse_error(self, error:str) -> int: 182 | # """ Helper function to parse the error code from a SSE exception """ 183 | # try: 184 | # parts = error.split(': ') 185 | # error_code = int(parts[-1]) 186 | # return error_code 187 | # except: 188 | # return 0 189 | -------------------------------------------------------------------------------- /home_connect_async/appliance.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | import json 4 | import logging 5 | from collections.abc import Sequence, Callable 6 | from dataclasses import dataclass, field 7 | import re 8 | from typing import Optional 9 | from dataclasses_json import dataclass_json, Undefined, config 10 | from home_connect_async.api import HomeConnectApi 11 | import home_connect_async.homeconnect as homeconnect 12 | import home_connect_async.callback_registery as callback_registery 13 | from .const import Events 14 | from .common import HomeConnectError, Synchronization 15 | 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | @dataclass_json(undefined=Undefined.EXCLUDE) 20 | @dataclass 21 | class Status(): 22 | """ Class to represent a Home Connect Status """ 23 | key:str 24 | value:Optional[any] = None 25 | name:Optional[str] = None 26 | displayvalue:Optional[str] = None 27 | unit:Optional[str] = None 28 | 29 | @classmethod 30 | def create(cls, data:dict): 31 | """ A factory to create a new instance from a dictionary in the Home Connect format """ 32 | status = Status( 33 | key = data['key'], 34 | name = data.get('name'), 35 | value = data.get('value'), 36 | displayvalue= data.get('displayvalue'), 37 | unit = data.get('unit') 38 | ) 39 | return status 40 | 41 | 42 | @dataclass_json(undefined=Undefined.EXCLUDE) 43 | @dataclass 44 | class Command(): 45 | """ Class to represent a Home Connect Command """ 46 | key:str 47 | name:Optional[str] = None 48 | 49 | @classmethod 50 | def create(cls, data:dict): 51 | """ A factory to create a new instance from a dictionary in the Home Connect format """ 52 | status = Command( 53 | key = data['key'], 54 | name = data.get('name'), 55 | ) 56 | return status 57 | 58 | @dataclass_json(undefined=Undefined.EXCLUDE) 59 | @dataclass 60 | class Option(): 61 | """ Class to represent a Home Connect Option """ 62 | key:str 63 | value:Optional[any] = None 64 | type:Optional[str] = None 65 | name:Optional[str] = None 66 | unit:Optional[str] = None 67 | displayvalue:Optional[str] = None 68 | min:Optional[int] = None 69 | max:Optional[int] = None 70 | stepsize:Optional[int] = None 71 | allowedvalues:Optional[list[str]] = None 72 | allowedvaluesdisplay:Optional[list[str]] = None 73 | execution:Optional[str] = None 74 | liveupdate:Optional[bool] = None 75 | default:Optional[str] = None 76 | access:Optional[str] = None 77 | 78 | @classmethod 79 | def create(cls, data:dict): 80 | """ A factory to create a new instance from a dictionary in the Home Connect format """ 81 | option = Option( 82 | key = data['key'], 83 | type = data.get('type'), 84 | name = data.get('name'), 85 | value = data.get('value'), 86 | unit = data.get('unit'), 87 | displayvalue= data.get('displayvalue') 88 | ) 89 | if 'constraints' in data: 90 | constraints:dict = data['constraints'] 91 | option.min = constraints.get('min') 92 | option.max = constraints.get('max') 93 | option.stepsize = constraints.get('stepsize') 94 | option.allowedvalues = constraints.get('allowedvalues') 95 | option.allowedvaluesdisplay = constraints.get('displayvalues') 96 | option.execution = constraints.get('execution') 97 | option.liveupdate = constraints.get('liveupdate') 98 | option.default = constraints.get('default') 99 | option.access = constraints.get('access') 100 | return option 101 | 102 | def get_option_to_apply(self, value, exception_on_error=False): 103 | """ Construct an option dict that can be sent to the Home Connect API """ 104 | def value_error(): 105 | if exception_on_error: 106 | raise ValueError(f'Invalid value for this option: {value}') 107 | else: 108 | return None 109 | 110 | if self.allowedvalues is not None and value not in self.allowedvalues: 111 | return value_error() 112 | 113 | if self.min is not None and value < self.min: 114 | return value_error() 115 | 116 | if self.max is not None and value > self.max: 117 | return value_error() 118 | 119 | if self.stepsize is not None and value % self.stepsize != 0: 120 | return value_error() 121 | 122 | return { 'key': self.key, 'value': self.value, 'unit': self.unit} 123 | 124 | 125 | 126 | @dataclass_json 127 | @dataclass 128 | class Program(): 129 | """ Class to represent a Home Connect Program """ 130 | 131 | key:str 132 | name:Optional[str] = None 133 | options:dict[str, Option] = None 134 | execution:Optional[str] = None 135 | active:Optional[bool] = False 136 | 137 | @classmethod 138 | def create(cls, data:dict): 139 | """ A factory to create a new instance from a dict in the Home Connect format """ 140 | program = cls(data['key']) 141 | program._update(data) 142 | return program 143 | 144 | def _update(self, data:dict): 145 | self.key = data['key'] 146 | self.name = data.get('name') 147 | if 'constraints' in data: 148 | constraints:dict = data['constraints'] 149 | self.execution = constraints.get('execution') 150 | if 'options' in data: 151 | self.options = {} 152 | for opt in data['options']: 153 | o = Option.create(opt) 154 | self.options[o.key] = o 155 | return self 156 | 157 | 158 | 159 | @dataclass_json(undefined=Undefined.EXCLUDE) 160 | @dataclass 161 | class Appliance(): 162 | """ Class to represent a Home Connect Appliance """ 163 | name:str 164 | brand:str 165 | vib:str 166 | connected:bool 167 | type:str 168 | enumber:str 169 | haId:str 170 | uri:str 171 | #available_programs:Optional[ProgramsDict[str,Program]] = None 172 | available_programs:Optional[dict[str,Program]] = None 173 | active_program:Optional[Program] = None 174 | selected_program:Optional[Program] = None 175 | status:dict[str, Status] = None 176 | settings:dict[str, Option] = None 177 | commands:dict[str, Command] = None 178 | startonly_options:dict[str, Option] = None 179 | startonly_program:Optional[Program] = None 180 | 181 | # Internal fields 182 | _homeconnect:Optional[homeconnect.HomeConnect] = field(default_factory=lambda: None, metadata=config(encoder=lambda val: None, decoder=lambda val: None, exclude=lambda val: True)) 183 | _api:Optional[HomeConnectApi] = field(default=None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 184 | _callbacks:Optional[callback_registery.CallbackRegistry] = field(default_factory=lambda: None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 185 | _active_program_fail_count:Optional[int] = 0 186 | 187 | 188 | #region - Helper functions 189 | def get_applied_program(self) -> Program|None: 190 | """ gets the currently applied program which is the active or startonly or selected program """ 191 | if self.active_program: 192 | return self.active_program 193 | elif self.startonly_program: 194 | return self.startonly_program 195 | elif self.selected_program: 196 | return self.selected_program 197 | else: 198 | return None 199 | 200 | def get_applied_program_option(self, option_key:str) -> Option|None: 201 | prog = self.get_applied_program() 202 | if prog and prog.options and option_key in prog.options: 203 | return prog.options[option_key] 204 | return None 205 | 206 | def get_applied_program_available_options(self) -> dict[Option]|None: 207 | """ gets the available options for the applied program """ 208 | prog = self.get_applied_program() 209 | if prog and self.available_programs and prog.key in self.available_programs: 210 | return self.available_programs[prog.key].options 211 | else: 212 | return None 213 | 214 | def get_applied_program_available_option(self, option_key:str) -> Option|None: 215 | """ gets a specific available option for the applied program """ 216 | opts = self.get_applied_program_available_options() 217 | if opts and option_key in opts: 218 | return opts[option_key] 219 | else: 220 | return None 221 | 222 | def is_available_program(self, program_key:str) -> bool: 223 | """ Test if the specified program is currently available """ 224 | return self.available_programs and program_key in self.available_programs 225 | 226 | def is_available_option(self, option_key:str) -> bool: 227 | """ Test if the specified option key is currently available for the applied program """ 228 | opt = self.get_applied_program_available_option(option_key) 229 | return opt is not None 230 | 231 | #endregion 232 | 233 | #region - Control Appliance 234 | def set_startonly_option(self, option_key:str, value) -> None: 235 | """ Set an option that will be used when starting the program """ 236 | _LOGGER.debug("Setting startonly option %s to: %s", option_key, str(value)) 237 | if not self.startonly_options: 238 | self.startonly_options = {} 239 | if option_key not in self.startonly_options: 240 | self.startonly_options[option_key] = Option(option_key, value=value) 241 | else: 242 | self.startonly_options[option_key].value = value 243 | 244 | def clear_startonly_option(self, option_key:str) -> None: 245 | """ Clear a previously set startonly option """ 246 | if self.startonly_options and option_key in self.startonly_options: 247 | _LOGGER.debug("Clearing startonly option %s", option_key) 248 | del self.startonly_options[option_key] 249 | 250 | async def async_get_active_program(self): 251 | """ Get the active program """ 252 | prog = await self._async_fetch_programs('active') 253 | self.active_program = prog 254 | return prog 255 | 256 | async def async_get_selected_program(self): 257 | """ Get the selected program """ 258 | prog = await self._async_fetch_programs('selected') 259 | self.selected_program = prog 260 | return prog 261 | 262 | async def async_select_program(self, program_key:str=None, options:Sequence[dict]=None, validate:bool=True): 263 | """ Set the selected program 264 | 265 | Parameters: 266 | key: The key of the program to select 267 | options: Additional program options to set 268 | """ 269 | 270 | if self.available_programs and program_key in self.available_programs: 271 | program = self.available_programs[program_key] 272 | elif validate: 273 | _LOGGER.error("The selected program key is not available") 274 | raise HomeConnectError("The selected program key is not available") 275 | else: 276 | program = None 277 | 278 | previous_program = self.startonly_program if self.startonly_program else self.selected_program 279 | if program: 280 | if program.execution == 'startonly' and not self.active_program: 281 | self.startonly_program = program 282 | _LOGGER.debug("Setting startonly_program=%s", program.key) 283 | if not previous_program or previous_program.key != program_key: 284 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_SELECTED, program_key) 285 | return 286 | else: 287 | self.startonly_program = None 288 | 289 | async with Synchronization.selected_program_lock: 290 | if self.active_program: 291 | res = await self._async_set_program(program_key, options, 'active') 292 | else: 293 | res = await self._async_set_program(program_key, options, 'selected') 294 | if res and (not previous_program or previous_program.key != program_key): 295 | # There is a race condition between this and the selected program event 296 | # so check if it was alreayd update so we don't call twice 297 | # Note that this can't be dropped because the new options notification may arrive before the 298 | # program selected event and then the option values will not match the values that were there for the 299 | # previous program 300 | if self.active_program: 301 | self.active_program = await self._async_fetch_programs('active') 302 | else: 303 | self.selected_program = await self._async_fetch_programs('selected') 304 | #TODO: Consider if the above updates can be removed or if adding available_programs is required 305 | self.available_programs = await self._async_fetch_programs('available') 306 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_SELECTED, program_key) 307 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 308 | 309 | 310 | async def async_start_program(self, program_key:str=None, options:Sequence[dict]=None, validate:bool=True) -> bool: 311 | """ Started the specified program 312 | 313 | Parameters: 314 | key: The key of the program to select 315 | options: Additional program options to set 316 | program: A Program object that represents the selected program. If used then "key" is ignored. 317 | """ 318 | 319 | if not program_key: 320 | if self.startonly_program: 321 | program_key = self.startonly_program.key 322 | elif self.selected_program: 323 | program_key = self.selected_program.key 324 | else: 325 | _LOGGER.error('"program_key" was not specified and there is no selected program to start') 326 | raise HomeConnectError('Either "program" or "key" must be specified') 327 | 328 | if validate and (not self.available_programs or program_key not in self.available_programs): 329 | _LOGGER.warning("The selected program in not one of the available programs (not supported by the API)") 330 | raise HomeConnectError("The specified program in not one of the available programs (not supported by the API)") 331 | 332 | if options is None: 333 | options = [] 334 | if self.selected_program and self.available_programs and not self.startonly_program: 335 | for opt in self.selected_program.options.values(): 336 | if opt.key in self.available_programs[program_key].options and (not self.startonly_options or opt.key not in self.startonly_options): 337 | option = { "key": opt.key, "value": opt.value} 338 | options.append(option) 339 | if self.startonly_options: 340 | for opt in self.startonly_options.values(): 341 | option = { "key": opt.key, "value": opt.value} 342 | options.append(option) 343 | 344 | return await self._async_set_program(program_key, options, 'active') 345 | 346 | async def async_stop_active_program(self) -> bool: 347 | """ Stop the active program """ 348 | if self.active_program is None: 349 | await self.async_get_active_program() 350 | if self.active_program: 351 | endpoint = f'{self._base_endpoint}/programs/active' 352 | response = await self._api.async_delete(endpoint) 353 | if response.status == 204: 354 | return True 355 | elif response.error_description: 356 | raise HomeConnectError(response.error_description, response=response) 357 | raise HomeConnectError("Failed to stop the program ({response.status})", response=response) 358 | return False 359 | 360 | async def async_pause_active_program(self): 361 | """ Pause the active program """ 362 | if "BSH.Common.Command.PauseProgram" in self.commands \ 363 | and "BSH.Common.Status.OperationState" in self.status \ 364 | and self.status["BSH.Common.Status.OperationState"].value == "BSH.Common.EnumType.OperationState.Run": 365 | return await self.async_send_command("BSH.Common.Command.PauseProgram", True) 366 | return False 367 | 368 | async def async_resume_paused_program(self): 369 | """ Resume a paused program """ 370 | if "BSH.Common.Command.ResumeProgram" in self.commands \ 371 | and "BSH.Common.Status.OperationState" in self.status \ 372 | and self.status["BSH.Common.Status.OperationState"].value == "BSH.Common.EnumType.OperationState.Pause": 373 | return await self.async_send_command("BSH.Common.Command.ResumeProgram", True) 374 | return False 375 | 376 | async def async_send_command(self, command_key:str, value:any) -> bool: 377 | """ Stop the active program """ 378 | return await self._async_set_service_value("commands", command_key, value) 379 | 380 | async def async_set_option(self, option_key, value) -> bool: 381 | """ Set a value for a specific program option """ 382 | opt = self.get_applied_program_available_option(option_key) 383 | if not opt: 384 | _LOGGER.debug("Attempting to set unavailable option: %s", option_key) 385 | _LOGGER.debug(self.available_programs) 386 | raise ValueError("The option isn't currently available") 387 | 388 | if opt.execution == "startonly": 389 | if value: 390 | self.set_startonly_option(option_key, value) 391 | else: 392 | self.clear_startonly_option(option_key) 393 | return True 394 | 395 | return await self._async_set_service_value("options", option_key, value) 396 | 397 | async def async_apply_setting(self, setting_key, value) -> bool: 398 | """ Apply a global appliance setting """ 399 | return await self._async_set_service_value("settings", setting_key, value) 400 | 401 | async def _async_set_service_value(self, service_type:str, key:str, value:any) -> bool: 402 | """ Helper function to set key/value type service properties """ 403 | if service_type in ['settings', 'commands']: 404 | endpoint = f'{self._base_endpoint}/{service_type}/{key}' 405 | elif service_type == 'options': 406 | if self.active_program: 407 | endpoint = f'{self._base_endpoint}/programs/active/options/{key}' 408 | elif self.selected_program: 409 | endpoint = f'{self._base_endpoint}/programs/selected/options/{key}' 410 | else: 411 | raise ValueError("No active/selected program to apply the options to") 412 | else: 413 | raise ValueError(f"Unsupported service_type value: '{service_type}'") 414 | 415 | command = { 416 | "data": { 417 | "key": key, 418 | "value": value 419 | } 420 | } 421 | jscmd = json.dumps(command, indent=2) 422 | response = await self._api.async_put(endpoint, jscmd) 423 | if response.status == 204: 424 | return True 425 | elif response.error_description: 426 | raise HomeConnectError(response.error_description, response=response) 427 | raise HomeConnectError("Failed to set service value ({response.status})", response=response) 428 | 429 | async def _async_set_program(self, key, options:Sequence[dict], mode:str) -> bool: 430 | """ Main function to handle all scenarions of setting a program """ 431 | endpoint = f'{self._base_endpoint}/programs/{mode}' 432 | if options and not isinstance(options, list): 433 | options = [ options ] 434 | 435 | command = { 436 | "data": { 437 | "key": key, 438 | "options": [] 439 | } 440 | } 441 | retry = True 442 | while retry: 443 | if options: 444 | command['data']['options'] = options 445 | 446 | jscmd = json.dumps(command, indent=2) 447 | response = await self._api.async_put(endpoint, jscmd) 448 | if response.status == 204: 449 | return True 450 | elif response.error_key == "SDK.Error.UnsupportedOption": 451 | m = re.fullmatch("Option ([^ ]*) not supported", response.error_description) 452 | if m: 453 | bad_option = m.group(1) 454 | options = [option for option in options if option['key']!= bad_option] 455 | else: 456 | retry = False 457 | else: 458 | retry = False 459 | 460 | if response.error_description: 461 | raise HomeConnectError(response.error_description, response=response) 462 | raise HomeConnectError("Failed to set program ({response.status})", response=response) 463 | 464 | #endregion 465 | 466 | 467 | async def async_set_connection_state(self, connected:bool): 468 | """ Update the appliance connection state when notified about a state change from the event stream """ 469 | if connected != self.connected: 470 | self.connected = connected 471 | if connected: 472 | await self.async_fetch_data(include_static_data=False) 473 | await self._callbacks.async_broadcast_event(self, Events.CONNECTION_CHANGED, connected) 474 | 475 | 476 | #region - Handle Updates, Events and Callbacks 477 | 478 | 479 | async def async_update_data(self, data:dict) -> None: 480 | """ Update the appliance data model from a change event notification """ 481 | key:str = data["key"] 482 | value = data["value"] 483 | uri = data["uri"] if "uri" in data else "" 484 | 485 | if not self.connected: 486 | # an event was received for a disconnected appliance, which means we didn"t get the CONNECTED event, so reload the appliace data 487 | await self.async_fetch_data() 488 | await self._callbacks.async_broadcast_event(self, Events.PAIRED) 489 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 490 | 491 | # Fetch data from the API on major events 492 | elif key == "BSH.Common.Root.SelectedProgram" and (not self.selected_program or self.selected_program.key != value): 493 | # handle selected program 494 | async with Synchronization.selected_program_lock: 495 | # Have to check again after aquiring the lock 496 | if key == "BSH.Common.Root.SelectedProgram" and (not self.selected_program or self.selected_program.key != value): 497 | if value: 498 | self.selected_program = await self._async_fetch_programs("selected") 499 | selected_key = self.selected_program.key 500 | if not self.available_programs: 501 | self.available_programs = await self._async_fetch_programs("available") 502 | elif selected_key in self.available_programs and self.available_programs[selected_key].options is None: 503 | self.available_programs[selected_key].options = await self._async_fetch_available_options(selected_key) 504 | else: 505 | _LOGGER.debug("Skipping fetch_available_options() for selected program") 506 | 507 | # TODO: Trying to remove update of settings when the selected program is changed (2023-07-15) 508 | # self.settings = await self._async_fetch_settings() 509 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_SELECTED, value) 510 | else: 511 | self.selected_program = None 512 | #self.available_programs = await self._async_fetch_programs("available") 513 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 514 | self._active_program_fail_count = 0 515 | 516 | elif ( # (key == "BSH.Common.Root.ActiveProgram" and value) or # NOTE: It seems that the ActiveProgam event is received before the API returns the active program so let's try to ignore it and rely on the OperationState only 517 | # Apparently it is possible to get progress notifications without getting the Run OperationState first so we handle that 518 | # but we want to give the OperationState event a chance to be received so we wait until the second progress event before we handle it as an active program 519 | (key == "BSH.Common.Option.RemainingProgramTime" and (not self.selected_program or key not in self.selected_program.options or value < self.selected_program.options[key].value)) or 520 | (key == "BSH.Common.Option.ProgramProgress" and value>0 ) or 521 | # it is also possible to get operation state Run without getting the ActiveProgram event 522 | (key == "BSH.Common.Status.OperationState" and value in ["BSH.Common.EnumType.OperationState.Run", "BSH.Common.EnumType.OperationState.DelayedStart"]) 523 | ) and \ 524 | (not self.active_program or (key == "BSH.Common.Root.ActiveProgram" and self.active_program.key != value) ) and \ 525 | self._active_program_fail_count < 3 : 526 | # handle program start 527 | self.active_program = await self._async_fetch_programs("active") 528 | if self.active_program: 529 | self._active_program_fail_count = 0 530 | self.available_programs = await self._async_fetch_programs("available") 531 | self.commands = await self._async_fetch_commands() 532 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_STARTED, self.active_program.key) 533 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 534 | else: 535 | # This is a workaround to prevent rate limiting when receiving progress events but active_program returns 404 536 | self._active_program_fail_count += 1 537 | if self._active_program_fail_count == 3 : 538 | self.available_programs = await self._async_fetch_programs("available") 539 | self.commands = await self._async_fetch_commands() 540 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_STARTED, None) 541 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 542 | 543 | elif ( (key == "BSH.Common.Root.ActiveProgram" and not value) or 544 | (key == "BSH.Common.Status.OperationState" and value == "BSH.Common.EnumType.OperationState.Finished") or 545 | (key == "BSH.Common.Event.ProgramFinished") 546 | ) and self.active_program: 547 | # handle program end 548 | 549 | # NOTE: Depending on the received event there may still be an active program provided by the API 550 | # This creates an inconsistency of HA is restarted while the appliance is in this state 551 | # however, it still seems bettert than relying on the order of received events which is very inconsistent 552 | 553 | prev_prog = self.active_program.key if self.active_program else None 554 | self.active_program = None 555 | self._active_program_fail_count = 0 556 | self.commands = await self._async_fetch_commands() 557 | # TODO: should self.available_programs = None ???? 558 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_FINISHED, prev_prog) 559 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 560 | 561 | elif key == "BSH.Common.Status.OperationState" and value == "BSH.Common.EnumType.OperationState.Ready" \ 562 | and ("BSH.Common.Status.OperationState" not in self.status or self.status["BSH.Common.Status.OperationState"].value != value): # ignore repeating events 563 | prev_prog = self.active_program.key if self.active_program else None 564 | self.active_program = None 565 | self._active_program_fail_count = 0 566 | self.selected_program = await self._async_fetch_programs("selected") 567 | self.available_programs = await self._async_fetch_programs("available") 568 | self.commands = await self._async_fetch_commands() 569 | if not self.settings: 570 | self.settings = await self._async_fetch_settings() 571 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 572 | if prev_prog: 573 | await self._callbacks.async_broadcast_event(self, Events.PROGRAM_FINISHED, prev_prog) 574 | 575 | elif key == "BSH.Common.Status.OperationState" and value == "BSH.Common.EnumType.OperationState.Inactive" \ 576 | and ("BSH.Common.Status.OperationState" not in self.status or self.status["BSH.Common.Status.OperationState"].value != value): 577 | self.active_program = None 578 | self._active_program_fail_count = 0 579 | self.selected_program = None 580 | # It appears that there are appliances, like Hood that can be inactive, or even powered off, 581 | # but still have available programs that can be started with a call to start_program. 582 | # However, we only want to fetch the available program if we we're in a state that already fetched them 583 | if "BSH.Common.Status.OperationState" not in self.status or self.status["BSH.Common.Status.OperationState"].value != "BSH.Common.EnumType.OperationState.Ready": 584 | self.available_programs = await self._async_fetch_programs("available") 585 | 586 | # Update the commands only if they weren't updated in the previous state 587 | if "BSH.Common.Status.OperationState" not in self.status or \ 588 | self.status["BSH.Common.Status.OperationState"].value not in \ 589 | ["BSH.Common.EnumType.OperationState.Finished", "BSH.Common.EnumType.OperationState.Ready"]: 590 | self.commands = await self._async_fetch_commands() 591 | 592 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 593 | 594 | elif key == "BSH.Common.Status.OperationState" and value == "BSH.Common.EnumType.OperationState.Pause" \ 595 | and ("BSH.Common.Status.OperationState" not in self.status or self.status["BSH.Common.Status.OperationState"].value != value): 596 | self.commands = await self._async_fetch_commands() 597 | 598 | elif key == "BSH.Common.Status.OperationState" \ 599 | and value in [ "BSH.Common.EnumType.OperationState.ActionRequired", "BSH.Common.EnumType.OperationState.Error", "BSH.Common.EnumType.OperationState.Aborting" ] \ 600 | and ("BSH.Common.Status.OperationState" not in self.status or self.status["BSH.Common.Status.OperationState"].value != value): 601 | _LOGGER.debug("The appliance entered and error operation state: %s", data) 602 | 603 | elif key =="BSH.Common.Status.RemoteControlStartAllowed": 604 | self.available_programs = await self._async_fetch_programs("available") 605 | self.commands = await self._async_fetch_commands() 606 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 607 | 608 | 609 | 610 | if self.selected_program and self.selected_program.options and key in self.selected_program.options: 611 | self.selected_program.options[key].value = value 612 | self.selected_program.options[key].name = data.get("name") 613 | self.selected_program.options[key].displayvalue = data.get("displayvalue") 614 | elif "programs/selected" in uri and key != "BSH.Common.Root.SelectedProgram": 615 | if not self.selected_program and \ 616 | self.active_program and self.active_program.options and key in self.active_program.options: 617 | # This is a workaround for a HC bug where an event is reporting an option for the selected program 618 | # instead of the active program but there is no selected program available 619 | _LOGGER.debug("There is no select program, updating the active program instead with option: %s", data) 620 | self.active_program.options[key].value = value 621 | else: 622 | _LOGGER.debug("Got event for unknown option: %s", data) 623 | self.selected_program = await self._async_fetch_programs("selected") 624 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 625 | 626 | if self.active_program and self.active_program.options and key in self.active_program.options: 627 | self.active_program.options[key].value = value 628 | self.active_program.options[key].name = data.get("name") 629 | self.active_program.options[key].displayvalue = data.get("displayvalue") 630 | elif ( "programs/active" in uri and key != "BSH.Common.Root.ActiveProgram" 631 | # ignore late active program events coming after the program has finished 632 | # this implies that an unknown event will not triger the first active program fetch, which should be fine 633 | and self.active_program 634 | ): 635 | _LOGGER.debug("Got event for unknown property: %s", data) 636 | # Issue #471 shows BSH.Common.Option.ProgramProgress events being received but the option itself is 637 | # not received when loading the active program. This causes the _async_fetch_programs() function to be 638 | # called every 60 seconds but the entity is never added 639 | # As a workaround we check if the options was retrieved and if not add it manually 640 | self.active_program = await self._async_fetch_programs("active") 641 | if self.active_program and self.active_program.options and key not in self.active_program.options: 642 | _LOGGER.debug("Manually adding option %s because it wasn't received from the API", key) 643 | o = Option.create(data) 644 | self.active_program.options[key] = o 645 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 646 | 647 | if key in self.status: 648 | self.status[key].value = value 649 | self.status[key].name = data.get("name") 650 | self.status[key].displayvalue = data.get("displayvalue") 651 | elif "/status/" in uri: 652 | _LOGGER.debug("Got event for unknown property: %s", data) 653 | self.status = await self._async_fetch_status() 654 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 655 | 656 | if key in self.settings: 657 | self.settings[key].value = value 658 | self.settings[key].name = data.get("name") 659 | self.settings[key].displayvalue = data.get("displayvalue") 660 | elif "/settings/" in uri: 661 | _LOGGER.debug("Got event for unknown property: %s", data) 662 | self.settings = await self._async_fetch_settings() 663 | await self._callbacks.async_broadcast_event(self, Events.DATA_CHANGED) 664 | # broadcast the specific event that was received 665 | await self._callbacks.async_broadcast_event(self, key, value) 666 | 667 | def register_callback(self, callback:Callable[[Appliance, str, any], None], keys:str|Sequence[str] ) -> None: 668 | """ Register a callback to be called when an update is received for the specified keys 669 | Wildcard syntax is also supported for the keys 670 | 671 | The key Events.CONNECTION_CHANGED will be used when the connection state of the appliance changes 672 | 673 | The special key "DEFAULT" may be used to catch all unhandled events 674 | """ 675 | self._callbacks.register_callback(callback, keys, self) 676 | 677 | def deregister_callback(self, callback:Callable[[], None], keys:str|Sequence[str]) -> None: 678 | """ Clear a callback that was prevesiously registered so it stops getting notifications """ 679 | self._callbacks.deregister_callback(callback, keys, self) 680 | 681 | def clear_all_callbacks(self): 682 | """ Clear all the registered callbacks """ 683 | self._callbacks.clear_appliance_callbacks(self) 684 | 685 | 686 | #endregion 687 | 688 | #region - Initialization and Data Loading 689 | @classmethod 690 | async def async_create(cls, hc:homeconnect.HomeConnect, properties:dict=None, haId:str=None) -> Appliance: 691 | """ A factory to create an instance of the class """ 692 | if haId: 693 | response = await hc._api.async_get(f"/api/homeappliances/{haId}") # This should either work or raise an exception 694 | properties = response.data 695 | 696 | appliance = cls( 697 | name = properties['name'], 698 | brand = properties['brand'], 699 | type = properties['type'], 700 | vib = properties['vib'], 701 | connected = properties['connected'], 702 | enumber = properties['enumber'], 703 | haId = properties['haId'], 704 | uri = f"/api/homeappliances/{properties['haId']}" 705 | ) 706 | appliance._homeconnect = hc 707 | appliance._callbacks = hc._callbacks 708 | appliance._api = hc._api 709 | 710 | await appliance.async_fetch_data() 711 | 712 | return appliance 713 | 714 | _base_endpoint = property(lambda self: f"/api/homeappliances/{self.haId}") 715 | 716 | async def async_fetch_data(self, include_static_data:bool=True, delay=0): 717 | """ Load the appliance data from the cloud service 718 | 719 | Either a Events.DATA_CHANGED or Events.CONNECTION_CHANGED even will be fired after the data is updated 720 | """ 721 | 722 | if delay>0: 723 | _LOGGER.debug("Sleeping for %ds before starting to load appliance data for %s (%s)", delay, self.name, self.haId ) 724 | await asyncio.sleep(delay) 725 | try: 726 | _LOGGER.debug("Starting to load appliance data for %s (%s)", self.name, self.haId) 727 | 728 | self.selected_program = await self._async_fetch_programs('selected') 729 | self.active_program = await self._async_fetch_programs('active') 730 | self.settings = await self._async_fetch_settings() 731 | self.status = await self._async_fetch_status() 732 | self.commands = await self._async_fetch_commands() 733 | self.available_programs = await self._async_fetch_programs('available') 734 | # if include_static_data or not self.available_programs: 735 | # if ( 736 | # 'BSH.Common.Status.OperationState' not in self.status 737 | # or self.status['BSH.Common.Status.OperationState'].value == 'BSH.Common.EnumType.OperationState.Ready' 738 | # ) and ( 739 | # 'BSH.Common.Status.RemoteControlActive' not in self.status 740 | # or self.status['BSH.Common.Status.RemoteControlActive'].value): 741 | # # Only load the available programs if the state allows them to be loaded 742 | # available_programs = await self._async_fetch_programs('available') 743 | # if available_programs and (not self.available_programs or len(self.available_programs)<2): 744 | # # Only update the available programs if we got new data 745 | # self.available_programs = available_programs 746 | # else: 747 | # self.available_programs = None 748 | # _LOGGER.debug("Not loading available programs becuase BSH.Common.Status.OperationState=%s and BSH.Common.Status.RemoteControlActive=%s", 749 | # self.status['BSH.Common.Status.OperationState'].value if 'BSH.Common.Status.OperationState' in self.status else None, 750 | # str(self.status['BSH.Common.Status.RemoteControlActive'].value) if 'BSH.Common.Status.RemoteControlActive' in self.status else None ) 751 | 752 | _LOGGER.debug("Finished loading appliance data for %s (%s)", self.name, self.haId) 753 | if not self.connected: 754 | await self.async_set_connection_state(True) 755 | except HomeConnectError as ex: 756 | if ex.error_key: 757 | delay = delay + 60 if delay<300 else 300 758 | _LOGGER.debug("Got an error loading appliance data for %s (%s) code=%d error=%s - will retry in %ds", self.name, self.haId, ex.code, ex.error_key, delay) 759 | await self.async_set_connection_state(False) 760 | self._wait_for_device_task = asyncio.create_task(self.async_fetch_data(include_static_data, delay=delay)) 761 | except Exception as ex: 762 | _LOGGER.debug("Unexpected exception in Appliance.async_fetch_data", exc_info=ex) 763 | raise HomeConnectError("Unexpected exception in Appliance.async_fetch_data", inner_exception=ex) 764 | 765 | 766 | async def _async_fetch_programs(self, program_type:str): 767 | """ Main function to fetch the different kinds of programs with their options from the cloud service """ 768 | endpoint = f'{self._base_endpoint}/programs/{program_type}' 769 | response = await self._api.async_get(endpoint) 770 | if response.error_key: 771 | _LOGGER.debug("Failed to load %s programs with error code=%d key=%s", program_type, response.status, response.error_key) 772 | return None 773 | elif not response.data: 774 | _LOGGER.debug("Didn't get any data for Programs: %s", program_type) 775 | raise HomeConnectError(msg=f"Failed to get a valid response from the Home Connect service ({response.status})", response=response) 776 | data = response.data 777 | 778 | current_program_key = self.active_program.key if self.active_program else self.selected_program.key if self.selected_program else None 779 | programs = {} 780 | if 'programs' not in data: 781 | # When fetching selected and active programs the parent program node doesn't exist so we force it 782 | data = { 'programs': [ data ] } 783 | 784 | for p in data['programs']: 785 | prog = Program.create(p) 786 | if 'options' in p: 787 | options = self.optionlist_to_dict(p['options']) 788 | _LOGGER.debug("Loaded %d Options for %s/%s", len(options), program_type, prog.key) 789 | elif program_type=='available' and (prog.key == current_program_key or prog.execution == 'startonly'): 790 | options = await self._async_fetch_available_options(prog.key) 791 | else: 792 | options = None 793 | prog.options = options 794 | 795 | programs[p['key']] = prog 796 | 797 | 798 | if program_type in ['selected', 'active'] and len(programs)==1: 799 | _LOGGER.debug("Loaded data for %s Program", program_type) 800 | return list(programs.values())[0] 801 | else: 802 | _LOGGER.debug("Loaded %d available Programs", len(programs)) 803 | return programs 804 | 805 | async def _async_fetch_available_options(self, program_key:str=None): 806 | """ Fetch detailed options of a program """ 807 | endpoint = f"{self._base_endpoint}/programs/available/{program_key}" 808 | response = await self._api.async_get(endpoint) # This is expected to always succeed if the previous call succeeds 809 | if response.error_key: 810 | _LOGGER.debug("Failed to load Options of available/%s with error code=%d key=%s", program_key, response.status, response.error_key) 811 | return None 812 | data = response.data 813 | if data is None or 'options' not in data: 814 | _LOGGER.debug("Didn't get any data for Options of available/%s", program_key) 815 | return None 816 | 817 | options = self.optionlist_to_dict(data['options']) 818 | _LOGGER.debug("Loaded %d Options for available/%s", len(options), program_key) 819 | return options 820 | 821 | 822 | async def _async_fetch_status(self): 823 | """ Fetch the appliance status values """ 824 | endpoint = f'{self._base_endpoint}/status' 825 | response = await self._api.async_get(endpoint) 826 | if response.error_key: 827 | _LOGGER.debug("Failed to load Status with error code=%d key=%s", response.status, response.error_key) 828 | return {} 829 | data = response.data 830 | if data is None or 'status' not in data: 831 | _LOGGER.debug("Didn't get any data for Status") 832 | return {} 833 | 834 | statuses = {} 835 | for status in data['status']: 836 | statuses[status['key']] = Status.create(status) 837 | 838 | _LOGGER.debug("Loaded %d Statuses", len(statuses)) 839 | return statuses 840 | 841 | 842 | async def _async_fetch_settings(self): 843 | """ Fetch the appliance settings """ 844 | endpoint = f'{self._base_endpoint}/settings' 845 | response = await self._api.async_get(endpoint) 846 | if response.error_key: 847 | _LOGGER.debug("Failed to load Settings with error code=%d key=%s", response.status, response.error_key) 848 | return {} 849 | data = response.data 850 | if data is None or 'settings' not in data: 851 | _LOGGER.debug("Didn't get any data for Settings") 852 | return {} 853 | 854 | settings = {} 855 | for setting in data['settings']: 856 | endpoint = f'{self._base_endpoint}/settings/{setting["key"]}' 857 | response = await self._api.async_get(endpoint) 858 | if response.status != 200: 859 | continue 860 | settings[setting['key']] = Option.create(response.data) 861 | _LOGGER.debug("Loaded %d Settings", len(settings)) 862 | return settings 863 | 864 | 865 | async def _async_fetch_commands(self): 866 | """ Fetch the appliance commands """ 867 | endpoint = f'{self._base_endpoint}/commands' 868 | response = await self._api.async_get(endpoint) 869 | if response.error_key: 870 | _LOGGER.debug("Failed to load Settings with error code=%d key=%s", response.status, response.error_key) 871 | return {} 872 | data = response.data 873 | if data is None or 'commands' not in data: 874 | _LOGGER.debug("Didn't get any data for Settings") 875 | return {} 876 | 877 | commands = {} 878 | for command in data['commands']: 879 | commands[command['key']] = Command.create(command) 880 | 881 | _LOGGER.debug("Loaded %d Commands", len(commands)) 882 | return commands 883 | 884 | 885 | def optionlist_to_dict(self, options_list:Sequence[dict]) -> dict: 886 | """ Helper funtion to convert a list of options into a dictionary keyd by the option "key" """ 887 | d = {} 888 | for element in options_list: 889 | d[element['key']] = Option.create(element) 890 | return d 891 | 892 | #endregion -------------------------------------------------------------------------------- /home_connect_async/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC, abstractmethod 3 | from datetime import datetime, timedelta 4 | from typing import Optional 5 | import webbrowser 6 | import logging 7 | import aiohttp 8 | from aiohttp import ClientSession, ClientResponse 9 | from aiohttp_sse_client import client as sse_client 10 | from oauth2_client.credentials_manager import CredentialManager, ServiceInformation 11 | 12 | 13 | from .const import SIM_HOST, API_HOST, DEFAULT_SCOPES, ENDPOINT_AUTHORIZE, ENDPOINT_TOKEN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | # This is for compatability with Home Assistant 18 | class AbstractAuth(ABC): 19 | """Abstract class to make authenticated requests. This is a pattern required by Home Assistant """ 20 | 21 | def __init__(self, websession: ClientSession, host: str): 22 | """Initialize the auth.""" 23 | self.websession = websession 24 | self.host = host 25 | 26 | @abstractmethod 27 | async def async_get_access_token(self) -> str: 28 | """Return a valid access token.""" 29 | 30 | async def request(self, method, endpoint:str, lang:str=None, **kwargs) -> ClientResponse: 31 | """Make a request.""" 32 | headers = kwargs.get("headers") 33 | 34 | if headers is None: 35 | headers = {} 36 | else: 37 | headers = dict(headers) 38 | 39 | access_token = await self.async_get_access_token() 40 | headers['authorization'] = f'Bearer {access_token}' 41 | headers['Accept'] = 'application/vnd.bsh.sdk.v1+json' 42 | if lang: 43 | headers['Accept-Language'] = lang 44 | if method == 'PUT': 45 | headers['Content-Type'] = 'application/vnd.bsh.sdk.v1+json' 46 | 47 | return await self.websession.request( 48 | method, f"{self.host}{endpoint}", **kwargs, headers=headers, 49 | ) 50 | 51 | async def stream(self, endpoint:str, lang:str, sse_timeout:int, **kwargs) -> sse_client.EventSource: 52 | """ Initiate a SSE stream """ 53 | headers = {} 54 | access_token = await self.async_get_access_token() 55 | headers['authorization'] = f'Bearer {access_token}' 56 | headers['Accept'] = 'application/vnd.bsh.sdk.v1+json' 57 | if lang: 58 | headers['Accept-Language'] = lang 59 | #timeout = aiohttp.ClientTimeout(total = ( self._auth.access_token_expirs_at - datetime.now() ).total_seconds() ) 60 | sse_timeout = aiohttp.ClientTimeout(total = sse_timeout*60 ) 61 | return sse_client.EventSource(f"{self.host}{endpoint}", session=self.websession, headers=headers, timeout=sse_timeout, **kwargs) 62 | 63 | 64 | class AuthManager(AbstractAuth): 65 | """ Class the implements a full fledged authentication manager when the SDK is not being used by Home Assistant """ 66 | def __init__(self, client_id, client_secret, scopes=None, simulate=False): 67 | host = SIM_HOST if simulate else API_HOST 68 | session = ClientSession() 69 | super().__init__(session, host) 70 | 71 | if scopes is None: scopes = DEFAULT_SCOPES 72 | service_information = ServiceInformation( 73 | f'{host}{ENDPOINT_AUTHORIZE}', 74 | f'{host}{ENDPOINT_TOKEN}', 75 | client_id=client_id, 76 | client_secret=client_secret, 77 | scopes=scopes 78 | ) 79 | self._cm = HomeConnectCredentialsManager(service_information) 80 | 81 | def renew_token(self): 82 | """ Renews the access token using the stored refresh token """ 83 | self._cm.init_with_token(self.refresh_token) 84 | 85 | def get_access_token(self): 86 | """ Gets an access token """ 87 | if self._cm.access_token_expirs_at and datetime.now() > self._cm.access_token_expirs_at: 88 | self.renew_token() 89 | return self._cm._access_token 90 | 91 | async def async_get_access_token(self) -> str: 92 | """ Gets an access token """ 93 | return self.get_access_token() 94 | 95 | 96 | access_token = property(get_access_token) 97 | access_token_expirs_at = property(lambda self: self._cm.access_token_expirs_at) 98 | refresh_token = property( 99 | lambda self: self._cm.refresh_token, 100 | lambda self, token: self._cm.init_with_token(token) 101 | ) 102 | 103 | def login(self, redirect_url:str=None): 104 | """ Login to the Home Connect service using the code flow of OAuth 2 """ 105 | if redirect_url is None: 106 | redirect_url = 'http://localhost:7878/auth' 107 | 108 | # Builds the authorization url and starts the local server according to the redirect_uri parameter 109 | url = self._cm.init_authorize_code_process(redirect_url, state='ignore') 110 | webbrowser.open(url) 111 | 112 | code = self._cm.wait_and_terminate_authorize_code_process() 113 | # From this point the http server is opened on the specified port and waits to receive a single GET request 114 | _LOGGER.debug('Code got = %s', code) 115 | self._cm.init_with_authorize_code(redirect_url, code) 116 | _LOGGER.debug('Access got = %s', self.access_token) 117 | 118 | async def close(self): 119 | """ Close the authentication manager when it is no longer in use """ 120 | await self.websession.close() 121 | 122 | 123 | # Extend the CredentialManager class so we can capture the token expiration time 124 | class HomeConnectCredentialsManager(CredentialManager): 125 | """ Extend the oauth2_client library CredentialManager to handle and store the received token """ 126 | 127 | def __init__(self, service_information: ServiceInformation, proxies: Optional[dict] = None): 128 | super().__init__(service_information, proxies) 129 | self._raw_token = None 130 | self.access_token_expirs_at = None 131 | self.id_token = None 132 | 133 | def _process_token_response(self, token_response: dict, refresh_token_mandatory: bool): 134 | """ Override the parent's method to handle the extra data we care about """ 135 | self._raw_token = token_response 136 | if 'expires_in' in token_response: 137 | self.access_token_expirs_at = datetime.now() + timedelta(seconds=token_response['expires_in']) 138 | else: 139 | self.access_token_expirs_at = None 140 | self.id_token = token_response.get('id_token') 141 | return super()._process_token_response(token_response, refresh_token_mandatory) 142 | 143 | 144 | -------------------------------------------------------------------------------- /home_connect_async/callback_registery.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import fnmatch 3 | import inspect 4 | import logging 5 | import re 6 | from typing import Callable 7 | from collections.abc import Sequence 8 | 9 | from .const import Events 10 | from .appliance import Appliance 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | class CallbackRegistry(): 15 | """ Calss for managing callback registration and notifications """ 16 | WILDCARD_KEY = "WILDCARD" 17 | 18 | def __init__(self) -> None: 19 | self._callbacks = {} 20 | 21 | 22 | def register_callback(self, 23 | callback:Callable[[Appliance, str, any], None] | Callable[[Appliance, str], None] | Callable[[Appliance], None] | Callable[[], None], 24 | keys:str|Events|Sequence[str|Events], 25 | appliance:Appliance|str = None 26 | ): 27 | """ Register callback for change event notifications 28 | 29 | Use the Appliance.register_callback() to register for appliance data update events 30 | 31 | Parameters: 32 | * callback - A callback function to call when the event occurs, all the parameters are optional 33 | * keys - A single event key or a list of event keys. An event key may be one of the values of the "Events" enum or a string with a BSH event ID 34 | * appliance - An optional appliance object or haId to filter the events for 35 | """ 36 | 37 | if not isinstance(keys, list): 38 | keys = [ keys ] 39 | 40 | haid = appliance.haId if isinstance(appliance, Appliance) else appliance 41 | 42 | if haid not in self._callbacks: 43 | self._callbacks[haid] = {} 44 | 45 | for key in keys: 46 | if '*' in key: 47 | callback_record = { 48 | "key": key, 49 | "regex": re.compile(fnmatch.translate(key), re.IGNORECASE), 50 | "callback": callback 51 | } 52 | if self.WILDCARD_KEY not in self._callbacks[haid]: 53 | self._callbacks[haid][self.WILDCARD_KEY] = [] 54 | if not self.wildcard_registered(callback_record, self._callbacks[haid][self.WILDCARD_KEY]): 55 | self._callbacks[haid][self.WILDCARD_KEY].append(callback_record) 56 | else: 57 | if key not in self._callbacks[haid]: 58 | self._callbacks[haid][key] = set() 59 | self._callbacks[haid][key].add(callback) 60 | 61 | def deregister_callback(self, 62 | callback:Callable[[Appliance, str, any], None] | Callable[[Appliance, str], None] | Callable[[Appliance], None] | Callable[[], None], 63 | keys:str|Events|Sequence[str|Events], 64 | appliance:Appliance|str = None 65 | ): 66 | """ Clear a callback that was prevesiously registered so it stops getting notifications """ 67 | 68 | if not isinstance(keys, list): 69 | keys = [ keys ] 70 | 71 | haid = appliance.haId if isinstance(appliance, Appliance) else appliance 72 | 73 | if haid not in self._callbacks: 74 | self._callbacks[haid] = {} 75 | 76 | for key in keys: 77 | if '*' in key: 78 | if haid in self._callbacks and self.WILDCARD_KEY in self._callbacks[haid]: 79 | new_list = [ item for item in self._callbacks[haid][self.WILDCARD_KEY] if item['key'] != key or item['callback'] != callback] 80 | self._callbacks[haid][self.WILDCARD_KEY] = new_list 81 | else: 82 | if haid in self._callbacks and key in self._callbacks[haid]: 83 | self._callbacks[haid][key].remove(callback) 84 | 85 | def wildcard_registered(self, callback_record, callback_list) -> bool: 86 | """ Checks if the key and callback pair are already in the list of callbacks """ 87 | for item in callback_list: 88 | if item['key'] == callback_record['key'] and item['callback'] == callback_record['callback']: 89 | return True 90 | return False 91 | 92 | def clear_all_callbacks(self): 93 | """ Clear all the registered callbacks """ 94 | self._callbacks = {} 95 | 96 | def clear_appliance_callbacks(self, appliance:Appliance|str): 97 | """ Clear all the registered callbacks """ 98 | haid = appliance.haId if isinstance(appliance, Appliance) else appliance 99 | 100 | if haid in self._callbacks: 101 | del self._callbacks[haid] 102 | 103 | async def async_broadcast_event(self, appliance:Appliance, event_key:str|Events, value:any = None) -> None: 104 | """ Broadcast an event to all subscribed callbacks """ 105 | 106 | _LOGGER.debug("Broadcasting event: %s = %s", event_key, str(value)) 107 | handled:bool = False 108 | haid = appliance.haId 109 | 110 | # dispatch simple event callbacks 111 | handlers = [ handler for handler in [None, appliance.haId] if handler in self._callbacks] 112 | for haid in handlers: 113 | if event_key in self._callbacks[haid]: 114 | for callback in self._callbacks[haid][event_key]: 115 | await self._async_call(callback, appliance, event_key, value) 116 | handled = True 117 | 118 | # dispatch wildcard or value based callbacks 119 | if self.WILDCARD_KEY in self._callbacks[haid]: 120 | for callback_record in self._callbacks[haid][self.WILDCARD_KEY]: 121 | if callback_record["regex"].fullmatch(event_key): 122 | callback = callback_record['callback'] 123 | await self._async_call(callback, appliance, event_key, value) 124 | handled = True 125 | 126 | # dispatch default callbacks for unhandled events 127 | if not handled and Events.UNHANDLED in self._callbacks[haid]: 128 | for callback in self._callbacks[Events.UNHANDLED]: 129 | self._async_call(callback, appliance, event_key, value) 130 | 131 | 132 | async def _async_call(self, callback:Callable, appliance:Appliance, event_key:str|Events, value:any) -> None: 133 | """ Helper funtion to make the right kind of call to the callback funtion """ 134 | sig = inspect.signature(callback) 135 | param_count = len(sig.parameters) 136 | callback_error = False 137 | try: 138 | if inspect.iscoroutinefunction(callback): 139 | if param_count == 3: 140 | await callback(appliance, event_key, value) 141 | elif param_count == 2: 142 | await callback(appliance, event_key) 143 | elif param_count == 1: 144 | await callback(appliance) 145 | elif param_count == 0: 146 | await callback() 147 | else: 148 | callback_error = True 149 | 150 | else: 151 | if param_count == 3: 152 | callback(appliance, event_key, value) 153 | elif param_count == 2: 154 | callback(appliance, event_key) 155 | elif param_count == 1: 156 | callback(appliance) 157 | elif param_count == 0: 158 | callback() 159 | else: 160 | callback_error = True 161 | except Exception as ex: 162 | _LOGGER.warning("Unhandled exception in callback function for event_key: %s", event_key, exc_info=ex) 163 | if callback_error: 164 | raise ValueError(f"Unexpected number of callback parameters: {sig}") -------------------------------------------------------------------------------- /home_connect_async/common.py: -------------------------------------------------------------------------------- 1 | """ Common classes shared across the code """ 2 | 3 | import asyncio 4 | from datetime import datetime, timedelta 5 | from enum import IntFlag 6 | from logging import Logger 7 | 8 | class ConditionalLogger: 9 | """ Class for conditional logging based on the log mode """ 10 | class LogMode(IntFlag): 11 | """ Enum to control special logging """ 12 | NONE = 0 13 | VERBOSE = 1 14 | REQUESTS = 2 15 | RESPONSES = 4 16 | 17 | _log_flags:LogMode = None 18 | 19 | @classmethod 20 | def mode(self, log_flags:LogMode=None) -> LogMode: 21 | """ Gets or Sets the log flags for conditional logging """ 22 | if log_flags: 23 | self._log_flags = log_flags 24 | return self._log_flags 25 | 26 | 27 | @classmethod 28 | def ismode(self, logmode:LogMode) -> bool: 29 | """ Check if the specified logmode is enabled """ 30 | return self._log_flags & logmode 31 | 32 | @classmethod 33 | def debug(self, logger:Logger, logmode:LogMode, *args, **kwargs ) -> None: 34 | """ Conditional debug log """ 35 | if self._log_flags & logmode: 36 | logger.debug(*args, **kwargs) 37 | 38 | 39 | class HomeConnectError(Exception): 40 | """ Common exception class for the SDK """ 41 | def __init__(self, msg:str = None, code:int = None, response = None, inner_exception = None): 42 | self.msg:str = msg 43 | self.code:int = code 44 | self.response = response 45 | self.inner_exception = inner_exception 46 | if response: 47 | self.error_key:str = response.error_key 48 | self.error_description:str = response.error_description 49 | if not code: self.code = response.status 50 | else: 51 | self.error_key = None 52 | self.error_description = None 53 | 54 | super().__init__(msg, code, self.error_key, self.error_description, inner_exception) 55 | 56 | 57 | class Synchronization(): 58 | """ Class to hold global syncronization objects """ 59 | selected_program_lock = asyncio.Lock() 60 | 61 | 62 | class HealthStatus: 63 | """ Store the Home Connect connection health status """ 64 | class Status(IntFlag): 65 | """ Enum for the current status of the Home Connect data loading process """ 66 | INIT = 0 67 | RUNNING = 1 68 | LOADED = 3 69 | UPDATES = 4 70 | UPDATES_NO_DATA = 5 71 | READY = 7 72 | LOADING_FAILED = 8 73 | BLOCKED = 16 74 | 75 | def __init__(self) -> None: 76 | self._status:self.Status = self.Status.INIT 77 | self._blocked_until:datetime = None 78 | 79 | def set_status(self, status:Status, delay:int=None) -> None: 80 | """ Set the status """ 81 | self._status |= status 82 | if delay: 83 | self._blocked_until = datetime.now() + timedelta(seconds=delay) 84 | 85 | def unset_status(self, status:Status) -> None: 86 | """ Set the status """ 87 | self._status &= ~status 88 | if status == self.Status.BLOCKED: 89 | self._blocked_until = None 90 | 91 | def get_status(self) -> Status: 92 | """ Get the status """ 93 | if self._status & self.Status.BLOCKED: 94 | return self.Status.BLOCKED 95 | elif self._status & self.Status.LOADING_FAILED: 96 | return self.Status.LOADING_FAILED 97 | return self._status 98 | 99 | def get_status_str(self) -> str: 100 | """ Return the status as a formatted string""" 101 | if self._blocked_until: 102 | return f"Blocked for {self.get_block_time_str()}" 103 | elif self._status & self.Status.LOADING_FAILED: 104 | return self.Status.LOADING_FAILED.name 105 | else: 106 | return self._status.name 107 | 108 | def get_blocked_until(self): 109 | return self._blocked_until 110 | 111 | def get_block_time_str(self): 112 | if self._blocked_until: 113 | delta = (self._blocked_until - datetime.now()).seconds 114 | if delta < 60: 115 | return f"{delta}s" 116 | else: 117 | hours = delta //3600 118 | minutes = (delta - hours*3600) // 60 119 | return f"{hours}:{minutes:02}h" 120 | else: 121 | return None 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /home_connect_async/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | SIM_HOST = "https://simulator.home-connect.com" 4 | API_HOST = "https://api.home-connect.com" 5 | ENDPOINT_AUTHORIZE = "/security/oauth/authorize" 6 | ENDPOINT_TOKEN = "/security/oauth/token" 7 | DEFAULT_SCOPES = [ 'IdentifyAppliance', 'Monitor', 'Control', 'Settings' ] 8 | 9 | class Events(str,Enum): 10 | """ Enum for special event types """ 11 | DATA_CHANGED = "DATA_CHANGED" 12 | CONNECTION_CHANGED = "CONNECTION_CHANGED" 13 | CONNECTED = "CONNECTED" 14 | DISCONNECTED = "DISCONNECTED" 15 | PAIRED = "PAIRED" 16 | DEPAIRED = "DEPAIRED" 17 | PROGRAM_SELECTED = "PROGRAM_SELECTED" 18 | PROGRAM_STARTED = "PROGRAM_STARTED" 19 | PROGRAM_FINISHED = "PROGRAM_FINISHED" 20 | UNHANDLED = "UNHANDLED" 21 | 22 | 23 | -------------------------------------------------------------------------------- /home_connect_async/homeconnect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | from asyncio import Task 4 | from enum import Enum 5 | import inspect 6 | import logging 7 | import json 8 | from typing import ClassVar, Optional, Sequence 9 | from datetime import datetime 10 | from collections.abc import Callable 11 | from dataclasses import dataclass, field 12 | from dataclasses_json import Undefined, config, DataClassJsonMixin 13 | 14 | from aiohttp_sse_client.client import MessageEvent 15 | 16 | from .const import Events 17 | from .common import ConditionalLogger, HomeConnectError, HealthStatus 18 | from .callback_registery import CallbackRegistry 19 | from .appliance import Appliance 20 | from .auth import AuthManager 21 | from .api import HomeConnectApi 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | #@dataclass_json(undefined=Undefined.EXCLUDE) 27 | @dataclass 28 | class HomeConnect(DataClassJsonMixin): 29 | """ The main class that wraps the whole data model, 30 | coordinates the loading of data from the cloud service and listens for update events 31 | """ 32 | 33 | class RefreshMode(Enum): 34 | """ Enum for the supported data refresh modes """ 35 | NOTHING = 0 36 | VALIDATE = 1 37 | DYNAMIC_ONLY = 2 38 | ALL = 3 39 | 40 | # This is a class variable used as configuration for the dataclass_json 41 | dataclass_json_config:ClassVar[config] = config(undefined=Undefined.EXCLUDE) 42 | 43 | # The data calss fields 44 | appliances:dict[str, Appliance] = field(default_factory=dict) 45 | # status:HomeConnect.HomeConnectStatus = \ 46 | # field( 47 | # default=HomeConnectStatus.INIT, 48 | # metadata=config(encoder = lambda val: val.name, exclude = lambda val: True) 49 | # ) 50 | 51 | 52 | _disabled_appliances:Optional[list[str]] = field(default_factory=lambda: list() ) 53 | 54 | # Internal fields - not serialized to JSON 55 | _api:Optional[HomeConnectApi] = field(default=None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 56 | _updates_task:Optional[Task] = field(default=None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 57 | _load_task:Optional[Task] = field(default=None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 58 | _health:Optional[HealthStatus] = field(default=None, metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 59 | _callbacks:Optional[CallbackRegistry] = field(default_factory=lambda: CallbackRegistry(), metadata=config(encoder=lambda val: None, exclude=lambda val: True)) 60 | _sse_timeout:Optional[int] = field(default=None) 61 | 62 | @classmethod 63 | async def async_create(cls, 64 | am:AuthManager, 65 | json_data:str=None, 66 | delayed_load:bool=False, 67 | refresh:RefreshMode=RefreshMode.DYNAMIC_ONLY, 68 | auto_update:bool=False, 69 | lang:str=None, 70 | disabled_appliances:list[str] = [], 71 | sse_timeout:int=10 72 | ) -> HomeConnect: 73 | """ Factory for creating a HomeConnect object - DO NOT USE THE DEFAULT CONSTRUCTOR 74 | 75 | Parameters: 76 | * json_data - A JSON string of cached data model data obtained by calling .to_json() on a previously loaded HomeConnect object 77 | * delayed_load - Should appliance data be loaded synchronously, within the execution of this call or skipped and called explicitly. 78 | * refresh - Specifies which parts of the data should be refreshed. Only applicable when json_data was provided and ignored for delayed_load. 79 | * auto_update - Subscribe for real-time updates to the data model, ignored for delayed_load 80 | 81 | Notes: 82 | If delayed_load is set then async_load_data() should be called to complete the loading of the data. 83 | 84 | If auto_update is set to False then subscribe_for_updates() should be called to receive real-time updates to the data 85 | """ 86 | health = HealthStatus() 87 | api = HomeConnectApi(am, lang, health) 88 | hc:HomeConnect = None 89 | if json_data: 90 | try: 91 | hc = HomeConnect.from_json(json_data) 92 | #hc.status = cls.HomeConnectStatus.INIT 93 | # manually initialize the appliances because they were created from json 94 | for appliance in hc.appliances.values(): 95 | appliance._homeconnect = hc 96 | appliance._callbacks = hc._callbacks 97 | appliance._api = api 98 | except Exception as ex: 99 | _LOGGER.exception("Exception when loading HomeConnect data from JSON", exc_info=ex) 100 | if not hc: 101 | hc = HomeConnect() 102 | 103 | hc._api = api 104 | hc._health = health 105 | hc._refresh_mode = refresh 106 | hc._disabled_appliances = disabled_appliances 107 | hc._sse_timeout = sse_timeout 108 | 109 | if not delayed_load: 110 | await hc.async_load_data(refresh) 111 | 112 | if auto_update and not delayed_load: 113 | hc.subscribe_for_updates() 114 | 115 | return hc 116 | 117 | def start_load_data_task(self, 118 | refresh:RefreshMode = None, 119 | on_complete:Callable[[HomeConnect], None] = None, 120 | on_error:Callable[[HomeConnect, Exception], None] = None 121 | ) -> asyncio.Task: 122 | """Complete the loading of the data when using delayed load 123 | 124 | This method can also be used for refreshing the data after it has been loaded. 125 | 126 | Parameters: 127 | * on_complete - an optional callback method that will be called after the loading has completed 128 | * refresh - optional refresh mode, if not supplied the value from async_create() will be used 129 | """ 130 | refresh = refresh if refresh else self._refresh_mode 131 | self._load_task = asyncio.create_task(self.async_load_data(refresh, on_complete, on_error), name="_async_load_data") 132 | return self._load_task 133 | 134 | async def async_load_data(self, 135 | refresh:RefreshMode=RefreshMode.DYNAMIC_ONLY, 136 | on_complete:Callable[[HomeConnect], None] = None, 137 | on_error:Callable[[HomeConnect, Exception], None] = None 138 | ) -> None: 139 | """ Loads or just refreshes the data model from the cloud service """ 140 | #self.status |= self.HomeConnectStatus.LOADING 141 | self._health.set_status(self._health.Status.RUNNING) 142 | self._health.unset_status(self._health.Status.LOADING_FAILED) 143 | 144 | try: 145 | if refresh == self.RefreshMode.NOTHING: 146 | for appliance in self.appliances.values(): 147 | await self._callbacks.async_broadcast_event(appliance, Events.PAIRED) 148 | 149 | else: 150 | response = await self._api.async_get('/api/homeappliances') 151 | if response.status != 200: 152 | _LOGGER.warning("Failed to get the list of appliances code=%d error=%s", response.status, response.error_key) 153 | raise HomeConnectError(f"Failed to get the list of appliances (code={response.status})", response=response) 154 | data = response.data 155 | 156 | haid_list = [] 157 | if 'homeappliances' in data: 158 | for ha in data['homeappliances']: 159 | haid = ha['haId'] 160 | if haid in self._disabled_appliances or haid.lower().replace('-','_') in self._disabled_appliances: 161 | continue 162 | 163 | haid_list.append(haid) 164 | if ha['connected']: 165 | if haid in self.appliances: 166 | # the appliance was already loaded so just refresh the data 167 | if refresh == self.RefreshMode.DYNAMIC_ONLY: 168 | await self.appliances[haid].async_fetch_data(include_static_data=False) 169 | elif refresh == self.RefreshMode.ALL: 170 | await self.appliances[haid].async_fetch_data(include_static_data=True) 171 | else: 172 | appliance = await Appliance.async_create(self, ha) 173 | self.appliances[haid] = appliance 174 | await self._callbacks.async_broadcast_event(self.appliances[ha['haId']], Events.PAIRED) 175 | await self._callbacks.async_broadcast_event(self.appliances[ha['haId']], Events.DATA_CHANGED) 176 | _LOGGER.debug("Loadded appliance: %s", self.appliances[ha['haId']].name) 177 | elif haid in self.appliances: 178 | _LOGGER.warning("The appliance (%s) is disconnected when loading for the first time", haid) 179 | await self.appliances[haid].async_set_connection_state(False) 180 | 181 | # clear appliances that are no longer paired with the service 182 | for haId in self.appliances.keys(): 183 | if haId not in haid_list: 184 | await self._callbacks.async_broadcast_event(self.appliances[haId], Events.DEPAIRED) 185 | del self.appliances[haId] 186 | 187 | #self.status |= self.HomeConnectStatus.LOADED 188 | self._health.set_status(self._health.Status.LOADED) 189 | except Exception as ex: 190 | _LOGGER.warning("Failed to load data from Home Connect (%s)", str(ex), exc_info=ex) 191 | #self.status = self.HomeConnectStatus.LOADING_FAILED 192 | self._health.set_status(self._health.Status.LOADING_FAILED) 193 | if on_error: 194 | if inspect.iscoroutinefunction(on_error): 195 | await on_error(self, ex) 196 | else: 197 | on_error(self, ex) 198 | raise 199 | 200 | if on_complete: 201 | if inspect.iscoroutinefunction(on_complete): 202 | await on_complete(self) 203 | else: 204 | on_complete(self) 205 | 206 | 207 | def subscribe_for_updates(self): 208 | """ Subscribe to receive real-time updates from the Home Connect cloud service 209 | 210 | close() must be called before the HomeConnect object is terminated to cleanly close the updates channel 211 | """ 212 | if not self._updates_task: 213 | #self._updates_task = asyncio.create_task(self._api.stream('/api/homeappliances/events', message_handler=self._async_process_updates), name="subscribe_for_updates") 214 | self._updates_task = asyncio.create_task(self.async_events_stream(), name="subscribe_for_updates") 215 | return self._updates_task 216 | 217 | 218 | def close(self): 219 | """ Close the updates channel and clear all the configured callbacks 220 | 221 | This method must be called if updates subscription was requested 222 | """ 223 | if self._load_task and not self._load_task.cancelled(): 224 | self._load_task.cancel() 225 | self._load_task = None 226 | 227 | if self._updates_task and not self._updates_task.cancelled(): 228 | self._updates_task.cancel() 229 | self._updates_task = None 230 | 231 | self.clear_all_callbacks() 232 | 233 | for appliance in self.appliances.values(): 234 | appliance.clear_all_callbacks() 235 | 236 | 237 | def __getitem__(self, haId) -> Appliance: 238 | """ Supports simple access to an appliance based on its haId """ 239 | return self.appliances.get(haId) 240 | 241 | @property 242 | def health(self): 243 | return self._health 244 | 245 | 246 | #region - Event stream and updates 247 | 248 | async def async_events_stream(self): 249 | """ Open the SSE channel, process the incoming events and handle errors """ 250 | 251 | def parse_sse_error(error:str) -> int: 252 | try: 253 | parts = error.split(': ') 254 | error_code = int(parts[-1]) 255 | return error_code 256 | except: 257 | return 0 258 | 259 | 260 | backoff = 2 261 | event_source = None 262 | while True: 263 | try: 264 | _LOGGER.debug("Connecting to SSE stream") 265 | event_source = await self._api.async_get_event_stream('/api/homeappliances/events', self._sse_timeout) 266 | await event_source.connect() 267 | #self.status |= self.HomeConnectStatus.UPDATES 268 | self._health.set_status(self._health.Status.UPDATES) 269 | 270 | async for event in event_source: 271 | _LOGGER.debug("Received event from SSE stream: %s", str(event)) 272 | backoff = 1 273 | try: 274 | await self._async_process_updates(event) 275 | except Exception as ex: 276 | _LOGGER.debug('Unhandled exception in stream event handler', exc_info=ex) 277 | except asyncio.CancelledError as ex: 278 | _LOGGER.debug('Got asyncio.CancelledError exception. Home Assistant is probably closing so aborting SSE loop', exc_info=ex) 279 | break 280 | except ConnectionRefusedError as ex: 281 | #self.status &= self.HomeConnectStatus.NOUPDATES 282 | self._health.unset_status(self._health.Status.UPDATES) 283 | _LOGGER.debug('ConnectionRefusedError in SSE connection refused. Will try again', exc_info=ex) 284 | except ConnectionError as ex: 285 | #self.status &= self.HomeConnectStatus.NOUPDATES 286 | self._health.unset_status(self._health.Status.UPDATES) 287 | error_code = parse_sse_error(ex.args[0]) 288 | if error_code == 429: 289 | backoff *= 2 290 | if backoff > 3600: backoff = 3600 291 | elif backoff < 60: backoff = 60 292 | _LOGGER.debug('Got error 429 when opening event stream connection, will sleep for %s seconds and retry', backoff) 293 | else: 294 | _LOGGER.debug('ConnectionError in SSE event stream. Will wait for %d seconds and retry ', backoff, exc_info=ex) 295 | backoff *= 2 296 | if backoff > 120: backoff = 120 297 | 298 | await asyncio.sleep(backoff) 299 | 300 | except asyncio.TimeoutError: 301 | # it is expected that the connection will time out every hour 302 | _LOGGER.debug("The SSE connection timeed-out, will renew and retry") 303 | except Exception as ex: 304 | #self.status &= self.HomeConnectStatus.NOUPDATES 305 | _LOGGER.debug('Exception in SSE event stream. Will wait for %d seconds and retry ', backoff, exc_info=ex) 306 | self._health.unset_status(self._health.Status.UPDATES) 307 | await asyncio.sleep(backoff) 308 | backoff *= 2 309 | if backoff > 120: backoff = 120 310 | 311 | finally: 312 | if event_source: 313 | await event_source.close() 314 | event_source = None 315 | 316 | #self.status &= self.HomeConnectStatus.NOUPDATES 317 | self._health.unset_status(self._health.Status.UPDATES) 318 | _LOGGER.debug("Exiting SSE event stream") 319 | 320 | 321 | async def _async_process_updates(self, event:MessageEvent): 322 | """ Handle the different kinds of events received over the SSE channel """ 323 | haid = event.last_event_id 324 | if event.type == 'KEEP-ALIVE' or haid.lower().replace('-','_') in self._disabled_appliances: 325 | self._last_update = datetime.now() 326 | return 327 | if haid not in self.appliances: 328 | # handle cases where the appliance wasn't loaded before 329 | _LOGGER.debug("Unknown haId '%s' reloading HomeConnected from the API", haid) 330 | await self.async_load_data() 331 | if event.type == 'PAIRED': 332 | self.appliances[haid] = await Appliance.async_create(self, haId=haid) 333 | await self._callbacks.async_broadcast_event(self.appliances[haid], Events.PAIRED) 334 | elif event.type == 'DEPAIRED': 335 | if haid in self.appliances: 336 | await self._callbacks.async_broadcast_event(self.appliances[haid], Events.DEPAIRED) 337 | del self.appliances[haid] 338 | elif event.type =='DISCONNECTED': 339 | if haid in self.appliances: 340 | await self.appliances[haid].async_set_connection_state(False) 341 | await self._callbacks.async_broadcast_event(self.appliances[haid], Events.DISCONNECTED) 342 | elif event.type == 'CONNECTED': 343 | if haid in self.appliances: 344 | await self.appliances[haid].async_set_connection_state(True) 345 | await self._callbacks.async_broadcast_event(self.appliances[haid], Events.CONNECTED) 346 | else: 347 | self.appliances[haid] = await Appliance.async_create(self, haId=haid) 348 | await self._callbacks.async_broadcast_event(self.appliances[haid], Events.PAIRED) 349 | else: 350 | # Type is NOTIFY or EVENT 351 | data = json.loads(event.data) 352 | haid = data['haId'] 353 | if haid not in self.appliances: 354 | _LOGGER.debug("Unknown haId '%s' reloading HomeConnected from the API", haid) 355 | await self.async_load_data() 356 | if 'items' in data: 357 | for item in data['items']: 358 | # haid = self._get_haId_from_event(item) if 'uri' in item else haid 359 | if haid in self.appliances: 360 | appliance = self.appliances[haid] 361 | await appliance.async_update_data(item) 362 | 363 | 364 | # def _get_haId_from_event(self, event:dict): 365 | # """ Parse the uri field that exists in some streamed events to extract the haID 366 | # This seems safer than relying on the last_event_id field so preferred when it's available 367 | # """ 368 | # uri_parts = event['uri'].split('/') 369 | # assert(uri_parts[0]=='') 370 | # assert(uri_parts[1]=='api') 371 | # assert(uri_parts[2]=='homeappliances') 372 | # haId = uri_parts[3] 373 | # return haId 374 | 375 | 376 | def register_callback(self, callback:Callable[[Appliance, str], None] | Callable[[Appliance, str, any], None], keys:str|Sequence[str], appliance:Appliance|str = None): 377 | """ Register callback for change event notifications 378 | 379 | Use the Appliance.register_callback() to register for appliance data update events 380 | """ 381 | 382 | self._callbacks.register_callback(callback, keys, appliance) 383 | 384 | 385 | def clear_all_callbacks(self): 386 | """ Clear all the registered callbacks """ 387 | self._callbacks.clear_all_callbacks() 388 | 389 | #endregion 390 | 391 | 392 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | aiohttp-sse-client 3 | dataclasses-json 4 | oauth2-client 5 | cchardet -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | long_description = file: README.md 3 | long_description_content_type = text/markdown; charset=UTF-8 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name = 'home-connect-async', 5 | packages = ['home_connect_async'], 6 | version = '0.8.2', 7 | license='MIT', 8 | description = 'Async SDK for BSH Home Connect API', 9 | author = 'Eran Kutner', 10 | author_email = 'eran@kutner.org', 11 | url = 'https://github.com/ekutner/home-connect-async', 12 | keywords = ['HomeConnect', 'Home Connect', 'BSH', 'Async', 'SDK'], 13 | install_requires=[ 14 | 'aiohttp', 15 | 'aiohttp-sse-client>=0.2.1', 16 | 'dataclasses-json>=0.5.6', 17 | 'oauth2-client>=1.2.1', 18 | 'charset_normalizer' 19 | ], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package 22 | 'Intended Audience :: Developers', # Define that your audience are developers 23 | 'Topic :: Software Development :: Build Tools', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.9', 27 | 'Programming Language :: Python :: 3.10', 28 | ], 29 | ) --------------------------------------------------------------------------------