Repository: HomeAssistant-Mods/home-assistant-miele Branch: master Commit: 38e06adc78f7 Files: 13 Total size: 69.8 KB Directory structure: gitextract_hihk12rd/ ├── .github/ │ └── workflows/ │ └── combined.yaml ├── .gitignore ├── README.md ├── custom_components/ │ └── miele/ │ ├── __init__.py │ ├── binary_sensor.py │ ├── fan.py │ ├── light.py │ ├── manifest.json │ ├── miele_at_home.py │ ├── sensor.py │ └── services.yaml ├── hacs.json └── info.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/combined.yaml ================================================ name: "Validation And Formatting" on: push: pull_request: jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 name: Download repo with: fetch-depth: 0 - uses: actions/setup-python@v2 name: Setup Python - uses: actions/cache@v2 name: Cache with: path: | ~/.cache/pip key: custom-component-ci - uses: hacs/action@main with: CATEGORY: integration - uses: KTibow/ha-blueprint@stable name: CI with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ **/*.pyc .idea/ *.iml ================================================ FILE: README.md ================================================ Buy Me A Coffee Donate with PayPal button # Home Assistant support for Miele@home connected appliances ## Introduction This project exposes Miele state information of appliances connected to a Miele user account. This is achieved by communicating with the Miele Cloud Service, which exposes both applicances connected to a Miele@home Gateway XGW3000, as well as those devices connected via WiFi Con@ct. ## Prerequisite * A running version of [Home Assistant](https://home-assistant.io). While earlier versions may work, the custom component has been developed and tested with version 0.76.x. * Following the [instructions on the Miele developer site](https://www.miele.com/f/com/en/register_api.aspx), you need to request your personal ```ClientID``` and ```ClientSecret```. ## HACS Install We are now included in the default Repo of HACS. This is the recomanded way to install this integration. * Install HACS if you haven't yet, instructions to install HACS can be found here : https://hacs.xyz/docs/installation/prerequisites * Open the HACS component from your sidebar -> click integrations -> Search for Miele and install the Integration. * Enable the new platform in your ```configuration.yaml```: ``` miele: client_id: client_secret: lang: cache_path: ``` * Restart Home Assistant. * The Home Assistant Web UI will show you a UI to configure the Miele platform. Follow the instructions to log into the Miele Cloud Service. This will communicate back an authentication token that will be cached to communicate with the Cloud Service. Done. If you follow all the instructions, the Miele integration should be up and running. All Miele devices that you can see in your Mobile application should now be also visible in Home Assistant (miele.*). In addition, there will be a number of ```binary_sensors``` and ```sensors``` that can be used for automation. ## Manual Installation of the custom component * Copy the content of this repository into your ```custom_components``` folder, which is a subdirectory of your Home Assistant configuration directory. By default, this directory is located under ```~/.home-assistant```. The structure of the ```custom_components``` directory should look like this afterwards: ``` - miele - __init__.py - miele_at_home.py - binary_sensor.py - light.py - sensor.py ``` * Enable the new platform in your ```configuration.yaml```: ``` miele: client_id: client_secret: lang: cache_path: interval: ``` * Restart Home Assistant. * The Home Assistant Web UI will show you a UI to configure the Miele platform. Follow the instructions to log into the Miele Cloud Service. This will communicate back an authentication token that will be cached to communicate with the Cloud Service. Done. If you follow all the instructions, the Miele integration should be up and running. All Miele devices that you can see in your Mobile application should now be also visible in Home Assistant (miele.*). In addition, there will be a number of ```binary_sensors``` and ```sensors``` that can be used for automation. ## Questions Please see the [Miele@home, miele@mobile component](https://community.home-assistant.io/t/miele-home-miele-mobile-component/64508) discussion thread on the Home Assistant community site. ================================================ FILE: custom_components/miele/__init__.py ================================================ """ Support for Miele. """ import asyncio import functools import logging from datetime import timedelta from importlib import import_module import homeassistant.helpers.config_validation as cv import voluptuous as vol from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.core import callback from homeassistant.helpers import network from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import STORAGE_DIR from .miele_at_home import MieleClient, MieleOAuth _LOGGER = logging.getLogger(__name__) DEVICES = [] DEFAULT_NAME = "Miele@home" DOMAIN = "miele" _CONFIGURING = {} DATA_OAUTH = "oauth" DATA_DEVICES = "devices" DATA_CLIENT = "client" SERVICE_ACTION = "action" SERVICE_START_PROGRAM = "start_program" SERVICE_STOP_PROGRAM = "stop_program" SCOPE = "code" DEFAULT_LANG = "en" DEFAULT_INTERVAL = 5 AUTH_CALLBACK_PATH = "/api/miele/callback" AUTH_CALLBACK_NAME = "api:miele:callback" CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" CONF_LANG = "lang" CONF_CACHE_PATH = "cache_path" CONF_INTERVAL = "interval" CONFIGURATOR_LINK_NAME = "Link Miele account" CONFIGURATOR_SUBMIT_CAPTION = "I have authorized Miele@home." CONFIGURATOR_DESCRIPTION = ( "To link your Miele account, " "click the link, login, and authorize:" ) CONFIGURATOR_DESCRIPTION_IMAGE = ( "https://api.mcs3.miele.com/assets/images/miele_logo.svg" ) MIELE_COMPONENTS = ["binary_sensor", "light", "sensor", "fan"] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LANG): cv.string, vol.Optional(CONF_CACHE_PATH): cv.string, vol.Optional(CONF_INTERVAL): cv.positive_int, } ), }, extra=vol.ALLOW_EXTRA, ) CAPABILITIES = { "1": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature.0", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", "spinningSpeed", "ecoFeedback.energyConsumption", "ecoFeedback.waterConsumption", ], "2": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", "dryingStep", "ecoFeedback.energyConsumption", ], "7": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "signalInfo", "signalFailure", "remoteEnable", "elapsedTime", "ecoFeedback.energyConsumption", "ecoFeedback.waterConsumption", ], "12": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "13": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "14": ["status", "signalFailure", "plateStep"], "15": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "16": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "17": [ "ProgramID", "status", "programPhase", "signalInfo", "signalFailure", "remoteEnable", ], "18": [ "status", "signalInfo", "signalFailure", "remoteEnable", "ventilationStep", ], "19": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "20": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "21": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "23": [ "ProgramID", "status", "programType", "signalInfo", "signalFailure", "remoteEnable", "batteryLevel", ], "24": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "targetTemperature.0", "startTime", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", "spinningSpeed", "dryingStep", "ecoFeedback.energyConsumption", "ecoFeedback.waterConsumption", ], "25": [ "status", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "elapsedTime", ], "27": ["status", "signalFailure", "plateStep"], "31": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "32": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "33": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "34": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", ], "45": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "67": [ "ProgramID", "status", "programType", "programPhase", "remainingTime", "startTime", "targetTemperature", "temperature", "signalInfo", "signalFailure", "signalDoor", "remoteEnable", "elapsedTime", ], "68": [ "status", "targetTemperature", "temperature", "signalInfo", "signalFailure", "remoteEnable", ], } def request_configuration(hass, config, oauth): """Request Miele authorization.""" async def miele_configuration_callback(callback_data): if not hass.data[DOMAIN][DATA_OAUTH].authorized: configurator.async_notify_errors( _CONFIGURING[DOMAIN], "Failed to register, please try again." ) return if DOMAIN in _CONFIGURING: req_config = _CONFIGURING.pop(DOMAIN) hass.components.configurator.async_request_done(req_config) await async_setup(hass, config) _LOGGER.info("Requesting authorization...") configurator = hass.components.configurator _CONFIGURING[DOMAIN] = configurator.async_request_config( DEFAULT_NAME, miele_configuration_callback, link_name=CONFIGURATOR_LINK_NAME, link_url=oauth.authorization_url, description=CONFIGURATOR_DESCRIPTION, description_image=CONFIGURATOR_DESCRIPTION_IMAGE, submit_caption=CONFIGURATOR_SUBMIT_CAPTION, ) return def create_sensor(client, hass, home_device, lang): return MieleDevice(hass, client, home_device, lang) def _to_dict(items): # Replace with map() result = {} for item in items: ident = item["ident"] result[ident["deviceIdentLabel"]["fabNumber"]] = item return result async def async_setup(hass, config): """Set up the Miele platform.""" if DOMAIN not in hass.data: hass.data[DOMAIN] = {} if DATA_OAUTH not in hass.data[DOMAIN]: callback_url = "{}{}".format( network.get_url(hass, allow_external=True, prefer_external=True), AUTH_CALLBACK_PATH, ) cache = config[DOMAIN].get( CONF_CACHE_PATH, hass.config.path(STORAGE_DIR, f".miele-token-cache") ) hass.data[DOMAIN][DATA_OAUTH] = MieleOAuth( hass, config[DOMAIN].get(CONF_CLIENT_ID), config[DOMAIN].get(CONF_CLIENT_SECRET), redirect_uri=callback_url, cache_path=cache, ) if not hass.data[DOMAIN][DATA_OAUTH].authorized: _LOGGER.info("no token; requesting authorization") hass.http.register_view( MieleAuthCallbackView(config, hass.data[DOMAIN][DATA_OAUTH]) ) request_configuration(hass, config, hass.data[DOMAIN][DATA_OAUTH]) return True lang = config[DOMAIN].get(CONF_LANG, DEFAULT_LANG) component = EntityComponent(_LOGGER, DOMAIN, hass) client = MieleClient(hass, hass.data[DOMAIN][DATA_OAUTH]) hass.data[DOMAIN][DATA_CLIENT] = client data_get_devices = await client.get_devices(lang) hass.data[DOMAIN][DATA_DEVICES] = _to_dict(data_get_devices) DEVICES.extend( [ create_sensor(client, hass, home_device, lang) for k, home_device in hass.data[DOMAIN][DATA_DEVICES].items() ] ) await component.async_add_entities(DEVICES, False) for component in MIELE_COMPONENTS: load_platform(hass, component, DOMAIN, {}, config) async def refresh_devices(event_time): _LOGGER.debug("Attempting to update Miele devices") try: device_state = await client.get_devices(lang) except: device_state = None if device_state is None: _LOGGER.error("Did not receive Miele devices") else: hass.data[DOMAIN][DATA_DEVICES] = _to_dict(device_state) for device in DEVICES: device.async_schedule_update_ha_state(True) for component in MIELE_COMPONENTS: platform = import_module(".{}".format(component), __name__) platform.update_device_state() register_services(hass) interval = timedelta(seconds=config[DOMAIN].get(CONF_INTERVAL, DEFAULT_INTERVAL)) async_track_time_interval(hass, refresh_devices, interval) return True def register_services(hass): """Register all services for Miele devices.""" hass.services.async_register(DOMAIN, SERVICE_ACTION, _action_service) hass.services.async_register(DOMAIN, SERVICE_START_PROGRAM, _action_start_program) hass.services.async_register(DOMAIN, SERVICE_STOP_PROGRAM, _action_stop_program) async def _apply_service(service, service_func, *service_func_args): entity_ids = service.data.get("entity_id") _devices = [] if entity_ids: _devices.extend( [device for device in DEVICES if device.entity_id in entity_ids] ) device_ids = service.data.get("device_id") if device_ids: _devices.extend( [device for device in DEVICES if device.unique_id in device_ids] ) for device in _devices: await service_func(device, *service_func_args) async def _action_service(service): body = service.data.get("body") await _apply_service(service, MieleDevice.action, body) async def _action_start_program(service): program_id = service.data.get("program_id") await _apply_service(service, MieleDevice.start_program, program_id) async def _action_stop_program(service): body = {"processAction": 2} await _apply_service(service, MieleDevice.action, body) class MieleAuthCallbackView(HomeAssistantView): """Miele Authorization Callback View.""" requires_auth = False url = AUTH_CALLBACK_PATH name = AUTH_CALLBACK_NAME def __init__(self, config, oauth): """Initialize.""" self.config = config self.oauth = oauth @callback async def get(self, request): """Receive authorization token.""" hass = request.app["hass"] from oauthlib.oauth2.rfc6749.errors import ( MismatchingStateError, MissingTokenError, ) response_message = """Miele@home has been successfully authorized! You can close this window now!""" result = None if request.query.get("code") is not None: try: func = functools.partial( self.oauth.get_access_token, request.query["code"] ) result = await hass.async_add_executor_job(func) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when attempting authenticating with Miele@home. The error encountered was {}. Please try again!""".format( error ) except MismatchingStateError as error: _LOGGER.error("Mismatched state, CSRF error: %s", error) response_message = """Something went wrong when attempting authenticating with Miele@home. The error encountered was {}. Please try again!""".format( error ) else: _LOGGER.error("Unknown error when authorizing") response_message = """Something went wrong when attempting authenticating with Miele@home. An unknown error occurred. Please try again! """ html_response = """Miele@home Auth

{}

""".format( response_message ) response = web.Response( body=html_response, content_type="text/html", status=200, headers=None ) response.enable_compression() return response class MieleDevice(Entity): def __init__(self, hass, client, home_device, lang): self._hass = hass self._client = client self._home_device = home_device self._lang = lang @property def unique_id(self): """Return the unique ID for this sensor.""" return self._home_device["ident"]["deviceIdentLabel"]["fabNumber"] @property def name(self): """Return the name of the sensor.""" ident = self._home_device["ident"] result = ident["deviceName"] if len(result) == 0: result = ident["type"]["value_localized"] return result @property def state(self): """Return the state of the sensor.""" result = self._home_device["state"]["status"]["value_localized"] if result == None: result = self._home_device["state"]["status"]["value_raw"] return result @property def extra_state_attributes(self): """Attributes.""" result = {} result["state_raw"] = self._home_device["state"]["status"]["value_raw"] result["model"] = self._home_device["ident"]["deviceIdentLabel"]["techType"] result["device_type"] = self._home_device["ident"]["type"]["value_localized"] result["fabrication_number"] = self._home_device["ident"]["deviceIdentLabel"][ "fabNumber" ] result["gateway_type"] = self._home_device["ident"]["xkmIdentLabel"]["techType"] result["gateway_version"] = self._home_device["ident"]["xkmIdentLabel"][ "releaseVersion" ] return result async def action(self, action): await self._client.action(self.unique_id, action) async def start_program(self, program_id): await self._client.start_program(self.unique_id, program_id) async def async_update(self): if not self.unique_id in self._hass.data[DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device not found: {}".format(self.unique_id)) else: self._home_device = self._hass.data[DOMAIN][DATA_DEVICES][self.unique_id] ================================================ FILE: custom_components/miele/binary_sensor.py ================================================ import logging from datetime import timedelta from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.entity import Entity from custom_components.miele import CAPABILITIES, DATA_DEVICES from custom_components.miele import DOMAIN as MIELE_DOMAIN PLATFORMS = ["miele"] _LOGGER = logging.getLogger(__name__) ALL_DEVICES = [] def state_capability(type, state): type_str = str(type) if state in CAPABILITIES[type_str]: return True def _map_key(key): if key == "signalInfo": return "Info" elif key == "signalFailure": return "Failure" elif key == "signalDoor": return "Door" elif key == "mobileStart": return "MobileStart" # pylint: disable=W0612 def setup_platform(hass, config, add_devices, discovery_info=None): global ALL_DEVICES devices = hass.data[MIELE_DOMAIN][DATA_DEVICES] for k, device in devices.items(): device_state = device["state"] device_type = device["ident"]["type"]["value_raw"] binary_devices = [] if "signalInfo" in device_state and state_capability( type=device_type, state="signalInfo" ): binary_devices.append(MieleBinarySensor(hass, device, "signalInfo")) if "signalFailure" in device_state and state_capability( type=device_type, state="signalFailure" ): binary_devices.append(MieleBinarySensor(hass, device, "signalFailure")) if "signalDoor" in device_state and state_capability( type=device_type, state="signalDoor" ): binary_devices.append(MieleBinarySensor(hass, device, "signalDoor")) if "remoteEnable" in device_state and state_capability( type=device_type, state="remoteEnable" ): remote_state = device_state["remoteEnable"] if "mobileStart" in remote_state: binary_devices.append( MieleBinarySensor(hass, device, "remoteEnable.mobileStart") ) add_devices(binary_devices) ALL_DEVICES = ALL_DEVICES + binary_devices def update_device_state(): for device in ALL_DEVICES: try: device.async_schedule_update_ha_state(True) except (AssertionError, AttributeError): _LOGGER.debug( "Component most likely is disabled manually, if not please report to developer" "{}".format(device.entity_id) ) class MieleBinarySensor(BinarySensorEntity): def __init__(self, hass, device, key): self._hass = hass self._device = device self._keys = key.split(".") self._key = self._keys[-1] self._ha_key = _map_key(self._key) @property def device_id(self): """Return the unique ID for this sensor.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this sensor.""" return self.device_id + "_" + self._ha_key @property def name(self): """Return the name of the sensor.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return ident["type"]["value_localized"] + " " + self._ha_key else: return result + " " + self._ha_key @property def is_on(self): """Return the state of the sensor.""" current_val = self._device["state"] for k in self._keys: current_val = current_val[k] return bool(current_val) @property def device_class(self): if self._key == "signalDoor": return "door" elif self._key == "mobileStart": return "running" else: return "problem" async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device not found: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] ================================================ FILE: custom_components/miele/fan.py ================================================ import logging import math from datetime import timedelta from typing import Optional from homeassistant.components.fan import FanEntityFeature, FanEntity from homeassistant.helpers.entity import Entity from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) from custom_components.miele import DATA_CLIENT, DATA_DEVICES from custom_components.miele import DOMAIN as MIELE_DOMAIN PLATFORMS = ["miele"] _LOGGER = logging.getLogger(__name__) ALL_DEVICES = [] SUPPORTED_TYPES = [18] SPEED_RANGE = (1, 4) # pylint: disable=W0612 def setup_platform(hass, config, add_devices, discovery_info=None): global ALL_DEVICES devices = hass.data[MIELE_DOMAIN][DATA_DEVICES] for k, device in devices.items(): device_type = device["ident"]["type"] fan_devices = [] if device_type["value_raw"] in SUPPORTED_TYPES: fan_devices.append(MieleFan(hass, device)) add_devices(fan_devices) ALL_DEVICES = ALL_DEVICES + fan_devices def update_device_state(): for device in ALL_DEVICES: try: device.async_schedule_update_ha_state(True) except (AssertionError, AttributeError): _LOGGER.debug( "Component most likely is disabled manually, if not please report to developer" "{}".format(device.entity_id) ) class MieleFan(FanEntity): def __init__(self, hass, device): self._hass = hass self._device = device self._ha_key = "fan" self._current_speed = 0 @property def device_id(self): """Return the unique ID for this fan.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this fan.""" return self.device_id @property def name(self): """Return the name of the fan.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return ident["type"]["value_localized"] else: return result @property def is_on(self): """Return the state of the fan.""" value_raw = self._device["state"]["ventilationStep"]["value_raw"] return value_raw != None and value_raw != 0 @property def supported_features(self): """Flag supported features.""" return FanEntityFeature.SET_SPEED @property def speed(self): """Return the current speed""" return self._device["state"]["ventilationStep"]["value_raw"] @property def percentage(self) -> Optional[int]: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._current_speed) @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" return int_states_in_range(SPEED_RANGE) def turn_on(self, percentage: Optional[int] = None, **kwargs) -> None: """Turn on the fan.""" value_in_range = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) if percentage == "0": self.turn_off() client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] client.action(device_id=self.device_id, body={"powerOn": True}) async def async_turn_on(self, percentage: Optional[int] = None, **kwargs): """Turn on the fan.""" if percentage == "0": await self.async_turn_off() elif percentage is not None: await self.async_set_percentage(percentage=percentage) client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] await client.action(device_id=self.device_id, body={"powerOn": True}) else: _LOGGER.debug("Turning on") client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] await client.action(device_id=self.device_id, body={"powerOn": True}) def turn_off(self, **kwargs): _LOGGER.debug("Turning off") client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] client.action(device_id=self.device_id, body={"powerOff": True}) async def async_turn_off(self, **kwargs): client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] await client.action(device_id=self.device_id, body={"powerOff": True}) def set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" # TODO: value_in_range = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) self._current_speed = value_in_range _LOGGER.debug("Setting speed to : {}".format(value_in_range)) client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] client.action( device_id=self.device_id, body={"ventilationStep": value_in_range} ) async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" # value_in_range = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) self._current_speed = value_in_range _LOGGER.debug("Setting speed to : {}".format(value_in_range)) client = self._hass.data[MIELE_DOMAIN][DATA_CLIENT] await client.action( device_id=self.device_id, body={"ventilationStep": value_in_range} ) async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device not found: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] ================================================ FILE: custom_components/miele/light.py ================================================ import logging from datetime import timedelta from homeassistant.components.light import LightEntity from homeassistant.helpers.entity import Entity from custom_components.miele import DATA_CLIENT, DATA_DEVICES from custom_components.miele import DOMAIN as MIELE_DOMAIN PLATFORMS = ["miele"] _LOGGER = logging.getLogger(__name__) ALL_DEVICES = [] SUPPORTED_TYPES = [17, 18, 32, 33, 34, 68] # pylint: disable=W0612 def setup_platform(hass, config, add_devices, discovery_info=None): global ALL_DEVICES devices = hass.data[MIELE_DOMAIN][DATA_DEVICES] for k, device in devices.items(): device_type = device["ident"]["type"] light_devices = [] if device_type["value_raw"] in SUPPORTED_TYPES: light_devices.append(MieleLight(hass, device)) add_devices(light_devices) ALL_DEVICES = ALL_DEVICES + light_devices def update_device_state(): for device in ALL_DEVICES: try: device.async_schedule_update_ha_state(True) except (AssertionError, AttributeError): _LOGGER.debug( "Component most likely is disabled manually, if not please report to developer" "{}".format(device.entity_id) ) class MieleLight(LightEntity): def __init__(self, hass, device): self._hass = hass self._device = device self._ha_key = "light" @property def device_id(self): """Return the unique ID for this light.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this light.""" return self.device_id @property def name(self): """Return the name of the light.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return ident["type"]["value_localized"] else: return result @property def is_on(self): """Return the state of the light.""" return self._device["state"]["light"] == 1 def turn_on(self, **kwargs): service_parameters = {"device_id": self.device_id, "body": {"light": 1}} self._hass.services.call(MIELE_DOMAIN, "action", service_parameters) def turn_off(self, **kwargs): service_parameters = {"device_id": self.device_id, "body": {"light": 2}} self._hass.services.call(MIELE_DOMAIN, "action", service_parameters) async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device not found: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] ================================================ FILE: custom_components/miele/manifest.json ================================================ { "domain": "miele", "name": "Miele@home", "documentation": "https://github.com/HomeAssistant-Mods/home-assistant-miele", "issue_tracker": "https://github.com/HomeAssistant-Mods/home-assistant-miele/issues", "version": "v2021.10.12", "iot_class": "cloud_polling", "requirements": [ "requests_oauthlib>=1.3.0" ], "dependencies": [ "http","configurator" ], "codeowners": [ "@kloknibor", "@docbobo" ] } ================================================ FILE: custom_components/miele/miele_at_home.py ================================================ import asyncio import functools import json import logging import os from datetime import timedelta from requests.exceptions import ConnectionError from requests_oauthlib import OAuth2Session _LOGGER = logging.getLogger(__name__) class MieleClient(object): DEVICES_URL = "https://api.mcs3.miele.com/v1/devices" ACTION_URL = "https://api.mcs3.miele.com/v1/devices/{0}/actions" PROGRAMS_URL = "https://api.mcs3.miele.com/v1/devices/{0}/programs" def __init__(self, hass, session): self._session = session self.hass = hass async def _get_devices_raw(self, lang): _LOGGER.debug("Requesting Miele device update") try: func = functools.partial( self._session._session.get, MieleClient.DEVICES_URL, params={"language": lang}, ) devices = await self.hass.async_add_executor_job(func) if devices.status_code == 401: _LOGGER.info("Request unauthorized - attempting token refresh") if await self._session.refresh_token(self.hass): return await self._get_devices_raw(lang) if devices.status_code != 200: _LOGGER.debug( "Failed to retrieve devices: {}".format(devices.status_code) ) return None return devices.json() except ConnectionError as err: _LOGGER.error("Failed to retrieve Miele devices: {0}".format(err)) return None async def get_devices(self, lang="en"): home_devices = await self._get_devices_raw(lang) if home_devices is None: return None result = [] for home_device in home_devices: result.append(home_devices[home_device]) return result def get_device(self, device_id, lang="en"): devices = self._get_devices_raw(lang) if devices is None: return None if devices is not None: return devices[device_id] return None async def action(self, device_id, body): _LOGGER.debug("Executing device action for {}{}".format(device_id, body)) try: headers = {"Content-Type": "application/json"} func = functools.partial( self._session._session.put, MieleClient.ACTION_URL.format(device_id), data=json.dumps(body), headers=headers, ) result = await self.hass.async_add_executor_job(func) if result.status_code == 401: _LOGGER.info("Request unauthorized - attempting token refresh") if await self._session.refresh_token(self.hass): if self._session.authorized: return self.action(device_id, body) else: self._session._delete_token() self._session.new_session() return self.action(device_id, body) if result.status_code == 200: return result.json() elif result.status_code == 204: return None else: _LOGGER.error( "Failed to execute device action for {}: {} {}".format( device_id, result.status_code, result.json() ) ) return None except ConnectionError as err: _LOGGER.error("Failed to execute device action: {}".format(err)) return None async def start_program(self, device_id, program_id): _LOGGER.debug("Starting program {} for {}".format(program_id, device_id)) try: headers = {"Content-Type": "application/json"} func = functools.partial( self._session._session.put, MieleClient.PROGRAMS_URL.format(device_id), data=json.dumps({"programId": program_id}), headers=headers, ) result = await self.hass.async_add_executor_job(func) if result.status_code == 401: _LOGGER.info("Request unauthorized - attempting token refresh") if await self._session.refresh_token(self.hass): if self._session.authorized: return self.start_program(device_id, program_id) else: self._session._delete_token() self._session.new_session() return self.start_program(device_id, program_id) if result.status_code == 200: return result.json() elif result.status_code == 204: return None else: _LOGGER.error( "Failed to execute start program for {}: {} {}".format( device_id, result.status_code, result.json() ) ) return None except ConnectionError as err: _LOGGER.error("Failed to execute start program: {}".format(err)) return None class MieleOAuth(object): """ Implements Authorization Code Flow for Miele@home implementation. """ OAUTH_AUTHORIZE_URL = "https://api.mcs3.miele.com/thirdparty/login" OAUTH_TOKEN_URL = "https://api.mcs3.miele.com/thirdparty/token" def __init__(self, hass, client_id, client_secret, redirect_uri, cache_path=None): self._client_id = client_id self._client_secret = client_secret self._cache_path = cache_path self._redirect_uri = redirect_uri self._token = self._get_cached_token() self._extra = { "client_id": self._client_id, "client_secret": self._client_secret, } self._session = OAuth2Session( self._client_id, auto_refresh_url=MieleOAuth.OAUTH_TOKEN_URL, redirect_uri=redirect_uri, token=self._token, token_updater=self._save_token, auto_refresh_kwargs=self._extra, ) if self.authorized: asyncio.create_task(self.refresh_token(hass)) @property def authorized(self): return self._session.authorized @property def authorization_url(self): return self._session.authorization_url( MieleOAuth.OAUTH_AUTHORIZE_URL, state="login" )[0] def get_access_token(self, client_code): token = self._session.fetch_token( MieleOAuth.OAUTH_TOKEN_URL, code=client_code, include_client_id=True, client_secret=self._client_secret, ) self._save_token(token) return token async def refresh_token(self, hass): body = "client_id={}&client_secret={}&".format( self._client_id, self._client_secret ) self._token = await hass.async_add_executor_job( self.sync_refresh_token, MieleOAuth.OAUTH_TOKEN_URL, body, self._token["refresh_token"], ) self._save_token(self._token) def sync_refresh_token(self, token_url, body, refresh_token): try: return self._session.refresh_token( token_url, body=body, refresh_token=refresh_token ) except: self._remove_token() def _get_cached_token(self): token = None if self._cache_path: try: f = open(self._cache_path) token_info_string = f.read() f.close() token = json.loads(token_info_string) except IOError: pass return token def _delete_token(self): if self._cache_path: try: os.remove(self._cache_path) except IOError: _LOGGER.warn("Unable to delete cached token") self._token = None def _new_session(self, redirect_uri): self._session = OAuth2Session( self._client_id, auto_refresh_url=MieleOAuth.OAUTH_TOKEN_URL, redirect_uri=self._redirect_uri, token=self._token, token_updater=self._save_token, auto_refresh_kwargs=self._extra, ) if self.authorized: self.refresh_token() def _save_token(self, token): _LOGGER.debug("trying to save new token") if self._cache_path: try: f = open(self._cache_path, "w") f.write(json.dumps(token)) f.close() except IOError: _LOGGER.warn( "Couldn't write token cache to {0}".format(self._cache_path) ) pass self._token = token def _remove_token(self): _LOGGER.debug("trying to REMOVE token to create it again on next startup so please re-startup and try again!") if self._cache_path: try: os.remove(self._cache_path) except IOError: _LOGGER.warn( "Couldn't delte token cache to {0}".format(self._cache_path) ) pass ================================================ FILE: custom_components/miele/sensor.py ================================================ import logging from datetime import datetime, timedelta from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.helpers.entity import Entity from custom_components.miele import CAPABILITIES, DATA_DEVICES from custom_components.miele import DOMAIN as MIELE_DOMAIN PLATFORMS = ["miele"] _LOGGER = logging.getLogger(__name__) ALL_DEVICES = [] # https://www.miele.com/developer/swagger-ui/swagger.html#/ STATUS_OFF = 1 STATUS_ON = 2 STATUS_PROGRAMMED = 3 STATUS_PROGRAMMED_WAITING_TO_START = 4 STATUS_RUNNING = 5 STATUS_PAUSE = 6 STATUS_END_PROGRAMMED = 7 STATUS_FAILURE = 8 STATUS_PROGRAMME_INTERRUPTED = 9 STATUS_IDLE = 10 STATUS_RINSE_HOLD = 11 STATUS_SERVICE = 12 STATUS_SUPERFREEZING = 13 STATUS_SUPERCOOLING = 14 STATUS_SUPERHEATING = 15 STATUS_SUPERCOOLING_SUPERFREEZING = 146 STATUS_NOT_CONNECTED = 255 def _map_key(key): if key == "status": return "Status" elif key == "ProgramID": return "Program ID" elif key == "programType": return "Program Type" elif key == "programPhase": return "Program Phase" elif key == "targetTemperature": return "Target Temperature" elif key == "temperature": return "Temperature" elif key == "dryingStep": return "Drying Step" elif key == "spinningSpeed": return "Spin Speed" elif key == "remainingTime": return "Remaining Time" elif key == "elapsedTime": return "Elapsed Time" elif key == "startTime": return "Start Time" elif key == "energyConsumption": return "Energy" elif key == "waterConsumption": return "Water Consumption" elif key == "batteryLevel": return "Battery Level" elif key == "energyForecast": return "Energy cons. forecast" elif key == "waterForecast": return "Water cons. forecast" def state_capability(type, state): type_str = str(type) if state in CAPABILITIES[type_str]: return True def _is_running(device_status): return device_status in [ STATUS_RUNNING, STATUS_PAUSE, STATUS_END_PROGRAMMED, STATUS_PROGRAMME_INTERRUPTED, STATUS_RINSE_HOLD, ] def _is_terminated(device_status): return device_status in [STATUS_END_PROGRAMMED, STATUS_PROGRAMME_INTERRUPTED] def _to_seconds(time_array): if len(time_array) == 3: return time_array[0] * 3600 + time_array[1] * 60 + time_array[2] elif len(time_array) == 2: return time_array[0] * 3600 + time_array[1] * 60 else: return 0 # pylint: disable=W0612 def setup_platform(hass, config, add_devices, discovery_info=None): global ALL_DEVICES devices = hass.data[MIELE_DOMAIN][DATA_DEVICES] for k, device in devices.items(): device_state = device["state"] device_type = device["ident"]["type"]["value_raw"] sensors = [] if "status" in device_state and state_capability( type=device_type, state="status" ): sensors.append(MieleStatusSensor(hass, device, "status")) if "ProgramID" in device_state and state_capability( type=device_type, state="ProgramID" ): sensors.append(MieleTextSensor(hass, device, "ProgramID")) if "programPhase" in device_state and state_capability( type=device_type, state="programPhase" ): sensors.append(MieleTextSensor(hass, device, "programPhase")) if "targetTemperature" in device_state and state_capability( type=device_type, state="targetTemperature" ): for i, val in enumerate(device_state["targetTemperature"]): sensors.append( MieleTemperatureSensor(hass, device, "targetTemperature", i) ) # washer, washer-dryer and dishwasher only have first target temperarure sensor if "targetTemperature" in device_state and state_capability( type=device_type, state="targetTemperature.0" ): sensors.append( MieleTemperatureSensor(hass, device, "targetTemperature", 0, True) ) if "temperature" in device_state and state_capability( type=device_type, state="temperature" ): for i, val in enumerate(device_state["temperature"]): sensors.append(MieleTemperatureSensor(hass, device, "temperature", i)) if "dryingStep" in device_state and state_capability( type=device_type, state="dryingStep" ): sensors.append(MieleTextSensor(hass, device, "dryingStep")) if "spinningSpeed" in device_state and state_capability( type=device_type, state="spinningSpeed" ): sensors.append(MieleTextSensor(hass, device, "spinningSpeed")) if "remainingTime" in device_state and state_capability( type=device_type, state="remainingTime" ): sensors.append(MieleTimeSensor(hass, device, "remainingTime", True)) if "startTime" in device_state and state_capability( type=device_type, state="startTime" ): sensors.append(MieleTimeSensor(hass, device, "startTime")) if "elapsedTime" in device_state and state_capability( type=device_type, state="elapsedTime" ): sensors.append(MieleTimeSensor(hass, device, "elapsedTime")) if "ecoFeedback" in device_state and state_capability( type=device_type, state="ecoFeedback.energyConsumption" ): sensors.append( MieleConsumptionSensor( hass, device, "energyConsumption", "kWh", SensorDeviceClass.ENERGY ) ) if "ecoFeedback" in device_state and state_capability( type=device_type, state="ecoFeedback.waterConsumption" ): sensors.append( MieleConsumptionForecastSensor(hass, device, "energyForecast") ) if "ecoFeedback" in device_state and state_capability( type=device_type, state="ecoFeedback.waterConsumption" ): sensors.append( MieleConsumptionSensor(hass, device, "waterConsumption", "L", None) ) sensors.append( MieleConsumptionForecastSensor(hass, device, "waterForecast") ) if "batteryLevel" in device_state and state_capability( type=device_type, state="batteryLevel" ): sensors.append(MieleBatterySensor(hass, device, "batteryLevel")) add_devices(sensors) ALL_DEVICES = ALL_DEVICES + sensors def update_device_state(): for device in ALL_DEVICES: try: device.async_schedule_update_ha_state(True) except (AssertionError, AttributeError): _LOGGER.debug( "Component most likely is disabled manually, if not please report to developer" "{}".format(device.entity_id) ) class MieleRawSensor(Entity): def __init__(self, hass, device, key): self._hass = hass self._device = device self._key = key @property def device_id(self): """Return the unique ID for this sensor.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this sensor.""" return self.device_id + "_" + self._key @property def name(self): """Return the name of the sensor.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return ident["type"]["value_localized"] + " " + _map_key(self._key) else: return result + " " + _map_key(self._key) @property def state(self): """Return the state of the sensor.""" return self._device["state"][self._key]["value_raw"] async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device disappeared: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] class MieleSensorEntity(SensorEntity): def __init__(self, hass, device, key): self._hass = hass self._device = device self._key = key @property def device_id(self): """Return the unique ID for this sensor.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this sensor.""" return self.device_id + "_" + self._key @property def name(self): """Return the name of the sensor.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return ident["type"]["value_localized"] + " " + _map_key(self._key) else: return result + " " + _map_key(self._key) async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug("Miele device disappeared: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] class MieleStatusSensor(MieleRawSensor): @property def state(self): """Return the state of the sensor.""" result = self._device["state"]["status"]["value_localized"] if result == None: result = self._device["state"]["status"]["value_raw"] return result @property def extra_state_attributes(self): """Attributes.""" device_state = self._device["state"] attributes = {} if "ProgramID" in device_state: attributes["ProgramID"] = device_state["ProgramID"]["value_localized"] attributes["rawProgramID"] = device_state["ProgramID"]["value_raw"] if "programType" in device_state: attributes["programType"] = device_state["programType"]["value_localized"] attributes["rawProgramType"] = device_state["programType"]["value_raw"] if "programPhase" in device_state: attributes["programPhase"] = device_state["programPhase"]["value_localized"] attributes["rawProgramPhase"] = device_state["programPhase"]["value_raw"] if "dryingStep" in device_state: attributes["dryingStep"] = device_state["dryingStep"]["value_localized"] attributes["rawDryingStep"] = device_state["dryingStep"]["value_raw"] if "spinningSpeed" in device_state: attributes["spinningSpeed"] = device_state["spinningSpeed"][ "value_localized" ] attributes["rawSpinningSpeed"] = device_state["spinningSpeed"]["value_raw"] if "ventilationStep" in device_state: attributes["ventilationStep"] = device_state["ventilationStep"][ "value_localized" ] attributes["rawVentilationStep"] = device_state["ventilationStep"][ "value_raw" ] if "plateStep" in device_state: plate_steps = 1 for plateStep in device_state["plateStep"]: attributes["plateStep" + str(plate_steps)] = plateStep[ "value_localized" ] attributes["rawPlateStep" + str(plate_steps)] = plateStep["value_raw"] plate_steps += 1 if "ecoFeedback" in device_state and device_state["ecoFeedback"] is not None: if "currentWaterConsumption" in device_state["ecoFeedback"]: attributes["currentWaterConsumption"] = device_state["ecoFeedback"][ "currentWaterConsumption" ]["value"] attributes["currentWaterConsumptionUnit"] = device_state["ecoFeedback"][ "currentWaterConsumption" ]["unit"] if "currentEnergyConsumption" in device_state["ecoFeedback"]: attributes["currentEnergyConsumption"] = device_state["ecoFeedback"][ "currentEnergyConsumption" ]["value"] attributes["currentEnergyConsumptionUnit"] = device_state[ "ecoFeedback" ]["currentEnergyConsumption"]["unit"] if "waterForecast" in device_state["ecoFeedback"]: attributes["waterForecast"] = device_state["ecoFeedback"][ "waterForecast" ] if "energyForecast" in device_state["ecoFeedback"]: attributes["energyForecast"] = device_state["ecoFeedback"][ "energyForecast" ] # Programs will only be running of both remainingTime and elapsedTime indicate # a value > 0 if "remainingTime" in device_state and "elapsedTime" in device_state: remainingTime = _to_seconds(device_state["remainingTime"]) elapsedTime = _to_seconds(device_state["elapsedTime"]) if "startTime" in device_state: startTime = _to_seconds(device_state["startTime"]) else: startTime = 0 # Calculate progress if (elapsedTime + remainingTime) == 0: attributes["progress"] = None else: attributes["progress"] = round( elapsedTime / (elapsedTime + remainingTime) * 100, 1 ) # Calculate end time if remainingTime == 0: attributes["finishTime"] = None else: now = datetime.now() attributes["finishTime"] = ( now + timedelta(seconds=startTime) + timedelta(seconds=remainingTime) ).strftime("%H:%M") # Calculate start time if startTime == 0: now = datetime.now() attributes["kickoffTime"] = ( now - timedelta(seconds=elapsedTime) ).strftime("%H:%M") else: now = datetime.now() attributes["kickoffTime"] = ( now + timedelta(seconds=startTime) ).strftime("%H:%M") return attributes class MieleConsumptionSensor(MieleSensorEntity): def __init__(self, hass, device, key, measurement, device_class): super().__init__(hass, device, key) self._attr_native_unit_of_measurement = measurement self._cached_consumption = -1 self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._attr_device_class = device_class @property def state(self): """Return the state of the sensor.""" device_state = self._device["state"] device_status_value = self._device["state"]["status"]["value_raw"] if ( not _is_running(device_status_value) and device_status_value != STATUS_NOT_CONNECTED ): self._cached_consumption = -1 return 0 if self._cached_consumption >= 0: if ( "ecoFeedback" not in device_state or device_state["ecoFeedback"] is None or device_status_value == STATUS_NOT_CONNECTED ): # Sometimes the Miele API seems to return a null ecoFeedback # object even though the Miele device is running. Or if the the # Miele device has lost the connection to the Miele cloud, the # status is "not connected". Either way, we need to return the # last known value until the API starts returning something # sane again, otherwise the statistics generated from this # sensor would be messed up. return self._cached_consumption consumption = 0 if self._key == "energyConsumption": if "currentEnergyConsumption" in device_state["ecoFeedback"]: consumption_container = device_state["ecoFeedback"][ "currentEnergyConsumption" ] if consumption_container["unit"] == "kWh": consumption = consumption_container["value"] elif consumption_container["unit"] == "Wh": consumption = consumption_container["value"] / 1000.0 else: return self._cached_consumption elif self._key == "waterConsumption": if "currentWaterConsumption" in device_state["ecoFeedback"]: consumption = device_state["ecoFeedback"]["currentWaterConsumption"][ "value" ] else: return self._cached_consumption self._cached_consumption = consumption return consumption class MieleTimeSensor(MieleRawSensor): def __init__(self, hass, device, key, decreasing=False): super().__init__(hass, device, key) self._init_value = "--:--" self._cached_time = self._init_value self._decreasing = decreasing @property def state(self): """Return the state of the sensor.""" state_value = self._device["state"][self._key] device_status_value = self._device["state"]["status"]["value_raw"] formatted_value = None if len(state_value) == 2: formatted_value = "{:02d}:{:02d}".format(state_value[0], state_value[1]) if ( not _is_running(device_status_value) and device_status_value != STATUS_NOT_CONNECTED ): self._cached_time = self._init_value return formatted_value if self._cached_time != self._init_value: # As for energy consumption, also this information could become "00:00" # when appliance is not reachable. Provide cached value in that case. # Some appliances also clear time status when terminating program. if self._decreasing and _is_terminated(device_status_value): return formatted_value elif ( formatted_value is None or device_status_value == STATUS_NOT_CONNECTED or _is_terminated(device_status_value) ): return self._cached_time self._cached_time = formatted_value return formatted_value class MieleTemperatureSensor(Entity): def __init__(self, hass, device, key, index, force_int=False): self._hass = hass self._device = device self._key = key self._index = index self._force_int = force_int @property def device_id(self): """Return the unique ID for this sensor.""" return self._device["ident"]["deviceIdentLabel"]["fabNumber"] @property def unique_id(self): """Return the unique ID for this sensor.""" return self.device_id + "_" + self._key + "_{}".format(self._index) @property def name(self): """Return the name of the sensor.""" ident = self._device["ident"] result = ident["deviceName"] if len(result) == 0: return "{} {} {}".format( ident["type"]["value_localized"], _map_key(self._key), self._index ) else: return "{} {} {}".format(result, _map_key(self._key), self._index) @property def state(self): """Return the state of the sensor.""" state_value = self._device["state"][self._key][self._index]["value_raw"] if state_value == -32768: return None elif self._force_int: return int(state_value / 100) else: return state_value / 100 @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self._device["state"][self._key][self._index]["unit"] == "Celsius": return "°C" elif self._device["state"][self._key][self._index]["unit"] == "Fahrenheit": return "°F" @property def device_class(self): return "temperature" async def async_update(self): if not self.device_id in self._hass.data[MIELE_DOMAIN][DATA_DEVICES]: _LOGGER.debug(" Miele device disappeared: {}".format(self.device_id)) else: self._device = self._hass.data[MIELE_DOMAIN][DATA_DEVICES][self.device_id] class MieleTextSensor(MieleRawSensor): @property def state(self): """Return the state of the sensor.""" result = self._device["state"][self._key]["value_localized"] if result == "": result = None return result class MieleBatterySensor(MieleSensorEntity): def __init__(self, hass, device, key): super().__init__(hass, device, key) self._attr_device_class = SensorDeviceClass.BATTERY self._attr_native_unit_of_measurement = "%" self._attr_state_class = SensorStateClass.MEASUREMENT @property def state(self): return self._device["state"][self._key] class MieleConsumptionForecastSensor(MieleSensorEntity): def __init__(self, hass, device, key): super().__init__(hass, device, key) self._attr_native_unit_of_measurement = "%" self._attr_state_class = SensorStateClass.MEASUREMENT @property def state(self): """Return the state of the sensor.""" device_state = self._device["state"] if ( device_state["ecoFeedback"] is not None and self._key in device_state["ecoFeedback"] ): return device_state["ecoFeedback"][self._key] * 100 return None ================================================ FILE: custom_components/miele/services.yaml ================================================ # Example services.yaml entry action: # Description of the service description: Runs action of Miele device # Different fields that your service accepts fields: # Key of the field entity_id: # Description of the field description: Name(s) of the entities to set (optional, either set this or device_id) # Example value that can be passed for this field example: "miele.washing_machine" device_id: # Description of the field description: fab number of device to set (optional, either set this or entity_id) # Example value that can be passed for this field example: "000123456789" body: description: The command to send example: "{\"powerOff\": true}" start_program: # Description of the service description: Starts a program on a Miele device # Different fields that your service accepts fields: # Key of the field entity_id: # Description of the field description: Name(s) of the entities to set (optional, either set this or device_id) # Example value that can be passed for this field example: "miele.washing_machine" device_id: # Description of the field description: fab number of device to set (optional, either set this or entity_id) # Example value that can be passed for this field example: "000123456789" program_id: description: The program id to start example: 1 stop_program: # Description of the service description: Stops program on a Miele device # Different fields that your service accepts fields: # Key of the field entity_id: # Description of the field description: Name(s) of the entities to set (optional, either set this or device_id) # Example value that can be passed for this field example: "miele.washing_machine" device_id: # Description of the field description: fab number of device to set (optional, either set this or entity_id) # Example value that can be passed for this field example: "000123456789" ================================================ FILE: hacs.json ================================================ { "name": "Miele integration", "homeassistant": "2025.1.0" } ================================================ FILE: info.md ================================================ # Home Assistant support for Miele@home connected appliances ## Introduction This project exposes Miele state information of appliances connected to a Miele user account. This is achieved by communicating with the Miele Cloud Service, which exposes both applicances connected to a Miele@home Gateway XGW3000, as well as those devices connected via WiFi Con@ct. ## Prerequisite * A running version of [Home Assistant](https://home-assistant.io). While earlier versions may work, the custom component has been developed and tested with version 0.76.x. * The ```requests_oauthlib``` library as part of your HA installation. Please install via ```pip3 install requests_oauthlib```. For Hassbian you need to install this via : ``` cd /srv/ sudo chown homeassistant:homeassistant homeassistant sudo su -s /bin/bash homeassistant cd /srv/homeassistant source bin/activate pip3 install requests_oauthlib ``` * Following the [instructions on the Miele developer site](https://www.miele.com/developer/getinvolved.html), you need to request your personal ```ClientID``` and ```ClientSecret```. ## Installation of the custom component * Copy the content of this repository into your ```custom_components``` folder, which is a subdirectory of your Home Assistant configuration directory. By default, this directory is located under ```~/.home-assistant```. The structure of the ```custom_components``` directory should look like this afterwards: ``` - miele - __init__.py - miele_at_home.py - binary_sensor.py - light.py - sensor.py ``` * Enabled the new platform in your ```configuration.yaml```: ``` miele: client_id: client_secret: lang: cache_path: ``` * Restart Home Assistant. * The Home Assistant Web UI will show you a UI to configure the Miele platform. Follow the instructions to log into the Miele Cloud Service. This will communicate back an authentication token that will be cached to communicate with the Cloud Service. Done. If you follow all the instructions, the Miele integration should be up and running. All Miele devices that you can see in your Mobile application should now be also visible in Home Assistant (miele.*). In addition, there will be a number of ```binary_sensors``` and ```sensors``` that can be used for automation. ## Questions Please see the [Miele@home, miele@mobile component](https://community.home-assistant.io/t/miele-home-miele-mobile-component/64508) discussion thread on the Home Assistant community site.