├── ge_kitchen ├── erd_constants │ ├── __init__.py │ └── oven_constants.py ├── manifest.json ├── exceptions.py ├── const.py ├── strings.json ├── translations │ └── en.json ├── switch.py ├── erd_string_utils.py ├── __init__.py ├── binary_sensor.py ├── config_flow.py ├── sensor.py ├── appliance_api.py ├── entities.py ├── update_coordinator.py └── water_heater.py ├── img ├── fridge_control.png ├── oven_controls.png ├── shark_vacuum_card.png ├── appliance_entities.png ├── fridge_controls_dark.png └── shark_vacuum_control.png ├── .gitattributes ├── README.md ├── LICENSE └── .gitignore /ge_kitchen/erd_constants/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/fridge_control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/fridge_control.png -------------------------------------------------------------------------------- /img/oven_controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/oven_controls.png -------------------------------------------------------------------------------- /img/shark_vacuum_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/shark_vacuum_card.png -------------------------------------------------------------------------------- /img/appliance_entities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/appliance_entities.png -------------------------------------------------------------------------------- /img/fridge_controls_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/fridge_controls_dark.png -------------------------------------------------------------------------------- /img/shark_vacuum_control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajmarks/ha_components/HEAD/img/shark_vacuum_control.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Source files 2 | # ============ 3 | *.py text diff=python eol=lf 4 | 5 | # Config and data files 6 | *.json text eol=lf 7 | *.yaml text eol=lf 8 | -------------------------------------------------------------------------------- /ge_kitchen/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ge_kitchen", 3 | "name": "GE Kitchen", 4 | "config_flow": true, 5 | "documentation": "https://www.home-assistant.io/integrations/ge_kitchen", 6 | "requirements": ["gekitchen==0.2.20"], 7 | "codeowners": ["@ajmarks"] 8 | } 9 | -------------------------------------------------------------------------------- /ge_kitchen/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions go here.""" 2 | 3 | from homeassistant import exceptions as ha_exc 4 | 5 | 6 | class CannotConnect(ha_exc.HomeAssistantError): 7 | """Error to indicate we cannot connect.""" 8 | 9 | 10 | class AuthError(ha_exc.HomeAssistantError): 11 | """Error to indicate authentication failure.""" 12 | -------------------------------------------------------------------------------- /ge_kitchen/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the ge_kitchen integration.""" 2 | from gekitchen.const import LOGIN_URL 3 | 4 | DOMAIN = "ge_kitchen" 5 | 6 | # TODO Update with your own urls 7 | # OAUTH2_AUTHORIZE = f"{LOGIN_URL}/oauth2/auth" 8 | OAUTH2_AUTH_URL = f"{LOGIN_URL}/oauth2/auth" 9 | OAUTH2_TOKEN_URL = f"{LOGIN_URL}/oauth2/token" 10 | 11 | AUTH_HANDLER = "auth_handler" 12 | EVENT_ALL_APPLIANCES_READY = 'all_appliances_ready' 13 | COORDINATOR = "coordinator" 14 | GE_TOKEN = "ge_token" 15 | MOBILE_DEVICE_TOKEN = "mdt" 16 | XMPP_CREDENTIALS = "xmpp_credentials" 17 | 18 | UPDATE_INTERVAL = 30 19 | -------------------------------------------------------------------------------- /ge_kitchen/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "[%key:common::config_flow::data::username%]", 7 | "password": "[%key:common::config_flow::data::password%]" 8 | } 9 | } 10 | }, 11 | "error": { 12 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 13 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 14 | "unknown": "[%key:common::config_flow::error::unknown%]" 15 | }, 16 | "abort": { 17 | "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ge_kitchen/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "GE Kitchen", 3 | "config": { 4 | "step": { 5 | "init": { 6 | "data": { 7 | "username": "Username", 8 | "password": "Password" 9 | } 10 | }, 11 | "user": { 12 | "data": { 13 | "username": "Username", 14 | "password": "Password" 15 | } 16 | } 17 | }, 18 | "error": { 19 | "cannot_connect": "Failed to connect", 20 | "invalid_auth": "Invalid authentication", 21 | "unknown": "Unexpected error" 22 | }, 23 | "abort": { 24 | "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Components for Home Assistant 2 | 3 | ## `ge_kitchen` 4 | Integration for GE WiFi-enabled kitchen appliances. So far, I've only done fridges and ovens (because that's what I 5 | have), but I hope to to dishwashers next. Because HA doesn't have Fridge or Oven platforms, both fridges and ovens are 6 | primarily represented as water heater entities, which works surprisingly well. If anybody who has other GE appliances 7 | sees this and wants to pitch in, please shoot me a message or make a PR. 8 | 9 | Entities card: 10 | 11 | ![Entities](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/appliance_entities.png) 12 | 13 | Fridge Controls: 14 | 15 | ![Fridge controls](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/fridge_control.png) 16 | 17 | Oven Controls: 18 | 19 | ![Fridge controls](https://raw.githubusercontent.com/ajmarks/ha_components/master/img/oven_controls.png) 20 | 21 | ## What happened to `shark_iq`? 22 | 23 | It's part of Home Assistant as of [0.115](https://www.home-assistant.io/blog/2020/09/17/release-115/)! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andrew Marks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ge_kitchen/switch.py: -------------------------------------------------------------------------------- 1 | """GE Kitchen Sensor Entities""" 2 | import async_timeout 3 | import logging 4 | from typing import Callable, TYPE_CHECKING 5 | 6 | from homeassistant.components.switch import SwitchEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .binary_sensor import GeErdBinarySensor 11 | from .const import DOMAIN 12 | 13 | if TYPE_CHECKING: 14 | from .update_coordinator import GeKitchenUpdateCoordinator 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class GeErdSwitch(GeErdBinarySensor, SwitchEntity): 20 | """Switches for boolean ERD codes.""" 21 | device_class = "switch" 22 | 23 | @property 24 | def is_on(self) -> bool: 25 | """Return True if switch is on.""" 26 | return bool(self.appliance.get_erd_value(self.erd_code)) 27 | 28 | async def async_turn_on(self, **kwargs): 29 | """Turn the switch on.""" 30 | _LOGGER.debug(f"Turning on {self.unique_id}") 31 | await self.appliance.async_set_erd_value(self.erd_code, True) 32 | 33 | async def async_turn_off(self, **kwargs): 34 | """Turn the switch off.""" 35 | _LOGGER.debug(f"Turning on {self.unique_id}") 36 | await self.appliance.async_set_erd_value(self.erd_code, False) 37 | 38 | 39 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): 40 | """GE Kitchen sensors.""" 41 | _LOGGER.debug('Adding GE Kitchen switches') 42 | coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] 43 | 44 | # This should be a NOP, but let's be safe 45 | with async_timeout.timeout(20): 46 | await coordinator.initialization_future 47 | _LOGGER.debug('Coordinator init future finished') 48 | 49 | apis = list(coordinator.appliance_apis.values()) 50 | _LOGGER.debug(f'Found {len(apis):d} appliance APIs') 51 | entities = [ 52 | entity 53 | for api in apis 54 | for entity in api.entities 55 | if isinstance(entity, GeErdSwitch) and entity.erd_code in api.appliance._property_cache 56 | ] 57 | _LOGGER.debug(f'Found {len(entities):d} switches') 58 | async_add_entities(entities) 59 | -------------------------------------------------------------------------------- /ge_kitchen/erd_string_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities to make nice strings from ERD values.""" 2 | 3 | __all__ = ( 4 | "hot_water_status_str", 5 | "oven_display_state_to_str", 6 | "oven_cook_setting_to_str", 7 | "bucket_status_to_str", 8 | "door_status_to_str", 9 | ) 10 | 11 | from typing import Optional 12 | 13 | from gekitchen.erd_types import OvenCookSetting, FridgeIceBucketStatus, HotWaterStatus 14 | from gekitchen.erd_constants import ErdOvenState, ErdFullNotFull, ErdDoorStatus 15 | from .erd_constants.oven_constants import ( 16 | OVEN_DISPLAY_STATE_MAP, 17 | STATE_OVEN_DELAY, 18 | STATE_OVEN_PROBE, 19 | STATE_OVEN_SABBATH, 20 | STATE_OVEN_TIMED, 21 | STATE_OVEN_UNKNOWN, 22 | ) 23 | 24 | 25 | def oven_display_state_to_str(oven_state: ErdOvenState) -> str: 26 | """Translate ErdOvenState values to a nice constant.""" 27 | return OVEN_DISPLAY_STATE_MAP.get(oven_state, STATE_OVEN_UNKNOWN) 28 | 29 | 30 | def oven_cook_setting_to_str(cook_setting: OvenCookSetting, units: str) -> str: 31 | """Format OvenCookSetting values nicely.""" 32 | cook_mode = cook_setting.cook_mode 33 | cook_state = cook_mode.oven_state 34 | temperature = cook_setting.temperature 35 | 36 | modifiers = [] 37 | if cook_mode.timed: 38 | modifiers.append(STATE_OVEN_TIMED) 39 | if cook_mode.delayed: 40 | modifiers.append(STATE_OVEN_DELAY) 41 | if cook_mode.probe: 42 | modifiers.append(STATE_OVEN_PROBE) 43 | if cook_mode.sabbath: 44 | modifiers.append(STATE_OVEN_SABBATH) 45 | 46 | temp_str = f" ({temperature}{units})" if temperature > 0 else "" 47 | modifier_str = f" ({', '.join(modifiers)})" if modifiers else "" 48 | display_state = oven_display_state_to_str(cook_state) 49 | return f"{display_state}{temp_str}{modifier_str}" 50 | 51 | 52 | def bucket_status_to_str(bucket_status: FridgeIceBucketStatus) -> str: 53 | status = bucket_status.total_status 54 | if status == ErdFullNotFull.FULL: 55 | return "Full" 56 | if status == ErdFullNotFull.NOT_FULL: 57 | return "Not Full" 58 | if status == ErdFullNotFull.NA: 59 | return "NA" 60 | 61 | 62 | def hot_water_status_str(water_status: HotWaterStatus) -> str: 63 | raise NotImplementedError 64 | 65 | 66 | def door_status_to_str(door_status: ErdDoorStatus) -> Optional[str]: 67 | if door_status == ErdDoorStatus.NA: 68 | return None 69 | return door_status.name.title() 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDE files 132 | .idea/ 133 | .vscode/ 134 | 135 | # Secrets 136 | */secrets.py -------------------------------------------------------------------------------- /ge_kitchen/__init__.py: -------------------------------------------------------------------------------- 1 | """The ge_kitchen integration.""" 2 | 3 | import asyncio 4 | import async_timeout 5 | import logging 6 | import voluptuous as vol 7 | 8 | from gekitchen import GeAuthError, GeServerError 9 | 10 | from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT 11 | from homeassistant.const import CONF_USERNAME 12 | from homeassistant.core import HomeAssistant 13 | from . import config_flow 14 | from .const import ( 15 | AUTH_HANDLER, 16 | COORDINATOR, 17 | DOMAIN, 18 | OAUTH2_AUTH_URL, 19 | OAUTH2_TOKEN_URL, 20 | ) 21 | from .exceptions import AuthError, CannotConnect 22 | from .update_coordinator import GeKitchenUpdateCoordinator 23 | 24 | CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) 25 | PLATFORMS = ["binary_sensor", "sensor", "switch", "water_heater"] 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup(hass: HomeAssistant, config: dict): 31 | """Set up the ge_kitchen component.""" 32 | hass.data.setdefault(DOMAIN, {}) 33 | if DOMAIN not in config: 34 | return True 35 | 36 | 37 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 38 | """Set up ge_kitchen from a config entry.""" 39 | coordinator = GeKitchenUpdateCoordinator(hass, entry) 40 | hass.data[DOMAIN][entry.entry_id] = coordinator 41 | 42 | try: 43 | await coordinator.async_start_client() 44 | except GeAuthError: 45 | raise AuthError('Authentication failure') 46 | except GeServerError: 47 | raise CannotConnect('Cannot connect (server error)') 48 | except Exception: 49 | raise CannotConnect('Unknown connection failure') 50 | 51 | try: 52 | with async_timeout.timeout(30): 53 | await coordinator.initialization_future 54 | except TimeoutError: 55 | raise CannotConnect('Initialization timed out') 56 | 57 | for component in PLATFORMS: 58 | hass.async_create_task( 59 | hass.config_entries.async_forward_entry_setup(entry, component) 60 | ) 61 | 62 | return True 63 | 64 | 65 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 66 | """Unload a config entry.""" 67 | unload_ok = all( 68 | await asyncio.gather( 69 | *[ 70 | hass.config_entries.async_forward_entry_unload(entry, component) 71 | for component in PLATFORMS 72 | ] 73 | ) 74 | ) 75 | if unload_ok: 76 | hass.data[DOMAIN].pop(entry.entry_id) 77 | 78 | return unload_ok 79 | 80 | 81 | async def async_update_options(hass, config_entry): 82 | """Update options.""" 83 | await hass.config_entries.async_reload(config_entry.entry_id) 84 | -------------------------------------------------------------------------------- /ge_kitchen/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """GE Kitchen Sensor Entities""" 2 | import async_timeout 3 | import logging 4 | from typing import Callable, Optional, TYPE_CHECKING 5 | 6 | from gekitchen import ErdCodeType 7 | 8 | from homeassistant.components.binary_sensor import BinarySensorEntity 9 | from homeassistant.components.switch import SwitchEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .const import DOMAIN 14 | from .entities import DOOR_ERD_CODES, GeErdEntity, boolify_erd_value, get_erd_icon 15 | 16 | if TYPE_CHECKING: 17 | from .appliance_api import ApplianceApi 18 | from .update_coordinator import GeKitchenUpdateCoordinator 19 | 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class GeErdBinarySensor(GeErdEntity, BinarySensorEntity): 25 | """GE Entity for binary sensors""" 26 | @property 27 | def is_on(self) -> bool: 28 | """Return True if entity is on.""" 29 | return bool(self.appliance.get_erd_value(self.erd_code)) 30 | 31 | @property 32 | def icon(self) -> Optional[str]: 33 | return get_erd_icon(self.erd_code, self.is_on) 34 | 35 | @property 36 | def device_class(self) -> Optional[str]: 37 | if self.erd_code in DOOR_ERD_CODES: 38 | return "door" 39 | return None 40 | 41 | 42 | class GeErdPropertyBinarySensor(GeErdBinarySensor): 43 | """GE Entity for property binary sensors""" 44 | def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType, erd_property: str): 45 | super().__init__(api, erd_code) 46 | self.erd_property = erd_property 47 | 48 | @property 49 | def is_on(self) -> Optional[bool]: 50 | """Return True if entity is on.""" 51 | try: 52 | value = getattr(self.appliance.get_erd_value(self.erd_code), self.erd_property) 53 | except KeyError: 54 | return None 55 | return boolify_erd_value(self.erd_code, value) 56 | 57 | @property 58 | def icon(self) -> Optional[str]: 59 | return get_erd_icon(self.erd_code, self.is_on) 60 | 61 | @property 62 | def unique_id(self) -> Optional[str]: 63 | return f"{super().unique_id}_{self.erd_property}" 64 | 65 | @property 66 | def name(self) -> Optional[str]: 67 | base_string = super().name 68 | property_name = self.erd_property.replace("_", " ").title() 69 | return f"{base_string} {property_name}" 70 | 71 | 72 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): 73 | """GE Kitchen sensors.""" 74 | 75 | coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] 76 | 77 | # This should be a NOP, but let's be safe 78 | with async_timeout.timeout(20): 79 | await coordinator.initialization_future 80 | 81 | apis = coordinator.appliance_apis.values() 82 | _LOGGER.debug(f'Found {len(apis):d} appliance APIs') 83 | entities = [ 84 | entity 85 | for api in apis 86 | for entity in api.entities 87 | if isinstance(entity, GeErdBinarySensor) and not isinstance(entity, SwitchEntity) 88 | ] 89 | _LOGGER.debug(f'Found {len(entities):d} binary sensors ') 90 | async_add_entities(entities) 91 | -------------------------------------------------------------------------------- /ge_kitchen/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for GE Kitchen integration.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Dict, Optional 6 | 7 | import aiohttp 8 | import async_timeout 9 | from gekitchen import GeAuthError, GeServerError, async_get_oauth2_token 10 | import voluptuous as vol 11 | 12 | from homeassistant import config_entries, core 13 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 14 | 15 | from .const import DOMAIN # pylint:disable=unused-import 16 | from .exceptions import AuthError, CannotConnect 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | GEKITCHEN_SCHEMA = vol.Schema( 21 | {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} 22 | ) 23 | 24 | 25 | async def validate_input(hass: core.HomeAssistant, data): 26 | """Validate the user input allows us to connect.""" 27 | 28 | session = hass.helpers.aiohttp_client.async_get_clientsession(hass) 29 | 30 | # noinspection PyBroadException 31 | try: 32 | with async_timeout.timeout(10): 33 | _ = await async_get_oauth2_token(session, data[CONF_USERNAME], data[CONF_PASSWORD]) 34 | except (asyncio.TimeoutError, aiohttp.ClientError): 35 | raise CannotConnect('Connection failure') 36 | except GeAuthError: 37 | raise AuthError('Authentication failure') 38 | except GeServerError: 39 | raise CannotConnect('Cannot connect (server error)') 40 | except Exception as exc: 41 | _LOGGER.exception("Unkown connection failure", exc_info=exc) 42 | raise CannotConnect('Unknown connection failure') 43 | 44 | # Return info that you want to store in the config entry. 45 | return {"title": f"GE Kitchen ({data[CONF_USERNAME]:s})"} 46 | 47 | 48 | class GeKitchenConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 49 | """Handle a config flow for GE Kitchen.""" 50 | 51 | VERSION = 1 52 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH 53 | 54 | async def _async_validate_input(self, user_input): 55 | """Validate form input.""" 56 | errors = {} 57 | info = None 58 | 59 | if user_input is not None: 60 | # noinspection PyBroadException 61 | try: 62 | info = await validate_input(self.hass, user_input) 63 | except CannotConnect: 64 | errors["base"] = "cannot_connect" 65 | except AuthError: 66 | errors["base"] = "invalid_auth" 67 | except Exception: # pylint: disable=broad-except 68 | _LOGGER.exception("Unexpected exception") 69 | errors["base"] = "unknown" 70 | return info, errors 71 | 72 | async def async_step_user(self, user_input: Optional[Dict] = None): 73 | """Handle the initial step.""" 74 | errors = {} 75 | if user_input is not None: 76 | info, errors = await self._async_validate_input(user_input) 77 | if info: 78 | return self.async_create_entry(title=info["title"], data=user_input) 79 | 80 | return self.async_show_form( 81 | step_id="user", data_schema=GEKITCHEN_SCHEMA, errors=errors 82 | ) 83 | 84 | async def async_step_reauth(self, user_input: Optional[dict] = None): 85 | """Handle re-auth if login is invalid.""" 86 | errors = {} 87 | 88 | if user_input is not None: 89 | _, errors = await self._async_validate_input(user_input) 90 | 91 | if not errors: 92 | for entry in self._async_current_entries(): 93 | if entry.unique_id == self.unique_id: 94 | self.hass.config_entries.async_update_entry( 95 | entry, data=user_input 96 | ) 97 | await self.hass.config_entries.async_reload(entry.entry_id) 98 | return self.async_abort(reason="reauth_successful") 99 | 100 | if errors["base"] != "invalid_auth": 101 | return self.async_abort(reason=errors["base"]) 102 | 103 | return self.async_show_form( 104 | step_id="reauth", data_schema=GEKITCHEN_SCHEMA, errors=errors, 105 | ) 106 | -------------------------------------------------------------------------------- /ge_kitchen/erd_constants/oven_constants.py: -------------------------------------------------------------------------------- 1 | """Constants for GE Oven states""" 2 | 3 | from gekitchen.erd_constants import ErdOvenState 4 | 5 | STATE_OVEN_BAKE = "Bake" 6 | STATE_OVEN_BAKE_TWO_TEMP = "Bake (Two Temp.)" 7 | STATE_OVEN_BAKED_GOODS = "Baked Goods" 8 | STATE_OVEN_BROIL_HIGH = "Broil (High)" 9 | STATE_OVEN_BROIL_LOW = "Broil (Low)" 10 | STATE_OVEN_CONV_BAKE = "Convection Bake" 11 | STATE_OVEN_CONV_BAKE_TWO_TEMP = "Convection Bake (Two Temp.)" 12 | STATE_OVEN_CONV_BROIL_CRISP = "Convection Broil (Crisp)" 13 | STATE_OVEN_CONV_BROIL_HIGH = "Convection Broil (High)" 14 | STATE_OVEN_CONV_BROIL_LOW = "Convection Broil (Low)" 15 | STATE_OVEN_CONV_BAKE_MULTI = "Convection Multi Bake" 16 | STATE_OVEN_CONV_ROAST = "Convection Roast" 17 | STATE_OVEN_CONV_ROAST_TWO_TEMP = "Convection Roast (Two Temp.)" 18 | STATE_OVEN_DUAL_BROIL_HIGH = "Dual Broil (High)" 19 | STATE_OVEN_DUAL_BROIL_LOW = "Dual Broil (Low)" 20 | STATE_OVEN_DELAY = "Delayed Start" 21 | STATE_OVEN_FROZEN_PIZZA = "Frozen Pizza" 22 | STATE_OVEN_FROZEN_SNACKS = "Frozen Snacks" 23 | STATE_OVEN_MULTI_BAKE = "Multi Bake" 24 | STATE_OVEN_PREHEAT = "Preheat" 25 | STATE_OVEN_PROBE = "Probe" 26 | STATE_OVEN_PROOF = "Proof" 27 | STATE_OVEN_OFF = "Off" 28 | STATE_OVEN_SABBATH = "Sabbath Mode" 29 | STATE_OVEN_SELF_CLEAN = "Self Clean" 30 | STATE_OVEN_SPECIAL = "Special" 31 | STATE_OVEN_STEAM_CLEAN = "Steam Clean" 32 | STATE_OVEN_TIMED = "Timed" 33 | STATE_OVEN_UNKNOWN = "Unknown" 34 | STATE_OVEN_WARM = "Keep Warm" 35 | 36 | OVEN_DISPLAY_STATE_MAP = { 37 | ErdOvenState.BAKE: STATE_OVEN_BAKE, 38 | ErdOvenState.BAKE_PREHEAT: STATE_OVEN_PREHEAT, 39 | ErdOvenState.BAKE_TWO_TEMP: STATE_OVEN_BAKE_TWO_TEMP, 40 | ErdOvenState.BROIL_HIGH: STATE_OVEN_BROIL_HIGH, 41 | ErdOvenState.BROIL_LOW: STATE_OVEN_BROIL_LOW, 42 | ErdOvenState.CLEAN_COOL_DOWN: STATE_OVEN_SELF_CLEAN, 43 | ErdOvenState.CLEAN_STAGE1: STATE_OVEN_SELF_CLEAN, 44 | ErdOvenState.CLEAN_STAGE2: STATE_OVEN_SELF_CLEAN, 45 | ErdOvenState.CONV_BAKE: STATE_OVEN_CONV_BAKE, 46 | ErdOvenState.CONV_BAKE_PREHEAT: STATE_OVEN_PREHEAT, 47 | ErdOvenState.CONV_BAKE_TWO_TEMP: STATE_OVEN_CONV_BAKE_TWO_TEMP, 48 | ErdOvenState.CONV_BROIL_CRISP: STATE_OVEN_CONV_BROIL_CRISP, 49 | ErdOvenState.CONV_BROIL_HIGH: STATE_OVEN_CONV_BROIL_HIGH, 50 | ErdOvenState.CONV_BROIL_LOW: STATE_OVEN_CONV_BROIL_LOW, 51 | ErdOvenState.CONV_MULTI_BAKE_PREHEAT: STATE_OVEN_PREHEAT, 52 | ErdOvenState.CONV_MULTI_TWO_BAKE: STATE_OVEN_MULTI_BAKE, 53 | ErdOvenState.CONV_MUTLI_BAKE: STATE_OVEN_MULTI_BAKE, 54 | ErdOvenState.CONV_ROAST: STATE_OVEN_CONV_ROAST, 55 | ErdOvenState.CONV_ROAST2: STATE_OVEN_CONV_ROAST_TWO_TEMP, 56 | ErdOvenState.CONV_ROAST_BAKE_PREHEAT: STATE_OVEN_PREHEAT, 57 | ErdOvenState.CUSTOM_CLEAN_STAGE2: STATE_OVEN_SELF_CLEAN, 58 | ErdOvenState.DELAY: STATE_OVEN_DELAY, 59 | ErdOvenState.NO_MODE: STATE_OVEN_OFF, 60 | ErdOvenState.PROOF: STATE_OVEN_PROOF, 61 | ErdOvenState.SABBATH: STATE_OVEN_SABBATH, 62 | ErdOvenState.STEAM_CLEAN_STAGE2: STATE_OVEN_STEAM_CLEAN, 63 | ErdOvenState.STEAM_COOL_DOWN: STATE_OVEN_STEAM_CLEAN, 64 | ErdOvenState.WARM: STATE_OVEN_WARM, 65 | ErdOvenState.OVEN_STATE_BAKE: STATE_OVEN_BAKE, 66 | ErdOvenState.OVEN_STATE_BAKED_GOODS: STATE_OVEN_BAKED_GOODS, 67 | ErdOvenState.OVEN_STATE_BROIL: STATE_OVEN_BROIL_HIGH, 68 | ErdOvenState.OVEN_STATE_CONV_BAKE: STATE_OVEN_CONV_BAKE, 69 | ErdOvenState.OVEN_STATE_CONV_BAKE_MULTI: STATE_OVEN_CONV_BAKE_MULTI, 70 | ErdOvenState.OVEN_STATE_CONV_BROIL: STATE_OVEN_CONV_BROIL_HIGH, 71 | ErdOvenState.OVEN_STATE_CONV_ROAST: STATE_OVEN_CONV_ROAST, 72 | ErdOvenState.OVEN_STATE_DUAL_BROIL_HIGH: STATE_OVEN_DUAL_BROIL_HIGH, 73 | ErdOvenState.OVEN_STATE_DUAL_BROIL_LOW: STATE_OVEN_DUAL_BROIL_LOW, 74 | ErdOvenState.OVEN_STATE_DELAY_START: STATE_OVEN_DELAY, 75 | ErdOvenState.OVEN_STATE_FROZEN_PIZZA: STATE_OVEN_FROZEN_PIZZA, 76 | ErdOvenState.OVEN_STATE_FROZEN_PIZZA_MULTI: STATE_OVEN_FROZEN_PIZZA, 77 | ErdOvenState.OVEN_STATE_FROZEN_SNACKS: STATE_OVEN_FROZEN_SNACKS, 78 | ErdOvenState.OVEN_STATE_FROZEN_SNACKS_MULTI: STATE_OVEN_FROZEN_SNACKS, 79 | ErdOvenState.OVEN_STATE_PROOF: STATE_OVEN_PROOF, 80 | ErdOvenState.OVEN_STATE_SELF_CLEAN: STATE_OVEN_SELF_CLEAN, 81 | ErdOvenState.OVEN_STATE_SPECIAL_X: STATE_OVEN_SPECIAL, 82 | ErdOvenState.OVEN_STATE_STEAM_START: STATE_OVEN_STEAM_CLEAN, 83 | ErdOvenState.OVEN_STATE_WARM: STATE_OVEN_WARM, 84 | ErdOvenState.STATUS_DASH: STATE_OVEN_OFF, 85 | } 86 | -------------------------------------------------------------------------------- /ge_kitchen/sensor.py: -------------------------------------------------------------------------------- 1 | """GE Kitchen Sensor Entities""" 2 | import async_timeout 3 | import logging 4 | from typing import Optional, Callable, TYPE_CHECKING 5 | 6 | from gekitchen import ErdCodeType 7 | from gekitchen.erd_constants import * 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity import Entity 13 | 14 | from .const import DOMAIN 15 | from .entities import ( 16 | TEMPERATURE_ERD_CODES, 17 | GeErdEntity, 18 | get_erd_icon, 19 | get_erd_units, 20 | stringify_erd_value, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | from .update_coordinator import GeKitchenUpdateCoordinator 25 | from .appliance_api import ApplianceApi 26 | 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | class GeErdSensor(GeErdEntity, Entity): 32 | """GE Entity for sensors""" 33 | @property 34 | def state(self) -> Optional[str]: 35 | try: 36 | value = self.appliance.get_erd_value(self.erd_code) 37 | except KeyError: 38 | return None 39 | return stringify_erd_value(self.erd_code, value, self.units) 40 | 41 | @property 42 | def measurement_system(self) -> Optional[ErdMeasurementUnits]: 43 | return self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) 44 | 45 | @property 46 | def units(self) -> Optional[str]: 47 | return get_erd_units(self.erd_code, self.measurement_system) 48 | 49 | @property 50 | def device_class(self) -> Optional[str]: 51 | if self.erd_code in TEMPERATURE_ERD_CODES: 52 | return DEVICE_CLASS_TEMPERATURE 53 | return None 54 | 55 | @property 56 | def icon(self) -> Optional[str]: 57 | return get_erd_icon(self.erd_code, self.state) 58 | 59 | @property 60 | def unit_of_measurement(self) -> Optional[str]: 61 | if self.device_class == DEVICE_CLASS_TEMPERATURE: 62 | return self.units 63 | 64 | 65 | class GeErdPropertySensor(GeErdSensor): 66 | """GE Entity for sensors""" 67 | def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType, erd_property: str): 68 | super().__init__(api, erd_code) 69 | self.erd_property = erd_property 70 | 71 | @property 72 | def unique_id(self) -> Optional[str]: 73 | return f"{super().unique_id}_{self.erd_property}" 74 | 75 | @property 76 | def name(self) -> Optional[str]: 77 | base_string = super().name 78 | property_name = self.erd_property.replace("_", " ").title() 79 | return f"{base_string} {property_name}" 80 | 81 | @property 82 | def state(self) -> Optional[str]: 83 | try: 84 | value = getattr(self.appliance.get_erd_value(self.erd_code), self.erd_property) 85 | except KeyError: 86 | return None 87 | return stringify_erd_value(self.erd_code, value, self.units) 88 | 89 | @property 90 | def measurement_system(self) -> Optional[ErdMeasurementUnits]: 91 | return self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) 92 | 93 | @property 94 | def units(self) -> Optional[str]: 95 | return get_erd_units(self.erd_code, self.measurement_system) 96 | 97 | @property 98 | def device_class(self) -> Optional[str]: 99 | if self.erd_code in TEMPERATURE_ERD_CODES: 100 | return "temperature" 101 | return None 102 | 103 | 104 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): 105 | """GE Kitchen sensors.""" 106 | _LOGGER.debug('Adding GE Kitchen sensors') 107 | coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] 108 | 109 | # This should be a NOP, but let's be safe 110 | with async_timeout.timeout(20): 111 | await coordinator.initialization_future 112 | _LOGGER.debug('Coordinator init future finished') 113 | 114 | apis = list(coordinator.appliance_apis.values()) 115 | _LOGGER.debug(f'Found {len(apis):d} appliance APIs') 116 | entities = [ 117 | entity 118 | for api in apis 119 | for entity in api.entities 120 | if isinstance(entity, GeErdSensor) and entity.erd_code in api.appliance._property_cache 121 | ] 122 | _LOGGER.debug(f'Found {len(entities):d} sensors') 123 | async_add_entities(entities) 124 | -------------------------------------------------------------------------------- /ge_kitchen/appliance_api.py: -------------------------------------------------------------------------------- 1 | """Oven state representation.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Dict, List, Optional, Type, TYPE_CHECKING 6 | 7 | from gekitchen import GeAppliance 8 | from gekitchen.erd_constants import * 9 | from gekitchen.erd_types import * 10 | 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity import Entity 13 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 14 | 15 | from .binary_sensor import GeErdBinarySensor 16 | from .const import DOMAIN 17 | from .entities import GeErdEntity 18 | from .sensor import GeErdSensor 19 | from .switch import GeErdSwitch 20 | from .water_heater import ( 21 | GeFreezerEntity, 22 | GeFridgeEntity, 23 | GeOvenHeaterEntity, 24 | LOWER_OVEN, 25 | UPPER_OVEN, 26 | ) 27 | 28 | 29 | 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | def get_appliance_api_type(appliance_type: ErdApplianceType) -> Type: 35 | """Get the appropriate appliance type""" 36 | if appliance_type == ErdApplianceType.OVEN: 37 | return OvenApi 38 | if appliance_type == ErdApplianceType.FRIDGE: 39 | return FridgeApi 40 | # Fallback 41 | return ApplianceApi 42 | 43 | 44 | class ApplianceApi: 45 | """ 46 | API class to represent a single physical device. 47 | 48 | Since a physical device can have many entities, we"ll pool common elements here 49 | """ 50 | APPLIANCE_TYPE = None # type: Optional[ErdApplianceType] 51 | 52 | def __init__(self, coordinator: DataUpdateCoordinator, appliance: GeAppliance): 53 | if not appliance.initialized: 54 | raise RuntimeError("Appliance not ready") 55 | self._appliance = appliance 56 | self._loop = appliance.client.loop 57 | self._hass = coordinator.hass 58 | self.coordinator = coordinator 59 | self.initial_update = False 60 | self._entities = {} # type: Optional[Dict[str, Entity]] 61 | 62 | @property 63 | def hass(self) -> HomeAssistant: 64 | return self._hass 65 | 66 | @property 67 | def loop(self) -> Optional[asyncio.AbstractEventLoop]: 68 | if self._loop is None: 69 | self._loop = self._appliance.client.loop 70 | return self._loop 71 | 72 | @property 73 | def appliance(self) -> GeAppliance: 74 | return self._appliance 75 | 76 | @property 77 | def serial_number(self) -> str: 78 | return self.appliance.get_erd_value(ErdCode.SERIAL_NUMBER) 79 | 80 | @property 81 | def model_number(self) -> str: 82 | return self.appliance.get_erd_value(ErdCode.MODEL_NUMBER) 83 | 84 | @property 85 | def name(self) -> str: 86 | appliance_type = self.appliance.appliance_type 87 | if appliance_type is None or appliance_type == ErdApplianceType.UNKNOWN: 88 | appliance_type = "Appliance" 89 | else: 90 | appliance_type = appliance_type.name.replace("_", " ").title() 91 | return f"GE {appliance_type} {self.serial_number}" 92 | 93 | @property 94 | def device_info(self) -> Dict: 95 | """Device info dictionary.""" 96 | return { 97 | "identifiers": {(DOMAIN, self.serial_number)}, 98 | "name": self.name, 99 | "manufacturer": "GE", 100 | "model": self.model_number, 101 | "sw_version": self.appliance.get_erd_value(ErdCode.WIFI_MODULE_SW_VERSION), 102 | } 103 | 104 | @property 105 | def entities(self) -> List[Entity]: 106 | return list(self._entities.values()) 107 | 108 | def get_all_entities(self) -> List[Entity]: 109 | """Create Entities for this device.""" 110 | entities = [ 111 | GeErdSensor(self, ErdCode.CLOCK_TIME), 112 | GeErdSwitch(self, ErdCode.SABBATH_MODE), 113 | ] 114 | return entities 115 | 116 | def build_entities_list(self) -> None: 117 | """Build the entities list, adding anything new.""" 118 | entities = [ 119 | e for e in self.get_all_entities() 120 | if not isinstance(e, GeErdEntity) or e.erd_code in self.appliance.known_properties 121 | ] 122 | 123 | for entity in entities: 124 | if entity.unique_id not in self._entities: 125 | self._entities[entity.unique_id] = entity 126 | 127 | 128 | class OvenApi(ApplianceApi): 129 | """API class for oven objects""" 130 | APPLIANCE_TYPE = ErdApplianceType.OVEN 131 | 132 | def get_all_entities(self) -> List[Entity]: 133 | base_entities = super().get_all_entities() 134 | oven_config: OvenConfiguration = self.appliance.get_erd_value(ErdCode.OVEN_CONFIGURATION) 135 | _LOGGER.debug(f"Oven Config: {oven_config}") 136 | oven_entities = [ 137 | GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_MODE), 138 | GeErdSensor(self, ErdCode.UPPER_OVEN_COOK_TIME_REMAINING), 139 | GeErdSensor(self, ErdCode.UPPER_OVEN_KITCHEN_TIMER), 140 | GeErdSensor(self, ErdCode.UPPER_OVEN_USER_TEMP_OFFSET), 141 | GeErdBinarySensor(self, ErdCode.UPPER_OVEN_REMOTE_ENABLED), 142 | ] 143 | 144 | if oven_config.has_lower_oven: 145 | oven_entities.extend([ 146 | GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_MODE), 147 | GeErdSensor(self, ErdCode.LOWER_OVEN_COOK_TIME_REMAINING), 148 | GeErdSensor(self, ErdCode.LOWER_OVEN_USER_TEMP_OFFSET), 149 | GeErdBinarySensor(self, ErdCode.LOWER_OVEN_REMOTE_ENABLED), 150 | GeOvenHeaterEntity(self, LOWER_OVEN, True), 151 | GeOvenHeaterEntity(self, UPPER_OVEN, True), 152 | ]) 153 | else: 154 | oven_entities.append(GeOvenHeaterEntity(self, UPPER_OVEN, False)) 155 | return base_entities + oven_entities 156 | 157 | 158 | class FridgeApi(ApplianceApi): 159 | """API class for oven objects""" 160 | APPLIANCE_TYPE = ErdApplianceType.FRIDGE 161 | 162 | def get_all_entities(self) -> List[Entity]: 163 | base_entities = super().get_all_entities() 164 | 165 | fridge_entities = [ 166 | # GeErdSensor(self, ErdCode.AIR_FILTER_STATUS), 167 | GeErdSensor(self, ErdCode.DOOR_STATUS), 168 | GeErdSensor(self, ErdCode.FRIDGE_MODEL_INFO), 169 | # GeErdSensor(self, ErdCode.HOT_WATER_LOCAL_USE), 170 | # GeErdSensor(self, ErdCode.HOT_WATER_SET_TEMP), 171 | # GeErdSensor(self, ErdCode.HOT_WATER_STATUS), 172 | GeErdSwitch(self, ErdCode.SABBATH_MODE), 173 | GeFreezerEntity(self), 174 | GeFridgeEntity(self), 175 | ] 176 | entities = base_entities + fridge_entities 177 | return entities 178 | -------------------------------------------------------------------------------- /ge_kitchen/entities.py: -------------------------------------------------------------------------------- 1 | """Define all of the entity types""" 2 | 3 | import logging 4 | from typing import Any, Dict, Optional, TYPE_CHECKING 5 | 6 | from gekitchen import ErdCodeType, GeAppliance, translate_erd_code 7 | from gekitchen.erd_types import * 8 | from gekitchen.erd_constants import * 9 | from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT 10 | from homeassistant.core import HomeAssistant 11 | 12 | 13 | from .const import DOMAIN 14 | from .erd_string_utils import * 15 | 16 | if TYPE_CHECKING: 17 | from .appliance_api import ApplianceApi 18 | 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | DOOR_ERD_CODES = { 23 | ErdCode.DOOR_STATUS 24 | } 25 | RAW_TEMPERATURE_ERD_CODES = { 26 | ErdCode.LOWER_OVEN_RAW_TEMPERATURE, 27 | ErdCode.LOWER_OVEN_USER_TEMP_OFFSET, 28 | ErdCode.UPPER_OVEN_RAW_TEMPERATURE, 29 | ErdCode.UPPER_OVEN_USER_TEMP_OFFSET, 30 | ErdCode.CURRENT_TEMPERATURE, 31 | ErdCode.TEMPERATURE_SETTING, 32 | } 33 | NONZERO_TEMPERATURE_ERD_CODES = { 34 | ErdCode.HOT_WATER_SET_TEMP, 35 | ErdCode.LOWER_OVEN_DISPLAY_TEMPERATURE, 36 | ErdCode.LOWER_OVEN_PROBE_DISPLAY_TEMP, 37 | ErdCode.UPPER_OVEN_DISPLAY_TEMPERATURE, 38 | ErdCode.UPPER_OVEN_PROBE_DISPLAY_TEMP, 39 | } 40 | TEMPERATURE_ERD_CODES = RAW_TEMPERATURE_ERD_CODES.union(NONZERO_TEMPERATURE_ERD_CODES) 41 | TIMER_ERD_CODES = { 42 | ErdCode.LOWER_OVEN_ELAPSED_COOK_TIME, 43 | ErdCode.LOWER_OVEN_KITCHEN_TIMER, 44 | ErdCode.LOWER_OVEN_DELAY_TIME_REMAINING, 45 | ErdCode.LOWER_OVEN_COOK_TIME_REMAINING, 46 | ErdCode.LOWER_OVEN_ELAPSED_COOK_TIME, 47 | ErdCode.ELAPSED_ON_TIME, 48 | ErdCode.TIME_REMAINING, 49 | ErdCode.UPPER_OVEN_ELAPSED_COOK_TIME, 50 | ErdCode.UPPER_OVEN_KITCHEN_TIMER, 51 | ErdCode.UPPER_OVEN_DELAY_TIME_REMAINING, 52 | ErdCode.UPPER_OVEN_ELAPSED_COOK_TIME, 53 | ErdCode.UPPER_OVEN_COOK_TIME_REMAINING, 54 | } 55 | 56 | 57 | def boolify_erd_value(erd_code: ErdCodeType, value: Any) -> Optional[bool]: 58 | """ 59 | Convert an erd property value to a bool 60 | 61 | :param erd_code: The ERD code for the property 62 | :param value: The current value in its native format 63 | :return: The value converted to a bool 64 | """ 65 | erd_code = translate_erd_code(erd_code) 66 | if isinstance(value, ErdDoorStatus): 67 | if value == ErdDoorStatus.NA: 68 | return None 69 | return value == ErdDoorStatus.OPEN 70 | if value is None: 71 | return None 72 | return bool(value) 73 | 74 | 75 | def stringify_erd_value(erd_code: ErdCodeType, value: Any, units: Optional[str] = None) -> Optional[str]: 76 | """ 77 | Convert an erd property value to a nice string 78 | 79 | :param erd_code: The ERD code for the property 80 | :param value: The current value in its native format 81 | :param units: Units to apply, if applicable 82 | :return: The value converted to a string 83 | """ 84 | erd_code = translate_erd_code(erd_code) 85 | 86 | if isinstance(value, ErdOvenState): 87 | return oven_display_state_to_str(value) 88 | if isinstance(value, OvenCookSetting): 89 | return oven_cook_setting_to_str(value, units) 90 | if isinstance(value, FridgeDoorStatus): 91 | return value.status 92 | if isinstance(value, FridgeIceBucketStatus): 93 | return bucket_status_to_str(value) 94 | if isinstance(value, ErdFilterStatus): 95 | return value.name.capitalize() 96 | if isinstance(value, HotWaterStatus): 97 | return hot_water_status_str(value) 98 | if isinstance(value, ErdDoorStatus): 99 | return door_status_to_str(value) 100 | 101 | if erd_code == ErdCode.CLOCK_TIME: 102 | return value.strftime("%H:%M:%S") if value else None 103 | if erd_code in RAW_TEMPERATURE_ERD_CODES: 104 | return f"{value}" 105 | if erd_code in NONZERO_TEMPERATURE_ERD_CODES: 106 | return f"{value}" if value else "" 107 | if erd_code in TIMER_ERD_CODES: 108 | return str(value)[:-3] if value else "" 109 | if erd_code == ErdCode.DOOR_STATUS: 110 | return value.status 111 | if value is None: 112 | return None 113 | return str(value) 114 | 115 | 116 | def get_erd_units(erd_code: ErdCodeType, measurement_units: ErdMeasurementUnits): 117 | """Get the units for a sensor.""" 118 | erd_code = translate_erd_code(erd_code) 119 | if not measurement_units: 120 | return None 121 | 122 | if erd_code in TEMPERATURE_ERD_CODES or erd_code in {ErdCode.LOWER_OVEN_COOK_MODE, ErdCode.UPPER_OVEN_COOK_MODE}: 123 | if measurement_units == ErdMeasurementUnits.METRIC: 124 | return TEMP_CELSIUS 125 | return TEMP_FAHRENHEIT 126 | return None 127 | 128 | 129 | def get_erd_icon(erd_code: ErdCodeType, value: Any = None) -> Optional[str]: 130 | """Select an appropriate icon.""" 131 | erd_code = translate_erd_code(erd_code) 132 | if not isinstance(erd_code, ErdCode): 133 | return None 134 | if erd_code in TIMER_ERD_CODES: 135 | return "mdi:timer-outline" 136 | if erd_code in { 137 | ErdCode.LOWER_OVEN_COOK_MODE, 138 | ErdCode.LOWER_OVEN_CURRENT_STATE, 139 | ErdCode.LOWER_OVEN_WARMING_DRAWER_STATE, 140 | ErdCode.UPPER_OVEN_COOK_MODE, 141 | ErdCode.UPPER_OVEN_CURRENT_STATE, 142 | ErdCode.UPPER_OVEN_WARMING_DRAWER_STATE, 143 | ErdCode.WARMING_DRAWER_STATE, 144 | }: 145 | return "mdi:stove" 146 | if erd_code in { 147 | ErdCode.TURBO_COOL_STATUS, 148 | ErdCode.TURBO_FREEZE_STATUS, 149 | }: 150 | return "mdi:snowflake" 151 | if erd_code == ErdCode.SABBATH_MODE: 152 | return "mdi:judaism" 153 | 154 | # Let binary sensors assign their own. Might be worth passing 155 | # the actual entity in if we want to do more of this. 156 | if erd_code in DOOR_ERD_CODES and isinstance(value, str): 157 | if "open" in value.lower(): 158 | return "mdi:door-open" 159 | return "mdi:door-closed" 160 | 161 | return None 162 | 163 | 164 | class GeEntity: 165 | """Base class for all GE Entities""" 166 | should_poll = False 167 | 168 | def __init__(self, api: "ApplianceApi"): 169 | self._api = api 170 | self.hass = None # type: Optional[HomeAssistant] 171 | 172 | @property 173 | def unique_id(self) -> str: 174 | raise NotImplementedError 175 | 176 | @property 177 | def api(self) -> "ApplianceApi": 178 | return self._api 179 | 180 | @property 181 | def device_info(self) -> Optional[Dict[str, Any]]: 182 | return self.api.device_info 183 | 184 | @property 185 | def serial_number(self): 186 | return self.api.serial_number 187 | 188 | @property 189 | def available(self) -> bool: 190 | return self.appliance.available 191 | 192 | @property 193 | def appliance(self) -> GeAppliance: 194 | return self.api.appliance 195 | 196 | @property 197 | def name(self) -> Optional[str]: 198 | raise NotImplementedError 199 | 200 | 201 | class GeErdEntity(GeEntity): 202 | """Parent class for GE entities tied to a specific ERD""" 203 | def __init__(self, api: "ApplianceApi", erd_code: ErdCodeType): 204 | super().__init__(api) 205 | self._erd_code = translate_erd_code(erd_code) 206 | 207 | @property 208 | def erd_code(self) -> ErdCodeType: 209 | return self._erd_code 210 | 211 | @property 212 | def erd_string(self) -> str: 213 | erd_code = self.erd_code 214 | if isinstance(self.erd_code, ErdCode): 215 | return erd_code.name 216 | return erd_code 217 | 218 | @property 219 | def name(self) -> Optional[str]: 220 | erd_string = self.erd_string 221 | return " ".join(erd_string.split("_")).title() 222 | 223 | @property 224 | def unique_id(self) -> Optional[str]: 225 | return f"{DOMAIN}_{self.serial_number}_{self.erd_string.lower()}" 226 | 227 | @property 228 | def icon(self) -> Optional[str]: 229 | return get_erd_icon(self.erd_code) 230 | -------------------------------------------------------------------------------- /ge_kitchen/update_coordinator.py: -------------------------------------------------------------------------------- 1 | """Data update coordinator for shark iq vacuums.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Any, Dict, Iterable, Optional, Tuple 6 | 7 | from gekitchen import ( 8 | EVENT_APPLIANCE_INITIAL_UPDATE, 9 | EVENT_APPLIANCE_UPDATE_RECEIVED, 10 | EVENT_CONNECTED, 11 | EVENT_DISCONNECTED, 12 | EVENT_GOT_APPLIANCE_LIST, 13 | ErdCodeType, 14 | GeAppliance, 15 | GeWebsocketClient, 16 | ) 17 | 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 20 | from homeassistant.core import HomeAssistant 21 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 22 | 23 | from .const import DOMAIN, EVENT_ALL_APPLIANCES_READY, UPDATE_INTERVAL 24 | from .appliance_api import ApplianceApi, get_appliance_api_type 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | class GeKitchenUpdateCoordinator(DataUpdateCoordinator): 30 | """Define a wrapper class to update Shark IQ data.""" 31 | 32 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 33 | """Set up the SharkIqUpdateCoordinator class.""" 34 | self._hass = hass 35 | self._config_entry = config_entry 36 | self._username = config_entry.data[CONF_USERNAME] 37 | self._password = config_entry.data[CONF_PASSWORD] 38 | self.client = None # type: Optional[GeWebsocketClient] 39 | self._appliance_apis = {} # type: Dict[str, ApplianceApi] 40 | 41 | # Some record keeping to let us know when we can start generating entities 42 | self._got_roster = False 43 | self._init_done = False 44 | self.initialization_future = asyncio.Future() 45 | 46 | super().__init__(hass, _LOGGER, name=DOMAIN) 47 | 48 | def create_ge_client(self, event_loop: Optional[asyncio.AbstractEventLoop]) -> GeWebsocketClient: 49 | """ 50 | Create a new GeClient object with some helpful callbacks. 51 | 52 | :param event_loop: Event loop 53 | :return: GeWebsocketClient 54 | """ 55 | client = GeWebsocketClient(event_loop=event_loop, username=self._username, password=self._password) 56 | client.add_event_handler(EVENT_APPLIANCE_INITIAL_UPDATE, self.on_device_initial_update) 57 | client.add_event_handler(EVENT_APPLIANCE_UPDATE_RECEIVED, self.on_device_update) 58 | client.add_event_handler(EVENT_GOT_APPLIANCE_LIST, self.on_appliance_list) 59 | client.add_event_handler(EVENT_DISCONNECTED, self.on_disconnect) 60 | client.add_event_handler(EVENT_CONNECTED, self.on_connect) 61 | return client 62 | 63 | @property 64 | def appliances(self) -> Iterable[GeAppliance]: 65 | return self.client.appliances.values() 66 | 67 | @property 68 | def appliance_apis(self) -> Dict[str, ApplianceApi]: 69 | return self._appliance_apis 70 | 71 | def _get_appliance_api(self, appliance: GeAppliance) -> ApplianceApi: 72 | api_type = get_appliance_api_type(appliance.appliance_type) 73 | return api_type(self, appliance) 74 | 75 | def regenerate_appliance_apis(self): 76 | """Regenerate the appliance_apis dictionary, adding elements as necessary.""" 77 | for jid, appliance in self.client.appliances.keys(): 78 | if jid not in self._appliance_apis: 79 | self._appliance_apis[jid] = self._get_appliance_api(appliance) 80 | 81 | def maybe_add_appliance_api(self, appliance: GeAppliance): 82 | mac_addr = appliance.mac_addr 83 | if mac_addr not in self.appliance_apis: 84 | _LOGGER.debug(f"Adding appliance api for appliance {mac_addr} ({appliance.appliance_type})") 85 | api = self._get_appliance_api(appliance) 86 | api.build_entities_list() 87 | self.appliance_apis[mac_addr] = api 88 | 89 | async def get_client(self) -> GeWebsocketClient: 90 | """Get a new GE Websocket client.""" 91 | if self.client is not None: 92 | await self.client.disconnect() 93 | 94 | loop = self._hass.loop 95 | self.client = self.create_ge_client(event_loop=loop) 96 | return self.client 97 | 98 | async def async_start_client(self): 99 | """Start a new GeClient in the HASS event loop.""" 100 | _LOGGER.debug('Running client') 101 | client = await self.get_client() 102 | 103 | session = self._hass.helpers.aiohttp_client.async_get_clientsession() 104 | await client.async_get_credentials(session) 105 | fut = asyncio.ensure_future(client.async_run_client(), loop=self._hass.loop) 106 | _LOGGER.debug('Client running') 107 | return fut 108 | 109 | async def _kill_client(self): 110 | """Kill the client. Leaving this in for testing purposes.""" 111 | await asyncio.sleep(30) 112 | _LOGGER.critical('Killing the connection. Popcorn time.') 113 | await self.client.websocket.close() 114 | 115 | async def on_device_update(self, data: Tuple[GeAppliance, Dict[ErdCodeType, Any]]): 116 | """Let HA know there's new state.""" 117 | self.last_update_success = True 118 | appliance, _ = data 119 | try: 120 | api = self.appliance_apis[appliance.mac_addr] 121 | except KeyError: 122 | return 123 | for entity in api.entities: 124 | _LOGGER.debug(f'Updating {entity} ({entity.unique_id}, {entity.entity_id})') 125 | entity.async_write_ha_state() 126 | 127 | @property 128 | def all_appliances_updated(self) -> bool: 129 | """True if all appliances have had an initial update.""" 130 | return all([a.initialized for a in self.appliances]) 131 | 132 | async def on_appliance_list(self, _): 133 | """When we get an appliance list, mark it and maybe trigger all ready.""" 134 | _LOGGER.debug('Got roster update') 135 | self.last_update_success = True 136 | if not self._got_roster: 137 | self._got_roster = True 138 | await asyncio.sleep(5) # After the initial roster update, wait a bit and hit go 139 | await self.async_maybe_trigger_all_ready() 140 | 141 | async def on_device_initial_update(self, appliance: GeAppliance): 142 | """When an appliance first becomes ready, let the system know and schedule periodic updates.""" 143 | _LOGGER.debug(f'Got initial update for {appliance.mac_addr}') 144 | self.last_update_success = True 145 | self.maybe_add_appliance_api(appliance) 146 | await self.async_maybe_trigger_all_ready() 147 | _LOGGER.debug(f'Requesting updates for {appliance.mac_addr}') 148 | while not self.client.websocket.closed and appliance.available: 149 | await asyncio.sleep(UPDATE_INTERVAL) 150 | await appliance.async_request_update() 151 | _LOGGER.debug(f'No longer requesting updates for {appliance.mac_addr}') 152 | 153 | async def on_disconnect(self, _): 154 | """Handle disconnection.""" 155 | _LOGGER.debug("Disconnected. Attempting to reconnect.") 156 | self.last_update_success = False 157 | 158 | flow_context = { 159 | "source": "reauth", 160 | "unique_id": self._config_entry.unique_id, 161 | } 162 | 163 | matching_flows = [ 164 | flow 165 | for flow in self.hass.config_entries.flow.async_progress() 166 | if flow["context"] == flow_context 167 | ] 168 | 169 | if not matching_flows: 170 | self.hass.async_create_task( 171 | self.hass.config_entries.flow.async_init( 172 | DOMAIN, context=flow_context, data=self._config_entry.data, 173 | ) 174 | ) 175 | 176 | async def on_connect(self, _): 177 | """Set state upon connection.""" 178 | self.last_update_success = True 179 | 180 | async def async_maybe_trigger_all_ready(self): 181 | """See if we're all ready to go, and if so, let the games begin.""" 182 | if self._init_done or self.initialization_future.done(): 183 | # Been here, done this 184 | return 185 | if self._got_roster and self.all_appliances_updated: 186 | _LOGGER.debug('Ready to go. Waiting 2 seconds and setting init future result.') 187 | # The the flag and wait to prevent two different fun race conditions 188 | self._init_done = True 189 | await asyncio.sleep(2) 190 | self.initialization_future.set_result(True) 191 | await self.client.async_event(EVENT_ALL_APPLIANCES_READY, None) 192 | -------------------------------------------------------------------------------- /ge_kitchen/water_heater.py: -------------------------------------------------------------------------------- 1 | """GE Kitchen Sensor Entities""" 2 | import abc 3 | import async_timeout 4 | from datetime import timedelta 5 | import logging 6 | from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING 7 | 8 | from bidict import bidict 9 | from gekitchen import ( 10 | ErdCode, 11 | ErdDoorStatus, 12 | ErdFilterStatus, 13 | ErdFullNotFull, 14 | ErdHotWaterStatus, 15 | ErdMeasurementUnits, 16 | ErdOnOff, 17 | ErdOvenCookMode, 18 | ErdPodStatus, 19 | ErdPresent, 20 | OVEN_COOK_MODE_MAP, 21 | ) 22 | from gekitchen.erd_types import ( 23 | FridgeDoorStatus, 24 | FridgeSetPointLimits, 25 | FridgeSetPoints, 26 | FridgeIceBucketStatus, 27 | HotWaterStatus, 28 | IceMakerControlStatus, 29 | OvenCookMode, 30 | OvenCookSetting, 31 | ) 32 | 33 | from homeassistant.components.water_heater import ( 34 | SUPPORT_OPERATION_MODE, 35 | SUPPORT_TARGET_TEMPERATURE, 36 | WaterHeaterEntity, 37 | ) 38 | from homeassistant.config_entries import ConfigEntry 39 | from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT 40 | from homeassistant.core import HomeAssistant 41 | 42 | from .entities import GeEntity, stringify_erd_value 43 | from .const import DOMAIN 44 | 45 | if TYPE_CHECKING: 46 | from .appliance_api import ApplianceApi 47 | from .update_coordinator import GeKitchenUpdateCoordinator 48 | 49 | _LOGGER = logging.getLogger(__name__) 50 | 51 | ATTR_DOOR_STATUS = "door_status" 52 | GE_FRIDGE_SUPPORT = (SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE) 53 | HEATER_TYPE_FRIDGE = "fridge" 54 | HEATER_TYPE_FREEZER = "freezer" 55 | 56 | # Fridge/Freezer 57 | OP_MODE_K_CUP = "K-Cup Brewing" 58 | OP_MODE_NORMAL = "Normal" 59 | OP_MODE_SABBATH = "Sabbath Mode" 60 | OP_MODE_TURBO_COOL = "Turbo Cool" 61 | OP_MODE_TURBO_FREEZE = "Turbo Freeze" 62 | 63 | # Oven 64 | OP_MODE_OFF = "Off" 65 | OP_MODE_BAKE = "Bake" 66 | OP_MODE_CONVMULTIBAKE = "Conv. Multi-Bake" 67 | OP_MODE_CONVBAKE = "Convection Bake" 68 | OP_MODE_CONVROAST = "Convection Roast" 69 | OP_MODE_COOK_UNK = "Unknown" 70 | 71 | UPPER_OVEN = "UPPER_OVEN" 72 | LOWER_OVEN = "LOWER_OVEN" 73 | 74 | COOK_MODE_OP_MAP = bidict({ 75 | ErdOvenCookMode.NOMODE: OP_MODE_OFF, 76 | ErdOvenCookMode.CONVMULTIBAKE_NOOPTION: OP_MODE_CONVMULTIBAKE, 77 | ErdOvenCookMode.CONVBAKE_NOOPTION: OP_MODE_CONVBAKE, 78 | ErdOvenCookMode.CONVROAST_NOOPTION: OP_MODE_CONVROAST, 79 | ErdOvenCookMode.BAKE_NOOPTION: OP_MODE_BAKE, 80 | }) 81 | 82 | 83 | class GeAbstractFridgeEntity(GeEntity, WaterHeaterEntity, metaclass=abc.ABCMeta): 84 | """Mock a fridge or freezer as a water heater.""" 85 | 86 | @property 87 | def heater_type(self) -> str: 88 | raise NotImplementedError 89 | 90 | @property 91 | def turbo_erd_code(self) -> str: 92 | raise NotImplementedError 93 | 94 | @property 95 | def turbo_mode(self) -> str: 96 | raise NotImplementedError 97 | 98 | @property 99 | def operation_list(self) -> List[str]: 100 | return [OP_MODE_NORMAL, OP_MODE_SABBATH, self.turbo_mode] 101 | 102 | @property 103 | def unique_id(self) -> str: 104 | return f"{self.serial_number}-{self.heater_type}" 105 | 106 | @property 107 | def name(self) -> Optional[str]: 108 | return f"GE {self.heater_type.title()} {self.serial_number}" 109 | 110 | @property 111 | def temperature_unit(self): 112 | measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) 113 | if measurement_system == ErdMeasurementUnits.METRIC: 114 | return TEMP_CELSIUS 115 | return TEMP_FAHRENHEIT 116 | 117 | @property 118 | def target_temps(self) -> FridgeSetPoints: 119 | """Get the current temperature settings tuple.""" 120 | return self.appliance.get_erd_value(ErdCode.TEMPERATURE_SETTING) 121 | 122 | @property 123 | def target_temperature(self) -> int: 124 | """Return the temperature we try to reach.""" 125 | return getattr(self.target_temps, self.heater_type) 126 | 127 | @property 128 | def current_temperature(self) -> int: 129 | """Return the current temperature.""" 130 | current_temps = self.appliance.get_erd_value(ErdCode.CURRENT_TEMPERATURE) 131 | current_temp = getattr(current_temps, self.heater_type) 132 | if current_temp is None: 133 | _LOGGER.exception(f"{self.name} has None for current_temperature (available: {self.available})!") 134 | return current_temp 135 | 136 | async def async_set_temperature(self, **kwargs): 137 | target_temp = kwargs.get(ATTR_TEMPERATURE) 138 | if target_temp is None: 139 | return 140 | if not self.min_temp <= target_temp <= self.max_temp: 141 | raise ValueError("Tried to set temperature out of device range") 142 | 143 | if self.heater_type == HEATER_TYPE_FRIDGE: 144 | new_temp = FridgeSetPoints(fridge=target_temp, freezer=self.target_temps.freezer) 145 | elif self.heater_type == HEATER_TYPE_FREEZER: 146 | new_temp = FridgeSetPoints(fridge=self.target_temps.fridge, freezer=target_temp) 147 | else: 148 | raise ValueError("Invalid heater_type") 149 | 150 | await self.appliance.async_set_erd_value(ErdCode.TEMPERATURE_SETTING, new_temp) 151 | 152 | @property 153 | def supported_features(self): 154 | return GE_FRIDGE_SUPPORT 155 | 156 | @property 157 | def setpoint_limits(self) -> FridgeSetPointLimits: 158 | return self.appliance.get_erd_value(ErdCode.SETPOINT_LIMITS) 159 | 160 | @property 161 | def min_temp(self): 162 | """Return the minimum temperature.""" 163 | return getattr(self.setpoint_limits, f"{self.heater_type}_min") 164 | 165 | @property 166 | def max_temp(self): 167 | """Return the maximum temperature.""" 168 | return getattr(self.setpoint_limits, f"{self.heater_type}_max") 169 | 170 | @property 171 | def current_operation(self) -> str: 172 | """Get ther current operation mode.""" 173 | if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): 174 | return OP_MODE_SABBATH 175 | if self.appliance.get_erd_value(self.turbo_erd_code): 176 | return self.turbo_mode 177 | return OP_MODE_NORMAL 178 | 179 | async def async_set_sabbath_mode(self, sabbath_on: bool = True): 180 | """Set sabbath mode if it's changed""" 181 | if self.appliance.get_erd_value(ErdCode.SABBATH_MODE) == sabbath_on: 182 | return 183 | await self.appliance.async_set_erd_value(ErdCode.SABBATH_MODE, sabbath_on) 184 | 185 | async def async_set_operation_mode(self, operation_mode): 186 | """Set the operation mode.""" 187 | if operation_mode not in self.operation_list: 188 | raise ValueError("Invalid operation mode") 189 | if operation_mode == self.current_operation: 190 | return 191 | sabbath_mode = operation_mode == OP_MODE_SABBATH 192 | await self.async_set_sabbath_mode(sabbath_mode) 193 | if not sabbath_mode: 194 | await self.appliance.async_set_erd_value(self.turbo_erd_code, operation_mode == self.turbo_mode) 195 | 196 | @property 197 | def door_status(self) -> FridgeDoorStatus: 198 | """Shorthand to get door status.""" 199 | return self.appliance.get_erd_value(ErdCode.DOOR_STATUS) 200 | 201 | @property 202 | def ice_maker_state_attrs(self) -> Dict[str, Any]: 203 | """Get state attributes for the ice maker, if applicable.""" 204 | data = {} 205 | 206 | erd_val: FridgeIceBucketStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_BUCKET_STATUS) 207 | ice_bucket_status = getattr(erd_val, f"state_full_{self.heater_type}") 208 | if ice_bucket_status != ErdFullNotFull.NA: 209 | data["ice_bucket"] = ice_bucket_status.name.replace("_", " ").title() 210 | 211 | erd_val: IceMakerControlStatus = self.appliance.get_erd_value(ErdCode.ICE_MAKER_CONTROL) 212 | ice_control_status = getattr(erd_val, f"status_{self.heater_type}") 213 | if ice_control_status != ErdOnOff.NA: 214 | data["ice_maker"] = ice_control_status.name.replace("_", " ").lower() 215 | 216 | return data 217 | 218 | @property 219 | def door_state_attrs(self) -> Dict[str, Any]: 220 | """Get state attributes for the doors.""" 221 | return {} 222 | 223 | @property 224 | def other_state_attrs(self) -> Dict[str, Any]: 225 | """State attributes to be optionally overridden in subclasses.""" 226 | return {} 227 | 228 | @property 229 | def device_state_attributes(self) -> Dict[str, Any]: 230 | door_attrs = self.door_state_attrs 231 | ice_maker_attrs = self.ice_maker_state_attrs 232 | other_attrs = self.other_state_attrs 233 | return {**door_attrs, **ice_maker_attrs, **other_attrs} 234 | 235 | 236 | class GeFridgeEntity(GeAbstractFridgeEntity): 237 | heater_type = HEATER_TYPE_FRIDGE 238 | turbo_erd_code = ErdCode.TURBO_COOL_STATUS 239 | turbo_mode = OP_MODE_TURBO_COOL 240 | icon = "mdi:fridge-bottom" 241 | 242 | @property 243 | def available(self) -> bool: 244 | available = super().available 245 | if not available: 246 | app = self.appliance 247 | _LOGGER.critical(f"{self.name} unavailable. Appliance info: Availaible - {app._available} and Init - {app.initialized}") 248 | return available 249 | 250 | @property 251 | def other_state_attrs(self) -> Dict[str, Any]: 252 | """Water filter state.""" 253 | filter_status: ErdFilterStatus = self.appliance.get_erd_value(ErdCode.WATER_FILTER_STATUS) 254 | if filter_status == ErdFilterStatus.NA: 255 | return {} 256 | return {"water_filter_status": filter_status.name.replace("_", " ").title()} 257 | 258 | @property 259 | def door_state_attrs(self) -> Dict[str, Any]: 260 | """Get state attributes for the doors.""" 261 | data = {} 262 | door_status = self.door_status 263 | if not door_status: 264 | return {} 265 | door_right = door_status.fridge_right 266 | door_left = door_status.fridge_left 267 | drawer = door_status.drawer 268 | 269 | if door_right and door_right != ErdDoorStatus.NA: 270 | data["right_door"] = door_status.fridge_right.name.title() 271 | if door_left and door_left != ErdDoorStatus.NA: 272 | data["left_door"] = door_status.fridge_left.name.title() 273 | if drawer and drawer != ErdDoorStatus.NA: 274 | data["drawer"] = door_status.fridge_left.name.title() 275 | 276 | if data: 277 | all_closed = all(v == "Closed" for v in data.values()) 278 | data[ATTR_DOOR_STATUS] = "Closed" if all_closed else "Open" 279 | 280 | return data 281 | 282 | 283 | class GeFreezerEntity(GeAbstractFridgeEntity): 284 | """A freezer is basically a fridge.""" 285 | 286 | heater_type = HEATER_TYPE_FREEZER 287 | turbo_erd_code = ErdCode.TURBO_FREEZE_STATUS 288 | turbo_mode = OP_MODE_TURBO_FREEZE 289 | icon = "mdi:fridge-top" 290 | 291 | @property 292 | def door_state_attrs(self) -> Optional[Dict[str, Any]]: 293 | door_status = self.door_status.freezer 294 | if door_status and door_status != ErdDoorStatus.NA: 295 | return {ATTR_DOOR_STATUS: door_status.name.title()} 296 | return {} 297 | 298 | 299 | class GeFridgeWaterHeater(GeEntity, WaterHeaterEntity): 300 | """Entity for in-fridge water heaters""" 301 | 302 | # These values are from FridgeHotWaterFragment.smali in the android app 303 | min_temp = 90 304 | max_temp = 185 305 | 306 | @property 307 | def hot_water_status(self) -> HotWaterStatus: 308 | """Access the main status value conveniently.""" 309 | return self.appliance.get_erd_value(ErdCode.HOT_WATER_STATUS) 310 | 311 | @property 312 | def unique_id(self) -> str: 313 | """Make a unique id.""" 314 | return f"{self.serial_number}-fridge-hot-water" 315 | 316 | @property 317 | def name(self) -> Optional[str]: 318 | """Name it reasonably.""" 319 | return f"GE Fridge Water Heater {self.serial_number}" 320 | 321 | @property 322 | def temperature_unit(self): 323 | """Select the appropriate temperature unit.""" 324 | measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) 325 | if measurement_system == ErdMeasurementUnits.METRIC: 326 | return TEMP_CELSIUS 327 | return TEMP_FAHRENHEIT 328 | 329 | @property 330 | def supports_k_cups(self) -> bool: 331 | """Return True if the device supports k-cup brewing.""" 332 | status = self.hot_water_status 333 | return status.pod_status != ErdPodStatus.NA and status.brew_module != ErdPresent.NA 334 | 335 | @property 336 | def operation_list(self) -> List[str]: 337 | """Supported Operations List""" 338 | ops_list = [OP_MODE_NORMAL, OP_MODE_SABBATH] 339 | if self.supports_k_cups: 340 | ops_list.append(OP_MODE_K_CUP) 341 | return ops_list 342 | 343 | async def async_set_temperature(self, **kwargs): 344 | pass 345 | 346 | async def async_set_operation_mode(self, operation_mode): 347 | pass 348 | 349 | @property 350 | def supported_features(self): 351 | pass 352 | 353 | @property 354 | def current_operation(self) -> str: 355 | """Get the current operation mode.""" 356 | if self.appliance.get_erd_value(ErdCode.SABBATH_MODE): 357 | return OP_MODE_SABBATH 358 | return OP_MODE_NORMAL 359 | 360 | @property 361 | def current_temperature(self) -> Optional[int]: 362 | """Return the current temperature.""" 363 | return self.hot_water_status.current_temp 364 | 365 | 366 | class GeOvenHeaterEntity(GeEntity, WaterHeaterEntity): 367 | """Water Heater entity for ovens""" 368 | 369 | icon = "mdi:stove" 370 | 371 | def __init__(self, api: "ApplianceApi", oven_select: str = UPPER_OVEN, two_cavity: bool = False): 372 | if oven_select not in (UPPER_OVEN, LOWER_OVEN): 373 | raise ValueError(f"Invalid `oven_select` value ({oven_select})") 374 | 375 | self._oven_select = oven_select 376 | self._two_cavity = two_cavity 377 | super().__init__(api) 378 | 379 | @property 380 | def supported_features(self): 381 | return GE_FRIDGE_SUPPORT 382 | 383 | @property 384 | def unique_id(self) -> str: 385 | return f"{self.serial_number}-{self.oven_select.lower()}" 386 | 387 | @property 388 | def name(self) -> Optional[str]: 389 | if self._two_cavity: 390 | oven_title = self.oven_select.replace("_", " ").title() 391 | else: 392 | oven_title = "Oven" 393 | 394 | return f"GE {oven_title}" 395 | 396 | @property 397 | def temperature_unit(self): 398 | measurement_system = self.appliance.get_erd_value(ErdCode.TEMPERATURE_UNIT) 399 | if measurement_system == ErdMeasurementUnits.METRIC: 400 | return TEMP_CELSIUS 401 | return TEMP_FAHRENHEIT 402 | 403 | @property 404 | def oven_select(self) -> str: 405 | return self._oven_select 406 | 407 | def get_erd_code(self, suffix: str) -> ErdCode: 408 | """Return the appropriate ERD code for this oven_select""" 409 | return ErdCode[f"{self.oven_select}_{suffix}"] 410 | 411 | @property 412 | def current_temperature(self) -> Optional[int]: 413 | current_temp = self.get_erd_value("DISPLAY_TEMPERATURE") 414 | if current_temp: 415 | return current_temp 416 | return self.get_erd_value("RAW_TEMPERATURE") 417 | 418 | @property 419 | def current_operation(self) -> Optional[str]: 420 | cook_setting = self.current_cook_setting 421 | cook_mode = cook_setting.cook_mode 422 | # TODO: simplify this lookup nonsense somehow 423 | current_state = OVEN_COOK_MODE_MAP.inverse[cook_mode] 424 | try: 425 | return COOK_MODE_OP_MAP[current_state] 426 | except KeyError: 427 | _LOGGER.debug(f"Unable to map {current_state} to an operation mode") 428 | return OP_MODE_COOK_UNK 429 | 430 | @property 431 | def operation_list(self) -> List[str]: 432 | erd_code = self.get_erd_code("AVAILABLE_COOK_MODES") 433 | cook_modes: Set[ErdOvenCookMode] = self.appliance.get_erd_value(erd_code) 434 | op_modes = [o for o in (COOK_MODE_OP_MAP[c] for c in cook_modes) if o] 435 | op_modes = [OP_MODE_OFF] + op_modes 436 | return op_modes 437 | 438 | @property 439 | def current_cook_setting(self) -> OvenCookSetting: 440 | """Get the current cook mode.""" 441 | erd_code = self.get_erd_code("COOK_MODE") 442 | return self.appliance.get_erd_value(erd_code) 443 | 444 | @property 445 | def target_temperature(self) -> Optional[int]: 446 | """Return the temperature we try to reach.""" 447 | cook_mode = self.current_cook_setting 448 | if cook_mode.temperature: 449 | return cook_mode.temperature 450 | return None 451 | 452 | @property 453 | def min_temp(self) -> int: 454 | """Return the minimum temperature.""" 455 | min_temp, _ = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) 456 | return min_temp 457 | 458 | @property 459 | def max_temp(self) -> int: 460 | """Return the maximum temperature.""" 461 | _, max_temp = self.appliance.get_erd_value(ErdCode.OVEN_MODE_MIN_MAX_TEMP) 462 | return max_temp 463 | 464 | async def async_set_operation_mode(self, operation_mode: str): 465 | """Set the operation mode.""" 466 | 467 | erd_cook_mode = COOK_MODE_OP_MAP.inverse[operation_mode] 468 | # Pick a temperature to set. If there's not one already set, default to 469 | # good old 350F. 470 | if operation_mode == OP_MODE_OFF: 471 | target_temp = 0 472 | elif self.target_temperature: 473 | target_temp = self.target_temperature 474 | elif self.temperature_unit == TEMP_FAHRENHEIT: 475 | target_temp = 350 476 | else: 477 | target_temp = 177 478 | 479 | new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) 480 | erd_code = self.get_erd_code("COOK_MODE") 481 | await self.appliance.async_set_erd_value(erd_code, new_cook_mode) 482 | 483 | async def async_set_temperature(self, **kwargs): 484 | """Set the cook temperature""" 485 | target_temp = kwargs.get(ATTR_TEMPERATURE) 486 | if target_temp is None: 487 | return 488 | 489 | current_op = self.current_operation 490 | if current_op != OP_MODE_OFF: 491 | erd_cook_mode = COOK_MODE_OP_MAP.inverse[current_op] 492 | else: 493 | erd_cook_mode = ErdOvenCookMode.BAKE_NOOPTION 494 | 495 | new_cook_mode = OvenCookSetting(OVEN_COOK_MODE_MAP[erd_cook_mode], target_temp) 496 | erd_code = self.get_erd_code("COOK_MODE") 497 | await self.appliance.async_set_erd_value(erd_code, new_cook_mode) 498 | 499 | def get_erd_value(self, suffix: str) -> Any: 500 | erd_code = self.get_erd_code(suffix) 501 | return self.appliance.get_erd_value(erd_code) 502 | 503 | @property 504 | def display_state(self) -> Optional[str]: 505 | erd_code = self.get_erd_code("CURRENT_STATE") 506 | erd_value = self.appliance.get_erd_value(erd_code) 507 | return stringify_erd_value(erd_code, erd_value, self.temperature_unit) 508 | 509 | @property 510 | def device_state_attributes(self) -> Optional[Dict[str, Any]]: 511 | probe_present = self.get_erd_value("PROBE_PRESENT") 512 | data = { 513 | "display_state": self.display_state, 514 | "probe_present": probe_present, 515 | "raw_temperature": self.get_erd_value("RAW_TEMPERATURE"), 516 | } 517 | if probe_present: 518 | data["probe_temperature"] = self.get_erd_value("PROBE_DISPLAY_TEMP") 519 | elapsed_time = self.get_erd_value("ELAPSED_COOK_TIME") 520 | cook_time_left = self.get_erd_value("COOK_TIME_REMAINING") 521 | kitchen_timer = self.get_erd_value("KITCHEN_TIMER") 522 | delay_time = self.get_erd_value("DELAY_TIME_REMAINING") 523 | if elapsed_time: 524 | data["cook_time_elapsed"] = str(elapsed_time) 525 | if cook_time_left: 526 | data["cook_time_left"] = str(cook_time_left) 527 | if kitchen_timer: 528 | data["cook_time_remaining"] = str(kitchen_timer) 529 | if delay_time: 530 | data["delay_time_remaining"] = str(delay_time) 531 | return data 532 | 533 | 534 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable): 535 | """GE Kitchen sensors.""" 536 | _LOGGER.debug('Adding GE "Water Heaters"') 537 | coordinator: "GeKitchenUpdateCoordinator" = hass.data[DOMAIN][config_entry.entry_id] 538 | 539 | # This should be a NOP, but let's be safe 540 | with async_timeout.timeout(20): 541 | await coordinator.initialization_future 542 | _LOGGER.debug('Coordinator init future finished') 543 | 544 | apis = list(coordinator.appliance_apis.values()) 545 | _LOGGER.debug(f'Found {len(apis):d} appliance APIs') 546 | entities = [ 547 | entity for api in apis for entity in api.entities 548 | if isinstance(entity, WaterHeaterEntity) 549 | ] 550 | _LOGGER.debug(f'Found {len(entities):d} "water heaters"') 551 | async_add_entities(entities) 552 | --------------------------------------------------------------------------------